diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9fc7c95..f095b9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ permissions: contents: write env: - BIN_NAME: claude-codex-proxy + BIN_NAME: claude-code-proxy jobs: build: @@ -26,7 +26,7 @@ jobs: - target: bun-linux-arm64 platform: linux-arm64 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -40,14 +40,14 @@ jobs: bun build ./src/cli.ts \ --compile \ --target=${{ matrix.target }} \ - --define BUILD_VERSION="\"${VERSION}\"" \ + --define BUILD_VERSION="\"${VERSION#v}\"" \ --outfile dist/${BIN_NAME} - name: Package run: | archive="${BIN_NAME}-${{ matrix.platform }}.tar.gz" tar -C dist -czf "$archive" ${BIN_NAME} shasum -a 256 "$archive" > "${BIN_NAME}-${{ matrix.platform }}.sha256" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: ${{ matrix.platform }} path: | @@ -60,7 +60,7 @@ jobs: permissions: contents: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v5 with: merge-multiple: true - uses: softprops/action-gh-release@v2 @@ -75,12 +75,12 @@ jobs: needs: release runs-on: ubuntu-latest steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v5 with: merge-multiple: true - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: - repository: raine/homebrew-claude-codex-proxy + repository: raine/homebrew-claude-code-proxy token: ${{ secrets.RELEASE_TOKEN }} path: tap - name: Generate formula and push to tap @@ -88,54 +88,54 @@ jobs: VERSION: ${{ github.ref_name }} run: | VERSION="${VERSION#v}" - SHA_ARM=$(awk '{print $1}' claude-codex-proxy-darwin-arm64.sha256) - SHA_INTEL=$(awk '{print $1}' claude-codex-proxy-darwin-amd64.sha256) - SHA_LINUX_AMD=$(awk '{print $1}' claude-codex-proxy-linux-amd64.sha256) - SHA_LINUX_ARM=$(awk '{print $1}' claude-codex-proxy-linux-arm64.sha256) + SHA_ARM=$(awk '{print $1}' claude-code-proxy-darwin-arm64.sha256) + SHA_INTEL=$(awk '{print $1}' claude-code-proxy-darwin-amd64.sha256) + SHA_LINUX_AMD=$(awk '{print $1}' claude-code-proxy-linux-amd64.sha256) + SHA_LINUX_ARM=$(awk '{print $1}' claude-code-proxy-linux-arm64.sha256) mkdir -p tap/Formula - cat > tap/Formula/claude-codex-proxy.rb << EOF - class ClaudeCodexProxy < Formula + cat > tap/Formula/claude-code-proxy.rb << EOF + class ClaudeCodeProxy < Formula desc "Local proxy: Claude Code to ChatGPT subscription via Codex Responses API" - homepage "https://github.com/raine/claude-codex-proxy" + homepage "https://github.com/raine/claude-code-proxy" version "${VERSION}" license "MIT" on_macos do if Hardware::CPU.arm? - url "https://github.com/raine/claude-codex-proxy/releases/download/v${VERSION}/claude-codex-proxy-darwin-arm64.tar.gz" + url "https://github.com/raine/claude-code-proxy/releases/download/v${VERSION}/claude-code-proxy-darwin-arm64.tar.gz" sha256 "${SHA_ARM}" else - url "https://github.com/raine/claude-codex-proxy/releases/download/v${VERSION}/claude-codex-proxy-darwin-amd64.tar.gz" + url "https://github.com/raine/claude-code-proxy/releases/download/v${VERSION}/claude-code-proxy-darwin-amd64.tar.gz" sha256 "${SHA_INTEL}" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/raine/claude-codex-proxy/releases/download/v${VERSION}/claude-codex-proxy-linux-arm64.tar.gz" + url "https://github.com/raine/claude-code-proxy/releases/download/v${VERSION}/claude-code-proxy-linux-arm64.tar.gz" sha256 "${SHA_LINUX_ARM}" else - url "https://github.com/raine/claude-codex-proxy/releases/download/v${VERSION}/claude-codex-proxy-linux-amd64.tar.gz" + url "https://github.com/raine/claude-code-proxy/releases/download/v${VERSION}/claude-code-proxy-linux-amd64.tar.gz" sha256 "${SHA_LINUX_AMD}" end end def install - bin.install "claude-codex-proxy" + bin.install "claude-code-proxy" end test do - assert_match version.to_s, shell_output("\#{bin}/claude-codex-proxy --version") + assert_match version.to_s, shell_output("\#{bin}/claude-code-proxy --version") end end EOF - sed -i 's/^ //' tap/Formula/claude-codex-proxy.rb + sed -i 's/^ //' tap/Formula/claude-code-proxy.rb cd tap git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add Formula/claude-codex-proxy.rb - git commit -m "claude-codex-proxy ${VERSION}" + git add Formula/claude-code-proxy.rb + git commit -m "claude-code-proxy ${VERSION}" git pull --rebase git push diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..286823c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,75 @@ +# Changelog + +## v0.0.10 (2026-05-06) + +- Codex requests can now use `codex.serviceTier` or `CCP_CODEX_SERVICE_TIER` to request a service tier; `fast` is sent upstream as `priority`. +- Codex model names can now include `-fast`, such as `gpt-5.4-fast[1m]`, to request fast mode per request without restarting the proxy. +- Codex's upstream endpoint can now be overridden with `codex.baseUrl` or `CCP_CODEX_BASE_URL`. + +## v0.0.9 (2026-05-03) + +- Kimi debugging overrides now use `CCP_KIMI_OAUTH_HOST` and `CCP_KIMI_BASE_URL`, matching the proxy's `CCP_` environment variable naming. + +## v0.0.8 (2026-04-30) + +- Added exponential backoff retry on upstream 429 errors, respecting + `Retry-After` headers when present +- Added `config.json` as an alternative to environment variables (read from + `~/.config/claude-code-proxy/config.json` on macOS, XDG-compliant on Linux) +- Made the `originator` and `User-Agent` headers configurable via new env vars + (`CCP_CODEX_ORIGINATOR`, `CCP_CODEX_USER_AGENT`, `CCP_KIMI_USER_AGENT`, + `CCP_ORIGINATOR`, `CCP_USER_AGENT`) and the config file +- Codex now sends a default `User-Agent: claude-code-proxy/` header + +## v0.0.7 (2026-04-25) + +- Some security hardening inspired by [#5](https://github.com/raine/claude-code-proxy/pull/5) + +## v0.0.6 (2026-04-25) + +- Added support for `gpt-5.5`, and `opus`/`claude-opus-4-7` aliases now map to + `gpt-5.5` instead of `gpt-5.4` +- Model names with a `[1m]` context suffix (e.g. `gpt-5.4[1m]`) are now + accepted and stripped before routing, so Claude Code's larger-context model + variants work without errors +- Documented how to switch between the proxy and direct Anthropic in the README + +## v0.0.5 (2026-04-22) + +- Added `CCP_CODEX_MODEL` and `CCP_CODEX_EFFORT` environment variables to + override the model and reasoning effort for Codex requests + ([#2](https://github.com/raine/claude-code-proxy/pull/2)) +- Added `claude-sonnet-4-6` and additional model aliases so more Claude-style + model names resolve correctly +- Improved request logging with usage summaries, time-to-first-byte metrics, and + stream completion details for easier debugging +- Client disconnections during streaming are now handled gracefully + +## v0.0.4 (2026-04-20) + +- Kimi: reasoning content is now preserved across turns as Anthropic thinking + blocks, so Claude Code sees the model's thinking and multi-turn reasoning + stays coherent +- Kimi: thinking is always enabled + +## v0.0.3 (2026-04-20) + +- Renamed to `claude-code-proxy` to reflect multi-provider support +- Added Kimi (kimi.com) as a provider, with device-code login via the install + script and support for Kimi's chat models +- Requests are now routed to providers based on the requested model, so a single + proxy can serve both Codex and Kimi models simultaneously +- Improved token counting accuracy and fixed cached token usage reporting +- Added MIT license + +## v0.0.2 (2026-04-19) + +- Accept Claude-style model aliases (`haiku`, `sonnet`, `opus`, and `claude-*` + names), resolving them to the appropriate upstream model so portable configs + and subagents work without edits +- Fix malformed streamed Read tool arguments that Claude Code would reject when + upstream emitted an empty `pages` field + +## v0.0.1 (2026-04-19) + +Initial release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dcbc1d9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Raine Virta + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 0f9d8dc..2c3c4d6 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,25 @@ -# claude-codex-proxy +# claude-code-proxy -`claude-codex-proxy` is a local HTTP proxy that lets -[Claude Code](https://www.anthropic.com/claude-code) call OpenAI's Codex -Responses API using a **ChatGPT Pro/Plus subscription** — no Anthropic API key, -no OpenAI API key. +`claude-code-proxy` lets you use +[Claude Code](https://www.anthropic.com/claude-code) with your **ChatGPT +Plus/Pro** subscription or your **Kimi Code** (kimi.com) account. -It translates Anthropic-shaped `/v1/messages` requests to the Responses API, -calls `chatgpt.com/backend-api/codex/responses` with your ChatGPT OAuth token, -and streams the response back in Anthropic SSE format. Claude Code talks to it -over `ANTHROPIC_BASE_URL` and doesn't know the difference. +Claude Code running through claude-code-proxy -[Quick start](#quick-start) · [How it works](#how-it-works) · -[Configuration](#configuration) · [Limitations](#limitations) +[Quick start](#quick-start) · [Providers](#providers) · +[How it works](#how-it-works) · [Configuration](#configuration) · +[Limitations](#limitations) ## Why? -You're already paying for ChatGPT Plus or Pro. You like the Claude Code UX (TUI, -slash commands, hooks, skills, plugins). This proxy lets you use the former to -pay for the latter — running Claude Code against GPT-5.x through your existing -subscription. +I feel Claude Code is still the best harness around, despite occasional +frustrations caused by updates. However, Anthropic keeps tightening the usage +limits, while OpenAI is still much more generous. + +If you want to use OpenAI plans, your best options seem to be OpenCode and +Codex. I tried OpenCode, but the UX has many rough edges, especially around +skills feeling like a second-class feature. Fortunately it's open source and I +ended up forking it and applying some patches, but would much rather not do it. ## Quick start @@ -27,72 +28,104 @@ subscription. **Homebrew** (macOS and Linux): ```sh -brew install raine/claude-codex-proxy/claude-codex-proxy +brew install raine/claude-code-proxy/claude-code-proxy ``` **Install script** (macOS and Linux): ```sh -curl -fsSL https://raw.githubusercontent.com/raine/claude-codex-proxy/main/scripts/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/raine/claude-code-proxy/main/scripts/install.sh | bash ``` **Manual:** download a prebuilt binary for your platform from the -[releases page](https://github.com/raine/claude-codex-proxy/releases). +[releases page](https://github.com/raine/claude-code-proxy/releases). -**From source** (requires [Bun](https://bun.sh) 1.3+): +### 2. Pick a provider and authenticate -```sh -git clone https://github.com/raine/claude-codex-proxy -cd claude-codex-proxy -bun install -bun src/cli.ts --version -``` +The proxy supports two upstream providers. Pick one and run its login flow; the +proxy will refuse to start traffic until a token is stored. -### 2. Authenticate with ChatGPT - -Open a browser (PKCE flow): +**Codex (ChatGPT Plus/Pro):** ```sh -claude-codex-proxy auth login +claude-code-proxy codex auth login # browser OAuth (PKCE) +# or, on a headless machine: +claude-code-proxy codex auth device # device-code flow ``` -Or, on a headless machine (device code flow): +Sign in with your **ChatGPT Plus/Pro account**, not an OpenAI API account. + +**Kimi (kimi.com Kimi Code):** ```sh -claude-codex-proxy auth device +claude-code-proxy kimi auth login # device-code flow (prints URL + code) ``` -Either command prints a URL. Sign in with your **ChatGPT Plus/Pro account**. On -macOS, credentials are stored in Keychain. On other platforms, they are stored -locally for reuse by the proxy. +Sign in with your **kimi.com account**. The verification URL is displayed; open +it in any browser, confirm the code, and the CLI polls until done. -Verify it stuck: +On macOS credentials go to Keychain; on other platforms they are written to +`~/.config/claude-code-proxy//auth.json` (mode 0600). + +Verify: ```sh -claude-codex-proxy auth status +claude-code-proxy codex auth status +claude-code-proxy kimi auth status ``` ### 3. Start the proxy ```sh -claude-codex-proxy serve +claude-code-proxy serve # listens on 127.0.0.1:18765 +PORT=11435 claude-code-proxy serve # change the listen port ``` -Defaults to `http://127.0.0.1:18765` (loopback only). Override with -`PORT=11435 claude-codex-proxy serve`. +Binds to `127.0.0.1` only. One `serve` process handles all providers — the +upstream for each request is chosen from `ANTHROPIC_MODEL`. ### 4. Point Claude Code at it -One-shot: +`ANTHROPIC_MODEL` selects the provider: + +- `gpt-5.5`, `gpt-5.4`, `gpt-5.3-codex`, `gpt-5.4-mini`, `gpt-5.2` → **codex** +- `kimi-for-coding`, `kimi-k2.6`, `k2.6` → **kimi** + +An unknown model returns a 400 listing the supported ids. There is no +implicit default provider. + +Claude Code also issues background requests (session title generation, token +counts) against its built-in "small/fast" haiku model id. Those requests +would 400 because no provider claims it, so set +`ANTHROPIC_SMALL_FAST_MODEL` to a concrete id too (the same value as +`ANTHROPIC_MODEL` is usually fine): ```sh +# Codex +ANTHROPIC_BASE_URL=http://localhost:18765 \ +ANTHROPIC_AUTH_TOKEN=unused \ +ANTHROPIC_MODEL=gpt-5.4[1m] \ +ANTHROPIC_SMALL_FAST_MODEL=gpt-5.4-mini[1m] \ +CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ +CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK=1 \ + claude + +# Kimi ANTHROPIC_BASE_URL=http://localhost:18765 \ ANTHROPIC_AUTH_TOKEN=unused \ -ANTHROPIC_MODEL=gpt-5.4 \ +ANTHROPIC_MODEL=kimi-for-coding[1m] \ +ANTHROPIC_SMALL_FAST_MODEL=kimi-for-coding[1m] \ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ +CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK=1 \ claude ``` +`CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK=1` is recommended because the +proxy always talks to upstream providers with streaming requests, even when it +accumulates a non-streaming Anthropic response for Claude Code. Disabling Claude +Code's streaming-to-non-streaming fallback avoids retrying a partially completed +stream in a way that can duplicate tool calls. + Or set it persistently in `~/.claude/settings.json`: ```json @@ -100,30 +133,142 @@ Or set it persistently in `~/.claude/settings.json`: "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:18765", "ANTHROPIC_AUTH_TOKEN": "unused", - "ANTHROPIC_MODEL": "gpt-5.4", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1 + "ANTHROPIC_MODEL": "gpt-5.4[1m]", + "ANTHROPIC_SMALL_FAST_MODEL": "gpt-5.4-mini[1m]", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1, + "CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK": 1 } } ``` +### 5. Context window size + +Claude Code decides auto-compaction based on the model's context window. For +unknown models (like the ones the proxy uses) it defaults to 200K tokens, which +is smaller than what the upstream models actually support (GPT-5.4: 400K+, +Kimi: 256K). This causes auto-compact to fire earlier than necessary. + +The `[1m]` suffix on the model name (shown in the examples above) is a Claude +Code convention that tells it to use a 1M-token context window instead. This +raises the auto-compact threshold without disabling it entirely. + +If you'd rather disable auto-compact completely, set +`DISABLE_AUTO_COMPACT=1` in your env or `~/.claude/settings.json`. Manual +`/compact` still works, but you risk hitting real upstream limits before +Claude Code can compact for you. + +## Toggling between proxy and direct Anthropic + +If you still have an Anthropic subscription you want to fall back to, you can +put a small wrapper in front of `claude` that only injects the proxy env vars +when a flag file exists, plus a toggle script to flip the flag. Leave +`~/.claude/settings.json` free of proxy env vars so direct-to-Anthropic remains +the default. +`~/.local/bin/claude` (ahead of the real `claude` on `PATH`): -## Supported models +```bash +#!/bin/bash +# Wrapper that optionally routes to claude-code-proxy. +# Active when ~/.claude/claude-code-proxy-enabled exists. + +if [ -f "$HOME/.claude/claude-code-proxy-enabled" ]; then + export ANTHROPIC_BASE_URL="http://localhost:18765" + export ANTHROPIC_AUTH_TOKEN="unused" + export ANTHROPIC_MODEL="gpt-5.4[1m]" + export ANTHROPIC_SMALL_FAST_MODEL="gpt-5.4-mini[1m]" + export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1" + export CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK="1" +fi + +exec "$HOME/.local/bin/claude" "$@" +``` + +Adjust the exec path if the real `claude` binary lives elsewhere on your +system (e.g. `$(bun pm bin -g)/claude`, `$HOME/.claude/local/claude`). + +`claude-proxy-toggle` (anywhere on your `PATH`): + +```bash +#!/bin/bash +# Toggle claude-code-proxy routing for the claude wrapper. +set -euo pipefail + +flag="$HOME/.claude/claude-code-proxy-enabled" + +if [ -f "$flag" ]; then + rm "$flag" + echo "proxy: off" +else + mkdir -p "$(dirname "$flag")" + touch "$flag" + echo "proxy: on" +fi +``` + +Run `claude-proxy-toggle` to flip between routing through the proxy (Codex / +Kimi) and talking to Anthropic directly. New or continued `claude` sessions pick up +the change immediately; existing sessions keep whatever they started with. + +## Providers + +### Codex (ChatGPT) + +Upstream: `https://chatgpt.com/backend-api/codex/responses` (Responses API). Set `ANTHROPIC_MODEL` to a model your ChatGPT subscription is allowed to use. +Append `-fast` to a Codex model name to request Codex fast mode for that request +without restarting the proxy. For example, `gpt-5.4-fast[1m]` is sent upstream as +model `gpt-5.4` with `service_tier: "priority"`. An explicit +`codex.serviceTier` / `CCP_CODEX_SERVICE_TIER` override still takes precedence. + Confirmed working on **Plus**: - `gpt-5.4` - `gpt-5.3-codex` -Also verified working on this project: +Also verified: - `gpt-5.2` - `gpt-5.4-mini` -If you pass a model your account isn't entitled to, upstream returns a 400 like -`"The 'gpt-4.1' model is not supported when using Codex with a ChatGPT account."` -— the proxy surfaces it verbatim. +If the resolved model isn't supported by your account, upstream returns a 400 +like +`"The 'gpt-4.1' model is not supported when using Codex with a ChatGPT account."`. +The proxy surfaces that verbatim. + +Auth: + +| Command | What it does | +| ------------------- | ------------------------------------------ | +| `codex auth login` | Browser OAuth (PKCE) via `auth.openai.com` | +| `codex auth device` | Device-code OAuth for headless machines | +| `codex auth status` | Show account ID + token expiry | +| `codex auth logout` | Delete stored credentials | + +### Kimi (Kimi Code) + +Upstream: `https://api.kimi.com/coding/v1/chat/completions` (OpenAI-style +chat-completions). + +Only one wire model is exposed: `kimi-for-coding` (its display name in kimi-cli +is **Kimi-k2.6**, 256k context, supports reasoning + image input + video input). +`kimi-k2.6` and `k2.6` are accepted as aliases for the same wire id. + +Reasoning effort: Claude Code's `output_config.effort` value (the one you see in +the UI as `◐ medium · /effort`) is forwarded as Kimi's `reasoning_effort` (`low` +/ `medium` / `high`). Thinking blocks from the upstream model are forwarded to +Claude Code and rendered as thinking content. If Claude Code disables thinking, +the proxy drops both `reasoning_effort` and the `thinking: {type: "enabled"}` +flag before forwarding. + +Auth: + +| Command | What it does | +| ------------------ | ------------------------------------- | +| `kimi auth login` | Device-code OAuth via `auth.kimi.com` | +| `kimi auth status` | Show user ID + token expiry | +| `kimi auth logout` | Delete stored credentials | ## How it works @@ -131,81 +276,58 @@ If you pass a model your account isn't entitled to, upstream returns a 400 like sequenceDiagram autonumber participant CC as Claude Code - participant P as claude-codex-proxy - participant AUTH as auth.openai.com - participant CGX as chatgpt.com/
backend-api/codex + participant P as claude-code-proxy + participant AUTH as OAuth host
(auth.openai.com or
auth.kimi.com) + participant U as Upstream API
(chatgpt.com/codex or
api.kimi.com) - Note over P,AUTH: One-time: PKCE or device OAuth
tokens cached locally for reuse + Note over P,AUTH: One-time: PKCE / device OAuth
tokens cached locally for reuse CC->>P: POST /v1/messages (Anthropic shape, stream: true) alt access token expiring P->>AUTH: POST /oauth/token (refresh_token) - AUTH-->>P: new access + refresh + AUTH-->>P: new access (+ rotated refresh) end - P->>P: translate request
• strip max_tokens / temperature / cache_control
• system blocks → top-level "instructions"
• tool_use.id = call_id (identity)
• prompt_cache_key = session id - P->>CGX: POST /responses
Bearer + ChatGPT-Account-Id + originator - CGX-->>P: Responses API SSE
(output_item.*, output_text.delta, function_call_arguments.*, codex.rate_limits, response.completed) - P->>P: reducer: typed events
(text/tool start/delta/stop, finish) + P->>P: translate request
• strip Anthropic-only fields
• system blocks → instructions / system message
• tool_use / tool_result ↔ provider-specific shapes
• prompt_cache_key = session id + P->>U: POST upstream
Bearer + provider-specific headers + U-->>P: provider SSE
(Codex: output_item.*, output_text.delta, …)
(Kimi: chat.completion.chunk, reasoning_content, …) + P->>P: reducer: typed events
(thinking / text / tool start/delta/stop, finish) P-->>CC: Anthropic SSE
(message_start, content_block_*, message_delta, message_stop) ``` -Key decisions: - -- **Transport:** HTTP POST (not WebSocket). The backend accepts both; POST is - simpler and battle-tested by opencode. -- **State:** stateless replay every turn — no `previous_response_id`, no - `store: true`. Claude Code sends the full history each turn anyway; - `prompt_cache_key` keyed off `x-claude-code-session-id` lets the upstream - auto-cache most of it (typically 70%+ hit rate on follow-up turns). -- **Tool IDs:** identity mapping. Anthropic `tool_use.id` = OpenAI `call_id` - verbatim. -- **Reasoning:** dropped from the response. Responses-style reasoning summaries - can't be faked as Anthropic `thinking` blocks with valid signatures, so - they're omitted. The **effort level is forwarded**, though: Claude Code's - `output_config.effort` (`low | medium | high`, set via `/effort` in the TUI) - is mapped 1:1 to Responses `reasoning.effort`. OpenAI's `"minimal"` value - isn't reachable because Claude Code doesn't expose it. -- **System prompt:** Anthropic `system` blocks are concatenated into the - Responses API top-level `instructions` field. The rotating - `x-anthropic-billing-header:` block Claude Code injects is stripped so it - doesn't invalidate the prompt cache on every turn. -- **Stripped before upstream:** `max_tokens`, `temperature`, `top_p`, - `cache_control`, `thinking`, `context_management`, `metadata`, and all - `anthropic-beta` headers. - ## Commands -| Command | Description | -| ----------------------------- | ----------------------------------------- | -| [`serve`](#serve) | Start the proxy on `PORT` (default 18765) | -| [`auth login`](#auth-login) | Browser OAuth (PKCE) | -| [`auth device`](#auth-device) | Device-code OAuth (headless) | -| [`auth status`](#auth-status) | Show account ID and token expiry | -| [`auth logout`](#auth-logout) | Delete stored auth credentials | +| Command | Description | +| --------------------------------------------------- | ------------------------- | +| [`serve`](#serve) | Start the proxy on `PORT` | +| `codex auth login` / `device` / `status` / `logout` | Codex OAuth management | +| `kimi auth login` / `status` / `logout` | Kimi OAuth management | --- ### `serve` Starts the HTTP proxy and blocks. Binds to `127.0.0.1` only. Logs to -`$XDG_STATE_HOME/claude-codex-proxy/proxy.log` (rotated at 20 MiB). Set +`$XDG_STATE_HOME/claude-code-proxy/proxy.log` (rotated at 20 MiB). Set `CCP_LOG_STDERR=1` to mirror log lines to stderr while running. ```sh -claude-codex-proxy serve -PORT=11435 claude-codex-proxy serve -CCP_LOG_STDERR=1 claude-codex-proxy serve +claude-code-proxy serve +PORT=11435 claude-code-proxy serve +CCP_LOG_STDERR=1 claude-code-proxy serve ``` -Prints the exact `ANTHROPIC_BASE_URL` / `ANTHROPIC_MODEL` env vars to export on -startup. Refuses to start traffic until `auth login` (or `auth device`) has -stored a token. +Prints the supported model → provider mapping on startup. One `serve` process +dispatches to any provider based on the `model` field in each request. +Requests whose model isn't registered with any provider are rejected with +HTTP 400 listing the supported ids. --- -### `auth login` +### Codex auth commands + +#### `codex auth login` Runs the PKCE browser flow against `auth.openai.com` using the Codex CLI's client ID. Prints a URL, opens a local callback listener on port 1455, waits for @@ -214,36 +336,32 @@ in Keychain on macOS or locally on other platforms. The process exits automatically once the tokens are saved. ```sh -claude-codex-proxy auth login +claude-code-proxy codex auth login ``` -Sign in with your **ChatGPT Plus/Pro account** — not an OpenAI API account. The +Sign in with your **ChatGPT Plus/Pro account**, not an OpenAI API account. The token file includes the extracted `chatgpt_account_id` so the proxy can set the `ChatGPT-Account-Id` header on every upstream call. ---- - -### `auth device` +#### `codex auth device` Same OAuth flow, but for headless machines. Prints a short user code and a URL; you enter the code from any browser on any other device, and the CLI polls `auth.openai.com` until you authorize, then stores the token. ```sh -claude-codex-proxy auth device +claude-code-proxy codex auth device ``` Useful over SSH, inside a container, or on any host that can't open a browser. ---- - -### `auth status` +#### `codex auth status` Shows whether credentials are stored, the account ID, and how long until the access token expires. Non-zero exit if no auth is present. ```sh -claude-codex-proxy auth status +claude-code-proxy codex auth status ``` Example output: @@ -258,18 +376,57 @@ The proxy refreshes the access token 5 minutes before expiry with a single-flight guard, so concurrent requests never trigger stampedes of refresh calls. ---- - -### `auth logout` +#### `codex auth logout` Removes stored auth credentials. On macOS this deletes the Keychain entry. No server call is needed; the refresh token just becomes dead. ```sh -claude-codex-proxy auth logout +claude-code-proxy codex auth logout +``` + +Run `codex auth login` again to re-authenticate. + +--- + +### Kimi auth commands + +#### `kimi auth login` + +Runs a device-code OAuth flow (RFC 8628) against `auth.kimi.com` using the +kimi-cli client ID. Prints a verification URL and a short user code; open the +URL in any browser, confirm the code, and the CLI polls until the tokens are +issued. Tokens are stored in Keychain on macOS or a mode-0600 file elsewhere. + +```sh +claude-code-proxy kimi auth login +``` + +Sign in with your **kimi.com account**. The access token has a ~15 minute +lifetime; the proxy refreshes it 5 minutes before expiry with a single-flight +guard and persists the rotated refresh token. + +A persistent device ID is generated on first login at +`~/.config/claude-code-proxy/kimi/device_id` and reused forever — it's bound +into the issued JWT, so rotating it would invalidate your token. + +#### `kimi auth status` + +```sh +claude-code-proxy kimi auth status ``` -Run `auth login` again to re-authenticate. +Shows the user ID extracted from the token, expiry time, scope, and storage +backend. Non-zero exit if no auth is present. + +#### `kimi auth logout` + +```sh +claude-code-proxy kimi auth logout +``` + +Removes stored auth credentials (Keychain entry on macOS, file elsewhere). Run +`kimi auth login` again to re-authenticate. --- @@ -277,69 +434,135 @@ Run `auth login` again to re-authenticate. The proxy speaks enough of the Anthropic API for Claude Code: -- `POST /v1/messages` — the main turn endpoint (streaming and non-streaming) -- `POST /v1/messages?beta=true` — same (Claude Code always sends `?beta=true`) -- `POST /v1/messages/count_tokens` — local token count via `gpt-tokenizer` +- `POST /v1/messages`: the main turn endpoint (streaming and non-streaming) +- `POST /v1/messages?beta=true`: same (Claude Code always sends `?beta=true`) +- `POST /v1/messages/count_tokens`: local token count via `gpt-tokenizer` (o200k_base); used by Claude Code's compaction logic -- `GET /healthz` — liveness check +- `GET /healthz`: liveness check ## Configuration -Settings are environment variables on the proxy process, not a config file. +Settings can come from either environment variables or a `config.json` file. +Precedence per setting: **env var > config file > built-in default**. The +config file is optional — env-var-only setups continue to work unchanged. + +The file lives at `~/.config/claude-code-proxy/config.json` on macOS (the same +directory the auth tokens use, deliberately not `~/Library`) and at +`${XDG_CONFIG_HOME:-$HOME/.config}/claude-code-proxy/config.json` elsewhere. + +```json +{ + "port": 18765, + "codex": { + "originator": "claude-code-proxy", + "userAgent": "claude-code-proxy/dev", + "model": "gpt-5.4", + "effort": "medium", + "serviceTier": "fast", + "baseUrl": "https://chatgpt.com/backend-api/codex/responses" + }, + "kimi": { + "userAgent": "KimiCLI/1.37.0", + "oauthHost": "https://auth.kimi.com", + "baseUrl": "https://api.kimi.com/coding/v1" + }, + "log": { + "stderr": false, + "verbose": false + } +} +``` -| Variable | Default | Purpose | -| ----------------- | ---------------- | -------------------------------------------------- | -| `PORT` | `18765` | Proxy listen port | -| `XDG_STATE_HOME` | `~/.local/state` | Base dir for `proxy.log` | -| `CCP_LOG_STDERR` | unset | Also mirror log lines to stderr | -| `CCP_LOG_VERBOSE` | unset | Log full request/response bodies + every SSE event | +| Variable | Config key | Default | Purpose | +| ------------------------ | ------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `PORT` | `port` | `18765` | Proxy listen port | +| `XDG_STATE_HOME` | — | `~/.local/state` | Base dir for `proxy.log` | +| `CCP_LOG_STDERR` | `log.stderr` | unset | Also mirror log lines to stderr | +| `CCP_LOG_VERBOSE` | `log.verbose` | unset | Log full request/response bodies + every SSE event | +| `CCP_KIMI_OAUTH_HOST` | `kimi.oauthHost` | `https://auth.kimi.com` | Override Kimi's OAuth host (debugging only) | +| `CCP_KIMI_BASE_URL` | `kimi.baseUrl` | `https://api.kimi.com/coding/v1` | Override Kimi's API base URL | +| `CCP_CODEX_MODEL` | `codex.model` | unset | Force all Codex requests to this model (`gpt-5.2`, `gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`) | +| `CCP_CODEX_EFFORT` | `codex.effort` | unset | Force all Codex requests to this reasoning effort (`none`, `low`, `medium`, `high`, `xhigh`) | +| `CCP_CODEX_SERVICE_TIER` | `codex.serviceTier` | unset | Force all Codex requests to this service tier (`fast`/`priority`, `flex`; `fast` is sent upstream as `priority`) | +| `CCP_CODEX_BASE_URL` | `codex.baseUrl` | `https://chatgpt.com/backend-api/codex/responses` | Override the Codex Responses endpoint | +| `CCP_CODEX_ORIGINATOR` | `codex.originator` | `claude-code-proxy` | Override the `originator` header sent to Codex | +| `CCP_CODEX_USER_AGENT` | `codex.userAgent` | `claude-code-proxy/` | Override the `User-Agent` header sent to Codex | +| `CCP_KIMI_USER_AGENT` | `kimi.userAgent` | `KimiCLI/1.37.0` | Override the `User-Agent` header sent to Kimi | +| `CCP_ORIGINATOR` | — | `claude-code-proxy` | Fallback for `CCP_CODEX_ORIGINATOR` | +| `CCP_USER_AGENT` | — | unset | Fallback for `CCP_CODEX_USER_AGENT` and `CCP_KIMI_USER_AGENT` | + +A malformed `config.json` is reported on stderr and ignored; defaults are used +in its place. Invalid types for individual keys are warned and skipped without +affecting other keys. ### Files -- `$XDG_STATE_HOME/claude-codex-proxy/proxy.log` — JSON-lines log, rotated at 20 +- `$XDG_STATE_HOME/claude-code-proxy/proxy.log` — JSON-lines log, rotated at 20 MiB. Secrets (`authorization`, `access`, `refresh`, `id_token`, `ChatGPT-Account-Id`, …) are redacted before write. +- `~/.config/claude-code-proxy/config.json` (macOS) or + `${XDG_CONFIG_HOME:-$HOME/.config}/claude-code-proxy/config.json` — optional + configuration file (see table above). +- `${XDG_CONFIG_HOME:-$HOME/.config}/claude-code-proxy/codex/auth.json` — codex + tokens (non-macOS; macOS uses Keychain under service + `claude-code-proxy.codex`). Pre-existing files at the legacy path + `~/.config/claude-code-proxy/codex/auth.json` are read as a fallback so + existing logins survive setting `XDG_CONFIG_HOME`. +- `${XDG_CONFIG_HOME:-$HOME/.config}/claude-code-proxy/kimi/auth.json` — kimi + tokens (non-macOS; macOS uses Keychain under service + `claude-code-proxy.kimi`). Same legacy-path fallback as above. +- `${XDG_CONFIG_HOME:-$HOME/.config}/claude-code-proxy/kimi/device_id` — + persistent UUID bound into the Kimi JWT at login. Reused for the lifetime + of the install. ## Limitations -- **Terms of service:** using the Codex backend from a non-official client is - the same gray area opencode occupies. Use at your own risk. -- **Rate limits:** shared across all clients of your ChatGPT account. - `codex.rate_limits.limit_reached` is surfaced as HTTP 429 with `retry-after`. -- **Image inputs in tool results:** Responses API `function_call_output` only - takes a string, so image blocks nested inside `tool_result` are replaced with - a `[image omitted: ]` placeholder. Top-level user-message images - do pass through. -- **Reasoning blocks:** not forwarded to Claude Code (dropped), even if the - upstream model produced them. +- **Terms of service:** using the Codex or Kimi backends from a non-official + client is a gray area. Use at your own risk. +- **Rate limits:** shared across all clients of your upstream account. Codex's + `codex.rate_limits.limit_reached` and Kimi's HTTP 429 are both surfaced as + HTTP 429 with `retry-after`. +- **Codex — image inputs in tool results:** Responses API `function_call_output` + only takes a string, so image blocks nested inside `tool_result` are replaced + with a `[image omitted: ]` placeholder. Top-level user-message + images pass through. +- **Kimi — image inputs in tool results:** pass through as `image_url` parts + (Kimi accepts them in `role:"tool"` content). +- **Codex — reasoning blocks:** not forwarded to Claude Code (dropped), even if + the upstream model produced them. +- **Kimi — reasoning blocks:** forwarded as Anthropic `thinking` content blocks + and rendered by Claude Code. Disable by setting + `thinking: {"type":"disabled"}` in your Anthropic request. - **Session title generation:** Claude Code's parallel title-gen request is - forwarded to Codex like any other structured-output request — costs a handful - of tokens per session rather than being stubbed. -- **OpenAI-flavored `output_config.format`:** translated to Responses API - `text.format` (json_schema with `strict: true`); other Anthropic-specific - `output_config` fields are dropped. + forwarded upstream like any other structured-output request. This costs a + handful of tokens per session rather than being stubbed. +- **Codex — `output_config.format`:** translated to Responses API `text.format` + (json_schema with `strict: true`); other Anthropic-specific `output_config` + fields are dropped. ## Development ```sh -bunx tsc --noEmit # typecheck -bun src/cli.ts serve # run locally -tail -f ~/.local/state/claude-codex-proxy/proxy.log | jq . +bunx tsc --noEmit # typecheck +bun src/cli.ts serve # run locally (routes all providers) +tail -f ~/.local/state/claude-code-proxy/proxy.log | jq . ``` -**Install a compiled dev build globally** — compile the current working tree to -a binary and place it on your `PATH` without linking: +**Install a compiled dev build globally:** compile the current working tree to a +binary and place it on your `PATH` without linking: ```sh mkdir -p ~/.local/bin -bun build ./src/cli.ts --compile --outfile ~/.local/bin/claude-codex-proxy +bun build ./src/cli.ts --compile --outfile ~/.local/bin/claude-code-proxy ``` ## Related projects -- [claude-history](https://github.com/raine/claude-history) — search Claude Code +- [claude-history](https://github.com/raine/claude-history): search Claude Code conversation history from the terminal -- [git-surgeon](https://github.com/raine/git-surgeon) — non-interactive +- [git-surgeon](https://github.com/raine/git-surgeon): non-interactive hunk-level git staging for AI agents -- [workmux](https://github.com/raine/workmux) — manage parallel AI coding tasks in - separate git worktrees with tmux +- [workmux](https://github.com/raine/workmux): manage parallel AI coding tasks + in separate git worktrees with tmux +- [consult-llm](https://github.com/raine/consult-llm): Consult other AI models + from your agent workflow diff --git a/meta/claude-code-screenshot.webp b/meta/claude-code-screenshot.webp new file mode 100644 index 0000000..d996a8d Binary files /dev/null and b/meta/claude-code-screenshot.webp differ diff --git a/package.json b/package.json index 7a329f9..956a07f 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { - "name": "claude-codex-proxy", - "version": "0.0.1", - "private": true, + "name": "claude-code-proxy", + "version": "0.0.10", + "license": "MIT", "type": "module", "bin": { - "claude-codex-proxy": "./src/cli.ts" + "claude-code-proxy": "./src/cli.ts" }, "scripts": { "start": "bun run src/cli.ts serve", diff --git a/scripts/install.sh b/scripts/install.sh index 5004a44..01663d4 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,21 +1,21 @@ #!/usr/bin/env bash # -# claude-codex-proxy installation script -# Usage: curl -fsSL https://raw.githubusercontent.com/raine/claude-codex-proxy/main/scripts/install.sh | bash +# claude-code-proxy installation script +# Usage: curl -fsSL https://raw.githubusercontent.com/raine/claude-code-proxy/main/scripts/install.sh | bash # # Environment variables: -# CLAUDE_CODEX_PROXY_VERSION - Pin a specific version (e.g., v0.1.0) -# CLAUDE_CODEX_PROXY_INSTALL_DIR - Override install directory (default: /usr/local/bin or ~/.local/bin) +# CLAUDE_CODE_PROXY_VERSION - Pin a specific version (e.g., v0.1.0) +# CLAUDE_CODE_PROXY_INSTALL_DIR - Override install directory (default: /usr/local/bin or ~/.local/bin) # # Examples: -# CLAUDE_CODEX_PROXY_VERSION=v0.1.0 bash install.sh -# CLAUDE_CODEX_PROXY_INSTALL_DIR=/opt/bin bash install.sh +# CLAUDE_CODE_PROXY_VERSION=v0.1.0 bash install.sh +# CLAUDE_CODE_PROXY_INSTALL_DIR=/opt/bin bash install.sh # set -e -BIN_NAME="claude-codex-proxy" -REPO="raine/claude-codex-proxy" +BIN_NAME="claude-code-proxy" +REPO="raine/claude-code-proxy" RED='\033[0;31m' GREEN='\033[0;32m' @@ -70,7 +70,7 @@ install_from_release() { tmp_dir=$(mktemp -d) trap 'rm -rf "$tmp_dir"' EXIT - local version="${CLAUDE_CODEX_PROXY_VERSION:-}" + local version="${CLAUDE_CODE_PROXY_VERSION:-}" if [ -z "$version" ]; then log_info "Fetching latest release..." @@ -93,7 +93,7 @@ install_from_release() { echo "" echo "This might be due to network issues or GitHub API rate limits." echo "You can specify a version manually:" - echo " CLAUDE_CODEX_PROXY_VERSION=v0.1.0 bash install.sh" + echo " CLAUDE_CODE_PROXY_VERSION=v0.1.0 bash install.sh" echo "" exit 1 fi @@ -166,7 +166,7 @@ install_from_release() { exit 1 fi - local install_dir="${CLAUDE_CODEX_PROXY_INSTALL_DIR:-}" + local install_dir="${CLAUDE_CODE_PROXY_INSTALL_DIR:-}" if [ -z "$install_dir" ]; then if [[ -w /usr/local/bin ]]; then install_dir="/usr/local/bin" @@ -199,8 +199,14 @@ install_from_release() { sudo mv -f "$tmp_binary" "$install_dir/${BIN_NAME}" fi - if [[ "$(uname -s)" == "Darwin" ]] && command -v xattr &>/dev/null; then - xattr -d com.apple.quarantine "$install_dir/${BIN_NAME}" 2>/dev/null || true + if [[ "$(uname -s)" == "Darwin" ]]; then + if command -v xattr &>/dev/null; then + xattr -d com.apple.quarantine "$install_dir/${BIN_NAME}" 2>/dev/null || true + fi + if command -v codesign &>/dev/null; then + codesign --remove-signature "$install_dir/${BIN_NAME}" 2>/dev/null || true + codesign --sign - --force "$install_dir/${BIN_NAME}" 2>/dev/null || true + fi fi log_success "${BIN_NAME} installed to $install_dir/${BIN_NAME}" @@ -237,8 +243,9 @@ verify_installation() { echo "" echo "Get started:" - echo " ${BIN_NAME} auth login # authenticate with your ChatGPT account" - echo " ${BIN_NAME} serve # start the proxy" + echo " ${BIN_NAME} codex auth login # authenticate with your ChatGPT account, or" + echo " ${BIN_NAME} kimi auth login # authenticate with your kimi.com account" + echo " ${BIN_NAME} serve # start the proxy" echo "" echo "Documentation: https://github.com/${REPO}" echo "" diff --git a/src/anthropic/schema.ts b/src/anthropic/schema.ts index 9743c13..c707cb6 100644 --- a/src/anthropic/schema.ts +++ b/src/anthropic/schema.ts @@ -25,11 +25,18 @@ export interface AnthropicToolResultBlock { is_error?: boolean } +export interface AnthropicThinkingBlock { + type: "thinking" + thinking: string + signature?: string +} + export type AnthropicContentBlock = | AnthropicTextBlock | AnthropicImageBlock | AnthropicToolUseBlock | AnthropicToolResultBlock + | AnthropicThinkingBlock export interface AnthropicMessage { role: "user" | "assistant" @@ -54,7 +61,7 @@ export interface AnthropicRequest { stream?: boolean thinking?: { type: string; [k: string]: unknown } output_config?: { - effort?: "low" | "medium" | "high" + effort?: "low" | "medium" | "high" | "max" format?: { type: "json_schema"; schema: unknown; name?: string; strict?: boolean } } context_management?: unknown diff --git a/src/auth/token-store.ts b/src/auth/token-store.ts deleted file mode 100644 index b3b6ed7..0000000 --- a/src/auth/token-store.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { mkdir, readFile, writeFile, chmod, unlink, rename } from "node:fs/promises" -import { dirname, join } from "node:path" -import { homedir } from "node:os" - -export interface StoredAuth { - access: string - refresh: string - expires: number - accountId?: string -} - -const DIR = join(homedir(), ".config", "claude-codex-proxy") -const FILE = join(DIR, "auth.json") -const KEYCHAIN_SERVICE = "claude-codex-proxy" -const KEYCHAIN_ACCOUNT = "auth" - -export async function loadAuth(): Promise { - if (process.platform === "darwin") { - const raw = await readKeychain().catch((err: Error & { code?: number }) => { - if (err.code === 44) return undefined - throw err - }) - if (!raw) return undefined - return JSON.parse(raw) as StoredAuth - } - - try { - const raw = await readFile(FILE, "utf8") - return JSON.parse(raw) as StoredAuth - } catch (err: any) { - if (err?.code === "ENOENT") return undefined - throw err - } -} - -export async function saveAuth(auth: StoredAuth): Promise { - if (process.platform === "darwin") { - await runSecurity([ - "add-generic-password", - "-U", - "-a", - KEYCHAIN_ACCOUNT, - "-s", - KEYCHAIN_SERVICE, - "-w", - JSON.stringify(auth), - ]) - return - } - - await mkdir(dirname(FILE), { recursive: true }) - const tmp = `${FILE}.${process.pid}.${Date.now()}.tmp` - await writeFile(tmp, JSON.stringify(auth, null, 2), { encoding: "utf8", mode: 0o600 }) - try { - await chmod(tmp, 0o600) - } catch { - // best-effort; mode in writeFile options usually suffices - } - await rename(tmp, FILE) -} - -export async function clearAuth(): Promise { - if (process.platform === "darwin") { - await runSecurity(["delete-generic-password", "-a", KEYCHAIN_ACCOUNT, "-s", KEYCHAIN_SERVICE]).catch( - (err: Error & { code?: number }) => { - if (err.code !== 44) throw err - }, - ) - return - } - - try { - await unlink(FILE) - } catch (err: any) { - if (err?.code !== "ENOENT") throw err - } -} - -export function authPath(): string { - return process.platform === "darwin" ? "macOS Keychain" : FILE -} - -async function readKeychain(): Promise { - const { stdout } = await runSecurity(["find-generic-password", "-w", "-a", KEYCHAIN_ACCOUNT, "-s", KEYCHAIN_SERVICE]) - return stdout.trim() -} - -async function runSecurity(args: string[]): Promise<{ stdout: string; stderr: string }> { - const proc = Bun.spawn(["security", ...args], { - stdout: "pipe", - stderr: "pipe", - }) - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]) - if (exitCode !== 0) { - const err = new Error(stderr.trim() || `security exited with ${exitCode}`) as Error & { code?: number } - err.code = exitCode - throw err - } - return { stdout, stderr } -} diff --git a/src/cli.ts b/src/cli.ts index 1e0775f..68be8a9 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,10 +1,17 @@ #!/usr/bin/env bun -import { runBrowserLogin } from "./auth/pkce.ts" -import { runDeviceLogin } from "./auth/device.ts" -import { persistInitialTokens } from "./auth/manager.ts" -import { loadAuth, authPath, clearAuth } from "./auth/token-store.ts" import { startServer } from "./server.ts" import { createLogger, logDir } from "./log.ts" +import { port as configPort, getConfig } from "./config.ts" +import { configDir } from "./paths.ts" +import { existsSync } from "node:fs" +import { join } from "node:path" +import { + allProviders, + allSupportedModels, + getProvider, + listProviders, +} from "./providers/registry.ts" +import type { CliHandlers } from "./providers/types.ts" declare const BUILD_VERSION: string | undefined const VERSION = typeof BUILD_VERSION === "string" ? BUILD_VERSION : "dev" @@ -12,78 +19,147 @@ const VERSION = typeof BUILD_VERSION === "string" ? BUILD_VERSION : "dev" const log = createLogger("cli") async function main() { - const [, , cmd, sub] = process.argv - if (cmd === "--version" || cmd === "-v" || cmd === "version") { - console.log(`claude-codex-proxy ${VERSION}`) + const args = process.argv.slice(2) + const [first, ...rest] = args + + if (first === "--version" || first === "-v" || first === "version") { + console.log(`claude-code-proxy ${VERSION}`) return } - switch (cmd) { - case "serve": - case undefined: { - const port = Number(process.env.PORT ?? 18765) - startServer({ port }) - console.log(`Proxy listening on http://localhost:${port}`) - console.log(`Logs: ${logDir()}/proxy.log`) - console.log() - console.log("Configure Claude Code:") - console.log(` export ANTHROPIC_BASE_URL="http://localhost:${port}"`) - console.log(` export ANTHROPIC_AUTH_TOKEN="anything"`) - console.log(` export ANTHROPIC_MODEL="gpt-5.4"`) - console.log(` export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1"`) - return + + if (!first || first === "serve") { + const port = configPort() + startServer({ port }) + console.log(`Proxy listening on http://localhost:${port}`) + console.log(`Logs: ${logDir()}/proxy.log`) + printConfigSummary() + console.log() + console.log("Providers are selected per-request by ANTHROPIC_MODEL:") + for (const p of allProviders()) { + const models = [...p.supportedModels].join(", ") + console.log(` ${p.name}: ${models}`) } - case "auth": { - if (sub === "login") { - const tokens = await runBrowserLogin() - const saved = await persistInitialTokens(tokens) - console.log(`Auth saved in ${authPath()}`) - if (saved.accountId) console.log(`Account: ${saved.accountId}`) - process.exit(0) - } - if (sub === "device") { - const tokens = await runDeviceLogin() - const saved = await persistInitialTokens(tokens) - console.log(`Auth saved in ${authPath()}`) - if (saved.accountId) console.log(`Account: ${saved.accountId}`) - process.exit(0) - } - if (sub === "status") { - const auth = await loadAuth() - if (!auth) { - console.log("Not authenticated") - process.exit(1) - } - const ms = auth.expires - Date.now() - console.log(`Account: ${auth.accountId ?? "(none)"}`) - console.log(`Expires: ${new Date(auth.expires).toISOString()} (in ${Math.floor(ms / 1000)}s)`) - console.log(`Storage: ${authPath()}`) - return + console.log() + console.log("Configure Claude Code (pick a model from above):") + console.log(` export ANTHROPIC_BASE_URL="http://localhost:${port}"`) + console.log(` export ANTHROPIC_AUTH_TOKEN="anything"`) + console.log(` export ANTHROPIC_MODEL="kimi-for-coding[1m]" # or gpt-5.4[1m], etc.`) + console.log(` export ANTHROPIC_SMALL_FAST_MODEL="kimi-for-coding[1m]" # background / title-gen`) + console.log(` export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1"`) + return + } + + if (listProviders().includes(first)) { + const provider = getProvider(first) + await runProviderCommand(provider.name, provider.cli, rest) + return + } + + usageAndExit() +} + +async function runProviderCommand(name: string, cli: CliHandlers, args: string[]): Promise { + const [group, sub] = args + if (group !== "auth") usageAndExit() + + switch (sub) { + case "login": + if (!cli.login) { + console.error(`${name}: browser login not supported`) + process.exit(2) } - if (sub === "logout") { - await clearAuth() - console.log("Logged out") - return + await cli.login() + process.exit(0) + case "device": + if (!cli.device) { + console.error(`${name}: device login not supported`) + process.exit(2) } - usageAndExit() + await cli.device() + process.exit(0) + case "status": + await cli.status() + return + case "logout": + await cli.logout() return - } default: usageAndExit() } } function usageAndExit(): never { + const providers = listProviders().join("|") + const models = allSupportedModels() + .map((m) => `${m.model} (${m.provider})`) + .join(", ") console.log(`Usage: - claude-codex-proxy serve Run proxy (PORT env, default 18765) - claude-codex-proxy auth login Browser OAuth (PKCE) - claude-codex-proxy auth device Device-code OAuth - claude-codex-proxy auth status Show current auth - claude-codex-proxy auth logout Clear stored auth - claude-codex-proxy --version Show version + claude-code-proxy serve Run proxy (PORT env or config.json port, default 18765) + Upstream is chosen per-request from ANTHROPIC_MODEL. + claude-code-proxy auth login Browser OAuth + claude-code-proxy auth device Device-code OAuth + claude-code-proxy auth status Show current auth + claude-code-proxy auth logout Clear stored auth + claude-code-proxy --version Show version + +Providers: ${providers} +Models: ${models} `) process.exit(2) } +function printConfigSummary(): void { + const cfg = getConfig() + const fromFile = cfg.file + const overrides: string[] = [] + + const configPath = join(configDir(), "config.json") + if (existsSync(configPath)) { + console.log(`Config: ${configPath}`) + } + + if (cfg.env.CCP_CODEX_ORIGINATOR) overrides.push("CCP_CODEX_ORIGINATOR (env)") + else if (fromFile.codex?.originator) overrides.push("codex.originator (config)") + + if (cfg.env.CCP_CODEX_USER_AGENT) overrides.push("CCP_CODEX_USER_AGENT (env)") + else if (cfg.env.CCP_USER_AGENT) overrides.push("CCP_USER_AGENT (env)") + else if (fromFile.codex?.userAgent) overrides.push("codex.userAgent (config)") + + if (cfg.env.CCP_KIMI_USER_AGENT) overrides.push("CCP_KIMI_USER_AGENT (env)") + else if (fromFile.kimi?.userAgent) overrides.push("kimi.userAgent (config)") + + if (cfg.env.CCP_CODEX_MODEL) overrides.push("CCP_CODEX_MODEL (env)") + else if (fromFile.codex?.model) overrides.push("codex.model (config)") + + if (cfg.env.CCP_CODEX_EFFORT) overrides.push("CCP_CODEX_EFFORT (env)") + else if (fromFile.codex?.effort) overrides.push("codex.effort (config)") + + if (cfg.env.CCP_CODEX_SERVICE_TIER) overrides.push("CCP_CODEX_SERVICE_TIER (env)") + else if (fromFile.codex?.serviceTier) overrides.push("codex.serviceTier (config)") + + if (cfg.env.CCP_CODEX_BASE_URL) overrides.push("CCP_CODEX_BASE_URL (env)") + else if (fromFile.codex?.baseUrl) overrides.push("codex.baseUrl (config)") + + if (cfg.env.CCP_LOG_VERBOSE !== undefined) overrides.push("CCP_LOG_VERBOSE (env)") + else if (fromFile.log?.verbose) overrides.push("log.verbose (config)") + + if (cfg.env.CCP_LOG_STDERR !== undefined) overrides.push("CCP_LOG_STDERR (env)") + else if (fromFile.log?.stderr) overrides.push("log.stderr (config)") + + if (cfg.env.CCP_KIMI_OAUTH_HOST) overrides.push("CCP_KIMI_OAUTH_HOST (env)") + else if (fromFile.kimi?.oauthHost) overrides.push("kimi.oauthHost (config)") + + if (cfg.env.CCP_KIMI_BASE_URL) overrides.push("CCP_KIMI_BASE_URL (env)") + else if (fromFile.kimi?.baseUrl) overrides.push("kimi.baseUrl (config)") + + if (overrides.length > 0) { + console.log("Overrides:") + for (const o of overrides) { + console.log(` ${o}`) + } + } +} + main().catch((err) => { log.error("cli fatal", { err: String(err), stack: (err as Error)?.stack }) console.error(err) diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..de64414 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it, beforeEach, afterEach } from "bun:test" +import { mkdtempSync, writeFileSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + loadConfig, + port, + codexOriginator, + codexUserAgent, + codexModel, + codexEffort, + codexServiceTier, + codexBaseUrl, + kimiUserAgent, + kimiOauthHost, + kimiBaseUrl, + logVerbose, + logStderr, +} from "./config.ts" + +let dir: string +let configPath: string + +function setEnv(env: NodeJS.ProcessEnv) { + loadConfig({ configPath, env, forceReload: true }) +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "ccp-config-")) + configPath = join(dir, "config.json") +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + // Reset module-level cache to a clean process-env baseline so unrelated + // tests that import config getters do not see leftover overrides. + loadConfig({ forceReload: true }) +}) + +describe("config defaults", () => { + it("returns built-in defaults when no env and no file", () => { + setEnv({}) + expect(port()).toBe(18765) + expect(codexOriginator("default-orig")).toBe("default-orig") + expect(codexUserAgent("default-ua")).toBe("default-ua") + expect(codexModel()).toBeUndefined() + expect(codexEffort()).toBeUndefined() + expect(codexServiceTier()).toBeUndefined() + expect(codexBaseUrl("default-codex-url")).toBe("default-codex-url") + expect(kimiUserAgent("default-kimi-ua")).toBe("default-kimi-ua") + expect(kimiOauthHost()).toBe("https://auth.kimi.com") + expect(kimiBaseUrl()).toBe("https://api.kimi.com/coding/v1") + expect(logVerbose()).toBe(false) + expect(logStderr()).toBe(false) + }) +}) + +describe("file overrides default", () => { + it("port from config.json", () => { + writeFileSync(configPath, JSON.stringify({ port: 11111 })) + setEnv({}) + expect(port()).toBe(11111) + }) + + it("codex.userAgent from config.json", () => { + writeFileSync( + configPath, + JSON.stringify({ codex: { userAgent: "ccp/file" } }), + ) + setEnv({}) + expect(codexUserAgent("default")).toBe("ccp/file") + }) + + it("codex.serviceTier from config.json", () => { + writeFileSync(configPath, JSON.stringify({ codex: { serviceTier: "fast" } })) + setEnv({}) + expect(codexServiceTier()).toBe("fast") + }) + + it("codex.baseUrl from config.json", () => { + writeFileSync( + configPath, + JSON.stringify({ + codex: { baseUrl: "http://127.0.0.1:2455/backend-api/codex/responses" }, + }), + ) + setEnv({}) + expect(codexBaseUrl("default")).toBe( + "http://127.0.0.1:2455/backend-api/codex/responses", + ) + }) + + it("kimi.oauthHost from config.json", () => { + writeFileSync( + configPath, + JSON.stringify({ kimi: { oauthHost: "https://auth.example.com" } }), + ) + setEnv({}) + expect(kimiOauthHost()).toBe("https://auth.example.com") + }) + + it("log.verbose from config.json", () => { + writeFileSync(configPath, JSON.stringify({ log: { verbose: true } })) + setEnv({}) + expect(logVerbose()).toBe(true) + }) +}) + +describe("env overrides file", () => { + it("PORT env wins over config port", () => { + writeFileSync(configPath, JSON.stringify({ port: 11111 })) + setEnv({ PORT: "22222" }) + expect(port()).toBe(22222) + }) + + it("CCP_CODEX_USER_AGENT env wins over config", () => { + writeFileSync( + configPath, + JSON.stringify({ codex: { userAgent: "from-file" } }), + ) + setEnv({ CCP_CODEX_USER_AGENT: "from-env" }) + expect(codexUserAgent("default")).toBe("from-env") + }) + + it("CCP_CODEX_SERVICE_TIER env wins over config", () => { + writeFileSync(configPath, JSON.stringify({ codex: { serviceTier: "flex" } })) + setEnv({ CCP_CODEX_SERVICE_TIER: "fast" }) + expect(codexServiceTier()).toBe("fast") + }) + + it("CCP_CODEX_BASE_URL env wins over config", () => { + writeFileSync( + configPath, + JSON.stringify({ codex: { baseUrl: "http://127.0.0.1:2455/file" } }), + ) + setEnv({ CCP_CODEX_BASE_URL: "http://127.0.0.1:2455/env" }) + expect(codexBaseUrl("default")).toBe("http://127.0.0.1:2455/env") + }) + + it("CCP_USER_AGENT env (generic fallback) is preferred over file", () => { + writeFileSync( + configPath, + JSON.stringify({ codex: { userAgent: "from-file" } }), + ) + setEnv({ CCP_USER_AGENT: "generic-env" }) + expect(codexUserAgent("default")).toBe("generic-env") + expect(kimiUserAgent("default")).toBe("generic-env") + }) + + it("logStderr env-set forces true even when config sets false", () => { + writeFileSync(configPath, JSON.stringify({ log: { stderr: false } })) + setEnv({ CCP_LOG_STDERR: "1" }) + expect(logStderr()).toBe(true) + }) +}) + +describe("empty-string semantics", () => { + it("empty CCP_CODEX_MODEL env falls through to file value", () => { + writeFileSync(configPath, JSON.stringify({ codex: { model: "gpt-5.2" } })) + setEnv({ CCP_CODEX_MODEL: "" }) + expect(codexModel()).toBe("gpt-5.2") + }) + + it("empty CCP_CODEX_MODEL env with no file value returns undefined", () => { + setEnv({ CCP_CODEX_MODEL: "" }) + expect(codexModel()).toBeUndefined() + }) + + it("empty CCP_CODEX_SERVICE_TIER env falls through to file value", () => { + writeFileSync(configPath, JSON.stringify({ codex: { serviceTier: "flex" } })) + setEnv({ CCP_CODEX_SERVICE_TIER: "" }) + expect(codexServiceTier()).toBe("flex") + }) + + it("empty PORT env falls through to file value", () => { + writeFileSync(configPath, JSON.stringify({ port: 33333 })) + setEnv({ PORT: "" }) + expect(port()).toBe(33333) + }) +}) + +describe("empty env-string compatibility", () => { + it("empty CCP_CODEX_USER_AGENT env is a valid value (legacy ?? semantics)", () => { + setEnv({ CCP_CODEX_USER_AGENT: "" }) + expect(codexUserAgent("default-ua")).toBe("") + }) + + it("empty CCP_KIMI_OAUTH_HOST env is a valid value (legacy ?? semantics)", () => { + setEnv({ CCP_KIMI_OAUTH_HOST: "" }) + expect(kimiOauthHost()).toBe("") + }) + + it("CCP_LOG_STDERR set to empty string still enables stderr (legacy !! semantics)", () => { + setEnv({ CCP_LOG_STDERR: "" }) + expect(logStderr()).toBe(true) + }) +}) + +describe("malformed config", () => { + it("returns defaults when JSON is invalid", () => { + writeFileSync(configPath, "{not valid json") + setEnv({}) + expect(port()).toBe(18765) + }) + + it("ignores wrong-typed values with a warning, keeps other valid ones", () => { + writeFileSync( + configPath, + JSON.stringify({ port: "not-a-number", codex: { userAgent: "good" } }), + ) + setEnv({}) + expect(port()).toBe(18765) + expect(codexUserAgent("default")).toBe("good") + }) + + it("returns defaults when file is missing entirely", () => { + setEnv({}) + expect(port()).toBe(18765) + }) +}) diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..221ba4b --- /dev/null +++ b/src/config.ts @@ -0,0 +1,248 @@ +import { readFileSync } from "node:fs" +import { join } from "node:path" +import { configDir } from "./paths.ts" + +// Config precedence per setting: +// provider-specific env > generic-fallback env (where one exists) > config.json > default +// +// The config file is parsed once on first access and cached. Empty strings +// from either env or the file are treated as "unset" so they fall through +// to the next layer (matches existing CCP_CODEX_MODEL behavior). + +export interface FileConfig { + port?: number + codex?: { + originator?: string + userAgent?: string + model?: string + effort?: string + serviceTier?: string + baseUrl?: string + } + kimi?: { + userAgent?: string + oauthHost?: string + baseUrl?: string + } + log?: { + stderr?: boolean + verbose?: boolean + } +} + +interface LoadedConfig { + file: FileConfig + env: NodeJS.ProcessEnv +} + +interface LoadOptions { + configPath?: string + env?: NodeJS.ProcessEnv + forceReload?: boolean +} + +let cached: LoadedConfig | undefined + +// Most env-var consumers historically used `??` semantics — empty string is +// a real value that wins. Only CCP_CODEX_MODEL and CCP_CODEX_EFFORT had +// explicit empty-string-as-unset handling in the legacy code, so only those +// getters use emptyOrUnset. +function emptyOrUnset(v: string | undefined): string | undefined { + return v === undefined || v === "" ? undefined : v +} + +function warnInvalid(key: string, expected: string, got: unknown): void { + process.stderr.write( + `claude-code-proxy: ignoring config.json key "${key}": expected ${expected}, got ${typeof got}\n`, + ) +} + +function validate(raw: unknown): FileConfig { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {} + const r = raw as Record + const out: FileConfig = {} + + if (r.port !== undefined) { + if (typeof r.port === "number" && Number.isFinite(r.port)) out.port = r.port + else warnInvalid("port", "number", r.port) + } + + const validateStringSection = ( + key: K, + keys: ReadonlyArray>, + types: Record, + ): NonNullable | undefined => { + if (r[key] === undefined) return undefined + const sec = r[key] + if (!sec || typeof sec !== "object" || Array.isArray(sec)) { + warnInvalid(key, "object", sec) + return undefined + } + const acc: Record = {} + for (const k of keys) { + const v = (sec as Record)[k as string] + if (v === undefined) continue + const expected = types[k as string] + if (expected && typeof v === expected) acc[k as string] = v + else warnInvalid(`${key}.${String(k)}`, expected ?? "unknown", v) + } + return acc as NonNullable + } + + const codex = validateStringSection( + "codex", + ["originator", "userAgent", "model", "effort", "serviceTier", "baseUrl"], + { + originator: "string", + userAgent: "string", + model: "string", + effort: "string", + serviceTier: "string", + baseUrl: "string", + }, + ) + if (codex) out.codex = codex + + const kimi = validateStringSection("kimi", ["userAgent", "oauthHost", "baseUrl"], { + userAgent: "string", + oauthHost: "string", + baseUrl: "string", + }) + if (kimi) out.kimi = kimi + + const log = validateStringSection("log", ["stderr", "verbose"], { + stderr: "boolean", + verbose: "boolean", + }) + if (log) out.log = log + + return out +} + +export function loadConfig(opts: LoadOptions = {}): LoadedConfig { + if (cached && !opts.forceReload && !opts.configPath && !opts.env) { + return cached + } + const env = opts.env ?? process.env + const path = opts.configPath ?? join(configDir(), "config.json") + let file: FileConfig = {} + try { + const raw = readFileSync(path, "utf8") + try { + file = validate(JSON.parse(raw)) + } catch (err) { + process.stderr.write( + `claude-code-proxy: failed to parse ${path} (${(err as Error).message}); using defaults\n`, + ) + } + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + process.stderr.write( + `claude-code-proxy: failed to read ${path} (${(err as Error).message}); using defaults\n`, + ) + } + } + const result: LoadedConfig = { file, env } + // Always update the cache when forceReload is requested (lets tests + // install a custom env+path under the same singleton other modules read). + if (opts.forceReload || (!opts.configPath && !opts.env)) cached = result + return result +} + +export function getConfig(): LoadedConfig { + return cached ?? loadConfig() +} + +// Per-setting getters. Each encodes its precedence chain explicitly. + +// Preserves legacy `Number(process.env.PORT ?? 18765)` semantics: an env-set +// PORT of empty string parsed to NaN under the old code (effectively broken), +// so we treat it as unset rather than returning NaN. +export function port(): number { + const c = getConfig() + const envPort = c.env.PORT + if (envPort !== undefined && envPort !== "") { + const n = Number(envPort) + if (Number.isFinite(n)) return n + } + return c.file.port ?? 18765 +} + +export function codexOriginator(defaultValue: string): string { + const c = getConfig() + return ( + c.env.CCP_CODEX_ORIGINATOR ?? + c.env.CCP_ORIGINATOR ?? + c.file.codex?.originator ?? + defaultValue + ) +} + +export function codexUserAgent(defaultValue: string): string { + const c = getConfig() + return ( + c.env.CCP_CODEX_USER_AGENT ?? + c.env.CCP_USER_AGENT ?? + c.file.codex?.userAgent ?? + defaultValue + ) +} + +// Returns undefined when neither env nor file specifies a value. Empty +// string in env is intentionally treated as "unset" (preserves the +// long-standing CCP_CODEX_MODEL escape hatch). +export function codexModel(): string | undefined { + const c = getConfig() + return emptyOrUnset(c.env.CCP_CODEX_MODEL) ?? emptyOrUnset(c.file.codex?.model) +} + +export function codexEffort(): string | undefined { + const c = getConfig() + return emptyOrUnset(c.env.CCP_CODEX_EFFORT) ?? emptyOrUnset(c.file.codex?.effort) +} + +export function codexServiceTier(): string | undefined { + const c = getConfig() + return emptyOrUnset(c.env.CCP_CODEX_SERVICE_TIER) ?? emptyOrUnset(c.file.codex?.serviceTier) +} + +export function codexBaseUrl(defaultValue: string): string { + const c = getConfig() + return c.env.CCP_CODEX_BASE_URL ?? c.file.codex?.baseUrl ?? defaultValue +} + +export function kimiUserAgent(defaultValue: string): string { + const c = getConfig() + return ( + c.env.CCP_KIMI_USER_AGENT ?? + c.env.CCP_USER_AGENT ?? + c.file.kimi?.userAgent ?? + defaultValue + ) +} + +export function kimiOauthHost(): string { + const c = getConfig() + return c.env.CCP_KIMI_OAUTH_HOST ?? c.file.kimi?.oauthHost ?? "https://auth.kimi.com" +} + +export function kimiBaseUrl(): string { + const c = getConfig() + return c.env.CCP_KIMI_BASE_URL ?? c.file.kimi?.baseUrl ?? "https://api.kimi.com/coding/v1" +} + +// Additive: error/warn always go to stderr in log.ts; this getter only +// controls whether *all* levels are also mirrored to stderr. Matches the +// pre-existing `!!process.env.CCP_LOG_STDERR` semantics where any value +// (including the empty string) enables it. +export function logStderr(): boolean { + const c = getConfig() + if (c.env.CCP_LOG_STDERR !== undefined) return true + return c.file.log?.stderr ?? false +} + +export function logVerbose(): boolean { + const c = getConfig() + if (c.env.CCP_LOG_VERBOSE !== undefined) return true + return c.file.log?.verbose ?? false +} diff --git a/src/count-tokens.ts b/src/count-tokens.ts deleted file mode 100644 index 217fd3d..0000000 --- a/src/count-tokens.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { encode } from "gpt-tokenizer/model/gpt-4o" -import type { AnthropicRequest } from "./anthropic/schema.ts" - -export function countTokens(req: AnthropicRequest): number { - let total = 0 - if (req.system) { - const text = - typeof req.system === "string" - ? req.system - : req.system.map((b) => b.text || "").join("\n") - total += encode(text).length - } - for (const msg of req.messages) { - if (typeof msg.content === "string") { - total += encode(msg.content).length - continue - } - for (const block of msg.content) { - if (block.type === "text") total += encode(block.text).length - else if (block.type === "tool_use") total += encode(JSON.stringify(block.input ?? {})).length + encode(block.name).length - else if (block.type === "tool_result") { - const text = - typeof block.content === "string" - ? block.content - : block.content.map((b) => (b.type === "text" ? b.text : "")).join("\n") - total += encode(text).length - } - } - } - for (const tool of req.tools ?? []) { - total += encode(tool.name).length - if (tool.description) total += encode(tool.description).length - total += encode(JSON.stringify(tool.input_schema ?? {})).length - } - // Rough per-message overhead - total += req.messages.length * 4 - return total -} diff --git a/src/keychain.ts b/src/keychain.ts new file mode 100644 index 0000000..5a318c5 --- /dev/null +++ b/src/keychain.ts @@ -0,0 +1,136 @@ +import { dlopen, FFIType, ptr, toArrayBuffer, type Pointer } from "bun:ffi" + +const errSecSuccess = 0 +const errSecItemNotFound = -25300 +const errSecDuplicateItem = -25299 + +function readPtr(buf: Uint8Array): Pointer { + return Number(new DataView(buf.buffer, buf.byteOffset).getBigUint64(0, true)) as unknown as Pointer +} + +let _sym: ReturnType["symbols"] | undefined + +function sym() { + if (!_sym) _sym = loadLib().symbols + return _sym +} + +function loadLib() { + return dlopen("/System/Library/Frameworks/Security.framework/Security", { + SecKeychainAddGenericPassword: { + args: [FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr], + returns: FFIType.i32, + }, + SecKeychainFindGenericPassword: { + args: [FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr], + returns: FFIType.i32, + }, + SecKeychainItemModifyAttributesAndData: { + args: [FFIType.ptr, FFIType.ptr, FFIType.u32, FFIType.ptr], + returns: FFIType.i32, + }, + SecKeychainItemFreeContent: { + args: [FFIType.ptr, FFIType.ptr], + returns: FFIType.i32, + }, + SecKeychainItemDelete: { + args: [FFIType.ptr], + returns: FFIType.i32, + }, + CFRelease: { + args: [FFIType.ptr], + returns: FFIType.void, + }, + } as const) +} + +export function keychainGet(service: string, account: string): string | undefined { + const svc = Buffer.from(service) + const acc = Buffer.from(account) + const lenBuf = new Uint8Array(4) + const dataBuf = new Uint8Array(8) + + const s = sym().SecKeychainFindGenericPassword( + null, + svc.byteLength, ptr(svc), + acc.byteLength, ptr(acc), + ptr(lenBuf), ptr(dataBuf), + null, + ) as number + + if (s === errSecItemNotFound) return undefined + if (s !== errSecSuccess) throw keychainError("read", s) + + const dataPtr = readPtr(dataBuf) + const dataLen = new DataView(lenBuf.buffer).getUint32(0, true) + const result = Buffer.from(toArrayBuffer(dataPtr, 0, dataLen)).toString("utf8") + sym().SecKeychainItemFreeContent(null, dataPtr) + return result +} + +export function keychainSet(service: string, account: string, password: string): void { + const svc = Buffer.from(service) + const acc = Buffer.from(account) + const pwd = Buffer.from(password) + const itemBuf = new Uint8Array(8) + + let s = sym().SecKeychainAddGenericPassword( + null, + svc.byteLength, ptr(svc), + acc.byteLength, ptr(acc), + pwd.byteLength, ptr(pwd), + ptr(itemBuf), + ) as number + + if (s === errSecSuccess) { + const ref = readPtr(itemBuf) + if (ref) sym().CFRelease(ref) + return + } + + if (s !== errSecDuplicateItem) throw keychainError("add", s) + + // Item exists — find it and update in place + const itemBuf2 = new Uint8Array(8) + s = sym().SecKeychainFindGenericPassword( + null, + svc.byteLength, ptr(svc), + acc.byteLength, ptr(acc), + null, null, + ptr(itemBuf2), + ) as number + if (s !== errSecSuccess) throw keychainError("find for update", s) + + const itemRef = readPtr(itemBuf2) + s = sym().SecKeychainItemModifyAttributesAndData(itemRef, null, pwd.byteLength, ptr(pwd)) as number + sym().CFRelease(itemRef) + if (s !== errSecSuccess) throw keychainError("update", s) +} + +export function keychainDelete(service: string, account: string): void { + const svc = Buffer.from(service) + const acc = Buffer.from(account) + const itemBuf = new Uint8Array(8) + + const s = sym().SecKeychainFindGenericPassword( + null, + svc.byteLength, ptr(svc), + acc.byteLength, ptr(acc), + null, null, + ptr(itemBuf), + ) as number + + if (s === errSecItemNotFound) return + if (s !== errSecSuccess) throw keychainError("find for delete", s) + + const itemRef = readPtr(itemBuf) + const delStatus = sym().SecKeychainItemDelete(itemRef) as number + sym().CFRelease(itemRef) + if (delStatus !== errSecSuccess) throw keychainError("delete", delStatus) +} + +function keychainError(op: string, code: number): Error { + const err = new Error(`Keychain ${op} failed: ${code}`) as Error & { code: number } + err.code = code + return err +} diff --git a/src/log.ts b/src/log.ts index 2510bfd..169ef20 100644 --- a/src/log.ts +++ b/src/log.ts @@ -1,12 +1,12 @@ import { mkdir, appendFile, stat, rename } from "node:fs/promises" import { createWriteStream, type WriteStream } from "node:fs" import { join } from "node:path" -import { homedir } from "node:os" +import { stateDir } from "./paths.ts" +import { logStderr, logVerbose } from "./config.ts" const MAX_LOG_BYTES = 20 * 1024 * 1024 // 20 MiB -const REDACT_KEYS = new Set([ +export const REDACT_KEYS = new Set([ "authorization", - "Authorization", "access", "access_token", "refresh", @@ -14,16 +14,10 @@ const REDACT_KEYS = new Set([ "id_token", "code", "code_verifier", - "ChatGPT-Account-Id", "chatgpt-account-id", "x-api-key", ]) -function stateDir(): string { - const base = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state") - return join(base, "claude-codex-proxy") -} - export function logDir(): string { return stateDir() } @@ -36,7 +30,7 @@ async function ensureStream(): Promise { const dir = stateDir() await mkdir(dir, { recursive: true }) const file = join(dir, "proxy.log") - stream = createWriteStream(file, { flags: "a" }) + stream = createWriteStream(file, { flags: "a", mode: 0o600 }) return stream } @@ -49,11 +43,11 @@ async function maybeRotate(): Promise { const s = await stat(file).catch(() => undefined) if (!s || s.size < MAX_LOG_BYTES) return const rotated = join(dir, `proxy.log.${Date.now()}`) + await rename(file, rotated) if (stream) { stream.end() stream = undefined } - await rename(file, rotated).catch(() => {}) } catch { // Never propagate rotation errors — logging must never crash the proxy. } finally { @@ -63,20 +57,18 @@ async function maybeRotate(): Promise { return rotating } -const VERBOSE = !!process.env.CCP_LOG_VERBOSE - function redact(value: unknown, depth = 0): unknown { if (depth > 6) return "[depth-limit]" if (value == null) return value if (typeof value === "string") { - if (!VERBOSE && value.length > 4000) return value.slice(0, 4000) + `…[${value.length - 4000} more]` + if (!logVerbose() && value.length > 4000) return value.slice(0, 4000) + `…[${value.length - 4000} more]` return value } if (typeof value !== "object") return value if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1)) const out: Record = {} for (const [k, v] of Object.entries(value as Record)) { - if (REDACT_KEYS.has(k)) { + if (REDACT_KEYS.has(k.toLowerCase())) { out[k] = typeof v === "string" ? `[redacted len=${v.length}]` : "[redacted]" } else { out[k] = redact(v, depth + 1) @@ -102,7 +94,7 @@ async function write(level: Level, service: string, msg: string, fields?: Record } catch { // swallow; also print to stderr for visibility } - if (level === "error" || level === "warn" || process.env.CCP_LOG_STDERR) { + if (level === "error" || level === "warn" || logStderr()) { process.stderr.write(line + "\n") } } @@ -112,13 +104,20 @@ export interface Logger { info(msg: string, fields?: Record): void warn(msg: string, fields?: Record): void error(msg: string, fields?: Record): void + child(bindings: Record): Logger } -export function createLogger(service: string): Logger { +export function createLogger( + service: string, + baseFields: Record = {}, +): Logger { + const merge = (f?: Record) => + f ? { ...baseFields, ...f } : baseFields return { - debug: (msg, fields) => void write("debug", service, msg, fields), - info: (msg, fields) => void write("info", service, msg, fields), - warn: (msg, fields) => void write("warn", service, msg, fields), - error: (msg, fields) => void write("error", service, msg, fields), + debug: (msg, fields) => void write("debug", service, msg, merge(fields)), + info: (msg, fields) => void write("info", service, msg, merge(fields)), + warn: (msg, fields) => void write("warn", service, msg, merge(fields)), + error: (msg, fields) => void write("error", service, msg, merge(fields)), + child: (bindings) => createLogger(service, { ...baseFields, ...bindings }), } } diff --git a/src/paths.test.ts b/src/paths.test.ts new file mode 100644 index 0000000..ca3c55a --- /dev/null +++ b/src/paths.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "bun:test" +import { resolveConfigDir, resolveStateDir } from "./paths.ts" + +describe("resolveConfigDir", () => { + it("uses ~/.config on darwin even when XDG_CONFIG_HOME is set", () => { + expect( + resolveConfigDir({ platform: "darwin", env: { XDG_CONFIG_HOME: "/x" }, home: "/home/u" }), + ).toBe("/home/u/.config/claude-code-proxy") + }) + + it("honors XDG_CONFIG_HOME on linux", () => { + expect( + resolveConfigDir({ platform: "linux", env: { XDG_CONFIG_HOME: "/x" }, home: "/home/u" }), + ).toBe("/x/claude-code-proxy") + }) + + it("falls back to $HOME/.config on linux without XDG_CONFIG_HOME", () => { + expect(resolveConfigDir({ platform: "linux", env: {}, home: "/home/u" })).toBe( + "/home/u/.config/claude-code-proxy", + ) + }) +}) + +describe("resolveStateDir", () => { + it("honors XDG_STATE_HOME on darwin (preserves pre-existing log.ts behavior)", () => { + expect( + resolveStateDir({ platform: "darwin", env: { XDG_STATE_HOME: "/x" }, home: "/home/u" }), + ).toBe("/x/claude-code-proxy") + }) + + it("falls back to $HOME/.local/state on darwin without XDG_STATE_HOME", () => { + expect(resolveStateDir({ platform: "darwin", env: {}, home: "/home/u" })).toBe( + "/home/u/.local/state/claude-code-proxy", + ) + }) + + it("honors XDG_STATE_HOME on linux", () => { + expect( + resolveStateDir({ platform: "linux", env: { XDG_STATE_HOME: "/x" }, home: "/home/u" }), + ).toBe("/x/claude-code-proxy") + }) +}) diff --git a/src/paths.ts b/src/paths.ts new file mode 100644 index 0000000..3d40164 --- /dev/null +++ b/src/paths.ts @@ -0,0 +1,46 @@ +import { homedir } from "node:os" +import { join } from "node:path" + +export interface DirResolverEnv { + platform: NodeJS.Platform + env: NodeJS.ProcessEnv + home: string +} + +function defaults(): DirResolverEnv { + return { platform: process.platform, env: process.env, home: homedir() } +} + +// macOS deliberately uses ~/.config/ rather than honoring XDG_CONFIG_HOME +// (which would redirect to ~/Library/Application Support). This matches where +// auth tokens have always been stored on macOS. +export function resolveConfigDir(deps: DirResolverEnv): string { + if (deps.platform === "darwin") { + return join(deps.home, ".config", "claude-code-proxy") + } + const base = deps.env.XDG_CONFIG_HOME || join(deps.home, ".config") + return join(base, "claude-code-proxy") +} + +// XDG_STATE_HOME is honored on every platform (including macOS) — that's the +// pre-config.json behavior of log.ts and is documented in the README. +export function resolveStateDir(deps: DirResolverEnv): string { + const base = deps.env.XDG_STATE_HOME || join(deps.home, ".local", "state") + return join(base, "claude-code-proxy") +} + +// Legacy (pre-config.json) auth/device-id path. Always ~/.config regardless +// of XDG_CONFIG_HOME — this is the directory token stores hardcoded before +// configDir() existed. Used as a read-only fallback so existing logins keep +// working after upgrade. +export function legacyConfigDir(deps: DirResolverEnv = defaults()): string { + return join(deps.home, ".config", "claude-code-proxy") +} + +export function configDir(): string { + return resolveConfigDir(defaults()) +} + +export function stateDir(): string { + return resolveStateDir(defaults()) +} diff --git a/src/providers/anthropic/auth/token-store.ts b/src/providers/anthropic/auth/token-store.ts new file mode 100644 index 0000000..b656c49 --- /dev/null +++ b/src/providers/anthropic/auth/token-store.ts @@ -0,0 +1,37 @@ +import { keychainGet } from "../../../keychain.ts" +import { userInfo } from "node:os" + +const KEYCHAIN_SERVICE = "Claude Code-credentials" + +interface ClaudeOAuthToken { + accessToken: string + refreshToken: string + expiresAt?: number +} + +interface ClaudeCredentials { + claudeAiOauth: ClaudeOAuthToken +} + +export function loadToken(): string | undefined { + const account = userInfo().username + let raw: string | undefined + try { + raw = keychainGet(KEYCHAIN_SERVICE, account) + } catch { + return undefined + } + if (!raw) return undefined + try { + const creds = JSON.parse(raw) as ClaudeCredentials + return creds.claudeAiOauth?.accessToken + } catch { + return undefined + } +} + +export function tokenStatus(): { found: boolean; account: string } { + const account = userInfo().username + const token = loadToken() + return { found: !!token, account } +} diff --git a/src/providers/anthropic/client.ts b/src/providers/anthropic/client.ts new file mode 100644 index 0000000..2cfc63e --- /dev/null +++ b/src/providers/anthropic/client.ts @@ -0,0 +1,34 @@ +import { loadToken } from "./auth/token-store.ts" + +export const ANTHROPIC_BASE_URL = "https://api.anthropic.com" + +export class AnthropicError extends Error { + constructor( + public readonly status: number, + public readonly detail: string, + ) { + super(`Anthropic ${status}: ${detail}`) + } +} + +export async function postAnthropic( + path: string, + body: unknown, + signal?: AbortSignal, +): Promise { + const token = loadToken() + if (!token) { + throw new AnthropicError(401, "No Claude Code credentials found in keychain. Run Claude Code at least once to authenticate.") + } + + return fetch(`${ANTHROPIC_BASE_URL}${path}`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify(body), + signal, + }) +} diff --git a/src/providers/anthropic/index.ts b/src/providers/anthropic/index.ts new file mode 100644 index 0000000..a164d6e --- /dev/null +++ b/src/providers/anthropic/index.ts @@ -0,0 +1,123 @@ +import type { Provider, RequestContext, CliHandlers } from "../types.ts" +import type { AnthropicRequest } from "../../anthropic/schema.ts" +import { postAnthropic, AnthropicError } from "./client.ts" +import { tokenStatus } from "./auth/token-store.ts" + +// These are the real Anthropic model IDs. By claiming them here, requests +// using these models are routed to api.anthropic.com instead of codex/kimi. +export const SUPPORTED_MODELS = new Set([ + // Latest generation + "claude-opus-4-7", + "claude-sonnet-4-6", + "claude-haiku-4-5", + "claude-haiku-4-5-20251001", + // Previous generation (still routed to api.anthropic.com when explicitly requested + // via /model command in Claude Code; older Claude Code versions also default to these) + "claude-opus-4-6", + "claude-sonnet-4-5", + "claude-haiku-4-4", +]) + +function jsonError(status: number, type: string, message: string): Response { + return new Response(JSON.stringify({ type: "error", error: { type, message } }), { + status, + headers: { "content-type": "application/json" }, + }) +} + +// Strip Claude Code-specific extension fields that the official API doesn't accept +function stripExtensions(body: AnthropicRequest): Omit { + const { context_management: _, output_config: __, ...clean } = body + return clean +} + +async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Promise { + const log = ctx.childLogger("provider.anthropic") + log.debug("forwarding to anthropic", { model: body.model }) + + let upstream: Response + try { + upstream = await postAnthropic("/v1/messages", stripExtensions(body), ctx.signal) + } catch (err) { + if (err instanceof AnthropicError) { + log.warn("anthropic error", { status: err.status, detail: err.detail }) + const type = err.status === 401 || err.status === 403 ? "authentication_error" : "api_error" + if (err.status === 429) { + return jsonError(429, "rate_limit_error", err.detail) + } + return jsonError(err.status, type, err.detail) + } + throw err + } + + if (!upstream.ok && upstream.status !== 200) { + const body_text = await upstream.text() + log.warn("anthropic upstream error", { status: upstream.status, body: body_text }) + // Pass through the error response as-is — it's already Anthropic format + return new Response(body_text, { + status: upstream.status, + headers: { "content-type": "application/json" }, + }) + } + + // Pass through response as-is (already in Anthropic format, streaming or not) + const headers = new Headers() + headers.set("content-type", upstream.headers.get("content-type") ?? "application/json") + if (upstream.headers.has("cache-control")) { + headers.set("cache-control", upstream.headers.get("cache-control")!) + } + + return new Response(upstream.body, { + status: upstream.status, + headers, + }) +} + +async function handleCountTokens(body: AnthropicRequest, ctx: RequestContext): Promise { + const log = ctx.childLogger("provider.anthropic") + log.debug("count_tokens via anthropic", { model: body.model }) + + // count_tokens endpoint only accepts a subset of fields; also strip CC extensions + const { max_tokens: _, stream: __, ...countBody } = stripExtensions(body) as typeof body & { max_tokens?: unknown; stream?: unknown } + + let upstream: Response + try { + upstream = await postAnthropic("/v1/messages/count_tokens", countBody, ctx.signal) + } catch (err) { + if (err instanceof AnthropicError) { + return jsonError(err.status, "api_error", err.detail) + } + throw err + } + + const text = await upstream.text() + return new Response(text, { + status: upstream.status, + headers: { "content-type": "application/json" }, + }) +} + +const cli: CliHandlers = { + async status() { + const { found, account } = tokenStatus() + if (!found) { + console.log(`No Claude Code credentials found for account "${account}"`) + console.log("Run Claude Code at least once to authenticate, then retry.") + process.exit(1) + } + console.log(`Account: ${account}`) + console.log("Token: found in keychain (managed by Claude Code)") + console.log("Note: token refresh is handled automatically by Claude Code") + }, + async logout() { + console.log("Anthropic tokens are managed by Claude Code — use Claude Code to log out.") + }, +} + +export const anthropicProvider: Provider = { + name: "anthropic", + supportedModels: SUPPORTED_MODELS, + handleMessages, + handleCountTokens, + cli, +} diff --git a/src/auth/constants.ts b/src/providers/codex/auth/constants.ts similarity index 88% rename from src/auth/constants.ts rename to src/providers/codex/auth/constants.ts index 1daf900..eadfda2 100644 --- a/src/auth/constants.ts +++ b/src/providers/codex/auth/constants.ts @@ -3,5 +3,5 @@ export const ISSUER = "https://auth.openai.com" export const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" export const OAUTH_PORT = 1455 export const OAUTH_REDIRECT_URI = `http://localhost:${OAUTH_PORT}/auth/callback` -export const ORIGINATOR = "codex-tui" +export const ORIGINATOR = "claude-code-proxy" export const REFRESH_MARGIN_MS = 5 * 60 * 1000 diff --git a/src/auth/device.ts b/src/providers/codex/auth/device.ts similarity index 100% rename from src/auth/device.ts rename to src/providers/codex/auth/device.ts diff --git a/src/auth/jwt.ts b/src/providers/codex/auth/jwt.ts similarity index 100% rename from src/auth/jwt.ts rename to src/providers/codex/auth/jwt.ts diff --git a/src/auth/manager.ts b/src/providers/codex/auth/manager.ts similarity index 73% rename from src/auth/manager.ts rename to src/providers/codex/auth/manager.ts index 2ed98df..5325343 100644 --- a/src/auth/manager.ts +++ b/src/providers/codex/auth/manager.ts @@ -2,13 +2,24 @@ import { CLIENT_ID, ISSUER, REFRESH_MARGIN_MS } from "./constants.ts" import { extractAccountId, type TokenResponse } from "./jwt.ts" import { loadAuth, saveAuth, type StoredAuth } from "./token-store.ts" +function validateTokenResponse(t: unknown): asserts t is TokenResponse { + if (!t || typeof t !== "object") throw new Error("Invalid token response: not an object") + const o = t as Record + if (typeof o.access_token !== "string" || !o.access_token) + throw new Error("Invalid token response: missing access_token") + if (typeof o.refresh_token !== "string" || !o.refresh_token) + throw new Error("Invalid token response: missing refresh_token") + if (o.expires_in !== undefined && (typeof o.expires_in !== "number" || !Number.isFinite(o.expires_in) || o.expires_in <= 0)) + throw new Error("Invalid token response: bad expires_in") +} + let cached: StoredAuth | undefined let inflight: Promise | undefined export async function getAuth(): Promise { if (!cached) { const stored = await loadAuth() - if (!stored) throw new Error("Not authenticated. Run: claude-codex-proxy auth login") + if (!stored) throw new Error("Not authenticated. Run: claude-code-proxy codex auth login") cached = stored } if (cached.expires - REFRESH_MARGIN_MS > Date.now()) { @@ -44,8 +55,9 @@ async function refreshNow(current: StoredAuth): Promise { client_id: CLIENT_ID, }).toString(), }) - if (!resp.ok) throw new Error(`Token refresh failed: ${resp.status} ${await resp.text()}`) - const tokens = (await resp.json()) as TokenResponse + if (!resp.ok) throw new Error(`Token refresh failed: ${resp.status}`) + const tokens = await resp.json() + validateTokenResponse(tokens) const accountId = extractAccountId(tokens) || current.accountId const next: StoredAuth = { access: tokens.access_token, @@ -59,6 +71,7 @@ async function refreshNow(current: StoredAuth): Promise { } export async function persistInitialTokens(tokens: TokenResponse): Promise { + validateTokenResponse(tokens) const auth: StoredAuth = { access: tokens.access_token, refresh: tokens.refresh_token, diff --git a/src/auth/pkce.ts b/src/providers/codex/auth/pkce.ts similarity index 91% rename from src/auth/pkce.ts rename to src/providers/codex/auth/pkce.ts index 92eda7a..c39991c 100644 --- a/src/auth/pkce.ts +++ b/src/providers/codex/auth/pkce.ts @@ -8,19 +8,11 @@ export interface PkceCodes { } export async function generatePKCE(): Promise { - const verifier = generateRandomString(43) + const verifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)) return { verifier, challenge: base64UrlEncode(hash) } } -function generateRandomString(length: number): string { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" - const bytes = crypto.getRandomValues(new Uint8Array(length)) - return Array.from(bytes) - .map((b) => chars[b % chars.length]) - .join("") -} - function base64UrlEncode(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer) let binary = "" @@ -109,7 +101,7 @@ export async function runBrowserLogin(): Promise { reject(err) }) }) - server.listen(OAUTH_PORT, () => { + server.listen(OAUTH_PORT, "127.0.0.1", () => { console.log(`Open this URL in your browser to authorize:\n\n ${authUrl}\n`) }) server.on("error", reject) diff --git a/src/providers/codex/auth/token-store.ts b/src/providers/codex/auth/token-store.ts new file mode 100644 index 0000000..542c7b4 --- /dev/null +++ b/src/providers/codex/auth/token-store.ts @@ -0,0 +1,77 @@ +import { mkdir, readFile, writeFile, unlink, rename } from "node:fs/promises" +import { dirname, join } from "node:path" +import { keychainGet, keychainSet, keychainDelete } from "../../../keychain.ts" +import { configDir, legacyConfigDir } from "../../../paths.ts" + +export interface StoredAuth { + access: string + refresh: string + expires: number + accountId?: string +} + +function file(): string { + return join(configDir(), "codex", "auth.json") +} +function legacyFile(): string { + return join(legacyConfigDir(), "codex", "auth.json") +} +const KEYCHAIN_SERVICE = "claude-code-proxy.codex" +const KEYCHAIN_ACCOUNT = "auth" + +export async function loadAuth(): Promise { + if (process.platform === "darwin") { + const raw = keychainGet(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) + if (!raw) return undefined + return JSON.parse(raw) as StoredAuth + } + + const primary = file() + try { + const raw = await readFile(primary, "utf8") + return JSON.parse(raw) as StoredAuth + } catch (err: any) { + if (err?.code !== "ENOENT") throw err + } + const legacy = legacyFile() + if (legacy === primary) return undefined + try { + const raw = await readFile(legacy, "utf8") + return JSON.parse(raw) as StoredAuth + } catch (err: any) { + if (err?.code === "ENOENT") return undefined + throw err + } +} + +export async function saveAuth(auth: StoredAuth): Promise { + if (process.platform === "darwin") { + keychainSet(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, JSON.stringify(auth)) + return + } + + const path = file() + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) + const tmp = `${path}.${process.pid}.${Date.now()}.tmp` + await writeFile(tmp, JSON.stringify(auth, null, 2), { encoding: "utf8", mode: 0o600 }) + await rename(tmp, path) +} + +export async function clearAuth(): Promise { + if (process.platform === "darwin") { + keychainDelete(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) + return + } + + for (const path of [file(), legacyFile()]) { + try { + await unlink(path) + } catch (err: any) { + if (err?.code !== "ENOENT") throw err + } + } +} + +export function authPath(): string { + return process.platform === "darwin" ? "macOS Keychain" : file() +} diff --git a/src/codex/client.ts b/src/providers/codex/client.ts similarity index 55% rename from src/codex/client.ts rename to src/providers/codex/client.ts index 9046a67..9d605f0 100644 --- a/src/codex/client.ts +++ b/src/providers/codex/client.ts @@ -1,14 +1,12 @@ -import { CODEX_API_ENDPOINT, ORIGINATOR } from "../auth/constants.ts" -import { forceRefresh, getAuth } from "../auth/manager.ts" -import { createLogger } from "../log.ts" -import type { ResponsesRequest } from "../translate/request.ts" - -const log = createLogger("codex.client") - -export interface CodexPostOptions { - sessionId?: string - signal?: AbortSignal -} +import { CODEX_API_ENDPOINT, ORIGINATOR as ORIGINATOR_DEFAULT } from "./auth/constants.ts" +import { codexBaseUrl, codexOriginator, codexUserAgent } from "../../config.ts" +declare const BUILD_VERSION: string | undefined +const PROXY_VERSION = typeof BUILD_VERSION === "string" ? BUILD_VERSION : "dev" +import { forceRefresh, getAuth } from "./auth/manager.ts" +import type { Logger } from "../../log.ts" +import type { RequestContext } from "../types.ts" +import type { ResponsesRequest } from "./translate/request.ts" +import { retryOn429 } from "../retry.ts" export interface CodexResponse { body: ReadableStream @@ -18,16 +16,32 @@ export interface CodexResponse { export async function postCodex( body: ResponsesRequest, - opts: CodexPostOptions = {}, + ctx: RequestContext, +): Promise { + const log = ctx.childLogger("codex.client") + return retryOn429(() => attemptPostCodex(body, ctx, log), { + log, + signal: ctx.signal, + classify: (err) => + err instanceof CodexError && err.status === 429 + ? { retryAfter: err.meta?.retryAfter } + : undefined, + }) +} + +async function attemptPostCodex( + body: ResponsesRequest, + ctx: RequestContext, + log: Logger, ): Promise { let auth = await getAuth() - let resp = await doFetch(auth.access, auth.accountId, body, opts) + let resp = await doFetch(auth.access, auth.accountId, body, log, ctx.signal, ctx.sessionId) if (resp.status === 401) { log.warn("got 401, refreshing token", {}) try { auth = await forceRefresh() - resp = await doFetch(auth.access, auth.accountId, body, opts) + resp = await doFetch(auth.access, auth.accountId, body, log, ctx.signal, ctx.sessionId) } catch (err) { log.error("refresh after 401 failed", { err: String(err) }) } @@ -59,35 +73,40 @@ async function doFetch( accessToken: string, accountId: string | undefined, body: ResponsesRequest, - opts: CodexPostOptions, + log: Logger, + signal?: AbortSignal, + sessionId?: string, ): Promise { const headers = new Headers({ "Content-Type": "application/json", accept: "text/event-stream", authorization: `Bearer ${accessToken}`, - originator: ORIGINATOR, + originator: codexOriginator(ORIGINATOR_DEFAULT), "openai-beta": "responses=experimental", }) + const userAgent = codexUserAgent(`claude-code-proxy/${PROXY_VERSION}`) + if (userAgent) headers.set("User-Agent", userAgent) if (accountId) headers.set("ChatGPT-Account-Id", accountId) - if (opts.sessionId) { - headers.set("session_id", opts.sessionId) - headers.set("x-client-request-id", opts.sessionId) - headers.set("x-codex-window-id", `${opts.sessionId}:0`) + if (sessionId) { + headers.set("session_id", sessionId) + headers.set("x-client-request-id", sessionId) + headers.set("x-codex-window-id", `${sessionId}:0`) } + const codexUrl = codexBaseUrl(CODEX_API_ENDPOINT) + log.debug("posting to codex", { - url: CODEX_API_ENDPOINT, + url: codexUrl, model: body.model, inputCount: body.input.length, toolCount: body.tools?.length ?? 0, - sessionId: opts.sessionId, }) - return fetch(CODEX_API_ENDPOINT, { + return fetch(codexUrl, { method: "POST", headers, body: JSON.stringify(body), - signal: opts.signal, + signal, }) } diff --git a/src/providers/codex/count-tokens.ts b/src/providers/codex/count-tokens.ts new file mode 100644 index 0000000..6920069 --- /dev/null +++ b/src/providers/codex/count-tokens.ts @@ -0,0 +1,84 @@ +import { encode } from "gpt-tokenizer/model/gpt-4o" +import type { AnthropicRequest } from "../../anthropic/schema.ts" +import type { ResponsesRequest } from "./translate/request.ts" +import { buildInstructions, normalizeContent, toolResultToString } from "./translate/request.ts" + +const IMAGE_TOKEN_ESTIMATE = 2000 + +export function countTokens(req: AnthropicRequest): number { + let total = 0 + const instructions = buildInstructions(req.system) + if (instructions) total += encode(instructions).length + + for (const msg of req.messages) { + const blocks = normalizeContent(msg.content) + for (const block of blocks) { + if (block.type === "text") { + total += encode(block.text).length + } else if (block.type === "image") { + total += IMAGE_TOKEN_ESTIMATE + } else if (block.type === "tool_use") { + total += encode(block.name).length + total += encode(JSON.stringify(block.input ?? {})).length + } else if (block.type === "tool_result") { + total += encode(toolResultToString(block.content)).length + } + } + } + + for (const tool of req.tools ?? []) { + total += encode(tool.name).length + if (tool.description) total += encode(tool.description).length + total += encode(JSON.stringify(tool.input_schema ?? {})).length + } + + total += req.messages.length * 4 + return total +} + +export function countTranslatedTokens( + req: Pick, +): number { + let total = 0 + if (req.instructions) total += encode(req.instructions).length + + for (const item of req.input) { + if (item.type === "message") { + for (const part of item.content) { + if (part.type === "input_text" || part.type === "output_text") { + total += encode(part.text).length + } else if (part.type === "input_image") { + total += IMAGE_TOKEN_ESTIMATE + } + } + } else if (item.type === "function_call") { + total += encode(item.call_id).length + total += encode(item.name).length + total += encode(item.arguments).length + } else if (item.type === "function_call_output") { + total += encode(item.call_id).length + total += encode(item.output).length + } + } + + for (const tool of req.tools ?? []) { + total += encode(tool.name).length + if (tool.description) total += encode(tool.description).length + total += encode(JSON.stringify(tool.parameters ?? {})).length + } + + if (req.text?.format?.type === "json_schema") { + total += encode(req.text.format.name).length + total += encode(JSON.stringify(req.text.format.schema)).length + } + + if (typeof req.tool_choice === "string") { + total += encode(req.tool_choice).length + } else if (req.tool_choice?.type === "function") { + total += encode(req.tool_choice.type).length + total += encode(req.tool_choice.name).length + } + + total += req.input.length * 4 + return total +} diff --git a/src/providers/codex/index.test.ts b/src/providers/codex/index.test.ts new file mode 100644 index 0000000..d00a7d4 --- /dev/null +++ b/src/providers/codex/index.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it } from "bun:test" +import type { RequestContext } from "../types.ts" +import { loadConfig } from "../../config.ts" +import { codexProvider } from "./index.ts" + +const ctx: RequestContext = { + reqId: "test-req", + signal: new AbortController().signal, + childLogger: () => ({ + debug() {}, + info() {}, + warn() {}, + error() {}, + child() { + return this + }, + }), +} + +afterEach(() => { + loadConfig({ forceReload: true }) +}) + +describe("codexProvider", () => { + it("returns 400 for invalid service tier config during token counting", async () => { + loadConfig({ env: { CCP_CODEX_SERVICE_TIER: "standard" }, forceReload: true }) + + const resp = await codexProvider.handleCountTokens( + { model: "gpt-5.4", messages: [{ role: "user", content: "hello" }] }, + ctx, + ) + + expect(resp.status).toBe(400) + expect(await resp.json()).toEqual({ + type: "error", + error: { + type: "invalid_request_error", + message: 'Invalid service tier override: "standard". Must be one of: fast, priority, flex', + }, + }) + }) + + it("returns 400 for invalid forced model during token counting", async () => { + loadConfig({ env: { CCP_CODEX_MODEL: "gpt-4.1" }, forceReload: true }) + + const resp = await codexProvider.handleCountTokens( + { model: "gpt-5.4", messages: [{ role: "user", content: "hello" }] }, + ctx, + ) + + expect(resp.status).toBe(400) + expect(await resp.json()).toEqual({ + type: "error", + error: { + type: "invalid_request_error", + message: 'Model "gpt-5.4" resolves to unsupported model "gpt-4.1"', + }, + }) + }) +}) diff --git a/src/providers/codex/index.ts b/src/providers/codex/index.ts new file mode 100644 index 0000000..3676c4d --- /dev/null +++ b/src/providers/codex/index.ts @@ -0,0 +1,394 @@ +import type { AnthropicRequest } from "../../anthropic/schema.ts" +import type { Provider, RequestContext, CliHandlers } from "../types.ts" +import { + ALLOWED_MODELS, + assertAllowedModel, + FAST_MODEL_ALIASES, + ModelNotAllowedError, + resolveModelRequest, +} from "./translate/model-allowlist.ts" +import { InvalidServiceTierError, translateRequest } from "./translate/request.ts" +import { translateStream } from "./translate/stream.ts" +import { accumulateResponse, UpstreamStreamError } from "./translate/accumulate.ts" +import { mapUsageToAnthropic } from "./translate/reducer.ts" +import { CodexError, postCodex } from "./client.ts" +import { countTokens, countTranslatedTokens } from "./count-tokens.ts" +import { runBrowserLogin } from "./auth/pkce.ts" +import { runDeviceLogin } from "./auth/device.ts" +import { persistInitialTokens } from "./auth/manager.ts" +import { loadAuth, authPath, clearAuth } from "./auth/token-store.ts" +import { logVerbose } from "../../config.ts" + +interface SessionCountSnapshot { + reqId: string + model: string + messageCount: number + toolCount: number + tokens: number +} + +interface SessionMessageSnapshot { + reqId: string + model: string + messageCount: number + toolCount: number + localInputTokens?: number + translatedInputTokens?: number +} + +interface SessionTimelineState { + lastCount?: SessionCountSnapshot + lastMessage?: SessionMessageSnapshot +} + +const sessionTimeline = new Map() + +function sessionState(sessionId?: string): SessionTimelineState | undefined { + if (!sessionId) return undefined + let state = sessionTimeline.get(sessionId) + if (!state) { + state = {} + sessionTimeline.set(sessionId, state) + } + return state +} + +function usageWindowTokens(usage: { + input_tokens: number + output_tokens: number + cache_creation_input_tokens: number + cache_read_input_tokens: number +}): number { + return ( + usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens + ) +} + +function upstreamHeaderSnapshot(headers: Headers): { + serverModel?: string + serverReasoningIncluded: boolean +} { + return { + serverModel: headers.get("OpenAI-Model") || undefined, + serverReasoningIncluded: headers.has("X-Reasoning-Included"), + } +} + +function jsonError(status: number, type: string, message: string): Response { + return new Response(JSON.stringify({ type: "error", error: { type, message } }), { + status, + headers: { "content-type": "application/json" }, + }) +} + +function invalidServiceTierResponse(err: InvalidServiceTierError): Response { + return jsonError(400, "invalid_request_error", err.message) +} + +async function handleCountTokens(body: AnthropicRequest, ctx: RequestContext): Promise { + const log = ctx.childLogger("provider.codex") + const resolved = resolveModelRequest(body.model) + const resolvedModel = resolved.model + let translated + try { + assertAllowedModel(resolvedModel) + translated = translateRequest({ ...body, model: resolvedModel }, { serviceTier: resolved.serviceTier }) + } catch (err) { + if (err instanceof ModelNotAllowedError) { + return jsonError( + 400, + "invalid_request_error", + `Model "${body.model}" resolves to unsupported model "${err.model}"`, + ) + } + if (err instanceof InvalidServiceTierError) return invalidServiceTierResponse(err) + throw err + } + const tokens = countTranslatedTokens(translated) + const messageCount = body.messages?.length ?? 0 + const toolCount = body.tools?.length ?? 0 + const state = sessionState(ctx.sessionId) + log.debug("count_tokens", { tokens }) + if (state) { + state.lastCount = { + reqId: ctx.reqId, + model: body.model, + messageCount, + toolCount, + tokens, + } + } + if (logVerbose()) { + log.info("compaction telemetry", { + phase: "count_tokens", + model: body.model, + resolvedModel, + tokens, + messageCount, + toolCount, + previousMessageReqId: state?.lastMessage?.reqId, + previousMessageModel: state?.lastMessage?.model, + previousMessageCount: state?.lastMessage?.messageCount, + previousMessageToolCount: state?.lastMessage?.toolCount, + previousMessageLocalInputTokens: state?.lastMessage?.localInputTokens, + previousMessageTranslatedInputTokens: state?.lastMessage?.translatedInputTokens, + }) + } + return new Response(JSON.stringify({ input_tokens: tokens }), { + headers: { "content-type": "application/json" }, + }) +} + +async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Promise { + const log = ctx.childLogger("provider.codex") + const messageId = `msg_${crypto.randomUUID().replace(/-/g, "")}` + const wantStream = body.stream !== false + const messageCount = body.messages?.length ?? 0 + const toolCount = body.tools?.length ?? 0 + const contextManagement = body.context_management + const state = sessionState(ctx.sessionId) + + log.debug("anthropic request", { + model: body.model, + messageCount, + toolCount, + stream: wantStream, + requestedMaxTokens: body.max_tokens, + hasContextManagement: contextManagement !== undefined, + hasJsonSchemaFormat: body.output_config?.format?.type === "json_schema", + }) + if (logVerbose()) log.debug("anthropic request body", { body }) + + const resolved = resolveModelRequest(body.model) + const resolvedModel = resolved.model + + let translated + try { + assertAllowedModel(resolvedModel) + translated = translateRequest( + { ...body, model: resolvedModel }, + { sessionId: ctx.sessionId, serviceTier: resolved.serviceTier }, + ) + } catch (err) { + if (err instanceof ModelNotAllowedError) { + return jsonError( + 400, + "invalid_request_error", + `Model "${body.model}" resolves to unsupported model "${err.model}"`, + ) + } + if (err instanceof InvalidServiceTierError) return invalidServiceTierResponse(err) + throw err + } + const localInputTokens = logVerbose() ? countTokens(body) : undefined + const translatedInputTokens = logVerbose() ? countTranslatedTokens(translated) : undefined + if (state) { + state.lastMessage = { + reqId: ctx.reqId, + model: body.model, + messageCount, + toolCount, + localInputTokens, + translatedInputTokens, + } + } + log.debug("translated request", { + requestedModel: body.model, + resolvedModel, + inputItems: translated.input.length, + tools: translated.tools?.length ?? 0, + hasInstructions: !!translated.instructions, + requestedMaxTokens: body.max_tokens, + hasContextManagement: contextManagement !== undefined, + promptCacheKey: translated.prompt_cache_key, + }) + if (logVerbose()) log.debug("translated request body", { body: translated }) + if (logVerbose()) { + log.info("compaction telemetry", { + phase: "translated_request", + requestedModel: body.model, + resolvedModel, + messageCount, + toolCount, + localInputTokens, + translatedInputTokens, + inputItems: translated.input.length, + translatedToolCount: translated.tools?.length ?? 0, + hasInstructions: !!translated.instructions, + requestedMaxTokens: body.max_tokens, + hasContextManagement: contextManagement !== undefined, + contextManagement, + previousCountReqId: state?.lastCount?.reqId, + previousCountModel: state?.lastCount?.model, + previousCountTokens: state?.lastCount?.tokens, + previousCountMessageCount: state?.lastCount?.messageCount, + previousCountToolCount: state?.lastCount?.toolCount, + }) + } + + let upstream + try { + upstream = await postCodex(translated, ctx) + } catch (err) { + if (err instanceof CodexError) { + log.warn("codex error", { status: err.status, detail: err.detail }) + if (err.status === 429) { + const headers: Record = { "content-type": "application/json" } + if (err.meta?.retryAfter) headers["retry-after"] = err.meta.retryAfter + return new Response( + JSON.stringify({ + type: "error", + error: { type: "rate_limit_error", message: err.detail || err.message }, + }), + { status: 429, headers }, + ) + } + const type = + err.status === 401 || err.status === 403 ? "authentication_error" : "api_error" + return jsonError(err.status, type, err.detail || err.message) + } + throw err + } + + if (wantStream) { + const { serverModel, serverReasoningIncluded } = upstreamHeaderSnapshot(upstream.headers) + const stream = translateStream(upstream.body, { + messageId, + model: body.model, + log: ctx.childLogger("codex.stream"), + onFinish: logVerbose() + ? (finish) => { + const mappedUsage = finish.usage ? mapUsageToAnthropic(finish.usage) : undefined + log.info("compaction telemetry", { + phase: "upstream_finish", + mode: "stream", + requestedModel: body.model, + resolvedModel, + serverModel, + serverReasoningIncluded, + messageCount, + toolCount, + localInputTokens, + translatedInputTokens, + requestedMaxTokens: body.max_tokens, + hasContextManagement: contextManagement !== undefined, + contextManagement, + upstreamInputTokens: finish.usage?.input_tokens ?? 0, + upstreamOutputTokens: finish.usage?.output_tokens ?? 0, + upstreamCachedInputTokens: finish.usage?.input_tokens_details?.cached_tokens ?? 0, + upstreamReasoningTokens: + finish.usage?.output_tokens_details?.reasoning_tokens ?? 0, + mappedInputTokens: mappedUsage?.input_tokens ?? 0, + mappedOutputTokens: mappedUsage?.output_tokens ?? 0, + mappedCachedInputTokens: mappedUsage?.cache_read_input_tokens ?? 0, + mappedContextWindowTokens: mappedUsage ? usageWindowTokens(mappedUsage) : 0, + stopReason: finish.stopReason, + }) + } + : undefined, + }) + return new Response(stream, { + status: 200, + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }, + }) + } + + try { + const result = await accumulateResponse(upstream.body, { messageId, model: body.model, log: ctx.childLogger("codex.accumulate") }) + if (logVerbose()) { + const { serverModel, serverReasoningIncluded } = upstreamHeaderSnapshot(upstream.headers) + log.info("compaction telemetry", { + phase: "upstream_finish", + mode: "non_stream", + requestedModel: body.model, + resolvedModel, + serverModel, + serverReasoningIncluded, + messageCount, + toolCount, + localInputTokens, + translatedInputTokens, + requestedMaxTokens: body.max_tokens, + hasContextManagement: contextManagement !== undefined, + contextManagement, + upstreamInputTokens: result.rawUsage?.input_tokens ?? 0, + upstreamOutputTokens: result.rawUsage?.output_tokens ?? 0, + upstreamCachedInputTokens: result.rawUsage?.input_tokens_details?.cached_tokens ?? 0, + upstreamReasoningTokens: result.rawUsage?.output_tokens_details?.reasoning_tokens ?? 0, + mappedInputTokens: result.response.usage.input_tokens, + mappedOutputTokens: result.response.usage.output_tokens, + mappedCachedInputTokens: result.response.usage.cache_read_input_tokens, + mappedContextWindowTokens: usageWindowTokens(result.response.usage), + stopReason: result.response.stop_reason, + }) + } + return new Response(JSON.stringify(result.response), { + headers: { "content-type": "application/json" }, + }) + } catch (err) { + if (err instanceof UpstreamStreamError) { + log.warn("upstream stream error (non-streaming)", { + kind: err.kind, + message: err.message, + }) + if (err.kind === "rate_limit") { + const headers: Record = { "content-type": "application/json" } + if (err.retryAfterSeconds) headers["retry-after"] = String(err.retryAfterSeconds) + return new Response( + JSON.stringify({ + type: "error", + error: { type: "rate_limit_error", message: err.message }, + }), + { status: 429, headers }, + ) + } + return jsonError(502, "api_error", err.message) + } + throw err + } +} + +const cli: CliHandlers = { + async login() { + const tokens = await runBrowserLogin() + const saved = await persistInitialTokens(tokens) + console.log(`Auth saved in ${authPath()}`) + if (saved.accountId) console.log(`Account: ${saved.accountId}`) + }, + async device() { + const tokens = await runDeviceLogin() + const saved = await persistInitialTokens(tokens) + console.log(`Auth saved in ${authPath()}`) + if (saved.accountId) console.log(`Account: ${saved.accountId}`) + }, + async status() { + const auth = await loadAuth() + if (!auth) { + console.log("Not authenticated") + process.exit(1) + } + const ms = auth.expires - Date.now() + console.log(`Account: ${auth.accountId ?? "(none)"}`) + console.log(`Expires: ${new Date(auth.expires).toISOString()} (in ${Math.floor(ms / 1000)}s)`) + console.log(`Storage: ${authPath()}`) + }, + async logout() { + await clearAuth() + console.log("Logged out") + }, +} + +export const codexProvider: Provider = { + name: "codex", + supportedModels: new Set([...ALLOWED_MODELS, ...FAST_MODEL_ALIASES]), + handleMessages, + handleCountTokens, + cli, +} diff --git a/src/translate/accumulate.ts b/src/providers/codex/translate/accumulate.ts similarity index 76% rename from src/translate/accumulate.ts rename to src/providers/codex/translate/accumulate.ts index 9e1ae95..712e606 100644 --- a/src/translate/accumulate.ts +++ b/src/providers/codex/translate/accumulate.ts @@ -1,4 +1,6 @@ import { mapUsageToAnthropic, reduceUpstream } from "./reducer.ts" +import type { CodexUsage } from "./reducer.ts" +import type { Logger } from "../../../log.ts" export { UpstreamStreamError } from "./reducer.ts" @@ -21,6 +23,11 @@ export interface AnthropicNonStreamResponse { } } +export interface AccumulatedResponse { + response: AnthropicNonStreamResponse + rawUsage?: CodexUsage +} + /** * Drive the Codex SSE stream to completion through the shared reducer * and fold the ReducerEvents into a single Anthropic non-streaming @@ -29,8 +36,8 @@ export interface AnthropicNonStreamResponse { */ export async function accumulateResponse( upstream: ReadableStream, - opts: { messageId: string; model: string }, -): Promise { + opts: { messageId: string; model: string; log: Logger }, +): Promise { type Block = | { kind: "text"; text: string } | { kind: "tool"; id: string; name: string; args: string } @@ -39,8 +46,9 @@ export async function accumulateResponse( const blocks = new Map() let stopReason: AnthropicNonStreamResponse["stop_reason"] = null let usage: ReturnType | undefined + let rawUsage: CodexUsage | undefined - for await (const e of reduceUpstream(upstream)) { + for await (const e of reduceUpstream(upstream, opts.log)) { switch (e.kind) { case "text-start": blocks.set(e.index, { kind: "text", text: "" }) @@ -65,6 +73,7 @@ export async function accumulateResponse( break case "finish": stopReason = e.stopReason + rawUsage = e.usage usage = mapUsageToAnthropic(e.usage) break } @@ -88,18 +97,21 @@ export async function accumulateResponse( } return { - id: opts.messageId, - type: "message", - role: "assistant", - model: opts.model, - content, - stop_reason: stopReason, - stop_sequence: null, - usage: usage ?? { - input_tokens: 0, - output_tokens: 0, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, + rawUsage, + response: { + id: opts.messageId, + type: "message", + role: "assistant", + model: opts.model, + content, + stop_reason: stopReason, + stop_sequence: null, + usage: usage ?? { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, }, } } diff --git a/src/providers/codex/translate/model-allowlist.test.ts b/src/providers/codex/translate/model-allowlist.test.ts new file mode 100644 index 0000000..2137d69 --- /dev/null +++ b/src/providers/codex/translate/model-allowlist.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, afterEach } from "bun:test" +import { mkdtempSync, writeFileSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { resolveModel, resolveModelRequest } from "./model-allowlist.ts" +import { loadConfig } from "../../../config.ts" + +afterEach(() => { + loadConfig({ forceReload: true }) +}) + +describe("resolveModel", () => { + it("returns alias when no override is set", () => { + loadConfig({ env: {}, forceReload: true }) + expect(resolveModel("sonnet")).toBe("gpt-5.4") + }) + + it("env CCP_CODEX_MODEL takes precedence", () => { + loadConfig({ env: { CCP_CODEX_MODEL: "gpt-5.2" }, forceReload: true }) + expect(resolveModel("sonnet")).toBe("gpt-5.2") + }) + + it("config.json codex.model overrides aliases", () => { + const dir = mkdtempSync(join(tmpdir(), "ccp-model-")) + const path = join(dir, "config.json") + writeFileSync(path, JSON.stringify({ codex: { model: "gpt-5.5" } })) + try { + loadConfig({ configPath: path, env: {}, forceReload: true }) + expect(resolveModel("sonnet")).toBe("gpt-5.5") + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it("empty CCP_CODEX_MODEL env is treated as unset (no regression)", () => { + const dir = mkdtempSync(join(tmpdir(), "ccp-model-")) + const path = join(dir, "config.json") + writeFileSync(path, JSON.stringify({ codex: { model: "gpt-5.5" } })) + try { + loadConfig({ + configPath: path, + env: { CCP_CODEX_MODEL: "" }, + forceReload: true, + }) + // Empty env should fall through to file value + expect(resolveModel("sonnet")).toBe("gpt-5.5") + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it("empty env and no file value falls through to alias", () => { + loadConfig({ env: { CCP_CODEX_MODEL: "" }, forceReload: true }) + expect(resolveModel("sonnet")).toBe("gpt-5.4") + }) + + it("detects fast model aliases", () => { + loadConfig({ env: {}, forceReload: true }) + expect(resolveModelRequest("gpt-5.4-fast")).toEqual({ + model: "gpt-5.4", + serviceTier: "priority", + }) + }) + + it("model override preserves fast model alias service tier", () => { + loadConfig({ env: { CCP_CODEX_MODEL: "gpt-5.5" }, forceReload: true }) + expect(resolveModelRequest("gpt-5.4-fast")).toEqual({ + model: "gpt-5.5", + serviceTier: "priority", + }) + }) + + it("does not strip unsupported fast-looking model names", () => { + loadConfig({ env: {}, forceReload: true }) + expect(resolveModelRequest("gpt-4.1-fast")).toEqual({ model: "gpt-4.1-fast" }) + }) +}) diff --git a/src/providers/codex/translate/model-allowlist.ts b/src/providers/codex/translate/model-allowlist.ts new file mode 100644 index 0000000..e045763 --- /dev/null +++ b/src/providers/codex/translate/model-allowlist.ts @@ -0,0 +1,62 @@ +import { codexModel } from "../../../config.ts" +import type { ServiceTier } from "./request.ts" + +export const ALLOWED_MODELS = new Set([ + "gpt-5.2", + "gpt-5.3-codex", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.5", +]) + +export const FAST_MODEL_ALIASES = new Set( + Array.from(ALLOWED_MODELS, (model) => `${model}-fast`), +) + +export const MODEL_ALIASES = new Map([ + ["haiku", "gpt-5.4-mini"], + ["claude-haiku-4-5", "gpt-5.4-mini"], + ["claude-haiku-4-5-20251001", "gpt-5.4-mini"], + ["sonnet", "gpt-5.4"], + ["claude-sonnet-4-6", "gpt-5.4"], + ["opus", "gpt-5.5"], + ["claude-opus-4-7", "gpt-5.5"], +]) + +export interface ResolvedModel { + model: string + serviceTier?: ServiceTier +} + +export function resolveModel(model: string): string { + return resolveModelRequest(model).model +} + +export function resolveModelRequest(model: string): ResolvedModel { + // CCP_CODEX_MODEL (env) or codex.model (config.json) overrides the model + // so that regardless of whatever model is requested by the harness, the + // provided model is always used. Empty values fall through to alias + // resolution. + const alias = MODEL_ALIASES.get(model) ?? model + const isFastAlias = FAST_MODEL_ALIASES.has(alias) + const modelWithoutTier = isFastAlias ? alias.slice(0, -"-fast".length) : alias + const override = codexModel() + + return { + model: override ?? modelWithoutTier, + ...(isFastAlias ? { serviceTier: "priority" } : {}), + } +} + +export function assertAllowedModel(model: string): void { + if (!ALLOWED_MODELS.has(model)) { + throw new ModelNotAllowedError(model) + } +} + +export class ModelNotAllowedError extends Error { + constructor(public model: string) { + super(`Model not allowed: ${model}`) + this.name = "ModelNotAllowedError" + } +} diff --git a/src/translate/reducer.ts b/src/providers/codex/translate/reducer.ts similarity index 76% rename from src/translate/reducer.ts rename to src/providers/codex/translate/reducer.ts index 67ee75e..e151531 100644 --- a/src/translate/reducer.ts +++ b/src/providers/codex/translate/reducer.ts @@ -1,8 +1,6 @@ -import { parseSseStream } from "./sse.ts" -import { createLogger } from "../log.ts" - -const log = createLogger("translate.reducer") -const VERBOSE = !!process.env.CCP_LOG_VERBOSE +import { parseSseStream } from "../../../sse.ts" +import type { Logger } from "../../../log.ts" +import { logVerbose } from "../../../config.ts" export class UpstreamStreamError extends Error { constructor( @@ -45,9 +43,29 @@ interface ToolState { name: string argsAccum: string hadDelta: boolean + bufferUntilDone: boolean + emittedArgs: boolean } type BlockState = TextState | ToolState +function shouldBufferToolArgs(name: string): boolean { + return name === "Read" +} + +function sanitizeToolArgs(name: string, args: string): string { + if (name !== "Read" || !args) return args + try { + const parsed = JSON.parse(args) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return args + if (!("pages" in parsed) || parsed.pages !== "") return args + const sanitized = { ...parsed } + delete sanitized.pages + return JSON.stringify(sanitized) + } catch { + return args + } +} + /** * Single source of truth for translating Codex Responses SSE into a * stream of typed, downstream-agnostic ReducerEvents. Both the streaming @@ -59,6 +77,7 @@ type BlockState = TextState | ToolState */ export async function* reduceUpstream( upstream: ReadableStream, + log: Logger, ): AsyncGenerator { const blocksByOutputIndex = new Map() const itemIdToOutputIndex = new Map() @@ -72,12 +91,13 @@ export async function* reduceUpstream( let p: any try { p = JSON.parse(evt.data) - } catch { + } catch (err) { + log.warn("upstream sse: invalid json", { err: String(err), preview: evt.data.slice(0, 200) }) continue } const t: string = p.type || evt.event || "" - if (VERBOSE) log.debug("upstream event", { type: t, output_index: p.output_index, item_id: p.item_id }) + if (logVerbose()) log.debug("upstream event", { type: t, output_index: p.output_index, item_id: p.item_id }) if (t === "codex.rate_limits") { if (p.rate_limits?.limit_reached) { @@ -116,10 +136,13 @@ export async function* reduceUpstream( name: item.name, argsAccum: "", hadDelta: false, + bufferUntilDone: shouldBufferToolArgs(item.name), + emittedArgs: false, }) yield { kind: "tool-start", index: idx, id: item.call_id, name: item.name } continue } + continue } @@ -147,7 +170,10 @@ export async function* reduceUpstream( if (!delta) continue state.argsAccum += delta state.hadDelta = true - yield { kind: "tool-delta", index: state.index, partialJson: delta } + if (!state.bufferUntilDone) { + state.emittedArgs = true + yield { kind: "tool-delta", index: state.index, partialJson: delta } + } continue } @@ -172,14 +198,17 @@ export async function* reduceUpstream( continue } if (item.type === "reasoning") continue - if (state.kind === "tool" && !state.hadDelta) { + if (state.kind === "tool") { const finalArgs = (typeof item.arguments === "string" && item.arguments.length ? item.arguments : state.argsAccum) || "" if (finalArgs.length) { - state.argsAccum = finalArgs - yield { kind: "tool-delta", index: state.index, partialJson: finalArgs } + state.argsAccum = sanitizeToolArgs(state.name, finalArgs) + if (state.bufferUntilDone || !state.emittedArgs) { + state.emittedArgs = true + yield { kind: "tool-delta", index: state.index, partialJson: state.argsAccum } + } } } if (state.kind === "text") { @@ -222,10 +251,17 @@ export function mapUsageToAnthropic(u: CodexUsage | undefined): { cache_creation_input_tokens: number cache_read_input_tokens: number } { + const cachedTokens = u?.input_tokens_details?.cached_tokens ?? 0 + const totalInputTokens = u?.input_tokens ?? 0 return { - input_tokens: u?.input_tokens ?? 0, + // OpenAI-style usage reports cached prompt tokens inside input_tokens. + // Anthropic-style usage reports cache reads separately, and Claude Code + // sums input_tokens + cache_read_input_tokens when deciding context size. + // Subtract cached reads here so the downstream total matches the real + // prompt window instead of double-counting cached context. + input_tokens: Math.max(0, totalInputTokens - cachedTokens), output_tokens: u?.output_tokens ?? 0, cache_creation_input_tokens: 0, - cache_read_input_tokens: u?.input_tokens_details?.cached_tokens ?? 0, + cache_read_input_tokens: cachedTokens, } } diff --git a/src/providers/codex/translate/request.test.ts b/src/providers/codex/translate/request.test.ts new file mode 100644 index 0000000..efb4f96 --- /dev/null +++ b/src/providers/codex/translate/request.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it } from "bun:test" +import { loadConfig } from "../../../config.ts" +import type { AnthropicRequest } from "../../../anthropic/schema.ts" +import { InvalidServiceTierError, translateRequest } from "./request.ts" +const baseRequest: AnthropicRequest = { + model: "claude-sonnet-4-6", + messages: [{ role: "user", content: "hello" }], +} + +afterEach(() => { + loadConfig({ forceReload: true }) +}) + +describe("translateRequest", () => { + it("omits reasoning include when reasoning is not enabled", () => { + const translated = translateRequest(baseRequest) + + expect(translated.reasoning).toBeUndefined() + expect(translated.include).toBeUndefined() + }) + + it("includes encrypted reasoning content when reasoning is enabled", () => { + const translated = translateRequest({ + ...baseRequest, + output_config: { effort: "medium" }, + }) + + expect(translated.reasoning).toEqual({ effort: "medium" }) + expect(translated.include).toEqual(["reasoning.encrypted_content"]) + }) + + it("normalizes fast service tier to upstream priority", () => { + loadConfig({ env: { CCP_CODEX_SERVICE_TIER: "fast" }, forceReload: true }) + + const translated = translateRequest(baseRequest) + + expect(translated.service_tier).toBe("priority") + }) + + it("passes flex service tier through", () => { + loadConfig({ env: { CCP_CODEX_SERVICE_TIER: "flex" }, forceReload: true }) + + const translated = translateRequest(baseRequest) + + expect(translated.service_tier).toBe("flex") + }) + + it("uses model service tier when no override is set", () => { + loadConfig({ env: {}, forceReload: true }) + + const translated = translateRequest(baseRequest, { serviceTier: "priority" }) + + expect(translated.service_tier).toBe("priority") + }) + + it("service tier override takes precedence over model service tier", () => { + loadConfig({ env: { CCP_CODEX_SERVICE_TIER: "flex" }, forceReload: true }) + + const translated = translateRequest(baseRequest, { serviceTier: "priority" }) + + expect(translated.service_tier).toBe("flex") + }) + + it("rejects invalid service tier overrides", () => { + loadConfig({ env: { CCP_CODEX_SERVICE_TIER: "standard" }, forceReload: true }) + + expect(() => translateRequest(baseRequest)).toThrow(InvalidServiceTierError) + expect(() => translateRequest(baseRequest)).toThrow('Invalid service tier override: "standard"') + }) + + it("returns only the expected top-level upstream request fields", () => { + const translated = translateRequest({ + ...baseRequest, + system: "follow instructions", + tools: [ + { + name: "lookup_weather", + description: "Look up the weather", + input_schema: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, + ], + tool_choice: { type: "tool", name: "lookup_weather" }, + output_config: { + effort: "high", + format: { + type: "json_schema", + name: "weather_response", + schema: { + type: "object", + properties: { forecast: { type: "string" } }, + required: ["forecast"], + }, + }, + }, + }) + + expect(Object.keys(translated).sort()).toEqual([ + "include", + "input", + "instructions", + "model", + "parallel_tool_calls", + "reasoning", + "store", + "stream", + "text", + "tool_choice", + "tools", + ]) + }) +}) diff --git a/src/translate/request.ts b/src/providers/codex/translate/request.ts similarity index 67% rename from src/translate/request.ts rename to src/providers/codex/translate/request.ts index 326455e..74ddf08 100644 --- a/src/translate/request.ts +++ b/src/providers/codex/translate/request.ts @@ -5,8 +5,23 @@ import type { AnthropicRequest, AnthropicTextBlock, AnthropicTool, -} from "../anthropic/schema.ts" +} from "../../../anthropic/schema.ts" +import { codexEffort, codexServiceTier } from "../../../config.ts" +export type Effort = "none" | "low" | "medium" | "high" | "xhigh" +export type ServiceTier = "priority" | "flex" + +export class InvalidServiceTierError extends Error { + constructor(public serviceTier: string) { + super( + `Invalid service tier override: "${serviceTier}". Must be one of: ${Array.from(VALID_SERVICE_TIERS).join(", ")}`, + ) + this.name = "InvalidServiceTierError" + } +} + +// Keep this aligned to the upstream Codex ResponsesApiRequest field set. +// Do not add plausible-looking top-level fields without source support or a confirmed live test. export interface ResponsesRequest { model: string instructions?: string @@ -18,11 +33,12 @@ export interface ResponsesRequest { | "required" | { type: "function"; name: string } parallel_tool_calls?: boolean + reasoning?: { effort?: Effort; summary?: unknown } store: false stream: true include?: string[] + service_tier?: ServiceTier prompt_cache_key?: string - reasoning?: { effort?: "low" | "medium" | "high" } text?: { verbosity?: "low" | "medium" | "high" format?: @@ -30,6 +46,7 @@ export interface ResponsesRequest { | { type: "json_object" } | { type: "json_schema"; name: string; schema: unknown; strict?: boolean } } + client_metadata?: Record } export type ResponsesInputItem = @@ -65,6 +82,54 @@ export interface ResponsesTool { export interface TranslateOptions { sessionId?: string + serviceTier?: ServiceTier +} + +const VALID_EFFORTS = new Set(["none", "low", "medium", "high", "xhigh"]) + +const ANTHROPIC_EFFORTS = new Set(["low", "medium", "high", "max"]) + +const VALID_SERVICE_TIERS = new Set(["fast", "priority", "flex"]) + +function assertValidEffort(effort: unknown): void { + if (effort !== undefined && !ANTHROPIC_EFFORTS.has(effort as string)) { + throw new Error( + `Invalid output_config.effort: "${effort}". Must be one of: ${Array.from(ANTHROPIC_EFFORTS).join(", ")}`, + ) + } +} + +function toCodexEffort( + effort: NonNullable["effort"], +): Effort | undefined { + if (effort === "max") return "xhigh" + return effort +} + +function resolveEffort(effort?: Effort): Effort | undefined { + const override = codexEffort() + if (override === undefined) { + return effort + } + if (!VALID_EFFORTS.has(override as Effort)) { + throw new Error( + `Invalid effort override: "${override}". Must be one of: ${Array.from(VALID_EFFORTS).join(", ")}`, + ) + } + return override as Effort +} + +function normalizeServiceTier(tier: string): ServiceTier { + if (!VALID_SERVICE_TIERS.has(tier)) { + throw new InvalidServiceTierError(tier) + } + return tier === "flex" ? "flex" : "priority" +} + +function resolveServiceTier(modelServiceTier?: ServiceTier): ServiceTier | undefined { + const tier = codexServiceTier() + if (tier === undefined) return modelServiceTier + return normalizeServiceTier(tier) } export function translateRequest(req: AnthropicRequest, opts: TranslateOptions = {}): ResponsesRequest { @@ -88,7 +153,6 @@ export function translateRequest(req: AnthropicRequest, opts: TranslateOptions = input, store: false, stream: true, - include: ["reasoning.encrypted_content"], parallel_tool_calls: true, tool_choice: mapToolChoice(req.tool_choice), text, @@ -96,8 +160,14 @@ export function translateRequest(req: AnthropicRequest, opts: TranslateOptions = if (instructions) out.instructions = instructions if (tools && tools.length) out.tools = tools if (opts.sessionId) out.prompt_cache_key = opts.sessionId - const effort = req.output_config?.effort - if (effort) out.reasoning = { effort } + const serviceTier = resolveServiceTier(opts.serviceTier) + if (serviceTier) out.service_tier = serviceTier + assertValidEffort(req.output_config?.effort) + const effort = resolveEffort(toCodexEffort(req.output_config?.effort)) + if (effort) { + out.reasoning = { effort } + out.include = ["reasoning.encrypted_content"] + } return out } @@ -117,7 +187,7 @@ function mapToolChoice( } } -function buildInstructions(system: AnthropicRequest["system"]): string | undefined { +export function buildInstructions(system: AnthropicRequest["system"]): string | undefined { if (!system) return undefined const blocks: AnthropicTextBlock[] = typeof system === "string" ? [{ type: "text", text: system }] : system @@ -181,7 +251,7 @@ function buildInput(messages: AnthropicMessage[]): ResponsesInputItem[] { return out } -function normalizeContent(content: AnthropicMessage["content"]): AnthropicContentBlock[] { +export function normalizeContent(content: AnthropicMessage["content"]): AnthropicContentBlock[] { if (typeof content === "string") return [{ type: "text", text: content }] return content } @@ -191,7 +261,7 @@ function imageToUrl(block: Extract): s return `data:${block.source.media_type};base64,${block.source.data}` } -function toolResultToString( +export function toolResultToString( content: string | Array, ): string { if (typeof content === "string") return content diff --git a/src/translate/stream.ts b/src/providers/codex/translate/stream.ts similarity index 77% rename from src/translate/stream.ts rename to src/providers/codex/translate/stream.ts index a551282..ba29961 100644 --- a/src/translate/stream.ts +++ b/src/providers/codex/translate/stream.ts @@ -1,9 +1,7 @@ -import { encodeSseEvent } from "./sse.ts" -import { createLogger } from "../log.ts" +import { encodeSseEvent } from "../../../sse.ts" +import type { Logger } from "../../../log.ts" import { mapUsageToAnthropic, reduceUpstream, UpstreamStreamError } from "./reducer.ts" -const log = createLogger("translate.stream") - /** * Translate a Codex Responses SSE stream into Anthropic SSE events. * Returns a ReadableStream ready to pipe to the client. @@ -14,7 +12,12 @@ const log = createLogger("translate.stream") */ export function translateStream( upstream: ReadableStream, - opts: { messageId: string; model: string }, + opts: { + messageId: string + model: string + log: Logger + onFinish?: (finish: { stopReason: "end_turn" | "tool_use" | "max_tokens"; usage?: Parameters[0] }) => void + }, ): ReadableStream { const encoder = new TextEncoder() return new ReadableStream({ @@ -22,6 +25,7 @@ export function translateStream( const emit = (event: string, data: unknown) => { controller.enqueue(encoder.encode(encodeSseEvent(event, data))) } + const activeTools = new Map() let messageStarted = false const ensureMessageStart = () => { if (messageStarted) return @@ -48,7 +52,7 @@ export function translateStream( } try { - for await (const e of reduceUpstream(upstream)) { + for await (const e of reduceUpstream(upstream, opts.log)) { switch (e.kind) { case "text-start": ensureMessageStart() @@ -69,6 +73,7 @@ export function translateStream( emit("content_block_stop", { type: "content_block_stop", index: e.index }) break case "tool-start": + activeTools.set(e.index, { id: e.id, name: e.name }) ensureMessageStart() emit("content_block_start", { type: "content_block_start", @@ -89,10 +94,12 @@ export function translateStream( }) break case "tool-stop": + activeTools.delete(e.index) emit("content_block_stop", { type: "content_block_stop", index: e.index }) break case "finish": ensureMessageStart() + opts.onFinish?.({ stopReason: e.stopReason, usage: e.usage }) emit("message_delta", { type: "message_delta", delta: { stop_reason: e.stopReason, stop_sequence: null }, @@ -103,8 +110,15 @@ export function translateStream( } } } catch (err) { + const activeToolNames = Array.from(activeTools.values(), (tool) => tool.name) + const activeToolCalls = Array.from(activeTools.values()) if (err instanceof UpstreamStreamError) { - log.warn("upstream stream error", { kind: err.kind, message: err.message }) + opts.log.warn("upstream stream error", { + kind: err.kind, + message: err.message, + activeToolNames, + activeToolCalls, + }) ensureMessageStart() emit("error", { type: "error", @@ -114,7 +128,11 @@ export function translateStream( }, }) } else { - log.error("stream translation error", { err: String(err) }) + opts.log.error("stream translation error", { + err: String(err), + activeToolNames, + activeToolCalls, + }) ensureMessageStart() emit("error", { type: "error", diff --git a/src/providers/kimi/auth/constants.ts b/src/providers/kimi/auth/constants.ts new file mode 100644 index 0000000..58fbf9a --- /dev/null +++ b/src/providers/kimi/auth/constants.ts @@ -0,0 +1,15 @@ +import { kimiBaseUrl, kimiOauthHost } from "../../../config.ts" + +export const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098" +// Read lazily so config.json values apply. CCP_KIMI_OAUTH_HOST / CCP_KIMI_BASE_URL +// env vars still work (env wins over file in the getter). +export function oauthHost(): string { + return kimiOauthHost() +} +export function apiBaseUrl(): string { + return kimiBaseUrl() +} +// Masquerade as kimi-cli so the server accepts us. Bumping this in lockstep +// with kimi-cli releases may become necessary. +export const KIMI_CLI_VERSION = "1.37.0" +export const REFRESH_MARGIN_MS = 5 * 60 * 1000 diff --git a/src/providers/kimi/auth/device-id.ts b/src/providers/kimi/auth/device-id.ts new file mode 100644 index 0000000..570e9d9 --- /dev/null +++ b/src/providers/kimi/auth/device-id.ts @@ -0,0 +1,30 @@ +import { mkdir, readFile, writeFile, chmod } from "node:fs/promises" +import { dirname, join } from "node:path" +import { configDir, legacyConfigDir } from "../../../paths.ts" + +function path(): string { + return join(configDir(), "kimi", "device_id") +} +function legacyPath(): string { + return join(legacyConfigDir(), "kimi", "device_id") +} + +export async function getDeviceId(): Promise { + for (const candidate of [path(), legacyPath()]) { + try { + const raw = await readFile(candidate, "utf8") + const trimmed = raw.trim() + if (trimmed) return trimmed + } catch (err: any) { + if (err?.code !== "ENOENT") throw err + } + } + const id = crypto.randomUUID().replace(/-/g, "") + const target = path() + await mkdir(dirname(target), { recursive: true }) + await writeFile(target, id, { encoding: "utf8", mode: 0o600 }) + try { + await chmod(target, 0o600) + } catch {} + return id +} diff --git a/src/providers/kimi/auth/headers.ts b/src/providers/kimi/auth/headers.ts new file mode 100644 index 0000000..21ecb53 --- /dev/null +++ b/src/providers/kimi/auth/headers.ts @@ -0,0 +1,31 @@ +import { hostname, release, arch, platform } from "node:os" +import { KIMI_CLI_VERSION } from "./constants.ts" +import { getDeviceId } from "./device-id.ts" +import { kimiUserAgent } from "../../../config.ts" + +function deviceModel(): string { + const a = arch() + const p = platform() + if (p === "darwin") return `macOS ${release()} ${a}`.trim() + if (p === "win32") return `Windows ${release()} ${a}`.trim() + return `${p} ${release()} ${a}`.trim() +} + +function asciiOnly(value: string, fallback = "unknown"): string { + // Strip non-ASCII, which would be rejected as an HTTP header value. + const cleaned = value.replace(/[^\x20-\x7e]/g, "").trim() + return cleaned || fallback +} + +export async function commonHeaders(): Promise> { + const deviceId = await getDeviceId() + return { + "X-Msh-Platform": "kimi_cli", + "X-Msh-Version": KIMI_CLI_VERSION, + "X-Msh-Device-Name": asciiOnly(hostname()), + "X-Msh-Device-Model": asciiOnly(deviceModel()), + "X-Msh-Os-Version": asciiOnly(release()), + "X-Msh-Device-Id": deviceId, + "User-Agent": kimiUserAgent(`KimiCLI/${KIMI_CLI_VERSION}`), + } +} diff --git a/src/providers/kimi/auth/jwt.ts b/src/providers/kimi/auth/jwt.ts new file mode 100644 index 0000000..c606998 --- /dev/null +++ b/src/providers/kimi/auth/jwt.ts @@ -0,0 +1,23 @@ +interface KimiClaims { + user_id?: string + device_id?: string + scope?: string + exp?: number + iat?: number +} + +export function decodeClaims(jwt: string): KimiClaims | undefined { + const parts = jwt.split(".") + if (parts.length < 2) return undefined + try { + const payload = parts[1]!.replace(/-/g, "+").replace(/_/g, "/") + const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4) + return JSON.parse(Buffer.from(padded, "base64").toString("utf8")) as KimiClaims + } catch { + return undefined + } +} + +export function extractUserId(accessToken: string): string | undefined { + return decodeClaims(accessToken)?.user_id +} diff --git a/src/providers/kimi/auth/login.ts b/src/providers/kimi/auth/login.ts new file mode 100644 index 0000000..43c018e --- /dev/null +++ b/src/providers/kimi/auth/login.ts @@ -0,0 +1,75 @@ +import { CLIENT_ID, oauthHost } from "./constants.ts" +import { commonHeaders } from "./headers.ts" + +export interface TokenResponse { + access_token: string + refresh_token: string + expires_in: number + scope: string + token_type: string +} + +interface DeviceAuth { + user_code: string + device_code: string + verification_uri?: string + verification_uri_complete: string + expires_in?: number + interval: number +} + +const GRANT_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code" +const POLL_SAFETY_MARGIN_MS = 500 + +export async function runDeviceLogin(): Promise { + const headers = await commonHeaders() + + const initResp = await fetch(`${oauthHost()}/api/oauth/device_authorization`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ client_id: CLIENT_ID }).toString(), + }) + if (!initResp.ok) { + throw new Error(`Device authorization failed: ${initResp.status} ${await initResp.text()}`) + } + const auth = (await initResp.json()) as DeviceAuth + const intervalMs = Math.max(auth.interval || 5, 1) * 1000 + + console.log(`\nVisit: ${auth.verification_uri_complete}`) + console.log(`Code: ${auth.user_code}\n`) + + while (true) { + const resp = await fetch(`${oauthHost()}/api/oauth/token`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + device_code: auth.device_code, + grant_type: GRANT_DEVICE_CODE, + }).toString(), + }) + + if (resp.status === 200) { + return (await resp.json()) as TokenResponse + } + + // Pending / slow_down / expired come back as non-200 with a JSON error payload. + const body = (await resp.json().catch(() => ({}))) as { + error?: string + error_description?: string + } + const error = body.error ?? `http_${resp.status}` + + if (error === "expired_token") { + throw new Error("Device code expired. Run login again.") + } + if (error !== "authorization_pending" && error !== "slow_down") { + throw new Error( + `Device token poll failed (${resp.status}): ${error}${ + body.error_description ? ` — ${body.error_description}` : "" + }`, + ) + } + await new Promise((r) => setTimeout(r, intervalMs + POLL_SAFETY_MARGIN_MS)) + } +} diff --git a/src/providers/kimi/auth/manager.ts b/src/providers/kimi/auth/manager.ts new file mode 100644 index 0000000..dff90e4 --- /dev/null +++ b/src/providers/kimi/auth/manager.ts @@ -0,0 +1,148 @@ +import { CLIENT_ID, oauthHost, REFRESH_MARGIN_MS } from "./constants.ts" +import { commonHeaders } from "./headers.ts" +import { extractUserId } from "./jwt.ts" +import type { TokenResponse } from "./login.ts" +import { clearAuth, loadAuth, saveAuth, type StoredAuth } from "./token-store.ts" +import { createLogger } from "../../../log.ts" + +const log = createLogger("kimi.auth") + +const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]) +const MAX_REFRESH_ATTEMPTS = 3 + +function validateTokenResponse(t: unknown): asserts t is TokenResponse { + if (!t || typeof t !== "object") throw new Error("Invalid token response: not an object") + const o = t as Record + if (typeof o.access_token !== "string" || !o.access_token) + throw new Error("Invalid token response: missing access_token") + if (typeof o.refresh_token !== "string" || !o.refresh_token) + throw new Error("Invalid token response: missing refresh_token") + if (o.expires_in !== undefined && (typeof o.expires_in !== "number" || !Number.isFinite(o.expires_in) || o.expires_in <= 0)) + throw new Error("Invalid token response: bad expires_in") +} + +let cached: StoredAuth | undefined +let inflight: Promise | undefined + +export class KimiAuthUnauthorizedError extends Error { + constructor(message: string) { + super(message) + this.name = "KimiAuthUnauthorizedError" + } +} + +export async function getAuth(): Promise { + if (!cached) { + const stored = await loadAuth() + if (!stored) throw new Error("Not authenticated. Run: claude-code-proxy kimi auth login") + cached = stored + } + if (cached.expires - REFRESH_MARGIN_MS > Date.now()) { + return cached + } + if (inflight) return inflight + inflight = refreshNow(cached).finally(() => { + inflight = undefined + }) + return inflight +} + +export async function forceRefresh(): Promise { + if (!cached) { + const stored = await loadAuth() + if (!stored) throw new Error("Not authenticated") + cached = stored + } + if (inflight) return inflight + inflight = refreshNow(cached).finally(() => { + inflight = undefined + }) + return inflight +} + +export async function persistInitialTokens(tokens: TokenResponse): Promise { + validateTokenResponse(tokens) + const auth = tokensToStored(tokens) + await saveAuth(auth) + cached = auth + return auth +} + +export function resetCache(): void { + cached = undefined +} + +function tokensToStored(tokens: TokenResponse): StoredAuth { + return { + access: tokens.access_token, + refresh: tokens.refresh_token, + expires: Date.now() + (tokens.expires_in ?? 900) * 1000, + scope: tokens.scope, + userId: extractUserId(tokens.access_token), + } +} + +async function refreshNow(current: StoredAuth): Promise { + if (!current.refresh) { + throw new KimiAuthUnauthorizedError("No refresh token stored; re-authenticate") + } + const headers = await commonHeaders() + + let lastErr: unknown + for (let attempt = 0; attempt < MAX_REFRESH_ATTEMPTS; attempt++) { + let resp: Response + try { + resp = await fetch(`${oauthHost()}/api/oauth/token`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + grant_type: "refresh_token", + refresh_token: current.refresh, + }).toString(), + }) + } catch (err) { + lastErr = err + log.warn("refresh network error", { attempt, err: String(err) }) + await backoff(attempt) + continue + } + + if (resp.status === 200) { + const tokens = await resp.json() + validateTokenResponse(tokens) + const next: StoredAuth = { + ...tokensToStored(tokens), + refresh: tokens.refresh_token || current.refresh, + userId: extractUserId(tokens.access_token) || current.userId, + } + await saveAuth(next) + cached = next + return next + } + + if (resp.status === 401 || resp.status === 403) { + // Refresh token is dead; clear local state so the next login is clean. + cached = undefined + await clearAuth().catch(() => undefined) + const body = (await resp.json().catch(() => ({}))) as { error_description?: string } + throw new KimiAuthUnauthorizedError( + body.error_description || `Token refresh unauthorized (${resp.status})`, + ) + } + + if (!RETRYABLE_STATUSES.has(resp.status)) { + throw new Error(`Token refresh failed: ${resp.status}`) + } + + lastErr = new Error(`Token refresh failed: ${resp.status}`) + log.warn("refresh retryable error", { attempt, status: resp.status }) + await backoff(attempt) + } + throw new Error(`Token refresh failed after ${MAX_REFRESH_ATTEMPTS} attempts`) +} + +function backoff(attempt: number): Promise { + const ms = 2 ** attempt * 1000 + return new Promise((r) => setTimeout(r, ms)) +} diff --git a/src/providers/kimi/auth/token-store.ts b/src/providers/kimi/auth/token-store.ts new file mode 100644 index 0000000..6bae7d3 --- /dev/null +++ b/src/providers/kimi/auth/token-store.ts @@ -0,0 +1,78 @@ +import { mkdir, readFile, writeFile, unlink, rename } from "node:fs/promises" +import { dirname, join } from "node:path" +import { keychainGet, keychainSet, keychainDelete } from "../../../keychain.ts" +import { configDir, legacyConfigDir } from "../../../paths.ts" + +export interface StoredAuth { + access: string + refresh: string + expires: number + scope?: string + userId?: string +} + +function file(): string { + return join(configDir(), "kimi", "auth.json") +} +function legacyFile(): string { + return join(legacyConfigDir(), "kimi", "auth.json") +} +const KEYCHAIN_SERVICE = "claude-code-proxy.kimi" +const KEYCHAIN_ACCOUNT = "auth" + +export async function loadAuth(): Promise { + if (process.platform === "darwin") { + const raw = keychainGet(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) + if (!raw) return undefined + return JSON.parse(raw) as StoredAuth + } + + const primary = file() + try { + const raw = await readFile(primary, "utf8") + return JSON.parse(raw) as StoredAuth + } catch (err: any) { + if (err?.code !== "ENOENT") throw err + } + const legacy = legacyFile() + if (legacy === primary) return undefined + try { + const raw = await readFile(legacy, "utf8") + return JSON.parse(raw) as StoredAuth + } catch (err: any) { + if (err?.code === "ENOENT") return undefined + throw err + } +} + +export async function saveAuth(auth: StoredAuth): Promise { + if (process.platform === "darwin") { + keychainSet(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, JSON.stringify(auth)) + return + } + + const path = file() + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) + const tmp = `${path}.${process.pid}.${Date.now()}.tmp` + await writeFile(tmp, JSON.stringify(auth, null, 2), { encoding: "utf8", mode: 0o600 }) + await rename(tmp, path) +} + +export async function clearAuth(): Promise { + if (process.platform === "darwin") { + keychainDelete(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) + return + } + + for (const path of [file(), legacyFile()]) { + try { + await unlink(path) + } catch (err: any) { + if (err?.code !== "ENOENT") throw err + } + } +} + +export function authPath(): string { + return process.platform === "darwin" ? "macOS Keychain" : file() +} diff --git a/src/providers/kimi/client.ts b/src/providers/kimi/client.ts new file mode 100644 index 0000000..ca1ff0a --- /dev/null +++ b/src/providers/kimi/client.ts @@ -0,0 +1,124 @@ +import { apiBaseUrl } from "./auth/constants.ts" +import { commonHeaders } from "./auth/headers.ts" +import { forceRefresh, getAuth, KimiAuthUnauthorizedError } from "./auth/manager.ts" +import type { Logger } from "../../log.ts" +import type { RequestContext } from "../types.ts" +import type { KimiChatRequest } from "./translate/request.ts" +import { retryOn429 } from "../retry.ts" + +export interface KimiResponse { + body: ReadableStream + status: number + headers: Headers + requestStartTime: number +} + +export class KimiError extends Error { + constructor( + public status: number, + message: string, + public detail?: string, + public meta?: { retryAfter?: string }, + ) { + super(message) + this.name = "KimiError" + } +} + +export async function postKimi( + body: KimiChatRequest, + ctx: RequestContext, +): Promise { + const log = ctx.childLogger("kimi.client") + return retryOn429(() => attemptPostKimi(body, ctx, log), { + log, + signal: ctx.signal, + classify: (err) => + err instanceof KimiError && err.status === 429 + ? { retryAfter: err.meta?.retryAfter } + : undefined, + }) +} + +async function attemptPostKimi( + body: KimiChatRequest, + ctx: RequestContext, + log: Logger, +): Promise { + let auth = await getAuth() + const requestStartTime = Date.now() + let resp = await doFetch(auth.access, body, log, ctx.signal) + + if (resp.status === 401) { + log.warn("got 401, refreshing token", {}) + try { + auth = await forceRefresh() + resp = await doFetch(auth.access, body, log, ctx.signal) + } catch (err) { + if (err instanceof KimiAuthUnauthorizedError) { + throw new KimiError(401, "Unauthorized", err.message) + } + log.error("refresh after 401 failed", { err: String(err) }) + } + } + + if (resp.status === 429) { + const retryAfter = resp.headers.get("retry-after") || undefined + const text = await safeText(resp) + throw new KimiError(429, "Rate limited", text, { retryAfter }) + } + + if (!resp.ok) { + const text = await safeText(resp) + const type = resp.status === 401 || resp.status === 403 ? "Unauthorized" : "Upstream error" + throw new KimiError(resp.status, type, text) + } + + if (!resp.body) throw new KimiError(500, "Upstream returned no body") + + log.debug("upstream response", { + status: resp.status, + timeToHeadersMs: Date.now() - requestStartTime, + }) + + return { body: resp.body, status: resp.status, headers: resp.headers, requestStartTime } +} + +async function doFetch( + accessToken: string, + body: KimiChatRequest, + log: Logger, + signal?: AbortSignal, +): Promise { + const fp = await commonHeaders() + const headers = new Headers({ + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + ...fp, + }) + + const bodyJson = JSON.stringify(body) + log.debug("posting to kimi", { + url: `${apiBaseUrl()}/chat/completions`, + model: body.model, + messageCount: body.messages.length, + toolCount: body.tools?.length ?? 0, + requestBodyBytes: new TextEncoder().encode(bodyJson).length, + }) + + return fetch(`${apiBaseUrl()}/chat/completions`, { + method: "POST", + headers, + body: bodyJson, + signal, + }) +} + +async function safeText(resp: Response): Promise { + try { + return await resp.text() + } catch { + return "" + } +} diff --git a/src/providers/kimi/count-tokens.ts b/src/providers/kimi/count-tokens.ts new file mode 100644 index 0000000..5698740 --- /dev/null +++ b/src/providers/kimi/count-tokens.ts @@ -0,0 +1,82 @@ +import { encode } from "gpt-tokenizer/model/gpt-4o" +import type { AnthropicRequest } from "../../anthropic/schema.ts" +import type { KimiChatRequest } from "./translate/request.ts" +import { buildSystemMessage, normalizeContent, toolResultToString } from "./translate/request.ts" + +const IMAGE_TOKEN_ESTIMATE = 2000 + +// Approximate — Kimi's tokenizer isn't gpt-tokenizer, but Claude Code's +// compaction logic only needs a monotonic estimate, not an exact count. +export function countTokens(req: AnthropicRequest): number { + let total = 0 + const system = buildSystemMessage(req.system) + if (system) total += encode(system).length + + for (const msg of req.messages) { + const blocks = normalizeContent(msg.content) + for (const block of blocks) { + if (block.type === "text") { + total += encode(block.text).length + } else if (block.type === "image") { + total += IMAGE_TOKEN_ESTIMATE + } else if (block.type === "tool_use") { + total += encode(block.name).length + total += encode(JSON.stringify(block.input ?? {})).length + } else if (block.type === "tool_result") { + total += encode(toolResultToString(block.content)).length + } else if (block.type === "thinking") { + total += encode(block.thinking).length + } + } + } + + for (const tool of req.tools ?? []) { + total += encode(tool.name).length + if (tool.description) total += encode(tool.description).length + total += encode(JSON.stringify(tool.input_schema ?? {})).length + } + + total += req.messages.length * 4 + return total +} + +export function countTranslatedTokens(req: KimiChatRequest): number { + let total = 0 + for (const m of req.messages) { + if (m.role === "system") { + total += encode(m.content).length + } else if (m.role === "user") { + if (typeof m.content === "string") total += encode(m.content).length + else { + for (const p of m.content) { + if (p.type === "text") total += encode(p.text).length + else total += IMAGE_TOKEN_ESTIMATE + } + } + } else if (m.role === "assistant") { + if (typeof m.content === "string") total += encode(m.content).length + if (m.reasoning_content) total += encode(m.reasoning_content).length + for (const tc of m.tool_calls ?? []) { + total += encode(tc.function.name).length + total += encode(tc.function.arguments).length + } + } else if (m.role === "tool") { + if (typeof m.content === "string") total += encode(m.content).length + else { + for (const p of m.content) { + if (p.type === "text") total += encode(p.text).length + else total += IMAGE_TOKEN_ESTIMATE + } + } + } + } + + for (const tool of req.tools ?? []) { + total += encode(tool.function.name).length + if (tool.function.description) total += encode(tool.function.description).length + total += encode(JSON.stringify(tool.function.parameters ?? {})).length + } + + total += req.messages.length * 4 + return total +} diff --git a/src/providers/kimi/index.ts b/src/providers/kimi/index.ts new file mode 100644 index 0000000..56ddbce --- /dev/null +++ b/src/providers/kimi/index.ts @@ -0,0 +1,217 @@ +import type { Provider, CliHandlers, RequestContext } from "../types.ts" +import type { AnthropicRequest } from "../../anthropic/schema.ts" +import { + assertAllowedModel, + ModelNotAllowedError, + resolveModel, +} from "./translate/model-allowlist.ts" +import { translateRequest } from "./translate/request.ts" +import { translateStream } from "./translate/stream.ts" +import { accumulateResponse, UpstreamStreamError } from "./translate/accumulate.ts" +import { mapUsageToAnthropic } from "./translate/reducer.ts" +import { countTokens, countTranslatedTokens } from "./count-tokens.ts" +import { KimiError, postKimi } from "./client.ts" +import { runDeviceLogin } from "./auth/login.ts" +import { persistInitialTokens } from "./auth/manager.ts" +import { loadAuth, clearAuth, authPath } from "./auth/token-store.ts" +import { logVerbose } from "../../config.ts" + +function jsonError(status: number, type: string, message: string): Response { + return new Response(JSON.stringify({ type: "error", error: { type, message } }), { + status, + headers: { "content-type": "application/json" }, + }) +} + +async function handleCountTokens(body: AnthropicRequest, ctx: RequestContext): Promise { + const log = ctx.childLogger("provider.kimi") + const resolvedModel = resolveModel(body.model) + const translated = translateRequest({ ...body, model: resolvedModel }) + const tokens = countTranslatedTokens(translated) + log.debug("count_tokens", { tokens }) + return new Response(JSON.stringify({ input_tokens: tokens }), { + headers: { "content-type": "application/json" }, + }) +} + +async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Promise { + const log = ctx.childLogger("provider.kimi") + const messageId = `msg_${crypto.randomUUID().replace(/-/g, "")}` + const wantStream = body.stream !== false + const messageCount = body.messages?.length ?? 0 + const toolCount = body.tools?.length ?? 0 + + log.debug("anthropic request", { + model: body.model, + messageCount, + toolCount, + stream: wantStream, + requestedMaxTokens: body.max_tokens, + }) + if (logVerbose()) log.debug("anthropic request body", { body }) + + const resolvedModel = resolveModel(body.model) + try { + assertAllowedModel(resolvedModel) + } catch (err) { + if (err instanceof ModelNotAllowedError) { + return jsonError( + 400, + "invalid_request_error", + `Model "${body.model}" resolves to unsupported model "${err.model}"`, + ) + } + throw err + } + + const translated = translateRequest( + { ...body, model: resolvedModel }, + { sessionId: ctx.sessionId }, + ) + const localInputTokens = countTokens(body) + const translatedInputTokens = countTranslatedTokens(translated) + log.debug("translated request", { + requestedModel: body.model, + resolvedModel, + messageCount: translated.messages.length, + toolCount: translated.tools?.length ?? 0, + localInputTokens, + translatedInputTokens, + promptCacheKey: translated.prompt_cache_key, + reasoningEffort: translated.reasoning_effort, + thinking: translated.thinking?.type, + maxTokens: translated.max_tokens, + }) + if (logVerbose()) log.debug("translated request body", { body: translated }) + + let upstream + try { + upstream = await postKimi(translated, ctx) + } catch (err) { + if (err instanceof KimiError) { + log.warn("kimi error", { status: err.status, detail: err.detail }) + if (err.status === 429) { + const headers: Record = { "content-type": "application/json" } + if (err.meta?.retryAfter) headers["retry-after"] = err.meta.retryAfter + return new Response( + JSON.stringify({ + type: "error", + error: { type: "rate_limit_error", message: err.detail || err.message }, + }), + { status: 429, headers }, + ) + } + const type = + err.status === 401 || err.status === 403 ? "authentication_error" : "api_error" + return jsonError(err.status, type, err.detail || err.message) + } + throw err + } + + if (wantStream) { + const stream = translateStream(upstream.body, { + messageId, + model: body.model, + log: ctx.childLogger("kimi.stream"), + requestStartTime: upstream.requestStartTime, + onFinish: (finish) => { + const mappedUsage = finish.usage ? mapUsageToAnthropic(finish.usage) : undefined + log.debug("stream finish", { + stopReason: finish.stopReason, + upstreamInputTokens: finish.usage?.prompt_tokens ?? 0, + upstreamOutputTokens: finish.usage?.completion_tokens ?? 0, + upstreamCachedInputTokens: + finish.usage?.prompt_tokens_details?.cached_tokens ?? + finish.usage?.cached_tokens ?? + 0, + upstreamReasoningTokens: + finish.usage?.completion_tokens_details?.reasoning_tokens ?? 0, + mappedInputTokens: mappedUsage?.input_tokens ?? 0, + mappedOutputTokens: mappedUsage?.output_tokens ?? 0, + mappedCacheReadTokens: mappedUsage?.cache_read_input_tokens ?? 0, + }) + }, + }) + return new Response(stream, { + status: 200, + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }, + }) + } + + try { + const result = await accumulateResponse(upstream.body, { messageId, model: body.model, log: ctx.childLogger("kimi.accumulate") }) + return new Response(JSON.stringify(result.response), { + headers: { "content-type": "application/json" }, + }) + } catch (err) { + if (err instanceof UpstreamStreamError) { + log.warn("upstream stream error (non-streaming)", { + kind: err.kind, + message: err.message, + }) + if (err.kind === "rate_limit") { + const headers: Record = { "content-type": "application/json" } + if (err.retryAfterSeconds) headers["retry-after"] = String(err.retryAfterSeconds) + return new Response( + JSON.stringify({ + type: "error", + error: { type: "rate_limit_error", message: err.message }, + }), + { status: 429, headers }, + ) + } + return jsonError(502, "api_error", err.message) + } + throw err + } +} + +const cli: CliHandlers = { + async login() { + const tokens = await runDeviceLogin() + const saved = await persistInitialTokens(tokens) + console.log(`Auth saved in ${authPath()}`) + if (saved.userId) console.log(`User: ${saved.userId}`) + const secs = Math.floor((saved.expires - Date.now()) / 1000) + console.log(`Expires in ${secs}s`) + }, + async status() { + const auth = await loadAuth() + if (!auth) { + console.log("Not authenticated") + process.exit(1) + } + const ms = auth.expires - Date.now() + console.log(`User: ${auth.userId ?? "(none)"}`) + console.log(`Expires: ${new Date(auth.expires).toISOString()} (in ${Math.floor(ms / 1000)}s)`) + console.log(`Scope: ${auth.scope ?? "(none)"}`) + console.log(`Storage: ${authPath()}`) + }, + async logout() { + await clearAuth() + console.log("Logged out") + }, +} + +export const kimiProvider: Provider = { + name: "kimi", + supportedModels: new Set([ + "kimi-for-coding", + "kimi-k2.6", + "k2.6", + "haiku", + "claude-haiku-4-5", + "claude-haiku-4-5-20251001", + "sonnet", + "claude-sonnet-4-6", + "opus", + "claude-opus-4-7", + ]), + handleMessages, + handleCountTokens, + cli, +} diff --git a/src/providers/kimi/translate/accumulate.ts b/src/providers/kimi/translate/accumulate.ts new file mode 100644 index 0000000..3572043 --- /dev/null +++ b/src/providers/kimi/translate/accumulate.ts @@ -0,0 +1,151 @@ +import { mapUsageToAnthropic, reduceUpstream, type KimiUsage } from "./reducer.ts" +import { makeThinkingSignature } from "./signature.ts" + +export { UpstreamStreamError } from "./reducer.ts" + +export interface AnthropicNonStreamResponse { + id: string + type: "message" + role: "assistant" + model: string + content: Array< + | { type: "text"; text: string } + | { type: "thinking"; thinking: string; signature: string } + | { type: "tool_use"; id: string; name: string; input: unknown } + > + stop_reason: "end_turn" | "tool_use" | "max_tokens" | null + stop_sequence: null + usage: { + input_tokens: number + output_tokens: number + cache_creation_input_tokens: number + cache_read_input_tokens: number + } +} + +export interface AccumulatedResponse { + response: AnthropicNonStreamResponse + rawUsage?: KimiUsage +} + +import type { Logger } from "../../../log.ts" + +export async function accumulateResponse( + upstream: ReadableStream, + opts: { messageId: string; model: string; log: Logger }, +): Promise { + type Block = + | { kind: "thinking"; text: string } + | { kind: "text"; text: string } + | { kind: "tool"; id: string; name: string; args: string } + + const ordered: number[] = [] + const blocks = new Map() + let stopReason: AnthropicNonStreamResponse["stop_reason"] = null + let usage: ReturnType | undefined + let rawUsage: KimiUsage | undefined + let reasoningChars = 0 + let contentChars = 0 + let toolCount = 0 + const stats = { chunkCount: 0 } + + for await (const e of reduceUpstream(upstream, stats, opts.log)) { + switch (e.kind) { + case "thinking-start": + blocks.set(e.index, { kind: "thinking", text: "" }) + ordered.push(e.index) + break + case "thinking-delta": { + const b = blocks.get(e.index) + if (b?.kind === "thinking") { + b.text += e.text + reasoningChars += e.text.length + } + break + } + case "text-start": + blocks.set(e.index, { kind: "text", text: "" }) + ordered.push(e.index) + break + case "text-delta": { + const b = blocks.get(e.index) + if (b?.kind === "text") { + b.text += e.text + contentChars += e.text.length + } + break + } + case "tool-start": + toolCount++ + blocks.set(e.index, { kind: "tool", id: e.id, name: e.name, args: "" }) + ordered.push(e.index) + break + case "tool-delta": { + const b = blocks.get(e.index) + if (b?.kind === "tool") b.args += e.partialJson + break + } + case "thinking-stop": + case "text-stop": + case "tool-stop": + break + case "finish": + stopReason = e.stopReason + rawUsage = e.usage + usage = mapUsageToAnthropic(e.usage) + break + } + } + + const content: AnthropicNonStreamResponse["content"] = [] + for (const i of ordered) { + const b = blocks.get(i) + if (!b) continue + if (b.kind === "thinking") { + if (b.text) + content.push({ + type: "thinking", + thinking: b.text, + signature: makeThinkingSignature(opts.messageId, i), + }) + } else if (b.kind === "text") { + if (b.text) content.push({ type: "text", text: b.text }) + } else { + let input: unknown = {} + try { + input = b.args ? JSON.parse(b.args) : {} + } catch { + input = { _raw: b.args } + } + content.push({ type: "tool_use", id: b.id, name: b.name, input }) + } + } + + opts.log.debug("accumulate summary", { + chunkCount: stats.chunkCount, + reasoningChars, + contentChars, + toolCount, + stopReason, + usage: rawUsage, + }) + + return { + rawUsage, + response: { + id: opts.messageId, + type: "message", + role: "assistant", + model: opts.model, + content, + stop_reason: stopReason, + stop_sequence: null, + usage: usage ?? { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + } +} diff --git a/src/providers/kimi/translate/model-allowlist.ts b/src/providers/kimi/translate/model-allowlist.ts new file mode 100644 index 0000000..4ebf640 --- /dev/null +++ b/src/providers/kimi/translate/model-allowlist.ts @@ -0,0 +1,32 @@ +export const KIMI_DEFAULT_MODEL = "kimi-for-coding" + +export const ALLOWED_MODELS = new Set([KIMI_DEFAULT_MODEL]) + +// Every incoming model name collapses to kimi-for-coding — the only model +// Kimi Code exposes. Explicit aliases exist so `ANTHROPIC_MODEL=haiku` works +// for portability with the codex provider's aliasing. +const ALIAS_TARGETS: Record = { + haiku: KIMI_DEFAULT_MODEL, + "claude-haiku-4-5": KIMI_DEFAULT_MODEL, + "claude-haiku-4-5-20251001": KIMI_DEFAULT_MODEL, + sonnet: KIMI_DEFAULT_MODEL, + "claude-sonnet-4-6": KIMI_DEFAULT_MODEL, + opus: KIMI_DEFAULT_MODEL, + "claude-opus-4-7": KIMI_DEFAULT_MODEL, + "kimi-for-coding": KIMI_DEFAULT_MODEL, +} + +export function resolveModel(model: string): string { + return ALIAS_TARGETS[model] ?? KIMI_DEFAULT_MODEL +} + +export function assertAllowedModel(model: string): void { + if (!ALLOWED_MODELS.has(model)) throw new ModelNotAllowedError(model) +} + +export class ModelNotAllowedError extends Error { + constructor(public model: string) { + super(`Model not allowed: ${model}`) + this.name = "ModelNotAllowedError" + } +} diff --git a/src/providers/kimi/translate/reducer.ts b/src/providers/kimi/translate/reducer.ts new file mode 100644 index 0000000..841e2f9 --- /dev/null +++ b/src/providers/kimi/translate/reducer.ts @@ -0,0 +1,212 @@ +import { parseSseStream } from "../../../sse.ts" +import type { Logger } from "../../../log.ts" + +export class UpstreamStreamError extends Error { + constructor( + public kind: "rate_limit" | "failed", + message: string, + public retryAfterSeconds?: number, + ) { + super(message) + this.name = "UpstreamStreamError" + } +} + +export interface KimiUsage { + prompt_tokens?: number + completion_tokens?: number + total_tokens?: number + cached_tokens?: number + prompt_tokens_details?: { cached_tokens?: number } + completion_tokens_details?: { reasoning_tokens?: number } +} + +export type StopReason = "end_turn" | "tool_use" | "max_tokens" + +export type ReducerEvent = + | { kind: "thinking-start"; index: number } + | { kind: "thinking-delta"; index: number; text: string } + | { kind: "thinking-stop"; index: number } + | { kind: "text-start"; index: number } + | { kind: "text-delta"; index: number; text: string } + | { kind: "text-stop"; index: number } + | { kind: "tool-start"; index: number; id: string; name: string } + | { kind: "tool-delta"; index: number; partialJson: string } + | { kind: "tool-stop"; index: number } + | { kind: "finish"; stopReason: StopReason; usage: KimiUsage | undefined } + +interface StreamChunk { + choices?: Array<{ + delta?: { + role?: string + content?: string | null + reasoning_content?: string | null + tool_calls?: Array<{ + index: number + id?: string + type?: string + function?: { name?: string; arguments?: string } + }> + } + finish_reason?: string | null + }> + usage?: KimiUsage + error?: { message?: string; type?: string } +} + +interface ToolSlot { + blockIndex: number + id: string + name: string +} + +/** + * Translate a Kimi/OpenAI-style chat-completions SSE stream into a stream + * of typed, downstream-agnostic ReducerEvents consumed by both the + * streaming and non-streaming frontends. + */ +export interface ReducerStats { + chunkCount: number +} + +export async function* reduceUpstream( + upstream: ReadableStream, + stats?: ReducerStats, + log?: Logger, +): AsyncGenerator { + let nextBlockIndex = 0 + let thinkingIndex: number | undefined + let textIndex: number | undefined + const toolSlots = new Map() // keyed by upstream tool_calls[].index + + let sawToolCalls = false + let finishReason: string | null | undefined + let finalUsage: KimiUsage | undefined + + const closeThinking = function* () { + if (thinkingIndex !== undefined) { + const idx = thinkingIndex + thinkingIndex = undefined + yield { kind: "thinking-stop" as const, index: idx } + } + } + const closeText = function* () { + if (textIndex !== undefined) { + const idx = textIndex + textIndex = undefined + yield { kind: "text-stop" as const, index: idx } + } + } + const closeAllTools = function* () { + for (const slot of toolSlots.values()) { + yield { kind: "tool-stop" as const, index: slot.blockIndex } + } + toolSlots.clear() + } + + for await (const evt of parseSseStream(upstream)) { + if (!evt.data) continue + const data = evt.data.trim() + if (data === "[DONE]") continue + + let chunk: StreamChunk + try { + chunk = JSON.parse(data) as StreamChunk + } catch (err) { + log?.warn("upstream sse: invalid json", { err: String(err), preview: data.slice(0, 200) }) + continue + } + + if (stats) stats.chunkCount++ + + if (chunk.error) { + throw new UpstreamStreamError("failed", chunk.error.message || "Upstream error") + } + + if (chunk.usage && !chunk.choices?.length) { + finalUsage = chunk.usage + continue + } + + const choice = chunk.choices?.[0] + if (!choice) continue + const delta = choice.delta ?? {} + + if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) { + if (thinkingIndex === undefined) { + thinkingIndex = nextBlockIndex++ + yield { kind: "thinking-start", index: thinkingIndex } + } + yield { kind: "thinking-delta", index: thinkingIndex, text: delta.reasoning_content } + } + + if (typeof delta.content === "string" && delta.content.length > 0) { + yield* closeThinking() + if (textIndex === undefined) { + textIndex = nextBlockIndex++ + yield { kind: "text-start", index: textIndex } + } + yield { kind: "text-delta", index: textIndex, text: delta.content } + } + + if (delta.tool_calls?.length) { + yield* closeThinking() + yield* closeText() + for (const tc of delta.tool_calls) { + const upstreamIdx = tc.index + let slot = toolSlots.get(upstreamIdx) + if (!slot) { + // First fragment of this tool call: must carry id + function.name. + const id = tc.id ?? "" + const name = tc.function?.name ?? "" + if (!id || !name) { + // Defensive: out-of-order fragment before the initial one. Skip. + continue + } + sawToolCalls = true + slot = { blockIndex: nextBlockIndex++, id, name } + toolSlots.set(upstreamIdx, slot) + yield { kind: "tool-start", index: slot.blockIndex, id, name } + } + const argsDelta = tc.function?.arguments + if (typeof argsDelta === "string" && argsDelta.length > 0) { + yield { kind: "tool-delta", index: slot.blockIndex, partialJson: argsDelta } + } + } + } + + if (choice.finish_reason) { + finishReason = choice.finish_reason + if (chunk.usage) finalUsage = chunk.usage + } + } + + yield* closeThinking() + yield* closeText() + yield* closeAllTools() + + const stopReason: StopReason = + finishReason === "length" + ? "max_tokens" + : finishReason === "tool_calls" || sawToolCalls + ? "tool_use" + : "end_turn" + + yield { kind: "finish", stopReason, usage: finalUsage } +} + +export function mapUsageToAnthropic(u: KimiUsage | undefined): { + input_tokens: number + output_tokens: number + cache_creation_input_tokens: number + cache_read_input_tokens: number +} { + const cached = u?.prompt_tokens_details?.cached_tokens ?? u?.cached_tokens ?? 0 + const totalPrompt = u?.prompt_tokens ?? 0 + return { + input_tokens: Math.max(0, totalPrompt - cached), + output_tokens: u?.completion_tokens ?? 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: cached, + } +} diff --git a/src/providers/kimi/translate/request.ts b/src/providers/kimi/translate/request.ts new file mode 100644 index 0000000..470b79f --- /dev/null +++ b/src/providers/kimi/translate/request.ts @@ -0,0 +1,298 @@ +import type { + AnthropicContentBlock, + AnthropicImageBlock, + AnthropicMessage, + AnthropicRequest, + AnthropicTextBlock, + AnthropicTool, +} from "../../../anthropic/schema.ts" + +// OpenAI-compatible chat-completions request shape used by Kimi. +// Only the fields kimi-cli is known to send are included; unknown +// fields are not forwarded. +export interface KimiChatRequest { + model: string + messages: KimiMessage[] + tools?: KimiTool[] + tool_choice?: KimiToolChoice + stream: true + stream_options: { include_usage: true } + max_tokens: number + reasoning_effort?: "low" | "medium" | "high" + thinking?: { type: "enabled" } + prompt_cache_key?: string +} + +export type KimiToolChoice = + | "auto" + | "none" + | "required" + | { type: "function"; function: { name: string } } + +export type KimiAssistantMessage = { + role: "assistant" + content?: string | null + reasoning_content?: string + tool_calls?: KimiAssistantToolCall[] +} + +export type KimiMessage = + | { role: "system"; content: string } + | { role: "user"; content: string | KimiUserContentPart[] } + | KimiAssistantMessage + | { role: "tool"; tool_call_id: string; content: string | KimiToolResultPart[] } + +export type KimiUserContentPart = + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string } } + +export type KimiToolResultPart = + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string } } + +export interface KimiAssistantToolCall { + id: string + type: "function" + function: { name: string; arguments: string } +} + +export interface KimiTool { + type: "function" + function: { name: string; description?: string; parameters: unknown } +} + +export interface TranslateOptions { + sessionId?: string +} + +const DEFAULT_MAX_TOKENS = 32000 + +// Kimi's `kimi-for-coding` is a reasoning model: it always produces +// reasoning_content and its server always enforces that prior assistant +// tool_call messages carry reasoning_content back. The `thinking` / +// `reasoning_effort` flags on the request don't gate that contract — +// they're a model-level property. So we always enable thinking on the +// outbound Kimi request and always forward/replay reasoning end-to-end. +export function translateRequest( + req: AnthropicRequest, + opts: TranslateOptions = {}, +): KimiChatRequest { + const messages = buildMessages(req) + const tools = req.tools?.map(toKimiTool) + + assertValidEffort(req.output_config?.effort) + const out: KimiChatRequest = { + model: req.model, + messages, + stream: true, + stream_options: { include_usage: true }, + max_tokens: clampMaxTokens(req.max_tokens), + reasoning_effort: mapReasoningEffort(req.output_config?.effort), + thinking: { type: "enabled" }, + } + if (tools && tools.length) out.tools = tools + const tool_choice = mapToolChoice(req.tool_choice) + if (tool_choice !== "auto") out.tool_choice = tool_choice + if (opts.sessionId) out.prompt_cache_key = opts.sessionId + return out +} + +function clampMaxTokens(requested: number | undefined): number { + if (!requested || requested <= 0) return DEFAULT_MAX_TOKENS + return Math.min(requested, DEFAULT_MAX_TOKENS) +} + +const ANTHROPIC_EFFORTS = new Set(["low", "medium", "high", "max"]) + +function assertValidEffort(effort: unknown): void { + if (effort !== undefined && !ANTHROPIC_EFFORTS.has(effort as string)) { + throw new Error( + `Invalid output_config.effort: "${effort}". Must be one of: ${Array.from(ANTHROPIC_EFFORTS).join(", ")}`, + ) + } +} + +// Kimi's reasoning_effort is capped at "high"; collapse the proxy's "max" +// to "high" since Kimi has no stronger tier, and default to "medium" when no +// effort is requested. +function mapReasoningEffort( + effort: NonNullable["effort"], +): "low" | "medium" | "high" { + if (effort === "max") return "high" + return effort ?? "medium" +} + +function mapToolChoice(choice: AnthropicRequest["tool_choice"]): KimiToolChoice { + if (!choice) return "auto" + switch (choice.type) { + case "auto": + return "auto" + case "none": + return "none" + case "any": + return "required" + case "tool": + return choice.name + ? { type: "function", function: { name: choice.name } } + : "required" + } +} + +export function buildSystemMessage(system: AnthropicRequest["system"]): string | undefined { + if (!system) return undefined + const blocks: AnthropicTextBlock[] = + typeof system === "string" ? [{ type: "text", text: system }] : system + const texts = blocks + .filter((b) => b && b.type === "text" && typeof b.text === "string") + .map((b) => b.text) + .filter((t) => !t.startsWith("x-anthropic-billing-header:")) + if (!texts.length) return undefined + return texts.join("\n\n") +} + +function buildMessages(req: AnthropicRequest): KimiMessage[] { + const out: KimiMessage[] = [] + const system = buildSystemMessage(req.system) + if (system) out.push({ role: "system", content: system }) + + for (const msg of req.messages) { + const blocks = normalizeContent(msg.content) + if (msg.role === "user") { + pushUserMessages(out, blocks) + } else { + pushAssistantMessage(out, blocks) + } + } + return out +} + +// In Anthropic-land a single user message can carry a mix of text, image, +// and tool_result blocks. OpenAI-style chat-completions requires tool +// results to be their own role="tool" messages, so we split on tool_result +// boundaries, preserving order. +function pushUserMessages(out: KimiMessage[], blocks: AnthropicContentBlock[]): void { + let buffer: KimiUserContentPart[] = [] + const flushBuffer = () => { + if (!buffer.length) return + // Collapse to a string when every part is text — matches what kimi-cli + // sends and keeps the wire payload compact. + const allText = buffer.every((p) => p.type === "text") + if (allText) { + out.push({ + role: "user", + content: buffer.map((p) => (p as { type: "text"; text: string }).text).join(""), + }) + } else { + out.push({ role: "user", content: buffer }) + } + buffer = [] + } + + for (const block of blocks) { + if (block.type === "text") { + buffer.push({ type: "text", text: block.text }) + } else if (block.type === "image") { + buffer.push({ type: "image_url", image_url: { url: imageToUrl(block) } }) + } else if (block.type === "tool_result") { + flushBuffer() + out.push({ + role: "tool", + tool_call_id: block.tool_use_id, + content: toolResultContent(block.content, block.is_error), + }) + } + } + flushBuffer() +} + +function pushAssistantMessage(out: KimiMessage[], blocks: AnthropicContentBlock[]): void { + const textParts: string[] = [] + const thinkingParts: string[] = [] + const toolCalls: KimiAssistantToolCall[] = [] + for (const block of blocks) { + if (block.type === "text") { + if (block.text) textParts.push(block.text) + } else if (block.type === "thinking") { + if (block.thinking) thinkingParts.push(block.thinking) + } else if (block.type === "tool_use") { + toolCalls.push({ + id: block.id, + type: "function", + function: { + name: block.name, + arguments: JSON.stringify(block.input ?? {}), + }, + }) + } + // image blocks from the assistant are dropped — Kimi's assistant + // schema does not express them. + } + + if (!textParts.length && !toolCalls.length && !thinkingParts.length) return + + const msg: KimiAssistantMessage = { role: "assistant" } + msg.content = textParts.length ? textParts.join("") : "" + // Kimi accepts one reasoning_content string per assistant turn. If a turn + // has multiple interleaved thinking blocks we concatenate in order — + // exact block/tool_use pairing can't be preserved over the wire. + if (thinkingParts.length) msg.reasoning_content = thinkingParts.join("\n\n") + if (toolCalls.length) msg.tool_calls = toolCalls + out.push(msg) +} + +export function normalizeContent(content: AnthropicMessage["content"]): AnthropicContentBlock[] { + if (typeof content === "string") return [{ type: "text", text: content }] + return content +} + +function imageToUrl(block: Extract): string { + if (block.source.type === "url") return block.source.url + return `data:${block.source.media_type};base64,${block.source.data}` +} + +export function toolResultContent( + content: string | Array, + isError: boolean | undefined, +): string | KimiToolResultPart[] { + const prefix = isError ? "[tool execution error]\n" : "" + if (typeof content === "string") return prefix + content + + const parts: KimiToolResultPart[] = [] + if (prefix) parts.push({ type: "text", text: prefix }) + for (const b of content) { + if (b.type === "text") { + parts.push({ type: "text", text: b.text }) + } else { + // Kimi accepts image_url parts inside role="tool" content (its own + // ReadMediaFile tool uses exactly this shape). + parts.push({ type: "image_url", image_url: { url: imageToUrl(b) } }) + } + } + if (parts.length === 1 && parts[0]!.type === "text") return parts[0]!.text + return parts +} + +export function toolResultToString( + content: string | Array, +): string { + // Kept for the token counter, which wants a flat string. + if (typeof content === "string") return content + return content + .map((b) => { + if (b.type === "text") return b.text + const mt = b.source.type === "base64" ? b.source.media_type : "url" + return `[image omitted: ${mt}]` + }) + .join("\n") +} + +function toKimiTool(tool: AnthropicTool): KimiTool { + return { + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.input_schema, + }, + } +} diff --git a/src/providers/kimi/translate/signature.ts b/src/providers/kimi/translate/signature.ts new file mode 100644 index 0000000..9475a9b --- /dev/null +++ b/src/providers/kimi/translate/signature.ts @@ -0,0 +1,7 @@ +// Opaque per-block signature for Anthropic thinking blocks. Claude Code +// treats this as a passthrough string, so the only requirements are: +// stable per (messageId, blockIndex) and identical between the streaming +// and non-streaming paths. +export function makeThinkingSignature(messageId: string, index: number): string { + return Buffer.from(`ccp:kimi:v1:${messageId}:${index}`).toString("base64url") +} diff --git a/src/providers/kimi/translate/stream.ts b/src/providers/kimi/translate/stream.ts new file mode 100644 index 0000000..b615329 --- /dev/null +++ b/src/providers/kimi/translate/stream.ts @@ -0,0 +1,221 @@ +import { encodeSseEvent } from "../../../sse.ts" +import type { Logger } from "../../../log.ts" +import { mapUsageToAnthropic, reduceUpstream, UpstreamStreamError, type KimiUsage } from "./reducer.ts" +import { makeThinkingSignature } from "./signature.ts" + +function isAbortError(err: unknown): boolean { + return err instanceof Error && err.name === "AbortError" +} + +/** + * Translate a Kimi chat-completions SSE stream into Anthropic SSE events. + * HTTP status 200 is already committed before the first upstream event is + * read, so rate-limit and upstream-failed conditions surface here as SSE + * `error` events instead of non-200 statuses. + */ +export function translateStream( + upstream: ReadableStream, + opts: { + messageId: string + model: string + log: Logger + requestStartTime?: number + onFinish?: (finish: { + stopReason: "end_turn" | "tool_use" | "max_tokens" + usage?: Parameters[0] + }) => void + }, +): ReadableStream { + const encoder = new TextEncoder() + return new ReadableStream({ + async start(controller) { + const emit = (event: string, data: unknown) => { + controller.enqueue(encoder.encode(encodeSseEvent(event, data))) + } + const activeTools = new Map() + let messageStarted = false + const ensureMessageStart = () => { + if (messageStarted) return + messageStarted = true + emit("message_start", { + type: "message_start", + message: { + id: opts.messageId, + type: "message", + role: "assistant", + model: opts.model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }) + emit("ping", { type: "ping" }) + } + + const streamStart = Date.now() + let firstChunkAt: number | undefined + let reasoningChars = 0 + let contentChars = 0 + let toolCount = 0 + let finishUsage: KimiUsage | undefined + let finishStopReason: string | undefined + const stats = { chunkCount: 0 } + + try { + for await (const e of reduceUpstream(upstream, stats, opts.log)) { + if (firstChunkAt === undefined) firstChunkAt = Date.now() + + switch (e.kind) { + case "thinking-start": + ensureMessageStart() + emit("content_block_start", { + type: "content_block_start", + index: e.index, + content_block: { type: "thinking", thinking: "" }, + }) + break + case "thinking-delta": + reasoningChars += e.text.length + emit("content_block_delta", { + type: "content_block_delta", + index: e.index, + delta: { type: "thinking_delta", thinking: e.text }, + }) + break + case "thinking-stop": + // Emit the signature as a separate signature_delta right + // before content_block_stop — matches Anthropic's native + // wire format and keeps Claude Code's thinking-block parser + // happy on round-trip. + emit("content_block_delta", { + type: "content_block_delta", + index: e.index, + delta: { + type: "signature_delta", + signature: makeThinkingSignature(opts.messageId, e.index), + }, + }) + emit("content_block_stop", { type: "content_block_stop", index: e.index }) + break + case "text-start": + ensureMessageStart() + emit("content_block_start", { + type: "content_block_start", + index: e.index, + content_block: { type: "text", text: "" }, + }) + break + case "text-delta": + contentChars += e.text.length + emit("content_block_delta", { + type: "content_block_delta", + index: e.index, + delta: { type: "text_delta", text: e.text }, + }) + break + case "text-stop": + emit("content_block_stop", { type: "content_block_stop", index: e.index }) + break + case "tool-start": + toolCount++ + activeTools.set(e.index, { id: e.id, name: e.name }) + ensureMessageStart() + emit("content_block_start", { + type: "content_block_start", + index: e.index, + content_block: { + type: "tool_use", + id: e.id, + name: e.name, + input: {}, + }, + }) + break + case "tool-delta": + emit("content_block_delta", { + type: "content_block_delta", + index: e.index, + delta: { type: "input_json_delta", partial_json: e.partialJson }, + }) + break + case "tool-stop": + activeTools.delete(e.index) + emit("content_block_stop", { type: "content_block_stop", index: e.index }) + break + case "finish": + ensureMessageStart() + finishUsage = e.usage + finishStopReason = e.stopReason + opts.onFinish?.({ stopReason: e.stopReason, usage: e.usage }) + emit("message_delta", { + type: "message_delta", + delta: { stop_reason: e.stopReason, stop_sequence: null }, + usage: mapUsageToAnthropic(e.usage), + }) + emit("message_stop", { type: "message_stop" }) + break + } + } + } catch (err) { + const activeToolNames = Array.from(activeTools.values(), (t) => t.name) + const activeToolCalls = Array.from(activeTools.values()) + if (isAbortError(err)) { + opts.log.info("client disconnected") + } else if (err instanceof UpstreamStreamError) { + opts.log.warn("upstream stream error", { + kind: err.kind, + message: err.message, + activeToolNames, + activeToolCalls, + }) + ensureMessageStart() + emit("error", { + type: "error", + error: { + type: err.kind === "rate_limit" ? "rate_limit_error" : "api_error", + message: err.message, + }, + }) + } else { + opts.log.error("stream translation error", { + err: String(err), + activeToolNames, + activeToolCalls, + }) + ensureMessageStart() + emit("error", { + type: "error", + error: { type: "api_error", message: String(err) }, + }) + } + } finally { + const now = Date.now() + const timeToFirstChunkMs = opts.requestStartTime && firstChunkAt + ? firstChunkAt - opts.requestStartTime + : undefined + opts.log.debug("stream summary", { + chunkCount: stats.chunkCount, + timeToFirstChunkMs, + streamDurationMs: firstChunkAt ? now - firstChunkAt : undefined, + totalMs: opts.requestStartTime ? now - opts.requestStartTime : now - streamStart, + reasoningChars, + contentChars, + toolCount, + stopReason: finishStopReason, + usage: finishUsage, + }) + try { + controller.close() + } catch { + // ignore if controller already errored or cancelled + } + } + }, + }) +} diff --git a/src/providers/registry.test.ts b/src/providers/registry.test.ts new file mode 100644 index 0000000..bd318f5 --- /dev/null +++ b/src/providers/registry.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "bun:test" +import { providerForModel } from "./registry.ts" +import { normalizeIncomingModel } from "../server.ts" + +describe("provider routing", () => { + it("routes fast Codex model aliases after context suffix normalization", () => { + const model = normalizeIncomingModel("gpt-5.4-fast[1m]") + + expect(model).toBe("gpt-5.4-fast") + expect(providerForModel(model)?.name).toBe("codex") + }) +}) diff --git a/src/providers/registry.ts b/src/providers/registry.ts new file mode 100644 index 0000000..e7ab9c2 --- /dev/null +++ b/src/providers/registry.ts @@ -0,0 +1,47 @@ +import type { Provider } from "./types.ts" +import { codexProvider } from "./codex/index.ts" +import { kimiProvider } from "./kimi/index.ts" +import { anthropicProvider } from "./anthropic/index.ts" + +const PROVIDERS: Record = { + anthropic: anthropicProvider, + codex: codexProvider, + kimi: kimiProvider, +} + +export function getProvider(name: string): Provider { + const p = PROVIDERS[name] + if (!p) { + throw new Error( + `Unknown provider: ${name}. Available: ${Object.keys(PROVIDERS).join(", ")}`, + ) + } + return p +} + +export function listProviders(): string[] { + return Object.keys(PROVIDERS) +} + +export function allProviders(): Provider[] { + return Object.values(PROVIDERS) +} + +// Look up the single provider that claims the given model id. Returns +// undefined when no provider declares it (the caller should surface an +// error). Cross-provider aliases like `haiku` / `sonnet` are deliberately +// NOT resolvable here — users must use a concrete, provider-owned model id. +export function providerForModel(model: string): Provider | undefined { + for (const p of allProviders()) { + if (p.supportedModels.has(model)) return p + } + return undefined +} + +export function allSupportedModels(): Array<{ model: string; provider: string }> { + const out: Array<{ model: string; provider: string }> = [] + for (const p of allProviders()) { + for (const m of p.supportedModels) out.push({ model: m, provider: p.name }) + } + return out +} diff --git a/src/providers/retry.test.ts b/src/providers/retry.test.ts new file mode 100644 index 0000000..b7178da --- /dev/null +++ b/src/providers/retry.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "bun:test" +import { + computeBackoffDelay, + MAX_RATE_LIMIT_RETRIES, + RETRY_INITIAL_DELAY_MS, + RETRY_MAX_DELAY_MS, + retryOn429, + sleep, +} from "./retry.ts" + +const silentLog = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => silentLog, +} + +describe("computeBackoffDelay", () => { + it("uses jittered exponential backoff without retry-after", () => { + // equal jitter: result is in [cap/2, cap] + for (const attempt of [0, 1, 2]) { + const cap = RETRY_INITIAL_DELAY_MS * 2 ** attempt + const { waitMs } = computeBackoffDelay(attempt) + expect(waitMs).toBeGreaterThanOrEqual(cap / 2) + expect(waitMs).toBeLessThanOrEqual(cap) + } + }) + + it("caps exponential backoff at max delay", () => { + const { waitMs } = computeBackoffDelay(20) + expect(waitMs).toBeGreaterThanOrEqual(RETRY_MAX_DELAY_MS / 2) + expect(waitMs).toBeLessThanOrEqual(RETRY_MAX_DELAY_MS) + }) + + it("respects numeric retry-after as seconds", () => { + expect(computeBackoffDelay(0, "5").waitMs).toBe(5000) + }) + + it("flags retry-after that exceeds budget", () => { + const out = computeBackoffDelay(0, "120") + expect(out.waitMs).toBe(RETRY_MAX_DELAY_MS) + expect(out.exceedsBudget).toBe(true) + }) + + it("rejects non-numeric retry-after garbage", () => { + const { waitMs } = computeBackoffDelay(0, "1abc") + expect(waitMs).toBeGreaterThanOrEqual(RETRY_INITIAL_DELAY_MS / 2) + expect(waitMs).toBeLessThanOrEqual(RETRY_INITIAL_DELAY_MS) + }) + + it("parses HTTP-date retry-after", () => { + const date = new Date(Date.now() + 5000).toUTCString() + const out = computeBackoffDelay(0, date) + expect(out.waitMs).toBeGreaterThan(3500) + expect(out.waitMs).toBeLessThanOrEqual(5000) + }) +}) + +describe("sleep", () => { + it("rejects if signal already aborted", async () => { + const c = new AbortController() + c.abort() + await expect(sleep(1000, c.signal)).rejects.toThrow() + }) + + it("rejects when aborted mid-sleep", async () => { + const c = new AbortController() + const p = sleep(1000, c.signal) + setTimeout(() => c.abort(), 10) + await expect(p).rejects.toThrow() + }) +}) + +describe("retryOn429", () => { + class FakeRateLimit extends Error { + constructor(public retryAfter?: string) { + super("rate limited") + } + } + + it("returns successful result without retry", async () => { + let calls = 0 + const result = await retryOn429( + async () => { + calls++ + return "ok" + }, + { + log: silentLog, + classify: () => undefined, + }, + ) + expect(result).toBe("ok") + expect(calls).toBe(1) + }) + + it("retries up to MAX_RATE_LIMIT_RETRIES then throws", async () => { + let calls = 0 + const start = Date.now() + await expect( + retryOn429( + async () => { + calls++ + throw new FakeRateLimit("0") + }, + { + log: silentLog, + classify: (err) => + err instanceof FakeRateLimit ? { retryAfter: err.retryAfter } : undefined, + }, + ), + ).rejects.toBeInstanceOf(FakeRateLimit) + expect(calls).toBe(MAX_RATE_LIMIT_RETRIES + 1) + expect(Date.now() - start).toBeLessThan(2000) + }) + + it("gives up immediately when retry-after exceeds budget", async () => { + let calls = 0 + await expect( + retryOn429( + async () => { + calls++ + throw new FakeRateLimit("120") + }, + { + log: silentLog, + classify: (err) => + err instanceof FakeRateLimit ? { retryAfter: err.retryAfter } : undefined, + }, + ), + ).rejects.toBeInstanceOf(FakeRateLimit) + expect(calls).toBe(1) + }) + + it("does not retry non-rate-limit errors", async () => { + let calls = 0 + await expect( + retryOn429( + async () => { + calls++ + throw new Error("other") + }, + { + log: silentLog, + classify: () => undefined, + }, + ), + ).rejects.toThrow("other") + expect(calls).toBe(1) + }) + + it("succeeds after a transient 429", async () => { + let calls = 0 + const result = await retryOn429( + async () => { + calls++ + if (calls === 1) throw new FakeRateLimit("0") + return "recovered" + }, + { + log: silentLog, + classify: (err) => + err instanceof FakeRateLimit ? { retryAfter: err.retryAfter } : undefined, + }, + ) + expect(result).toBe("recovered") + expect(calls).toBe(2) + }) +}) diff --git a/src/providers/retry.ts b/src/providers/retry.ts new file mode 100644 index 0000000..99a9abe --- /dev/null +++ b/src/providers/retry.ts @@ -0,0 +1,89 @@ +import type { Logger } from "../log.ts" + +export const RETRY_INITIAL_DELAY_MS = 2000 +export const RETRY_BACKOFF_FACTOR = 2 +export const RETRY_MAX_DELAY_MS = 30_000 +export const MAX_RATE_LIMIT_RETRIES = 3 + +const STRICT_NUMERIC = /^\s*\d+(?:\.\d+)?\s*$/ + +export interface BackoffOutcome { + waitMs: number + exceedsBudget: boolean +} + +export function computeBackoffDelay(attempt: number, retryAfter?: string): BackoffOutcome { + if (retryAfter) { + if (STRICT_NUMERIC.test(retryAfter)) { + const ms = Math.ceil(Number.parseFloat(retryAfter) * 1000) + return { waitMs: Math.min(ms, RETRY_MAX_DELAY_MS), exceedsBudget: ms > RETRY_MAX_DELAY_MS } + } + const dateMs = Date.parse(retryAfter) - Date.now() + if (!Number.isNaN(dateMs) && dateMs > 0) { + return { + waitMs: Math.min(Math.ceil(dateMs), RETRY_MAX_DELAY_MS), + exceedsBudget: dateMs > RETRY_MAX_DELAY_MS, + } + } + } + const exp = RETRY_INITIAL_DELAY_MS * Math.pow(RETRY_BACKOFF_FACTOR, attempt) + const capped = Math.min(exp, RETRY_MAX_DELAY_MS) + // Equal jitter on the exponential fallback to avoid synchronized retries + // across concurrent requests. Never jitter an explicit Retry-After. + const jittered = capped / 2 + Math.random() * (capped / 2) + return { waitMs: Math.round(jittered), exceedsBudget: false } +} + +export function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("Aborted", "AbortError")) + return + } + const onAbort = () => { + clearTimeout(timer) + reject(new DOMException("Aborted", "AbortError")) + } + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort) + resolve() + }, ms) + signal?.addEventListener("abort", onAbort, { once: true }) + }) +} + +export interface RateLimitInfo { + retryAfter?: string +} + +export interface RetryOptions { + log: Logger + signal?: AbortSignal + classify: (err: unknown) => RateLimitInfo | undefined +} + +export async function retryOn429(run: () => Promise, opts: RetryOptions): Promise { + for (let attempt = 0; ; attempt++) { + try { + return await run() + } catch (err) { + const info = opts.classify(err) + if (!info || attempt >= MAX_RATE_LIMIT_RETRIES) throw err + const { waitMs, exceedsBudget } = computeBackoffDelay(attempt, info.retryAfter) + if (exceedsBudget) { + opts.log.warn("upstream 429 retry-after exceeds budget; giving up", { + retryAfter: info.retryAfter, + maxDelayMs: RETRY_MAX_DELAY_MS, + }) + throw err + } + opts.log.warn("upstream 429, retrying after backoff", { + attempt: attempt + 1, + maxRetries: MAX_RATE_LIMIT_RETRIES, + waitMs, + retryAfter: info.retryAfter, + }) + await sleep(waitMs, opts.signal) + } + } +} diff --git a/src/providers/types.ts b/src/providers/types.ts new file mode 100644 index 0000000..d3bb3d7 --- /dev/null +++ b/src/providers/types.ts @@ -0,0 +1,31 @@ +import type { AnthropicRequest } from "../anthropic/schema.ts" + +import type { Logger } from "../log.ts" + +export interface RequestContext { + reqId: string + sessionId?: string + sessionSeq?: number + signal: AbortSignal + childLogger(service: string): Logger +} + +export interface CliHandlers { + login?: () => Promise + device?: () => Promise + status: () => Promise + logout: () => Promise +} + +export interface Provider { + name: string + // Unambiguous model identifiers this provider claims. The server uses + // these to dispatch a request body's `model` field to the right + // provider when multiple are registered. Cross-provider aliases like + // `haiku`/`sonnet` are deliberately excluded — they fall back to the + // default provider. + supportedModels: Set + handleMessages(body: AnthropicRequest, ctx: RequestContext): Promise + handleCountTokens(body: AnthropicRequest, ctx: RequestContext): Promise + cli: CliHandlers +} diff --git a/src/server.ts b/src/server.ts index 2f1d73e..7fa73e3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,19 +1,24 @@ -import { createLogger, logDir } from "./log.ts" +import { createLogger, logDir, REDACT_KEYS } from "./log.ts" + import type { AnthropicRequest } from "./anthropic/schema.ts" -import { assertAllowedModel, ModelNotAllowedError } from "./translate/model-allowlist.ts" -import { translateRequest } from "./translate/request.ts" -import { translateStream } from "./translate/stream.ts" -import { accumulateResponse, UpstreamStreamError } from "./translate/accumulate.ts" -import { CodexError, postCodex } from "./codex/client.ts" -import { countTokens } from "./count-tokens.ts" +import type { Provider, RequestContext } from "./providers/types.ts" +import { allSupportedModels, providerForModel } from "./providers/registry.ts" -const log = createLogger("server") -const VERBOSE = !!process.env.CCP_LOG_VERBOSE +const rootLog = createLogger("server") export interface ServeOptions { port: number } +const sessionSeqs = new Map() + +function nextSessionSeq(sessionId?: string): number | undefined { + if (!sessionId) return undefined + const seq = (sessionSeqs.get(sessionId) ?? 0) + 1 + sessionSeqs.set(sessionId, seq) + return seq +} + export function startServer(opts: ServeOptions): { stop: () => void; port: number } { const server = Bun.serve({ hostname: "127.0.0.1", @@ -23,18 +28,29 @@ export function startServer(opts: ServeOptions): { stop: () => void; port: numbe const url = new URL(req.url) const start = Date.now() const reqId = crypto.randomUUID() - log.info("request", { reqId, method: req.method, path: url.pathname, query: url.search }) + rootLog.info("request", { + reqId, + method: req.method, + path: url.pathname, + ...(url.search ? { query: redactedQuery(url) } : {}), + }) try { const resp = await route(req, url, reqId) - log.info("response", { reqId, status: resp.status, ms: Date.now() - start }) - return resp + const ms = Date.now() - start + rootLog.info("response", { reqId, status: resp.status, ms }) + if (!resp.body) return resp + return wrapStreamResponse(resp, reqId, start, rootLog) } catch (err) { - log.error("handler error", { reqId, err: String(err), stack: (err as Error)?.stack }) + if (isAbortError(err)) { + rootLog.info("client disconnected", { reqId, ms: Date.now() - start }) + return new Response(null, { status: 499 }) + } + rootLog.error("handler error", { reqId, err: String(err), stack: (err as Error)?.stack }) return jsonError(500, "internal_error", String(err)) } }, }) - log.info("server listening", { port: server.port, logDir: logDir() }) + rootLog.info("server listening", { port: server.port, logDir: logDir() }) return { port: Number(server.port), stop: () => server.stop(), @@ -49,130 +65,145 @@ async function route(req: Request, url: URL, reqId: string): Promise { } if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") { - const body = (await req.json()) as AnthropicRequest - const tokens = countTokens(body) - log.debug("count_tokens", { reqId, tokens }) - return new Response(JSON.stringify({ input_tokens: tokens }), { - headers: { "content-type": "application/json" }, - }) + const body = await parseJsonBody(req) + if (body instanceof Response) return body + const provider = routeProvider(body, reqId) + if (provider instanceof Response) return provider + const ctx = buildCtx(req, reqId, provider.name) + ctx.childLogger("server").info("dispatch", { model: body.model }) + return provider.handleCountTokens(body, ctx) } if (req.method === "POST" && url.pathname === "/v1/messages") { - return handleMessages(req, reqId) + const body = await parseJsonBody(req) + if (body instanceof Response) return body + const provider = routeProvider(body, reqId) + if (provider instanceof Response) return provider + const ctx = buildCtx(req, reqId, provider.name) + ctx.childLogger("server").info("dispatch", { model: body.model }) + return provider.handleMessages(body, ctx) } return jsonError(404, "not_found", `No route for ${req.method} ${url.pathname}`) } -async function handleMessages(req: Request, reqId: string): Promise { - let body: AnthropicRequest - try { - body = (await req.json()) as AnthropicRequest - } catch (err) { - return jsonError(400, "invalid_request_error", `Invalid JSON: ${err}`) - } - +function buildCtx(req: Request, reqId: string, providerName: string): RequestContext { const sessionId = req.headers.get("x-claude-code-session-id") || undefined - const messageId = `msg_${crypto.randomUUID().replace(/-/g, "")}` - const wantStream = body.stream !== false - - log.debug("anthropic request", { + const sessionSeq = nextSessionSeq(sessionId) + const bindings = { reqId, sessionId, sessionSeq, provider: providerName } + return { reqId, - model: body.model, - messageCount: body.messages?.length, - toolCount: body.tools?.length ?? 0, - stream: wantStream, sessionId, - hasJsonSchemaFormat: body.output_config?.format?.type === "json_schema", - }) - if (VERBOSE) log.debug("anthropic request body", { reqId, body }) - - try { - assertAllowedModel(body.model) - } catch (err) { - if (err instanceof ModelNotAllowedError) { - return jsonError( - 400, - "invalid_request_error", - `Model "${err.model}" is not in the Codex OAuth allowlist`, - ) - } - throw err + sessionSeq, + signal: req.signal, + childLogger: (service) => createLogger(service, bindings), } +} - const translated = translateRequest(body, { sessionId }) - log.debug("translated request", { - reqId, - inputItems: translated.input.length, - tools: translated.tools?.length ?? 0, - hasInstructions: !!translated.instructions, - promptCacheKey: translated.prompt_cache_key, - }) - if (VERBOSE) log.debug("translated request body", { reqId, body: translated }) +// Claude Code uses a [1m] suffix convention (e.g. "gpt-5.4[1m]") to +// signal that the model should be treated as having a 1M-token context +// window. Claude Code normalizes this away before sending requests to +// the API, but we strip it here too as defense-in-depth in case a +// future version or a different client includes it. +export function normalizeIncomingModel(model: string): string { + return model.replace(/\[1m\]$/i, "") +} - let upstream - try { - upstream = await postCodex(translated, { sessionId, signal: req.signal }) - } catch (err) { - if (err instanceof CodexError) { - log.warn("codex error", { reqId, status: err.status, detail: err.detail }) - if (err.status === 429) { - const headers: Record = { "content-type": "application/json" } - if (err.meta?.retryAfter) headers["retry-after"] = err.meta.retryAfter - return new Response( - JSON.stringify({ - type: "error", - error: { type: "rate_limit_error", message: err.detail || err.message }, - }), - { status: 429, headers }, - ) - } - const type = - err.status === 401 || err.status === 403 ? "authentication_error" : "api_error" - return jsonError(err.status, type, err.detail || err.message) - } - throw err +function routeProvider(body: AnthropicRequest, reqId: string): Provider | Response { + if (!body.model) { + return jsonError( + 400, + "invalid_request_error", + `Missing "model" in request body. ${knownModelsMessage()}`, + ) + } + body.model = normalizeIncomingModel(body.model) + const provider = providerForModel(body.model) + if (!provider) { + rootLog.warn("unknown model", { reqId, model: body.model }) + return jsonError( + 400, + "invalid_request_error", + `Unknown model "${body.model}". ${knownModelsMessage()}`, + ) } + return provider +} - if (wantStream) { - const stream = translateStream(upstream.body, { messageId, model: body.model }) - return new Response(stream, { - status: 200, - headers: { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }, - }) +function knownModelsMessage(): string { + const groups = new Map() + for (const { model, provider } of allSupportedModels()) { + const list = groups.get(provider) ?? [] + list.push(model) + groups.set(provider, list) + } + const parts: string[] = [] + for (const [provider, models] of groups) { + parts.push(`${provider}: ${models.join(", ")}`) } + return `Supported: ${parts.join("; ")}.` +} +async function parseJsonBody(req: Request): Promise { try { - const result = await accumulateResponse(upstream.body, { messageId, model: body.model }) - return new Response(JSON.stringify(result), { - headers: { "content-type": "application/json" }, - }) + return (await req.json()) as AnthropicRequest } catch (err) { - if (err instanceof UpstreamStreamError) { - log.warn("upstream stream error (non-streaming)", { - reqId, - kind: err.kind, - message: err.message, - }) - if (err.kind === "rate_limit") { - const headers: Record = { "content-type": "application/json" } - if (err.retryAfterSeconds) headers["retry-after"] = String(err.retryAfterSeconds) - return new Response( - JSON.stringify({ - type: "error", - error: { type: "rate_limit_error", message: err.message }, - }), - { status: 429, headers }, - ) + return jsonError(400, "invalid_request_error", `Invalid JSON: ${err}`) + } +} + +function isAbortError(err: unknown): boolean { + return err instanceof Error && err.name === "AbortError" +} + +function wrapStreamResponse( + resp: Response, + reqId: string, + start: number, + log: ReturnType, +): Response { + const body = resp.body! + const reader = body.getReader() + const stream = new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await reader.read() + if (done) { + log.info("request_completed", { reqId, status: resp.status, ms: Date.now() - start }) + controller.close() + return + } + controller.enqueue(value) + } catch (err) { + if (isAbortError(err)) { + log.info("client disconnected", { reqId, ms: Date.now() - start }) + } else { + log.error("stream error", { reqId, err: String(err) }) + } + controller.error(err) } - return jsonError(502, "api_error", err.message) - } - throw err + }, + cancel() { + reader.cancel().catch(() => {}) + }, + }) + const headers = new Headers(resp.headers) + headers.delete("content-encoding") + headers.delete("content-length") + headers.delete("transfer-encoding") + return new Response(stream, { + status: resp.status, + statusText: resp.statusText, + headers, + }) +} + +function redactedQuery(url: URL): Record { + const out: Record = {} + for (const [k, v] of url.searchParams) { + out[k] = REDACT_KEYS.has(k.toLowerCase()) ? `[redacted len=${v.length}]` : v } + return out } function jsonError(status: number, type: string, message: string): Response { diff --git a/src/translate/sse.ts b/src/sse.ts similarity index 100% rename from src/translate/sse.ts rename to src/sse.ts diff --git a/src/translate/model-allowlist.ts b/src/translate/model-allowlist.ts deleted file mode 100644 index c561967..0000000 --- a/src/translate/model-allowlist.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const ALLOWED_MODELS = new Set([ - "gpt-5.2", - "gpt-5.3-codex", - "gpt-5.4", - "gpt-5.4-mini", -]) - -export function assertAllowedModel(model: string): void { - if (!ALLOWED_MODELS.has(model)) { - throw new ModelNotAllowedError(model) - } -} - -export class ModelNotAllowedError extends Error { - constructor(public model: string) { - super(`Model not allowed: ${model}`) - this.name = "ModelNotAllowedError" - } -}