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
1 change: 1 addition & 0 deletions src/server/claude-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,7 @@ async function handleClaudeMessagesWithBudget(
// Without this the replay would look native and a Responses-scoped wire default
// would fire, disagreeing with the pre-flight decision above.
inboundWire: "anthropic",
stripClaudeMainAuthForNoncanonicalForward: true,
translatorBudget,
...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}),
onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }),
Expand Down
25 changes: 24 additions & 1 deletion src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,12 @@ export interface HandleResponsesOptions {
inboundWire?: InboundWire;
/** Internal transport identity for route-scoped upstream compatibility policy. */
inboundTransport?: "websocket";
/**
* Claude replay may add native-main auth so OpenAI sidecars remain available.
* Strip only that internal credential when the final route is a noncanonical
* forward destination; final routing can differ from Claude's preflight route.
*/
stripClaudeMainAuthForNoncanonicalForward?: boolean;
/** Internal recursion guard; callers outside this module must not set it. */
comboAttempt?: boolean;
/** Internal combo handoff: allow a later same-provider model after a reset-derived 429/402. */
Expand Down Expand Up @@ -1701,7 +1707,24 @@ async function handleResponsesInner(
parsed.options.promptCacheKey,
route.providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(route.providerName) : undefined,
);
const adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
let adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
const stripClaudeMainAuth = options.stripClaudeMainAuthForNoncanonicalForward === true
&& adapterProvider.adapter === "openai-responses"
&& adapterProvider.authMode === "forward"
&& !isCanonicalOpenAiForwardProvider(adapterProvider);
if (stripClaudeMainAuth) {
releaseCodexAuthContextProbeLease(authCtx);
authCtx = { kind: "main", accountId: null };
route.provider = stripCodexRuntimeProviderFields(route.provider);
adapterProvider = stripCodexRuntimeProviderFields(adapterProvider);
selectedForwardHeaders = new Headers(selectedForwardHeaders);
selectedForwardHeaders.delete("authorization");
selectedForwardHeaders.delete("chatgpt-account-id");
delete route.codexAccountMode;
delete route.codexAccountId;
delete route.codexAccountNamespace;
logCtx.provider = route.providerName;
}
const adapter = resolveAdapter(adapterProvider, config.cacheRetention);
logCtx.providerAdapter = adapter.name;
// Ordinary requests receive one durable attempt only after their final initial
Expand Down
115 changes: 115 additions & 0 deletions tests/claude-messages-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,121 @@ test("native openai-responses route carries prompt_cache_key + synthesized sessi
}
});

test("custom forward openai-responses route never receives the main ChatGPT credential", async () => {
writeFileSync(join(isolatedCodexHome!.path, "auth.json"), JSON.stringify({
tokens: { access_token: "main-secret-must-not-leave", account_id: "main-account-must-not-leave" },
}));
const captured: Array<{ authorization: string | null; accountId: string | null }> = [];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const upstream = Bun.serve({
port: 0,
fetch(req) {
captured.push({
authorization: req.headers.get("authorization"),
accountId: req.headers.get("chatgpt-account-id"),
});
return new Response([
'event: response.created\ndata: {"response":{"id":"resp_1","status":"in_progress"}}\n\n',
'event: response.output_text.delta\ndata: {"delta":"ok"}\n\n',
'event: response.completed\ndata: {"response":{"status":"completed","usage":{"input_tokens":1,"output_tokens":1}}}\n\n',
].join(""), { headers: { "content-type": "text/event-stream" } });
},
});
saveConfig({
port: 0,
defaultProvider: "custom",
providers: {
custom: {
adapter: "openai-chat",
baseUrl: `${upstream.url.toString().replace(/\/$/, "")}/v1`,
authMode: "forward",
allowPrivateNetwork: true,
modelAdapters: { "gpt-test": "openai-responses" },
},
},
} as OcxConfig);
const server = startServer(0);
try {
const response = await fetch(new URL("/v1/messages", server.url), {
method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer claude-placeholder" },
body: JSON.stringify({
model: "custom/gpt-test",
max_tokens: 16,
messages: [{ role: "user", content: "hi" }],
}),
});
const responseBody = await response.text();
expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 });
expect(captured).toEqual([{ authorization: null, accountId: null }]);
} finally {
await server.stop(true);
upstream.stop(true);
}
});

test("shadow-call rerouting cannot carry the main ChatGPT credential to a custom forward route", async () => {
writeFileSync(join(isolatedCodexHome!.path, "auth.json"), JSON.stringify({
tokens: { access_token: "main-secret-must-not-leave", account_id: "main-account-must-not-leave" },
}));
const captured: Array<{ authorization: string | null; accountId: string | null }> = [];
const upstream = Bun.serve({
port: 0,
fetch(req) {
captured.push({
authorization: req.headers.get("authorization"),
accountId: req.headers.get("chatgpt-account-id"),
});
return new Response([
'event: response.created\ndata: {"response":{"id":"resp_1","status":"in_progress"}}\n\n',
'event: response.output_text.delta\ndata: {"delta":"ok"}\n\n',
'event: response.completed\ndata: {"response":{"status":"completed","usage":{"input_tokens":1,"output_tokens":1}}}\n\n',
].join(""), { headers: { "content-type": "text/event-stream" } });
},
});
saveConfig({
port: 0,
defaultProvider: "openai",
providers: {
openai: {
adapter: "openai-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
authMode: "forward",
codexAccountMode: "direct",
},
custom: {
adapter: "openai-chat",
baseUrl: `${upstream.url.toString().replace(/\/$/, "")}/v1`,
authMode: "forward",
allowPrivateNetwork: true,
modelAdapters: { "gpt-test": "openai-responses" },
},
},
shadowCallIntercept: {
enabled: true,
model: "custom/gpt-test",
sourceModels: ["gpt-5.6-luna"],
},
} as OcxConfig);
const server = startServer(0);
try {
const response = await fetch(new URL("/v1/messages", server.url), {
method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer claude-placeholder" },
body: JSON.stringify({
model: "gpt-5.6-luna",
max_tokens: 16,
messages: [{ role: "user", content: "hi" }],
}),
});
const responseBody = await response.text();
expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 });
expect(captured).toEqual([{ authorization: null, accountId: null }]);
} finally {
await server.stop(true);
upstream.stop(true);
}
});

test("Claude replay owns optional main enrichment while routed work survives drain and recovery", async () => {
resetLifecycleDrainStateForTests();
writeFileSync(join(isolatedCodexHome!.path, "auth.json"), JSON.stringify({
Expand Down
Loading