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
7 changes: 5 additions & 2 deletions docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,13 +156,16 @@ caching and billing identity stay fully native, and routed models keep working i
via the picker aliases.

**Header handling:** hop-by-hop headers plus `host`, `content-length`, `accept-encoding`,
`x-opencodex-api-key`, and `origin` are stripped before forwarding. All other headers (including
`anthropic-beta` and `anthropic-version`) pass through.
`x-opencodex-api-key`, and `origin` are stripped before forwarding. Any `authorization` or
`x-api-key` value that matches an OpenCodex admission secret is also stripped; other end-to-end
headers (including `anthropic-beta` and `anthropic-version`) pass through.

The passthrough fires when **all four** conditions are met: `nativePassthrough` is not `false`;
the model begins with `claude` or `anthropic`; the bearer or `x-api-key` starts with `sk-ant-`;
and alias/model-map resolution returns the same model unchanged. This also means the
"claude.ai connectors are disabled" warning no longer appears with `ocx claude`.
On a non-loopback listener, native passthrough additionally requires proxy admission through
`x-opencodex-api-key`; `authorization` and `x-api-key` remain exclusively provider-owned.

Disable with `claudeCode.nativePassthrough: false`; point elsewhere with
`claudeCode.anthropicBaseUrl`.
Expand Down
5 changes: 4 additions & 1 deletion docs-site/src/content/docs/ko/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,16 @@ macOS의 자동 연결(`claudeCode.systemEnv`)도 같은 방식으로 판단하
네이티브 상태로 유지되고, 같은 세션에서 선택기 별칭을 써서 라우팅 모델도 계속 사용할 수 있어요.

**헤더 처리:** hop-by-hop 헤더와 `host`, `content-length`, `accept-encoding`,
`x-opencodex-api-key`, `origin`은 전달 전에 제거해요. 그 밖의 헤더(`anthropic-beta`,
`x-opencodex-api-key`, `origin`은 전달 전에 제거해요. OpenCodex 입장 비밀과 일치하는
`authorization` 또는 `x-api-key` 값도 제거하고, 그 밖의 종단 간 헤더(`anthropic-beta`,
`anthropic-version` 포함)는 그대로 전달해요.

다음 네 조건을 **모두** 충족하면 패스스루가 작동해요. `nativePassthrough`가 `false`가 아니고,
모델 이름이 `claude` 또는 `anthropic`으로 시작하며, bearer 또는 `x-api-key`가 `sk-ant-`로
시작하고, 별칭/모델 맵 해석 결과가 변경되지 않은 같은 모델이어야 해요. 그래서 `ocx claude`를
사용할 때 "claude.ai connectors are disabled" 경고도 더 이상 나타나지 않아요.
루프백이 아닌 리스너에서는 `x-opencodex-api-key`를 통한 프록시 인증도 필요하며,
`authorization`과 `x-api-key`는 제공자 자격 증명 전용이에요.

`claudeCode.nativePassthrough: false`로 끌 수 있고, `claudeCode.anthropicBaseUrl`로 다른 주소를
지정할 수 있어요.
Expand Down
5 changes: 4 additions & 1 deletion docs-site/src/content/docs/zh-cn/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,16 @@ macOS;在其他平台上,请使用 `ocx claude`。
而已路由模型仍可在同一会话中通过选择器别名使用。

**请求头处理:**转发前会移除逐跳请求头以及 `host`、`content-length`、`accept-encoding`、
`x-opencodex-api-key` 和 `origin`。其他所有请求头(包括 `anthropic-beta` 和
`x-opencodex-api-key` 和 `origin`。与 OpenCodex 准入密钥匹配的 `authorization` 或
`x-api-key` 值也会被移除;其他端到端请求头(包括 `anthropic-beta` 和
`anthropic-version`)都会透传。

只有同时满足以下**四个**条件时才会触发透传:`nativePassthrough` 不为 `false`;模型以
`claude` 或 `anthropic` 开头;bearer 或 `x-api-key` 以 `sk-ant-` 开头;并且别名/模型映射
解析后返回的模型保持不变。这也意味着使用 `ocx claude` 时不再出现
“claude.ai connectors are disabled”警告。
在非回环监听器上,原生透传还要求通过 `x-opencodex-api-key` 完成代理准入;
`authorization` 和 `x-api-key` 仅用于上游提供商凭据。

可以设置 `claudeCode.nativePassthrough: false` 来禁用;也可以通过
`claudeCode.anthropicBaseUrl` 指向其他位置。
Expand Down
22 changes: 18 additions & 4 deletions src/server/claude-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { estimateTokens } from "../lib/token-estimate";
import { NoEligiblePolicyCandidateError, routeModel } from "../router";
import { evidenceFromBody } from "../routing/request-evidence";
import { resolveWireProtocolOverride } from "./adapter-resolve";
import { isApiAuthRequired, isDataPlaneAdmissionSecret, isProxyAdmissionSecret } from "./auth-cors";
import type { OcxConfig } from "../types";
import { readJsonRequestBody } from "./request-decompress";
import { addFinalRequestLog, httpStatusForTerminalStatus, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log";
Expand Down Expand Up @@ -94,16 +95,23 @@ const PASSTHROUGH_STRIP_HEADERS = new Set([
"accept-encoding", "x-opencodex-api-key", "origin",
]);

function hasAnthropicNativeCredential(req: Request): boolean {
function hasAnthropicNativeCredential(req: Request, config: OcxConfig): boolean {
const bearer = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() ?? "";
const apiKey = req.headers.get("x-api-key")?.trim() ?? "";
return bearer.startsWith("sk-ant-") || apiKey.startsWith("sk-ant-");
return (bearer.startsWith("sk-ant-") && !isProxyAdmissionSecret(bearer, config))
|| (apiKey.startsWith("sk-ant-") && !isProxyAdmissionSecret(apiKey, config));
}

function wantsNativePassthrough(req: Request, config: OcxConfig, model: unknown): model is string {
if (config.claudeCode?.nativePassthrough === false) return false;
if (typeof model !== "string" || !/^(claude|anthropic)/i.test(model)) return false;
if (!hasAnthropicNativeCredential(req)) return false;
// On exposed listeners, keep proxy admission on its dedicated header so the
// provider-owned Authorization/X-Api-Key credentials are never ambiguous.
if (isApiAuthRequired(config)) {
const admission = req.headers.get("x-opencodex-api-key")?.trim() ?? "";
if (!isDataPlaneAdmissionSecret(admission, config)) return false;
}
if (!hasAnthropicNativeCredential(req, config)) return false;
// An alias or modelMap hit means the user asked for a ROUTED model: translate instead.
return resolveInboundModel(model, config.claudeCode) === model;
}
Expand Down Expand Up @@ -324,7 +332,13 @@ async function anthropicNativePassthrough(
}
const headers = new Headers();
req.headers.forEach((value, name) => {
if (!PASSTHROUGH_STRIP_HEADERS.has(name.toLowerCase())) headers.set(name, value);
const lowerName = name.toLowerCase();
if (PASSTHROUGH_STRIP_HEADERS.has(lowerName)) return;
const credential = lowerName === "authorization"
? value.replace(/^Bearer\s+/i, "").trim()
: lowerName === "x-api-key" ? value.trim() : "";
if (credential && isProxyAdmissionSecret(credential, config)) return;
headers.set(name, value);
});
headers.set("content-type", "application/json");

Expand Down
46 changes: 46 additions & 0 deletions tests/claude-native-passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,52 @@ test("count_tokens passes through with native credentials", async () => {
}
});

test("native passthrough never forwards proxy admission credentials", async () => {
const previousToken = process.env.OPENCODEX_API_AUTH_TOKEN;
process.env.OPENCODEX_API_AUTH_TOKEN = "sk-ant-api03-key";
const captured: Captured[] = [];
const upstream = mockAnthropicUpstream(captured);
saveConfig({ ...cfg(upstream.url.toString().replace(/\/$/, "")), hostname: "0.0.0.0" });
const server = startServer(0);
const url = new URL("/v1/messages", server.url);
url.hostname = "127.0.0.1";
try {
const ambiguous = await globalThis.fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
"authorization": "Bearer sk-ant-api03-key",
"x-api-key": "sk-ant-oat01-tst",
},
body: JSON.stringify(claudeBody()),
});
expect(ambiguous.status).not.toBe(200);
expect(captured).toHaveLength(0);

const dedicated = await globalThis.fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
"x-opencodex-api-key": "sk-ant-api03-key",
"authorization": "Bearer sk-ant-oat01-tst",
"x-api-key": "sk-ant-api03-key",
},
body: JSON.stringify(claudeBody()),
});
expect(dedicated.status).toBe(200);
await dedicated.text();
expect(captured).toHaveLength(1);
expect(captured[0].headers.get("authorization")).toBe("Bearer sk-ant-oat01-tst");
expect(captured[0].headers.get("x-api-key")).toBeNull();
expect(captured[0].headers.get("x-opencodex-api-key")).toBeNull();
Comment on lines +230 to +245

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Add a successful passthrough case for authorization filtering.

Lines 243-245 only verify removal of an admission secret from x-api-key. The rejected request does not enter anthropicNativePassthrough, so it does not test the authorization branch at src/server/claude-messages.ts lines 337-340.

Send a second dedicated request with the admission secret in authorization, a provider credential in x-api-key, and the valid x-opencodex-api-key. Assert that the upstream receives no authorization header and retains the provider x-api-key.

Proposed regression case
+    const authorizationAdmission = await globalThis.fetch(url, {
+      method: "POST",
+      headers: {
+        "content-type": "application/json",
+        "x-opencodex-api-key": "sk-ant-api03-key",
+        "authorization": "Bearer sk-ant-api03-key",
+        "x-api-key": "sk-ant-oat01-tst",
+      },
+      body: JSON.stringify(claudeBody()),
+    });
+    expect(authorizationAdmission.status).toBe(200);
+    await authorizationAdmission.text();
+    expect(captured).toHaveLength(2);
+    expect(captured[1].headers.get("authorization")).toBeNull();
+    expect(captured[1].headers.get("x-api-key")).toBe("sk-ant-oat01-tst");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const dedicated = await globalThis.fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
"x-opencodex-api-key": "sk-ant-api03-key",
"authorization": "Bearer sk-ant-oat01-tst",
"x-api-key": "sk-ant-api03-key",
},
body: JSON.stringify(claudeBody()),
});
expect(dedicated.status).toBe(200);
await dedicated.text();
expect(captured).toHaveLength(1);
expect(captured[0].headers.get("authorization")).toBe("Bearer sk-ant-oat01-tst");
expect(captured[0].headers.get("x-api-key")).toBeNull();
expect(captured[0].headers.get("x-opencodex-api-key")).toBeNull();
const dedicated = await globalThis.fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
"x-opencodex-api-key": "sk-ant-api03-key",
"authorization": "Bearer sk-ant-oat01-tst",
"x-api-key": "sk-ant-api03-key",
},
body: JSON.stringify(claudeBody()),
});
expect(dedicated.status).toBe(200);
await dedicated.text();
expect(captured).toHaveLength(1);
expect(captured[0].headers.get("authorization")).toBe("Bearer sk-ant-oat01-tst");
expect(captured[0].headers.get("x-api-key")).toBeNull();
expect(captured[0].headers.get("x-opencodex-api-key")).toBeNull();
const authorizationAdmission = await globalThis.fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
"x-opencodex-api-key": "sk-ant-api03-key",
"authorization": "Bearer sk-ant-api03-key",
"x-api-key": "sk-ant-oat01-tst",
},
body: JSON.stringify(claudeBody()),
});
expect(authorizationAdmission.status).toBe(200);
await authorizationAdmission.text();
expect(captured).toHaveLength(2);
expect(captured[1].headers.get("authorization")).toBeNull();
expect(captured[1].headers.get("x-api-key")).toBe("sk-ant-oat01-tst");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/claude-native-passthrough.test.ts` around lines 230 - 245, Add a second
successful dedicated passthrough request in this test, using the admission
secret in authorization, a provider credential in x-api-key, and the valid
x-opencodex-api-key. Assert the request succeeds and the captured upstream
request removes authorization while retaining the provider x-api-key; keep the
existing x-opencodex-api-key filtering assertions.

Source: Path instructions

} finally {
await server.stop(true);
upstream.stop(true);
if (previousToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN;
else process.env.OPENCODEX_API_AUTH_TOKEN = previousToken;
}
});

test("alias/mapped models and non-anthropic credentials do NOT pass through", async () => {
const captured: Captured[] = [];
const upstream = mockAnthropicUpstream(captured);
Expand Down
Loading