Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/images/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -900,7 +900,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
}

const sse = bridgeToResponsesSSE(
produce(), parsed.modelId, toolNsMap, freeform, toolSearch, () => {
produce(), parsed._responseModelId ?? parsed.modelId, toolNsMap, freeform, toolSearch, () => {
internalAbort.abort("client closed responses stream");
}, 2_000,
{
Expand Down
8 changes: 7 additions & 1 deletion src/server/request-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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);
Expand Down
29 changes: 29 additions & 0 deletions src/server/responses-model-rewrite.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
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);
}
51 changes: 38 additions & 13 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -864,6 +865,12 @@ async function applyFinalRouteRequestNormalization(args: {
}): Promise<void> {
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";
Comment on lines +871 to +872

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include finalized Anthropic wire routes in preservation

When a client requests opencode-go/minimax-m3 (similarly minimax-m2.5 or minimax-m2.7), the provider initially has the openai-chat adapter, so this predicate is false; line 891 then applies the repository's hard pin and switches the finalized adapter to anthropic. Consequently, parsed.modelId is stripped to minimax-m3 without setting _responseModelId, and JSON/SSE responses still return the bare selector, so round-tripping it loses the opencode-go route—the same failure this change is intended to fix. Include the finalized adapter or pinned-wire result in the preservation predicate and add coverage for a pinned Anthropic route.

Useful? React with 👍 / 👎.


// Apply the routed model id upstream: routing may strip a "<provider>/" namespace.
if (route.modelId !== parsed.modelId) {
if (parsed._rawBody && typeof parsed._rawBody === "object") {
Expand All @@ -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;
Expand All @@ -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).
Expand Down Expand Up @@ -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<typeof rewrite> => rewrite !== undefined);
// #893: sparse-snapshot gateways get field backfills AND lifecycle event
// injection at the block level, after payload rewrites. Defaults come
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/web-search/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -770,7 +770,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
}

const sse = bridgeToResponsesSSE(
produce(), parsed.modelId, toolNsMap, freeform, toolSearch, () => {
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`);
Expand Down
29 changes: 29 additions & 0 deletions tests/images/loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }]],
Expand Down
23 changes: 23 additions & 0 deletions tests/request-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading