Skip to content

Commit 2a13ed8

Browse files
authored
Merge pull request #1138 from lidge-jun/codex/260806-stack07-anthropic-identity
fix(anthropic): preserve the provider-qualified response model (#1117)
2 parents e35faa7 + 1879bd0 commit 2a13ed8

10 files changed

Lines changed: 345 additions & 16 deletions

File tree

src/images/loop.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -900,7 +900,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
900900
}
901901

902902
const sse = bridgeToResponsesSSE(
903-
produce(), parsed.modelId, toolNsMap, freeform, toolSearch, () => {
903+
produce(), parsed._responseModelId ?? parsed.modelId, toolNsMap, freeform, toolSearch, () => {
904904
internalAbort.abort("client closed responses stream");
905905
}, 2_000,
906906
{

src/server/request-log.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ export interface RequestLogContext {
6868
modelSupportsServiceTier?: boolean;
6969
responseServiceTier?: string;
7070
resolvedModel?: string;
71+
/** Internal: client-facing response metadata must not replace the physical routed model. */
72+
preserveResolvedModelFromRoute?: boolean;
7173
usage?: OcxUsage;
7274
usageLogInputTokens?: number;
7375
attempts?: PersistedUsageAttempt[];
@@ -512,7 +514,11 @@ export function applyResponseLogMetadata(logCtx: RequestLogContext, payload: unk
512514
: payload;
513515
if (!source || typeof source !== "object") return;
514516
const model = (source as { model?: unknown }).model;
515-
if (typeof model === "string" && model.trim()) logCtx.resolvedModel = model;
517+
if (
518+
!logCtx.preserveResolvedModelFromRoute
519+
&& typeof model === "string"
520+
&& model.trim()
521+
) logCtx.resolvedModel = model;
516522
const serviceTier = (source as { service_tier?: unknown }).service_tier;
517523
if (typeof serviceTier === "string" && serviceTier.trim()) logCtx.responseServiceTier = serviceTier;
518524
const usage = usageFromResponsesPayload((source as { usage?: unknown }).usage);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { SsePayloadRewrite } from "./sse-payload-rewrite";
2+
3+
function rewriteResponseObjectModel(value: unknown, responseModelId: string): boolean {
4+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
5+
const response = value as Record<string, unknown>;
6+
if (typeof response.model !== "string" || response.model === responseModelId) return false;
7+
response.model = responseModelId;
8+
return true;
9+
}
10+
11+
/** Rewrite only existing Responses model metadata; unrelated and malformed payloads stay byte-identical. */
12+
export function rewriteResponsesModelJson(json: string, responseModelId: string): string {
13+
let parsed: unknown;
14+
try {
15+
parsed = JSON.parse(json);
16+
} catch {
17+
return json;
18+
}
19+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return json;
20+
const record = parsed as Record<string, unknown>;
21+
const rootChanged = rewriteResponseObjectModel(record, responseModelId);
22+
const nestedChanged = rewriteResponseObjectModel(record.response, responseModelId);
23+
const changed = rootChanged || nestedChanged;
24+
return changed ? JSON.stringify(record) : json;
25+
}
26+
27+
export function createResponsesModelPayloadRewrite(responseModelId: string): SsePayloadRewrite {
28+
return payload => rewriteResponsesModelJson(payload, responseModelId);
29+
}

src/server/responses/core.ts

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ import {
172172
restoreImageGenCallsInJson,
173173
} from "../responses-image-gen-repair";
174174
import { composeSsePayloadRewrites, relaySseWithPayloadRewrite } from "../sse-payload-rewrite";
175+
import { createResponsesModelPayloadRewrite, rewriteResponsesModelJson } from "../responses-model-rewrite";
175176
import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";
176177

177178
import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration";
@@ -864,6 +865,12 @@ async function applyFinalRouteRequestNormalization(args: {
864865
}): Promise<void> {
865866
const { parsed, route, config, req, logCtx, inboundWire, inboundTransport } = args;
866867

868+
// Only Anthropic message routes retain the Codex-facing selector. Other providers must keep
869+
// their existing response.model contract even when their public and wire model ids differ.
870+
const responseModelId = parsed.modelId;
871+
const preserveAnthropicResponseModel = route.providerName === "anthropic"
872+
|| route.provider.adapter === "anthropic";
873+
867874
// Apply the routed model id upstream: routing may strip a "<provider>/" namespace.
868875
if (route.modelId !== parsed.modelId) {
869876
if (parsed._rawBody && typeof parsed._rawBody === "object") {
@@ -882,6 +889,7 @@ async function applyFinalRouteRequestNormalization(args: {
882889
// Settle the wire once so logging, fast-mode, auth, and sidecars read the adapter
883890
// this request will actually use (#404).
884891
route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
892+
if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId;
885893
logCtx.model = route.modelId;
886894
logCtx.provider = route.providerName;
887895
logCtx.providerAdapter = route.provider.adapter;
@@ -899,6 +907,10 @@ async function applyFinalRouteRequestNormalization(args: {
899907

900908
// Virtual model rewriting: Pro aliases → base model + reasoning.mode="pro".
901909
applyOpenAiVirtualModel(parsed, route, logCtx);
910+
if (parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId) {
911+
logCtx.resolvedModel = route.modelId;
912+
logCtx.preserveResolvedModelFromRoute = true;
913+
}
902914

903915
// Fast mode override for OpenAI-routed models, only where the provider's Responses
904916
// route documents `service_tier` support (capability gate below strips everywhere else).
@@ -2061,13 +2073,21 @@ async function handleResponsesInner(
20612073
if (isEventStream && upstreamResponse.body) {
20622074
const repairConfig = route.provider.responsesItemIdRepair;
20632075
const snapshotRepairEnabled = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair);
2064-
const needsClientRewrite = imageGenCallAliases.size > 0 || hasResponsesItemIdRepair(repairConfig) || snapshotRepairEnabled;
2076+
const responseModelRewrite = parsed._responseModelId !== undefined
2077+
&& parsed._responseModelId !== parsed.modelId
2078+
? createResponsesModelPayloadRewrite(parsed._responseModelId)
2079+
: undefined;
2080+
const needsClientRewrite = imageGenCallAliases.size > 0
2081+
|| hasResponsesItemIdRepair(repairConfig)
2082+
|| snapshotRepairEnabled
2083+
|| responseModelRewrite !== undefined;
20652084
// Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first).
20662085
const payloadRewrites = [
20672086
createImageGenCallRestoreRewrite(imageGenCallAliases),
20682087
hasResponsesItemIdRepair(repairConfig)
20692088
? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget)
20702089
: undefined,
2090+
responseModelRewrite,
20712091
].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
20722092
// #893: sparse-snapshot gateways get field backfills AND lifecycle event
20732093
// injection at the block level, after payload rewrites. Defaults come
@@ -2259,14 +2279,19 @@ async function handleResponsesInner(
22592279
}
22602280
const clientJson = (() => {
22612281
const restored = restoreImageGenCallsInJson(text, imageGenCallAliases);
2262-
if (!hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair)) return restored;
2263-
let outbound: unknown;
2264-
try {
2265-
outbound = JSON.parse(request.body);
2266-
} catch {
2267-
outbound = undefined;
2268-
}
2269-
return repairResponsesSnapshotJson(restored, outbound);
2282+
const repaired = (() => {
2283+
if (!hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair)) return restored;
2284+
let outbound: unknown;
2285+
try {
2286+
outbound = JSON.parse(request.body);
2287+
} catch {
2288+
outbound = undefined;
2289+
}
2290+
return repairResponsesSnapshotJson(restored, outbound);
2291+
})();
2292+
return parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId
2293+
? rewriteResponsesModelJson(repaired, parsed._responseModelId)
2294+
: repaired;
22702295
})();
22712296
// #875: the transport-neutral reliability policy forced a bounded JSON
22722297
// upstream for a client that asked for SSE. Reframe the completed JSON
@@ -2569,7 +2594,7 @@ async function handleResponsesInner(
25692594
eventSource = preflight.stream;
25702595
}
25712596
const sseStream = bridgeToResponsesSSE(
2572-
eventSource, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
2597+
eventSource, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
25732598
() => {
25742599
runTurnAbort.abort();
25752600
queue.close();
@@ -2621,7 +2646,7 @@ async function handleResponsesInner(
26212646
}
26222647
}
26232648
let providerState: OcxProviderContinuationState | undefined;
2624-
const json = buildResponseJSON(events, parsed.modelId, {
2649+
const json = buildResponseJSON(events, parsed._responseModelId ?? parsed.modelId, {
26252650
translatorBudget,
26262651
replayCacheScope: parsed._clientThreadId ?? "global",
26272652
hideThinkingSummary: parsed.options.hideThinkingSummary,
@@ -3263,7 +3288,7 @@ async function handleResponsesInner(
32633288
: initialEventStream;
32643289
const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
32653290
const sseStream = bridgeToResponsesSSE(
3266-
eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
3291+
eventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
32673292
() => upstream.abort(), 2_000,
32683293
{
32693294
translatorBudget,
@@ -3323,7 +3348,7 @@ async function handleResponsesInner(
33233348
}
33243349
const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
33253350
let providerState: OcxProviderContinuationState | undefined;
3326-
const json = buildResponseJSON(events, parsed.modelId, {
3351+
const json = buildResponseJSON(events, parsed._responseModelId ?? parsed.modelId, {
33273352
translatorBudget,
33283353
replayCacheScope: parsed._clientThreadId ?? "global",
33293354
hideThinkingSummary: parsed.options.hideThinkingSummary,

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import type { KiroOAuthMetadata } from "./oauth/types";
22

33
export interface OcxParsedRequest {
44
modelId: string;
5+
/** Client-facing model selector retained for Anthropic routes after wire-model normalization. */
6+
_responseModelId?: string;
57
/** Selected OpenAI API virtual-model id retained after it rewrites the upstream wire model. */
68
_openAiVirtualSelectedModelId?: string;
79
previousResponseId?: string;

src/web-search/loop.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -770,7 +770,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
770770
}
771771

772772
const sse = bridgeToResponsesSSE(
773-
produce(), parsed.modelId, toolNsMap, freeform, toolSearch, () => {
773+
produce(), parsed._responseModelId ?? parsed.modelId, toolNsMap, freeform, toolSearch, () => {
774774
const elapsed = Date.now() - loopT0;
775775
if (executedSearchCount > 0 || searchesExecuted > 0) {
776776
console.warn(`[web-search-loop] cancelled — ${executedSearchCount} real searches, ${searchesExecuted - executedSearchCount} placeholders, ${elapsed}ms`);

tests/images/loop.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,35 @@ describe("runWithImageBridge", () => {
126126
expect(sse).toContain("hello world");
127127
});
128128

129+
test("image-loop SSE snapshots preserve the client-facing model selector", async () => {
130+
const parsed = makeParsed();
131+
parsed.modelId = "claude-sonnet-5";
132+
parsed._responseModelId = "anthropic/claude-sonnet-5";
133+
let upstreamModel = "";
134+
streamQueue = [[{ type: "text_delta", text: "hello" }, { type: "done" }]];
135+
const response = await runWithImageBridge({
136+
parsed,
137+
adapter: {
138+
...mockAdapter,
139+
buildRequest: async request => {
140+
upstreamModel = request.modelId;
141+
return { url: "https://test/v1/chat", method: "POST", headers: {}, body: "{}" };
142+
},
143+
},
144+
plan,
145+
});
146+
const models = (await response.text()).split("\n\n").flatMap(block => {
147+
const data = block.split("\n").find(line => line.startsWith("data: "))?.slice(6);
148+
if (!data || data === "[DONE]") return [];
149+
const payload = JSON.parse(data) as { response?: { model?: unknown } };
150+
return typeof payload.response?.model === "string" ? [payload.response.model] : [];
151+
});
152+
153+
expect(upstreamModel).toBe("claude-sonnet-5");
154+
expect(models.length).toBeGreaterThan(0);
155+
expect(new Set(models)).toEqual(new Set(["anthropic/claude-sonnet-5"]));
156+
});
157+
129158
test("single image call → fulfilled, second iteration yields text", async () => {
130159
const sse = await runAndGetSSE(
131160
[imageCallEvents, [{ type: "text_delta", text: "Here is your image" }, { type: "done" }]],

tests/request-log.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,29 @@ describe("request log metadata", () => {
619619
});
620620
});
621621

622+
test("client-facing response selectors do not replace the physical routed model", async () => {
623+
const entries: RequestLogEntry[] = [];
624+
const logCtx: RequestLogContext = {
625+
model: "claude-sonnet-5",
626+
provider: "anthropic",
627+
resolvedModel: "claude-sonnet-5",
628+
preserveResolvedModelFromRoute: true,
629+
};
630+
const response = responseWithDeferredRequestLog(
631+
new Response(JSON.stringify({
632+
model: "anthropic/claude-sonnet-5",
633+
status: "completed",
634+
}), { status: 200, headers: { "content-type": "application/json" } }),
635+
"ocx-test-routed-model",
636+
Date.now(),
637+
logCtx,
638+
entry => entries.push(entry),
639+
);
640+
641+
expect(await response.json()).toMatchObject({ model: "anthropic/claude-sonnet-5" });
642+
expect(entries[0]?.resolvedModel).toBe("claude-sonnet-5");
643+
});
644+
622645
test("deferred JSON logging captures reported usage", async () => {
623646
const entries: RequestLogEntry[] = [];
624647
const response = responseWithDeferredRequestLog(

0 commit comments

Comments
 (0)