diff --git a/docs-site/src/content/docs/fr/getting-started/quickstart.md b/docs-site/src/content/docs/fr/getting-started/quickstart.md index 0609944822..ecd35fd180 100644 --- a/docs-site/src/content/docs/fr/getting-started/quickstart.md +++ b/docs-site/src/content/docs/fr/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` vous accompagne dans les étapes suivantes : -1. **Choix d’un fournisseur** — sélectionnez l’un des 97 préréglages intégrés au registre, ou `custom` pour saisir une +1. **Choix d’un fournisseur** — sélectionnez l’un des 98 préréglages intégrés au registre, ou `custom` pour saisir une URL de base et un adaptateur. 2. **Clé API** — collez une clé ou référencez une variable d’environnement telle que `${ANTHROPIC_API_KEY}`. 3. **Modèle par défaut** — pour les fournisseurs clés, locaux et personnalisés, acceptez le préréglage ou saisissez un identifiant de modèle. diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 6888d4b327..debf0f51f2 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -295,7 +295,7 @@ existante n'est pas concernée. ## 3. Catalogue des clés API -opencodex fournit 97 préréglages intégrés : 80 à clé, 13 OAuth, trois locaux et un préréglage par défaut de +opencodex fournit 98 préréglages intégrés : 81 à clé, 13 OAuth, trois locaux et un préréglage par défaut de transfert ChatGPT. Dans le tableau de bord, le sélecteur **Ajouter un fournisseur** ouvre le tableau de bord du fournisseur à clé, valide la clé et l'enregistre ; la validation dépend du fournisseur. Parmi les entrées notables : diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index 69944caca5..db6a4894d3 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` walks you through: -1. **Pick a provider** — choose one of the 97 built-in registry presets or `custom` to type a base +1. **Pick a provider** — choose one of the 98 built-in registry presets or `custom` to type a base URL and adapter. 2. **API key** — paste a key, or reference an environment variable like `${ANTHROPIC_API_KEY}`. 3. **Default model** — for key, local, and custom providers, accept the preset or enter a model id. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index c89793a478..a0663d011c 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -443,7 +443,7 @@ selectors, then retry. Signing in from a machine with no existing `kiro-cli` ses ## 3. API-key catalog -opencodex ships 97 built-in presets: 80 key-based, 13 OAuth, three local, and one default +opencodex ships 98 built-in presets: 81 key-based, 13 OAuth, three local, and one default ChatGPT-forward preset. The dashboard's **Add provider** picker opens a key provider's dashboard, validates the key, and stores it; validation is provider-specific. Notable entries: @@ -912,6 +912,63 @@ OpenCodex provides official adapter support for Qoder through the `qoder` (Globa - **Quota:** No public quota API is used, so totals and reset times are unavailable. Insufficient-credit errors (vendor code 118) surface as HTTP 429 `insufficient_quota`. - **Operators:** Qoder Global is operated by BRIGHT ZENITH PRIVATE LIMITED under the [product service terms](https://qoder.com/product-service); Qoder CN by 通义云启(杭州)信息技术有限公司 with Alibaba Cloud. Verify `ocx provider test qoder` (or `qoder-cn`) after configuring. +### Claude Code CLI (subscription) + +OpenCodex can spend a Claude subscription through Anthropic's own harness instead of replaying a +Claude Code identity against the Messages API. The `claude-cli` preset runs the official Claude Code +CLI headlessly (`claude -p`, `stream-json`) once per turn: + +```json +{ + "providers": { + "claude-cli": { + "adapter": "claude-cli", + "baseUrl": "https://api.anthropic.com" + } + } +} +``` + +- **Prerequisites:** `npm install -g @anthropic-ai/claude-code`, then sign in once with `claude` + (or `claude setup-token`). The CLI uses the machine's own Claude Code sign-in (the macOS Keychain + entry, or `~/.claude/.credentials.json` elsewhere). +- **No credential stored:** this row holds no API key, and OpenCodex never reads, copies or forwards + a Claude token. The CLI owns the login and bills the account itself. A CLI that is not signed in + fails the turn with a sign-in error naming the command, instead of a generic `401`. + Classification follows the same fact: the preset is a keyless key row (`keyOptional`), so it needs + no API key and no key field is offered for it. An API key saved on this row by other means is never + handed to the harness — key billing belongs to the `anthropic-apikey` preset. +- **One sign-in serves the whole proxy:** the harness reads the Claude Code sign-in of the user + OpenCodex runs as, so every request routed through this row — from any client of the proxy — + spends that one Claude account. There is no per-client account, no pooling and no multiplexing; + giving several people their own Claude usage needs one proxy user per sign-in. +- **Input media:** the row publishes its models as text-only for v1. The CLI accepts an image frame + on its stream-json input, but no headless turn has been shown to hand those bytes to the model, so + an image sent straight to this provider is refused (`unsupported_input_modality`, the same + refusal the Qoder presets make) instead of being silently dropped and answered blind. With the + vision sidecar on the request path, images are captioned into text before they reach the row. +- **Isolation:** every turn runs in a scoped child environment with no inherited `ANTHROPIC_*` + variable (a `claude` already pointed at this proxy therefore cannot loop back into it), telemetry, + feedback and the auto-updater disabled, and `--tools ""`, `--strict-mcp-config` plus + `--setting-sources ""`. The harness loads no CLAUDE.md, skill, hook, plugin or MCP server from the + machine and can neither read, write, exec nor browse. No session is persisted between turns. +- **System prompt:** the caller's system and developer prompts replace the Claude Code preset + (`--system-prompt-file`), so the turn answers the client's contract rather than the harness + persona. The folded prompt is staged in a private per-turn file (mode `0600`) and passed by path, + because process arguments are world-readable through process listing; a request that carries + neither a system nor a developer prompt gets an empty file, which replaces the preset with nothing. +- **Tool ownership:** v1 is text and reasoning only, exactly like the CodeBuddy and Qoder presets: + with no tool channel, approval, sandboxing and execution stay with the client. The shared + capture-only tool bridge is the documented follow-up. +- **Destination:** the canonical row names `https://api.anthropic.com` because that is where the + subscription's traffic lands. OpenCodex never sends that request itself, and overriding the base + URL fails closed rather than handing the turn to another environment. + +> **Terms:** this preset spends your Claude subscription through Anthropic's own CLI. Whether +> driving that harness headlessly from a proxy fits your plan's terms is a question between you and +> Anthropic. OpenCodex does not convert the login into an API key and does not reproduce the CLI's +> HTTP identity. + ### A6API credit quota A custom `openai-chat` provider using `authMode: "key"` and the canonical diff --git a/docs-site/src/content/docs/ja/getting-started/quickstart.md b/docs-site/src/content/docs/ja/getting-started/quickstart.md index 0661da7173..ae29e0b8d1 100644 --- a/docs-site/src/content/docs/ja/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ja/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` では次の手順を説明します。 -1. **プロバイダーを選択してください** — 97 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。 +1. **プロバイダーを選択してください** — 98 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。 2. **API キー** — キーを貼り付けるか、`${ANTHROPIC_API_KEY}` のような環境変数を参照します。 3. **デフォルト モデル** — キー、ローカル、カスタム プロバイダーの場合は、プリセットを受け入れるか、モデル ID を入力します。 4. **プロキシ ポート** — デフォルトは `10100` です。 diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 98e4e155d7..c0c76b7a8c 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -193,7 +193,7 @@ Kiro のログインには Kiro CLI が必要です。Unix では `curl -fsSL ht ## 3. API キーカタログ -opencodex には組み込みプリセットが 97 個含まれています。キー方式 80、OAuth 13、ローカル 3、 +opencodex には組み込みプリセットが 98 個含まれています。キー方式 81、OAuth 13、ローカル 3、 デフォルト ChatGPT 転送プリセット 1 です。ダッシュボードの **Add provider** ピッカーはキー発行ページを開き、 入力したキーを検証した後保存します(検証はプロバイダー固有です)。主な項目は以下のとおりです: diff --git a/docs-site/src/content/docs/ko/getting-started/quickstart.md b/docs-site/src/content/docs/ko/getting-started/quickstart.md index cc53424c63..f27de93b0e 100644 --- a/docs-site/src/content/docs/ko/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ko/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init`은 다음 과정을 안내합니다: -1. **프로바이더 선택** — 내장 레지스트리 프리셋 97개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. +1. **프로바이더 선택** — 내장 레지스트리 프리셋 98개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. 2. **API 키** — 키를 붙여넣거나 `${ANTHROPIC_API_KEY}` 같은 환경 변수를 참조합니다. 3. **기본 모델** — 키, 로컬, custom 프로바이더에서는 프리셋을 그대로 쓰거나 모델 ID를 직접 입력합니다. 4. **프록시 포트** — 기본값은 `10100`입니다. diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 40a7d6296d..ce779d0e7d 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -190,7 +190,7 @@ Kiro 로그인에는 Kiro CLI가 필요합니다. Unix에서는 `curl -fsSL http ## 3. API 키 카탈로그 -opencodex에는 빌트인 프리셋이 97개 들어 있습니다. 키 방식 80개, OAuth 13개, 로컬 3개, +opencodex에는 빌트인 프리셋이 98개 들어 있습니다. 키 방식 81개, OAuth 13개, 로컬 3개, 기본 ChatGPT 포워드 프리셋 1개입니다. 대시보드의 **Add provider** 선택기는 키 발급 페이지를 열고, 입력한 키를 검증한 뒤 저장합니다(검증은 프로바이더별로 다릅니다). 주요 항목은 다음과 같습니다: diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 764706feeb..b17008d573 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -194,7 +194,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | Field | Type | Meaning | | --- | --- | --- | -| `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (or alias `azure`), `codebuddy`, `qoder`. | +| `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `claude-cli`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (or alias `azure`), `codebuddy`, `qoder`. | | `baseUrl` | `string` | Upstream API base URL. Most built-in fixed endpoints ignore a mismatch; collision-safe key presets preserve an older same-named custom destination. | | `proxy?` | `string \| null` | Per-provider egress route. Omit it to inherit the global proxy decision; use `"direct"` or `null` to force direct egress; or provide an absolute `http://`, `https://`, `socks5://`, or `socks5h://` proxy URL. An empty string is rejected. | | `noProxy?` | `string \| string[]` | Destinations this provider reaches directly, using `NO_PROXY` host-pattern syntax. A match bypasses both this provider's own proxy and an inherited global proxy. | diff --git a/docs-site/src/content/docs/ru/getting-started/quickstart.md b/docs-site/src/content/docs/ru/getting-started/quickstart.md index a8a0abc08b..825ec59193 100644 --- a/docs-site/src/content/docs/ru/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ru/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` проведёт вас по следующим шагам: -1. **Выбор провайдера** — выберите один из 97 встроенных пресетов реестра или `custom`, чтобы +1. **Выбор провайдера** — выберите один из 98 встроенных пресетов реестра или `custom`, чтобы ввести базовый URL и адаптер вручную. 2. **API-ключ** — вставьте ключ или сошлитесь на переменную окружения вида `${ANTHROPIC_API_KEY}`. 3. **Модель по умолчанию** — для провайдеров с ключом, локальных и `custom` примите значение из diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 27b449045f..a83fb7145f 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -206,7 +206,7 @@ Inline JSON и лишние позиционные аргументы откло ## 3. Каталог API-ключей -opencodex поставляется с 97 встроенными пресетами: 80 на основе ключей, 13 OAuth, три локальных и +opencodex поставляется с 98 встроенными пресетами: 81 на основе ключей, 13 OAuth, три локальных и один пресет ChatGPT-форварда по умолчанию. Селектор **Add provider** в дашборде открывает страницу выдачи ключей провайдера, проверяет ключ и сохраняет его; проверка зависит от провайдера. Наиболее заметные записи: diff --git a/docs-site/src/content/docs/tr/getting-started/quickstart.md b/docs-site/src/content/docs/tr/getting-started/quickstart.md index fe77fcae63..6214216d4f 100644 --- a/docs-site/src/content/docs/tr/getting-started/quickstart.md +++ b/docs-site/src/content/docs/tr/getting-started/quickstart.md @@ -14,7 +14,7 @@ ocx init `ocx init` adım adım size rehberlik eder: -1. **Bir sağlayıcı seçin** — yerleşik kayıt defterindeki 97 önayardan birini +1. **Bir sağlayıcı seçin** — yerleşik kayıt defterindeki 98 önayardan birini veya bir temel URL ile adaptör yazmak için `custom` seçeneğini belirleyin. 2. **API anahtarı** — bir anahtar yapıştırın veya `${ANTHROPIC_API_KEY}` gibi bir ortam değişkenine başvurun. diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 073b57dc68..58c0441070 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -327,7 +327,7 @@ olmayan bir makineden oturum açmak bundan etkilenmez. ## 3. API anahtarı kataloğu -opencodex 97 yerleşik önayar ile birlikte gelir: 80 anahtar tabanlı, 13 +opencodex 98 yerleşik önayar ile birlikte gelir: 81 anahtar tabanlı, 13 OAuth, üç yerel ve bir varsayılan ChatGPT iletme önayarı. Kontrol panelinin **Sağlayıcı ekle** seçicisi bir anahtar sağlayıcısının kontrol panelini açar, anahtarı doğrular ve saklar; doğrulama sağlayıcıya özgüdür. Dikkate değer diff --git a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md index 7a7d9d01c5..8682fa6220 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` 会引导你完成: -1. **选择 provider** — 从内置 registry 的 97 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 +1. **选择 provider** — 从内置 registry 的 98 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 2. **API key** — 粘贴一个 key,或引用一个环境变量,例如 `${ANTHROPIC_API_KEY}`。 3. **默认模型** — 对于 key、本地和 custom provider,接受预设值或输入模型 id。 4. **代理端口** — 默认为 `10100`。 diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 43223c280c..280c2d9414 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -181,7 +181,7 @@ Kiro 登录需要 Kiro CLI:Unix 使用 `curl -fsSL https://cli.kiro.dev/instal ## 3. API 密钥目录 -opencodex 内置 97 个预设:80 个密钥预设、13 个 OAuth 预设、3 个本地预设,以及 1 个默认的 +opencodex 内置 98 个预设:81 个密钥预设、13 个 OAuth 预设、3 个本地预设,以及 1 个默认的 ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提供商的控制台,验证并保存密钥。 验证因提供商而异。主要条目包括: diff --git a/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md b/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md index 70607e4c8a..5cfb4c8041 100644 --- a/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` 會引導你完成: -1. **選擇 provider** —— 從內建 registry 的 97 個預設中選擇一個,或選擇 `custom` 手動輸入 +1. **選擇 provider** —— 從內建 registry 的 98 個預設中選擇一個,或選擇 `custom` 手動輸入 base URL 和 adapter。 2. **API key** —— 貼上一個 key,或引用一個環境變數,例如 `${ANTHROPIC_API_KEY}`。 3. **預設模型** —— 對於 API key、本機和 custom provider,可接受預設值或輸入模型 id。 diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 1ec5b07d1e..95f6eff34a 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -249,7 +249,7 @@ database 並移除目前的 WAL、SHM 與 journal sidecar,再發布先前的 s ## 3. API 金鑰目錄 -opencodex 內建 97 個 preset:80 個 key-based、13 個 OAuth、3 個 local,以及 1 個預設 ChatGPT-forward +opencodex 內建 98 個 preset:81 個 key-based、13 個 OAuth、3 個 local,以及 1 個預設 ChatGPT-forward preset。儀表板的 **Add provider** picker 會開啟 key provider 的 dashboard、驗證金鑰並儲存;驗證方式 依 provider 而異。主要條目如下。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index cde0cb65e4..2cf881efec 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -361,6 +361,7 @@ "claude-auth-detect.test.ts": "claude-integration", "claude-auth-mode.test.ts": "claude-integration", "claude-authmode-migration.test.ts": "claude-integration", + "claude-cli-adapter.test.ts": "providers", "claude-cli.test.ts": "claude-integration", "claude-code-thought-signature-scope.test.ts": "claude-integration", "claude-compatibility.test.ts": "claude-integration", diff --git a/src/adapters/claude-cli/adapter.ts b/src/adapters/claude-cli/adapter.ts new file mode 100644 index 0000000000..28d302f083 --- /dev/null +++ b/src/adapters/claude-cli/adapter.ts @@ -0,0 +1,218 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; +import type { AdapterRequest, ProviderAdapter } from "../base"; +import { mapReasoningEffort } from "../../reasoning-effort"; +import { buildSystemPrompt } from "../coding-agent/protocol"; +import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps } from "../coding-agent/turn"; +import { CLAUDE_CLI_PROFILES, type ClaudeCliProfile } from "./profiles"; + +export type { SpawnFn } from "../coding-agent/turn"; +export type ClaudeCliAdapterDeps = CodingAgentDeps; + +/** + * Quiet the CLI's own outbound traffic. + * + * The spawned turn is infrastructure, not somebody's editor: nobody reads its usage metrics, its + * crash reports describe a process the operator never launched by hand, and an auto-updater + * swapping the binary underneath a running proxy is skew rather than a feature. The shared scoped + * env inherits none of these keys, so these values are the ones the turn runs with. + */ +export const CLAUDE_CLI_QUIET_ENV: Readonly> = { + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY: "1", + CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1", + DISABLE_AUTOUPDATER: "1", + DISABLE_TELEMETRY: "1", + DISABLE_ERROR_REPORTING: "1", + DISABLE_FEEDBACK_COMMAND: "1", +}; + +/** + * Build the scoped child-process environment for one Claude Code turn. + * + * No credential is layered here on purpose. Claude Code reads the operator's own sign-in (the + * macOS Keychain entry, or `~/.claude/.credentials.json` elsewhere), which is exactly the property + * this provider exists for: the token never enters OpenCodex, its config, or a child environment. + * + * The shared base env also drops every inherited `ANTHROPIC_*` variable, which is what keeps a + * `claude` the operator already points at this proxy from looping back into it. + * + * `USER` is the one inherited name added back, and it is not a credential: the CLI resolves its own + * sign-in by account name, so a scoped env without it makes a signed-in machine answer "not logged + * in". Measured with `claude auth status` under `env -i`: `USER` alone reports `loggedIn: true`, + * `LOGNAME` alone or neither reports `loggedIn: false`. + */ +export function buildChildEnv(_profile: ClaudeCliProfile, _apiKey: string): Record { + const env: Record = { + ...baseScopedEnv(), + ...CLAUDE_CLI_QUIET_ENV, + }; + const user = process.env.USER; + if (user) env.USER = user; + return env; +} + +/** + * Build the headless Claude Code arguments for one turn. + * + * Tool ownership stays with the client: `--tools ""` disables every built-in tool and + * `--strict-mcp-config` (with no `--mcp-config`) keeps user, project and plugin MCP servers out, so + * the harness can neither read, write, exec nor browse the operator's tree. `--setting-sources ""` + * stops the CLI from loading CLAUDE.md, skills, hooks, plugins and output styles into a proxied + * turn, which is what makes the request deterministic instead of dependent on the host's setup. + * + * The system prompt REPLACES the Claude Code preset rather than appending to it. The caller's system + * and developer prompts are the contract this turn answers under; leaving the harness preset in + * place would put a second, contradictory instruction set in front of them and would describe tools + * this turn deliberately does not have. + * + * It travels as a `--system-prompt-file` path rather than inline, because argv is world-readable + * through process listing — the same reason the CodeBuddy adapter stages its folded prompt. The + * staging file is passed by `runTurn`, which always writes one: omitting the flag is not "no system + * prompt", it is "Claude Code's preset", so a caller that sends neither a system nor a developer + * prompt gets an empty file instead. Verified against 2.1.270 by reading the `prompt_snapshot` + * attachment the CLI writes into a session transcript — a file holding MARKER snapshots `["MARKER"]`, + * an empty file snapshots `[""]`, and an omitted flag snapshots the fourteen-block harness preset. + * + * `--no-session-persistence` keeps every turn stateless. The client replays its own conversation + * and `buildConversationInput` projects it into the single stream-json user frame the CLI accepts. + * + * There is deliberately no `--max-turns` here: the Claude Code CLI exposes no such flag (the Agent + * SDK sets it on the turn budget instead), and with no tool channel a single `-p` turn cannot loop. + */ +export function buildArgs( + _profile: ClaudeCliProfile, + parsed: OcxParsedRequest, + provider: OcxProviderConfig, + systemPromptFile?: string, +): string[] { + const args: string[] = [ + "-p", + "--output-format", "stream-json", + "--input-format", "stream-json", + "--include-partial-messages", + "--verbose", + "--no-session-persistence", + "--tools", "", + "--strict-mcp-config", + "--setting-sources", "", + "--model", parsed.modelId, + ]; + const effort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); + if (effort) args.push("--effort", effort); + if (systemPromptFile) args.push("--system-prompt-file", systemPromptFile); + return args; +} + +/** + * Refuse image input the way the Qoder presets do. + * + * The CLI parses an image frame in its stream-json input without complaint (verified against + * 2.1.270), but nothing verifies that a headless turn hands those bytes to the model, and an image + * the harness drops produces a confident answer to the wrong question. v1 therefore publishes + * text-only models — `noVisionModels` on the registry row — and refuses a direct image here; an + * operator with the vision sidecar on the request path still gets images captioned into text before + * they reach this adapter. + */ +function hasImageInput(parsed: OcxParsedRequest): boolean { + return parsed.context.messages.some(message => + Array.isArray(message.content) && message.content.some(part => part.type === "image"), + ); +} + +/** + * Turn the CLI's unauthenticated turn into the one action a subscription user can take. + * + * An unauthenticated `claude` does not fail the process: it emits an ordinary terminal `result` + * frame with `is_error: true` and the text "Not logged in · Please run /login", which the shared + * mapper reports as a generic 401. Nothing in that reaches for the CLI's own sign-in, so the + * operator is left guessing whether the key, the provider row or the account is wrong. + */ +export function withClaudeLoginHint(emit: (event: AdapterEvent) => void): (event: AdapterEvent) => void { + return event => { + if (event.type === "error" && event.status === 401 && /not logged in|please run \/login/i.test(event.message)) { + emit({ + ...event, + code: "claude_cli_not_logged_in", + message: + "Claude Code is not signed in, so this subscription provider has no account to spend. " + + "Run `claude` once and sign in (or `claude setup-token`), then retry. " + + `CLI reported: ${event.message}`, + }); + return; + } + emit(event); + }; +} + +/** + * Create the Claude Code CLI adapter: one headless, tools-disabled, sessionless turn per request. + * + * As with CodeBuddy and Qoder, `runTurn` owns the turn and the HTTP path is disabled — the CLI + * performs the transport, and OpenCodex contributes the request projection, the stream mapping and + * the process lifecycle. + */ +export function createClaudeCliAdapter(provider: OcxProviderConfig, deps: ClaudeCliAdapterDeps = {}): ProviderAdapter { + return { + name: "claude-cli", + + buildRequest(): AdapterRequest { + return { url: provider.baseUrl, method: "POST", headers: {}, body: "" }; + }, + async *parseStream(): AsyncGenerator { + yield { type: "error", message: "Claude Code CLI adapter uses runTurn; the fetch/parseStream path is disabled." }; + }, + + async runTurn(parsed, incoming, emit): Promise { + if (hasImageInput(parsed)) { + emit({ + type: "error", + message: "Claude Code CLI image input is not enabled because the CLI provider route has no verified multimodal contract.", + status: 400, + errorType: "invalid_request_error", + code: "unsupported_input_modality", + retryable: false, + }); + return; + } + // argv is world-readable via process listing, so the folded system+developer prompt is staged + // in a private per-turn file and passed by path. The file is written even when the caller + // sends no prompt at all: the flag has to be present either way, and an empty replacement is + // what keeps the harness preset out of the turn. + let promptDir: string | undefined; + let promptFile: string | undefined; + try { + promptDir = await mkdtemp(join(tmpdir(), "ocx-claude-cli-prompt-")); + promptFile = join(promptDir, "system-prompt.txt"); + await writeFile(promptFile, buildSystemPrompt(parsed) ?? "", { encoding: "utf8", mode: 0o600, flag: "wx" }); + } catch { + if (promptDir) await rm(promptDir, { recursive: true, force: true }).catch(() => {}); + emit({ + type: "error", + message: "Claude Code system prompt could not be staged securely.", + status: 500, + errorType: "upstream_error", + code: "system_prompt_staging_failed", + retryable: false, + }); + return; + } + try { + await runCodingAgentTurn({ + profiles: CLAUDE_CLI_PROFILES, + provider, + parsed, + incoming, + emit: withClaudeLoginHint(emit), + buildArgs: (profile, req, prov) => buildArgs(profile as ClaudeCliProfile, req, prov, promptFile), + buildEnv: (profile, apiKey) => buildChildEnv(profile as ClaudeCliProfile, apiKey), + deps, + }); + } finally { + if (promptDir) await rm(promptDir, { recursive: true, force: true }).catch(() => {}); + } + }, + }; +} diff --git a/src/adapters/claude-cli/profiles.ts b/src/adapters/claude-cli/profiles.ts new file mode 100644 index 0000000000..0445b05b63 --- /dev/null +++ b/src/adapters/claude-cli/profiles.ts @@ -0,0 +1,38 @@ +import { clearCodingAgentBinaryCache, type CodingAgentProviderProfile } from "../coding-agent/profile"; + +/** + * Profile for the official Claude Code CLI — Anthropic's own harness (§三十一). + * + * The CLI owns the account: OpenCodex stores no Claude token, reads none, and injects none, so a + * subscription turn is spent through the harness Anthropic ships instead of a replayed Claude Code + * identity against the Messages API. There is exactly one destination, so the profile carries the + * single-binary shape of the shared coding-agent seam and no `tokenEnv`. + * + * Evidence (verified 2026-09-24 against the installed CLI 2.1.270): the full argument set built by + * `./adapter.ts` is accepted — including `--tools ""`, `--setting-sources ""`, `--effort` and + * `--system-prompt` — and a stream-json turn reaches the account check, ending on a terminal + * `result` frame with `is_error: true` and "Not logged in · Please run /login" rather than on an + * unknown option. The success-path frame shapes are the shared parser's, unchanged from the family + * this profile joins (`../coding-agent/protocol.ts`). + */ +export interface ClaudeCliProfile extends CodingAgentProviderProfile { + family: "claude"; +} + +export const CLAUDE_CLI_PROFILE: ClaudeCliProfile = { + providerId: "claude-cli", + family: "claude", + // Not a vendor region switch: Claude Code has one destination, and the shared seam's region slot + // carries the neutral value. The profile stays the single authority either way. + region: "global", + label: "Claude Code", + canonicalBaseUrl: "https://api.anthropic.com", + binaryCandidates: ["claude"], + installHint: "npm install -g @anthropic-ai/claude-code", + documentationUrl: "https://docs.claude.com/en/docs/claude-code/cli-reference", +}; + +export const CLAUDE_CLI_PROFILES: readonly ClaudeCliProfile[] = [CLAUDE_CLI_PROFILE]; + +/** Binary-discovery cache is shared across coding-agent families; re-exported for test isolation. */ +export const clearClaudeCliBinaryCache = clearCodingAgentBinaryCache; diff --git a/src/adapters/codebuddy/profiles.ts b/src/adapters/codebuddy/profiles.ts index f06edb8ed5..6c70a4bd47 100644 --- a/src/adapters/codebuddy/profiles.ts +++ b/src/adapters/codebuddy/profiles.ts @@ -16,6 +16,8 @@ import { clearCodingAgentBinaryCache, type CodingAgentProviderProfile } from ".. */ export interface CodeBuddyProfile extends CodingAgentProviderProfile { family: "codebuddy"; + /** Required for this family: the CLI is driven with a vendor API key. */ + tokenEnv: string; /** Official `CODEBUDDY_INTERNET_ENVIRONMENT` value for this region. */ internetEnvironment: "public" | "internal"; } diff --git a/src/adapters/coding-agent/profile.ts b/src/adapters/coding-agent/profile.ts index 7298469767..d1f9d0bd33 100644 --- a/src/adapters/coding-agent/profile.ts +++ b/src/adapters/coding-agent/profile.ts @@ -13,8 +13,11 @@ export interface CodingAgentProviderProfile { /** Canonical OpenCodex provider id this profile serves. */ providerId: string; /** Vendor family; selects the arg/env builder in the family adapter. */ - family: "codebuddy" | "qoder"; - /** Region; drives the vendor's own region switch and keeps credentials deterministic. */ + family: "claude" | "codebuddy" | "qoder"; + /** + * Region; drives the vendor's own region switch and keeps credentials deterministic. + * A family with a single destination carries `global`. + */ region: "global" | "cn"; /** Human label for diagnostics/error copy (never sent upstream). */ label: string; @@ -26,8 +29,13 @@ export interface CodingAgentProviderProfile { canonicalBaseUrl: string; /** Executable names to resolve on PATH, in preference order. */ binaryCandidates: readonly string[]; - /** Official credential environment variable consumed by the CLI. */ - tokenEnv: string; + /** + * Official credential environment variable consumed by the CLI. + * + * Omitted when the CLI owns its own sign-in (Claude Code reads the operator's `claude` session), + * where the adapter neither requires nor injects a key and has no secret to redact. + */ + tokenEnv?: string; /** Install command surfaced when the CLI is missing (§二十六). */ installHint: string; /** Official documentation for the automation surface. */ diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index ae5aa1c743..6701496062 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -79,13 +79,20 @@ export function baseScopedEnv(): Record { return env; } -/** Redact the profile's credential and common secret shapes before surfacing diagnostics. */ -export function redactSecrets(text: string, tokenEnv: string, credential?: string): string { - const escaped = tokenEnv.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +/** + * Redact the profile's credential and common secret shapes before surfacing diagnostics. + * + * `tokenEnv` is absent for a credentialless profile (the CLI owns its sign-in), which only drops + * the `NAME=value` rule; the generic secret shapes are redacted either way. + */ +export function redactSecrets(text: string, tokenEnv: string | undefined, credential?: string): string { let redacted = text; if (credential) redacted = redacted.split(credential).join("[redacted]"); + if (tokenEnv) { + const escaped = tokenEnv.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + redacted = redacted.replace(new RegExp(`(${escaped}\\s*[:=]\\s*)\\S+`, "gi"), "$1[redacted]"); + } return redacted - .replace(new RegExp(`(${escaped}\\s*[:=]\\s*)\\S+`, "gi"), "$1[redacted]") .replace(/(authorization\s*[:=]\s*)\S+/gi, "$1[redacted]") .replace(/\b(sk-[A-Za-z0-9_-]{6,})\b/g, "[redacted]"); } @@ -173,8 +180,11 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise createQoderAdapter(provider), }, + "claude-cli": { + // Claude Code speaks the same stream-json contract this repo already parses for CodeBuddy and + // Qoder, so the contract is inherited rather than restated. The family owns its args and env, + // and the CLI owns the credential: the adapter stores and injects none. + contractParent: "codebuddy", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createClaudeCliAdapter(provider), + }, } as const satisfies Record; export type AdapterId = keyof typeof ADAPTER_REGISTRY; diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index 42c0bbda40..c4327df4f0 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -108,6 +108,12 @@ import { STEPFUN_MODEL_INPUT_MODALITIES, STEPFUN_NO_VISION_MODELS, STEPFUN_REASONING_EFFORTS, + ANTHROPIC_MODELS, + ANTHROPIC_MODEL_CONTEXT_WINDOWS, + ANTHROPIC_MODEL_INPUT_MODALITIES, + ANTHROPIC_MODEL_REASONING_EFFORTS, + ANTHROPIC_REASONING_EFFORTS, + ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, } from "./model-seeds"; export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ @@ -1432,4 +1438,54 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ reasoningEfforts: STEPFUN_REASONING_EFFORTS, note: "StepFun (阶跃星辰) official OpenAI-compatible API.", }, + { + // Official Claude Code CLI as the transport for a Claude subscription (§三十一). The CLI owns + // the account: this row stores no token and the adapter reads and injects none, so the request + // path is Anthropic's own harness rather than a replayed Claude Code identity against the + // Messages API. `baseUrl` is the destination the subscription's traffic reaches; OpenCodex + // never sends it. Fails closed if the row's base URL is overridden. + // v1 runs tools-disabled (`--tools ""`, no `--mcp-config`) so the client keeps tool ownership: + // text/reasoning only until the shared capture-only tool bridge lands. Requires the CLI: + // `npm i -g @anthropic-ai/claude-code`, plus a signed-in session (`claude` -> /login). + // GOVERNANCE: whether a subscription login may be driven through a proxy for a third-party + // agent is Anthropic's call rather than OpenCodex's — flagged for maintainer review, as with + // the CodeBuddy rows above. + id: "claude-cli", + label: "Claude Code CLI (subscription)", + adapter: "claude-cli", + baseUrl: "https://api.anthropic.com", + // `key` + `keyOptional`, deliberately not `local`. "local" (Ollama, vLLM, LM Studio) means the + // traffic never leaves the machine and there is no credential to classify; this row reaches + // api.anthropic.com, so `local` misreported it wherever auth is classified — the account + // surface answered "local provider ... has no credentials" (`classifyAccount`, + // src/cli/account-api.ts) and the dashboard filed the row as a local runtime. What IS true is + // keyless: the CLI reads the operator's own sign-in, so `keyOptional` is the existing flag that + // exempts a row from key enforcement without claiming a key exists. Key rows are also what + // `deriveProviderPresets` lists, so this entry needs no `dashboardPreset` flag to stay + // reachable from the Providers page. + authKind: "key", + keyOptional: true, + // There is no key console for a keyless row: the link that helps an operator is the one that + // documents the install and sign-in this provider requires. + dashboardUrl: "https://docs.claude.com/en/docs/claude-code/setup", + defaultModel: "claude-sonnet-5", + models: [...ANTHROPIC_MODELS], + // Static roster, exactly like the CodeBuddy rows. Without this the catalog treats the row as a + // live-discovery candidate and requests a model list the CLI route never serves: a real start + // logged `Provider model discovery for "claude-cli" failed with HTTP 404` and then fell back to + // these ids anyway. `liveModels: false` makes the configured roster authoritative and skips the + // request entirely (src/codex/catalog/provider-models.ts). + liveModels: false, + modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, + // Text-only for v1, not the image modality the Messages API rows publish. The CLI parses an + // image frame (verified against 2.1.270), but a headless turn has no verified contract that the + // harness hands those bytes to the model, and advertising a modality the route cannot honour + // makes a route selection pick this row for a picture it then answers blind. The adapter refuses + // direct image input for the same reason; the vision sidecar still captions images into text. + noVisionModels: [...ANTHROPIC_MODELS], + reasoningEfforts: ANTHROPIC_REASONING_EFFORTS, + modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, + defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + note: "Runs Claude subscription traffic through Anthropic's own harness: the official Claude Code CLI headlessly (`claude -p`), one turn per request. OpenCodex stores no Claude token, reads none and injects none — the CLI signs in and bills the account itself, which is why this row is keyless and an API key saved here never reaches the harness (use `anthropic-apikey` for key billing). The sign-in is the one of the user this proxy runs as, so every request served through this row — by any client of this proxy — spends that same account; OpenCodex neither pools nor multiplexes Claude sign-ins. Requires the CLI (`npm i -g @anthropic-ai/claude-code`) and a signed-in session (`claude` -> /login). v1 disables CLI tools (--tools \"\", --strict-mcp-config) so the client retains tool ownership: text/reasoning only for now. Subscription routing authorization flagged for maintainer review.", + }, ]; diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index f6933c5ad0..71a01735d4 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -30,6 +30,20 @@ Some adapters share another adapter's routed-tool semantics while retaining inde The inherited contract includes Meta Muse's host-gated 64-character tool-name alias when the constructed send URL is `api.meta.ai` (`src/responses/muse-tool-name-alias.ts`). - `mimo-free` inherits the `openai-chat` contract. +- `claude-cli` inherits the `codebuddy` contract. Claude Code speaks the same stream-json + protocol this repository already parses for CodeBuddy and Qoder, so the wire is inherited and the + family module (`src/adapters/claude-cli/`) supplies only its own arguments and child environment. + That profile is the first credentialless one: it omits `tokenEnv`, the CLI reads the operator's + own Claude Code sign-in, and the turn neither requires nor injects an API key. The proxy-safety + controls are set per invocation, through CLI arguments and the child environment, and + `tests/providers/claude-cli-adapter.test.ts` pins them: `--tools ""`, `--strict-mcp-config`, + `--setting-sources ""`, `--no-session-persistence`, no permission bypass, a folded prompt staged + in a 0600 per-turn file and passed as `--system-prompt-file` rather than as a world-readable + argument, and a child environment that carries no inherited `ANTHROPIC_*` value. + Its registry row is `authKind: "key"` with `keyOptional: true`, NOT `local`: the turn leaves the + machine for `api.anthropic.com`, and `local` (Ollama, vLLM, LM Studio) is the classification for + traffic that never does. `keyOptional` is the existing exemption from key enforcement, and key + rows are what `deriveProviderPresets` lists, so the entry needs no `dashboardPreset` flag either. - `cursor` stays direct because its `runTurn` transport and gated native-file fallback are distinct. - `devin` is direct for a related reason. It streams Cognition's `ApiServerService/GetChatMessage` over Connect-RPC from `runTurn` with hand-written protobuf diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index bcc051a9e3..9344a6f573 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -84,7 +84,7 @@ Manual navigation is defined in `docs-site/astro.config.mjs`. When adding a publ sidebar and either add localized copies or intentionally accept Starlight fallback behavior. Provider preset totals are recounted from the current registry when a preset lands. The -documented split is 97 total: 80 key-based, 13 OAuth, three local, and one default +documented split is 98 total: 81 key-based, 13 OAuth, three local, and one default ChatGPT-forward preset. The English provider guide, all seven translated copies, and all eight quickstarts carry the same counts. diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index b0b43c8b19..cc4fcab6dc 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -24,6 +24,7 @@ const EXPECTED_ADAPTER_NAMES = { devin: "devin", "mimo-free": "mimo-free", qoder: "qoder", + "claude-cli": "claude-cli", } as const; function provider(adapter: string): OcxProviderConfig { diff --git a/tests/adapters/adapter-tool-conformance.test.ts b/tests/adapters/adapter-tool-conformance.test.ts index 9471581107..d25fd7228f 100644 --- a/tests/adapters/adapter-tool-conformance.test.ts +++ b/tests/adapters/adapter-tool-conformance.test.ts @@ -419,7 +419,7 @@ describe("registry-derived routed tool conformance", () => { } }); - const TOOL_LESS_ADAPTERS = new Set(["codebuddy", "qoder"]); + const TOOL_LESS_ADAPTERS = new Set(["codebuddy", "qoder", "claude-cli"]); // The Devin adapter is runTurn-only: it streams Connect-RPC from runTurn, so // buildRequest returns a placeholder and tools never travel the wire path. // Both Devin provider rows share it and differ only in where the credential diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a6d9ded15f..ce7dd22090 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -187,6 +187,7 @@ "claude-auth-detect.test.ts": "claude-integration", "claude-auth-mode.test.ts": "claude-integration", "claude-authmode-migration.test.ts": "claude-integration", + "claude-cli-adapter.test.ts": "providers", "claude-cli.test.ts": "claude-integration", "claude-code-thought-signature-scope.test.ts": "claude-integration", "claude-compatibility.test.ts": "claude-integration", diff --git a/tests/providers/claude-cli-adapter.test.ts b/tests/providers/claude-cli-adapter.test.ts new file mode 100644 index 0000000000..86d2c164b4 --- /dev/null +++ b/tests/providers/claude-cli-adapter.test.ts @@ -0,0 +1,379 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { Readable, Writable } from "node:stream"; +import type { ChildProcess } from "node:child_process"; +import { + buildArgs, + buildChildEnv, + CLAUDE_CLI_QUIET_ENV, + createClaudeCliAdapter, + withClaudeLoginHint, + type SpawnFn, +} from "../../src/adapters/claude-cli/adapter"; +import { baseScopedEnv } from "../../src/adapters/coding-agent/turn"; +import { CLAUDE_CLI_PROFILE, clearClaudeCliBinaryCache } from "../../src/adapters/claude-cli/profiles"; +import { effectiveAdapterContract, getAdapterDefinition } from "../../src/adapters/registry"; +import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import { deriveProviderPresets, providerConfigSeed } from "../../src/providers/derive"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +const enc = new TextEncoder(); + +// The binary-discovery cache is module-level (a production perf seam); reset it so a test that +// reports a missing CLI cannot mask a later test's injected binary. +beforeEach(() => clearClaudeCliBinaryCache()); + +interface FakeChild extends EventEmitter { + stdout: Readable; + stderr: Readable; + stdin: Writable; + killed: boolean; + exitCode: number | null; + kill: (signal?: string) => boolean; + written: string[]; +} + +function fakeChild(stdout: Uint8Array[], opts: { stderr?: string; exitCode?: number } = {}): FakeChild { + const child = new EventEmitter() as FakeChild; + child.stdout = Readable.from(stdout); + child.stderr = Readable.from(opts.stderr ? [enc.encode(opts.stderr)] : []); + child.written = []; + child.stdin = new Writable({ write(chunk, _enc, cb) { child.written.push(String(chunk)); cb(); } }); + child.killed = false; + child.exitCode = null; + child.kill = () => { child.killed = true; return true; }; + setTimeout(() => { child.exitCode = opts.exitCode ?? 0; child.emit("close", opts.exitCode ?? 0); }, 3); + return child; +} + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "claude-cli", + baseUrl: CLAUDE_CLI_PROFILE.canonicalBaseUrl, + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + ...overrides, + } as OcxProviderConfig; +} + +function parsed(overrides: Partial = {}): OcxParsedRequest { + return { + modelId: "claude-sonnet-5", + stream: true, + options: {}, + context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, + ...overrides, + } as OcxParsedRequest; +} + +function incoming(abortSignal?: AbortSignal) { + return { headers: new Headers(), translatorBudget: createTestTranslatorBudget(), ...(abortSignal ? { abortSignal } : {}) }; +} + +async function run(adapter: ReturnType, p: OcxParsedRequest): Promise { + const events: AdapterEvent[] = []; + await adapter.runTurn!(p, incoming(), e => events.push(e)); + return events; +} + +describe("claude-cli is an official-harness provider, not a Messages relay", () => { + test("the registry row and the adapter agree on the one canonical destination", () => { + const entry = PROVIDER_REGISTRY.find(candidate => candidate.id === "claude-cli"); + expect(entry).toBeDefined(); + expect(entry!.adapter).toBe("claude-cli"); + expect(entry!.baseUrl).toBe(CLAUDE_CLI_PROFILE.canonicalBaseUrl); + expect(entry!.defaultModel).toBe("claude-sonnet-5"); + expect(entry!.models).toContain(entry!.defaultModel!); + expect(entry!.modelContextWindows?.[entry!.defaultModel!]).toBeGreaterThan(0); + // Static roster: a live discovery request against this route answers 404 and is pure noise. + expect(entry!.liveModels).toBe(false); + // The CLI parses an image frame, but no headless turn was shown to hand those bytes to the + // model, so the row publishes text-only models instead of the Messages API rows' image + // modality: an advertised input the route cannot honour is how a picture gets answered blind. + expect(entry!.noVisionModels).toEqual(entry!.models ?? []); + expect(entry!.modelInputModalities).toBeUndefined(); + }); + + test("the row is a keyless key provider, not a local runtime, and needs no dashboardPreset flag", () => { + const entry = PROVIDER_REGISTRY.find(candidate => candidate.id === "claude-cli")!; + // "local" is the Ollama / vLLM / LM Studio classification: the traffic never leaves the machine + // and there is no credential to classify. This row's turn leaves for api.anthropic.com, and the + // account surface answers from `authKind` (`classifyAccount` in src/cli/account-api.ts), where + // "local" claimed there were no credentials at all — for a provider whose whole point is a + // credential the CLI owns. + expect(entry.authKind).toBe("key"); + // Keyless is expressed by `keyOptional`, the flag key enforcement already honors + // (src/server/auth-cors.ts, src/providers/api-key-selection.ts) without pretending a key exists. + expect(entry.keyOptional).toBe(true); + // A key row must name where its credential comes from; deriveKeyLoginMap throws without this. + expect(entry.dashboardUrl).toBeTruthy(); + // They keyed a keyless row into the picker by hand. That is what the flag was for, and key rows + // are listed already, so it is gone rather than duplicated. + expect(entry.dashboardPreset).toBeUndefined(); + expect(providerConfigSeed(entry)).toMatchObject({ authMode: "key", keyOptional: true }); + expect(deriveProviderPresets().find(candidate => candidate.id === "claude-cli")) + .toMatchObject({ auth: "key", keyOptional: true }); + }); + + test("the adapter inherits the shared coding-agent contract instead of a second wire", () => { + expect(getAdapterDefinition("claude-cli")?.contractParent).toBe("codebuddy"); + expect(effectiveAdapterContract("claude-cli").wire).toBe("codebuddy"); + }); +}); + +describe("claude-cli headless arguments keep tool ownership with the client", () => { + test("disables built-in tools and every MCP source, and never requests a bypass", () => { + const args = buildArgs(CLAUDE_CLI_PROFILE, parsed(), provider()); + expect(args[0]).toBe("-p"); + expect(args[args.indexOf("--output-format") + 1]).toBe("stream-json"); + expect(args[args.indexOf("--input-format") + 1]).toBe("stream-json"); + expect(args[args.indexOf("--tools") + 1]).toBe(""); // "" = every built-in tool off + expect(args).toContain("--strict-mcp-config"); // and no MCP server from settings or plugins + expect(args).not.toContain("--mcp-config"); + expect(args).not.toContain("--dangerously-skip-permissions"); + expect(args).not.toContain("--allow-dangerously-skip-permissions"); + expect(args).not.toContain("--permission-mode"); + expect(args).toContain("--no-session-persistence"); + expect(args[args.indexOf("--model") + 1]).toBe("claude-sonnet-5"); + }); + + test("loads no user, project or local settings into a proxied turn", () => { + const args = buildArgs(CLAUDE_CLI_PROFILE, parsed(), provider()); + expect(args[args.indexOf("--setting-sources") + 1]).toBe(""); + expect(args).not.toContain("--append-system-prompt"); + }); + + test("the caller's system prompt REPLACES the harness preset", () => { + const args = buildArgs(CLAUDE_CLI_PROFILE, parsed({ + context: { systemPrompt: ["Be terse."], messages: [] }, + }), provider(), "/private/system-prompt.txt"); + expect(args[args.indexOf("--system-prompt-file") + 1]).toBe("/private/system-prompt.txt"); + // argv is world-readable through process listing, so the folded prompt is a path, not an argument. + expect(args).not.toContain("Be terse."); + expect(args).not.toContain("--system-prompt"); + }); + + test("no staged prompt means no flag at all, so runTurn always stages one", () => { + expect(buildArgs(CLAUDE_CLI_PROFILE, parsed(), provider())).not.toContain("--system-prompt-file"); + }); + + test("maps the caller's reasoning effort onto the CLI's --effort", () => { + const args = buildArgs(CLAUDE_CLI_PROFILE, parsed({ options: { reasoning: "high" } }), provider()); + expect(args[args.indexOf("--effort") + 1]).toBe("high"); + }); + + test("passes no --max-turns: the Claude Code CLI has no such flag", () => { + // CodeBuddy's CLI accepts --max-turns and this family shares its parser; the flag must not be + // copied across, or every turn dies on an unknown option. + expect(buildArgs(CLAUDE_CLI_PROFILE, parsed(), provider())).not.toContain("--max-turns"); + }); +}); + +describe("claude-cli child environment carries no credential and no proxy destination", () => { + test("an inherited ANTHROPIC_* variable cannot point the harness back at this proxy", () => { + const previous = { base: process.env.ANTHROPIC_BASE_URL, key: process.env.ANTHROPIC_API_KEY }; + process.env.ANTHROPIC_BASE_URL = "http://127.0.0.1:10100"; + process.env.ANTHROPIC_API_KEY = "inherited-key"; + try { + const env = buildChildEnv(CLAUDE_CLI_PROFILE, ""); + expect(Object.keys(env).filter(name => name.startsWith("ANTHROPIC_") || name.startsWith("CLAUDE_CODE_OAUTH"))).toEqual([]); + expect(JSON.stringify(env)).not.toContain("inherited-key"); + expect(JSON.stringify(env)).not.toContain("127.0.0.1:10100"); + } finally { + if (previous.base === undefined) delete process.env.ANTHROPIC_BASE_URL; + else process.env.ANTHROPIC_BASE_URL = previous.base; + if (previous.key === undefined) delete process.env.ANTHROPIC_API_KEY; + else process.env.ANTHROPIC_API_KEY = previous.key; + } + }); + + test("keeps the home directory the CLI signs in from, and quiets its own telemetry", () => { + const env = buildChildEnv(CLAUDE_CLI_PROFILE, ""); + // The inherited HOME is the property the provider exists for and the one an operator must know + // about: the sign-in belongs to the user this proxy runs as, so every request served through + // this row — by any client of the proxy — spends that same Claude account. + expect(env.HOME).toBe(process.env.HOME); + expect(env.DISABLE_AUTOUPDATER).toBe("1"); + expect(env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC).toBe("1"); + }); + + test("a key configured on the row is never handed to the harness", () => { + // `keyOptional` makes the row keyless without making it key-*blind*: an operator who saved an + // API key in the dashboard or with `ocx provider add --api-key` must not silently believe it + // bills the turn. Nothing layers a credential onto the child environment. + const env = buildChildEnv(CLAUDE_CLI_PROFILE, "sk-ant-row-key"); + expect(JSON.stringify(env)).not.toContain("sk-ant-row-key"); + expect(Object.keys(env).filter(name => name.startsWith("ANTHROPIC_") || name.startsWith("CLAUDE_CODE_OAUTH"))).toEqual([]); + }); + + test("carries the account name the CLI resolves its keychain sign-in by, and nothing else new", () => { + // Without USER the CLI reports "not logged in" on a signed-in machine: it looks its own keychain + // entry up by account name. The value is a name, not a credential — no token is added here. + const previous = process.env.USER; + process.env.USER = "ocx-probe-user"; + try { + const env = buildChildEnv(CLAUDE_CLI_PROFILE, ""); + expect(env.USER).toBe("ocx-probe-user"); + // Derived from the two owners rather than restated, so a new quiet flag cannot silently + // become the third thing this environment carries. + expect(Object.keys(env).sort()).toEqual( + [...new Set([...Object.keys(baseScopedEnv()), ...Object.keys(CLAUDE_CLI_QUIET_ENV), "USER"])].sort(), + ); + } finally { + if (previous === undefined) delete process.env.USER; + else process.env.USER = previous; + } + }); + + test("adds no USER key when the parent has none", () => { + const previous = process.env.USER; + delete process.env.USER; + try { + expect("USER" in buildChildEnv(CLAUDE_CLI_PROFILE, "")).toBe(false); + } finally { + if (previous !== undefined) process.env.USER = previous; + } + }); +}); + +describe("claude-cli runTurn fails closed before any spawn", () => { + test("a non-canonical base URL is refused", async () => { + let spawned = 0; + const spawn: SpawnFn = () => { spawned++; return fakeChild([]) as unknown as ChildProcess; }; + const adapter = createClaudeCliAdapter(provider({ baseUrl: "https://evil.example.test" }), { spawn, which: () => "/usr/bin/claude" }); + const events = await run(adapter, parsed()); + expect(spawned).toBe(0); + expect(events[0]).toMatchObject({ type: "error", code: "non_canonical_destination", retryable: false }); + }); + + test("a missing CLI is a clear pre-flight error naming the install command", async () => { + let spawned = 0; + const adapter = createClaudeCliAdapter(provider(), { spawn: () => { spawned++; return fakeChild([]) as unknown as ChildProcess; }, which: () => undefined }); + const events = await run(adapter, parsed()); + expect(spawned).toBe(0); + expect(events[0]).toMatchObject({ type: "error", code: "cli_not_found", retryable: false }); + expect(String((events[0] as { message: string }).message)).toContain("npm install -g @anthropic-ai/claude-code"); + }); + + test("an image is refused rather than handed to a harness that was never shown to carry it", async () => { + let spawned = 0; + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => { spawned++; return fakeChild([]) as unknown as ChildProcess; }, + which: () => "/opt/homebrew/bin/claude", + }); + const events = await run(adapter, parsed({ + context: { messages: [{ role: "user", content: [{ type: "text", text: "what is this?" }, { type: "image", imageUrl: "data:image/png;base64,iVBORw0KGgo=" }], timestamp: 0 }] }, + })); + // Same refusal the Qoder presets make: a dropped image answers the wrong question confidently, + // and no headless Claude Code turn was shown to deliver image bytes to the model. + expect(spawned).toBe(0); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", status: 400, code: "unsupported_input_modality", retryable: false }); + }); +}); + +describe("claude-cli stages the folded prompt out of argv", () => { + test("keeps the folded prompt out of argv, in a private file that is removed afterwards", async () => { + const secret = "private-system-instruction"; + let promptFile = ""; + const adapter = createClaudeCliAdapter(provider(), { + which: () => "/opt/homebrew/bin/claude", + spawn: (_command, args) => { + expect(args).not.toContain(secret); + const index = args.indexOf("--system-prompt-file"); + expect(index).toBeGreaterThanOrEqual(0); + promptFile = args[index + 1] ?? ""; + expect(readFileSync(promptFile, "utf8")).toBe(secret); + if (process.platform !== "win32") expect(statSync(promptFile).mode & 0o777).toBe(0o600); + return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess; + }, + killGraceMs: 20, + }); + + await run(adapter, parsed({ context: { systemPrompt: [secret], messages: [] } })); + expect(promptFile).not.toBe(""); + expect(existsSync(promptFile)).toBe(false); + }); + + test("a request with no system prompt stages an empty replacement, never the harness preset", async () => { + // Omitting the flag is not "no system prompt": it is Claude Code's own fourteen-block preset, + // which describes a harness with tools this turn does not have. An empty file is what the CLI + // snapshots as an empty system prompt (verified against 2.1.270 through the prompt_snapshot + // attachment), and it is the same request the Messages API path forwards with no system message. + let promptFile = ""; + const adapter = createClaudeCliAdapter(provider(), { + which: () => "/opt/homebrew/bin/claude", + spawn: (_command, args) => { + const index = args.indexOf("--system-prompt-file"); + expect(index).toBeGreaterThanOrEqual(0); + promptFile = args[index + 1] ?? ""; + expect(readFileSync(promptFile, "utf8")).toBe(""); + return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess; + }, + killGraceMs: 20, + }); + + await run(adapter, parsed()); + expect(existsSync(promptFile)).toBe(false); + }); +}); + +describe("claude-cli runTurn streams a subscription turn", () => { + test("runs without any stored API key, because the CLI owns the account", async () => { + let spawned = 0; + const stdout = [ + enc.encode('{"type":"system","subtype":"init"}\n'), + enc.encode('{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hel"}}}\n'), + enc.encode('{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"lo"}}}\n'), + enc.encode('{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"think"}}}\n'), + enc.encode('{"type":"result","subtype":"success","is_error":false,"usage":{"input_tokens":7,"output_tokens":2}}\n'), + ]; + const child = fakeChild(stdout); + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => { spawned++; return child as unknown as ChildProcess; }, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + + const events = await run(adapter, parsed()); + expect(spawned).toBe(1); + expect(events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join("")).toBe("Hello"); + expect(events.some(e => e.type === "thinking_delta")).toBe(true); + expect(events.at(-1)).toMatchObject({ type: "done", usage: { inputTokens: 7, outputTokens: 2, totalTokens: 9 } }); + expect(child.written.join("")).toContain('"text":"hello"'); + }); + + test("an unauthenticated CLI becomes an actionable sign-in error", async () => { + // Verbatim shape of a real 2.1.270 turn: exit code 1, `is_error` result, no HTTP status. + const stdout = [enc.encode(`${JSON.stringify({ + type: "result", + subtype: "success", + is_error: true, + result: "Not logged in · Please run /login", + })}\n`)]; + const adapter = createClaudeCliAdapter(provider(), { + spawn: () => fakeChild(stdout, { exitCode: 1 }) as unknown as ChildProcess, + which: () => "/opt/homebrew/bin/claude", + killGraceMs: 20, + }); + + const events = await run(adapter, parsed()); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", status: 401, code: "claude_cli_not_logged_in", retryable: false }); + expect(String((events[0] as { message: string }).message)).toContain("claude"); + }); + + test("the sign-in hint leaves every other error untouched", () => { + const events: AdapterEvent[] = []; + const hinted = withClaudeLoginHint(event => events.push(event)); + hinted({ type: "error", message: "upstream exploded", status: 502, code: "upstream_error" }); + hinted({ type: "error", message: "rate limited", status: 429, code: "rate_limit_exceeded" }); + hinted({ type: "text_delta", text: "hi" }); + expect(events).toEqual([ + { type: "error", message: "upstream exploded", status: 502, code: "upstream_error" }, + { type: "error", message: "rate limited", status: 429, code: "rate_limit_exceeded" }, + { type: "text_delta", text: "hi" }, + ]); + }); +}); diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 7ad56d082f..8feb629806 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -46,7 +46,7 @@ const EXPECTED_KEY_PROVIDER_IDS = [ "volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral", "minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway", "opper", "opencode-free", "xiaomi", "xiaomi-mimo", "kilo", "mimo-free", "mimo", "cloudflare-ai-gateway", "cloudflare-workers-ai", "gitlab-duo", - "qoder", "qoder-cn", "codebuddy", "codebuddy-cn", "stepfun", + "qoder", "qoder-cn", "codebuddy", "codebuddy-cn", "stepfun", "claude-cli", ]; describe("provider registry parity", () => { @@ -1107,7 +1107,9 @@ describe("provider registry parity", () => { expect(litellm?.authKind).toBe("key"); expect(providerConfigSeed(litellm!).keyOptional).toBe(true); - expect(optionalKeyProviders).toEqual(["litellm", "opencode-free", "mimo-free"]); + // claude-cli joins them as the first CLI-backed member: its row is `key` because the turn + // leaves this machine, and keyless because the Claude Code CLI reads the operator's sign-in. + expect(optionalKeyProviders).toEqual(["litellm", "opencode-free", "mimo-free", "claude-cli"]); }); test("NVIDIA NIM is free-tier priced but still requires an API key", () => {