Skip to content
Closed
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: 2 additions & 0 deletions docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ working. OpenCodex only writes two variables into the `env` block of `~/.claude/

Claude Desktop first-party routes its Code tab and subagents through OpenCodex. The standalone Claude Code CLI has a separate first-party switch. Both clients read the same `~/.claude/settings.json` proxy and CA settings: if only one switch is on, the other client still transits the local proxy, where TLS terminates, but its Messages requests relay to Anthropic unchanged. Other Anthropic paths relay unchanged and unrelated hosts remain blind tunnels.

Subagents on routed (non-Claude) models do not use Claude Code's server-side message threads, because only Anthropic stores that state. OpenCodex declines a threaded request for such a model, and Claude Code resends that turn, and the turns after it, with the full conversation.

Mode is persisted as `claudeCode.desktopMode`. Installs that already applied either mode retain it,
including first-party installs from before the mode was persisted. An explicit mode takes priority;
otherwise an owned selected gateway row, an applied gateway fingerprint, or owned first-party
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,7 @@
"claude-management-api.test.ts": "claude-integration",
"claude-manual-env.test.ts": "gui",
"claude-messages-endpoint.test.ts": "claude-integration",
"claude-messages-thread.test.ts": "claude-integration",
"messages-native.test.ts": "claude-integration",
"messages-native-decline-trace.test.ts": "claude-integration",
"messages-native-oauth.test.ts": "claude-integration",
Expand Down
28 changes: 28 additions & 0 deletions src/claude/message-threads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Claude Code's message-threads beta, which it only enables against first-party Anthropic, sends
* a `thread` object on subagent turns. A `continue` carries just the messages after
* `previous_message_id` and may omit `system` and `tools`, because Anthropic replays them from the
* stored thread. A translated route has no such store, so translating that delta silently drops the
* task, the instructions and the earlier turns.
*
* Claude Code reads this error code as "threads are unsupported for this model": it resends the
* same turn with the full conversation and keeps that model stateless for the rest of the session.
*/
export const MESSAGE_THREAD_UNSUPPORTED_ERROR_CODE = "thread_unsupported_request";

export function carriesMessageThread(body: unknown): boolean {
if (body === null || typeof body !== "object" || Array.isArray(body)) return false;
const thread = (body as Record<string, unknown>).thread;
return thread !== null && typeof thread === "object" && !Array.isArray(thread);
}

export function messageThreadUnsupportedResponse(): Response {
return new Response(JSON.stringify({
type: "error",
error: {
type: "invalid_request_error",
message: "message threads are not supported on translated routes",
details: { error_code: MESSAGE_THREAD_UNSUPPORTED_ERROR_CODE },
},
}), { status: 400, headers: { "Content-Type": "application/json" } });
}
9 changes: 9 additions & 0 deletions src/server/claude-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { stripOneMillionMarker } from "../claude/context-windows";
import { captureClaudeInbound } from "../claude/inbound-debug";
import { claudeCodeForIngress } from "../claude/intercept/model-bindings";
import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../claude/compatibility";
import { carriesMessageThread, messageThreadUnsupportedResponse } from "../claude/message-threads";
import {
applyReplayRefusalClientHeaders,
carryReplayRefusal,
Expand Down Expand Up @@ -837,6 +838,12 @@ async function handleClaudeMessagesWithBudget(
recordProtocolShadowPlan(logCtx, config, { inbound: "messages", model: requestedModel });
return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages");
}
// Only Anthropic holds message-thread state; the error makes Claude Code resend the full turn.
if (carriesMessageThread(anthropicBody)) {
logCtx.errorCode = "claude_thread_unsupported";
if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 400, { closeReason: "non_stream" });
return messageThreadUnsupportedResponse();
}
// Capture source semantics before effort rewriting or translation drops fields.
// This policy is uniform across translated targets, including later fallback attempts.
const compatibilityMode: unknown = config.claudeCode?.compatibility;
Expand Down Expand Up @@ -1431,6 +1438,8 @@ export async function handleClaudeCountTokens(
if (wantsNativePassthrough(req, config, requestPolicy, model, cc)) {
return await anthropicNativePassthrough(req, config, { model, provider: "anthropic-native", surface: "claude" }, undefined, raw, "/v1/messages/count_tokens");
}
// A thread delta would undercount; refuse it exactly as the translated Messages path does.
if (carriesMessageThread(raw)) return messageThreadUnsupportedResponse();
// PF-08: an eligible managed-key route counts the body the native lane would send.
const nativeCountBody = resolveProtocolSettings(config).rollout.managedMessagesNative
? (await import("./messages-native")).nativeMessagesCountBody(config, cc, raw, { fastRow: countFastRow !== null })
Expand Down
14 changes: 14 additions & 0 deletions structure/data-planes/inbound-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,20 @@ The [explicit model-capability contract](../config.md#explicit-per-model-capabil

Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior.

## Claude message threads on translated routes

Claude Code enables its message-threads beta only against first-party Anthropic, so it reaches
OpenCodex through the first-party intercept. A threaded request carries a `thread` object; a
`continue` sends only the messages after `previous_message_id` and may leave `system` and `tools`
to the thread Anthropic stores. `src/server/claude-messages.ts` forwards the request unchanged on
native passthrough. On the translated path, before compatibility analysis or inference, it
answers any `thread` object with the 400 from `src/claude/message-threads.ts`, whose
`error.details.error_code` is `thread_unsupported_request` and whose request-log error code is
`claude_thread_unsupported`. A translated `count_tokens` request with a `thread` object gets the
same 400, because counting the delta would undercount the conversation. Claude Code then resends the turn with the full conversation and keeps
that model stateless for the session. Translating the delta instead would drop the task,
instructions and earlier turns without an error.

## Shared inbound Chat image recognition

`src/chat/image-parts.ts` owns which `messages[].content[]` shapes count as an image
Expand Down
173 changes: 173 additions & 0 deletions tests/claude-integration/claude-messages-thread.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { afterEach, beforeEach, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { saveConfig } from "../../src/config";
import { startServer } from "../../src/server";
import { clearRequestLogsForTests, getRequestLogEntries } from "../../src/server/request-log";
import type { OcxConfig } from "../../src/types";
import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home";
import { removeTreeWithRetry } from "../helpers/remove-tree";

let testDir = "";
let previousHome: string | undefined;
let isolatedCodexHome: IsolatedCodexHome | null = null;

beforeEach(() => {
previousHome = process.env.OPENCODEX_HOME;
isolatedCodexHome = installIsolatedCodexHome("ocx-claude-thread-");
testDir = mkdtempSync(join(tmpdir(), "ocx-claude-thread-"));
process.env.OPENCODEX_HOME = testDir;
});

afterEach(() => {
if (previousHome === undefined) delete process.env.OPENCODEX_HOME;
else process.env.OPENCODEX_HOME = previousHome;
isolatedCodexHome?.restore();
isolatedCodexHome = null;
if (testDir) removeTreeWithRetry(testDir);
});

function mockChatUpstream() {
const captured: Array<Record<string, unknown>> = [];
const server = Bun.serve({
port: 0,
async fetch(req) {
captured.push(await req.json() as Record<string, unknown>);
const frames = [
`data: ${JSON.stringify({ choices: [{ index: 0, delta: { role: "assistant", content: "ok" }, finish_reason: "stop" }] })}\n\n`,
"data: [DONE]\n\n",
];
return new Response(frames.join(""), { headers: { "Content-Type": "text/event-stream" } });
},
});
return { server, captured };
}

function routedConfig(baseUrl: string): OcxConfig {
return {
port: 0,
defaultProvider: "mock",
providers: { mock: { adapter: "openai-chat", baseUrl, apiKey: "k", allowPrivateNetwork: true } },
} as OcxConfig;
}

const headers = {
"content-type": "application/json",
"x-api-key": "placeholder",
"anthropic-beta": "message-threads-2026-08-12",
};

// Turn 2 of a Claude Code subagent as the message-threads beta sends it: only the delta after
// the anchor, with system and tools left to the server-side thread.
const continueTurn = {
model: "mock/test-model",
max_tokens: 64,
thread: { type: "continue", previous_message_id: "msg_turn_one" },
messages: [{ role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "file body" }] }],
};

// The stateless resend Claude Code makes after the unsupported error.
const statelessTurn = {
model: "mock/test-model",
max_tokens: 64,
system: [{ type: "text", text: "You are a subagent." }],
tools: [{ name: "Read", input_schema: { type: "object" } }],
messages: [
{ role: "user", content: "Read the file and report the secret word." },
{ role: "assistant", content: [{ type: "tool_use", id: "toolu_1", name: "Read", input: { path: "a.txt" } }] },
{ role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "file body" }] },
],
};

test("a translated route refuses message threads with Claude Code's unsupported code before inference", async () => {
const upstream = mockChatUpstream();
saveConfig(routedConfig(new URL("/v1", upstream.server.url).href));
const server = startServer(0);
try {
clearRequestLogsForTests();
for (const [index, thread] of [
{ type: "continue", previous_message_id: "msg_turn_one" },
{ type: "create" },
].entries()) {
const response = await fetch(new URL("/v1/messages?beta=true", server.url), {
method: "POST",
headers,
body: JSON.stringify({ ...continueTurn, stream: index === 0, thread }),
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({
type: "error",
error: {
type: "invalid_request_error",
message: "message threads are not supported on translated routes",
details: { error_code: "thread_unsupported_request" },
},
});
expect(getRequestLogEntries().at(-1)?.errorCode).toBe("claude_thread_unsupported");
}
expect(upstream.captured).toHaveLength(0);

// A thread delta would undercount, so count_tokens refuses it the same way.
const counted = await fetch(new URL("/v1/messages/count_tokens?beta=true", server.url), {
method: "POST",
headers,
body: JSON.stringify(continueTurn),
});
expect(counted.status).toBe(400);
expect(await counted.json()).toMatchObject({ error: { details: { error_code: "thread_unsupported_request" } } });

const resent = await fetch(new URL("/v1/messages?beta=true", server.url), {
method: "POST",
headers,
body: JSON.stringify(statelessTurn),
});
expect(resent.status).toBe(200);
await resent.text();
expect(upstream.captured).toHaveLength(1);
const sent = JSON.stringify(upstream.captured[0]);
expect(sent).toContain("You are a subagent.");
expect(sent).toContain("Read the file and report the secret word.");
} finally {
await server.stop(true);
upstream.server.stop(true);
}
});

test("native Anthropic passthrough still forwards the thread unchanged", async () => {
let captured: Record<string, unknown> | null = null;
const upstream = Bun.serve({
port: 0,
async fetch(req) {
captured = await req.json() as Record<string, unknown>;
return Response.json({
id: "msg_turn_two",
type: "message",
role: "assistant",
model: "claude-haiku-4-5",
content: [{ type: "text", text: "ok" }],
stop_reason: "end_turn",
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },
});
},
});
saveConfig({
...routedConfig("http://127.0.0.1:1/v1"),
claudeCode: { anthropicBaseUrl: upstream.url.toString().replace(/\/$/, "") },
} as OcxConfig);
const server = startServer(0);
try {
const response = await fetch(new URL("/v1/messages", server.url), {
method: "POST",
headers: { ...headers, "x-api-key": "sk-ant-test" },
body: JSON.stringify({ ...continueTurn, model: "claude-haiku-4-5" }),
});
expect(response.status).toBe(200);
await response.text();
expect(captured).toMatchObject({ thread: { type: "continue", previous_message_id: "msg_turn_one" } });
} finally {
await server.stop(true);
upstream.stop(true);
}
});
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@
"claude-management-api.test.ts": "claude-integration",
"claude-manual-env.test.ts": "gui",
"claude-messages-endpoint.test.ts": "claude-integration",
"claude-messages-thread.test.ts": "claude-integration",
"messages-native.test.ts": "claude-integration",
"messages-native-decline-trace.test.ts": "claude-integration",
"messages-native-oauth.test.ts": "claude-integration",
Expand Down
Loading