diff --git a/src/images/loop.ts b/src/images/loop.ts index e1eb47f089..5a2efd715f 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -900,7 +900,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise { + produce(), parsed._responseModelId ?? parsed.modelId, toolNsMap, freeform, toolSearch, () => { internalAbort.abort("client closed responses stream"); }, 2_000, { diff --git a/src/server/request-log.ts b/src/server/request-log.ts index 4e317967b6..2bbd881954 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -68,6 +68,8 @@ export interface RequestLogContext { modelSupportsServiceTier?: boolean; responseServiceTier?: string; resolvedModel?: string; + /** Internal: client-facing response metadata must not replace the physical routed model. */ + preserveResolvedModelFromRoute?: boolean; usage?: OcxUsage; usageLogInputTokens?: number; attempts?: PersistedUsageAttempt[]; @@ -512,7 +514,11 @@ export function applyResponseLogMetadata(logCtx: RequestLogContext, payload: unk : payload; if (!source || typeof source !== "object") return; const model = (source as { model?: unknown }).model; - if (typeof model === "string" && model.trim()) logCtx.resolvedModel = model; + if ( + !logCtx.preserveResolvedModelFromRoute + && typeof model === "string" + && model.trim() + ) logCtx.resolvedModel = model; const serviceTier = (source as { service_tier?: unknown }).service_tier; if (typeof serviceTier === "string" && serviceTier.trim()) logCtx.responseServiceTier = serviceTier; const usage = usageFromResponsesPayload((source as { usage?: unknown }).usage); diff --git a/src/server/responses-model-rewrite.ts b/src/server/responses-model-rewrite.ts new file mode 100644 index 0000000000..b0ed8a3e6c --- /dev/null +++ b/src/server/responses-model-rewrite.ts @@ -0,0 +1,29 @@ +import type { SsePayloadRewrite } from "./sse-payload-rewrite"; + +function rewriteResponseObjectModel(value: unknown, responseModelId: string): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const response = value as Record; + if (typeof response.model !== "string" || response.model === responseModelId) return false; + response.model = responseModelId; + return true; +} + +/** Rewrite only existing Responses model metadata; unrelated and malformed payloads stay byte-identical. */ +export function rewriteResponsesModelJson(json: string, responseModelId: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return json; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return json; + const record = parsed as Record; + const rootChanged = rewriteResponseObjectModel(record, responseModelId); + const nestedChanged = rewriteResponseObjectModel(record.response, responseModelId); + const changed = rootChanged || nestedChanged; + return changed ? JSON.stringify(record) : json; +} + +export function createResponsesModelPayloadRewrite(responseModelId: string): SsePayloadRewrite { + return payload => rewriteResponsesModelJson(payload, responseModelId); +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 465f3ef452..c0d9bf8373 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -172,6 +172,7 @@ import { restoreImageGenCallsInJson, } from "../responses-image-gen-repair"; import { composeSsePayloadRewrites, relaySseWithPayloadRewrite } from "../sse-payload-rewrite"; +import { createResponsesModelPayloadRewrite, rewriteResponsesModelJson } from "../responses-model-rewrite"; import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog"; import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration"; @@ -864,6 +865,12 @@ async function applyFinalRouteRequestNormalization(args: { }): Promise { const { parsed, route, config, req, logCtx, inboundWire, inboundTransport } = args; + // Only Anthropic message routes retain the Codex-facing selector. Other providers must keep + // their existing response.model contract even when their public and wire model ids differ. + const responseModelId = parsed.modelId; + const preserveAnthropicResponseModel = route.providerName === "anthropic" + || route.provider.adapter === "anthropic"; + // Apply the routed model id upstream: routing may strip a "/" namespace. if (route.modelId !== parsed.modelId) { if (parsed._rawBody && typeof parsed._rawBody === "object") { @@ -882,6 +889,7 @@ async function applyFinalRouteRequestNormalization(args: { // Settle the wire once so logging, fast-mode, auth, and sidecars read the adapter // this request will actually use (#404). route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); + if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId; logCtx.model = route.modelId; logCtx.provider = route.providerName; logCtx.providerAdapter = route.provider.adapter; @@ -899,6 +907,10 @@ async function applyFinalRouteRequestNormalization(args: { // Virtual model rewriting: Pro aliases → base model + reasoning.mode="pro". applyOpenAiVirtualModel(parsed, route, logCtx); + if (parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId) { + logCtx.resolvedModel = route.modelId; + logCtx.preserveResolvedModelFromRoute = true; + } // Fast mode override for OpenAI-routed models, only where the provider's Responses // route documents `service_tier` support (capability gate below strips everywhere else). @@ -2061,13 +2073,21 @@ async function handleResponsesInner( if (isEventStream && upstreamResponse.body) { const repairConfig = route.provider.responsesItemIdRepair; const snapshotRepairEnabled = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair); - const needsClientRewrite = imageGenCallAliases.size > 0 || hasResponsesItemIdRepair(repairConfig) || snapshotRepairEnabled; + const responseModelRewrite = parsed._responseModelId !== undefined + && parsed._responseModelId !== parsed.modelId + ? createResponsesModelPayloadRewrite(parsed._responseModelId) + : undefined; + const needsClientRewrite = imageGenCallAliases.size > 0 + || hasResponsesItemIdRepair(repairConfig) + || snapshotRepairEnabled + || responseModelRewrite !== undefined; // Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first). const payloadRewrites = [ createImageGenCallRestoreRewrite(imageGenCallAliases), hasResponsesItemIdRepair(repairConfig) ? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget) : undefined, + responseModelRewrite, ].filter((rewrite): rewrite is NonNullable => rewrite !== undefined); // #893: sparse-snapshot gateways get field backfills AND lifecycle event // injection at the block level, after payload rewrites. Defaults come @@ -2259,14 +2279,19 @@ async function handleResponsesInner( } const clientJson = (() => { const restored = restoreImageGenCallsInJson(text, imageGenCallAliases); - if (!hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair)) return restored; - let outbound: unknown; - try { - outbound = JSON.parse(request.body); - } catch { - outbound = undefined; - } - return repairResponsesSnapshotJson(restored, outbound); + const repaired = (() => { + if (!hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair)) return restored; + let outbound: unknown; + try { + outbound = JSON.parse(request.body); + } catch { + outbound = undefined; + } + return repairResponsesSnapshotJson(restored, outbound); + })(); + return parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId + ? rewriteResponsesModelJson(repaired, parsed._responseModelId) + : repaired; })(); // #875: the transport-neutral reliability policy forced a bounded JSON // upstream for a client that asked for SSE. Reframe the completed JSON @@ -2569,7 +2594,7 @@ async function handleResponsesInner( eventSource = preflight.stream; } const sseStream = bridgeToResponsesSSE( - eventSource, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, + eventSource, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, () => { runTurnAbort.abort(); queue.close(); @@ -2621,7 +2646,7 @@ async function handleResponsesInner( } } let providerState: OcxProviderContinuationState | undefined; - const json = buildResponseJSON(events, parsed.modelId, { + const json = buildResponseJSON(events, parsed._responseModelId ?? parsed.modelId, { translatorBudget, replayCacheScope: parsed._clientThreadId ?? "global", hideThinkingSummary: parsed.options.hideThinkingSummary, @@ -3263,7 +3288,7 @@ async function handleResponsesInner( : initialEventStream; const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; const sseStream = bridgeToResponsesSSE( - eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, + eventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, () => upstream.abort(), 2_000, { translatorBudget, @@ -3323,7 +3348,7 @@ async function handleResponsesInner( } const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; let providerState: OcxProviderContinuationState | undefined; - const json = buildResponseJSON(events, parsed.modelId, { + const json = buildResponseJSON(events, parsed._responseModelId ?? parsed.modelId, { translatorBudget, replayCacheScope: parsed._clientThreadId ?? "global", hideThinkingSummary: parsed.options.hideThinkingSummary, diff --git a/src/types.ts b/src/types.ts index 7ef8f98c89..91280a5c62 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,8 @@ import type { KiroOAuthMetadata } from "./oauth/types"; export interface OcxParsedRequest { modelId: string; + /** Client-facing model selector retained for Anthropic routes after wire-model normalization. */ + _responseModelId?: string; /** Selected OpenAI API virtual-model id retained after it rewrites the upstream wire model. */ _openAiVirtualSelectedModelId?: string; previousResponseId?: string; diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index ce4e4eb45a..5349b78638 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -770,7 +770,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise { + produce(), parsed._responseModelId ?? parsed.modelId, toolNsMap, freeform, toolSearch, () => { const elapsed = Date.now() - loopT0; if (executedSearchCount > 0 || searchesExecuted > 0) { console.warn(`[web-search-loop] cancelled — ${executedSearchCount} real searches, ${searchesExecuted - executedSearchCount} placeholders, ${elapsed}ms`); diff --git a/tests/images/loop.test.ts b/tests/images/loop.test.ts index 8310b49bdf..688ff18ae1 100644 --- a/tests/images/loop.test.ts +++ b/tests/images/loop.test.ts @@ -126,6 +126,35 @@ describe("runWithImageBridge", () => { expect(sse).toContain("hello world"); }); + test("image-loop SSE snapshots preserve the client-facing model selector", async () => { + const parsed = makeParsed(); + parsed.modelId = "claude-sonnet-5"; + parsed._responseModelId = "anthropic/claude-sonnet-5"; + let upstreamModel = ""; + streamQueue = [[{ type: "text_delta", text: "hello" }, { type: "done" }]]; + const response = await runWithImageBridge({ + parsed, + adapter: { + ...mockAdapter, + buildRequest: async request => { + upstreamModel = request.modelId; + return { url: "https://test/v1/chat", method: "POST", headers: {}, body: "{}" }; + }, + }, + plan, + }); + const models = (await response.text()).split("\n\n").flatMap(block => { + const data = block.split("\n").find(line => line.startsWith("data: "))?.slice(6); + if (!data || data === "[DONE]") return []; + const payload = JSON.parse(data) as { response?: { model?: unknown } }; + return typeof payload.response?.model === "string" ? [payload.response.model] : []; + }); + + expect(upstreamModel).toBe("claude-sonnet-5"); + expect(models.length).toBeGreaterThan(0); + expect(new Set(models)).toEqual(new Set(["anthropic/claude-sonnet-5"])); + }); + test("single image call → fulfilled, second iteration yields text", async () => { const sse = await runAndGetSSE( [imageCallEvents, [{ type: "text_delta", text: "Here is your image" }, { type: "done" }]], diff --git a/tests/request-log.test.ts b/tests/request-log.test.ts index 50042794d7..f668b460cf 100644 --- a/tests/request-log.test.ts +++ b/tests/request-log.test.ts @@ -619,6 +619,29 @@ describe("request log metadata", () => { }); }); + test("client-facing response selectors do not replace the physical routed model", async () => { + const entries: RequestLogEntry[] = []; + const logCtx: RequestLogContext = { + model: "claude-sonnet-5", + provider: "anthropic", + resolvedModel: "claude-sonnet-5", + preserveResolvedModelFromRoute: true, + }; + const response = responseWithDeferredRequestLog( + new Response(JSON.stringify({ + model: "anthropic/claude-sonnet-5", + status: "completed", + }), { status: 200, headers: { "content-type": "application/json" } }), + "ocx-test-routed-model", + Date.now(), + logCtx, + entry => entries.push(entry), + ); + + expect(await response.json()).toMatchObject({ model: "anthropic/claude-sonnet-5" }); + expect(entries[0]?.resolvedModel).toBe("claude-sonnet-5"); + }); + test("deferred JSON logging captures reported usage", async () => { const entries: RequestLogEntry[] = []; const response = responseWithDeferredRequestLog( diff --git a/tests/response-model-identity.test.ts b/tests/response-model-identity.test.ts new file mode 100644 index 0000000000..9f5a5eb042 --- /dev/null +++ b/tests/response-model-identity.test.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { handleResponses } from "../src/server/responses/core"; +import type { RequestLogContext } from "../src/server/request-log"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function routedConfig( + providerName: string, + adapter: OcxProviderConfig["adapter"], + model: string, + wireAdapter?: OcxProviderConfig["adapter"], +): OcxConfig { + return { + port: 0, + defaultProvider: providerName, + providers: { + [providerName]: { + adapter, + baseUrl: "https://provider.example.test/v1", + authMode: "key", + apiKey: "test-key", + ...(wireAdapter ? { modelAdapters: { [model]: wireAdapter } } : {}), + }, + }, + } as OcxConfig; +} + +function responseSnapshot(model: unknown): Record { + return { + id: "resp_fixture", + object: "response", + created_at: 1, + status: "completed", + model, + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }; +} + +async function post(args: { + model: string; + providerName?: string; + adapter?: OcxProviderConfig["adapter"]; + wireAdapter?: OcxProviderConfig["adapter"]; + stream?: boolean; +}): Promise<{ response: Response; upstreamModel: unknown; logCtx: RequestLogContext }> { + const providerName = args.providerName ?? "fixture-anthropic"; + const adapter = args.adapter ?? "anthropic"; + const effectiveAdapter = args.wireAdapter ?? adapter; + const stream = args.stream ?? false; + let upstreamModel: unknown; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + upstreamModel = (await request.clone().json() as Record).model; + if (effectiveAdapter === "openai-responses") { + const snapshot = responseSnapshot(upstreamModel); + if (stream) { + const created = JSON.stringify({ type: "response.created", response: { ...snapshot, status: "in_progress" } }); + const completed = JSON.stringify({ type: "response.completed", response: snapshot }); + return new Response( + `event: response.created\ndata: ${created}\n\nevent: response.completed\ndata: ${completed}\n\ndata: [DONE]\n\n`, + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + } + return new Response(JSON.stringify(snapshot), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (stream) { + return new Response( + 'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}\n\ndata: [DONE]\n\n', + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + } + return new Response(JSON.stringify({ + choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + + const logCtx = { model: "", provider: "" } as RequestLogContext; + const response = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: args.model, input: "ping", stream }), + }), + routedConfig(providerName, adapter, args.model.includes("/") ? args.model.slice(args.model.indexOf("/") + 1) : args.model, args.wireAdapter), + logCtx, + {}, + ); + return { response, upstreamModel, logCtx }; +} + +function responseModelsFromSse(text: string): string[] { + return text.split(/\r?\n\r?\n/).flatMap(block => { + const payload = block.split(/\r?\n/).find(line => line.startsWith("data: "))?.slice(6); + if (!payload || payload === "[DONE]") return []; + const value = JSON.parse(payload) as { model?: unknown; response?: { model?: unknown } }; + const model = value.response?.model ?? value.model; + return typeof model === "string" ? [model] : []; + }); +} + +describe("Anthropic response model identity", () => { + test("preserves a provider-qualified selector in bridged JSON", async () => { + const result = await post({ model: "fixture-anthropic/claude-sonnet-5" }); + + expect(result.upstreamModel).toBe("claude-sonnet-5"); + expect((await result.response.json() as Record).model) + .toBe("fixture-anthropic/claude-sonnet-5"); + expect(result.logCtx.resolvedModel).toBe("claude-sonnet-5"); + }); + + test("keeps a legacy bare selector byte-identical in bridged JSON", async () => { + const result = await post({ model: "claude-sonnet-5" }); + + expect(result.upstreamModel).toBe("claude-sonnet-5"); + expect((await result.response.json() as Record).model).toBe("claude-sonnet-5"); + }); + + test("preserves a provider-qualified selector in bridged SSE", async () => { + const result = await post({ model: "fixture-anthropic/claude-sonnet-5", stream: true }); + const models = responseModelsFromSse(await result.response.text()); + + expect(result.upstreamModel).toBe("claude-sonnet-5"); + expect(models.length).toBeGreaterThan(0); + expect(new Set(models)).toEqual(new Set(["fixture-anthropic/claude-sonnet-5"])); + }); + + test("rewrites Responses passthrough JSON while logging the physical model", async () => { + const result = await post({ + model: "fixture-anthropic/claude-sonnet-5", + wireAdapter: "openai-responses", + }); + + expect(result.upstreamModel).toBe("claude-sonnet-5"); + expect((await result.response.json() as Record).model) + .toBe("fixture-anthropic/claude-sonnet-5"); + expect(result.logCtx.resolvedModel).toBe("claude-sonnet-5"); + }); + + test("rewrites every Responses passthrough SSE snapshot", async () => { + const result = await post({ + model: "fixture-anthropic/claude-sonnet-5", + wireAdapter: "openai-responses", + stream: true, + }); + + expect(responseModelsFromSse(await result.response.text())).toEqual([ + "fixture-anthropic/claude-sonnet-5", + "fixture-anthropic/claude-sonnet-5", + ]); + expect(result.logCtx.resolvedModel).toBe("claude-sonnet-5"); + }); + + test("non-Anthropic qualified selectors retain their pre-fix response model", async () => { + const result = await post({ + model: "fixture-openai/wire-model", + providerName: "fixture-openai", + adapter: "openai-responses", + }); + + expect(result.upstreamModel).toBe("wire-model"); + expect((await result.response.json() as Record).model).toBe("wire-model"); + expect(result.logCtx.resolvedModel).toBe("wire-model"); + }); +}); diff --git a/tests/web-search.test.ts b/tests/web-search.test.ts index 60ded4b156..b34a68ab6d 100644 --- a/tests/web-search.test.ts +++ b/tests/web-search.test.ts @@ -411,6 +411,47 @@ function scriptedAdapter(firstPass: AdapterEvent[]): ProviderAdapter { } describe("BUG-R86 routed web-search timeout semantics", () => { + test("web-search-loop SSE snapshots preserve the client-facing model selector", async () => { + const parsed = parseRequest({ + model: "claude-sonnet-5", + input: "hi", + stream: true, + tools: [{ type: "web_search" }], + }); + parsed._responseModelId = "anthropic/claude-sonnet-5"; + let upstreamModel = ""; + const adapter: ProviderAdapter = { + name: "identity", + buildRequest: request => { + upstreamModel = request.modelId; + return { url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }; + }, + fetchResponse: async () => new Response("wire", { status: 200 }), + async *parseStream() { + yield { type: "text_delta", text: "answer" }; + yield { type: "done" }; + }, + }; + const response = await runWithWebSearch({ + parsed, + adapter, + forwardProvider, + hostedTool: { type: "web_search" }, + selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, + maxSearches: 1, + }); + const frames = await collectSse(response.body!); + const models = frames.flatMap(frame => { + const responseModel = (frame.data.response as { model?: unknown } | undefined)?.model; + return typeof responseModel === "string" ? [responseModel] : []; + }); + + expect(upstreamModel).toBe("claude-sonnet-5"); + expect(models.length).toBeGreaterThan(0); + expect(new Set(models)).toEqual(new Set(["anthropic/claude-sonnet-5"])); + }); + test("translator overflow remains typed through the sidecar loop and bridge", async () => { const adapter: ProviderAdapter = { name: "overflow",