From 3548a32686176259747b8da1768e18b499823c40 Mon Sep 17 00:00:00 2001 From: Gustav Simonsson Date: Wed, 9 Sep 2026 13:44:23 -0400 Subject: [PATCH 1/7] Refactor xAI and Codex OAuth stacks onto shared provider factory --- src/auth/codex/callback-server.ts | 24 +---- src/auth/codex/login.ts | 22 +--- src/auth/codex/oauth.ts | 105 ++----------------- src/auth/codex/provider.ts | 124 ++++++++++++++++++++++ src/auth/codex/session.ts | 66 ++---------- src/auth/codex/store.ts | 41 +++----- src/auth/oauth/provider.ts | 169 ++++++++++++++++++++++++++++++ src/auth/xai/callback-server.ts | 18 +--- src/auth/xai/login.ts | 21 +--- src/auth/xai/oauth.ts | 68 ++---------- src/auth/xai/provider.ts | 77 ++++++++++++++ src/auth/xai/session.ts | 54 ++-------- src/auth/xai/store.ts | 37 ++----- 13 files changed, 444 insertions(+), 382 deletions(-) create mode 100644 src/auth/codex/provider.ts create mode 100644 src/auth/oauth/provider.ts create mode 100644 src/auth/xai/provider.ts 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; From 3c0fdcf59b86384e8f1d55118574317ebb5070dd Mon Sep 17 00:00:00 2001 From: Gustav Simonsson Date: Wed, 9 Sep 2026 13:44:23 -0400 Subject: [PATCH 2/7] Merge grok and openai responses adapters into one module --- src/config/index.ts | 4 +- src/provider/grok-responses-adapter.test.ts | 2 +- src/provider/inference-dependencies.ts | 10 +- src/provider/openai-responses-adapter.ts | 237 ------------------ src/provider/opencode-go-adapter.ts | 2 +- src/provider/opencode-go-anthropic-adapter.ts | 2 +- src/provider/replay-sanitizer.test.ts | 2 +- ...onses-adapter.ts => responses-adapters.ts} | 149 ++++++++--- src/subagent/provider-family.ts | 2 +- tests/unit/codex-responses-adapter.test.ts | 2 +- tests/unit/grok-responses-adapter.test.ts | 2 +- tests/unit/openai-responses-adapter.test.ts | 2 +- 12 files changed, 126 insertions(+), 290 deletions(-) delete mode 100644 src/provider/openai-responses-adapter.ts rename src/provider/{grok-responses-adapter.ts => responses-adapters.ts} (59%) diff --git a/src/config/index.ts b/src/config/index.ts index 3fd8d36c9..7ae59b20a 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -36,7 +36,7 @@ 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 +44,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/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/inference-dependencies.ts b/src/provider/inference-dependencies.ts index 1a1a66482..36aa8566d 100644 --- a/src/provider/inference-dependencies.ts +++ b/src/provider/inference-dependencies.ts @@ -3,16 +3,14 @@ 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 { GROK_RESPONSES_PROVIDER, OPENAI_RESPONSES_PROVIDER } 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: @@ -61,9 +59,9 @@ const localModules: Record = { "openai-compatible-adapter": openaiCompatible, "opencode-go-adapter": opencodeGo, "codex-responses-adapter": codexResponses, - "grok-responses-adapter": grokResponses, + "grok-responses-adapter": responsesAdapters, "bifrost-adapter": bifrostAdapter, - "openai-responses-adapter": openaiResponses, + "openai-responses-adapter": responsesAdapters, "opencode-go-anthropic-adapter": opencodeGoAnthropic, }; 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..53dbbde0c 100644 --- a/src/provider/opencode-go-adapter.ts +++ b/src/provider/opencode-go-adapter.ts @@ -1,6 +1,6 @@ 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"; type AdapterSource = Parameters[0]; 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/replay-sanitizer.test.ts b/src/provider/replay-sanitizer.test.ts index 0d3f13aef..fb5c7a6f6 100644 --- a/src/provider/replay-sanitizer.test.ts +++ b/src/provider/replay-sanitizer.test.ts @@ -7,7 +7,7 @@ import { createCodexResponsesAdapter, tagSignature, } from "./codex-responses-adapter.js"; -import { createGrokResponsesAdapter } from "./grok-responses-adapter.js"; +import { createGrokResponsesAdapter } from "./responses-adapters.js"; import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js"; import { sanitizeReplayTurns, diff --git a/src/provider/grok-responses-adapter.ts b/src/provider/responses-adapters.ts similarity index 59% rename from src/provider/grok-responses-adapter.ts rename to src/provider/responses-adapters.ts index 2cca85ae8..9a87f6265 100644 --- a/src/provider/grok-responses-adapter.ts +++ b/src/provider/responses-adapters.ts @@ -10,33 +10,43 @@ import type { 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, + isResponsesStreamTerminal, parseJSONResponse, parseResponse, signatureForModel, } from "./codex-responses-adapter.js"; +import { + XAI_RESPONSES_PATH, + XAI_CLIENT_IDENTIFIER, + XAI_CLIENT_VERSION, + XAI_USER_AGENT, +} from "../auth/xai/constants.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. +// Adapters for the OpenAI Responses API (POST /responses) as served by two +// backends that do NOT speak Chat Completions: +// - 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). +// Both share the Codex/Grok SSE parser (see codex-responses-adapter.ts) and the +// request mapper below; only the endpoint, headers, and reasoning/sampling +// configuration differ. 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 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"; type ResponsesInputContentPart = { type: "input_text"; text: string } | { type: "input_image"; image_url: string }; @@ -160,7 +170,7 @@ function toResponsesTools(options: InferenceOptions): unknown[] | undefined { })); } -function optionString(options: InferenceOptions, key: string): string | undefined { +export function optionString(options: InferenceOptions, key: string): string | undefined { const value = options.providerOptions?.[key]; return typeof value === "string" && value.length > 0 ? value : undefined; } @@ -185,11 +195,32 @@ function dedupeToolItems(items: ResponsesInputItem[]): ResponsesInputItem[] { }); } -function buildRequest( +// 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. It also forwards providerOptions.reasoning_effort when the + * source set it — the adapter does not invent a default. + */ + reasoningSummary: "auto" | "detailed"; + /** Forward maxTokens/temperature into the request body. */ + forwardSamplingParams: boolean; + /** Provider-specific headers on top of the base content-type/accept/auth set. */ + extraHeaders: (options: InferenceOptions, model: string) => Record; +} + +function buildResponsesRequest( messages: ConversationTurn[], model: string, options: InferenceOptions, requestProvider: string, + spec: ResponsesRequestSpec, ): BuiltRequest { const conversation = dedupeToolItems( messages.flatMap((turn) => toResponsesItems(turn, model, requestProvider)), @@ -201,9 +232,11 @@ function buildRequest( 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 reasoning: Record = { summary: spec.reasoningSummary }; + if (spec.reasoningSummary === "detailed") { + const effort = optionString(options, "reasoning_effort"); + if (effort !== undefined) reasoning["effort"] = effort; + } const body: Record = { model, @@ -211,45 +244,87 @@ function buildRequest( 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"; } + 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, GROK_SESSION_ID_OPTION); + 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, - "user-agent": XAI_USER_AGENT, - "x-grok-client-identifier": XAI_CLIENT_IDENTIFIER, - "x-grok-client-version": XAI_CLIENT_VERSION, - "x-grok-model-override": model, + 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), }; - 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) }; } +// 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", + forwardSamplingParams: false, + 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", + forwardSamplingParams: true, + extraHeaders: (options) => { + const opencodeSessionId = optionString(options, OPENCODE_SESSION_ID_OPTION); + return opencodeSessionId !== undefined ? { "x-opencode-session": opencodeSessionId } : {}; + }, +}; + 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); + 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 codex-responses-adapter.ts. + 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, + }; +} 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/tests/unit/codex-responses-adapter.test.ts b/tests/unit/codex-responses-adapter.test.ts index 4faebfbcf..6b5f244ed 100644 --- a/tests/unit/codex-responses-adapter.test.ts +++ b/tests/unit/codex-responses-adapter.test.ts @@ -7,7 +7,7 @@ import { 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"; +import { 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/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/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"; From c76410f6987050b502a8a0dc6231d43716085e6e Mon Sep 17 00:00:00 2001 From: Gustav Simonsson Date: Wed, 9 Sep 2026 13:44:23 -0400 Subject: [PATCH 3/7] Merge codex responses adapter into shared responses module --- docs/IMPLEMENTATION.md | 2 +- src/config/index.ts | 2 - src/provider/codex-responses-adapter.test.ts | 2 +- src/provider/codex-responses-adapter.ts | 652 ----------------- src/provider/inference-dependencies.ts | 19 +- src/provider/replay-sanitizer.test.ts | 2 +- src/provider/responses-adapters.ts | 695 ++++++++++++++++--- tests/fixtures/codex-sse/README.md | 2 +- tests/unit/codex-responses-adapter.test.ts | 4 +- tests/unit/codex-sse-fixtures.test.ts | 2 +- tests/unit/inference-response-kind.test.ts | 2 +- 11 files changed, 634 insertions(+), 750 deletions(-) delete mode 100644 src/provider/codex-responses-adapter.ts 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/config/index.ts b/src/config/index.ts index 7ae59b20a..0d4c57551 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -31,8 +31,6 @@ 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, 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/codex-responses-adapter.ts b/src/provider/codex-responses-adapter.ts deleted file mode 100644 index 1c5f40aaa..000000000 --- a/src/provider/codex-responses-adapter.ts +++ /dev/null @@ -1,652 +0,0 @@ -import { - BEARER_CREDENTIAL_SENTINEL, - ProtocolMismatchError, - decodeToolName, - encodeToolName, - type BuiltRequest, - type ProviderAdapter, - type ToolNameLimit, -} from "@intx/inference"; -import type { - ContentBlock, - ConversationTurn, - InferenceEvent, - InferenceOptions, - LastCycleSource, - 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. - -export const CODEX_RESPONSES_PROVIDER = "codex-responses"; - -// Keys the source stashes in defaults.providerOptions for this adapter. -export const CODEX_ACCOUNT_ID_OPTION = "codexAccountId"; -export const CODEX_SESSION_ID_OPTION = "codexSessionId"; - -const EMPTY_PARTIAL: PartialMessage = { text: "" }; - -type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise; - -function requestURL(input: string | URL | Request): string { - if (typeof input === "string") return input; - if (input instanceof URL) return input.toString(); - return input.url; -} - -// Content type the request's accept header committed to, or null when the -// commitment is ambiguous. Reads init headers first, falling back to a -// Request object's own headers so both fetch calling conventions are -// honored. Media types are prefix-matched per comma-separated entry so -// parameters do not defeat the match; a list naming BOTH supported -// protocols is ambiguous and yields null. -function acceptedContentType( - input: string | URL | Request, - init: RequestInit | undefined, -): string | null { - const headers = - init?.headers !== undefined - ? new Headers(init.headers) - : input instanceof Request - ? input.headers - : undefined; - const accept = headers?.get("accept"); - if (accept === undefined || accept === null) return null; - const supported = new Set(); - for (const entry of accept.toLowerCase().split(",")) { - const media = entry.trim(); - if (media.startsWith("text/event-stream")) supported.add("text/event-stream"); - else if (media.startsWith("application/json")) supported.add("application/json"); - } - if (supported.size !== 1) return null; - return [...supported][0] ?? null; -} - -// The Codex backend omits the Content-Type header entirely on some model -// streams (observed live with the gpt-5.6 family) while the body is a valid -// SSE stream. The vendored harness detects the response protocol from that -// header alone and fails the turn when it is absent, so the header is -// restored here — at the fetch boundary Corbits owns, scoped to Codex -// responses requests — from the protocol the request's accept header -// declared. Responses that declare any Content-Type, non-2xx responses, and -// requests whose accept header is ambiguous pass through untouched, keeping -// the harness's loud protocol-mismatch failure for genuine violations. -export function withCodexContentTypeRepair(fetchImpl: FetchLike): FetchLike { - return async (input, init) => { - const response = await fetchImpl(input, init); - if (!requestURL(input).endsWith(CODEX_RESPONSES_PATH)) return response; - if (!response.ok) return response; - if (response.headers.get("content-type") !== null) return response; - const declared = acceptedContentType(input, init); - if (declared === null) return response; - const headers = new Headers(response.headers); - headers.set("content-type", declared); - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); - }; -} - -// --------------------------------------------------------------------------- -// 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: 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 -// (proxy aliases, two OpenAI-compatible endpoints) from declaring the same -// literal model name, so comparing `turn.model` alone treats a foreign -// signature as safe to replay. `ConversationTurn` carries no field for which -// provider produced it, so provenance rides inside the signature string -// itself: capture tags it `:` (see `tagSignature`), -// and replay only unwraps the ciphertext when both the tagged provider and -// the model match the current request. -// -// Provider, not the per-account source id, is the unit of decrypt -// capability — a Codex backend shared across ChatGPT accounts can decrypt a -// signature issued to any of them, so keying on provider (rather than source -// id) is what lets an account switch keep reasoning continuity while a -// genuine cross-provider collision still gets dropped. A poisoned history -// self-heals on the next request instead of being replayed forever. -const SIGNATURE_TAG_SEPARATOR = ":"; - -export function tagSignature(provider: string, encryptedContent: string): string { - return `${provider}${SIGNATURE_TAG_SEPARATOR}${encryptedContent}`; -} - -function untagSignature( - tagged: string, -): { provider: string; encryptedContent: string } | undefined { - const idx = tagged.indexOf(SIGNATURE_TAG_SEPARATOR); - if (idx === -1) return undefined; - return { provider: tagged.slice(0, idx), encryptedContent: tagged.slice(idx + 1) }; -} - -export function signatureForModel( - turn: ConversationTurn, - requestModel: string, - requestProvider: string, - signature: string, -): string | undefined { - // `model` is optional on the persisted turn schema; a turn saved before that - // field existed (or otherwise missing it) is not evidence of a model - // switch — treat the absence as benign and fall through to the provider - // check, rather than dropping reasoning that never actually crossed models. - if (turn.model !== undefined && turn.model !== requestModel) return undefined; - const tagged = untagSignature(signature); - if (tagged === undefined) return undefined; - return tagged.provider === requestProvider ? tagged.encryptedContent : undefined; -} - -// 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 -// `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, -): ResponsesInputItem[] { - const items: ResponsesInputItem[] = []; - const textKind: "input_text" | "output_text" = - turn.role === "assistant" ? "output_text" : "input_text"; - const textParts: ResponsesContentPart[] = []; - // 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 - // precede it — the exact orphaned shape that degenerates reasoning models. - // Suppress function_call items until the next text or successfully-replayed - // reasoning item re-establishes a clean turn shape; tool results are - // 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; - } - }; - - for (const block of turn.content) { - if (block.type === "text") { - textParts.push({ type: textKind, text: block.text } as ResponsesContentPart); - } else if (block.type === "image") { - if (block.source.kind === "base64") { - textParts.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 }); - } else { - textParts.push({ - type: textKind, - text: `[Unsupported image reference omitted: ${block.source.reference}]`, - } as ResponsesContentPart); - } - } else if (block.type === "tool_call") { - if (suppressOrphanedCalls) continue; - flushText(); - 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") { - flushText(); - 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 - ) { - flushText(); - const encryptedContent = signatureForModel( - turn, - requestModel, - requestProvider, - block.signature, - ); - if (encryptedContent !== undefined) { - items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent }); - suppressOrphanedCalls = false; - } else { - suppressOrphanedCalls = true; - } - } - } - flushText(); - 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(""); -} - -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( - messages: ConversationTurn[], - model: string, - options: InferenceOptions, - requestProvider: string, -): BuiltRequest { - const input = messages.flatMap((turn) => toResponsesItems(turn, model, requestProvider)); - 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. - store: false, - stream: true, - include: ["reasoning.encrypted_content"], - // 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["instructions"] = options.systemPrompt; - } - // The Codex backend rejects `max_output_tokens`; it is intentionally omitted. - 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 (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", - }; - 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) }; -} - -// --------------------------------------------------------------------------- -// Response parsing — Responses SSE events → internal inference events -// --------------------------------------------------------------------------- - -// Per-request block indexing. The Responses stream tags every streaming item -// with an `item_id`, so we allocate one content-block index per distinct item -// id (regardless of kind). Keying by item id — rather than one sticky index per -// kind — preserves true arrival order when reasoning, text, and tool calls -// interleave, and lets `response.output_item.done` attach an encrypted-reasoning -// signature to the exact thinking block it belongs to. `kind` is recorded so a -// signature is only emitted against a real thinking block. -type CodexBlockKind = "text" | "thinking" | "tool_call"; -export interface CodexBlockIndexer { - nextIndex: number; - 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. -export function createResponsesBlockIndexer(): CodexBlockIndexer { - return { nextIndex: 0, items: new Map() }; -} - -function blockIndexFor(state: CodexBlockIndexer, itemId: string, kind: CodexBlockKind): number { - const existing = state.items.get(itemId); - if (existing !== undefined) return existing.index; - const index = state.nextIndex; - state.nextIndex += 1; - state.items.set(itemId, { index, kind }); - return index; -} - -function usageFromResponse(response: Record): TokenUsage | undefined { - const usage = response["usage"]; - if (typeof usage !== "object" || usage === null) return undefined; - const u = usage as Record; - const num = (v: unknown): number => (typeof v === "number" ? v : 0); - const inputDetails = u["input_tokens_details"] as Record | undefined; - const outputDetails = u["output_tokens_details"] as Record | undefined; - // Responses-API `input_tokens` counts the full prompt and `cached_tokens` - // is a subset of it. Downstream consumers (context meter, compaction - // governor, faremeter) treat the TokenUsage fields as non-overlapping and - // sum them, so the cached subset must be split out of input here — emitting - // the wire counts verbatim double-counts every cached token and inflates - // context occupancy up to ~2x on high cache-hit sessions. - const totalInputTokens = num(u["input_tokens"]); - const cachedTokens = num(inputDetails?.["cached_tokens"]); - return { - input: Math.max(0, totalInputTokens - cachedTokens), - output: num(u["output_tokens"]), - cacheRead: cachedTokens, - // OpenAI does not charge for writing to the prompt cache, so the public - // Responses API usually omits a write count; read it defensively under - // `cache_creation_tokens` in case a gateway/proxy in front of this - // OpenAI-shaped endpoint (Codex, Grok) reports one, rather than always - // hardcoding zero. - cacheWrite: num(inputDetails?.["cache_creation_tokens"]), - thinking: num(outputDetails?.["reasoning_tokens"]), - }; -} - -export function parseResponse( - sseData: string, - indexer: CodexBlockIndexer, - source: LastCycleSource, - label = "codex-responses", -): InferenceEvent[] { - let parsed: unknown; - try { - parsed = JSON.parse(sseData); - } catch (cause) { - throw new ProtocolMismatchError( - `${label} parseResponse: malformed JSON in SSE data payload: ${cause instanceof Error ? cause.message : String(cause)}`, - sseData, - ); - } - if (typeof parsed !== "object" || parsed === null) return []; - const event = parsed as Record; - const eventType = event["type"]; - if (typeof eventType !== "string") return []; - - const seq = 0; - const events: InferenceEvent[] = []; - - switch (eventType) { - case "response.output_text.delta": { - const token = event["delta"]; - const itemId = - typeof event["item_id"] === "string" ? (event["item_id"] as string) : "__text__"; - if (typeof token === "string" && token.length > 0) { - events.push({ - type: "inference.text.delta", - seq, - data: { token, partial: EMPTY_PARTIAL, index: blockIndexFor(indexer, itemId, "text") }, - }); - } - return events; - } - case "response.reasoning_summary_text.delta": - case "response.reasoning_text.delta": { - // Always register the block and emit a thinking delta (even for empty - // tokens). This ensures a preceding thinking block exists for any - // subsequent signature, supporting reasoning items whose visible - // summary may be empty or delivered only via the done envelope. - const token = event["delta"]; - const itemId = - typeof event["item_id"] === "string" ? (event["item_id"] as string) : "__thinking__"; - const index = blockIndexFor(indexer, itemId, "thinking"); - const tok = typeof token === "string" ? token : ""; - events.push({ - type: "inference.thinking.delta", - seq, - data: { token: tok, partial: EMPTY_PARTIAL, index }, - }); - return events; - } - case "response.output_item.added": { - const item = event["item"]; - if (typeof item === "object" && item !== null) { - const it = item as Record; - if (it["type"] === "function_call") { - const itemId = typeof it["id"] === "string" ? it["id"] : undefined; - const callId = it["call_id"]; - const name = it["name"]; - if (itemId !== undefined && typeof callId === "string" && typeof name === "string") { - events.push({ - type: "inference.tool_call.start", - seq, - data: { - callId, - name: decodeToolName(name), - partial: EMPTY_PARTIAL, - index: blockIndexFor(indexer, itemId, "tool_call"), - }, - }); - } - } else if (it["type"] === "reasoning") { - // Pre-register reasoning items on added so the index is stable - // even if no text deltas follow (pure-encrypted case). - const itemId = typeof it["id"] === "string" ? (it["id"] as string) : undefined; - if (itemId !== undefined) { - const index = blockIndexFor(indexer, itemId, "thinking"); - events.push({ - type: "inference.thinking.delta", - seq, - data: { token: "", partial: EMPTY_PARTIAL, index }, - }); - } - } - } - return events; - } - case "response.output_item.done": { - // Capture the encrypted reasoning blob (signature) so it can be echoed - // back on the next turn. Required for multi-turn continuity when the - // backend uses store:false + reasoning.encrypted_content. - // We ensure a thinking block exists (emitting an empty delta if this - // is the first signal for the item) so the harness can attach the - // signature without ProtocolMismatchError. - const item = event["item"] as Record | undefined; - if ( - item?.["type"] === "reasoning" && - typeof item["id"] === "string" && - typeof item["encrypted_content"] === "string" - ) { - const itemId = item["id"] as string; - const hadPrior = indexer.items.has(itemId); - const index = blockIndexFor(indexer, itemId, "thinking"); - if (!hadPrior) { - events.push({ - type: "inference.thinking.delta", - seq, - data: { token: "", partial: EMPTY_PARTIAL, index }, - }); - } - events.push({ - type: "inference.block.signature", - seq, - data: { - signature: tagSignature(source.provider, item["encrypted_content"] as string), - index, - }, - }); - } - return events; - } - case "response.function_call_arguments.delta": { - const itemId = event["item_id"]; - const fragment = event["delta"]; - if (typeof itemId === "string" && typeof fragment === "string" && fragment.length > 0) { - const blockIndex = blockIndexFor(indexer, itemId, "tool_call"); - events.push({ - type: "inference.tool_call.delta", - seq, - // The harness routes argument fragments by a per-stream placeholder - // keyed to the block index registered on the start event. - data: { - callId: String(blockIndex), - argumentFragment: fragment, - partial: EMPTY_PARTIAL, - index: blockIndex, - }, - }); - } - return events; - } - case "response.completed": { - const response = event["response"]; - if (typeof response === "object" && response !== null) { - const usage = usageFromResponse(response as Record); - if (usage !== undefined) { - events.push({ type: "inference.usage", seq, data: { usage, source } }); - } - } - return events; - } - case "response.failed": { - const response = event["response"] as Record | undefined; - const error = response?.["error"] as Record | undefined; - const message = typeof error?.["message"] === "string" ? error["message"] : "response failed"; - throw new ProtocolMismatchError(`${label}: ${message}`, parsed); - } - case "error": { - const message = typeof event["message"] === "string" ? event["message"] : "stream error"; - throw new ProtocolMismatchError(`${label}: ${message}`, parsed); - } - default: - // Lifecycle envelopes (response.created, response.in_progress, - // content_part.*, *_text.done) carry no incremental payload the harness - // needs; ignore them. - return events; - } -} - -// The Responses stream ends on a semantic lifecycle event, not `[DONE]` or a -// socket close: `response.completed` on success, `response.incomplete` when the -// backend truncates, `response.done` as an alias some backends emit. The -// harness reads this to stop the loop once the terminal event is processed; -// failure envelopes (`response.failed`, `error`) already throw in -// `parseResponse`, which terminates the loop through the harness's catch. -const RESPONSES_TERMINAL_EVENTS = new Set([ - "response.completed", - "response.incomplete", - "response.done", -]); - -// 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 -// provider ignored the streaming request — a protocol violation, not a -// supported code path to parse. -export function parseJSONResponse(): never { - throw new ProtocolMismatchError( - "responses adapter: received a non-streaming JSON response, but this adapter always requests stream: true", - ); -} - -export function isResponsesStreamTerminal(sseData: string): boolean { - let parsed: unknown; - try { - parsed = JSON.parse(sseData); - } catch { - // parseResponse re-parses the same payload and raises the protocol error; - // reporting "not terminal" here defers to that single throw site. - return false; - } - if (typeof parsed !== "object" || parsed === null) return false; - 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/inference-dependencies.ts b/src/provider/inference-dependencies.ts index 36aa8566d..491f0e2b4 100644 --- a/src/provider/inference-dependencies.ts +++ b/src/provider/inference-dependencies.ts @@ -2,15 +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 bifrostAdapter from "./bifrost-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 { 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 { GROK_RESPONSES_PROVIDER, OPENAI_RESPONSES_PROVIDER } from "./responses-adapters.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: @@ -30,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", }, { @@ -45,7 +48,7 @@ const manifest: AdapterManifest = [ }, { provider: OPENAI_RESPONSES_PROVIDER, - specifier: "openai-responses-adapter", + specifier: "responses-adapters", export: "createOpenAIResponsesAdapter", }, { @@ -58,10 +61,8 @@ const manifest: AdapterManifest = [ const localModules: Record = { "openai-compatible-adapter": openaiCompatible, "opencode-go-adapter": opencodeGo, - "codex-responses-adapter": codexResponses, - "grok-responses-adapter": responsesAdapters, + "responses-adapters": responsesAdapters, "bifrost-adapter": bifrostAdapter, - "openai-responses-adapter": responsesAdapters, "opencode-go-anthropic-adapter": opencodeGoAnthropic, }; diff --git a/src/provider/replay-sanitizer.test.ts b/src/provider/replay-sanitizer.test.ts index fb5c7a6f6..271f590a2 100644 --- a/src/provider/replay-sanitizer.test.ts +++ b/src/provider/replay-sanitizer.test.ts @@ -6,7 +6,7 @@ import { CODEX_RESPONSES_PROVIDER, createCodexResponsesAdapter, tagSignature, -} from "./codex-responses-adapter.js"; +} from "./responses-adapters.js"; import { createGrokResponsesAdapter } from "./responses-adapters.js"; import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js"; import { diff --git a/src/provider/responses-adapters.ts b/src/provider/responses-adapters.ts index 9a87f6265..07c1ad48e 100644 --- a/src/provider/responses-adapters.ts +++ b/src/provider/responses-adapters.ts @@ -1,32 +1,40 @@ import { BEARER_CREDENTIAL_SENTINEL, + ProtocolMismatchError, + decodeToolName, encodeToolName, type BuiltRequest, type ProviderAdapter, + type ToolNameLimit, } from "@intx/inference"; import type { ContentBlock, ConversationTurn, + InferenceEvent, InferenceOptions, LastCycleSource, + PartialMessage, + TokenUsage, } from "@intx/types/runtime"; +import { CODEX_AUTHORIZE_EXTRA_PARAMS, CODEX_RESPONSES_PATH } from "../auth/codex/constants.js"; import { - RESPONSES_TOOL_NAME_LIMIT, - createResponsesBlockIndexer, - isResponsesStreamTerminal, - parseJSONResponse, - parseResponse, - signatureForModel, -} from "./codex-responses-adapter.js"; -import { - XAI_RESPONSES_PATH, 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 two +// 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 @@ -35,32 +43,171 @@ import { // - openai-responses: generic OpenAI Responses endpoints, used by OpenCode Go // models that speak Responses rather than Chat Completions (e.g. // gpt-5.6-luna). -// Both share the Codex/Grok SSE parser (see codex-responses-adapter.ts) and the -// request mapper below; only the endpoint, headers, and reasoning/sampling -// configuration differ. +// 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 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"; -type ResponsesInputContentPart = - { type: "input_text"; text: string } | { type: "input_image"; image_url: string }; +// 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; + +function requestURL(input: string | URL | Request): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + return input.url; +} + +// Content type the request's accept header committed to, or null when the +// commitment is ambiguous. Reads init headers first, falling back to a +// Request object's own headers so both fetch calling conventions are +// honored. Media types are prefix-matched per comma-separated entry so +// parameters do not defeat the match; a list naming BOTH supported +// protocols is ambiguous and yields null. +function acceptedContentType( + input: string | URL | Request, + init: RequestInit | undefined, +): string | null { + const headers = + init?.headers !== undefined + ? new Headers(init.headers) + : input instanceof Request + ? input.headers + : undefined; + const accept = headers?.get("accept"); + if (accept === undefined || accept === null) return null; + const supported = new Set(); + for (const entry of accept.toLowerCase().split(",")) { + const media = entry.trim(); + if (media.startsWith("text/event-stream")) supported.add("text/event-stream"); + else if (media.startsWith("application/json")) supported.add("application/json"); + } + if (supported.size !== 1) return null; + return [...supported][0] ?? null; +} + +// The Codex backend omits the Content-Type header entirely on some model +// streams (observed live with the gpt-5.6 family) while the body is a valid +// SSE stream. The vendored harness detects the response protocol from that +// header alone and fails the turn when it is absent, so the header is +// restored here — at the fetch boundary Corbits owns, scoped to Codex +// responses requests — from the protocol the request's accept header +// declared. Responses that declare any Content-Type, non-2xx responses, and +// requests whose accept header is ambiguous pass through untouched, keeping +// the harness's loud protocol-mismatch failure for genuine violations. +export function withCodexContentTypeRepair(fetchImpl: FetchLike): FetchLike { + return async (input, init) => { + const response = await fetchImpl(input, init); + if (!requestURL(input).endsWith(CODEX_RESPONSES_PATH)) return response; + if (!response.ok) return response; + if (response.headers.get("content-type") !== null) return response; + const declared = acceptedContentType(input, init); + if (declared === null) return response; + const headers = new Headers(response.headers); + headers.set("content-type", declared); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + }; +} + +// --------------------------------------------------------------------------- +// Reasoning-signature tagging (multi-turn continuity) +// --------------------------------------------------------------------------- + +// 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 +// (proxy aliases, two OpenAI-compatible endpoints) from declaring the same +// literal model name, so comparing `turn.model` alone treats a foreign +// signature as safe to replay. `ConversationTurn` carries no field for which +// provider produced it, so provenance rides inside the signature string +// itself: capture tags it `:` (see `tagSignature`), +// and replay only unwraps the ciphertext when both the tagged provider and +// the model match the current request. +// +// Provider, not the per-account source id, is the unit of decrypt +// capability — a Codex backend shared across ChatGPT accounts can decrypt a +// signature issued to any of them, so keying on provider (rather than source +// id) is what lets an account switch keep reasoning continuity while a +// genuine cross-provider collision still gets dropped. A poisoned history +// self-heals on the next request instead of being replayed forever. +const SIGNATURE_TAG_SEPARATOR = ":"; + +export function tagSignature(provider: string, encryptedContent: string): string { + return `${provider}${SIGNATURE_TAG_SEPARATOR}${encryptedContent}`; +} + +function untagSignature( + tagged: string, +): { provider: string; encryptedContent: string } | undefined { + const idx = tagged.indexOf(SIGNATURE_TAG_SEPARATOR); + if (idx === -1) return undefined; + return { provider: tagged.slice(0, idx), encryptedContent: tagged.slice(idx + 1) }; +} + +export function signatureForModel( + turn: ConversationTurn, + requestModel: string, + requestProvider: string, + signature: string, +): string | undefined { + // `model` is optional on the persisted turn schema; a turn saved before that + // field existed (or otherwise missing it) is not evidence of a model + // switch — treat the absence as benign and fall through to the provider + // check, rather than dropping reasoning that never actually crossed models. + if (turn.model !== undefined && turn.model !== requestModel) return undefined; + const tagged = untagSignature(signature); + if (tagged === undefined) return undefined; + 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"; - content: string | ResponsesInputContentPart[]; + 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) { @@ -70,20 +217,83 @@ function toolResultText(block: Extract): 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 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 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. function toResponsesItems( turn: ConversationTurn, requestModel: string, requestProvider: string, + shape: ResponsesMessageShape, ): ResponsesInputItem[] { const items: ResponsesInputItem[] = []; const role = turn.role; - const parts: ResponsesInputContentPart[] = []; + 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; - // See codex-responses-adapter.ts toResponsesItems for why an unreplayed - // reasoning item suppresses the function_call(s) it produced. + // 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" = + 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 + // precede it — the exact orphaned shape that degenerates reasoning models. + // Suppress function_call items until the next text or successfully-replayed + // reasoning item re-establishes a clean turn shape; tool results are + // unaffected since they never need a preceding reasoning item. let suppressOrphanedCalls = false; const flushMessage = (): void => { @@ -91,9 +301,10 @@ function toResponsesItems( items.push({ type: "message", role, - content: hasImage - ? [...parts] - : parts.map((part) => (part.type === "input_text" ? part.text : "")).join(""), + content: + shape === "grok" && !hasImage + ? parts.map((part) => (part.type === "input_text" ? part.text : "")).join("") + : [...parts], }); parts.length = 0; hasImage = false; @@ -102,7 +313,7 @@ function toResponsesItems( for (const block of turn.content) { if (block.type === "text") { - parts.push({ type: "input_text", text: block.text }); + parts.push({ type: textKind, text: block.text } as ResponsesContentPart); } else if (block.type === "image") { if (block.source.kind === "base64") { hasImage = true; @@ -115,9 +326,9 @@ function toResponsesItems( parts.push({ type: "input_image", image_url: block.source.url }); } else { parts.push({ - type: "input_text", + type: textKind, text: `[Unsupported image reference omitted: ${block.source.reference}]`, - }); + } as ResponsesContentPart); } } else if (block.type === "tool_call") { if (suppressOrphanedCalls) continue; @@ -160,41 +371,6 @@ function toResponsesItems( 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; - }); -} - // Everything that differs between the Responses backends this module adapts. interface ResponsesRequestSpec { /** Endpoint path for the Responses API. */ @@ -205,12 +381,27 @@ interface ResponsesRequestSpec { * 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. It also forwards providerOptions.reasoning_effort when the - * source set it — the adapter does not invent a default. + * 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"; + 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; } @@ -222,30 +413,48 @@ function buildResponsesRequest( requestProvider: string, spec: ResponsesRequestSpec, ): BuiltRequest { - const conversation = dedupeToolItems( - 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 = - options.systemPrompt !== 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 reasoning: Record = { summary: spec.reasoningSummary }; - if (spec.reasoningSummary === "detailed") { - const effort = optionString(options, "reasoning_effort"); - if (effort !== undefined) reasoning["effort"] = effort; - } - const body: Record = { model, input, + // 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"], - reasoning, }; + 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. + 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; + } + 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"; @@ -271,13 +480,49 @@ function buildResponsesRequest( }; } +// 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, @@ -295,15 +540,34 @@ 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, + }; +} + export function createGrokResponsesAdapter(source: LastCycleSource): ProviderAdapter { - // Re-created per request in buildRequest — see codex-responses-adapter.ts. + // Re-created per request in buildRequest — see parseResponse below. let indexer = createResponsesBlockIndexer(); return { buildRequest: (messages, model, options) => { @@ -316,7 +580,7 @@ export function createGrokResponsesAdapter(source: LastCycleSource): ProviderAda } export function createOpenAIResponsesAdapter(source: LastCycleSource): ProviderAdapter { - // Re-created per request in buildRequest — see codex-responses-adapter.ts. + // Re-created per request in buildRequest — see parseResponse below. let indexer = createResponsesBlockIndexer(); return { buildRequest: (messages, model, options) => { @@ -328,3 +592,276 @@ export function createOpenAIResponsesAdapter(source: LastCycleSource): ProviderA isStreamTerminal: isResponsesStreamTerminal, }; } + +// --------------------------------------------------------------------------- +// Response parsing — Responses SSE events → internal inference events +// --------------------------------------------------------------------------- + +// Per-request block indexing. The Responses stream tags every streaming item +// with an `item_id`, so we allocate one content-block index per distinct item +// id (regardless of kind). Keying by item id — rather than one sticky index per +// kind — preserves true arrival order when reasoning, text, and tool calls +// interleave, and lets `response.output_item.done` attach an encrypted-reasoning +// signature to the exact thinking block it belongs to. `kind` is recorded so a +// signature is only emitted against a real thinking block. +type CodexBlockKind = "text" | "thinking" | "tool_call"; +export interface CodexBlockIndexer { + nextIndex: number; + items: Map; +} + +// 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() }; +} + +function blockIndexFor(state: CodexBlockIndexer, itemId: string, kind: CodexBlockKind): number { + const existing = state.items.get(itemId); + if (existing !== undefined) return existing.index; + const index = state.nextIndex; + state.nextIndex += 1; + state.items.set(itemId, { index, kind }); + 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; + const u = usage as Record; + const num = (v: unknown): number => (typeof v === "number" ? v : 0); + const inputDetails = u["input_tokens_details"] as Record | undefined; + const outputDetails = u["output_tokens_details"] as Record | undefined; + // Responses-API `input_tokens` counts the full prompt and `cached_tokens` + // is a subset of it. Downstream consumers (context meter, compaction + // governor, faremeter) treat the TokenUsage fields as non-overlapping and + // sum them, so the cached subset must be split out of input here — emitting + // the wire counts verbatim double-counts every cached token and inflates + // context occupancy up to ~2x on high cache-hit sessions. + const totalInputTokens = num(u["input_tokens"]); + const cachedTokens = num(inputDetails?.["cached_tokens"]); + return { + input: Math.max(0, totalInputTokens - cachedTokens), + output: num(u["output_tokens"]), + cacheRead: cachedTokens, + // OpenAI does not charge for writing to the prompt cache, so the public + // Responses API usually omits a write count; read it defensively under + // `cache_creation_tokens` in case a gateway/proxy in front of this + // OpenAI-shaped endpoint (Codex, Grok) reports one, rather than always + // hardcoding zero. + cacheWrite: num(inputDetails?.["cache_creation_tokens"]), + thinking: num(outputDetails?.["reasoning_tokens"]), + }; +} + +export function parseResponse( + sseData: string, + indexer: CodexBlockIndexer, + source: LastCycleSource, + label = "codex-responses", +): InferenceEvent[] { + let parsed: unknown; + try { + parsed = JSON.parse(sseData); + } catch (cause) { + throw new ProtocolMismatchError( + `${label} parseResponse: malformed JSON in SSE data payload: ${cause instanceof Error ? cause.message : String(cause)}`, + sseData, + ); + } + if (typeof parsed !== "object" || parsed === null) return []; + const event = parsed as Record; + const eventType = event["type"]; + if (typeof eventType !== "string") return []; + + const seq = 0; + const events: InferenceEvent[] = []; + + switch (eventType) { + case "response.output_text.delta": { + const token = event["delta"]; + const itemId = + typeof event["item_id"] === "string" ? (event["item_id"] as string) : "__text__"; + if (typeof token === "string" && token.length > 0) { + events.push({ + type: "inference.text.delta", + seq, + data: { token, partial: EMPTY_PARTIAL, index: blockIndexFor(indexer, itemId, "text") }, + }); + } + return events; + } + case "response.reasoning_summary_text.delta": + case "response.reasoning_text.delta": { + // Always register the block and emit a thinking delta (even for empty + // tokens). This ensures a preceding thinking block exists for any + // subsequent signature, supporting reasoning items whose visible + // summary may be empty or delivered only via the done envelope. + const token = event["delta"]; + const itemId = + typeof event["item_id"] === "string" ? (event["item_id"] as string) : "__thinking__"; + const index = blockIndexFor(indexer, itemId, "thinking"); + const tok = typeof token === "string" ? token : ""; + events.push({ + type: "inference.thinking.delta", + seq, + data: { token: tok, partial: EMPTY_PARTIAL, index }, + }); + return events; + } + case "response.output_item.added": { + const item = event["item"]; + if (typeof item === "object" && item !== null) { + const it = item as Record; + if (it["type"] === "function_call") { + const itemId = typeof it["id"] === "string" ? it["id"] : undefined; + const callId = it["call_id"]; + const name = it["name"]; + if (itemId !== undefined && typeof callId === "string" && typeof name === "string") { + events.push({ + type: "inference.tool_call.start", + seq, + data: { + callId, + name: decodeToolName(name), + partial: EMPTY_PARTIAL, + index: blockIndexFor(indexer, itemId, "tool_call"), + }, + }); + } + } else if (it["type"] === "reasoning") { + // Pre-register reasoning items on added so the index is stable + // even if no text deltas follow (pure-encrypted case). + const itemId = typeof it["id"] === "string" ? (it["id"] as string) : undefined; + if (itemId !== undefined) { + const index = blockIndexFor(indexer, itemId, "thinking"); + events.push({ + type: "inference.thinking.delta", + seq, + data: { token: "", partial: EMPTY_PARTIAL, index }, + }); + } + } + } + return events; + } + case "response.output_item.done": { + // Capture the encrypted reasoning blob (signature) so it can be echoed + // back on the next turn. Required for multi-turn continuity when the + // backend uses store:false + reasoning.encrypted_content. + // We ensure a thinking block exists (emitting an empty delta if this + // is the first signal for the item) so the harness can attach the + // signature without ProtocolMismatchError. + const item = event["item"] as Record | undefined; + if ( + item?.["type"] === "reasoning" && + typeof item["id"] === "string" && + typeof item["encrypted_content"] === "string" + ) { + const itemId = item["id"] as string; + const hadPrior = indexer.items.has(itemId); + const index = blockIndexFor(indexer, itemId, "thinking"); + if (!hadPrior) { + events.push({ + type: "inference.thinking.delta", + seq, + data: { token: "", partial: EMPTY_PARTIAL, index }, + }); + } + events.push({ + type: "inference.block.signature", + seq, + data: { + signature: tagSignature(source.provider, item["encrypted_content"] as string), + index, + }, + }); + } + return events; + } + case "response.function_call_arguments.delta": { + const itemId = event["item_id"]; + const fragment = event["delta"]; + if (typeof itemId === "string" && typeof fragment === "string" && fragment.length > 0) { + const blockIndex = blockIndexFor(indexer, itemId, "tool_call"); + events.push({ + type: "inference.tool_call.delta", + seq, + // The harness routes argument fragments by a per-stream placeholder + // keyed to the block index registered on the start event. + data: { + callId: String(blockIndex), + argumentFragment: fragment, + partial: EMPTY_PARTIAL, + index: blockIndex, + }, + }); + } + return events; + } + case "response.completed": { + const response = event["response"]; + if (typeof response === "object" && response !== null) { + const usage = usageFromResponse(response as Record); + if (usage !== undefined) { + events.push({ type: "inference.usage", seq, data: { usage, source } }); + } + } + return events; + } + case "response.failed": { + const response = event["response"] as Record | undefined; + const error = response?.["error"] as Record | undefined; + const message = typeof error?.["message"] === "string" ? error["message"] : "response failed"; + throw new ProtocolMismatchError(`${label}: ${message}`, parsed); + } + case "error": { + const message = typeof event["message"] === "string" ? event["message"] : "stream error"; + throw new ProtocolMismatchError(`${label}: ${message}`, parsed); + } + default: + // Lifecycle envelopes (response.created, response.in_progress, + // content_part.*, *_text.done) carry no incremental payload the harness + // needs; ignore them. + return events; + } +} + +// The Responses stream ends on a semantic lifecycle event, not `[DONE]` or a +// socket close: `response.completed` on success, `response.incomplete` when the +// backend truncates, `response.done` as an alias some backends emit. The +// harness reads this to stop the loop once the terminal event is processed; +// failure envelopes (`response.failed`, `error`) already throw in +// `parseResponse`, which terminates the loop through the harness's catch. +const RESPONSES_TERMINAL_EVENTS = new Set([ + "response.completed", + "response.incomplete", + "response.done", +]); + +// The Responses adapters in this file always request `stream: true` +// (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 { + throw new ProtocolMismatchError( + "responses adapter: received a non-streaming JSON response, but this adapter always requests stream: true", + ); +} + +export function isResponsesStreamTerminal(sseData: string): boolean { + let parsed: unknown; + try { + parsed = JSON.parse(sseData); + } catch { + // parseResponse re-parses the same payload and raises the protocol error; + // reporting "not terminal" here defers to that single throw site. + return false; + } + if (typeof parsed !== "object" || parsed === null) return false; + const eventType = (parsed as Record)["type"]; + return typeof eventType === "string" && RESPONSES_TERMINAL_EVENTS.has(eventType); +} 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 6b5f244ed..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/responses-adapters.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/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}`; From dacd6a37b8b9932c8d0466105cdf9d6ef44b365f Mon Sep 17 00:00:00 2001 From: Gustav Simonsson Date: Wed, 9 Sep 2026 13:44:23 -0400 Subject: [PATCH 4/7] Share SSE null-delta patch between openai-compatible and opencode adapters --- src/provider/openai-compatible-adapter.ts | 31 ++-------------- src/provider/opencode-go-adapter.ts | 31 +--------------- src/provider/sse-delta-patch.ts | 44 +++++++++++++++++++++++ 3 files changed, 47 insertions(+), 59 deletions(-) create mode 100644 src/provider/sse-delta-patch.ts 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/opencode-go-adapter.ts b/src/provider/opencode-go-adapter.ts index 53dbbde0c..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 "./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/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; +} From 173e4e1f6bbed3806364fd16714429d6ad1bd698 Mon Sep 17 00:00:00 2001 From: Gustav Simonsson Date: Wed, 9 Sep 2026 13:44:23 -0400 Subject: [PATCH 5/7] Share lexical search ranker across tool, skill, and agent search --- src/agent/agent-search.ts | 43 ++++++-------------- src/agent/lexical-search.ts | 80 +++++++++++++++++++++++++++++++++++++ src/agent/skill-search.ts | 36 ++++------------- src/agent/tool-search.ts | 42 +++++-------------- 4 files changed, 111 insertions(+), 90 deletions(-) create mode 100644 src/agent/lexical-search.ts 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); }, }; } From a06720d9ae2598f8d19f4a9dc4a86bbde2d23670 Mon Sep 17 00:00:00 2001 From: Gustav Simonsson Date: Wed, 9 Sep 2026 13:44:23 -0400 Subject: [PATCH 6/7] Share capped response-body reader between web_fetch and go model catalog --- src/provider/opencode-go-models.ts | 42 ++++--------------- src/tools/web-fetch.ts | 43 ++----------------- src/util/capped-body.ts | 67 ++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 75 deletions(-) create mode 100644 src/util/capped-body.ts 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/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/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 }; +} From 41dc4fc58290db346a15e79d04152be4ad8bce81 Mon Sep 17 00:00:00 2001 From: Gustav Simonsson Date: Wed, 9 Sep 2026 13:44:23 -0400 Subject: [PATCH 7/7] Share provider-catalog refresh between TUI connect and Go prefetch --- src/tui/runner/provider-refresh.ts | 79 ++++++++++++++++++++++++++++++ src/tui/runner/settings.ts | 49 +++--------------- src/tui/runner/wiring.ts | 34 +------------ 3 files changed, 87 insertions(+), 75 deletions(-) create mode 100644 src/tui/runner/provider-refresh.ts 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({