diff --git a/docs/models.md b/docs/models.md index e28816f2c1..c12f28e929 100644 --- a/docs/models.md +++ b/docs/models.md @@ -774,6 +774,7 @@ Request shaping: - `supportsStore` — emit `store: false` on requests. Default: auto (off for non-standard endpoints). - `supportsDeveloperRole` — use the `developer` system role for reasoning models instead of `system`. Default: auto. - `sendSessionHeaders` — forward the agent session id as `session_id` and `x-session-id` request headers so OpenAI-compatible relays/proxies can do session-affinity routing and reuse a server-side prompt cache. Default: `false`. Caller-set `headers`/`requestTransform` values are never overwritten. +- `supportsResponsesSessionAffinity` — for `openai-responses`, opt in to forwarding `session_id` and `x-client-request-id` affinity headers to a custom OpenAI-compatible relay. Canonical OpenAI routing remains automatic; known non-OpenAI provider IDs are rejected. Default: `false`. - `supportsUsageInStreaming` — send `stream_options: { include_usage: true }` to receive token usage on streaming responses. Default: `true`. - `maxTokensField` — `"max_completion_tokens"` or `"max_tokens"`. Default: auto. - `supportsToolChoice` — emit the `tool_choice` parameter when the caller forces a specific tool. Default: `true`. Set `false` for endpoints that 400 on `tool_choice` (e.g. DeepSeek when reasoning is on). diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 8cad412885..5a87b646af 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Added + +- Added opt-in `compat.supportsResponsesSessionAffinity` for OpenAI Responses custom relays. When enabled, supported `openai-responses` models may send `session_id` and `x-client-request-id` affinity headers to a custom endpoint; canonical OpenAI routing remains automatic and known non-OpenAI provider IDs remain excluded. ### Fixed diff --git a/packages/ai/README.md b/packages/ai/README.md index 26478d38e8..29fa6cb6ee 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -782,6 +782,7 @@ interface OpenAICompat { supportsStore?: boolean; // Whether provider supports the `store` field (default: true) supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true) sendSessionHeaders?: boolean; // Forward the session id as `session_id`/`x-session-id` headers for relay session-affinity & prompt-cache reuse (default: false) + supportsResponsesSessionAffinity?: boolean; // Opt in to session-affinity headers for custom openai-responses relays; canonical OpenAI routing is automatic (default: false) supportsReasoningEffort?: boolean; // Whether provider supports `reasoning_effort` (default: true) maxTokensField?: "max_completion_tokens" | "max_tokens"; // Which field name to use (default: max_completion_tokens) extraBody?: Record; // Extra request-body fields for custom proxy routing or provider-specific options diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts index 37f1120f4a..b325ffd6d8 100644 --- a/packages/ai/src/providers/openai-completions-compat.ts +++ b/packages/ai/src/providers/openai-completions-compat.ts @@ -6,13 +6,19 @@ type ResolvedToolStrictMode = NonNullable | "mix export type ResolvedOpenAICompat = Required< Omit< OpenAICompat, - "openRouterRouting" | "vercelGatewayRouting" | "extraBody" | "toolStrictMode" | "toolChoiceSupport" + | "openRouterRouting" + | "vercelGatewayRouting" + | "extraBody" + | "toolStrictMode" + | "toolChoiceSupport" + | "supportsResponsesSessionAffinity" > > & { openRouterRouting?: OpenAICompat["openRouterRouting"]; vercelGatewayRouting?: OpenAICompat["vercelGatewayRouting"]; extraBody?: OpenAICompat["extraBody"]; toolStrictMode: ResolvedToolStrictMode; + supportsResponsesSessionAffinity?: OpenAICompat["supportsResponsesSessionAffinity"]; /** Optional explicit capability override; resolved via deriveToolChoiceSupport. */ toolChoiceSupport?: OpenAICompat["toolChoiceSupport"]; }; @@ -204,6 +210,7 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB supportsStore: !isNonStandard, supportsDeveloperRole: !isNonStandard, sendSessionHeaders: false, + supportsResponsesSessionAffinity: false, supportsMultipleSystemMessages: supportsMultipleSystemMessagesDefault, supportsReasoningEffort: !isGrok && !isZai, reasoningEffortMap, @@ -270,6 +277,10 @@ export function resolveOpenAICompat( supportsStore: model.compat.supportsStore ?? detected.supportsStore, supportsDeveloperRole: model.compat.supportsDeveloperRole ?? detected.supportsDeveloperRole, sendSessionHeaders: model.compat.sendSessionHeaders ?? detected.sendSessionHeaders, + supportsResponsesSessionAffinity: + ("supportsResponsesSessionAffinity" in model.compat + ? model.compat.supportsResponsesSessionAffinity + : undefined) ?? detected.supportsResponsesSessionAffinity, supportsMultipleSystemMessages: model.compat.supportsMultipleSystemMessages ?? detected.supportsMultipleSystemMessages, supportsReasoningEffort: model.compat.supportsReasoningEffort ?? detected.supportsReasoningEffort, diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index a6db447a68..de1da13ee4 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -7,20 +7,21 @@ import type { } from "openai/resources/responses/responses"; import packageJson from "../../package.json" with { type: "json" }; import { getEnvApiKey } from "../stream"; -import type { - AssistantMessage, - CacheRetention, - Context, - FetchImpl, - MessageAttribution, - Model, - OpenAICompat, - ProviderSessionState, - ServiceTier, - StreamFunction, - StreamOptions, - Tool, - ToolChoice, +import { + type AssistantMessage, + type CacheRetention, + type Context, + type FetchImpl, + isKnownProvider, + type MessageAttribution, + type Model, + type OpenAICompat, + type ProviderSessionState, + type ServiceTier, + type StreamFunction, + type StreamOptions, + type Tool, + type ToolChoice, } from "../types"; import { createOpenAIResponsesHistoryPayload, @@ -142,6 +143,48 @@ function isDefaultOpenAIBaseUrl(baseUrl: string): boolean { } } +function isCanonicalOpenAIAffinityOrigin(baseUrl: string | undefined): boolean { + if (!baseUrl) return false; + try { + const url = new URL(baseUrl); + return ( + url.origin === "https://api.openai.com" && + url.username === "" && + url.password === "" && + (url.pathname === "" || url.pathname === "/" || url.pathname === "/v1") && + url.search === "" && + url.hash === "" + ); + } catch { + return false; + } +} +/** + * Official OpenAI keeps its existing session-routing behavior even when prompt + * caching is disabled. Relay affinity is opt-in, cache-enabled, and limited to + * explicitly supported openai or unknown provider ids so known non-target + * transports cannot inherit the headers. + */ + +function shouldSendOpenAIResponsesSessionHeaders( + model: Model<"openai-responses">, + baseUrl: string | undefined, + cacheRetention: CacheRetention, +): boolean { + if (model.provider === "openai") { + if (isCanonicalOpenAIAffinityOrigin(baseUrl)) return true; + return cacheRetention !== "none" && model.compat?.supportsResponsesSessionAffinity === true; + } + if (cacheRetention === "none" || isKnownProvider(model.provider)) { + return false; + } + return ( + Boolean(baseUrl?.trim()) && + model.compat?.supportsResponsesSessionAffinity === true && + !isCanonicalOpenAIAffinityOrigin(baseUrl) + ); +} + function isOpenAIHostBaseUrl(baseUrl: string): boolean { try { const url = new URL(baseUrl); @@ -305,6 +348,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( try { // Keep request headers and prompt-cache routing on the same session-derived value. const cacheSessionId = getOpenAIResponsesCacheSessionId(options); + const cacheRetention = resolveCacheRetention(options?.cacheRetention ?? model.cacheRetention); const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; const { client, copilotPremiumRequests, baseUrl, requestBaseUrl, requestQuery } = createClient( model, @@ -313,6 +357,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( options?.headers, options?.initiatorOverride, cacheSessionId, + cacheRetention, options?.onSseEvent, options?.fetch, options?.authCredentialType, @@ -323,7 +368,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( ); const premiumRequestsTotal = copilotPremiumRequests; const providerSessionState = getOpenAIResponsesProviderSessionState(model, options?.providerSessionState); - const { params } = buildParams(model, context, options, providerSessionState, baseUrl); + const { params } = buildParams(model, context, options, providerSessionState, cacheRetention, baseUrl); const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs(); options?.onPayload?.(params, undefined, options?.attemptScope); rawRequestDump = { @@ -469,6 +514,7 @@ function createClient( extraHeaders?: Record, initiatorOverride?: MessageAttribution, sessionId?: string, + cacheRetention?: CacheRetention, onSseEvent?: OpenAIResponsesOptions["onSseEvent"], fetchOverride?: FetchImpl, authCredentialType?: OpenAIResponsesOptions["authCredentialType"], @@ -502,11 +548,6 @@ function createClient( // `{...default, ...customHeaders}`). #3557. mergeDashScopeTokenPlanHeaders({ ...(model.headers ?? {}), ...(extraHeaders ?? {}) }) : { ...(model.headers ?? {}), ...(extraHeaders ?? {}) }; - const headers = applyOpenAIRequestTransformHeaders( - baseHeaders, - model.requestTransform, - `Gajae-Code/${packageJson.version}`, - ); let copilotPremiumRequests: number | undefined; let baseUrl = @@ -514,6 +555,7 @@ function createClient( if (model.provider === "openai" && !baseUrl) { baseUrl = OPENAI_DEFAULT_BASE_URL; } + let headers = baseHeaders; if (model.provider === "github-copilot") { apiKey = parseGitHubCopilotApiKey(rawApiKey).accessToken; const hasImages = hasCopilotVisionInput(context.messages); @@ -528,10 +570,11 @@ function createClient( copilotPremiumRequests = copilot.premiumRequests; baseUrl = resolveGitHubCopilotBaseUrl(model.baseUrl, rawApiKey) ?? model.baseUrl; } - if (sessionId && model.provider === "openai" && (!model.baseUrl || (baseUrl && isDefaultOpenAIBaseUrl(baseUrl)))) { + if (sessionId && shouldSendOpenAIResponsesSessionHeaders(model, baseUrl, cacheRetention ?? "short")) { headers.session_id ??= sessionId; headers["x-client-request-id"] ??= sessionId; } + headers = applyOpenAIRequestTransformHeaders(headers, model.requestTransform, `Gajae-Code/${packageJson.version}`); const { baseUrl: clientBaseUrl, query: endpointQuery } = splitBaseUrlQuery(baseUrl); const baseFetch = fetchOverride ?? fetch; const queryFetch = Object.assign( @@ -579,6 +622,7 @@ function buildParams( context: Context, options: OpenAIResponsesOptions | undefined, providerSessionState: OpenAIResponsesProviderSessionState | undefined, + cacheRetention: CacheRetention, resolvedBaseUrl?: string, ): { conversationMessages: ResponseInput; params: OpenAIResponsesSamplingParams } { const strictResponsesPairing = @@ -621,7 +665,6 @@ function buildParams( } } - const cacheRetention = resolveCacheRetention(options?.cacheRetention ?? model.cacheRetention); const promptCacheKey = getOpenAIResponsesCacheSessionId(options); const params: OpenAIResponsesSamplingParams = { model: model.wireModelId ?? model.id, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index d9e077a0ce..43c151e2c5 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -113,64 +113,73 @@ export interface ThinkingConfig { mode: ThinkingControlMode; } -export type KnownProvider = - | "alibaba-token-plan" - | "amazon-bedrock" - | "azure-openai" - | "anthropic" - | "google" - | "google-gemini-cli" - | "google-antigravity" - | "google-vertex" - | "openai" - | "openai-codex" - | "opencodex" - | "kimi-code" - | "minimax-code" - | "minimax-code-cn" - | "github-copilot" - | "fireworks" - | "firepass" - | "fugu" - | "gitlab-duo" - | "cursor" - | "deepseek" - | "deepinfra" - | "xai" - | "groq" - | "cerebras" - | "openrouter" - | "kilo" - | "vercel-ai-gateway" - | "zai" - | "glm-zcode" - | "mistral" - | "minimax" - | "opencode-go" - | "opencode-zen" - | "opengateway" - | "bizrouter" - | "mara" - | "synthetic" - | "cloudflare-ai-gateway" - | "huggingface" - | "litellm" - | "moonshot" - | "nvidia" - | "nanogpt" - | "ollama" - | "ollama-cloud" - | "qianfan" - | "qwen-portal" - | "together" - | "venice" - | "vllm" - | "xiaomi" - | "xiaomi-token-plan-sgp" - | "xiaomi-token-plan-ams" - | "xiaomi-token-plan-cn" - | "zenmux" - | "lm-studio"; +export const KNOWN_PROVIDERS = [ + "alibaba-token-plan", + "amazon-bedrock", + "azure-openai", + "anthropic", + "google", + "google-gemini-cli", + "google-antigravity", + "google-vertex", + "openai", + "openai-codex", + "opencodex", + "kimi-code", + "minimax-code", + "minimax-code-cn", + "github-copilot", + "fireworks", + "firepass", + "fugu", + "gitlab-duo", + "cursor", + "deepseek", + "deepinfra", + "xai", + "groq", + "cerebras", + "openrouter", + "kilo", + "vercel-ai-gateway", + "zai", + "glm-zcode", + "mistral", + "minimax", + "opencode-go", + "opencode-zen", + "opengateway", + "bizrouter", + "mara", + "synthetic", + "cloudflare-ai-gateway", + "huggingface", + "litellm", + "moonshot", + "nvidia", + "nanogpt", + "ollama", + "ollama-cloud", + "qianfan", + "qwen-portal", + "together", + "venice", + "vllm", + "xiaomi", + "xiaomi-token-plan-sgp", + "xiaomi-token-plan-ams", + "xiaomi-token-plan-cn", + "zenmux", + "lm-studio", +] as const; + +export type KnownProvider = (typeof KNOWN_PROVIDERS)[number]; + +const KNOWN_PROVIDER_SET = new Set(KNOWN_PROVIDERS); + +export function isKnownProvider(provider: string): provider is KnownProvider { + return KNOWN_PROVIDER_SET.has(provider); +} export type Provider = KnownProvider | string; import type { Effort } from "./model-thinking"; @@ -852,6 +861,13 @@ export interface OpenAICompat extends ToolChoiceCompat { * caller already set via `headers`/`requestTransform`. */ sendSessionHeaders?: boolean; + /** + * Whether an OpenAI Responses transport may forward the agent session id + * as `session_id` and `x-client-request-id` affinity headers for an + * explicitly configured custom relay. First-party OpenAI uses its canonical + * HTTPS origin automatically; known non-OpenAI providers remain excluded. + */ + supportsResponsesSessionAffinity?: boolean; /** * Whether the provider's chat-completions endpoint accepts multiple * leading `system`/`developer` messages. When false, ordered system diff --git a/packages/ai/test/composer-discipline.test.ts b/packages/ai/test/composer-discipline.test.ts index 9c3aad651e..711d725831 100644 --- a/packages/ai/test/composer-discipline.test.ts +++ b/packages/ai/test/composer-discipline.test.ts @@ -24,6 +24,7 @@ const compat: Required = { supportsStore: true, supportsDeveloperRole: false, sendSessionHeaders: false, + supportsResponsesSessionAffinity: false, supportsMultipleSystemMessages: true, supportsReasoningEffort: false, reasoningEffortMap: {}, diff --git a/packages/ai/test/issue-967-vision-guard.test.ts b/packages/ai/test/issue-967-vision-guard.test.ts index f3eadd1278..6c1329f332 100644 --- a/packages/ai/test/issue-967-vision-guard.test.ts +++ b/packages/ai/test/issue-967-vision-guard.test.ts @@ -23,6 +23,7 @@ const compat: Required = { supportsStore: true, supportsDeveloperRole: true, sendSessionHeaders: false, + supportsResponsesSessionAffinity: false, supportsMultipleSystemMessages: true, supportsReasoningEffort: true, reasoningEffortMap: {}, diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts index 64c1d0e539..e95cad6295 100644 --- a/packages/ai/test/openai-completions-compat.test.ts +++ b/packages/ai/test/openai-completions-compat.test.ts @@ -72,6 +72,7 @@ describe("openai-completions compatibility", () => { supportsStore: true, supportsDeveloperRole: true, sendSessionHeaders: false, + supportsResponsesSessionAffinity: false, supportsMultipleSystemMessages: true, supportsReasoningEffort: true, reasoningEffortMap: {}, diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index 28713f6fb1..e3d9561b61 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -16,6 +16,7 @@ const compat: Required = { supportsStore: true, supportsDeveloperRole: true, sendSessionHeaders: false, + supportsResponsesSessionAffinity: false, supportsMultipleSystemMessages: true, supportsReasoningEffort: true, reasoningEffortMap: {}, diff --git a/packages/ai/test/openai-responses-cache-affinity.test.ts b/packages/ai/test/openai-responses-cache-affinity.test.ts index 4e2204a121..8e17adc121 100644 --- a/packages/ai/test/openai-responses-cache-affinity.test.ts +++ b/packages/ai/test/openai-responses-cache-affinity.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { getBundledModel } from "../src/models"; import { type OpenAIResponsesOptions, streamOpenAIResponses } from "../src/providers/openai-responses"; -import type { Context, Model } from "../src/types"; +import type { AssistantMessage, Context, Model, ProviderSessionState } from "../src/types"; +import { createOpenAIResponsesHistoryPayload } from "../src/utils"; const originalFetch = global.fetch; const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; @@ -21,11 +22,18 @@ function getHeader(headers: RequestInit["headers"], name: string): string | null async function captureOpenAIResponseHeaders( options: OpenAIResponsesOptions, modelOverride: Model<"openai-responses"> = model, -): Promise<{ sessionId: string | null; clientRequestId: string | null; body: Record | null }> { + contextOverride?: Context, +): Promise<{ + sessionId: string | null; + clientRequestId: string | null; + body: Record | null; + message: AssistantMessage | null; +}> { const captured = { sessionId: null as string | null, clientRequestId: null as string | null, body: null as Record | null, + message: null as AssistantMessage | null, }; const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { captured.sessionId = getHeader(init?.headers, "session_id"); @@ -64,14 +72,18 @@ async function captureOpenAIResponseHeaders( }); global.fetch = Object.assign(fetchMock, { preconnect: originalFetch.preconnect }) as typeof fetch; - const context: Context = { + const context: Context = contextOverride ?? { systemPrompt: ["stable system", "stable durable context"], messages: [{ role: "user", content: "hi", timestamp: Date.now() }], }; const stream = streamOpenAIResponses(modelOverride, context, { apiKey: "test-key", ...options }); for await (const event of stream) { - if (event.type === "done" || event.type === "error") break; + if (event.type === "done") { + captured.message = event.message; + break; + } + if (event.type === "error") break; } return captured; @@ -83,29 +95,215 @@ afterEach(() => { }); describe("openai-responses cache affinity", () => { - it("sets session routing headers for official OpenAI Responses requests with a sessionId", async () => { + it("sets session routing headers for the canonical official OpenAI Responses origin", async () => { const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" }); expect(captured.sessionId).toBe("session-123"); expect(captured.clientRequestId).toBe("session-123"); expect(captured.body?.prompt_cache_key).toBe("session-123"); + expect(captured.body?.prompt_cache_retention).toBeUndefined(); }); - it("lets explicit headers override the default OpenAI session routing headers", async () => { - const captured = await captureOpenAIResponseHeaders({ - sessionId: "session-123", - headers: { - session_id: "override-session", - "x-client-request-id": "override-request", + it.each([ + "https://api.openai.com", + "https://api.openai.com/", + ])("sets affinity headers for the canonical official OpenAI Responses root origin %s", async baseUrl => { + const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" }, { ...model, baseUrl }); + + expect(captured.sessionId).toBe("session-123"); + expect(captured.clientRequestId).toBe("session-123"); + expect(captured.body?.prompt_cache_key).toBe("session-123"); + }); + + it("sets affinity headers for an explicitly opted-in openai-relay provider", async () => { + const captured = await captureOpenAIResponseHeaders( + { sessionId: "session-123" }, + { + ...model, + provider: "openai-relay", + baseUrl: "https://relay.example.com/v1", + compat: { ...model.compat, supportsResponsesSessionAffinity: true }, }, - }); + ); + + expect(captured.sessionId).toBe("session-123"); + expect(captured.clientRequestId).toBe("session-123"); + expect(captured.body?.prompt_cache_key).toBe("session-123"); + expect(captured.body?.prompt_cache_retention).toBeUndefined(); + }); + + it.each([ + "https://api.openai.com", + "https://api.openai.com/v1", + "https://api.openai.com/", + ])("does not set affinity headers for an unknown provider on a canonical OpenAI origin %s", async baseUrl => { + const captured = await captureOpenAIResponseHeaders( + { sessionId: "session-123" }, + { + ...model, + provider: "openai-relay", + baseUrl, + compat: { ...model.compat, supportsResponsesSessionAffinity: true }, + }, + ); + + expect(captured.sessionId).toBeNull(); + expect(captured.clientRequestId).toBeNull(); + }); + it("does not set affinity headers for an unknown provider without an explicit base URL", async () => { + const captured = await captureOpenAIResponseHeaders( + { sessionId: "session-123" }, + { + ...model, + provider: "openai-relay", + baseUrl: "", + compat: { ...model.compat, supportsResponsesSessionAffinity: true }, + }, + ); + + expect(captured.sessionId).toBeNull(); + expect(captured.clientRequestId).toBeNull(); + }); + + it("allows an explicit opt-in on the known openai provider when it uses a custom relay", async () => { + const captured = await captureOpenAIResponseHeaders( + { sessionId: "session-123" }, + { + ...model, + baseUrl: "https://relay.example.com/v1", + compat: { ...model.compat, supportsResponsesSessionAffinity: true }, + }, + ); - expect(captured.sessionId).toBe("override-session"); - expect(captured.clientRequestId).toBe("override-request"); + expect(captured.sessionId).toBe("session-123"); + expect(captured.clientRequestId).toBe("session-123"); + }); + + it("keeps an arbitrary relay default-off", async () => { + const captured = await captureOpenAIResponseHeaders( + { sessionId: "session-123" }, + { ...model, provider: "openai-relay", baseUrl: "https://relay.example.com/v1" }, + ); + + expect(captured.sessionId).toBeNull(); + expect(captured.clientRequestId).toBeNull(); expect(captured.body?.prompt_cache_key).toBe("session-123"); }); - it("keeps prompt_cache_key when cache retention is disabled", async () => { + it("excludes known non-target providers even when affinity is explicitly enabled", async () => { + const captured = await captureOpenAIResponseHeaders( + { sessionId: "session-123" }, + { + ...model, + provider: "github-copilot", + baseUrl: "https://relay.example.com/v1", + compat: { ...model.compat, supportsResponsesSessionAffinity: true }, + }, + ); + + expect(captured.sessionId).toBeNull(); + expect(captured.clientRequestId).toBeNull(); + }); + + it.each([ + "http://api.openai.com/v1", + "https://api.openai.com:8443/v1", + "https://api.openai.com/v2", + "https://user:password@api.openai.com/v1", + "https://api.openai.com/v1?tenant=relay", + ])("does not set automatic affinity headers for non-canonical origin %s", async baseUrl => { + const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" }, { ...model, baseUrl }); + + expect(captured.sessionId).toBeNull(); + expect(captured.clientRequestId).toBeNull(); + }); + + it("preserves model and request header precedence over affinity defaults", async () => { + const modelHeaders = await captureOpenAIResponseHeaders( + { sessionId: "session-123" }, + { + ...model, + headers: { + session_id: "model-session", + "x-client-request-id": "model-request", + }, + }, + ); + expect(modelHeaders.sessionId).toBe("model-session"); + expect(modelHeaders.clientRequestId).toBe("model-request"); + + const requestHeaders = await captureOpenAIResponseHeaders( + { + sessionId: "session-123", + headers: { + session_id: "request-session", + "x-client-request-id": "request-request", + }, + }, + { + ...model, + headers: { + session_id: "model-session", + "x-client-request-id": "model-request", + }, + }, + ); + expect(requestHeaders.sessionId).toBe("request-session"); + expect(requestHeaders.clientRequestId).toBe("request-request"); + expect(requestHeaders.body?.prompt_cache_key).toBe("session-123"); + }); + + it("preserves requestTransform strip, set, and null semantics", async () => { + const stripped = await captureOpenAIResponseHeaders( + { sessionId: "session-123" }, + { + ...model, + baseUrl: "https://relay.example.com/v1", + compat: { ...model.compat, supportsResponsesSessionAffinity: true }, + requestTransform: { + stripHeaders: ["session_id", "x-client-request-id"], + }, + }, + ); + expect(stripped.sessionId).toBeNull(); + expect(stripped.clientRequestId).toBeNull(); + + const set = await captureOpenAIResponseHeaders( + { sessionId: "session-123" }, + { + ...model, + baseUrl: "https://relay.example.com/v1", + compat: { ...model.compat, supportsResponsesSessionAffinity: true }, + requestTransform: { + setHeaders: { + session_id: "transform-session", + "x-client-request-id": "transform-request", + }, + }, + }, + ); + expect(set.sessionId).toBe("transform-session"); + expect(set.clientRequestId).toBe("transform-request"); + + const nulled = await captureOpenAIResponseHeaders( + { sessionId: "session-123" }, + { + ...model, + baseUrl: "https://relay.example.com/v1", + compat: { ...model.compat, supportsResponsesSessionAffinity: true }, + requestTransform: { + setHeaders: { + session_id: null, + "x-client-request-id": null, + }, + }, + }, + ); + expect(nulled.sessionId).toBeNull(); + expect(nulled.clientRequestId).toBeNull(); + }); + + it("keeps official affinity headers when retention is none but omits body retention", async () => { const captured = await captureOpenAIResponseHeaders({ cacheRetention: "none", sessionId: "session-123" }); expect(captured.sessionId).toBe("session-123"); @@ -114,43 +312,155 @@ describe("openai-responses cache affinity", () => { expect(captured.body?.prompt_cache_retention).toBeUndefined(); }); - it("uses model cacheRetention for OpenAI Responses retention when request omits cacheRetention", async () => { + it("gates opted-in relay affinity headers on effective retention", async () => { const captured = await captureOpenAIResponseHeaders( - { authCredentialType: "oauth", sessionId: "session-123" }, - { ...model, baseUrl: "https://api.openai.com/v1", cacheRetention: "long" }, + { cacheRetention: "none", sessionId: "session-123" }, + { + ...model, + provider: "openai-relay", + baseUrl: "https://relay.example.com/v1", + compat: { ...model.compat, supportsResponsesSessionAffinity: true }, + }, ); + expect(captured.sessionId).toBeNull(); + expect(captured.clientRequestId).toBeNull(); expect(captured.body?.prompt_cache_key).toBe("session-123"); - expect(captured.body?.prompt_cache_retention).toBe("24h"); + expect(captured.body?.prompt_cache_retention).toBeUndefined(); }); - it("lets explicit request cacheRetention win over model cacheRetention", async () => { + it.each(["short", "long"] as const)("uses the effective %s retention for relay affinity", async cacheRetention => { const captured = await captureOpenAIResponseHeaders( - { cacheRetention: "none", sessionId: "session-123" }, - { ...model, cacheRetention: "long" }, + { cacheRetention, sessionId: "session-123" }, + { + ...model, + provider: "openai-relay", + baseUrl: "https://relay.example.com/v1", + compat: { ...model.compat, supportsResponsesSessionAffinity: true }, + }, + ); + + expect(captured.sessionId).toBe("session-123"); + expect(captured.clientRequestId).toBe("session-123"); + expect(captured.body?.prompt_cache_key).toBe("session-123"); + expect(captured.body?.prompt_cache_retention).toBeUndefined(); + }); + + it("preserves protected body fields while allowing safe transform extras", async () => { + const captured = await captureOpenAIResponseHeaders( + { sessionId: "session-123" }, + { + ...model, + requestTransform: { + extraBody: { + prompt_cache_key: "wrong-key", + prompt_cache_retention: "wrong-retention", + store: true, + relay_marker: "present", + }, + }, + }, ); expect(captured.body?.prompt_cache_key).toBe("session-123"); expect(captured.body?.prompt_cache_retention).toBeUndefined(); + expect(captured.body?.store).toBe(false); + expect(captured.body?.relay_marker).toBe("present"); }); - it("uses GJC_CACHE_RETENTION when request and model omit cacheRetention", async () => { - const previous = Bun.env.GJC_CACHE_RETENTION; + it("keeps the same session identity when replaying provider-session-state history", async () => { + const providerSessionState = new Map(); + const options: OpenAIResponsesOptions = { + sessionId: "session-continuity", + providerSessionState, + }; + const firstContext: Context = { + messages: [{ role: "user", content: "first turn", timestamp: Date.now() }], + }; + const first = await captureOpenAIResponseHeaders(options, model, firstContext); + expect(first.message).not.toBeNull(); + (first.message as AssistantMessage).providerPayload = createOpenAIResponsesHistoryPayload("openai", [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "native replay marker" }], + status: "completed", + }, + ]); + const replayed = await captureOpenAIResponseHeaders(options, model, { + messages: [ + ...firstContext.messages, + first.message as AssistantMessage, + { role: "user", content: "follow-up turn", timestamp: Date.now() }, + ], + }); + + expect([first.sessionId, replayed.sessionId]).toEqual(["session-continuity", "session-continuity"]); + expect([first.clientRequestId, replayed.clientRequestId]).toEqual(["session-continuity", "session-continuity"]); + expect([first.body?.prompt_cache_key, replayed.body?.prompt_cache_key]).toEqual([ + "session-continuity", + "session-continuity", + ]); + const replayedInput = replayed.body?.input as Array>; + expect(replayedInput).toContainEqual({ + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "native replay marker" }], + status: "completed", + }); + expect(providerSessionState.size).toBe(1); + }); + + it("uses model retention when the request omits it and request retention takes precedence", async () => { + const modelRetention = await captureOpenAIResponseHeaders( + { authCredentialType: "oauth", sessionId: "session-123" }, + { ...model, baseUrl: "https://api.openai.com/v1", cacheRetention: "long" }, + ); + expect(modelRetention.body?.prompt_cache_key).toBe("session-123"); + expect(modelRetention.body?.prompt_cache_retention).toBe("24h"); + + const requestRetention = await captureOpenAIResponseHeaders( + { authCredentialType: "oauth", cacheRetention: "none", sessionId: "session-123" }, + { ...model, cacheRetention: "long" }, + ); + expect(requestRetention.body?.prompt_cache_key).toBe("session-123"); + expect(requestRetention.body?.prompt_cache_retention).toBeUndefined(); + }); + + it("isolates environment retention overrides", async () => { + const previousGjc = Bun.env.GJC_CACHE_RETENTION; + const previousPi = Bun.env.PI_CACHE_RETENTION; Bun.env.GJC_CACHE_RETENTION = "long"; + delete Bun.env.PI_CACHE_RETENTION; try { const captured = await captureOpenAIResponseHeaders( { authCredentialType: "oauth", sessionId: "session-123" }, { ...model, baseUrl: "https://api.openai.com/v1" }, ); - - expect(captured.body?.prompt_cache_key).toBe("session-123"); expect(captured.body?.prompt_cache_retention).toBe("24h"); } finally { - if (previous === undefined) { - delete Bun.env.GJC_CACHE_RETENTION; - } else { - Bun.env.GJC_CACHE_RETENTION = previous; - } + if (previousGjc === undefined) delete Bun.env.GJC_CACHE_RETENTION; + else Bun.env.GJC_CACHE_RETENTION = previousGjc; + if (previousPi === undefined) delete Bun.env.PI_CACHE_RETENTION; + else Bun.env.PI_CACHE_RETENTION = previousPi; + } + }); + + it("respects custom and HTTP OPENAI_BASE_URL without treating them as canonical affinity origins", async () => { + const previous = Bun.env.OPENAI_BASE_URL; + try { + Bun.env.OPENAI_BASE_URL = "https://relay.example.com/v1"; + const custom = await captureOpenAIResponseHeaders({ sessionId: "session-123" }); + expect(custom.sessionId).toBeNull(); + expect(custom.clientRequestId).toBeNull(); + + Bun.env.OPENAI_BASE_URL = "http://api.openai.com/v1"; + const http = await captureOpenAIResponseHeaders({ sessionId: "session-123" }); + expect(http.sessionId).toBeNull(); + expect(http.clientRequestId).toBeNull(); + } finally { + if (previous === undefined) delete Bun.env.OPENAI_BASE_URL; + else Bun.env.OPENAI_BASE_URL = previous; } }); }); diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 3796ff84a0..6f6daa1178 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -13,6 +13,7 @@ import { getBundledProviders, googleAntigravityModelManagerOptions, googleGeminiCliModelManagerOptions, + isKnownProvider, type Model, type ModelManagerOptions, type ModelRefreshStrategy, @@ -256,11 +257,51 @@ function getKnownProviderModelApi(providerName: string, modelId: string): Api | ?.api as Api | undefined; } +function isCanonicalOpenAIAffinityBaseUrl(baseUrl: string | undefined): boolean { + if (!baseUrl) return false; + try { + const url = new URL(baseUrl); + return ( + url.origin === "https://api.openai.com" && + url.username === "" && + url.password === "" && + (url.pathname === "/" || url.pathname === "/v1") && + url.search === "" && + url.hash === "" + ); + } catch { + return false; + } +} + +function assertResponsesSessionAffinitySupported( + providerName: string, + api: Api | undefined, + baseUrl: string | undefined, + source: string, +): void { + if (isKnownProvider(providerName) && providerName !== "openai") { + throw new Error( + `Provider ${providerName}: ${source} is only supported for the openai provider or unknown user-defined provider IDs.`, + ); + } + if (api !== "openai-responses") { + throw new Error(`Provider ${providerName}: ${source} is only supported with the openai-responses API.`); + } + if (!isKnownProvider(providerName) && (!baseUrl?.trim() || isCanonicalOpenAIAffinityBaseUrl(baseUrl))) { + throw new Error( + `Provider ${providerName}: ${source} requires a genuinely custom base URL for unknown provider IDs.`, + ); + } +} + interface ProviderValidationModel { id: string; + baseUrl?: string; api?: Api; contextWindow?: number; maxTokens?: number; + compat?: Model["compat"]; requestTransform?: ModelRequestTransform; } @@ -342,13 +383,49 @@ function validateProviderConfiguration( if (mode === "models-config" && config.discovery && !config.api) { throw new Error(`Provider ${providerName}: "api" is required when discovery is enabled at provider level.`); } + const configCompat = config.compat; + if ( + configCompat && + "supportsResponsesSessionAffinity" in configCompat && + configCompat.supportsResponsesSessionAffinity !== undefined + ) { + const source = '"compat.supportsResponsesSessionAffinity"'; + if (models.length > 0) { + for (const model of models) { + assertResponsesSessionAffinitySupported( + providerName, + model.api ?? config.api ?? getKnownProviderModelApi(providerName, model.id), + model.baseUrl ?? config.baseUrl, + source, + ); + } + } else if (config.api) { + assertResponsesSessionAffinitySupported(providerName, config.api, config.baseUrl, source); + } else { + const knownApis = getKnownProviderApis(providerName); + if (knownApis.size === 0) { + assertResponsesSessionAffinitySupported(providerName, undefined, config.baseUrl, source); + } + for (const api of knownApis) { + assertResponsesSessionAffinitySupported(providerName, api, config.baseUrl, source); + } + } + } for (const [modelId, rawOverride] of Object.entries(config.modelOverrides ?? {})) { const override = rawOverride as ModelOverride; - if (!override.requestTransform) continue; const effectiveApi = models.find(model => model.id === modelId)?.api ?? config.api ?? getKnownProviderModelApi(providerName, modelId); + if (override.compat?.supportsResponsesSessionAffinity !== undefined) { + assertResponsesSessionAffinitySupported( + providerName, + effectiveApi, + config.baseUrl, + `modelOverrides ${modelId} "compat.supportsResponsesSessionAffinity"`, + ); + } + if (!override.requestTransform) continue; if (effectiveApi) { assertRequestTransformSupportedForModelApi( providerName, @@ -383,6 +460,15 @@ function validateProviderConfiguration( throw new Error(`Provider ${providerName}: model missing "id"`); } const effectiveApi = modelDef.api ?? config.api; + const modelCompat = modelDef.compat; + if (modelCompat && "supportsResponsesSessionAffinity" in modelCompat) { + assertResponsesSessionAffinitySupported( + providerName, + effectiveApi, + modelDef.baseUrl ?? config.baseUrl, + `model ${modelDef.id} "compat.supportsResponsesSessionAffinity"`, + ); + } if (config.requestTransform && effectiveApi) { assertRequestTransformSupportedForModelApi( providerName, @@ -719,6 +805,26 @@ function mergeCompat( return merged as TBase & TOverride; } +function mergeProviderCompat( + baseCompat: Model["compat"], + overrideCompat: Model["compat"], +): Model["compat"] { + const merged = mergeCompat(baseCompat, overrideCompat); + // An explicit model-level opt-out must win over a provider-level opt-in. + const baseAffinity = + baseCompat && "supportsResponsesSessionAffinity" in baseCompat + ? baseCompat.supportsResponsesSessionAffinity + : undefined; + const overrideAffinity = + overrideCompat && "supportsResponsesSessionAffinity" in overrideCompat + ? overrideCompat.supportsResponsesSessionAffinity + : undefined; + if (baseAffinity === false && overrideAffinity !== undefined) { + return { ...merged, supportsResponsesSessionAffinity: false }; + } + return merged; +} + function mergeRequestTransform( base: ModelRequestTransform | undefined, override: ModelRequestTransform | undefined, @@ -1362,7 +1468,6 @@ export class ModelRegistry { const withTransportOverride = this.#applyProviderTransportOverride(m, providerOverride); return { ...withTransportOverride, - compat: mergeCompat(m.compat, providerOverride.compat), cacheRetention: m.cacheRetention ?? providerOverride.cacheRetention, }; }); @@ -1462,10 +1567,7 @@ export class ModelRegistry { const withTransport = providerOverride ? models.map(model => this.#applyProviderTransportOverride(model, providerOverride)) : models; - const withCompat = providerOverride?.compat - ? withTransport.map(model => ({ ...model, compat: mergeCompat(model.compat, providerOverride.compat) })) - : withTransport; - cachedModels.push(...this.#applyProviderModelOverrides(descriptor.providerId, withCompat)); + cachedModels.push(...this.#applyProviderModelOverrides(descriptor.providerId, withTransport)); } return cachedModels; } @@ -2940,12 +3042,24 @@ export class ModelRegistry { }; } #applyProviderTransportOverride< - T extends { baseUrl?: string; headers?: Record; cacheRetention?: CacheRetention }, + T extends { + baseUrl?: string; + headers?: Record; + compat?: Model["compat"]; + cacheRetention?: CacheRetention; + }, >( entry: T, override: Pick< ProviderOverride, - "baseUrl" | "headers" | "authHeader" | "apiKey" | "transport" | "requestTransform" | "cacheRetention" + | "baseUrl" + | "headers" + | "authHeader" + | "apiKey" + | "compat" + | "transport" + | "requestTransform" + | "cacheRetention" >, ): T { const headers = mergeAuthHeader( @@ -2955,6 +3069,7 @@ export class ModelRegistry { ); return { ...entry, + compat: mergeProviderCompat(entry.compat, override.compat), baseUrl: override.baseUrl ?? entry.baseUrl, headers, // Preserve the model's existing transport when the override omits one; @@ -2967,12 +3082,19 @@ export class ModelRegistry { cacheRetention: entry.cacheRetention ?? override.cacheRetention, }; } + #applyRuntimeProviderOverride(model: Model, override: ProviderOverride): Model { + const withTransportOverride = this.#applyProviderTransportOverride(model, override); + const modelCompat = this.#modelOverrides.get(model.provider)?.get(model.id)?.compat; + return modelCompat + ? { ...withTransportOverride, compat: mergeCompat(withTransportOverride.compat, modelCompat) } + : withTransportOverride; + } #applyRuntimeProviderOverrides(models: Model[]): Model[] { if (this.#runtimeProviderOverrides.size === 0) return models; return models.map(model => { const override = this.#runtimeProviderOverrides.get(model.provider); if (!override) return model; - return this.#applyProviderTransportOverride(model, override); + return this.#applyRuntimeProviderOverride(model, override); }); } #applyModelOverrides(models: Model[], overrides: Map>): Model[] { @@ -3558,6 +3680,7 @@ export class ModelRegistry { apiKey: config.apiKey, api: config.api, oauthConfigured: Boolean(config.oauth), + compat: config.compat, requestTransform: config.requestTransform, models: (config.models ?? []) as ProviderValidationModel[], }, @@ -3648,7 +3771,7 @@ export class ModelRegistry { const withRuntimeTransportOverride = runtimeTransportOverride ? nextModels.map(model => { if (model.provider !== providerName) return model; - return this.#applyProviderTransportOverride(model, runtimeTransportOverride); + return this.#applyRuntimeProviderOverride(model, runtimeTransportOverride); }) : nextModels; @@ -3675,6 +3798,7 @@ export class ModelRegistry { config.headers || config.apiKey || config.authHeader !== undefined || + config.compat !== undefined || config.requestTransform !== undefined || config.transport !== undefined ) { @@ -3683,6 +3807,7 @@ export class ModelRegistry { headers: config.headers, apiKey: config.apiKey, authHeader: config.authHeader, + compat: config.compat, requestTransform: config.requestTransform, transport: config.transport, }; @@ -3693,7 +3818,7 @@ export class ModelRegistry { this.#runtimeProviderOverrides.set(providerName, nextRuntimeOverride); this.#models = this.#models.map(m => { if (m.provider !== providerName) return m; - return this.#applyProviderTransportOverride(m, transportOverride); + return this.#applyRuntimeProviderOverride(m, transportOverride); }); this.#rebuildCanonicalIndex(); this.#rebuildProviderActivity(); diff --git a/packages/coding-agent/src/config/models-config-schema.ts b/packages/coding-agent/src/config/models-config-schema.ts index 4c1f266752..4f4a7e84e1 100644 --- a/packages/coding-agent/src/config/models-config-schema.ts +++ b/packages/coding-agent/src/config/models-config-schema.ts @@ -24,6 +24,7 @@ export const ModelCompatSchema = z.object({ supportsStore: z.boolean().optional(), supportsDeveloperRole: z.boolean().optional(), sendSessionHeaders: z.boolean().optional(), + supportsResponsesSessionAffinity: z.boolean().optional(), supportsMultipleSystemMessages: z.boolean().optional(), supportsReasoningEffort: z.boolean().optional(), reasoningEffortMap: ReasoningEffortMapSchema.optional(), diff --git a/packages/coding-agent/test/model-registry-runtime-provider.test.ts b/packages/coding-agent/test/model-registry-runtime-provider.test.ts index c911ae20aa..dd535de24a 100644 --- a/packages/coding-agent/test/model-registry-runtime-provider.test.ts +++ b/packages/coding-agent/test/model-registry-runtime-provider.test.ts @@ -200,6 +200,56 @@ describe("ModelRegistry runtime provider registration", () => { registry.clearSourceRegistrations("ext://runtime"); expectProviderHeader(registry, providerName, "Authorization", undefined); }); + test("registerProvider applies provider-only responses affinity compat across refresh", async () => { + const registry = new ModelRegistry(authStorage, modelsJsonPath); + registry.registerProvider( + "openai", + { + baseUrl: "https://openai-relay.example.com/v1", + api: "openai-responses", + compat: { supportsResponsesSessionAffinity: true }, + }, + "ext://runtime", + ); + + const readAffinity = () => + (registry.find("openai", "gpt-4o-mini")?.compat as { supportsResponsesSessionAffinity?: boolean } | undefined) + ?.supportsResponsesSessionAffinity; + expect(readAffinity()).toBe(true); + await registry.refresh("offline"); + expect(readAffinity()).toBe(true); + }); + test("model-level false survives a later runtime provider compat override", async () => { + const registry = new ModelRegistry(authStorage, modelsJsonPath); + registry.registerProvider( + "relay", + { + baseUrl: "https://relay.example.com/v1", + api: "openai-responses", + apiKey: "RUNTIME_KEY", + models: [{ ...baseModel, compat: { supportsResponsesSessionAffinity: false } }], + }, + "ext://runtime", + ); + registry.registerProvider( + "relay", + { + // Unknown provider IDs still require a genuinely custom base URL when + // enabling affinity, even on a transport-only re-registration. + baseUrl: "https://relay.example.com/v1", + api: "openai-responses", + compat: { supportsResponsesSessionAffinity: true }, + }, + "ext://runtime", + ); + + const readAffinity = () => + (registry.find("relay", "runtime-model")?.compat as { supportsResponsesSessionAffinity?: boolean } | undefined) + ?.supportsResponsesSessionAffinity; + expect(readAffinity()).toBe(false); + await registry.refresh("offline"); + expect(readAffinity()).toBe(false); + }); test("registerProvider preserves explicit thinking on runtime models", () => { const registry = new ModelRegistry(authStorage, modelsJsonPath); diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 4ccd601fdc..277c813c8c 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -1400,6 +1400,21 @@ describe("ModelRegistry", () => { expect(getOpenAICompat(model)?.allowsSyntheticReasoningContentForToolCalls).toBe(false); } }); + test("provider-level responses affinity applies to bundled OpenAI models", async () => { + writeRawModelsJson({ + openai: { + baseUrl: "https://openai-relay.example.com/v1", + api: "openai-responses", + apiKey: "TEST_KEY", + compat: { supportsResponsesSessionAffinity: true }, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + expect(getOpenAICompat(registry.find("openai", "gpt-4o-mini"))?.supportsResponsesSessionAffinity).toBe(true); + await registry.refresh("offline"); + expect(getOpenAICompat(registry.find("openai", "gpt-4o-mini"))?.supportsResponsesSessionAffinity).toBe(true); + }); test("provider-level compat applies to custom models", () => { writeRawModelsJson({ @@ -1464,6 +1479,32 @@ describe("ModelRegistry", () => { expect(compat?.supportsUsageInStreaming).toBe(true); expect(compat?.maxTokensField).toBe("max_completion_tokens"); }); + test("model-level false overrides provider-level responses affinity", async () => { + writeRawModelsJson({ + relay: { + baseUrl: "https://relay.example.com/v1", + apiKey: "TEST_KEY", + api: "openai-responses", + compat: { supportsResponsesSessionAffinity: true }, + models: [ + { + id: "relay-model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + compat: { supportsResponsesSessionAffinity: false }, + }, + ], + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + expect(getOpenAICompat(registry.find("relay", "relay-model"))?.supportsResponsesSessionAffinity).toBe(false); + await registry.refresh("offline"); + expect(getOpenAICompat(registry.find("relay", "relay-model"))?.supportsResponsesSessionAffinity).toBe(false); + }); }); describe("custom models merge behavior", () => { @@ -4466,6 +4507,84 @@ describe("ModelRegistry", () => { expect(model?.wireModelId).toBe("proxy-gpt-4o-mini"); expect(model?.requestTransform).toEqual({ extraBody: { routed: true } }); }); + test("rejects responses affinity on known non-target providers", () => { + writeRawModelsConfig({ + providers: { + anthropic: { + baseUrl: "https://relay.example.com/v1", + api: "openai-responses", + compat: { supportsResponsesSessionAffinity: true }, + }, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + expect(String(registry.getError()?.message)).toContain("only supported for the openai provider"); + }); + + test.each([ + "https://api.openai.com", + "https://api.openai.com/v1", + "https://api.openai.com/", + ])("rejects unknown-provider responses affinity on a canonical OpenAI base URL %s", baseUrl => { + writeRawModelsConfig({ + providers: { + relay: { + baseUrl, + api: "openai-responses", + compat: { supportsResponsesSessionAffinity: true }, + }, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + expect(String(registry.getError()?.message)).toContain("requires a genuinely custom base URL"); + }); + test("rejects unknown-provider responses affinity without a base URL", () => { + writeRawModelsConfig({ + providers: { + relay: { + api: "openai-responses", + compat: { supportsResponsesSessionAffinity: true }, + }, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + expect(String(registry.getError()?.message)).toContain("requires a genuinely custom base URL"); + }); + test("rejects responses affinity on non-Responses APIs", () => { + writeRawModelsConfig({ + providers: { + relay: { + baseUrl: "https://relay.example.com/v1", + apiKey: "TEST_KEY", + api: "openai-completions", + compat: { supportsResponsesSessionAffinity: true }, + }, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + expect(String(registry.getError()?.message)).toContain("only supported with the openai-responses API"); + }); + + test("rejects provider affinity inherited by a non-Responses model API", () => { + writeRawModelsConfig({ + providers: { + relay: { + baseUrl: "https://relay.example.com/v1", + api: "openai-responses", + apiKey: "TEST_KEY", + compat: { supportsResponsesSessionAffinity: true }, + models: [{ id: "chat-model", api: "openai-completions" }], + }, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + expect(String(registry.getError()?.message)).toContain("only supported with the openai-responses API"); + }); describe("generic local OpenAI-compatible provider config", () => { test("does not add a generic local provider by default", () => { const registry = new ModelRegistry(authStorage, modelsJsonPath); diff --git a/packages/coding-agent/test/models-config-send-session-headers.test.ts b/packages/coding-agent/test/models-config-send-session-headers.test.ts index fd78c22a2e..df4878b53e 100644 --- a/packages/coding-agent/test/models-config-send-session-headers.test.ts +++ b/packages/coding-agent/test/models-config-send-session-headers.test.ts @@ -23,6 +23,39 @@ describe("models config sendSessionHeaders", () => { }); expect(result.success).toBe(true); }); + test("accepts supportsResponsesSessionAffinity in provider and model compat", () => { + const result = ModelsConfigSchema.safeParse({ + providers: { + relay: { + baseUrl: "https://relay.example.com/v1", + api: "openai-responses", + compat: { supportsResponsesSessionAffinity: true }, + models: [ + { + id: "relay-model", + name: "Relay", + contextWindow: 128000, + maxTokens: 8192, + compat: { supportsResponsesSessionAffinity: false }, + }, + ], + }, + }, + }); + expect(result.success).toBe(true); + }); + test("rejects a non-boolean supportsResponsesSessionAffinity value", () => { + const result = ModelsConfigSchema.safeParse({ + providers: { + relay: { + baseUrl: "https://relay.example.com/v1", + api: "openai-responses", + compat: { supportsResponsesSessionAffinity: "yes" }, + }, + }, + }); + expect(result.success).toBe(false); + }); test("rejects a non-boolean sendSessionHeaders value", () => { const result = ModelsConfigSchema.safeParse({ diff --git a/schemas/models.schema.json b/schemas/models.schema.json index 2f0450eb59..e71f6b2255 100644 --- a/schemas/models.schema.json +++ b/schemas/models.schema.json @@ -62,6 +62,9 @@ "sendSessionHeaders": { "type": "boolean" }, + "supportsResponsesSessionAffinity": { + "type": "boolean" + }, "supportsMultipleSystemMessages": { "type": "boolean" }, @@ -505,6 +508,9 @@ "sendSessionHeaders": { "type": "boolean" }, + "supportsResponsesSessionAffinity": { + "type": "boolean" + }, "supportsMultipleSystemMessages": { "type": "boolean" }, @@ -888,6 +894,9 @@ "sendSessionHeaders": { "type": "boolean" }, + "supportsResponsesSessionAffinity": { + "type": "boolean" + }, "supportsMultipleSystemMessages": { "type": "boolean" },