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
24 changes: 24 additions & 0 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3303,6 +3303,30 @@ async function handleResponsesInner(
}
}
}
// A shadow-call replacement that names a COMBO is routing policy, not the identity of any
// one pick. The late intercept site below resolves it through routeModel/tryPickComboModel,
// which collapses the table to a single target while still tagging `routeKind: "combo"`, so
// the combo gate on the next line never fires, handleComboResponses never runs, and 429/5xx
// hops — which only exist inside that loop — are unreachable (#4129). Rewrite the selector
// here instead, before comboIdFromRawBody reads `model`, and identify the combo by CONFIG
// LOOKUP so the check can never observe a one-candidate collapse.
if (!options.comboAttempt && body && typeof body === "object" && !Array.isArray(body)) {
const shadowIntercept = config.shadowCallIntercept;
const rawShadowModel = (body as { model?: unknown }).model;
if (shadowIntercept?.enabled && shadowIntercept.model && typeof rawShadowModel === "string"
&& isShadowSourceModel(rawShadowModel, shadowIntercept.sourceModels)) {
const shadowComboId = resolveComboId(config, shadowIntercept.model);
if (shadowComboId && Object.hasOwn(config.combos ?? {}, shadowComboId)) {
(body as Record<string, unknown>).model = shadowIntercept.model;

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 Preserve Cursor isolation for combo shadow calls

When this rewrite dispatches a shadow call to a combo whose selected target uses the Cursor adapter, it returns through handleComboResponses before the late interceptor can set parsed._cursorIsolateConversation = true. Combo children preserve x-codex-parent-thread-id, so Cursor derives the parent's conversation ID and may reuse or update its checkpoint, allowing title/commit helper traffic to contaminate the main conversation; this regresses the previous single-target path, which did set the isolation flag. Carry a shadow-isolation bit into every combo child and add a Cursor-target regression test; structure/04_transports-and-sidecars.md:1116-1117 explicitly requires isolated helper/shadow turns never to join parent or sibling conversations.

AGENTS.md reference: src/AGENTS.md:L10-L11

Useful? React with 👍 / 👎.

// Same rule as the late intercept site: record the operator-configured prefix that
// matched, never the caller's raw model string. Matching is by prefix, so the raw
// value is caller-controlled and reaches usage.jsonl and /api/logs.
logCtx.shadowCallRewrittenFrom = sanitizeLogMetadataString(
shadowSourceModelPrefix(rawShadowModel, shadowIntercept.sourceModels),
);
}
}
}
const comboId = !options.comboAttempt ? comboIdFromRawBody(body, config) : null;
if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) {
options.onRequestBodyRead?.();
Expand Down
128 changes: 128 additions & 0 deletions tests/responses/responses-shadow-intercept.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,134 @@ describe("shadow call intercept request path (issue #311)", () => {
});
});

/**
* A shadow-call replacement naming a COMBO used to run exactly one attempt and never enter
* the failover loop (#4129). Two cooperating causes: the combo gate reads the UN-rewritten
* body, where the model is still the bare helper slug, and the late intercept resolved the
* replacement through routeModel/tryPickComboModel, which collapses the combo table to a
* single target while still tagging routeKind "combo" — so the reported "combo route, one
* attempt" was a collapsed native pick, and 429/5xx hops (which only exist inside
* handleComboResponses) were unreachable.
*/
function comboInterceptConfig(
targets: Array<{ provider: string; model: string }>,
shadowCallIntercept: Record<string, unknown> = { enabled: true, model: "combo/shadow" },
): OcxConfig {
return {
port: 0,
defaultProvider: "xai",
providers: {
xai: {
adapter: "openai-chat",
baseUrl: "https://api.x.ai/v1",
authMode: "key",
apiKey: "test-xai-key",
},
alt: {
adapter: "openai-chat",
baseUrl: "https://alt.example/v1",
authMode: "key",
apiKey: "test-alt-key",
},
},
combos: {
shadow: { strategy: "failover", targets },
},
shadowCallIntercept,
} as unknown as OcxConfig;
}

function chatOk(text: string): Response {
return Response.json({
choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1 },
});
}

describe("a combo shadow-call target enters the failover loop (#4129)", () => {
test("a helper call rewritten to a combo hops past a 429 to the second target", async () => {
const urls: string[] = [];
const logCtx: RequestLogContext = { model: "", provider: "" };
globalThis.fetch = (async (url: unknown) => {
urls.push(String(url));
return urls.length === 1
? Response.json({ error: { message: "rate limited" } }, { status: 429 })
: chatOk("ok");
}) as typeof fetch;

const config = comboInterceptConfig([
{ provider: "xai", model: "grok-4.5" },
{ provider: "alt", model: "grok-4.5" },
]);
const response = await post(config, "gpt-5.6-luna", "turn", logCtx);

expect(response.ok).toBe(true);
// The whole point: two upstream attempts, in configured order.
expect(urls).toHaveLength(2);
expect(urls[0]).toContain("api.x.ai");
expect(urls[1]).toContain("alt.example");
expect(logCtx.provider).toBe("combo");
expect(logCtx.comboId).toBe("shadow");
expect(logCtx.routeDecision?.routeKind).toBe("combo");
expect(logCtx.shadowCallRewrittenFrom).toBe("gpt-5.6-luna");
const attempts = (logCtx.attempts ?? []) as Array<{ provider?: string; model?: string }>;
expect(attempts).toHaveLength(2);
expect(attempts.map(a => `${a.provider}/${a.model}`))
.toEqual(["xai/grok-4.5", "alt/grok-4.5"]);
});

test("a combo whose first target intersects the source still routes as a combo", async () => {
const urls: string[] = [];
const logCtx: RequestLogContext = { model: "", provider: "" };
globalThis.fetch = (async (url: unknown) => {
urls.push(String(url));
return chatOk("ok");
}) as typeof fetch;

// The #2706 self-target shape: the source model routes to xai, and the combo's FIRST
// target is that same provider+model. shadowCallTargetsIntersect is therefore true for
// the collapsed one-candidate pick, which is what used to suppress the intercept
// outright and leave the request on a plain native route.
const config = comboInterceptConfig(
[
{ provider: "xai", model: "custom-helper" },
{ provider: "alt", model: "grok-4.5" },
],
{ enabled: true, model: "combo/shadow", sourceModels: ["custom-helper"] },
);
const response = await post(config, "custom-helper", "turn", logCtx);

expect(response.ok).toBe(true);
// A healthy first target still costs exactly one upstream call.
expect(urls).toHaveLength(1);
expect(urls[0]).toContain("api.x.ai");
expect(logCtx.provider).toBe("combo");
expect(logCtx.comboId).toBe("shadow");
expect(logCtx.routeDecision?.routeKind).toBe("combo");
// Red before the fix: shouldInterceptShadowCall saw the collapsed pick as a self-target,
// skipped the rewrite, and the request left as a plain native route with no marker.
expect(logCtx.shadowCallRewrittenFrom).toBe("custom-helper");
});

test("a non-combo replacement still takes the ordinary late intercept", async () => {
const urls: string[] = [];
const logCtx: RequestLogContext = { model: "", provider: "" };
globalThis.fetch = (async (url: unknown) => {
urls.push(String(url));
return chatOk("ok");
}) as typeof fetch;

const config = comboInterceptConfig([{ provider: "xai", model: "grok-4.5" }]);
config.shadowCallIntercept = { enabled: true, model: "xai/grok-4.5" };
const response = await post(config, "gpt-5.6-luna", "turn", logCtx);

expect(response.ok).toBe(true);
expect(urls).toHaveLength(1);
expect(logCtx.comboId).toBeUndefined();
expect(logCtx.shadowCallRewrittenFrom).toBe("gpt-5.6-luna");
});
});

/**
* The GUI badge/tooltip used to hard-code "5.4-mini", so it kept naming a model
* Codex no longer sends. The management API is the single source of truth for
Expand Down
Loading