From 9083f526715eb4bf4e6c047f4ba865ed548592e4 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:39:43 +0900 Subject: [PATCH 1/2] fix(claude): isolate native passthrough credentials --- .../src/content/docs/guides/claude-code.md | 12 ++- .../src/content/docs/ja/guides/claude-code.md | 12 ++- .../docs/ja/reference/configuration/server.md | 5 + .../docs/ja/reference/proxy-formats.md | 9 +- .../src/content/docs/ko/guides/claude-code.md | 12 ++- .../docs/ko/reference/configuration/server.md | 5 + .../docs/ko/reference/proxy-formats.md | 7 +- .../docs/reference/configuration/server.md | 5 + .../content/docs/reference/proxy-formats.md | 8 +- .../src/content/docs/ru/guides/claude-code.md | 15 ++- .../docs/ru/reference/configuration/server.md | 5 + .../docs/ru/reference/proxy-formats.md | 10 +- .../content/docs/zh-cn/guides/claude-code.md | 12 ++- .../zh-cn/reference/configuration/server.md | 4 + .../docs/zh-cn/reference/proxy-formats.md | 8 +- .../content/docs/zh-tw/guides/claude-code.md | 12 ++- .../zh-tw/reference/configuration/server.md | 4 + .../docs/zh-tw/reference/proxy-formats.md | 6 +- src/server/claude-messages.ts | 68 +++++++++++--- src/server/index.ts | 8 +- tests/claude-native-passthrough.test.ts | 92 +++++++++++++++++++ 21 files changed, 270 insertions(+), 49 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index d7b39d9687..5cf75f63ad 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -156,12 +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 always stripped before forwarding. On a non-loopback bind, +native passthrough also requires a valid proxy credential in `x-opencodex-api-key`; `Authorization` +and `x-api-key` then belong only to Anthropic. A proxy admission secret found in either provider +header is removed, while a genuine provider credential in the other header is preserved. Ambiguous +comma-joined credential headers are not forwarded. -The passthrough fires when **all four** conditions are met: `nativePassthrough` is not `false`; +The passthrough fires when all of these 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 +alias/model-map resolution returns the same model unchanged; and, on a non-loopback bind, the +dedicated proxy admission header is valid. This also means the "claude.ai connectors are disabled" warning no longer appears with `ocx claude`. Disable with `claudeCode.nativePassthrough: false`; point elsewhere with diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index 7b58804e1b..c6153c7e33 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -50,12 +50,16 @@ ocx claude ネイティブ状態で維持され、同じセッションでピッカーエイリアスを使ってルーティングモデルも引き続き使えます。 **ヘッダー処理:** hop-by-hop ヘッダーと `host`、`content-length`、`accept-encoding`、 -`x-opencodex-api-key`、`origin` は転送前に削除します。それ以外のヘッダー(`anthropic-beta`、 -`anthropic-version` を含む)はそのまま転送します。 +`x-opencodex-api-key`、`origin` は常に転送前に削除します。非ループバック bind のネイティブ +パススルーでは、有効なプロキシ認証情報を `x-opencodex-api-key` でも要求し、 +`Authorization` と `x-api-key` は Anthropic 専用になります。いずれかの provider ヘッダーに +プロキシ admission secret があれば削除し、もう一方の実際の provider 認証情報は維持します。 +カンマで結合された曖昧な認証ヘッダーは転送しません。 -次の 4 つの条件を**すべて**満たすとパススルーが動作します。`nativePassthrough` が `false` でなく、 +次の条件を**すべて**満たすとパススルーが動作します。`nativePassthrough` が `false` でなく、 モデル名が `claude` または `anthropic` で始まり、bearer または `x-api-key` が `sk-ant-` で -始まり、エイリアス/モデルマップ解決結果が変更されていない同じモデルであること。そのため `ocx claude` を +始まり、エイリアス/モデルマップ解決結果が変更されていない同じモデルであり、非ループバック bind +では専用プロキシ admission ヘッダーも有効であること。そのため `ocx claude` を 使うとき "claude.ai connectors are disabled" 警告ももう表示されません。 `claudeCode.nativePassthrough: false` でオフにでき、`claudeCode.anthropicBaseUrl` で別のアドレスを diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index 0267473785..26dbbf18f5 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -54,6 +54,11 @@ x-opencodex-api-key: your-secret-token 応答とチャット完了では、Codex Direct パススルーの可能性のために `Authorization` を予約しているため、そこでは専用のアドミッション ヘッダーのみが受け入れられます。ダッシュボードで生成された `apiKeys` は、起動後に環境トークンを置き換える可能性があります。候補は一定時間内に比較されます。 +Messages はルーティングクライアントとの互換性のために 3 つの admission 形式を引き続き受け入れます。 +ただし非ループバック bind のネイティブ Anthropic パススルーでは、プロキシ admission は +`x-opencodex-api-key` のみを使い、`Authorization` と `x-api-key` は Anthropic 認証情報用に +予約されます。これら provider ヘッダー内のプロキシ admission secret は転送前に削除されます。 + :::caution[LAN露出] `0.0.0.0` バインドは、プロキシと構成されたプロバイダーの LAN へのアクセスを公開します。強力なトークンを持つ信頼できるネットワークでのみ使用してください。 ::: diff --git a/docs-site/src/content/docs/ja/reference/proxy-formats.md b/docs-site/src/content/docs/ja/reference/proxy-formats.md index e5cb351320..2d6f4fdf6a 100644 --- a/docs-site/src/content/docs/ja/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ja/reference/proxy-formats.md @@ -123,11 +123,16 @@ WebSocket が無効になっている場合、アップグレード試行では - ネイティブ パススルーはクロード コード設定で無効になっていません。 - 要求されたモデルは `claude` または `anthropic` で始まります。 -- リクエストにはネイティブ Anthropic Bearer または `x-api-key` 資格情報が含まれます。そして +- リクエストにはネイティブ Anthropic Bearer または `x-api-key` 資格情報が含まれます。 +- 非ループバック listener では、有効なプロキシ admission を `x-opencodex-api-key` だけで送ります。そして - 構成されたエイリアスまたはモデル マップが、ルーティングされたターゲットのモデル ID を主張しません。 適格なリクエストは Anthropic 方言で転送されるため、ネイティブ ベータ ヘッダー、思考署名、およびサブスクリプション ID がエンドツーエンドで残ります。それ以外の場合は、応答が往復してかかります。 +専用 admission ヘッダーは転送されません。`Authorization` または `x-api-key` にあるプロキシ +admission secret も削除され、別の実際の Anthropic 認証情報は維持されます。カンマで結合された +曖昧な認証ヘッダーは fail closed します。 + `POST /v1/messages/count_tokens` は、同じモデルの解像度とパススルーの決定に従います。ネイティブ適格なリクエストは、Anthropic のカウント エンドポイントに転送されます。他のリクエストは、システム コンテンツ、メッセージ、ツールに対してローカルに文書化された見積もりを使用し、次を返します。 ```json @@ -213,4 +218,4 @@ Anthropic オリジンの失敗は Anthropic のエラー エンベロープで プロキシは、本物のバックエンド暗号文を不透明なものとして扱います。構造的に有効な暗号文はバイト単位で保存されます。opencodex は暗号文を復号したり、その内容を変換したり、別のプロバイダー用に再暗号化したりしません。 -一部のエージェント フックはこれまで、プレーンテキストの制御テキストを `encrypted_content` スロットに配置していました。互換性を確保するために、プロキシは、構造的に有効な Fernet の実行を変更せずに保持しながら、プレーンテキストをテキスト部分に分割します。 `agent_message` が修復中にすべての暗号化された部分を失った場合、それは通常のユーザー メッセージになります。現在の v2 タスクが完全に暗号化されたままであるが、選択したルーティングされたターゲットがネイティブ ChatGPT 暗号文を読み取ることができない場合、opencodex は読み取り不能なバイトをそのプロバイダーに送信する代わりに `unreadable_encrypted_agent_task` で失敗します。ワーカー タスクに関するクライアントの動作については、[サブエージェントサーフェス](/guides/sub-agent-surface/) を参照してください。 \ No newline at end of file +一部のエージェント フックはこれまで、プレーンテキストの制御テキストを `encrypted_content` スロットに配置していました。互換性を確保するために、プロキシは、構造的に有効な Fernet の実行を変更せずに保持しながら、プレーンテキストをテキスト部分に分割します。 `agent_message` が修復中にすべての暗号化された部分を失った場合、それは通常のユーザー メッセージになります。現在の v2 タスクが完全に暗号化されたままであるが、選択したルーティングされたターゲットがネイティブ ChatGPT 暗号文を読み取ることができない場合、opencodex は読み取り不能なバイトをそのプロバイダーに送信する代わりに `unreadable_encrypted_agent_task` で失敗します。ワーカー タスクに関するクライアントの動作については、[サブエージェントサーフェス](/guides/sub-agent-surface/) を参照してください。 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index fd225263e6..a1455ecd45 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -75,12 +75,16 @@ macOS의 자동 연결(`claudeCode.systemEnv`)도 같은 방식으로 판단하 네이티브 상태로 유지되고, 같은 세션에서 선택기 별칭을 써서 라우팅 모델도 계속 사용할 수 있어요. **헤더 처리:** hop-by-hop 헤더와 `host`, `content-length`, `accept-encoding`, -`x-opencodex-api-key`, `origin`은 전달 전에 제거해요. 그 밖의 헤더(`anthropic-beta`, -`anthropic-version` 포함)는 그대로 전달해요. +`x-opencodex-api-key`, `origin`은 항상 전달 전에 제거해요. 비루프백 바인드의 네이티브 패스스루는 +유효한 프록시 자격 증명을 `x-opencodex-api-key`로도 요구하고, 이때 `Authorization`과 +`x-api-key`는 Anthropic 전용이에요. 두 provider 헤더 중 프록시 admission secret이 있으면 +제거하고, 다른 헤더의 실제 provider 자격 증명은 유지해요. 쉼표로 결합된 모호한 자격 증명 +헤더는 전달하지 않아요. -다음 네 조건을 **모두** 충족하면 패스스루가 작동해요. `nativePassthrough`가 `false`가 아니고, +다음 조건을 **모두** 충족하면 패스스루가 작동해요. `nativePassthrough`가 `false`가 아니고, 모델 이름이 `claude` 또는 `anthropic`으로 시작하며, bearer 또는 `x-api-key`가 `sk-ant-`로 -시작하고, 별칭/모델 맵 해석 결과가 변경되지 않은 같은 모델이어야 해요. 그래서 `ocx claude`를 +시작하고, 별칭/모델 맵 해석 결과가 변경되지 않은 같은 모델이며, 비루프백 바인드에서는 전용 +프록시 admission 헤더도 유효해야 해요. 그래서 `ocx claude`를 사용할 때 "claude.ai connectors are disabled" 경고도 더 이상 나타나지 않아요. `claudeCode.nativePassthrough: false`로 끌 수 있고, `claudeCode.anthropicBaseUrl`로 다른 주소를 diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index ac267cf281..879f232c84 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -54,6 +54,11 @@ x-opencodex-api-key: your-secret-token Responses와 Chat Completions는 `Authorization`을 향후 Codex Direct 패스스루 용도로 예약해 두므로, 여기서는 전용 admission 헤더만 허용됩니다. 대시보드에서 생성한 `apiKeys`는 시작 후 환경 토큰을 대체할 수 있으며, 후보 값은 상수 시간으로 비교합니다. +Messages는 라우팅 클라이언트 호환성을 위해 세 admission 형식을 계속 허용합니다. 하지만 비루프백 +바인드의 네이티브 Anthropic 패스스루는 프록시 admission을 `x-opencodex-api-key`로만 받고, +`Authorization`과 `x-api-key`를 Anthropic 자격 증명용으로 예약합니다. 이 provider 헤더에 들어간 +프록시 admission secret은 upstream 전달 전에 제거됩니다. + :::caution[LAN exposure] `0.0.0.0` 바인드는 프록시와 설정된 provider 접근을 LAN에 노출합니다. 신뢰할 수 있는 네트워크에서 강한 토큰과 함께만 사용합니다. ::: diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index 066dc99b48..39497eef4b 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -153,11 +153,16 @@ Responses로 변환되어 일반적으로 라우팅된 뒤, Anthropic JSON 또 - Claude Code 설정에서 native passthrough가 비활성화되어 있지 않습니다. - 요청한 모델이 `claude` 또는 `anthropic`으로 시작합니다. - 요청에 네이티브 Anthropic bearer 또는 `x-api-key` 자격 증명이 들어 있습니다. +- 비루프백 listener에서는 `x-opencodex-api-key`에만 유효한 프록시 admission이 들어 있습니다. - 설정된 alias 또는 model map이 해당 model id를 라우팅 대상으로 점유하고 있지 않습니다. 적합한 요청은 Anthropic 방언으로 전달되므로 네이티브 beta 헤더, thinking 서명, 구독 식별 정보가 끝까지 유지됩니다. 그렇지 않으면 Responses 왕복을 탑니다. +전용 admission 헤더는 upstream으로 전달되지 않습니다. `Authorization` 또는 `x-api-key`에서 +프록시 admission secret이 발견되어도 제거하며, 별도의 실제 Anthropic 자격 증명은 유지합니다. +쉼표로 결합된 모호한 자격 증명 헤더는 전달하지 않고 fail closed합니다. + `POST /v1/messages/count_tokens`도 같은 model resolution과 passthrough 판단을 따릅니다. 네이티브로 적합한 요청은 Anthropic의 count endpoint로 전달됩니다. 그 외 요청은 system content, messages, tools에 대한 로컬 문서화 추정치를 사용하고 다음을 반환합니다. @@ -265,4 +270,4 @@ OpenAI 스타일 `origin_rejected` body가 아니라 403 `permission_error`입 작업이 실제로 암호화된 상태이고 선택된 라우팅 대상이 네이티브 ChatGPT 암호문을 읽을 수 없다면, opencodex는 읽을 수 없는 바이트를 프로바이더에 보내는 대신 `unreadable_encrypted_agent_task`로 실패합니다. worker task와 관련된 클라이언트 동작은 [서브에이전트 표면](/guides/sub-agent-surface/)을 -참조하세요. \ No newline at end of file +참조하세요. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 2aa02e20e0..5f1bcfb530 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -60,6 +60,11 @@ Responses and Chat Completions reserve `Authorization` for possible Codex Direct the dedicated admission header is accepted there. Dashboard-generated `apiKeys` may replace the environment token after startup; candidates are compared in constant time. +Messages keeps accepting all three admission forms for routed-client compatibility. Native +Anthropic passthrough is stricter on a non-loopback bind: proxy admission must use +`x-opencodex-api-key`, while `Authorization` and `x-api-key` are reserved for Anthropic credentials. +Any proxy admission secret placed in those provider headers is removed before forwarding. + :::caution[LAN exposure] A `0.0.0.0` bind exposes the proxy and configured provider access to the LAN. Use it only on trusted networks with a strong token. diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index af70c38f6d..bd02453019 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -172,13 +172,19 @@ Native Anthropic passthrough is eligible only when all of these are true: - native passthrough has not been disabled in Claude Code configuration; - the requested model begins with `claude` or `anthropic`; -- the request carries a native Anthropic bearer or `x-api-key` credential; and +- the request carries a native Anthropic bearer or `x-api-key` credential; +- on a non-loopback listener, the request also carries valid proxy admission only in + `x-opencodex-api-key`; and - no configured alias or model map claims that model id for a routed target. An eligible request is forwarded in the Anthropic dialect so native beta headers, thinking signatures, and subscription identity remain end to end. Otherwise it takes the Responses round-trip. +The dedicated admission header is never forwarded. Proxy admission secrets found in +`Authorization` or `x-api-key` are also removed; a separate genuine Anthropic credential is +preserved. Ambiguous comma-joined credential headers fail closed instead of being forwarded. + `POST /v1/messages/count_tokens` follows the same model resolution and passthrough decision. A native-eligible request is forwarded to Anthropic's count endpoint. Other requests use the local documented estimate over system content, messages, and tools and return: diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index c8ad0ba442..b3cde68a91 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -54,12 +54,17 @@ ocx claude работать в той же сессии через алиасы селектора. **Обработка заголовков:** hop-by-hop-заголовки, а также `host`, `content-length`, -`accept-encoding`, `x-opencodex-api-key` и `origin` удаляются перед пересылкой. Все остальные -заголовки (включая `anthropic-beta` и `anthropic-version`) проходят без изменений. - -Проброс срабатывает, когда выполнены **все четыре** условия: `nativePassthrough` не равен `false`; +`accept-encoding`, `x-opencodex-api-key` и `origin` всегда удаляются перед пересылкой. На +non-loopback bind нативный passthrough также требует валидный proxy credential именно в +`x-opencodex-api-key`; после этого `Authorization` и `x-api-key` принадлежат только Anthropic. +Proxy admission secret в любом provider-заголовке удаляется, а настоящий provider credential в +другом заголовке сохраняется. Неоднозначные credential-заголовки, объединённые запятыми, не +пересылаются. + +Проброс срабатывает, когда выполнены **все** условия: `nativePassthrough` не равен `false`; имя модели начинается с `claude` или `anthropic`; bearer или `x-api-key` начинается с `sk-ant-`; -и разрешение алиасов и карты моделей возвращает ту же модель без изменений. Это также означает, +разрешение алиасов и карты моделей возвращает ту же модель без изменений; а на non-loopback bind +валиден dedicated proxy admission-header. Это также означает, что предупреждение «claude.ai connectors are disabled» с `ocx claude` больше не появляется. Отключается параметром `claudeCode.nativePassthrough: false`; другой адрес задаётся через diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index d36fa33837..10eb6fa175 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -62,6 +62,11 @@ Responses и Chat Completions резервируют `Authorization` под во поэтому там принимается только dedicated admission-header. Сгенерированные в дашборде `apiKeys` могут после старта заменить env-token; сравнение кандидатов выполняется constant-time. +Messages ради совместимости routed-клиентов по-прежнему принимает все три формы admission. Но на +non-loopback bind нативный passthrough Anthropic принимает proxy admission только через +`x-opencodex-api-key`, а `Authorization` и `x-api-key` резервирует под credentials Anthropic. +Proxy admission secret в этих provider-заголовках удаляется перед пересылкой. + :::caution[Экспозиция в LAN] Bind на `0.0.0.0` открывает прокси и доступ к настроенным провайдерам всей локальной сети. Используйте его только в доверенных сетях и только с сильным токеном. diff --git a/docs-site/src/content/docs/ru/reference/proxy-formats.md b/docs-site/src/content/docs/ru/reference/proxy-formats.md index e2d4d3fa3e..6a461e8b9e 100644 --- a/docs-site/src/content/docs/ru/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ru/reference/proxy-formats.md @@ -163,13 +163,19 @@ adapter, вместо тихого изменения смысла вернёт - native passthrough не отключён в конфигурации Claude Code; - запрошенная модель начинается с `claude` или `anthropic`; -- запрос несёт нативный bearer Anthropic или `x-api-key`; и +- запрос несёт нативный bearer Anthropic или `x-api-key`; +- на non-loopback listener запрос также несёт валидный proxy admission только в + `x-opencodex-api-key`; и - ни один alias или model map не забирает этот model id в routed-цель. Если запрос подходит, он пересылается в Anthropic dialect, и нативные beta-header'ы, thinking signature'ы и subscription identity проходят сквозь систему end to end. В противном случае запрос идёт через round-trip Responses. +Dedicated admission-header никогда не пересылается. Proxy admission secret в `Authorization` или +`x-api-key` также удаляется, а отдельный настоящий credential Anthropic сохраняется. Неоднозначные +credential-заголовки, объединённые запятыми, завершаются fail closed. + `POST /v1/messages/count_tokens` использует те же правила разрешения модели и то же решение о passthrough. Native-eligible-запрос пересылается в count-endpoint Anthropic. Для остальных запросов используется локальная документированная оценка по system content, messages и tools, и @@ -283,4 +289,4 @@ ciphertext сохраняется байт в байт: opencodex его не р остаётся по-настоящему зашифрованной, а выбранная routed-цель не умеет читать ciphertext нативного ChatGPT, opencodex завершит запрос ошибкой `unreadable_encrypted_agent_task`, вместо того чтобы отправить нечитаемые байты этому провайдеру. О поведении клиента вокруг worker-task'ов см. -[Поверхность подагентов](/guides/sub-agent-surface/). \ No newline at end of file +[Поверхность подагентов](/guides/sub-agent-surface/). diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 82599d70ac..ebcce80711 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -49,13 +49,15 @@ macOS;在其他平台上,请使用 `ocx claude`。 **原样**转发到 `api.anthropic.com`——beta、思考签名、提示缓存和计费身份都保持完全原生, 而已路由模型仍可在同一会话中通过选择器别名使用。 -**请求头处理:**转发前会移除逐跳请求头以及 `host`、`content-length`、`accept-encoding`、 -`x-opencodex-api-key` 和 `origin`。其他所有请求头(包括 `anthropic-beta` 和 -`anthropic-version`)都会透传。 +**请求头处理:**转发前始终会移除逐跳请求头以及 `host`、`content-length`、 +`accept-encoding`、`x-opencodex-api-key` 和 `origin`。在非回环绑定上,原生透传还要求通过 +`x-opencodex-api-key` 提供有效的代理准入凭据;此时 `Authorization` 和 `x-api-key` 只属于 +Anthropic。若任一提供方请求头包含代理准入密钥,该密钥会被移除,而另一请求头中的真实提供方 +凭据会保留。含逗号拼接的歧义凭据请求头不会被转发。 -只有同时满足以下**四个**条件时才会触发透传:`nativePassthrough` 不为 `false`;模型以 +只有同时满足以下所有条件时才会触发透传:`nativePassthrough` 不为 `false`;模型以 `claude` 或 `anthropic` 开头;bearer 或 `x-api-key` 以 `sk-ant-` 开头;并且别名/模型映射 -解析后返回的模型保持不变。这也意味着使用 `ocx claude` 时不再出现 +解析后返回的模型保持不变;并且在非回环绑定上,专用代理准入请求头有效。这也意味着使用 `ocx claude` 时不再出现 “claude.ai connectors are disabled”警告。 可以设置 `claudeCode.nativePassthrough: false` 来禁用;也可以通过 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index 5da45c40f8..1303e33c65 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -59,6 +59,10 @@ x-opencodex-api-key: your-secret-token Responses 和 Chat Completions 会保留 `Authorization`,以便将来可能支持 Codex Direct 透传,因此这里只接受专用的准入头。仪表板生成的 `apiKeys` 可以在启动后替换 环境令牌;候选项按常量时间比较。 +Messages 为兼容路由客户端仍接受三种准入形式。但在非回环绑定上,原生 Anthropic 透传只通过 +`x-opencodex-api-key` 接受代理准入,并把 `Authorization` 和 `x-api-key` 保留给 Anthropic +凭据。放入这些提供方请求头的代理准入密钥会在转发前被移除。 + :::caution[LAN 暴露] 绑定到 `0.0.0.0` 会将代理及其配置的提供方访问暴露给局域网。仅应在受信任 的网络中配合强令牌使用。 diff --git a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md index 9b9cbc8aa0..a469e2f628 100644 --- a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md @@ -141,11 +141,15 @@ choice 增量、带 `finish_reason` 的终止 choice,以及 `data: [DONE]`。 - Claude Code 配置中尚未禁用原生透传; - 请求的模型以 `claude` 或 `anthropic` 开头; -- 请求携带原生 Anthropic bearer 或 `x-api-key` 凭证;并且 +- 请求携带原生 Anthropic bearer 或 `x-api-key` 凭证; +- 在非回环监听器上,请求还仅通过 `x-opencodex-api-key` 携带有效代理准入;并且 - 没有配置的别名或模型映射把该 model id 声明为一个被路由目标。 符合条件的请求会以 Anthropic 方言转发,因此原生 beta 头、thinking 签名和订阅身份都能端到端保留。否则它会走 Responses 往返。 +专用准入请求头绝不会转发。`Authorization` 或 `x-api-key` 中的代理准入密钥也会被移除, +而另一个请求头中的真实 Anthropic 凭据会保留。含逗号拼接的歧义凭据请求头会 fail closed。 + `POST /v1/messages/count_tokens` 采用相同的模型解析和透传决策。符合原生条件的请求会转发到 Anthropic 的 count 端点。其他请求会使用本地文档化的估算值,对 system 内容、messages 和 tools 进行统计,并返回: ```json @@ -237,4 +241,4 @@ Anthropic 来源的失败会以 Anthropic 的错误封装呈现,因此该方 代理把真正的后端密文视为不透明数据。结构有效的密文会逐字节保留:opencodex 不会对其解密、翻译其内容,或为另一个提供方重新加密。 某些 agent hook 历史上会把明文控制文本放进 `encrypted_content` 槽。为兼容起见,代理会把那部分明文拆分为文本片段,同时保持任何结构有效的 Fernet 片段不变。如果一个 `agent_message` 在该修复过程中失去了所有加密部分,它就会变成普通的 user message。如果当前的 v2 task 仍然真的是加密的,但所选路由目标无法读取原生 ChatGPT 密文,opencodex 会以 -`unreadable_encrypted_agent_task` 失败,而不是把不可读字节发送给该提供方。有关 worker task 周边的客户端行为,请参见 [Sub-agent Surface](/guides/sub-agent-surface/)。 \ No newline at end of file +`unreadable_encrypted_agent_task` 失败,而不是把不可读字节发送给该提供方。有关 worker task 周边的客户端行为,请参见 [Sub-agent Surface](/guides/sub-agent-surface/)。 diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index 9fae612f35..702ee72241 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -125,13 +125,15 @@ macOS;在其他平臺上,請使用 `ocx claude`。 **原樣**轉發到 `api.anthropic.com`——beta、思考簽名、提示快取和計費身份都保持完全原生, 而已路由模型仍可在同一會話中透過選擇器別名使用。 -**標頭處理:**轉發前會移除逐跳標頭以及 `host`、`content-length`、`accept-encoding`、 -`x-opencodex-api-key` 和 `origin`。其他所有標頭(包括 `anthropic-beta` 和 -`anthropic-version`)都會透傳。 +**標頭處理:**轉發前一律移除逐跳標頭以及 `host`、`content-length`、 +`accept-encoding`、`x-opencodex-api-key` 和 `origin`。在非回環綁定上,原生透傳還要求透過 +`x-opencodex-api-key` 提供有效的代理許可憑證;此時 `Authorization` 與 `x-api-key` 僅屬於 +Anthropic。若任一供應商標頭含有代理許可密鑰,該密鑰會被移除,而另一標頭中的真正供應商 +憑證會保留。以逗號合併的模糊憑證標頭不會被轉發。 -只有同時滿足以下**四個**條件時才會觸發透傳:`nativePassthrough` 不為 `false`;模型以 +只有同時滿足以下所有條件時才會觸發透傳:`nativePassthrough` 不為 `false`;模型以 `claude` 或 `anthropic` 開頭;bearer 或 `x-api-key` 以 `sk-ant-` 開頭;並且別名/模型對映 -解析後回傳的模型保持不變。這也意味著使用 `ocx claude` 時不再出現 +解析後回傳的模型保持不變;且在非回環綁定上,專用代理許可標頭有效。這也意味著使用 `ocx claude` 時不再出現 “claude.ai connectors are disabled”警告。 可以設定 `claudeCode.nativePassthrough: false` 來停用;也可以透過 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md index ba13867bd3..c3ef236c53 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md @@ -54,6 +54,10 @@ x-opencodex-api-key: your-secret-token Responses 與 Chat Completions 為可能的 Codex Direct passthrough 保留 `Authorization`,因此那裡僅接受專屬的許可標頭。儀表板生成的 `apiKeys` 可在啟動後取代環境 token;候選值以常數時間比對。 +Messages 為相容路由客戶端仍接受三種許可形式。但在非回環綁定上,原生 Anthropic 透傳只透過 +`x-opencodex-api-key` 接受代理許可,並將 `Authorization` 與 `x-api-key` 保留給 Anthropic +憑證。放在這些供應商標頭中的代理許可密鑰會在轉發前移除。 + :::caution[LAN 暴露] `0.0.0.0` 綁定將代理與設定的供應商存取暴露給 LAN。僅在受信任的網路上搭配強 token 使用。 ::: diff --git a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md index 4973da970b..64852d205c 100644 --- a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md @@ -127,11 +127,15 @@ Responses 表示是橋接的中心。原生相容的路由可跳過部分轉譯 - 原生 passthrough 未在 Claude Code 設定中被停用; - 請求的模型以 `claude` 或 `anthropic` 開頭; -- 請求帶有原生 Anthropic bearer 或 `x-api-key` 憑證;且 +- 請求帶有原生 Anthropic bearer 或 `x-api-key` 憑證; +- 在非回環 listener 上,請求還只透過 `x-opencodex-api-key` 攜帶有效代理許可;且 - 無設定的別名或模型映射為路由目標聲明該模型 id。 合格的請求以 Anthropic 方言轉發,使原生 beta 標頭、thinking 簽章與訂閱身分保持端到端。否則它走 Responses 往返。 +專用許可標頭絕不轉發。`Authorization` 或 `x-api-key` 中的代理許可密鑰也會被移除, +而另一標頭中的真正 Anthropic 憑證會保留。以逗號合併的模糊憑證標頭會 fail closed。 + `POST /v1/messages/count_tokens` 遵循相同的模型解析與 passthrough 決策。原生合格的請求被轉發到 Anthropic 的計數端點。其他請求使用基於 system 內容、訊息與工具的本機檔案式估計並回傳: ```json diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 766ca6a4d6..3391320d23 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -35,6 +35,12 @@ import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput, import { conversationIdFromClaudeMetadata } from "./request-log-conversation"; import { responseWithDeferredRequestLog } from "./relay"; import { handleResponses } from "./responses"; +import { + isApiAuthRequired, + isDataPlaneAdmissionSecret, + isProxyAdmissionSecret, + type RequestPolicyView, +} from "./auth-cors"; import type { AdmissionLease } from "../lib/admission"; import { tryClaimNativeMainProfileForTurn } from "../codex/native-main-admission"; import { CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE } from "../codex/auth-context"; @@ -96,20 +102,54 @@ const PASSTHROUGH_STRIP_HEADERS = new Set([ "accept-encoding", "x-opencodex-api-key", "origin", ]); -function hasAnthropicNativeCredential(req: Request): 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-"); +function singleCredentialToken(name: "authorization" | "x-api-key", value: string | null): string | null { + const raw = value?.trim() ?? ""; + // Fetch Headers comma-joins duplicate fields. Neither Anthropic credential format permits a + // comma, so treating a joined value as one token could hide an admission secret behind a real + // provider credential. Ambiguous credential headers fail closed. + if (!raw || raw.includes(",")) return null; + if (name === "authorization") { + const match = /^Bearer\s+(.+)$/i.exec(raw); + return match?.[1]?.trim() || null; + } + return raw; +} + +function hasAnthropicNativeCredential(req: Request, config: OcxConfig): boolean { + const bearer = singleCredentialToken("authorization", req.headers.get("authorization")); + const apiKey = singleCredentialToken("x-api-key", req.headers.get("x-api-key")); + return (!!bearer && bearer.startsWith("sk-ant-") && !isProxyAdmissionSecret(bearer, config)) + || (!!apiKey && apiKey.startsWith("sk-ant-") && !isProxyAdmissionSecret(apiKey, config)); } -function wantsNativePassthrough(req: Request, config: OcxConfig, model: unknown): model is string { +function wantsNativePassthrough( + req: Request, + config: OcxConfig, + requestPolicy: RequestPolicyView, + 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; + // Authorization and x-api-key both belong to the upstream on this branch. An exposed listener + // therefore requires the dedicated admission header even though the routed Messages surface + // keeps accepting all three legacy admission forms. + if (isApiAuthRequired(requestPolicy)) { + const dedicated = req.headers.get("x-opencodex-api-key")?.trim() ?? ""; + if (!isDataPlaneAdmissionSecret(dedicated, 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; } +function shouldForwardNativeHeader(name: string, value: string, config: OcxConfig): boolean { + const lowerName = name.toLowerCase(); + if (PASSTHROUGH_STRIP_HEADERS.has(lowerName)) return false; + if (lowerName !== "authorization" && lowerName !== "x-api-key") return true; + const token = singleCredentialToken(lowerName, value); + return !!token && !isProxyAdmissionSecret(token, config); +} + /** Format a 32-hex cache key as a uuid-shaped session id (version/variant nibbles forced). */ function uuidFromHex(hex32: string): string { const h = (hex32 + "0".repeat(32)).slice(0, 32); @@ -330,7 +370,7 @@ async function anthropicNativePassthrough( } const headers = new Headers(); req.headers.forEach((value, name) => { - if (!PASSTHROUGH_STRIP_HEADERS.has(name.toLowerCase())) headers.set(name, value); + if (shouldForwardNativeHeader(name, value, config)) headers.set(name, value); }); headers.set("content-type", "application/json"); @@ -528,11 +568,12 @@ export async function handleClaudeMessages( config: OcxConfig, logCtx: RequestLogContext, logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease }, + requestPolicy: RequestPolicyView = config, ): Promise { const translatorBudget = createTranslatorBudget(); try { return finalizeTranslatorBudgetResponse( - await handleClaudeMessagesWithBudget(req, config, logCtx, translatorBudget, logIds), + await handleClaudeMessagesWithBudget(req, config, logCtx, translatorBudget, logIds, requestPolicy), translatorBudget, ); } catch (error) { @@ -547,6 +588,7 @@ async function handleClaudeMessagesWithBudget( logCtx: RequestLogContext, translatorBudget: TranslatorBudget, logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease }, + requestPolicy: RequestPolicyView = config, ): Promise { logCtx.surface = "claude"; const disabled = claudeInboundDisabled(config); @@ -601,7 +643,7 @@ async function handleClaudeMessagesWithBudget( ); if (claudeConversationId) logCtx.conversationId = claudeConversationId; } - if (isRec(anthropicBody) && wantsNativePassthrough(req, config, anthropicBody.model)) { + if (isRec(anthropicBody) && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model)) { return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages"); } if (isRec(anthropicBody) && effortOverride) { @@ -940,7 +982,11 @@ export function estimateClaudeRequestTokens( return Math.max(1, estimateTokens(parts.join("\n"), modelId) + attachmentTokens); } -export async function handleClaudeCountTokens(req: Request, config: OcxConfig): Promise { +export async function handleClaudeCountTokens( + req: Request, + config: OcxConfig, + requestPolicy: RequestPolicyView = config, +): Promise { const disabled = claudeInboundDisabled(config); if (disabled) return disabled; @@ -973,7 +1019,7 @@ export async function handleClaudeCountTokens(req: Request, config: OcxConfig): raw.model = model; } captureClaudeInbound("count_tokens", raw, resolveInboundModel(model, config.claudeCode), req.headers.get("anthropic-beta") ?? undefined); - if (wantsNativePassthrough(req, config, model)) { + if (wantsNativePassthrough(req, config, requestPolicy, model)) { return await anthropicNativePassthrough(req, config, { model, provider: "anthropic-native", surface: "claude" }, undefined, raw, "/v1/messages/count_tokens"); } const inputTokens = estimateClaudeRequestTokens(raw, model); diff --git a/src/server/index.ts b/src/server/index.ts index 8a5ffd0103..36352446ca 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1210,7 +1210,11 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server withCors(await handleClaudeCountTokens(req, config), req, policy)); + return runAdmittedHttpTurn(req, policy, async () => withCors( + await handleClaudeCountTokens(req, config, policy), + req, + policy, + )); } if (url.pathname === "/v1/messages" && req.method === "POST") { @@ -1237,7 +1241,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server withCors( - await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease }), + await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease }, policy), req, config, )); diff --git a/tests/claude-native-passthrough.test.ts b/tests/claude-native-passthrough.test.ts index f64c4c9481..acfd6f5244 100644 --- a/tests/claude-native-passthrough.test.ts +++ b/tests/claude-native-passthrough.test.ts @@ -205,6 +205,98 @@ test("count_tokens passes through with native credentials", async () => { } }); +test("exposed native passthrough requires dedicated admission and never forwards admission credentials", async () => { + const admissionSecret = "sk-ant-api03-key"; + const providerBearer = "sk-ant-oat01-provider"; + const providerApiKey = "sk-ant-api03-provider"; + const captured: Captured[] = []; + const upstream = mockAnthropicUpstream(captured); + saveConfig({ + ...cfg(upstream.url.toString().replace(/\/$/, "")), + hostname: "0.0.0.0", + apiKeys: [{ id: "remote", name: "remote", key: admissionSecret, createdAt: "2026-08-12" }], + } as OcxConfig); + const server = startServer(0); + const messagesUrl = `http://127.0.0.1:${server.port}/v1/messages`; + try { + // The routed Messages surface still accepts legacy bearer admission, but native passthrough + // on an exposed bind requires the dedicated header so provider credentials stay unambiguous. + const withoutDedicated = await globalThis.fetch(messagesUrl, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${admissionSecret}`, + "x-api-key": providerApiKey, + }, + body: JSON.stringify(claudeBody()), + }); + expect(withoutDedicated.status).not.toBe(200); + await withoutDedicated.body?.cancel(); + expect(captured).toHaveLength(0); + + const bearerProvider = await globalThis.fetch(messagesUrl, { + method: "POST", + headers: { + "content-type": "application/json", + "x-opencodex-api-key": admissionSecret, + authorization: `Bearer ${providerBearer}`, + "x-api-key": admissionSecret, + }, + body: JSON.stringify(claudeBody()), + }); + expect(bearerProvider.status).toBe(200); + await bearerProvider.text(); + expect(captured).toHaveLength(1); + expect(captured[0].headers.get("authorization")).toBe(`Bearer ${providerBearer}`); + expect(captured[0].headers.get("x-api-key")).toBeNull(); + expect(captured[0].headers.get("x-opencodex-api-key")).toBeNull(); + + // CodeRabbit follow-up: the inverse layout must also keep the real provider x-api-key while + // removing an admission secret carried in Authorization. + const apiKeyProvider = await globalThis.fetch(messagesUrl, { + method: "POST", + headers: { + "content-type": "application/json", + "x-opencodex-api-key": admissionSecret, + authorization: `Bearer ${admissionSecret}`, + "x-api-key": providerApiKey, + }, + body: JSON.stringify(claudeBody()), + }); + expect(apiKeyProvider.status).toBe(200); + await apiKeyProvider.text(); + expect(captured).toHaveLength(2); + expect(captured[1].headers.get("authorization")).toBeNull(); + expect(captured[1].headers.get("x-api-key")).toBe(providerApiKey); + expect(captured[1].headers.get("x-opencodex-api-key")).toBeNull(); + + for (const headerName of ["authorization", "x-api-key"] as const) { + const headers = new Headers({ + "content-type": "application/json", + "x-opencodex-api-key": admissionSecret, + }); + if (headerName === "authorization") { + headers.append(headerName, `Bearer ${providerBearer}`); + headers.append(headerName, `Bearer ${admissionSecret}`); + } else { + headers.append(headerName, providerApiKey); + headers.append(headerName, admissionSecret); + } + const ambiguous = await globalThis.fetch(messagesUrl, { + method: "POST", + headers, + body: JSON.stringify(claudeBody()), + }); + expect(ambiguous.status).not.toBe(200); + await ambiguous.body?.cancel(); + expect(captured).toHaveLength(2); + } + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + test("alias/mapped models and non-anthropic credentials do NOT pass through", async () => { const captured: Captured[] = []; const upstream = mockAnthropicUpstream(captured); From 884e7fe7492bfd1e6ca4d1662b7cd478adc90525 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:34:49 +0900 Subject: [PATCH 2/2] fix(server): align Anthropic listener policy --- .../src/content/docs/guides/claude-code.md | 2 +- .../src/content/docs/ja/guides/claude-code.md | 2 +- .../docs/ja/reference/configuration/server.md | 5 +++-- .../src/content/docs/ko/guides/claude-code.md | 2 +- .../docs/ko/reference/configuration/server.md | 5 +++-- .../docs/reference/configuration/server.md | 5 +++-- .../src/content/docs/ru/guides/claude-code.md | 2 +- .../docs/ru/reference/configuration/server.md | 5 +++-- .../content/docs/zh-cn/guides/claude-code.md | 2 +- .../zh-cn/reference/configuration/server.md | 5 +++-- .../content/docs/zh-tw/guides/claude-code.md | 2 +- .../zh-tw/reference/configuration/server.md | 5 +++-- src/server/index.ts | 2 +- tests/loopback-listener-admission.test.ts | 18 ++++++++++++++++++ 14 files changed, 43 insertions(+), 19 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 5cf75f63ad..1229c15837 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -163,7 +163,7 @@ header is removed, while a genuine provider credential in the other header is pr comma-joined credential headers are not forwarded. The passthrough fires when all of these conditions are met: `nativePassthrough` is not `false`; -the model begins with `claude` or `anthropic`; the bearer or `x-api-key` starts with `sk-ant-`; +the model begins with `claude` or `anthropic`; the bearer token or `x-api-key` starts with `sk-ant-`; alias/model-map resolution returns the same model unchanged; and, on a non-loopback bind, the dedicated proxy admission header is valid. This also means the "claude.ai connectors are disabled" warning no longer appears with `ocx claude`. diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index c6153c7e33..bf4ad36904 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -57,7 +57,7 @@ ocx claude カンマで結合された曖昧な認証ヘッダーは転送しません。 次の条件を**すべて**満たすとパススルーが動作します。`nativePassthrough` が `false` でなく、 -モデル名が `claude` または `anthropic` で始まり、bearer または `x-api-key` が `sk-ant-` で +モデル名が `claude` または `anthropic` で始まり、bearer トークンまたは `x-api-key` が `sk-ant-` で 始まり、エイリアス/モデルマップ解決結果が変更されていない同じモデルであり、非ループバック bind では専用プロキシ admission ヘッダーも有効であること。そのため `ocx claude` を 使うとき "claude.ai connectors are disabled" 警告ももう表示されません。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index 26dbbf18f5..99c7085958 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -50,11 +50,12 @@ x-opencodex-api-key: your-secret-token | `/v1/responses` |受け入れられません | **必須** |受け入れられません | | `/v1/chat/completions` |受け入れられません | **必須** |受け入れられません | | `/v1/messages` |受け入れられました |受け入れられました |受け入れられました | +| `/v1/messages/count_tokens` |受け入れられました |受け入れられました |受け入れられました | | `/v1/models` |受け入れられました |受け入れられました |受け入れられました | 応答とチャット完了では、Codex Direct パススルーの可能性のために `Authorization` を予約しているため、そこでは専用のアドミッション ヘッダーのみが受け入れられます。ダッシュボードで生成された `apiKeys` は、起動後に環境トークンを置き換える可能性があります。候補は一定時間内に比較されます。 -Messages はルーティングクライアントとの互換性のために 3 つの admission 形式を引き続き受け入れます。 +Messages と `count_tokens` はルーティングクライアントとの互換性のために 3 つの admission 形式を引き続き受け入れます。 ただし非ループバック bind のネイティブ Anthropic パススルーでは、プロキシ admission は `x-opencodex-api-key` のみを使い、`Authorization` と `x-api-key` は Anthropic 認証情報用に 予約されます。これら provider ヘッダー内のプロキシ admission secret は転送前に削除されます。 @@ -89,7 +90,7 @@ ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remote ## Claude Code (`claudeCode`) -これらの設定は、`/v1/messages`、`ocx claude` ランチャー、および Claude ダッシュボード ページを制御します。 +これらの設定は、`/v1/messages`、`/v1/messages/count_tokens`、`ocx claude` ランチャー、および Claude ダッシュボード ページを制御します。 |キー |タイプ |デフォルト |説明 | | --- | --- | --- | --- | diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index a1455ecd45..c61a662cb2 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -82,7 +82,7 @@ macOS의 자동 연결(`claudeCode.systemEnv`)도 같은 방식으로 판단하 헤더는 전달하지 않아요. 다음 조건을 **모두** 충족하면 패스스루가 작동해요. `nativePassthrough`가 `false`가 아니고, -모델 이름이 `claude` 또는 `anthropic`으로 시작하며, bearer 또는 `x-api-key`가 `sk-ant-`로 +모델 이름이 `claude` 또는 `anthropic`으로 시작하며, bearer 토큰 또는 `x-api-key`가 `sk-ant-`로 시작하고, 별칭/모델 맵 해석 결과가 변경되지 않은 같은 모델이며, 비루프백 바인드에서는 전용 프록시 admission 헤더도 유효해야 해요. 그래서 `ocx claude`를 사용할 때 "claude.ai connectors are disabled" 경고도 더 이상 나타나지 않아요. diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index 879f232c84..984ff165bc 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -50,11 +50,12 @@ x-opencodex-api-key: your-secret-token | `/v1/responses` | 허용되지 않음 | **필수** | 허용되지 않음 | | `/v1/chat/completions` | 허용되지 않음 | **필수** | 허용되지 않음 | | `/v1/messages` | 허용됨 | 허용됨 | 허용됨 | +| `/v1/messages/count_tokens` | 허용됨 | 허용됨 | 허용됨 | | `/v1/models` | 허용됨 | 허용됨 | 허용됨 | Responses와 Chat Completions는 `Authorization`을 향후 Codex Direct 패스스루 용도로 예약해 두므로, 여기서는 전용 admission 헤더만 허용됩니다. 대시보드에서 생성한 `apiKeys`는 시작 후 환경 토큰을 대체할 수 있으며, 후보 값은 상수 시간으로 비교합니다. -Messages는 라우팅 클라이언트 호환성을 위해 세 admission 형식을 계속 허용합니다. 하지만 비루프백 +Messages와 `count_tokens`는 라우팅 클라이언트 호환성을 위해 세 admission 형식을 계속 허용합니다. 하지만 비루프백 바인드의 네이티브 Anthropic 패스스루는 프록시 admission을 `x-opencodex-api-key`로만 받고, `Authorization`과 `x-api-key`를 Anthropic 자격 증명용으로 예약합니다. 이 provider 헤더에 들어간 프록시 admission secret은 upstream 전달 전에 제거됩니다. @@ -89,7 +90,7 @@ ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remote ## Claude Code (`claudeCode`) -이 설정은 `/v1/messages`, `ocx claude` 실행기, 그리고 Claude 대시보드 페이지를 제어합니다. +이 설정은 `/v1/messages`, `/v1/messages/count_tokens`, `ocx claude` 실행기, 그리고 Claude 대시보드 페이지를 제어합니다. | 키 | 형식 | 기본값 | 설명 | | --- | --- | --- | --- | diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 5f1bcfb530..783c48b02f 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -54,13 +54,14 @@ x-opencodex-api-key: your-secret-token | `/v1/responses` | not accepted | **required** | not accepted | | `/v1/chat/completions` | not accepted | **required** | not accepted | | `/v1/messages` | accepted | accepted | accepted | +| `/v1/messages/count_tokens` | accepted | accepted | accepted | | `/v1/models` | accepted | accepted | accepted | Responses and Chat Completions reserve `Authorization` for possible Codex Direct passthrough, so only the dedicated admission header is accepted there. Dashboard-generated `apiKeys` may replace the environment token after startup; candidates are compared in constant time. -Messages keeps accepting all three admission forms for routed-client compatibility. Native +Messages and `count_tokens` keep accepting all three admission forms for routed-client compatibility. Native Anthropic passthrough is stricter on a non-loopback bind: proxy admission must use `x-opencodex-api-key`, while `Authorization` and `x-api-key` are reserved for Anthropic credentials. Any proxy admission secret placed in those provider headers is removed before forwarding. @@ -150,7 +151,7 @@ with `POST /api/storage/cleanup-policy/run`. ## Claude Code (`claudeCode`) -These settings govern `/v1/messages`, the `ocx claude` launcher, and the Claude dashboard page. +These settings govern `/v1/messages`, `/v1/messages/count_tokens`, the `ocx claude` launcher, and the Claude dashboard page. | Key | Type | Default | Description | | --- | --- | --- | --- | diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index b3cde68a91..91e28b2683 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -62,7 +62,7 @@ Proxy admission secret в любом provider-заголовке удаляет пересылаются. Проброс срабатывает, когда выполнены **все** условия: `nativePassthrough` не равен `false`; -имя модели начинается с `claude` или `anthropic`; bearer или `x-api-key` начинается с `sk-ant-`; +имя модели начинается с `claude` или `anthropic`; bearer-токен или `x-api-key` начинается с `sk-ant-`; разрешение алиасов и карты моделей возвращает ту же модель без изменений; а на non-loopback bind валиден dedicated proxy admission-header. Это также означает, что предупреждение «claude.ai connectors are disabled» с `ocx claude` больше не появляется. diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index 10eb6fa175..4ac4eaf94b 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -56,13 +56,14 @@ x-opencodex-api-key: your-secret-token | `/v1/responses` | not accepted | **required** | not accepted | | `/v1/chat/completions` | not accepted | **required** | not accepted | | `/v1/messages` | accepted | accepted | accepted | +| `/v1/messages/count_tokens` | accepted | accepted | accepted | | `/v1/models` | accepted | accepted | accepted | Responses и Chat Completions резервируют `Authorization` под возможный passthrough Codex Direct, поэтому там принимается только dedicated admission-header. Сгенерированные в дашборде `apiKeys` могут после старта заменить env-token; сравнение кандидатов выполняется constant-time. -Messages ради совместимости routed-клиентов по-прежнему принимает все три формы admission. Но на +Messages и `count_tokens` ради совместимости routed-клиентов по-прежнему принимают все три формы admission. Но на non-loopback bind нативный passthrough Anthropic принимает proxy admission только через `x-opencodex-api-key`, а `Authorization` и `x-api-key` резервирует под credentials Anthropic. Proxy admission secret в этих provider-заголовках удаляется перед пересылкой. @@ -111,7 +112,7 @@ ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remote ## Claude Code (`claudeCode`) -Эти настройки управляют `/v1/messages`, launcher'ом `ocx claude` и страницей Claude в дашборде. +Эти настройки управляют `/v1/messages`, `/v1/messages/count_tokens`, launcher'ом `ocx claude` и страницей Claude в дашборде. | Ключ | Тип | По умолчанию | Описание | | --- | --- | --- | --- | diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index ebcce80711..3929c58239 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -56,7 +56,7 @@ Anthropic。若任一提供方请求头包含代理准入密钥,该密钥会 凭据会保留。含逗号拼接的歧义凭据请求头不会被转发。 只有同时满足以下所有条件时才会触发透传:`nativePassthrough` 不为 `false`;模型以 -`claude` 或 `anthropic` 开头;bearer 或 `x-api-key` 以 `sk-ant-` 开头;并且别名/模型映射 +`claude` 或 `anthropic` 开头;bearer 令牌或 `x-api-key` 以 `sk-ant-` 开头;并且别名/模型映射 解析后返回的模型保持不变;并且在非回环绑定上,专用代理准入请求头有效。这也意味着使用 `ocx claude` 时不再出现 “claude.ai connectors are disabled”警告。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index 1303e33c65..6375d7ef35 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -54,12 +54,13 @@ x-opencodex-api-key: your-secret-token | `/v1/responses` | 不接受 | **必需** | 不接受 | | `/v1/chat/completions` | 不接受 | **必需** | 不接受 | | `/v1/messages` | 接受 | 接受 | 接受 | +| `/v1/messages/count_tokens` | 接受 | 接受 | 接受 | | `/v1/models` | 接受 | 接受 | 接受 | Responses 和 Chat Completions 会保留 `Authorization`,以便将来可能支持 Codex Direct 透传,因此这里只接受专用的准入头。仪表板生成的 `apiKeys` 可以在启动后替换 环境令牌;候选项按常量时间比较。 -Messages 为兼容路由客户端仍接受三种准入形式。但在非回环绑定上,原生 Anthropic 透传只通过 +Messages 和 `count_tokens` 为兼容路由客户端仍接受三种准入形式。但在非回环绑定上,原生 Anthropic 透传只通过 `x-opencodex-api-key` 接受代理准入,并把 `Authorization` 和 `x-api-key` 保留给 Anthropic 凭据。放入这些提供方请求头的代理准入密钥会在转发前被移除。 @@ -99,7 +100,7 @@ ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remote ## Claude Code (`claudeCode`) -这些设置控制 `/v1/messages`、`ocx claude` 启动器,以及 Claude 仪表板页面。 +这些设置控制 `/v1/messages`、`/v1/messages/count_tokens`、`ocx claude` 启动器,以及 Claude 仪表板页面。 | 键 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index 702ee72241..fec1841c5d 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -132,7 +132,7 @@ Anthropic。若任一供應商標頭含有代理許可密鑰,該密鑰會被 憑證會保留。以逗號合併的模糊憑證標頭不會被轉發。 只有同時滿足以下所有條件時才會觸發透傳:`nativePassthrough` 不為 `false`;模型以 -`claude` 或 `anthropic` 開頭;bearer 或 `x-api-key` 以 `sk-ant-` 開頭;並且別名/模型對映 +`claude` 或 `anthropic` 開頭;bearer token 或 `x-api-key` 以 `sk-ant-` 開頭;並且別名/模型對映 解析後回傳的模型保持不變;且在非回環綁定上,專用代理許可標頭有效。這也意味著使用 `ocx claude` 時不再出現 “claude.ai connectors are disabled”警告。 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md index c3ef236c53..40e1f4df5e 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md @@ -50,11 +50,12 @@ x-opencodex-api-key: your-secret-token | `/v1/responses` | 不接受 | **必填** | 不接受 | | `/v1/chat/completions` | 不接受 | **必填** | 不接受 | | `/v1/messages` | 接受 | 接受 | 接受 | +| `/v1/messages/count_tokens` | 接受 | 接受 | 接受 | | `/v1/models` | 接受 | 接受 | 接受 | Responses 與 Chat Completions 為可能的 Codex Direct passthrough 保留 `Authorization`,因此那裡僅接受專屬的許可標頭。儀表板生成的 `apiKeys` 可在啟動後取代環境 token;候選值以常數時間比對。 -Messages 為相容路由客戶端仍接受三種許可形式。但在非回環綁定上,原生 Anthropic 透傳只透過 +Messages 與 `count_tokens` 為相容路由客戶端仍接受三種許可形式。但在非回環綁定上,原生 Anthropic 透傳只透過 `x-opencodex-api-key` 接受代理許可,並將 `Authorization` 與 `x-api-key` 保留給 Anthropic 憑證。放在這些供應商標頭中的代理許可密鑰會在轉發前移除。 @@ -123,7 +124,7 @@ ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remote ## Claude Code(`claudeCode`) -這些設定治理 `/v1/messages`、`ocx claude` 啟動器與 Claude 儀表板頁面。 +這些設定治理 `/v1/messages`、`/v1/messages/count_tokens`、`ocx claude` 啟動器與 Claude 儀表板頁面。 | Key | 型別 | 預設值 | 說明 | | --- | --- | --- | --- | diff --git a/src/server/index.ts b/src/server/index.ts index 36352446ca..dac89f80be 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1243,7 +1243,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server withCors( await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease }, policy), req, - config, + policy, )); } diff --git a/tests/loopback-listener-admission.test.ts b/tests/loopback-listener-admission.test.ts index 2ecd269d4e..3a98c46912 100644 --- a/tests/loopback-listener-admission.test.ts +++ b/tests/loopback-listener-admission.test.ts @@ -12,6 +12,7 @@ * kernel refuses remote connections and there is no address to judge. */ import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; import { isAllowedRequestOrigin, requestPolicyView, @@ -57,6 +58,23 @@ describe("loopback listener policy view", () => { wildcardConfig, )).toEqual({ kind: "configured", keyId: "k1" }); }); + + test("both Anthropic routes finish CORS with the listener-effective policy", () => { + const source = readFileSync(new URL("../src/server/index.ts", import.meta.url), "utf8"); + const countTokensStart = source.indexOf('url.pathname === "/v1/messages/count_tokens"'); + const messagesStart = source.indexOf('url.pathname === "/v1/messages"', countTokensStart + 1); + const chatStart = source.indexOf('url.pathname === "/v1/chat/completions"', messagesStart); + expect(countTokensStart).toBeGreaterThan(-1); + expect(messagesStart).toBeGreaterThan(countTokensStart); + expect(chatStart).toBeGreaterThan(messagesStart); + for (const branch of [ + source.slice(countTokensStart, messagesStart), + source.slice(messagesStart, chatStart), + ]) { + expect(branch).toContain("req,\n policy,\n ));"); + expect(branch).not.toContain("req,\n config,\n ));"); + } + }); }); describe("loopback listener origin gate", () => {