diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 98e528db7..d3114d6d2 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -378,7 +378,7 @@ Positional arguments after flags are joined into the optional initial task deliv - OpenAI-compatible chat completions, streamed via `@intx/inference` - JSON-schema tool definitions for director-layer tools (`ask_operator`, `present`, `submit_output`) and agent tools (`manage_tasks`, `tool_search`, `use_skill`, `search_agents`, …) -- Codex Responses (`codex-responses-adapter.ts` `buildRequest`): ChatGPT Codex is Responses-only. `instructions` is exactly the supplied Corbits system prompt (including an empty string), omitted when unset. `input` contains only the converted conversation turns, preserving their roles and order without a synthetic developer bridge. There is no official-prompt fetch, cache, or startup refresh. It requires `store: false` (`store: true` → 400) and rejects `previous_response_id`. Multi-turn continuity is full `input` replay; encrypted reasoning captured via `include: ["reasoning.encrypted_content"]` is resent as a `reasoning` item. `prompt_cache_key` (session id) is the cache-routing signal. `parallel_tool_calls` is sent `false` (serial at this surface); the reactor already fans out a multi-call batch concurrently. `max_output_tokens` is omitted (backend rejects it). +- Codex Responses (`responses-adapters.ts` `buildResponsesRequest` (CODEX_SPEC)): ChatGPT Codex is Responses-only. `instructions` is exactly the supplied Corbits system prompt (including an empty string), omitted when unset. `input` contains only the converted conversation turns, preserving their roles and order without a synthetic developer bridge. There is no official-prompt fetch, cache, or startup refresh. It requires `store: false` (`store: true` → 400) and rejects `previous_response_id`. Multi-turn continuity is full `input` replay; encrypted reasoning captured via `include: ["reasoning.encrypted_content"]` is resent as a `reasoning` item. `prompt_cache_key` (session id) is the cache-routing signal. `parallel_tool_calls` is sent `false` (serial at this surface); the reactor already fans out a multi-call batch concurrently. `max_output_tokens` is omitted (backend rejects it). ### State Persistence diff --git a/src/agent/agent-search.ts b/src/agent/agent-search.ts index 4a1c6ca83..f5e228461 100644 --- a/src/agent/agent-search.ts +++ b/src/agent/agent-search.ts @@ -3,12 +3,9 @@ import type { AgentTool } from "@intx/agent"; import type { ToolDefinition } from "@intx/types/runtime"; import { type } from "arktype"; import { scrubSecretShapedContent } from "../plugins/tool-result-secret-scrub.js"; +import { rankLexicalMatches, tokenize } from "./lexical-search.js"; import type { AgentProfile } from "./profiles.js"; -function tokenize(text: string): string[] { - return text.toLowerCase().match(/[a-z0-9]+/g) ?? []; -} - function profileSearchText(profile: AgentProfile): string { const parts = [profile.id, profile.description ?? "", profile.systemPromptRole ?? ""]; return parts.join(" "); @@ -18,36 +15,22 @@ export interface AgentIndex { search(query: string, limit?: number): AgentProfile[]; } -// Lexical ranker over id, description, and role text — same spirit as tool_search. +// Lexical ranker over id, description, and role text — same weights as +// tool_search / skill_search, shared via lexical-search.ts. export function createAgentIndex(getProfiles: () => readonly AgentProfile[]): AgentIndex { - const score = (profile: AgentProfile, queryTokens: string[], rawQuery: string): number => { - const idTokens = tokenize(profile.id); - const blob = profileSearchText(profile).toLowerCase(); - const blobTokens = new Set(tokenize(blob)); - let total = 0; - for (const token of queryTokens) { - if (idTokens.includes(token)) total += 3; - else if (blobTokens.has(token)) total += 1; - else if (profile.id.toLowerCase().includes(token)) total += 0.75; - else if (blob.includes(token)) total += 0.25; - } - if (profile.id.toLowerCase().includes(rawQuery)) total += 1; - if ((profile.description ?? "").toLowerCase().includes(rawQuery)) total += 0.5; - return total; - }; - return { search(query: string, limit = 12): AgentProfile[] { - const rawQuery = query.toLowerCase().trim(); - const queryTokens = tokenize(query); const profiles = getProfiles(); - if (queryTokens.length === 0) return profiles.slice(0, limit); - return profiles - .map((p) => ({ profile: p, score: score(p, queryTokens, rawQuery) })) - .filter((entry) => entry.score > 0) - .sort((a, b) => b.score - a.score) - .slice(0, limit) - .map((entry) => entry.profile); + if (tokenize(query).length === 0) return profiles.slice(0, limit); + return rankLexicalMatches( + profiles, + (profile) => ({ name: profile.id, text: profileSearchText(profile) }), + query, + limit, + // A raw-query hit in the description outranks a role-text-only hit. + (profile, rawQuery) => + (profile.description ?? "").toLowerCase().includes(rawQuery) ? 0.5 : 0, + ); }, }; } diff --git a/src/agent/lexical-search.ts b/src/agent/lexical-search.ts new file mode 100644 index 000000000..a613aec4e --- /dev/null +++ b/src/agent/lexical-search.ts @@ -0,0 +1,80 @@ +// Shared lexical ranker for the agent-side search tools (tool_search, +// skill_search, search_agents). Each used to carry its own copy of the +// tokenizer and scoring weights; keeping them here means a ranking change +// (weight tuning, tokenizer tweaks) lands in one place instead of drifting +// across three copies. + +/** Lowercase alphanumeric tokens; any other character splits tokens. */ +export function tokenize(text: string): string[] { + return text.toLowerCase().match(/[a-z0-9]+/g) ?? []; +} + +/** The two scored surfaces of a searchable document. */ +export interface LexicalSearchFields { + /** Short identifier — exact token hits weigh most (tool name, skill name, agent id). */ + name: string; + /** Body text — token hits weigh less (description, role text). */ + text: string; +} + +/** + * Score one document against a query. Exact name-token hits weigh most (3), + * then text-token hits (1), then raw-substring matches (0.75 in the name, + * 0.25 in the text — so "linear" finds mcp__linear__* even though it is not + * a whole token there), plus a raw-query hit in the name (+1). `extra` lets a + * caller add a document-specific term without forking the weights. + */ +export function scoreLexicalMatch( + fields: LexicalSearchFields, + queryTokens: readonly string[], + rawQuery: string, + extra = 0, +): number { + const nameTokens = tokenize(fields.name); + const textTokens = new Set(tokenize(fields.text)); + const nameLower = fields.name.toLowerCase(); + const textLower = fields.text.toLowerCase(); + let total = 0; + for (const token of queryTokens) { + if (nameTokens.includes(token)) total += 3; + else if (textTokens.has(token)) total += 1; + else if (nameLower.includes(token)) total += 0.75; + else if (textLower.includes(token)) total += 0.25; + } + if (nameLower.includes(rawQuery)) total += 1; + return total + extra; +} + +/** + * Rank `items` against a query: score each document, drop zero-score hits, + * order by descending score (stable — ties keep input order), and cap at + * `limit`. Returns [] for an empty or whitespace-only query (no tokens to + * match), so callers that treat an empty query as "return everything" (agent + * search) keep that branch on their side. `extra` lets a caller add a + * per-item scoring term without forking the weights. + */ +export function rankLexicalMatches( + items: readonly T[], + fieldsFor: (item: T) => LexicalSearchFields, + query: string, + limit: number, + extra?: (item: T, rawQuery: string) => number, +): T[] { + const rawQuery = query.toLowerCase().trim(); + const queryTokens = tokenize(query); + if (queryTokens.length === 0) return []; + return items + .map((item) => ({ + item, + score: scoreLexicalMatch( + fieldsFor(item), + queryTokens, + rawQuery, + extra?.(item, rawQuery) ?? 0, + ), + })) + .filter((entry) => entry.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map((entry) => entry.item); +} diff --git a/src/agent/skill-search.ts b/src/agent/skill-search.ts index c32f7a6ae..986aa2662 100644 --- a/src/agent/skill-search.ts +++ b/src/agent/skill-search.ts @@ -4,6 +4,7 @@ import type { ToolDefinition } from "@intx/types/runtime"; import { type } from "arktype"; import type { SkillSummary } from "../extensions/skills.js"; +import { rankLexicalMatches } from "./lexical-search.js"; // Catalog lookup for skills. Names live in the system prompt; this tool returns // matching name + description so the model can choose. Bodies load via use_skill. @@ -33,10 +34,6 @@ export interface CreateSkillSearchToolArgs { allowedNames?: readonly string[]; } -function tokenize(text: string): string[] { - return text.toLowerCase().match(/[a-z0-9]+/g) ?? []; -} - function visibleSkills( skills: readonly SkillSummary[], allowedNames: readonly string[] | undefined, @@ -46,20 +43,6 @@ function visibleSkills( return skills.filter((skill) => allowed.has(skill.name)); } -function scoreSkill(skill: SkillSummary, queryTokens: string[], rawQuery: string): number { - const nameTokens = tokenize(skill.name); - const descTokens = new Set(tokenize(skill.description)); - let total = 0; - for (const token of queryTokens) { - if (nameTokens.includes(token)) total += 3; - else if (descTokens.has(token)) total += 1; - else if (skill.name.toLowerCase().includes(token)) total += 0.75; - else if (skill.description.toLowerCase().includes(token)) total += 0.25; - } - if (skill.name.toLowerCase().includes(rawQuery)) total += 1; - return total; -} - const SkillSearchArgs = type({ query: "string" }); const DEFAULT_LIMIT = 8; @@ -75,17 +58,12 @@ export function createSkillSearchTool(args: CreateSkillSearchToolArgs): AgentToo } const query = parsed.query.trim(); if (query.length === 0) return "Error: skill_search requires a non-empty query."; - const rawQuery = query.toLowerCase(); - const queryTokens = tokenize(query); - if (queryTokens.length === 0) { - return `No skills matched "${query}". Try different keywords describing the capability.`; - } - const matches = catalog - .map((skill) => ({ skill, score: scoreSkill(skill, queryTokens, rawQuery) })) - .filter((entry) => entry.score > 0) - .sort((a, b) => b.score - a.score) - .slice(0, DEFAULT_LIMIT) - .map((entry) => entry.skill); + const matches = rankLexicalMatches( + catalog, + (skill) => ({ name: skill.name, text: skill.description }), + query, + DEFAULT_LIMIT, + ); if (matches.length === 0) { return `No skills matched "${query}". Try different keywords describing the capability.`; } diff --git a/src/agent/tool-search.ts b/src/agent/tool-search.ts index f91eb3822..e6166e8cf 100644 --- a/src/agent/tool-search.ts +++ b/src/agent/tool-search.ts @@ -5,6 +5,7 @@ import { type } from "arktype"; import type { SessionMode } from "../config/session-mode.js"; import { sessionModeEnablesSubAgents } from "../config/session-mode.js"; +import { rankLexicalMatches } from "./lexical-search.js"; // Tools whose full schema is always advertised to the model. Everything else is // registered and dispatchable but discovered on demand via tool_search, keeping @@ -194,43 +195,22 @@ export interface ToolIndex { search(query: string, limit?: number): string[]; } -function tokenize(text: string): string[] { - return text.toLowerCase().match(/[a-z0-9]+/g) ?? []; -} - -// A dependency-free lexical ranker over each tool's name + description. Exact name -// token hits weigh most, then description token hits, then raw-substring matches -// (so "linear" finds mcp__linear__* even though it is not a whole token there). +// Lexical ranker over each tool's name + description — weights shared with +// skill_search and search_agents (see lexical-search.ts). Exact name token hits +// weigh most, then description token hits, then raw-substring matches (so +// "linear" finds mcp__linear__* even though it is not a whole token there). export function createToolIndex( getDefs: () => readonly ToolDefinition[], advertisedNames: readonly string[] = ADVERTISED_TOOL_NAMES, ): ToolIndex { - const score = (def: ToolDefinition, queryTokens: string[], rawQuery: string): number => { - const nameTokens = tokenize(def.name); - const descTokens = new Set(tokenize(def.description ?? "")); - let total = 0; - for (const token of queryTokens) { - if (nameTokens.includes(token)) total += 3; - else if (descTokens.has(token)) total += 1; - else if (def.name.toLowerCase().includes(token)) total += 0.75; - else if ((def.description ?? "").toLowerCase().includes(token)) total += 0.25; - } - if (def.name.toLowerCase().includes(rawQuery)) total += 1; - return total; - }; - return { search(query: string, limit = 8): string[] { - const rawQuery = query.toLowerCase().trim(); - const queryTokens = tokenize(query); - if (queryTokens.length === 0) return []; - return getDefs() - .filter((def) => !advertisedNames.includes(def.name)) - .map((def) => ({ name: def.name, score: score(def, queryTokens, rawQuery) })) - .filter((entry) => entry.score > 0) - .sort((a, b) => b.score - a.score) - .slice(0, limit) - .map((entry) => entry.name); + return rankLexicalMatches( + getDefs().filter((def) => !advertisedNames.includes(def.name)), + (def) => ({ name: def.name, text: def.description ?? "" }), + query, + limit, + ).map((def) => def.name); }, }; } diff --git a/src/auth/codex/callback-server.ts b/src/auth/codex/callback-server.ts index 5aec3744d..83ca143f7 100644 --- a/src/auth/codex/callback-server.ts +++ b/src/auth/codex/callback-server.ts @@ -1,23 +1,7 @@ -import { - authorizationDoneHtml, - startCallbackServer, - type CallbackServer, -} from "../oauth/callback-server.js"; -import { CODEX_CALLBACK_PATH, CODEX_CALLBACK_PORT } from "./constants.js"; +// Codex callback server — see the shared factory in ./provider.ts. +import type { CallbackServer } from "../oauth/callback-server.js"; +import { codexAuth } from "./provider.js"; export type CodexCallbackServer = CallbackServer; -// Codex registers a fixed loopback redirect on port 1455; the authorization -// server only accepts this exact redirect_uri for this client. -export async function startCodexCallbackServer( - expectedState: string, -): Promise { - return startCallbackServer(expectedState, { - port: CODEX_CALLBACK_PORT, - path: CODEX_CALLBACK_PATH, - // Codex's registered redirect_uri uses localhost (not 127.0.0.1). - publicHost: "localhost", - doneHtml: authorizationDoneHtml("Codex"), - label: "Codex", - }); -} +export const startCodexCallbackServer = codexAuth.startCallbackServer; diff --git a/src/auth/codex/login.ts b/src/auth/codex/login.ts index 91fce0924..e875aa1ba 100644 --- a/src/auth/codex/login.ts +++ b/src/auth/codex/login.ts @@ -1,28 +1,16 @@ +// Codex login flow — see the shared factory in ./provider.ts. import { openInBrowser } from "../oauth/browser.js"; -import { - startOAuthLogin, - type OAuthLoginHandle, - type StartOAuthLoginOptions, -} from "../oauth/login.js"; +import type { OAuthLoginHandle, StartOAuthLoginOptions } from "../oauth/login.js"; import { CODEX_BASE_URL, CODEX_DEFAULT_MODELS } from "./constants.js"; -import { startCodexCallbackServer } from "./callback-server.js"; -import { buildAuthorizeUrl, exchangeCode } from "./oauth.js"; -import { saveCodexProfile, type CodexTokens } from "./store.js"; +import { codexAuth } from "./provider.js"; +import type { CodexTokens } from "./provider.js"; export { openInBrowser }; export type CodexLoginHandle = OAuthLoginHandle; export type StartCodexLoginOptions = StartOAuthLoginOptions; -// Drive the loopback PKCE login for a Codex profile. -export async function startCodexLogin(opts: StartCodexLoginOptions): Promise { - return startOAuthLogin(opts, { - startCallbackServer: startCodexCallbackServer, - buildAuthorizeUrl, - exchangeCode, - saveProfile: saveCodexProfile, - }); -} +export const startCodexLogin = codexAuth.startLogin; // Metadata describing the Codex provider surface, used when projecting a logged // in profile into the provider catalog. diff --git a/src/auth/codex/oauth.ts b/src/auth/codex/oauth.ts index 32cc58142..14ef5fbec 100644 --- a/src/auth/codex/oauth.ts +++ b/src/auth/codex/oauth.ts @@ -1,97 +1,8 @@ -import { - baseTokensFromResponse, - buildAuthorizeUrl as buildSharedAuthorizeUrl, - exchangeCode as exchangeSharedCode, - refreshTokenRequest, - type OAuthClientConfig, - type TokenResponse, -} from "../oauth/client.js"; -import type { Pkce } from "../oauth/pkce.js"; -import { - CODEX_AUTHORIZE_EXTRA_PARAMS, - CODEX_AUTHORIZE_URL, - CODEX_CLIENT_ID, - CODEX_REDIRECT_URI, - CODEX_SCOPES, - CODEX_TOKEN_TIMEOUT_MS, - CODEX_TOKEN_URL, -} from "./constants.js"; -import type { CodexTokens } from "./store.js"; - -export const codexOAuthConfig: OAuthClientConfig = { - clientId: CODEX_CLIENT_ID, - authorizeUrl: CODEX_AUTHORIZE_URL, - tokenUrl: CODEX_TOKEN_URL, - redirectUri: CODEX_REDIRECT_URI, - scopes: CODEX_SCOPES, - extraAuthorizeParams: CODEX_AUTHORIZE_EXTRA_PARAMS, - tokenTimeoutMs: CODEX_TOKEN_TIMEOUT_MS, - label: "Codex", -}; - -// Build the authorization URL the user opens to grant Codex access. -export function buildAuthorizeUrl(pkce: Pkce, state: string): string { - return buildSharedAuthorizeUrl(codexOAuthConfig, pkce, state); -} - -// Decode the ChatGPT account id from an id_token (a JWT). The claim lives at -// `chatgpt_account_id` or nested under the `https://api.openai.com/auth` claim. -// Only the payload segment is read; the signature is not verified here because -// the token came straight from the authorization server over TLS and is used -// solely to label the account, not to authorize anything. -export function accountIdFromIdToken(idToken: string | undefined): string | undefined { - if (idToken === undefined) return undefined; - const payload = idToken.split(".")[1]; - if (payload === undefined) return undefined; - try { - const json = Buffer.from(payload, "base64url").toString("utf8"); - const claims = JSON.parse(json) as Record; - const direct = claims["chatgpt_account_id"]; - if (typeof direct === "string") return direct; - const nested = claims["https://api.openai.com/auth"]; - if (typeof nested === "object" && nested !== null) { - const id = (nested as Record)["chatgpt_account_id"]; - if (typeof id === "string") return id; - } - } catch { - // A malformed id_token just means no account id; the caller may still - // function for flows that do not require the header. - } - return undefined; -} - -// Convert a token response to stored tokens. `now` is injectable so callers -// (and tests) control the expiry baseline; `previousRefresh` is carried forward -// when a refresh response omits a new refresh_token (servers may rotate or not). -export function tokensFromResponse( - response: TokenResponse, - now: number, - previousRefresh?: string, -): CodexTokens { - const base = baseTokensFromResponse(response, now, previousRefresh, "Codex"); - const accountId = accountIdFromIdToken(response.id_token); - return { - ...base, - ...(accountId !== undefined ? { accountId } : {}), - }; -} - -// Exchange an authorization code for tokens. `now` defaults to the current time -// but stays injectable for deterministic tests. -export async function exchangeCode( - code: string, - verifier: string, - now: number, -): Promise { - return tokensFromResponse(await exchangeSharedCode(codexOAuthConfig, code, verifier), now); -} - -// Mint a fresh access token from a refresh token. Carries the prior refresh -// token forward if the server does not rotate it. -export async function refreshTokens(refreshToken: string, now: number): Promise { - return tokensFromResponse( - await refreshTokenRequest(codexOAuthConfig, refreshToken), - now, - refreshToken, - ); -} +// Codex OAuth helpers — see the shared factory in ./provider.ts. +export { codexOAuthConfig, accountIdFromIdToken } from "./provider.js"; +import { codexAuth } from "./provider.js"; + +export const buildAuthorizeUrl = codexAuth.buildAuthorizeUrl; +export const tokensFromResponse = codexAuth.tokensFromResponse; +export const exchangeCode = codexAuth.exchangeCode; +export const refreshTokens = codexAuth.refreshTokens; diff --git a/src/auth/codex/provider.ts b/src/auth/codex/provider.ts new file mode 100644 index 000000000..17d629293 --- /dev/null +++ b/src/auth/codex/provider.ts @@ -0,0 +1,124 @@ +// Codex (ChatGPT subscription) OAuth provider stack: PKCE login, named-profile +// storage, and transparent token refresh. The whole stack (store, session, +// oauth helpers, callback server, login) is built by the shared factory in +// auth/oauth/provider.ts; this module owns the provider config only. Profiles +// are keyed by user-chosen name so multiple Codex subscriptions can coexist. + +import { + baseTokensFromResponse, + type OAuthClientConfig, + type TokenResponse, +} from "../oauth/client.js"; +import { createProviderAuth, type ProviderAuthConfig } from "../oauth/provider.js"; +import type { BaseTokens } from "../oauth/store.js"; +import { + CODEX_AUTHORIZE_EXTRA_PARAMS, + CODEX_AUTHORIZE_URL, + CODEX_CALLBACK_PATH, + CODEX_CALLBACK_PORT, + CODEX_CLIENT_ID, + CODEX_REDIRECT_URI, + CODEX_REFRESH_SKEW_MS, + CODEX_SCOPES, + CODEX_TOKEN_TIMEOUT_MS, + CODEX_TOKEN_URL, +} from "./constants.js"; + +export type CodexTokens = BaseTokens & { + // ChatGPT account id extracted from the id_token, required as the + // `chatgpt-account-id` header on every Codex inference request. + accountId?: string; +}; + +export interface CodexAccess { + access: string; + accountId?: string | undefined; +} + +export const codexOAuthConfig: OAuthClientConfig = { + clientId: CODEX_CLIENT_ID, + authorizeUrl: CODEX_AUTHORIZE_URL, + tokenUrl: CODEX_TOKEN_URL, + redirectUri: CODEX_REDIRECT_URI, + scopes: CODEX_SCOPES, + extraAuthorizeParams: CODEX_AUTHORIZE_EXTRA_PARAMS, + tokenTimeoutMs: CODEX_TOKEN_TIMEOUT_MS, + label: "Codex", +}; + +function isCodexTokens(value: unknown): value is CodexTokens { + if (typeof value !== "object" || value === null) return false; + const t = value as Record; + return ( + typeof t.access === "string" && + typeof t.refresh === "string" && + typeof t.expiresAt === "number" && + (t.accountId === undefined || typeof t.accountId === "string") + ); +} + +// Decode the ChatGPT account id from an id_token (a JWT). The claim lives at +// `chatgpt_account_id` or nested under the `https://api.openai.com/auth` claim. +// Only the payload segment is read; the signature is not verified here because +// the token came straight from the authorization server over TLS and is used +// solely to label the account, not to authorize anything. +export function accountIdFromIdToken(idToken: string | undefined): string | undefined { + if (idToken === undefined) return undefined; + const payload = idToken.split(".")[1]; + if (payload === undefined) return undefined; + try { + const json = Buffer.from(payload, "base64url").toString("utf8"); + const claims = JSON.parse(json) as Record; + const direct = claims["chatgpt_account_id"]; + if (typeof direct === "string") return direct; + const nested = claims["https://api.openai.com/auth"]; + if (typeof nested === "object" && nested !== null) { + const id = (nested as Record)["chatgpt_account_id"]; + if (typeof id === "string") return id; + } + } catch { + // A malformed id_token just means no account id; the caller may still + // function for flows that do not require the header. + } + return undefined; +} + +const codexConfig: ProviderAuthConfig = { + errorName: "CodexAuthError", + label: "Codex", + filename: "codex-auth.json", + oauth: codexOAuthConfig, + callback: { + port: CODEX_CALLBACK_PORT, + path: CODEX_CALLBACK_PATH, + // Codex's registered redirect_uri uses localhost (not 127.0.0.1). + publicHost: "localhost", + }, + isTokens: isCodexTokens, + tokensFromResponse: (response: TokenResponse, now: number, previousRefresh?: string) => { + const base = baseTokensFromResponse(response, now, previousRefresh, "Codex"); + const accountId = accountIdFromIdToken(response.id_token); + return { + ...base, + ...(accountId !== undefined ? { accountId } : {}), + }; + }, + refreshSkewMs: CODEX_REFRESH_SKEW_MS, + // A usable access token plus the account id that must ride alongside it in + // the chatgpt-account-id header. Returned together so callers need a single + // load, not a token fetch followed by a separate profile read (which could + // observe a token and account id from two different points in a concurrent + // refresh). + toAccess: (tokens) => ({ access: tokens.access, accountId: tokens.accountId }), + // The refresh response rarely re-issues an id_token, so carry the account id + // forward from the prior tokens when the refresh did not supply one. + mergeRefreshed: (refreshed, previous) => + refreshed.accountId === undefined && previous.accountId !== undefined + ? { ...refreshed, accountId: previous.accountId } + : refreshed, + missingError: (name) => `Codex profile "${name}" is not authorized. Log in again.`, + refreshFailedError: (name, err) => + `Codex profile "${name}" could not be refreshed (${err instanceof Error ? err.message : String(err)}). Log in again.`, +}; + +export const codexAuth = createProviderAuth(codexConfig); diff --git a/src/auth/codex/session.ts b/src/auth/codex/session.ts index 4789ef498..4db28b751 100644 --- a/src/auth/codex/session.ts +++ b/src/auth/codex/session.ts @@ -1,64 +1,16 @@ -import { createTokenSession } from "../oauth/session.js"; -import { CODEX_REFRESH_SKEW_MS } from "./constants.js"; -import { refreshTokens } from "./oauth.js"; -import { loadCodexProfile, updateCodexTokens, type CodexTokens } from "./store.js"; - +// Codex token session — see the shared factory in ./provider.ts. +// // Raised when a Codex profile cannot yield a usable access token: it is gone, // or its refresh token has been revoked/expired. Carries the profile name so // the TUI can name the affected profile in a re-login prompt. `reason` // distinguishes "never authorized" from "refresh rejected" for messaging. -export class CodexAuthError extends Error { - readonly profile: string; - readonly reason: "missing" | "refresh-failed"; - - constructor(profile: string, reason: "missing" | "refresh-failed", message: string) { - super(message); - this.name = "CodexAuthError"; - this.profile = profile; - this.reason = reason; - } -} - -// A usable access token plus the account id that must ride alongside it in the -// chatgpt-account-id header. Returned together so callers need a single load, -// not a token fetch followed by a separate profile read (which could observe a -// token and account id from two different points in a concurrent refresh). -export interface CodexAccess { - access: string; - accountId?: string | undefined; -} +import { codexAuth } from "./provider.js"; -const session = createTokenSession({ - skewMs: CODEX_REFRESH_SKEW_MS, - loadProfile: loadCodexProfile, - updateTokens: updateCodexTokens, - refreshTokens, - toAccess: (tokens) => ({ access: tokens.access, accountId: tokens.accountId }), - // The refresh response rarely re-issues an id_token, so carry the account id - // forward from the prior tokens when the refresh did not supply one. - mergeRefreshed: (refreshed, previous) => - refreshed.accountId === undefined && previous.accountId !== undefined - ? { ...refreshed, accountId: previous.accountId } - : refreshed, - missingError: (name) => - new CodexAuthError(name, "missing", `Codex profile "${name}" is not authorized. Log in again.`), - refreshFailedError: (name, err) => - new CodexAuthError( - name, - "refresh-failed", - `Codex profile "${name}" could not be refreshed (${err instanceof Error ? err.message : String(err)}). Log in again.`, - ), -}); +export const CodexAuthError = codexAuth.AuthError; +export type CodexAuthError = InstanceType; -export const isCodexTokenExpired = session.isExpired; -export const getValidCodexToken = session.getValidToken; +export type { CodexAccess } from "./provider.js"; -export async function refreshStagedCodexTokens( - tokens: CodexTokens, - now: number = Date.now(), -): Promise { - if (!isCodexTokenExpired(tokens, now)) return tokens; - const refreshed = await refreshTokens(tokens.refresh, now); - Object.assign(tokens, refreshed); - return tokens; -} +export const isCodexTokenExpired = codexAuth.session.isExpired; +export const getValidCodexToken = codexAuth.session.getValidToken; +export const refreshStagedCodexTokens = codexAuth.refreshStaged; diff --git a/src/auth/codex/store.ts b/src/auth/codex/store.ts index a9e0d3f11..488808f21 100644 --- a/src/auth/codex/store.ts +++ b/src/auth/codex/store.ts @@ -1,37 +1,20 @@ -import { createAuthStore, type AuthProfile, type BaseTokens } from "../oauth/store.js"; - +// Codex profile store — see the shared factory in ./provider.ts. +// // On-disk store for Codex OAuth profiles. A user may hold multiple Codex // subscriptions (personal, work, ...), so credentials are keyed by a // user-chosen profile name within a single file. The provider type is shared; // the profile name is what differentiates instances throughout the app. +import type { AuthProfile } from "../oauth/store.js"; +import { codexAuth } from "./provider.js"; +import type { CodexTokens } from "./provider.js"; -export type CodexTokens = BaseTokens & { - // ChatGPT account id extracted from the id_token, required as the - // `chatgpt-account-id` header on every Codex inference request. - accountId?: string; -}; +export type { CodexTokens } from "./provider.js"; export type CodexProfile = AuthProfile; -function isCodexTokens(value: unknown): value is CodexTokens { - if (typeof value !== "object" || value === null) return false; - const t = value as Record; - return ( - typeof t.access === "string" && - typeof t.refresh === "string" && - typeof t.expiresAt === "number" && - (t.accountId === undefined || typeof t.accountId === "string") - ); -} - -const store = createAuthStore({ - filename: "codex-auth.json", - isTokens: isCodexTokens, -}); - -export const codexAuthPath = store.authPath; -export const listCodexProfiles = store.listProfiles; -export const loadCodexProfile = store.loadProfile; -export const saveCodexProfile = store.saveProfile; -export const updateCodexTokens = store.updateTokens; -export const removeCodexProfile = store.removeProfile; +export const codexAuthPath = codexAuth.store.authPath; +export const listCodexProfiles = codexAuth.store.listProfiles; +export const loadCodexProfile = codexAuth.store.loadProfile; +export const saveCodexProfile = codexAuth.store.saveProfile; +export const updateCodexTokens = codexAuth.store.updateTokens; +export const removeCodexProfile = codexAuth.store.removeProfile; diff --git a/src/auth/oauth/provider.ts b/src/auth/oauth/provider.ts new file mode 100644 index 000000000..3a9ffcc99 --- /dev/null +++ b/src/auth/oauth/provider.ts @@ -0,0 +1,169 @@ +// Generic OAuth provider stack factory. The provider-specific layers (xai, +// codex) used to re-implement the same store/session/oauth/login/callback +// wiring per provider; this factory builds the whole stack from one config so +// a provider owns only its constants, token shape, and messages. +// +// A provider module calls `createProviderAuth` with its config and re-exports +// the returned surface under its public names (see auth/xai/provider.ts and +// auth/codex/provider.ts). + +import { + buildAuthorizeUrl as buildSharedAuthorizeUrl, + exchangeCode as exchangeSharedCode, + refreshTokenRequest, + type OAuthClientConfig, + type TokenResponse, +} from "./client.js"; +import { + authorizationDoneHtml, + startCallbackServer as startSharedCallbackServer, + type CallbackServer, + type CallbackServerConfig, +} from "./callback-server.js"; +import { startOAuthLogin, type OAuthLoginHandle, type StartOAuthLoginOptions } from "./login.js"; +import type { Pkce } from "./pkce.js"; +import { createTokenSession, type TokenSession } from "./session.js"; +import { createAuthStore, type AuthStore, type BaseTokens } from "./store.js"; + +/** Error raised when a provider profile cannot yield a usable access token. */ +export interface ProviderAuthError extends Error { + readonly profile: string; + readonly reason: "missing" | "refresh-failed"; +} + +export type ProviderAuthErrorConstructor = new ( + profile: string, + reason: "missing" | "refresh-failed", + message: string, +) => ProviderAuthError; + +// Build the provider-named error class ("XaiAuthError", "CodexAuthError", …). +// `name` must equal the provider key exactly: callers classify OAuth send +// failures by `err.name` (see tui/runner/submit.ts) and by instanceof. +function createAuthErrorClass(errorName: string): ProviderAuthErrorConstructor { + return class ProviderAuthErrorImpl extends Error { + readonly profile: string; + readonly reason: "missing" | "refresh-failed"; + + constructor(profile: string, reason: "missing" | "refresh-failed", message: string) { + super(message); + this.name = errorName; + this.profile = profile; + this.reason = reason; + } + }; +} + +export interface ProviderAuthConfig { + /** Error class name, e.g. "XaiAuthError". Must match the provider key. */ + errorName: string; + /** Product label used in user-facing messages ("xAI", "Codex", …). */ + label: string; + /** Profile-store filename under ~/.corbits/ (e.g. "xai-auth.json"). */ + filename: string; + /** OAuth client endpoints, scopes, and timeout (see client.ts). */ + oauth: OAuthClientConfig; + /** Loopback callback-server binding. doneHtml/label derive from `label`. */ + callback: Omit; + isTokens: (value: unknown) => value is TTokens; + /** + * Map a raw token response onto the provider's stored token shape. `now` is + * injectable so callers (and tests) control the expiry baseline; + * `previousRefresh` is carried forward when a refresh response omits a new + * refresh_token (servers may rotate or not). + */ + tokensFromResponse: (response: TokenResponse, now: number, previousRefresh?: string) => TTokens; + refreshSkewMs: number; + /** Project stored tokens into the access shape returned to callers. */ + toAccess: (tokens: TTokens) => TAccess; + /** Optional merge when a refresh response omits provider-specific fields. */ + mergeRefreshed?: (refreshed: TTokens, previous: TTokens) => TTokens; + missingError: (name: string) => string; + refreshFailedError: (name: string, cause: unknown) => string; +} + +/** The complete per-provider auth stack produced by {@link createProviderAuth}. */ +export interface ProviderAuth { + /** Provider-named error class (also used for instanceof checks). */ + AuthError: ProviderAuthErrorConstructor; + store: AuthStore; + buildAuthorizeUrl: (pkce: Pkce, state: string) => string; + tokensFromResponse: (response: TokenResponse, now: number, previousRefresh?: string) => TTokens; + exchangeCode: (code: string, verifier: string, now: number) => Promise; + refreshTokens: (refreshToken: string, now: number) => Promise; + startCallbackServer: (expectedState: string) => Promise; + startLogin: (opts: StartOAuthLoginOptions) => Promise>; + session: TokenSession; + /** Refresh in place when at/over expiry; otherwise return tokens unchanged. */ + refreshStaged: (tokens: TTokens, now?: number) => Promise; +} + +export function createProviderAuth( + config: ProviderAuthConfig, +): ProviderAuth { + const AuthError = createAuthErrorClass(config.errorName); + const store = createAuthStore({ + filename: config.filename, + isTokens: config.isTokens, + }); + + const buildAuthorizeUrl = (pkce: Pkce, state: string): string => + buildSharedAuthorizeUrl(config.oauth, pkce, state); + + const exchangeCode = async (code: string, verifier: string, now: number): Promise => + config.tokensFromResponse(await exchangeSharedCode(config.oauth, code, verifier), now); + + const refreshTokens = async (refreshToken: string, now: number): Promise => + config.tokensFromResponse( + await refreshTokenRequest(config.oauth, refreshToken), + now, + refreshToken, + ); + + const startCallbackServer = (expectedState: string): Promise => + startSharedCallbackServer(expectedState, { + ...config.callback, + doneHtml: authorizationDoneHtml(config.label), + label: config.label, + }); + + const startLogin = (opts: StartOAuthLoginOptions): Promise> => + startOAuthLogin(opts, { + startCallbackServer, + buildAuthorizeUrl, + exchangeCode, + saveProfile: store.saveProfile, + }); + + const session = createTokenSession({ + skewMs: config.refreshSkewMs, + loadProfile: store.loadProfile, + updateTokens: store.updateTokens, + refreshTokens, + toAccess: config.toAccess, + ...(config.mergeRefreshed !== undefined ? { mergeRefreshed: config.mergeRefreshed } : {}), + missingError: (name) => new AuthError(name, "missing", config.missingError(name)), + refreshFailedError: (name, cause) => + new AuthError(name, "refresh-failed", config.refreshFailedError(name, cause)), + }); + + const refreshStaged = async (tokens: TTokens, now: number = Date.now()): Promise => { + if (!session.isExpired(tokens, now)) return tokens; + const refreshed = await refreshTokens(tokens.refresh, now); + Object.assign(tokens, refreshed); + return tokens; + }; + + return { + AuthError, + store, + buildAuthorizeUrl, + tokensFromResponse: config.tokensFromResponse, + exchangeCode, + refreshTokens, + startCallbackServer, + startLogin, + session, + refreshStaged, + }; +} diff --git a/src/auth/xai/callback-server.ts b/src/auth/xai/callback-server.ts index 56d1ffe8a..1f92ec32e 100644 --- a/src/auth/xai/callback-server.ts +++ b/src/auth/xai/callback-server.ts @@ -1,17 +1,7 @@ -import { - authorizationDoneHtml, - startCallbackServer, - type CallbackServer, -} from "../oauth/callback-server.js"; -import { XAI_CALLBACK_PATH, XAI_CALLBACK_PORT } from "./constants.js"; +// xAI callback server — see the shared factory in ./provider.ts. +import type { CallbackServer } from "../oauth/callback-server.js"; +import { xaiAuth } from "./provider.js"; export type XaiCallbackServer = CallbackServer; -export async function startXaiCallbackServer(expectedState: string): Promise { - return startCallbackServer(expectedState, { - port: XAI_CALLBACK_PORT, - path: XAI_CALLBACK_PATH, - doneHtml: authorizationDoneHtml("xAI"), - label: "xAI", - }); -} +export const startXaiCallbackServer = xaiAuth.startCallbackServer; diff --git a/src/auth/xai/login.ts b/src/auth/xai/login.ts index 291a435bb..8a3fc448f 100644 --- a/src/auth/xai/login.ts +++ b/src/auth/xai/login.ts @@ -1,24 +1,13 @@ -import { - startOAuthLogin, - type OAuthLoginHandle, - type StartOAuthLoginOptions, -} from "../oauth/login.js"; +// xAI login flow — see the shared factory in ./provider.ts. +import type { OAuthLoginHandle, StartOAuthLoginOptions } from "../oauth/login.js"; import { XAI_BASE_URL, XAI_DEFAULT_MODELS } from "./constants.js"; -import { startXaiCallbackServer } from "./callback-server.js"; -import { buildAuthorizeUrl, exchangeCode } from "./oauth.js"; -import { saveXaiProfile, type XaiTokens } from "./store.js"; +import { xaiAuth } from "./provider.js"; +import type { XaiTokens } from "./provider.js"; export type XaiLoginHandle = OAuthLoginHandle; export type StartXaiLoginOptions = StartOAuthLoginOptions; -export async function startXaiLogin(opts: StartXaiLoginOptions): Promise { - return startOAuthLogin(opts, { - startCallbackServer: startXaiCallbackServer, - buildAuthorizeUrl, - exchangeCode, - saveProfile: saveXaiProfile, - }); -} +export const startXaiLogin = xaiAuth.startLogin; export const xaiProviderSurface = { baseURL: XAI_BASE_URL, diff --git a/src/auth/xai/oauth.ts b/src/auth/xai/oauth.ts index aaf174eeb..77173f034 100644 --- a/src/auth/xai/oauth.ts +++ b/src/auth/xai/oauth.ts @@ -1,60 +1,8 @@ -import { - baseTokensFromResponse, - buildAuthorizeUrl as buildSharedAuthorizeUrl, - exchangeCode as exchangeSharedCode, - refreshTokenRequest, - type OAuthClientConfig, - type TokenResponse, -} from "../oauth/client.js"; -import type { Pkce } from "../oauth/pkce.js"; -import { - XAI_AUTHORIZE_URL, - XAI_CLIENT_ID, - XAI_REDIRECT_URI, - XAI_SCOPES, - XAI_TOKEN_TIMEOUT_MS, - XAI_TOKEN_URL, -} from "./constants.js"; -import type { XaiTokens } from "./store.js"; - -export const xaiOAuthConfig: OAuthClientConfig = { - clientId: XAI_CLIENT_ID, - authorizeUrl: XAI_AUTHORIZE_URL, - tokenUrl: XAI_TOKEN_URL, - redirectUri: XAI_REDIRECT_URI, - scopes: XAI_SCOPES, - tokenTimeoutMs: XAI_TOKEN_TIMEOUT_MS, - label: "xAI", -}; - -export function buildAuthorizeUrl(pkce: Pkce, state: string): string { - return buildSharedAuthorizeUrl(xaiOAuthConfig, pkce, state); -} - -export function tokensFromResponse( - response: TokenResponse, - now: number, - previousRefresh?: string, -): XaiTokens { - const base = baseTokensFromResponse(response, now, previousRefresh, "xAI"); - return { - ...base, - ...(response.id_token !== undefined ? { idToken: response.id_token } : {}), - }; -} - -export async function exchangeCode( - code: string, - verifier: string, - now: number, -): Promise { - return tokensFromResponse(await exchangeSharedCode(xaiOAuthConfig, code, verifier), now); -} - -export async function refreshTokens(refreshToken: string, now: number): Promise { - return tokensFromResponse( - await refreshTokenRequest(xaiOAuthConfig, refreshToken), - now, - refreshToken, - ); -} +// xAI OAuth helpers — see the shared factory in ./provider.ts. +export { xaiOAuthConfig } from "./provider.js"; +import { xaiAuth } from "./provider.js"; + +export const buildAuthorizeUrl = xaiAuth.buildAuthorizeUrl; +export const tokensFromResponse = xaiAuth.tokensFromResponse; +export const exchangeCode = xaiAuth.exchangeCode; +export const refreshTokens = xaiAuth.refreshTokens; diff --git a/src/auth/xai/provider.ts b/src/auth/xai/provider.ts new file mode 100644 index 000000000..38bcd5dc9 --- /dev/null +++ b/src/auth/xai/provider.ts @@ -0,0 +1,77 @@ +// xAI/Grok OAuth provider stack. xAI exposes an OpenAI-compatible Chat +// Completions API at api.x.ai; OAuth tokens come from auth.x.ai and are used as +// the bearer credential in the standard openai-compatible adapter. The whole +// stack (store, session, oauth helpers, callback server, login) is built by the +// shared factory in auth/oauth/provider.ts; this module owns the provider +// config only. + +import { + baseTokensFromResponse, + type OAuthClientConfig, + type TokenResponse, +} from "../oauth/client.js"; +import { createProviderAuth, type ProviderAuthConfig } from "../oauth/provider.js"; +import type { BaseTokens } from "../oauth/store.js"; +import { + XAI_AUTHORIZE_URL, + XAI_CALLBACK_PATH, + XAI_CALLBACK_PORT, + XAI_CLIENT_ID, + XAI_REDIRECT_URI, + XAI_REFRESH_SKEW_MS, + XAI_SCOPES, + XAI_TOKEN_TIMEOUT_MS, + XAI_TOKEN_URL, +} from "./constants.js"; + +export type XaiTokens = BaseTokens & { + idToken?: string; +}; + +export interface XaiAccess { + access: string; +} + +export const xaiOAuthConfig: OAuthClientConfig = { + clientId: XAI_CLIENT_ID, + authorizeUrl: XAI_AUTHORIZE_URL, + tokenUrl: XAI_TOKEN_URL, + redirectUri: XAI_REDIRECT_URI, + scopes: XAI_SCOPES, + tokenTimeoutMs: XAI_TOKEN_TIMEOUT_MS, + label: "xAI", +}; + +function isXaiTokens(value: unknown): value is XaiTokens { + if (typeof value !== "object" || value === null) return false; + const t = value as Record; + return ( + typeof t.access === "string" && + typeof t.refresh === "string" && + typeof t.expiresAt === "number" && + (t.idToken === undefined || typeof t.idToken === "string") + ); +} + +const xaiConfig: ProviderAuthConfig = { + errorName: "XaiAuthError", + label: "xAI", + filename: "xai-auth.json", + oauth: xaiOAuthConfig, + callback: { port: XAI_CALLBACK_PORT, path: XAI_CALLBACK_PATH }, + isTokens: isXaiTokens, + tokensFromResponse: (response: TokenResponse, now: number, previousRefresh?: string) => { + const base = baseTokensFromResponse(response, now, previousRefresh, "xAI"); + return { + ...base, + ...(response.id_token !== undefined ? { idToken: response.id_token } : {}), + }; + }, + refreshSkewMs: XAI_REFRESH_SKEW_MS, + toAccess: (tokens) => ({ access: tokens.access }), + missingError: (name) => `xAI profile "${name}" is not authorized. Log in again.`, + refreshFailedError: (name, err) => + `xAI profile "${name}" could not be refreshed (${err instanceof Error ? err.message : String(err)}). Log in again.`, +}; + +export const xaiAuth = createProviderAuth(xaiConfig); diff --git a/src/auth/xai/session.ts b/src/auth/xai/session.ts index b8aa6085f..62ec6346c 100644 --- a/src/auth/xai/session.ts +++ b/src/auth/xai/session.ts @@ -1,23 +1,14 @@ -import { createTokenSession } from "../oauth/session.js"; -import { XAI_REFRESH_SKEW_MS } from "./constants.js"; -import { refreshTokens } from "./oauth.js"; -import { loadXaiProfile, updateXaiTokens, type XaiTokens } from "./store.js"; +// xAI token session — see the shared factory in ./provider.ts. +import { xaiAuth } from "./provider.js"; -export class XaiAuthError extends Error { - readonly profile: string; - readonly reason: "missing" | "refresh-failed"; +export const XaiAuthError = xaiAuth.AuthError; +export type XaiAuthError = InstanceType; - constructor(profile: string, reason: "missing" | "refresh-failed", message: string) { - super(message); - this.name = "XaiAuthError"; - this.profile = profile; - this.reason = reason; - } -} +export type { XaiAccess } from "./provider.js"; -export interface XaiAccess { - access: string; -} +export const isXaiTokenExpired = xaiAuth.session.isExpired; +export const getValidXaiToken = xaiAuth.session.getValidToken; +export const refreshStagedXaiTokens = xaiAuth.refreshStaged; // The grok proxy wants the caller's user id in the x-grok-user-id header. The // access token is a JWT whose `sub` claim is that id; decode it rather than @@ -34,32 +25,3 @@ export function xaiUserIdFromAccessToken(access: string): string | undefined { return undefined; } } - -const session = createTokenSession({ - skewMs: XAI_REFRESH_SKEW_MS, - loadProfile: loadXaiProfile, - updateTokens: updateXaiTokens, - refreshTokens, - toAccess: (tokens) => ({ access: tokens.access }), - missingError: (name) => - new XaiAuthError(name, "missing", `xAI profile "${name}" is not authorized. Log in again.`), - refreshFailedError: (name, err) => - new XaiAuthError( - name, - "refresh-failed", - `xAI profile "${name}" could not be refreshed (${err instanceof Error ? err.message : String(err)}). Log in again.`, - ), -}); - -export const isXaiTokenExpired = session.isExpired; -export const getValidXaiToken = session.getValidToken; - -export async function refreshStagedXaiTokens( - tokens: XaiTokens, - now: number = Date.now(), -): Promise { - if (!isXaiTokenExpired(tokens, now)) return tokens; - const refreshed = await refreshTokens(tokens.refresh, now); - Object.assign(tokens, refreshed); - return tokens; -} diff --git a/src/auth/xai/store.ts b/src/auth/xai/store.ts index 82dd07d4d..7d9523882 100644 --- a/src/auth/xai/store.ts +++ b/src/auth/xai/store.ts @@ -1,30 +1,15 @@ -import { createAuthStore, type AuthProfile, type BaseTokens } from "../oauth/store.js"; +// xAI profile store — see the shared factory in ./provider.ts. +import type { AuthProfile } from "../oauth/store.js"; +import { xaiAuth } from "./provider.js"; +import type { XaiTokens } from "./provider.js"; -export type XaiTokens = BaseTokens & { - idToken?: string; -}; +export type { XaiTokens } from "./provider.js"; export type XaiProfile = AuthProfile; -function isXaiTokens(value: unknown): value is XaiTokens { - if (typeof value !== "object" || value === null) return false; - const t = value as Record; - return ( - typeof t.access === "string" && - typeof t.refresh === "string" && - typeof t.expiresAt === "number" && - (t.idToken === undefined || typeof t.idToken === "string") - ); -} - -const store = createAuthStore({ - filename: "xai-auth.json", - isTokens: isXaiTokens, -}); - -export const xaiAuthPath = store.authPath; -export const listXaiProfiles = store.listProfiles; -export const loadXaiProfile = store.loadProfile; -export const saveXaiProfile = store.saveProfile; -export const updateXaiTokens = store.updateTokens; -export const removeXaiProfile = store.removeProfile; +export const xaiAuthPath = xaiAuth.store.authPath; +export const listXaiProfiles = xaiAuth.store.listProfiles; +export const loadXaiProfile = xaiAuth.store.loadProfile; +export const saveXaiProfile = xaiAuth.store.saveProfile; +export const updateXaiTokens = xaiAuth.store.updateTokens; +export const removeXaiProfile = xaiAuth.store.removeProfile; diff --git a/src/config/index.ts b/src/config/index.ts index 3fd8d36c9..0d4c57551 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -31,12 +31,10 @@ import { CODEX_RESPONSES_PROVIDER, CODEX_ACCOUNT_ID_OPTION, CODEX_SESSION_ID_OPTION, -} from "../provider/codex-responses-adapter.js"; -import { GROK_RESPONSES_PROVIDER, GROK_SESSION_ID_OPTION, GROK_USER_ID_OPTION, -} from "../provider/grok-responses-adapter.js"; +} from "../provider/responses-adapters.js"; import { BIFROST_PROVIDER } from "../provider/bifrost-adapter.js"; import { isOllamaProviderId, ollamaOpenAIBaseURL } from "../provider/ollama.js"; import { selectableGoModelIds } from "../provider/opencode-go-models.js"; @@ -44,7 +42,7 @@ import { OPENAI_RESPONSES_PROVIDER, OPENAI_SESSION_ID_OPTION, OPENCODE_SESSION_ID_OPTION, -} from "../provider/openai-responses-adapter.js"; +} from "../provider/responses-adapters.js"; import { OPENCODE_GO_MESSAGES_PROVIDER } from "../provider/opencode-go-anthropic-adapter.js"; import { xaiUserIdFromAccessToken } from "../auth/xai/session.js"; import { diff --git a/src/provider/codex-responses-adapter.test.ts b/src/provider/codex-responses-adapter.test.ts index 41ba27c58..bce183fe9 100644 --- a/src/provider/codex-responses-adapter.test.ts +++ b/src/provider/codex-responses-adapter.test.ts @@ -5,7 +5,7 @@ import { isResponsesStreamTerminal, signatureForModel, tagSignature, -} from "./codex-responses-adapter.js"; +} from "./responses-adapters.js"; import { contextTokensFromUsage } from "./context-window.js"; const source: LastCycleSource = { diff --git a/src/provider/grok-responses-adapter.test.ts b/src/provider/grok-responses-adapter.test.ts index 26a44bb17..f1d6412ba 100644 --- a/src/provider/grok-responses-adapter.test.ts +++ b/src/provider/grok-responses-adapter.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { ConversationTurn, LastCycleSource } from "@intx/types/runtime"; -import { createGrokResponsesAdapter } from "./grok-responses-adapter.js"; +import { createGrokResponsesAdapter } from "./responses-adapters.js"; const source: LastCycleSource = { sourceId: "xai/test", diff --git a/src/provider/grok-responses-adapter.ts b/src/provider/grok-responses-adapter.ts deleted file mode 100644 index 2cca85ae8..000000000 --- a/src/provider/grok-responses-adapter.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { - BEARER_CREDENTIAL_SENTINEL, - encodeToolName, - type BuiltRequest, - type ProviderAdapter, -} from "@intx/inference"; -import type { - ContentBlock, - ConversationTurn, - InferenceOptions, - LastCycleSource, -} from "@intx/types/runtime"; -import { - XAI_RESPONSES_PATH, - XAI_CLIENT_IDENTIFIER, - XAI_CLIENT_VERSION, - XAI_USER_AGENT, -} from "../auth/xai/constants.js"; -import { - RESPONSES_TOOL_NAME_LIMIT, - createResponsesBlockIndexer, - parseJSONResponse, - parseResponse, - signatureForModel, -} from "./codex-responses-adapter.js"; - -// Adapter for the grok-cli OAuth proxy (cli-chat-proxy.grok.com), which serves -// the OpenAI Responses API at /v1/responses. The request shape mirrors the grok -// CLI's own /v1/responses call (captured live): the system prompt rides as a -// leading `system` input message (string content, not parts), reasoning is -// requested by summary, and the caller is identified by x-grok-* headers rather -// than a body field. The Responses SSE protocol is identical to Codex, so the -// stream parser is shared. - -export const GROK_RESPONSES_PROVIDER = "grok-responses"; - -// Keys the source stashes in defaults.providerOptions for this adapter. -export const GROK_USER_ID_OPTION = "grokUserId"; -export const GROK_SESSION_ID_OPTION = "grokSessionId"; - -type ResponsesInputContentPart = - { type: "input_text"; text: string } | { type: "input_image"; image_url: string }; - -type ResponsesInputItem = - | { - type: "message"; - role: "user" | "assistant" | "system"; - content: string | ResponsesInputContentPart[]; - } - | { type: "function_call"; name: string; arguments: string; call_id: string } - | { type: "function_call_output"; call_id: string; output: string } - | { type: "reasoning"; summary: never[]; encrypted_content: string }; - -function toolResultText(block: Extract): string { - const parts: string[] = []; - for (const c of block.content) { - if (c.type === "text") parts.push(c.text); - else parts.push(`[unsupported ${c.type} content omitted]`); - } - return parts.join(""); -} - -// Map one internal turn to Responses items. Text-only messages keep the string -// shape grok sends; messages with image blocks switch to Responses content parts -// so the model receives the actual pixels instead of only a text placeholder. -function toResponsesItems( - turn: ConversationTurn, - requestModel: string, - requestProvider: string, -): ResponsesInputItem[] { - const items: ResponsesInputItem[] = []; - const role = turn.role; - const parts: ResponsesInputContentPart[] = []; - let hasImage = false; - // See codex-responses-adapter.ts toResponsesItems for why an unreplayed - // reasoning item suppresses the function_call(s) it produced. - let suppressOrphanedCalls = false; - - const flushMessage = (): void => { - if (parts.length === 0) return; - items.push({ - type: "message", - role, - content: hasImage - ? [...parts] - : parts.map((part) => (part.type === "input_text" ? part.text : "")).join(""), - }); - parts.length = 0; - hasImage = false; - suppressOrphanedCalls = false; - }; - - for (const block of turn.content) { - if (block.type === "text") { - parts.push({ type: "input_text", text: block.text }); - } else if (block.type === "image") { - if (block.source.kind === "base64") { - hasImage = true; - parts.push({ - type: "input_image", - image_url: `data:${block.source.mimeType};base64,${block.source.data}`, - }); - } else if (block.source.kind === "url") { - hasImage = true; - parts.push({ type: "input_image", image_url: block.source.url }); - } else { - parts.push({ - type: "input_text", - text: `[Unsupported image reference omitted: ${block.source.reference}]`, - }); - } - } else if (block.type === "tool_call") { - if (suppressOrphanedCalls) continue; - flushMessage(); - items.push({ - type: "function_call", - name: encodeToolName(block.name, RESPONSES_TOOL_NAME_LIMIT), - arguments: JSON.stringify(block.arguments ?? {}), - call_id: block.id, - }); - } else if (block.type === "tool_result") { - flushMessage(); - suppressOrphanedCalls = false; - items.push({ - type: "function_call_output", - call_id: block.callId, - output: toolResultText(block), - }); - } else if ( - block.type === "thinking" && - typeof block.signature === "string" && - block.signature.length > 0 - ) { - flushMessage(); - const encryptedContent = signatureForModel( - turn, - requestModel, - requestProvider, - block.signature, - ); - if (encryptedContent !== undefined) { - items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent }); - suppressOrphanedCalls = false; - } else { - suppressOrphanedCalls = true; - } - } - } - flushMessage(); - return items; -} - -function toResponsesTools(options: InferenceOptions): unknown[] | undefined { - if (options.tools === undefined || options.tools.length === 0) return undefined; - return options.tools.map((t) => ({ - type: "function", - name: encodeToolName(t.name, RESPONSES_TOOL_NAME_LIMIT), - description: t.description, - parameters: t.inputSchema, - })); -} - -function optionString(options: InferenceOptions, key: string): string | undefined { - const value = options.providerOptions?.[key]; - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -// Keeps the LAST occurrence of each duplicate function_call / function_call_output -// call_id, not the first: a duplicate is most often a corrected retry, and -// discarding the retry in favor of the stale original silently replays the -// wrong tool result. Both item types are covered — a duplicated function_call -// is just as invalid on the wire as a duplicated output. -function dedupeToolItems(items: ResponsesInputItem[]): ResponsesInputItem[] { - const lastIndexForCall = new Map(); - items.forEach((item, i) => { - if (item.type === "function_call" || item.type === "function_call_output") { - lastIndexForCall.set(`${item.type}:${item.call_id}`, i); - } - }); - return items.filter((item, i) => { - if (item.type === "function_call" || item.type === "function_call_output") { - return lastIndexForCall.get(`${item.type}:${item.call_id}`) === i; - } - return true; - }); -} - -function buildRequest( - messages: ConversationTurn[], - model: string, - options: InferenceOptions, - requestProvider: string, -): BuiltRequest { - const conversation = dedupeToolItems( - messages.flatMap((turn) => toResponsesItems(turn, model, requestProvider)), - ); - const systemMessage: ResponsesInputItem | undefined = - options.systemPrompt !== undefined - ? { type: "message", role: "system", content: options.systemPrompt } - : undefined; - const input = systemMessage !== undefined ? [systemMessage, ...conversation] : conversation; - const tools = toResponsesTools(options); - - const reasoning: { summary: "detailed"; effort?: string } = { summary: "detailed" }; - const effort = optionString(options, "reasoning_effort"); - if (effort !== undefined) reasoning.effort = effort; - - const body: Record = { - model, - input, - store: false, - stream: true, - include: ["reasoning.encrypted_content"], - // "detailed" streams denser summary deltas than "auto". Grok bills full - // thinking tokens but only returns summarized text; sparse auto summaries - // left the stall/activity clocks quiet for 60–120s mid-think. Effort is - // forwarded when the source set it — this adapter does not invent a default. - reasoning, - }; - if (tools !== undefined) { - body["tools"] = tools; - body["tool_choice"] = "auto"; - } - // With store:false this is the only cache-routing signal; keying it to the - // inference thread's session id keeps every request on the same cache shard. - const sessionId = optionString(options, GROK_SESSION_ID_OPTION); - if (sessionId !== undefined) body["prompt_cache_key"] = sessionId; - - const headers: Record = { - "content-type": "application/json", - accept: "text/event-stream", - authorization: BEARER_CREDENTIAL_SENTINEL, - "user-agent": XAI_USER_AGENT, - "x-grok-client-identifier": XAI_CLIENT_IDENTIFIER, - "x-grok-client-version": XAI_CLIENT_VERSION, - "x-grok-model-override": model, - }; - const userId = optionString(options, GROK_USER_ID_OPTION); - if (userId !== undefined) headers["x-grok-user-id"] = userId; - - return { url: XAI_RESPONSES_PATH, headers, body: JSON.stringify(body) }; -} - -export function createGrokResponsesAdapter(source: LastCycleSource): ProviderAdapter { - // Re-created per request in buildRequest — see codex-responses-adapter.ts. - let indexer = createResponsesBlockIndexer(); - return { - buildRequest: (messages, model, options) => { - indexer = createResponsesBlockIndexer(); - return buildRequest(messages, model, options, source.provider); - }, - parseResponse: (sseData) => parseResponse(sseData, indexer, source, GROK_RESPONSES_PROVIDER), - parseJSONResponse, - }; -} diff --git a/src/provider/inference-dependencies.ts b/src/provider/inference-dependencies.ts index 1a1a66482..491f0e2b4 100644 --- a/src/provider/inference-dependencies.ts +++ b/src/provider/inference-dependencies.ts @@ -2,17 +2,18 @@ import { createDependencies, type Dependencies, type AdapterManifest } from "@in import { loadAdapterRegistry } from "@intx/inference/providers"; import * as openaiCompatible from "./openai-compatible-adapter.js"; import * as opencodeGo from "./opencode-go-adapter.js"; -import * as codexResponses from "./codex-responses-adapter.js"; -import * as grokResponses from "./grok-responses-adapter.js"; import * as bifrostAdapter from "./bifrost-adapter.js"; -import * as openaiResponses from "./openai-responses-adapter.js"; +import * as responsesAdapters from "./responses-adapters.js"; import * as opencodeGoAnthropic from "./opencode-go-anthropic-adapter.js"; -import { CODEX_RESPONSES_PROVIDER, withCodexContentTypeRepair } from "./codex-responses-adapter.js"; -import { GROK_RESPONSES_PROVIDER } from "./grok-responses-adapter.js"; import { withReplaySanitizer } from "./replay-sanitizer.js"; import { OPENCODE_GO_PROVIDER_ID } from "../../packages/opencode-go/src/index.js"; import { BIFROST_PROVIDER } from "./bifrost-adapter.js"; -import { OPENAI_RESPONSES_PROVIDER } from "./openai-responses-adapter.js"; +import { + CODEX_RESPONSES_PROVIDER, + GROK_RESPONSES_PROVIDER, + OPENAI_RESPONSES_PROVIDER, + withCodexContentTypeRepair, +} from "./responses-adapters.js"; import { OPENCODE_GO_MESSAGES_PROVIDER } from "./opencode-go-anthropic-adapter.js"; // Corbits Code ships first-party adapters on top of the built-in provider set: @@ -32,12 +33,12 @@ const manifest: AdapterManifest = [ }, { provider: CODEX_RESPONSES_PROVIDER, - specifier: "codex-responses-adapter", + specifier: "responses-adapters", export: "createCodexResponsesAdapter", }, { provider: GROK_RESPONSES_PROVIDER, - specifier: "grok-responses-adapter", + specifier: "responses-adapters", export: "createGrokResponsesAdapter", }, { @@ -47,7 +48,7 @@ const manifest: AdapterManifest = [ }, { provider: OPENAI_RESPONSES_PROVIDER, - specifier: "openai-responses-adapter", + specifier: "responses-adapters", export: "createOpenAIResponsesAdapter", }, { @@ -60,10 +61,8 @@ const manifest: AdapterManifest = [ const localModules: Record = { "openai-compatible-adapter": openaiCompatible, "opencode-go-adapter": opencodeGo, - "codex-responses-adapter": codexResponses, - "grok-responses-adapter": grokResponses, + "responses-adapters": responsesAdapters, "bifrost-adapter": bifrostAdapter, - "openai-responses-adapter": openaiResponses, "opencode-go-anthropic-adapter": opencodeGoAnthropic, }; diff --git a/src/provider/openai-compatible-adapter.ts b/src/provider/openai-compatible-adapter.ts index 987275979..f4b07e84a 100644 --- a/src/provider/openai-compatible-adapter.ts +++ b/src/provider/openai-compatible-adapter.ts @@ -1,5 +1,6 @@ import { type BuiltRequest, type ProviderAdapter } from "@intx/inference"; import { createOpenAIAdapter } from "@intx/inference/providers"; +import { normalizeNullDeltaFields } from "./sse-delta-patch.js"; // The stock OpenAI adapter builds the request body from a fixed set of fields // (max_tokens, temperature, tools, messages, response_format) and ignores @@ -58,37 +59,9 @@ export function createOpenAICompatibleAdapter( return ensureAccept(merged); }; - // DeepSeek via NVIDIA NIM sends null for delta fields the upstream schema - // requires to be non-null (role: string, tool_calls: array). Fields that - // legitimately accept null (content, reasoning_content, etc.) are left alone. - const NULL_REJECTED_DELTA_FIELDS = new Set(["role", "tool_calls"]); const parseResponse: ProviderAdapter["parseResponse"] = (sseData: string) => { if (!needsDeepSeekPatch) return base.parseResponse(sseData); - let data = sseData; - try { - const parsed = JSON.parse(sseData) as Record; - const choices = parsed["choices"]; - if (Array.isArray(choices)) { - let patched = false; - for (const choice of choices) { - if (choice !== null && typeof choice === "object") { - const delta = (choice as Record)["delta"]; - if (delta !== null && typeof delta === "object") { - for (const key of NULL_REJECTED_DELTA_FIELDS) { - if ((delta as Record)[key] === null) { - Reflect.deleteProperty(delta as object, key); - patched = true; - } - } - } - } - } - if (patched) data = JSON.stringify(parsed); - } - } catch { - /* not JSON — pass through */ - } - return base.parseResponse(data); + return base.parseResponse(normalizeNullDeltaFields(sseData)); }; return { ...base, buildRequest, parseResponse }; diff --git a/src/provider/openai-responses-adapter.ts b/src/provider/openai-responses-adapter.ts deleted file mode 100644 index 21e29202b..000000000 --- a/src/provider/openai-responses-adapter.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { - BEARER_CREDENTIAL_SENTINEL, - encodeToolName, - type BuiltRequest, - type ProviderAdapter, -} from "@intx/inference"; -import type { - ContentBlock, - ConversationTurn, - InferenceOptions, - LastCycleSource, -} from "@intx/types/runtime"; -import { - RESPONSES_TOOL_NAME_LIMIT, - createResponsesBlockIndexer, - isResponsesStreamTerminal, - parseJSONResponse, - parseResponse, - signatureForModel, -} from "./codex-responses-adapter.js"; - -// Generic OpenAI Responses API adapter (POST /responses). Used by OpenCode Go -// models that speak Responses rather than Chat Completions (e.g. gpt-5.6-luna). -// Shares the Codex/Grok SSE parser; only the request shape and path differ from -// Chat Completions and from the Grok-specific header set. - -export const OPENAI_RESPONSES_PROVIDER = "openai-responses"; - -// Key the source stashes in defaults.providerOptions for this adapter. -export const OPENAI_SESSION_ID_OPTION = "openaiSessionId"; -export const OPENCODE_SESSION_ID_OPTION = "opencodeSessionId"; - -type ResponsesInputContentPart = - { type: "input_text"; text: string } | { type: "input_image"; image_url: string }; - -type ResponsesInputItem = - | { - type: "message"; - role: "user" | "assistant" | "system"; - content: string | ResponsesInputContentPart[]; - } - | { type: "function_call"; name: string; arguments: string; call_id: string } - | { type: "function_call_output"; call_id: string; output: string } - | { type: "reasoning"; summary: never[]; encrypted_content: string }; - -function toolResultText(block: Extract): string { - const parts: string[] = []; - for (const c of block.content) { - if (c.type === "text") parts.push(c.text); - else parts.push(`[unsupported ${c.type} content omitted]`); - } - return parts.join(""); -} - -function toResponsesItems( - turn: ConversationTurn, - requestModel: string, - requestProvider: string, -): ResponsesInputItem[] { - const items: ResponsesInputItem[] = []; - const role = turn.role; - const parts: ResponsesInputContentPart[] = []; - let hasImage = false; - // See codex-responses-adapter.ts toResponsesItems for why an unreplayed - // reasoning item suppresses the function_call(s) it produced. - let suppressOrphanedCalls = false; - - const flushMessage = (): void => { - if (parts.length === 0) return; - items.push({ - type: "message", - role, - content: hasImage - ? [...parts] - : parts.map((part) => (part.type === "input_text" ? part.text : "")).join(""), - }); - parts.length = 0; - hasImage = false; - suppressOrphanedCalls = false; - }; - - for (const block of turn.content) { - if (block.type === "text") { - parts.push({ type: "input_text", text: block.text }); - } else if (block.type === "image") { - if (block.source.kind === "base64") { - hasImage = true; - parts.push({ - type: "input_image", - image_url: `data:${block.source.mimeType};base64,${block.source.data}`, - }); - } else if (block.source.kind === "url") { - hasImage = true; - parts.push({ type: "input_image", image_url: block.source.url }); - } else { - parts.push({ - type: "input_text", - text: `[Unsupported image reference omitted: ${block.source.reference}]`, - }); - } - } else if (block.type === "tool_call") { - if (suppressOrphanedCalls) continue; - flushMessage(); - items.push({ - type: "function_call", - name: encodeToolName(block.name, RESPONSES_TOOL_NAME_LIMIT), - arguments: JSON.stringify(block.arguments ?? {}), - call_id: block.id, - }); - } else if (block.type === "tool_result") { - flushMessage(); - suppressOrphanedCalls = false; - items.push({ - type: "function_call_output", - call_id: block.callId, - output: toolResultText(block), - }); - } else if ( - block.type === "thinking" && - typeof block.signature === "string" && - block.signature.length > 0 - ) { - flushMessage(); - const encryptedContent = signatureForModel( - turn, - requestModel, - requestProvider, - block.signature, - ); - if (encryptedContent !== undefined) { - items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent }); - suppressOrphanedCalls = false; - } else { - suppressOrphanedCalls = true; - } - } - } - flushMessage(); - return items; -} - -function toResponsesTools(options: InferenceOptions): unknown[] | undefined { - if (options.tools === undefined || options.tools.length === 0) return undefined; - return options.tools.map((t) => ({ - type: "function", - name: encodeToolName(t.name, RESPONSES_TOOL_NAME_LIMIT), - description: t.description, - parameters: t.inputSchema, - })); -} - -export function optionString(options: InferenceOptions, key: string): string | undefined { - const value = options.providerOptions?.[key]; - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -// Keeps the LAST occurrence of each duplicate function_call / function_call_output -// call_id, not the first: a duplicate is most often a corrected retry, and -// discarding the retry in favor of the stale original silently replays the -// wrong tool result. Both item types are covered — a duplicated function_call -// is just as invalid on the wire as a duplicated output. -function dedupeToolItems(items: ResponsesInputItem[]): ResponsesInputItem[] { - const lastIndexForCall = new Map(); - items.forEach((item, i) => { - if (item.type === "function_call" || item.type === "function_call_output") { - lastIndexForCall.set(`${item.type}:${item.call_id}`, i); - } - }); - return items.filter((item, i) => { - if (item.type === "function_call" || item.type === "function_call_output") { - return lastIndexForCall.get(`${item.type}:${item.call_id}`) === i; - } - return true; - }); -} - -function buildRequest( - messages: ConversationTurn[], - model: string, - options: InferenceOptions, - requestProvider: string, -): BuiltRequest { - const conversation = dedupeToolItems( - messages.flatMap((turn) => toResponsesItems(turn, model, requestProvider)), - ); - const systemMessage: ResponsesInputItem | undefined = - options.systemPrompt !== undefined - ? { type: "message", role: "system", content: options.systemPrompt } - : undefined; - const input = systemMessage !== undefined ? [systemMessage, ...conversation] : conversation; - const tools = toResponsesTools(options); - - const body: Record = { - model, - input, - store: false, - stream: true, - include: ["reasoning.encrypted_content"], - reasoning: { summary: "auto" }, - }; - if (tools !== undefined) { - body["tools"] = tools; - body["tool_choice"] = "auto"; - } - if (options.maxTokens !== undefined) body["max_output_tokens"] = options.maxTokens; - if (options.temperature !== undefined) body["temperature"] = options.temperature; - // With store:false this is the only cache-routing signal; keying it to the - // inference thread's session id keeps every request on the same cache shard. - const sessionId = optionString(options, OPENAI_SESSION_ID_OPTION); - if (sessionId !== undefined) body["prompt_cache_key"] = sessionId; - const opencodeSessionId = optionString(options, OPENCODE_SESSION_ID_OPTION); - - return { - url: "/responses", - headers: { - "content-type": "application/json", - accept: "text/event-stream", - authorization: BEARER_CREDENTIAL_SENTINEL, - ...(opencodeSessionId !== undefined ? { "x-opencode-session": opencodeSessionId } : {}), - }, - body: JSON.stringify(body), - }; -} - -export function createOpenAIResponsesAdapter(source: LastCycleSource): ProviderAdapter { - // Re-created per request in buildRequest — see codex-responses-adapter.ts. - let indexer = createResponsesBlockIndexer(); - return { - buildRequest: (messages, model, options) => { - indexer = createResponsesBlockIndexer(); - return buildRequest(messages, model, options, source.provider); - }, - parseResponse: (sseData) => parseResponse(sseData, indexer, source, OPENAI_RESPONSES_PROVIDER), - parseJSONResponse, - isStreamTerminal: isResponsesStreamTerminal, - }; -} diff --git a/src/provider/opencode-go-adapter.ts b/src/provider/opencode-go-adapter.ts index 003a8fccc..afbdbee97 100644 --- a/src/provider/opencode-go-adapter.ts +++ b/src/provider/opencode-go-adapter.ts @@ -1,39 +1,10 @@ import type { BuiltRequest, ProviderAdapter } from "@intx/inference"; import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js"; -import { OPENCODE_SESSION_ID_OPTION, optionString } from "./openai-responses-adapter.js"; +import { OPENCODE_SESSION_ID_OPTION, optionString } from "./responses-adapters.js"; +import { normalizeNullDeltaFields } from "./sse-delta-patch.js"; type AdapterSource = Parameters[0]; -const NULL_DELTA_FIELDS = ["role", "tool_calls"] as const; - -function normalizeNullDeltaFields(sseData: string): string { - let parsed: unknown; - try { - parsed = JSON.parse(sseData); - } catch { - return sseData; - } - if (parsed === null || typeof parsed !== "object") return sseData; - - const choices = (parsed as Record)["choices"]; - if (!Array.isArray(choices)) return sseData; - - let normalized = false; - for (const choice of choices) { - if (choice === null || typeof choice !== "object") continue; - const delta = (choice as Record)["delta"]; - if (delta === null || typeof delta !== "object") continue; - for (const field of NULL_DELTA_FIELDS) { - if ((delta as Record)[field] === null) { - Reflect.deleteProperty(delta, field); - normalized = true; - } - } - } - - return normalized ? JSON.stringify(parsed) : sseData; -} - export function createOpenCodeGoAdapter(source: AdapterSource, quirks?: unknown): ProviderAdapter { const base = createOpenAICompatibleAdapter(source, quirks); const buildRequest: ProviderAdapter["buildRequest"] = (messages, model, options) => { diff --git a/src/provider/opencode-go-anthropic-adapter.ts b/src/provider/opencode-go-anthropic-adapter.ts index 186cc6c90..16d32fd00 100644 --- a/src/provider/opencode-go-anthropic-adapter.ts +++ b/src/provider/opencode-go-anthropic-adapter.ts @@ -1,6 +1,6 @@ import { type BuiltRequest, type ProviderAdapter } from "@intx/inference"; import { createAnthropicAdapter } from "@intx/inference/providers"; -import { OPENCODE_SESSION_ID_OPTION, optionString } from "./openai-responses-adapter.js"; +import { OPENCODE_SESSION_ID_OPTION, optionString } from "./responses-adapters.js"; export const OPENCODE_GO_MESSAGES_PROVIDER = "opencode-go-messages"; diff --git a/src/provider/opencode-go-models.ts b/src/provider/opencode-go-models.ts index 97e826e66..0e87d03b0 100644 --- a/src/provider/opencode-go-models.ts +++ b/src/provider/opencode-go-models.ts @@ -5,6 +5,7 @@ import { OPENCODE_GO_MODEL_IDS, } from "../../packages/opencode-go/src/index.js"; import { requestModelsEndpoint } from "./models-endpoint.js"; +import { readCappedBody } from "../util/capped-body.js"; const GoModelsResponse = type({ data: type({ id: "string" }).array(), @@ -49,48 +50,19 @@ async function readCatalogJson( return { ok: false, message: oversizeMessage("bytes") }; } - const body = response.body; - if (body === null) { - try { - const text = await response.text(); - if (new TextEncoder().encode(text).byteLength > MAX_GO_CATALOG_BYTES) { - return { ok: false, message: oversizeMessage("bytes") }; - } - const value: unknown = JSON.parse(text); - return { ok: true, value }; - } catch (error) { - return { ok: false, message: error instanceof Error ? error.message : String(error) }; - } - } - - const reader = body.getReader(); - const chunks: Uint8Array[] = []; - let total = 0; + let text: string; + let truncated: boolean; try { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - if (value === undefined) continue; - total += value.byteLength; - if (total > MAX_GO_CATALOG_BYTES) { - await reader.cancel().catch(() => undefined); - return { ok: false, message: oversizeMessage("bytes") }; - } - chunks.push(value); - } + ({ text, truncated } = await readCappedBody(response, MAX_GO_CATALOG_BYTES)); } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) }; } - - const buffer = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - buffer.set(chunk, offset); - offset += chunk.byteLength; + if (truncated) { + return { ok: false, message: oversizeMessage("bytes") }; } try { - const value: unknown = JSON.parse(new TextDecoder().decode(buffer)); + const value: unknown = JSON.parse(text); return { ok: true, value }; } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) }; diff --git a/src/provider/replay-sanitizer.test.ts b/src/provider/replay-sanitizer.test.ts index 0d3f13aef..271f590a2 100644 --- a/src/provider/replay-sanitizer.test.ts +++ b/src/provider/replay-sanitizer.test.ts @@ -6,8 +6,8 @@ import { CODEX_RESPONSES_PROVIDER, createCodexResponsesAdapter, tagSignature, -} from "./codex-responses-adapter.js"; -import { createGrokResponsesAdapter } from "./grok-responses-adapter.js"; +} from "./responses-adapters.js"; +import { createGrokResponsesAdapter } from "./responses-adapters.js"; import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js"; import { sanitizeReplayTurns, diff --git a/src/provider/codex-responses-adapter.ts b/src/provider/responses-adapters.ts similarity index 62% rename from src/provider/codex-responses-adapter.ts rename to src/provider/responses-adapters.ts index 1c5f40aaa..07c1ad48e 100644 --- a/src/provider/codex-responses-adapter.ts +++ b/src/provider/responses-adapters.ts @@ -16,35 +16,60 @@ import type { PartialMessage, TokenUsage, } from "@intx/types/runtime"; -import { CODEX_RESPONSES_PATH, CODEX_AUTHORIZE_EXTRA_PARAMS } from "../auth/codex/constants.js"; - -// Adapter for the OpenAI Responses API as served by the Codex backend -// (chatgpt.com/backend-api/codex/responses). The Codex backend does NOT speak -// Chat Completions: requests use Responses `input` items + flat tools, and the -// stream is the Responses SSE event protocol. Registered under the provider id -// "codex-responses"; sources for `codex/` providers are built with -// that id so the harness routes them here instead of the OpenAI adapter. -// -// Credentials and the chatgpt-account-id ride through differently: the access -// token is injected by the harness via the bearer sentinel, while the account -// id and session id travel in `source.defaults.providerOptions` (merged into -// InferenceOptions.providerOptions by the harness). Account id is headers-only -// (`chatgpt-account-id`). Session id is the `session_id` header and -// `prompt_cache_key` in the body — the only cache-routing signal under -// `store: false`. -// -// Continuity is not Responses store chaining. The ChatGPT Codex backend -// requires `store: false` (`store: true` → 400) and rejects -// `previous_response_id`. Every turn resends the full `input`; encrypted -// reasoning captured from the prior stream is resent as a `reasoning` item. +import { CODEX_AUTHORIZE_EXTRA_PARAMS, CODEX_RESPONSES_PATH } from "../auth/codex/constants.js"; +import { + XAI_CLIENT_IDENTIFIER, + XAI_CLIENT_VERSION, + XAI_RESPONSES_PATH, + XAI_USER_AGENT, +} from "../auth/xai/constants.js"; + +// Adapters for the OpenAI Responses API (POST /responses) as served by three +// backends that do NOT speak Chat Completions: +// - codex-responses: the ChatGPT Codex backend +// (chatgpt.com/backend-api/codex/responses). Credentials and the +// chatgpt-account-id ride through differently: the access token is +// injected by the harness via the bearer sentinel, while the account id +// and session id travel in `source.defaults.providerOptions`. Continuity +// is not Responses store chaining: the backend requires `store: false` +// (`store: true` → 400) and rejects `previous_response_id`. Every turn +// resends the full `input`; encrypted reasoning captured from the prior +// stream is resent as a `reasoning` item. +// - grok-responses: the grok-cli OAuth proxy (cli-chat-proxy.grok.com). The +// request shape mirrors the grok CLI's own /v1/responses call (captured +// live): the system prompt rides as a leading `system` input message +// (string content, not parts), reasoning is requested by summary, and the +// caller is identified by x-grok-* headers rather than a body field. +// - openai-responses: generic OpenAI Responses endpoints, used by OpenCode Go +// models that speak Responses rather than Chat Completions (e.g. +// gpt-5.6-luna). +// All three share the same SSE parser and one request mapper parameterized by +// a per-backend spec (endpoint, headers, reasoning/sampling configuration, and +// the two wire conventions for message content — see ResponsesMessageShape). export const CODEX_RESPONSES_PROVIDER = "codex-responses"; +export const GROK_RESPONSES_PROVIDER = "grok-responses"; +export const OPENAI_RESPONSES_PROVIDER = "openai-responses"; -// Keys the source stashes in defaults.providerOptions for this adapter. +// Keys the sources stash in defaults.providerOptions for these adapters. export const CODEX_ACCOUNT_ID_OPTION = "codexAccountId"; export const CODEX_SESSION_ID_OPTION = "codexSessionId"; +export const GROK_USER_ID_OPTION = "grokUserId"; +export const GROK_SESSION_ID_OPTION = "grokSessionId"; +export const OPENAI_SESSION_ID_OPTION = "openaiSessionId"; +export const OPENCODE_SESSION_ID_OPTION = "opencodeSessionId"; -const EMPTY_PARTIAL: PartialMessage = { text: "" }; +// Wire-charset limit for function names on the Responses surface (Codex, +// Grok, and the generic OpenAI Responses adapter all share OpenAI's +// `^[a-zA-Z0-9_-]{1,64}$` function-name charset). +export const RESPONSES_TOOL_NAME_LIMIT: ToolNameLimit = { + provider: "responses", + maxLength: 64, +}; + +// --------------------------------------------------------------------------- +// Fetch boundary: Codex Content-Type repair +// --------------------------------------------------------------------------- type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise; @@ -110,24 +135,9 @@ export function withCodexContentTypeRepair(fetchImpl: FetchLike): FetchLike { } // --------------------------------------------------------------------------- -// Request building — internal turns → Responses `input` items +// Reasoning-signature tagging (multi-turn continuity) // --------------------------------------------------------------------------- -type ResponsesContentPart = - | { type: "input_text"; text: string } - | { type: "output_text"; text: string } - | { type: "input_image"; image_url: string }; - -type ResponsesInputItem = - | { - type: "message"; - role: "user" | "assistant" | "system" | "developer"; - content: ResponsesContentPart[]; - } - | { type: "function_call"; name: string; arguments: string; call_id: string } - | { type: "function_call_output"; call_id: string; output: string } - | { type: "reasoning"; summary: never[]; encrypted_content: string }; - // A thinking block's `signature` is opaque ciphertext a specific backend // issued for a specific model; only that backend can decrypt it. `model` is // arbitrary catalog/user-supplied text — nothing stops two distinct backends @@ -175,31 +185,108 @@ export function signatureForModel( return tagged.provider === requestProvider ? tagged.encryptedContent : undefined; } +// --------------------------------------------------------------------------- +// Request building — internal turns → Responses `input` items +// --------------------------------------------------------------------------- + +type ResponsesContentPart = + | { type: "input_text"; text: string } + | { type: "output_text"; text: string } + | { type: "input_image"; image_url: string }; + +type ResponsesInputItem = + | { + type: "message"; + role: "user" | "assistant" | "system" | "developer"; + content: string | ResponsesContentPart[]; + } + | { type: "function_call"; name: string; arguments: string; call_id: string } + | { type: "function_call_output"; call_id: string; output: string } + | { type: "reasoning"; summary: never[]; encrypted_content: string }; + +// Tool results carry a content array; the Responses API wants a string. Join +// the text parts; non-text content (images, etc.) is not representable here and +// is dropped with a marker so the model is not misled into thinking it is +// missing silently. +function toolResultText(block: Extract): string { + const parts: string[] = []; + for (const c of block.content) { + if (c.type === "text") parts.push(c.text); + else parts.push(`[unsupported ${c.type} content omitted]`); + } + return parts.join(""); +} + +function toResponsesTools(options: InferenceOptions): unknown[] | undefined { + if (options.tools === undefined || options.tools.length === 0) return undefined; + // Responses function tools are FLAT — name/description/parameters sit beside + // `type`, not nested under a `function` key (unlike Chat Completions). + return options.tools.map((t) => ({ + type: "function", + name: encodeToolName(t.name, RESPONSES_TOOL_NAME_LIMIT), + description: t.description, + parameters: t.inputSchema, + })); +} + +export function optionString(options: InferenceOptions, key: string): string | undefined { + const value = options.providerOptions?.[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +// Keeps the LAST occurrence of each duplicate function_call / function_call_output +// call_id, not the first: a duplicate is most often a corrected retry, and +// discarding the retry in favor of the stale original silently replays the +// wrong tool result. Both item types are covered — a duplicated function_call +// is just as invalid on the wire as a duplicated output. +function dedupeToolItems(items: ResponsesInputItem[]): ResponsesInputItem[] { + const lastIndexForCall = new Map(); + items.forEach((item, i) => { + if (item.type === "function_call" || item.type === "function_call_output") { + lastIndexForCall.set(`${item.type}:${item.call_id}`, i); + } + }); + return items.filter((item, i) => { + if (item.type === "function_call" || item.type === "function_call_output") { + return lastIndexForCall.get(`${item.type}:${item.call_id}`) === i; + } + return true; + }); +} + +// The two wire conventions for message content across the Responses backends: +// - "codex": content is always an array of parts, and assistant text uses +// `output_text` parts (the ChatGPT backend's shape). +// - "grok": text-only messages serialize content as a plain string (the +// shape the grok proxy emits); text is always `input_text`. +type ResponsesMessageShape = "codex" | "grok"; + // Map one internal turn to zero or more Responses items. Assistant text uses -// `output_text` parts; user/system text uses `input_text`. Tool calls become +// `output_text` parts on the codex shape and `input_text` everywhere else; +// the grok shape keeps text-only messages as a string. Tool calls become // `function_call` items (arguments serialized to a JSON string) and tool // results become `function_call_output` items. Reasoning blocks are echoed // back only when they carry the opaque `encrypted_content` the backend issued // (held in a thinking block's signature) AND that backend is the one this // request is going to — replaying it to a different provider gets a 400 it // cannot recover from. -// Wire-charset limit for function names on the Responses surface (Codex, -// Grok, and the generic OpenAI Responses adapter all share OpenAI's -// `^[a-zA-Z0-9_-]{1,64}$` function-name charset). -export const RESPONSES_TOOL_NAME_LIMIT: ToolNameLimit = { - provider: "responses", - maxLength: 64, -}; - function toResponsesItems( turn: ConversationTurn, requestModel: string, requestProvider: string, + shape: ResponsesMessageShape, ): ResponsesInputItem[] { const items: ResponsesInputItem[] = []; + const role = turn.role; + const parts: ResponsesContentPart[] = []; + // The grok shape flattens text-only messages to a string; a message with + // image blocks switches to content parts so the model receives the actual + // pixels instead of only a text placeholder. + let hasImage = false; + // Codex's ChatGPT backend distinguishes assistant output_text from input_text; + // the grok shape uses input_text for every role. const textKind: "input_text" | "output_text" = - turn.role === "assistant" ? "output_text" : "input_text"; - const textParts: ResponsesContentPart[] = []; + shape === "codex" && role === "assistant" ? "output_text" : "input_text"; // A reasoning block whose signature we could not replay (foreign provider, // model switch, or a missing/untagged signature) leaves any function_call // it produced without the reasoning item the Responses API expects to @@ -209,34 +296,43 @@ function toResponsesItems( // unaffected since they never need a preceding reasoning item. let suppressOrphanedCalls = false; - const flushText = (): void => { - if (textParts.length > 0) { - items.push({ type: "message", role: turn.role, content: [...textParts] }); - textParts.length = 0; - suppressOrphanedCalls = false; - } + const flushMessage = (): void => { + if (parts.length === 0) return; + items.push({ + type: "message", + role, + content: + shape === "grok" && !hasImage + ? parts.map((part) => (part.type === "input_text" ? part.text : "")).join("") + : [...parts], + }); + parts.length = 0; + hasImage = false; + suppressOrphanedCalls = false; }; for (const block of turn.content) { if (block.type === "text") { - textParts.push({ type: textKind, text: block.text } as ResponsesContentPart); + parts.push({ type: textKind, text: block.text } as ResponsesContentPart); } else if (block.type === "image") { if (block.source.kind === "base64") { - textParts.push({ + hasImage = true; + parts.push({ type: "input_image", image_url: `data:${block.source.mimeType};base64,${block.source.data}`, }); } else if (block.source.kind === "url") { - textParts.push({ type: "input_image", image_url: block.source.url }); + hasImage = true; + parts.push({ type: "input_image", image_url: block.source.url }); } else { - textParts.push({ + parts.push({ type: textKind, text: `[Unsupported image reference omitted: ${block.source.reference}]`, } as ResponsesContentPart); } } else if (block.type === "tool_call") { if (suppressOrphanedCalls) continue; - flushText(); + flushMessage(); items.push({ type: "function_call", name: encodeToolName(block.name, RESPONSES_TOOL_NAME_LIMIT), @@ -244,7 +340,7 @@ function toResponsesItems( call_id: block.id, }); } else if (block.type === "tool_result") { - flushText(); + flushMessage(); suppressOrphanedCalls = false; items.push({ type: "function_call_output", @@ -256,7 +352,7 @@ function toResponsesItems( typeof block.signature === "string" && block.signature.length > 0 ) { - flushText(); + flushMessage(); const encryptedContent = signatureForModel( turn, requestModel, @@ -271,97 +367,230 @@ function toResponsesItems( } } } - flushText(); + flushMessage(); return items; } -// Tool results carry a content array; the Responses API wants a string. Join -// the text parts; non-text content (images, etc.) is not representable here and -// is dropped with a marker so the model is not misled into thinking it is -// missing silently. -function toolResultText(block: Extract): string { - const parts: string[] = []; - for (const c of block.content) { - if (c.type === "text") parts.push(c.text); - else parts.push(`[unsupported ${c.type} content omitted]`); - } - return parts.join(""); +// Everything that differs between the Responses backends this module adapts. +interface ResponsesRequestSpec { + /** Endpoint path for the Responses API. */ + url: string; + /** providerOptions key carrying the inference thread's session id. */ + sessionIdOptionKey: string; + /** + * Reasoning summary mode. "detailed" streams denser summary deltas than + * "auto": Grok bills full thinking tokens but only returns summarized text; + * sparse auto summaries left the stall/activity clocks quiet for 60–120s + * mid-think. Absent for Codex: the ChatGPT backend rejects summary values + * for the gpt-5.6-terra / gpt-5.3-codex family (HTTP 400; supported: + * concise | detailed | none) and the Codex CLI catalog default is none, so + * Codex request bodies send effort only (CL-6893). + */ + reasoningSummary?: "auto" | "detailed"; + /** Extract the reasoning.effort value from options, or undefined to omit it. */ + effort: (options: InferenceOptions) => string | undefined; + /** Forward maxTokens/temperature into the request body. */ + forwardSamplingParams: boolean; + /** Codex sends the explicit `parallel_tool_calls: false` (backend rejects true). */ + parallelToolCalls: boolean; + /** + * Where the system prompt rides: Codex carries it in the `instructions` + * body field; the grok shape prepends a leading system input message. + */ + systemPromptIn: "instructions" | "input"; + /** Keep only the last duplicate tool item; Codex keeps every item verbatim. */ + dedupeToolItems: boolean; + /** Which message-content wire convention to emit. */ + messageShape: ResponsesMessageShape; + /** Provider-specific headers on top of the base content-type/accept/auth set. */ + extraHeaders: (options: InferenceOptions, model: string) => Record; } -function toResponsesTools(options: InferenceOptions): unknown[] | undefined { - if (options.tools === undefined || options.tools.length === 0) return undefined; - // Responses function tools are FLAT — name/description/parameters sit beside - // `type`, not nested under a `function` key (unlike Chat Completions). - return options.tools.map((t) => ({ - type: "function", - name: encodeToolName(t.name, RESPONSES_TOOL_NAME_LIMIT), - description: t.description, - parameters: t.inputSchema, - })); -} - -function optionString(options: InferenceOptions, key: string): string | undefined { - const value = options.providerOptions?.[key]; - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function buildRequest( +function buildResponsesRequest( messages: ConversationTurn[], model: string, options: InferenceOptions, requestProvider: string, + spec: ResponsesRequestSpec, ): BuiltRequest { - const input = messages.flatMap((turn) => toResponsesItems(turn, model, requestProvider)); + const mapped = messages.flatMap((turn) => + toResponsesItems(turn, model, requestProvider, spec.messageShape), + ); + const conversation = spec.dedupeToolItems ? dedupeToolItems(mapped) : mapped; + const systemMessage: ResponsesInputItem | undefined = + spec.systemPromptIn === "input" && options.systemPrompt !== undefined + ? { type: "message", role: "system", content: options.systemPrompt } + : undefined; + const input = systemMessage !== undefined ? [systemMessage, ...conversation] : conversation; const tools = toResponsesTools(options); - const accountId = optionString(options, CODEX_ACCOUNT_ID_OPTION); - const sessionId = optionString(options, CODEX_SESSION_ID_OPTION); const body: Record = { model, input, - // The Codex ChatGPT backend requires `store: false` (store:true → 400) and - // rejects `previous_response_id` as an unsupported parameter. Multi-turn - // continuity is full input plus encrypted reasoning round-trip only — do - // not attempt response-id chaining on this surface. + // Every backend requires `store: false` (Codex: store:true → 400) and + // rejects `previous_response_id`. Multi-turn continuity is full input plus + // encrypted reasoning round-trip only — do not attempt response-id + // chaining on this surface. store: false, stream: true, include: ["reasoning.encrypted_content"], + }; + if (spec.parallelToolCalls) { // Serial at the request layer. The reactor already executes a multi-call // batch concurrently; this flag is what the ChatGPT Codex backend is sent. // Do not flip without verifying the backend accepts true — unlike store / // previous_response_id there is no recorded 400. - parallel_tool_calls: false, - }; - if (options.systemPrompt !== undefined) { + body["parallel_tool_calls"] = false; + } + if (spec.systemPromptIn === "instructions" && options.systemPrompt !== undefined) { + // The Codex backend rejects `max_output_tokens`; it is intentionally + // omitted. `instructions` is exactly the supplied system prompt + // (including an empty string), omitted when unset. body["instructions"] = options.systemPrompt; } - // The Codex backend rejects `max_output_tokens`; it is intentionally omitted. + const reasoning: Record = {}; + if (spec.reasoningSummary !== undefined) reasoning["summary"] = spec.reasoningSummary; + // reasoning_effort rides in providerOptions (same place the OpenAI-compatible + // path reads it); the adapter does not invent a default. + const effort = spec.effort(options); + if (effort !== undefined) reasoning["effort"] = effort; + if (Object.keys(reasoning).length > 0) body["reasoning"] = reasoning; if (tools !== undefined) { body["tools"] = tools; body["tool_choice"] = "auto"; } - // reasoning_effort rides in providerOptions (same place the OpenAI-compatible - // path reads it); map it onto the Responses `reasoning.effort` field. - // ChatGPT Codex rejects summary:"auto" for gpt-5.6-terra / gpt-5.3-codex - // family (HTTP 400; supported: concise | detailed | none). Codex CLI catalog - // default_reasoning_summary is none — send effort only (CL-6893). - const effort = options.providerOptions?.["reasoning_effort"]; - if (typeof effort === "string" && effort !== "none") { - body["reasoning"] = { effort }; + if (spec.forwardSamplingParams) { + if (options.maxTokens !== undefined) body["max_output_tokens"] = options.maxTokens; + if (options.temperature !== undefined) body["temperature"] = options.temperature; } + // With store:false this is the only cache-routing signal; keying it to the + // inference thread's session id keeps every request on the same cache shard. + const sessionId = optionString(options, spec.sessionIdOptionKey); if (sessionId !== undefined) body["prompt_cache_key"] = sessionId; - const headers: Record = { - "content-type": "application/json", - accept: "text/event-stream", - authorization: BEARER_CREDENTIAL_SENTINEL, - "openai-beta": "responses=experimental", - originator: CODEX_AUTHORIZE_EXTRA_PARAMS["originator"] ?? "codex_cli_rs", + return { + url: spec.url, + headers: { + "content-type": "application/json", + accept: "text/event-stream", + authorization: BEARER_CREDENTIAL_SENTINEL, + ...spec.extraHeaders(options, model), + }, + body: JSON.stringify(body), + }; +} + +// The Codex backend identifies the caller by the bearer token plus the +// chatgpt-account-id header; the account id rides in providerOptions because +// the harness injects the token separately. +const CODEX_SPEC: ResponsesRequestSpec = { + url: CODEX_RESPONSES_PATH, + sessionIdOptionKey: CODEX_SESSION_ID_OPTION, + // CL-6893: no summary for the ChatGPT backend (see the field doc). + effort: (options) => { + // "none" is the Codex CLI catalog default for reasoning_effort — sending + // it is a no-op at best; skip it. + const value = options.providerOptions?.["reasoning_effort"]; + return typeof value === "string" && value !== "none" ? value : undefined; + }, + forwardSamplingParams: false, + parallelToolCalls: true, + systemPromptIn: "instructions", + dedupeToolItems: false, + messageShape: "codex", + extraHeaders: (options) => { + const headers: Record = { + "openai-beta": "responses=experimental", + originator: CODEX_AUTHORIZE_EXTRA_PARAMS["originator"] ?? "codex_cli_rs", + }; + const accountId = optionString(options, CODEX_ACCOUNT_ID_OPTION); + if (accountId !== undefined) headers["chatgpt-account-id"] = accountId; + const sessionId = optionString(options, CODEX_SESSION_ID_OPTION); + if (sessionId !== undefined) headers["session_id"] = sessionId; + return headers; + }, +}; + +// The grok proxy identifies the caller by client headers in addition to the +// bearer token; values mirror the grok CLI's own /v1/responses call. +const GROK_SPEC: ResponsesRequestSpec = { + url: XAI_RESPONSES_PATH, + sessionIdOptionKey: GROK_SESSION_ID_OPTION, + reasoningSummary: "detailed", + effort: (options) => optionString(options, "reasoning_effort"), + forwardSamplingParams: false, + parallelToolCalls: false, + systemPromptIn: "input", + dedupeToolItems: true, + messageShape: "grok", + extraHeaders: (options, model) => { + const headers: Record = { + "user-agent": XAI_USER_AGENT, + "x-grok-client-identifier": XAI_CLIENT_IDENTIFIER, + "x-grok-client-version": XAI_CLIENT_VERSION, + "x-grok-model-override": model, + }; + const userId = optionString(options, GROK_USER_ID_OPTION); + if (userId !== undefined) headers["x-grok-user-id"] = userId; + return headers; + }, +}; + +const OPENAI_SPEC: ResponsesRequestSpec = { + url: "/responses", + sessionIdOptionKey: OPENAI_SESSION_ID_OPTION, + reasoningSummary: "auto", + effort: (options) => optionString(options, "reasoning_effort"), + forwardSamplingParams: true, + parallelToolCalls: false, + systemPromptIn: "input", + dedupeToolItems: true, + messageShape: "grok", + extraHeaders: (options) => { + const opencodeSessionId = optionString(options, OPENCODE_SESSION_ID_OPTION); + return opencodeSessionId !== undefined ? { "x-opencode-session": opencodeSessionId } : {}; + }, +}; + +export function createCodexResponsesAdapter(source: LastCycleSource): ProviderAdapter { + // Re-created per request in buildRequest — see parseResponse below. + let indexer = createResponsesBlockIndexer(); + return { + buildRequest: (messages, model, options) => { + indexer = createResponsesBlockIndexer(); + return buildResponsesRequest(messages, model, options, source.provider, CODEX_SPEC); + }, + parseResponse: (sseData) => parseResponse(sseData, indexer, source), + parseJSONResponse, + isStreamTerminal: isResponsesStreamTerminal, }; - if (accountId !== undefined) headers["chatgpt-account-id"] = accountId; - if (sessionId !== undefined) headers["session_id"] = sessionId; +} - return { url: CODEX_RESPONSES_PATH, headers, body: JSON.stringify(body) }; +export function createGrokResponsesAdapter(source: LastCycleSource): ProviderAdapter { + // Re-created per request in buildRequest — see parseResponse below. + let indexer = createResponsesBlockIndexer(); + return { + buildRequest: (messages, model, options) => { + indexer = createResponsesBlockIndexer(); + return buildResponsesRequest(messages, model, options, source.provider, GROK_SPEC); + }, + parseResponse: (sseData) => parseResponse(sseData, indexer, source, GROK_RESPONSES_PROVIDER), + parseJSONResponse, + }; +} + +export function createOpenAIResponsesAdapter(source: LastCycleSource): ProviderAdapter { + // Re-created per request in buildRequest — see parseResponse below. + let indexer = createResponsesBlockIndexer(); + return { + buildRequest: (messages, model, options) => { + indexer = createResponsesBlockIndexer(); + return buildResponsesRequest(messages, model, options, source.provider, OPENAI_SPEC); + }, + parseResponse: (sseData) => parseResponse(sseData, indexer, source, OPENAI_RESPONSES_PROVIDER), + parseJSONResponse, + isStreamTerminal: isResponsesStreamTerminal, + }; } // --------------------------------------------------------------------------- @@ -381,8 +610,8 @@ export interface CodexBlockIndexer { items: Map; } -// Both the Codex and grok backends speak the same Responses SSE protocol, so -// the parser is shared. Each adapter creates its own indexer per request. +// All three backends speak the same Responses SSE protocol, so the parser is +// shared. Each adapter creates its own indexer per request. export function createResponsesBlockIndexer(): CodexBlockIndexer { return { nextIndex: 0, items: new Map() }; } @@ -396,6 +625,8 @@ function blockIndexFor(state: CodexBlockIndexer, itemId: string, kind: CodexBloc return index; } +const EMPTY_PARTIAL: PartialMessage = { text: "" }; + function usageFromResponse(response: Record): TokenUsage | undefined { const usage = response["usage"]; if (typeof usage !== "object" || usage === null) return undefined; @@ -611,8 +842,8 @@ const RESPONSES_TERMINAL_EVENTS = new Set([ ]); // The Responses adapters in this file always request `stream: true` -// (buildRequest sets it unconditionally), so a non-streaming JSON body -// reaching the harness means the response kind was misdetected or the +// (buildResponsesRequest sets it unconditionally), so a non-streaming JSON +// body reaching the harness means the response kind was misdetected or the // provider ignored the streaming request — a protocol violation, not a // supported code path to parse. export function parseJSONResponse(): never { @@ -634,19 +865,3 @@ export function isResponsesStreamTerminal(sseData: string): boolean { const eventType = (parsed as Record)["type"]; return typeof eventType === "string" && RESPONSES_TERMINAL_EVENTS.has(eventType); } - -export function createCodexResponsesAdapter(source: LastCycleSource): ProviderAdapter { - // Re-created per request in buildRequest, not just once here — otherwise - // block indices accumulate across every request the adapter instance ever - // serves, growing the map for the life of the conversation. - let indexer: CodexBlockIndexer = createResponsesBlockIndexer(); - return { - buildRequest: (messages, model, options) => { - indexer = createResponsesBlockIndexer(); - return buildRequest(messages, model, options, source.provider); - }, - parseResponse: (sseData) => parseResponse(sseData, indexer, source), - parseJSONResponse, - isStreamTerminal: isResponsesStreamTerminal, - }; -} diff --git a/src/provider/sse-delta-patch.ts b/src/provider/sse-delta-patch.ts new file mode 100644 index 000000000..ee571a9e0 --- /dev/null +++ b/src/provider/sse-delta-patch.ts @@ -0,0 +1,44 @@ +// Chat Completions SSE frames from some backends send `null` for delta fields +// the upstream schema requires to be non-null (`role: string`, `tool_calls: +// array`): DeepSeek via NVIDIA NIM and OpenCode Go both do this. Deleting the +// null fields lets the stock OpenAI adapter parse the frame; fields that +// legitimately accept null (content, reasoning_content, etc.) are left alone. +// +// Shared by the openai-compatible and OpenCode Go adapters so the patch stays +// in one place. + +/** Delta fields that must never be null in a valid Chat Completions frame. */ +export const NULL_REJECTED_DELTA_FIELDS = ["role", "tool_calls"] as const; + +/** + * Delete null-valued non-nullable delta fields from a Chat Completions SSE + * frame. Returns the input unchanged when the payload is not JSON, has no + * `choices[].delta` objects, or contains no null fields to remove. + */ +export function normalizeNullDeltaFields(sseData: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(sseData); + } catch { + return sseData; + } + if (parsed === null || typeof parsed !== "object") return sseData; + + const choices = (parsed as Record)["choices"]; + if (!Array.isArray(choices)) return sseData; + + let normalized = false; + for (const choice of choices) { + if (choice === null || typeof choice !== "object") continue; + const delta = (choice as Record)["delta"]; + if (delta === null || typeof delta !== "object") continue; + for (const field of NULL_REJECTED_DELTA_FIELDS) { + if ((delta as Record)[field] === null) { + Reflect.deleteProperty(delta, field); + normalized = true; + } + } + } + + return normalized ? JSON.stringify(parsed) : sseData; +} diff --git a/src/subagent/provider-family.ts b/src/subagent/provider-family.ts index e0aa2ee7c..a7be931ae 100644 --- a/src/subagent/provider-family.ts +++ b/src/subagent/provider-family.ts @@ -1,4 +1,4 @@ -import { GROK_RESPONSES_PROVIDER } from "../provider/grok-responses-adapter.js"; +import { GROK_RESPONSES_PROVIDER } from "../provider/responses-adapters.js"; import { isXaiProviderName } from "../config/xai-providers.js"; /** diff --git a/src/tools/web-fetch.ts b/src/tools/web-fetch.ts index 6a85089ce..7702d93a6 100644 --- a/src/tools/web-fetch.ts +++ b/src/tools/web-fetch.ts @@ -5,6 +5,7 @@ import type { ToolCall, ToolDefinition, ToolResult } from "@intx/types/runtime"; import { checkUrlForSsrf } from "./ssrf-guard.js"; import { htmlToMarkdown, htmlToText } from "./html-convert.js"; +import { readCappedBody } from "../util/capped-body.js"; import { COMMAND_NAME } from "../branding.js"; import type { MCPClient } from "../mcp/client.js"; import pkg from "../../package.json" with { type: "json" }; @@ -63,44 +64,6 @@ function looksLikeBotBlock(status: number): boolean { return status === 403 || status === 429 || status === 999; } -async function readCapped( - response: Response, - capBytes: number, -): Promise<{ text: string; truncated: boolean }> { - const body = response.body; - if (body === null) return { text: await response.text(), truncated: false }; - const reader = body.getReader(); - const chunks: Uint8Array[] = []; - let total = 0; - let truncated = false; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - if (value === undefined) continue; - const remaining = capBytes - total; - if (remaining <= 0) { - truncated = true; - await reader.cancel().catch(() => undefined); - break; - } - const slice = value.byteLength > remaining ? value.slice(0, remaining) : value; - chunks.push(slice); - total += slice.byteLength; - if (slice.byteLength < value.byteLength) { - truncated = true; - await reader.cancel().catch(() => undefined); - break; - } - } - const buffer = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - buffer.set(chunk, offset); - offset += chunk.byteLength; - } - return { text: new TextDecoder().decode(buffer), truncated }; -} - async function fetchOnce( url: string, userAgent: string, @@ -176,14 +139,14 @@ export async function runWebFetch( } if (!response.ok) { - const { text } = await readCapped(response, 8192); + const { text } = await readCappedBody(response, 8192); return { ok: false, error: `Fetch of ${currentUrl} failed with status ${response.status}: ${text.slice(0, 500)}`, }; } - const { text: body, truncated } = await readCapped(response, MAX_FETCH_BYTES); + const { text: body, truncated } = await readCappedBody(response, MAX_FETCH_BYTES); const contentType = response.headers.get("content-type") ?? ""; const isHtml = contentType.includes("html") || /^\s*<(!doctype|html)/i.test(body); diff --git a/src/tui/runner/provider-refresh.ts b/src/tui/runner/provider-refresh.ts new file mode 100644 index 000000000..350e41966 --- /dev/null +++ b/src/tui/runner/provider-refresh.ts @@ -0,0 +1,79 @@ +// Shared "reload settings, re-resolve the live provider catalog, repaint the +// model surfaces" step for the TUI runner. The Alt+A provider connect handler +// and the post-startup / post-connect OpenCode Go model prefetch each carried +// their own copy of this block; both now resolve through this module so a +// live connect and a startup prefetch cannot drift apart. + +import { getLogger } from "@intx/log"; + +import { LOG_NAMESPACE_ROOT } from "../../branding.js"; +import { + listFavoriteModels, + listRecentModels, + loadSettings, + type ResolvedProvider, +} from "../../config/settings.js"; +import { refreshLiveProviderCatalog } from "../../config/index.js"; +import { prefetchGoModels } from "../../provider/opencode-go-models.js"; +import type { ModelCatalogProvidersInput, ModelCatalogRef } from "../model-catalog.js"; +import type { RunnerState } from "./state.js"; + +const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); + +/** Model-surface repaint target for a refreshed catalog (host or holder). */ +export type RefreshModels = ( + recentModels: readonly ModelCatalogRef[], + favoriteModels: readonly ModelCatalogRef[], + providers?: ModelCatalogProvidersInput, +) => void; + +// Reload the on-disk settings and re-resolve the live provider catalog +// (including OAuth profiles) against the current config, then repaint the +// model surfaces. Shared by the provider connect handler and the Go-model +// prefetch so a newly authorized provider's models appear the same way in +// every path. +export async function refreshProviderCatalogAndSurfaces( + state: RunnerState, + refreshModels: RefreshModels, +): Promise { + const onDisk = await loadSettings(state.trueGlobalSettingsPath); + const resolvedForCatalog: ResolvedProvider = { + apiKey: state.config.apiKey, + baseURL: state.config.baseURL, + model: state.config.model, + providerName: state.config.providerName, + ...(state.config.keyless !== undefined ? { keyless: state.config.keyless } : {}), + }; + const providers = await refreshLiveProviderCatalog(onDisk, resolvedForCatalog); + state.config = { + ...state.config, + providers, + ...(onDisk !== null ? { settings: onDisk } : {}), + }; + refreshModels( + listRecentModels(state.config.settings ?? { providers: {} }), + listFavoriteModels(state.config.settings ?? { providers: {} }), + providers, + ); +} + +// Prefetch the OpenCode Go model catalog, then re-resolve settings and +// repaint. Shared by wirePostStartup and the connect handler's post-connect +// refresh. `getRefreshModels` is a callback so the host-availability guard is +// re-checked when the prefetch settles, not when it was started. +export function prefetchGoModelsAndRefresh( + state: RunnerState, + getRefreshModels: () => RefreshModels | undefined, +): void { + void prefetchGoModels() + .then(async () => { + const refreshModels = getRefreshModels(); + if (refreshModels === undefined) return; + await refreshProviderCatalogAndSurfaces(state, refreshModels); + }) + .catch((err: unknown) => { + tuiLogger.debug("go model prefetch failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); +} diff --git a/src/tui/runner/settings.ts b/src/tui/runner/settings.ts index dd3503a56..9a19732a3 100644 --- a/src/tui/runner/settings.ts +++ b/src/tui/runner/settings.ts @@ -21,11 +21,9 @@ import { toggleFavoriteModel, type LocalSettings, type ModelRef, - type ResolvedProvider, type Settings, } from "../../config/settings.js"; import { getTelemetry } from "../../telemetry/singleton.js"; -import { refreshLiveProviderCatalog } from "../../config/index.js"; import { createTelemetryToggleHandler } from "../../telemetry/toggle.js"; import { telemetryFirstRunPending } from "../../telemetry/first-run.js"; import { TELEMETRY_NOTICE } from "../../telemetry/index.js"; @@ -35,7 +33,6 @@ import type { GrantScope } from "../../permission/types.js"; import { connectProviderInline } from "../provider/connect.js"; import { persistConnectedSelection } from "../provider/submit.js"; import { modelOptionId } from "../model-catalog.js"; -import { prefetchGoModels } from "../../provider/opencode-go-models.js"; import { isOpenCodeGoProvider } from "../../../packages/opencode-go/src/index.js"; import { applyLiveModelSwitch } from "../../session/live-model-switch.js"; import { applyFocus } from "../shell/chrome.js"; @@ -43,6 +40,10 @@ import { setShellInputSuspended } from "../shell/prompt.js"; import { warningsForPluginEntry } from "../../plugins/diagnostics.js"; import { isPluginEnabledForSurface } from "../plugin-surface.js"; import { resolveWaitForApproval } from "../tool-execution-watchdog.js"; +import { + prefetchGoModelsAndRefresh, + refreshProviderCatalogAndSurfaces, +} from "./provider-refresh.js"; import { hostOf, type RunnerServices, type RunnerState } from "./state.js"; import { LOG_NAMESPACE_ROOT } from "../../branding.js"; @@ -252,25 +253,7 @@ export async function wireSettings( } if (!result.connected) return; - const onDisk = await loadSettings(trueGlobalSettingsPath); - const resolvedForCatalog: ResolvedProvider = { - apiKey: state.config.apiKey, - baseURL: state.config.baseURL, - model: state.config.model, - providerName: state.config.providerName, - ...(state.config.keyless !== undefined ? { keyless: state.config.keyless } : {}), - }; - const providers = await refreshLiveProviderCatalog(onDisk, resolvedForCatalog); - state.config = { - ...state.config, - providers, - ...(onDisk !== null ? { settings: onDisk } : {}), - }; - hostOf(state).refreshModels( - listRecentModels(state.config.settings ?? { providers: {} }), - listFavoriteModels(state.config.settings ?? { providers: {} }), - providers, - ); + await refreshProviderCatalogAndSurfaces(state, hostOf(state).refreshModels); // Reopen positioned at the account just connected — the picker's // default open (top of list) would otherwise leave the operator to // hunt for the row they just authorized. @@ -280,27 +263,7 @@ export async function wireSettings( ); state.systemNotice?.(`Connected ${connectedName}. Open /model to pick a model.`); if (isOpenCodeGoProvider({ name: providerName })) { - void prefetchGoModels() - .then(async () => { - if (services.hostHolder.instance === undefined) return; - const nextDisk = await loadSettings(trueGlobalSettingsPath); - const nextProviders = await refreshLiveProviderCatalog(nextDisk, resolvedForCatalog); - state.config = { - ...state.config, - providers: nextProviders, - ...(nextDisk !== null ? { settings: nextDisk } : {}), - }; - services.hostHolder.instance.refreshModels( - listRecentModels(state.config.settings ?? { providers: {} }), - listFavoriteModels(state.config.settings ?? { providers: {} }), - nextProviders, - ); - }) - .catch((err: unknown) => { - tuiLogger.debug("go model prefetch failed: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - }); + prefetchGoModelsAndRefresh(state, () => services.hostHolder.instance?.refreshModels); } })().catch((err: unknown) => { tuiLogger.debug("provider connect failed: {error}", { diff --git a/src/tui/runner/wiring.ts b/src/tui/runner/wiring.ts index 687b45cfc..728271c8d 100644 --- a/src/tui/runner/wiring.ts +++ b/src/tui/runner/wiring.ts @@ -7,10 +7,6 @@ */ import { getLogger } from "@intx/log"; -import { loadSettings, listFavoriteModels, listRecentModels } from "../../config/settings.js"; -import { refreshLiveProviderCatalog } from "../../config/index.js"; -import type { ResolvedProvider } from "../../config/settings.js"; -import { prefetchGoModels } from "../../provider/opencode-go-models.js"; import { isOpenCodeGoProvider } from "../../../packages/opencode-go/src/index.js"; import { loadRecentTurns } from "../../session/optimized-context-store.js"; import { loadSentMessages } from "../../session/sent-messages.js"; @@ -43,6 +39,7 @@ import { import { listPathSuggestions } from "../components/at-mention/list.js"; import { listCommands } from "../commands/registry.js"; import type { MCPConnectCallbacks } from "../../agent/tools.js"; +import { prefetchGoModelsAndRefresh } from "./provider-refresh.js"; import { createRuntimeShutdown } from "./shutdown.js"; import { resumeTranscriptLoadErrorBlock } from "./exit.js"; import { userInboundMessage } from "./submit.js"; @@ -57,34 +54,7 @@ export function wirePostStartup( mcpConnectCallbacks: MCPConnectCallbacks, ): void { if (state.config.providers.some((p) => isOpenCodeGoProvider(p))) { - void prefetchGoModels() - .then(async () => { - if (services.hostHolder.instance === undefined) return; - const onDisk = await loadSettings(state.trueGlobalSettingsPath); - const resolvedForCatalog: ResolvedProvider = { - apiKey: state.config.apiKey, - baseURL: state.config.baseURL, - model: state.config.model, - providerName: state.config.providerName, - ...(state.config.keyless !== undefined ? { keyless: state.config.keyless } : {}), - }; - const providers = await refreshLiveProviderCatalog(onDisk, resolvedForCatalog); - state.config = { - ...state.config, - providers, - ...(onDisk !== null ? { settings: onDisk } : {}), - }; - services.hostHolder.instance.refreshModels( - listRecentModels(state.config.settings ?? { providers: {} }), - listFavoriteModels(state.config.settings ?? { providers: {} }), - providers, - ); - }) - .catch((err: unknown) => { - tuiLogger.debug("go model prefetch failed: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - }); + prefetchGoModelsAndRefresh(state, () => services.hostHolder.instance?.refreshModels); } const shutdownRuntime = createRuntimeShutdown({ diff --git a/src/util/capped-body.ts b/src/util/capped-body.ts new file mode 100644 index 000000000..1f0a5e3df --- /dev/null +++ b/src/util/capped-body.ts @@ -0,0 +1,67 @@ +// Shared byte-capped response-body reader. +// +// Two callers used to carry their own copy of the same subtle byte-accounting +// loop (accumulate stream chunks up to a cap, cancel the reader the moment the +// cap is exceeded so the upstream socket is not drained, concatenate, decode): +// - web_fetch (src/tools/web-fetch.ts) caps page bodies at 5MB and error +// snippets at 8KB, keeping the first `capBytes` bytes and flagging the cut. +// - the OpenCode Go model catalog (src/provider/opencode-go-models.ts) caps +// the live /models response so an oversized or hostile catalog cannot blow +// process memory, rejecting (rather than keeping a prefix) when over. +// Keeping the reader here means a cap-accounting fix (off-by-one, cancel +// discipline, chunk slicing) lands once instead of drifting across copies. + +export interface CappedBody { + /** The body's content, decoded as UTF-8 and sliced to at most `capBytes` bytes. */ + text: string; + /** True when the body was longer than `capBytes`; reading stopped at the cap. */ + truncated: boolean; +} + +/** + * Read up to `capBytes` bytes of a Response body. Returns the decoded prefix + * plus whether the body was cut off; the reader is cancelled as soon as the cap + * is exceeded so oversized bodies are not drained. A Response whose body is + * null (synthetic responses, no-content statuses) is read via `text()` and + * truncation is judged from the decoded byte length. + */ +export async function readCappedBody(response: Response, capBytes: number): Promise { + const body = response.body; + if (body === null) { + const text = await response.text(); + return { + text, + truncated: new TextEncoder().encode(text).byteLength > capBytes, + }; + } + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + let truncated = false; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value === undefined) continue; + const remaining = capBytes - total; + if (remaining <= 0) { + truncated = true; + await reader.cancel().catch(() => undefined); + break; + } + const slice = value.byteLength > remaining ? value.slice(0, remaining) : value; + chunks.push(slice); + total += slice.byteLength; + if (slice.byteLength < value.byteLength) { + truncated = true; + await reader.cancel().catch(() => undefined); + break; + } + } + const buffer = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + buffer.set(chunk, offset); + offset += chunk.byteLength; + } + return { text: new TextDecoder().decode(buffer), truncated }; +} diff --git a/tests/fixtures/codex-sse/README.md b/tests/fixtures/codex-sse/README.md index b7bd6309a..e716ef5fa 100644 --- a/tests/fixtures/codex-sse/README.md +++ b/tests/fixtures/codex-sse/README.md @@ -1,7 +1,7 @@ # Codex / Responses SSE fixtures Sanitized multi-event streams for golden tests of `parseResponse` in -`src/provider/codex-responses-adapter.ts`. +`src/provider/responses-adapters.ts`. Each `*.json` file is a JSON array of Responses SSE **data payloads** (the object after `data: ` on each SSE line). No real tokens, prompts, account IDs, diff --git a/tests/unit/codex-responses-adapter.test.ts b/tests/unit/codex-responses-adapter.test.ts index 4faebfbcf..2206bd131 100644 --- a/tests/unit/codex-responses-adapter.test.ts +++ b/tests/unit/codex-responses-adapter.test.ts @@ -6,8 +6,8 @@ import { CODEX_ACCOUNT_ID_OPTION, CODEX_SESSION_ID_OPTION, CODEX_RESPONSES_PROVIDER, -} from "../../src/provider/codex-responses-adapter.js"; -import { GROK_RESPONSES_PROVIDER } from "../../src/provider/grok-responses-adapter.js"; + GROK_RESPONSES_PROVIDER, +} from "../../src/provider/responses-adapters.js"; import { BEARER_CREDENTIAL_SENTINEL } from "@intx/inference"; import type { ConversationTurn, InferenceOptions, LastCycleSource } from "@intx/types/runtime"; diff --git a/tests/unit/codex-sse-fixtures.test.ts b/tests/unit/codex-sse-fixtures.test.ts index 103288cbb..9ff407da0 100644 --- a/tests/unit/codex-sse-fixtures.test.ts +++ b/tests/unit/codex-sse-fixtures.test.ts @@ -11,7 +11,7 @@ import { createCodexResponsesAdapter, isResponsesStreamTerminal, tagSignature, -} from "../../src/provider/codex-responses-adapter.js"; +} from "../../src/provider/responses-adapters.js"; import type { InferenceEvent, LastCycleSource } from "@intx/types/runtime"; import { ProtocolMismatchError } from "@intx/inference"; diff --git a/tests/unit/grok-responses-adapter.test.ts b/tests/unit/grok-responses-adapter.test.ts index 57c4b61d6..ff530227e 100644 --- a/tests/unit/grok-responses-adapter.test.ts +++ b/tests/unit/grok-responses-adapter.test.ts @@ -3,7 +3,7 @@ import { createGrokResponsesAdapter, GROK_SESSION_ID_OPTION, GROK_USER_ID_OPTION, -} from "../../src/provider/grok-responses-adapter.js"; +} from "../../src/provider/responses-adapters.js"; import { BEARER_CREDENTIAL_SENTINEL } from "@intx/inference"; import type { ConversationTurn, InferenceOptions, LastCycleSource } from "@intx/types/runtime"; diff --git a/tests/unit/inference-response-kind.test.ts b/tests/unit/inference-response-kind.test.ts index f57b4a708..6ffac2eaa 100644 --- a/tests/unit/inference-response-kind.test.ts +++ b/tests/unit/inference-response-kind.test.ts @@ -14,7 +14,7 @@ import { createInferenceDependencies } from "../../src/provider/inference-depend import { CODEX_RESPONSES_PROVIDER, withCodexContentTypeRepair, -} from "../../src/provider/codex-responses-adapter.js"; +} from "../../src/provider/responses-adapters.js"; import { CODEX_RESPONSES_PATH } from "../../src/auth/codex/constants.js"; const CODEX_URL = `https://chatgpt.com/backend-api${CODEX_RESPONSES_PATH}`; diff --git a/tests/unit/openai-responses-adapter.test.ts b/tests/unit/openai-responses-adapter.test.ts index 523c5dfa6..b196ff909 100644 --- a/tests/unit/openai-responses-adapter.test.ts +++ b/tests/unit/openai-responses-adapter.test.ts @@ -3,7 +3,7 @@ import { createOpenAIResponsesAdapter, OPENAI_SESSION_ID_OPTION, OPENCODE_SESSION_ID_OPTION, -} from "../../src/provider/openai-responses-adapter.js"; +} from "../../src/provider/responses-adapters.js"; import { BEARER_CREDENTIAL_SENTINEL } from "@intx/inference"; import type { ConversationTurn, InferenceOptions, LastCycleSource } from "@intx/types/runtime";