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..2b16c4b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,69 @@ +# Changelog + +## 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..70a5d59 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,26 @@ -# 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, your **Gemini Code Assist** OAuth session, 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 +29,127 @@ 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). + +### 2. Pick a provider and authenticate + +The proxy supports three upstream providers. Pick one and run its login flow; the +proxy will refuse to start traffic until a token is stored. -**From source** (requires [Bun](https://bun.sh) 1.3+): +**Codex (ChatGPT Plus/Pro):** ```sh -git clone https://github.com/raine/claude-codex-proxy -cd claude-codex-proxy -bun install -bun src/cli.ts --version +claude-code-proxy codex auth login # browser OAuth (PKCE) +# or, on a headless machine: +claude-code-proxy codex auth device # device-code flow ``` -### 2. Authenticate with ChatGPT +Sign in with your **ChatGPT Plus/Pro account**, not an OpenAI API account. -Open a browser (PKCE flow): +**Kimi (kimi.com Kimi Code):** ```sh -claude-codex-proxy auth login +claude-code-proxy kimi auth login # device-code flow (prints URL + code) ``` -Or, on a headless machine (device code flow): +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. + +**Gemini (Google Gemini Code Assist OAuth):** ```sh -claude-codex-proxy auth device +claude-code-proxy gemini auth login # browser OAuth ``` -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. +This uses the same Google OAuth client and credential file convention as Gemini +CLI (`~/.gemini/oauth_creds.json` by default). If you already authenticated with +Gemini CLI, `gemini auth status` should usually work without logging in again. + +Codex and Kimi credentials go to Keychain on macOS; on other platforms they are +written to `~/.config/claude-code-proxy//auth.json` (mode 0600). +Gemini credentials use Gemini CLI's file convention unless you override the +path. -Verify it stuck: +Verify: ```sh -claude-codex-proxy auth status +claude-code-proxy codex auth status +claude-code-proxy gemini 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.4`, `gpt-5.3-codex`, `gpt-5.4-mini`, `gpt-5.2` → **codex** +- `gemini-3.1-pro-preview`, `gemini-3-pro-preview`, `gemini-3-flash-preview` → **gemini** +- `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=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 + +# Gemini ANTHROPIC_BASE_URL=http://localhost:18765 \ ANTHROPIC_AUTH_TOKEN=unused \ -ANTHROPIC_MODEL=gpt-5.4 \ +ANTHROPIC_MODEL=gemini-3.1-pro-preview[1m] \ +ANTHROPIC_SMALL_FAST_MODEL=gemini-3-flash-preview[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,15 +157,102 @@ 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`): + +```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. +## Local usage endpoint -## Supported models +When authenticated with Codex, the proxy exposes a local status endpoint for +ChatGPT/Codex plan usage: + +```sh +curl http://localhost:18765/_claude-code-proxy/usage | jq +``` + +The endpoint returns normalized primary and secondary usage windows, including +used percentage, window duration, and reset time. It is intended for statusline +or monitoring integrations and is cached briefly by the proxy to avoid polling +ChatGPT on every prompt render. + +## 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. Confirmed working on **Plus**: @@ -116,14 +260,79 @@ 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 | + +### Gemini (Gemini Code Assist) + +Upstream: `https://cloudcode-pa.googleapis.com/v1internal:*` (Gemini Code +Assist). + +Accepted models: + +- `gemini-3.1-pro-preview` +- `gemini-3-pro-preview` +- `gemini-3-flash-preview` +- `gemini-2.5-pro` +- `gemini-2.5-flash` +- `gemini-2.5-flash-lite` + +Preview model fallback is enabled by default: + +- `gemini-3.1-pro-preview` → `gemini-3-pro-preview` → `gemini-2.5-pro` +- `gemini-3-pro-preview` → `gemini-2.5-pro` +- `gemini-3-flash-preview` → `gemini-2.5-flash` + +Reasoning effort is translated to Gemini thinking settings. `max` and `xhigh` +map to the highest Gemini tier exposed by Code Assist. + +Auth: + +| Command | What it does | +| -------------------- | ------------------------------------------------- | +| `gemini auth login` | Browser OAuth using Gemini CLI's OAuth client | +| `gemini auth status` | Show token expiry and credential file path | +| `gemini auth logout` | Delete `~/.gemini/oauth_creds.json` or configured path | ## How it works @@ -131,81 +340,59 @@ 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 | +| `gemini auth login` / `status` / `logout` | Gemini 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 +401,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 +441,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 `auth login` again to re-authenticate. +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 +``` + +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 +499,157 @@ 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", + "defaultEffort": "high" + }, + "routing": { + "claudeAliasProvider": "none" + }, + "gemini": { + "model": "gemini-3.1-pro-preview", + "smallFastModel": "gemini-3-flash-preview", + "oauthCredsPath": "~/.gemini/oauth_creds.json", + "enableFallback": true, + "enableGoogleOneCredits": false, + "defaultEffort": "high" + }, + "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_CLAUDE_ALIAS_PROVIDER` | `routing.claudeAliasProvider` | `none` | Route Claude family aliases (`opus`, `sonnet`, `haiku`, `claude-opus-4-7`, etc.) to `codex`, `gemini`, or `kimi` | +| `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`) | +| `CCP_CODEX_EFFORT` | `codex.effort` | unset | Force all Codex requests to this reasoning effort, ignoring Claude Code `/effort` (`none`, `low`, `medium`, `high`, `xhigh`; `max` aliases to `xhigh`) | +| `CCP_CODEX_DEFAULT_EFFORT` | `codex.defaultEffort` | unset | Default Codex reasoning effort when Claude Code does not send one; Claude Code `/effort` wins (`none`, `low`, `medium`, `high`, `xhigh`; `max` aliases to `xhigh`) | +| `CCP_GEMINI_MODEL` | `gemini.model` | unset | Force all Gemini requests to this model | +| `CCP_GEMINI_SMALL_FAST_MODEL` | `gemini.smallFastModel` | unset | Wrapper/config hint for Gemini background requests | +| `CCP_GEMINI_OAUTH_CREDS_PATH` | `gemini.oauthCredsPath` | `~/.gemini/oauth_creds.json` | Gemini OAuth credential file path | +| `CCP_GEMINI_OAUTH_CLIENT_ID` | — | read from installed Gemini CLI | Override Gemini OAuth client ID if Gemini CLI is unavailable | +| `CCP_GEMINI_OAUTH_CLIENT_SECRET` | — | read from installed Gemini CLI | Override Gemini OAuth client secret if Gemini CLI is unavailable | +| `CCP_GEMINI_ENABLE_FALLBACK` | `gemini.enableFallback` | `true` | Enable preview-model fallback chains | +| `CCP_GEMINI_ENABLE_GOOGLE_ONE_CREDITS` | `gemini.enableGoogleOneCredits` | `false` | Opt in to Gemini Code Assist Google One AI credits for eligible preview models when available | +| `CCP_GEMINI_DEFAULT_EFFORT` | `gemini.defaultEffort` | unset | Default Gemini thinking effort when Claude Code does not send one (`none`, `low`, `medium`, `high`, `max`, `xhigh`) | +| `CCP_GEMINI_CODE_ASSIST_ENDPOINT` | `gemini.endpoint` | `https://cloudcode-pa.googleapis.com` | Override Gemini Code Assist endpoint (debugging only) | +| `CCP_GEMINI_CODE_ASSIST_API_VERSION` | `gemini.apiVersion` | `v1internal` | Override Gemini Code Assist API version (debugging only) | +| `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. +- `~/.gemini/oauth_creds.json` — Gemini OAuth credentials by default. Override + with `CCP_GEMINI_OAUTH_CREDS_PATH` or `gemini.oauthCredsPath`. ## 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/bun.lock b/bun.lock index 14ba968..c869289 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "claude-openai-proxy", "dependencies": { + "google-auth-library": "^10.6.2", "gpt-tokenizer": "^2.9.0", }, "devDependencies": { @@ -18,12 +19,58 @@ "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + + "google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + "gpt-tokenizer": ["gpt-tokenizer@2.9.0", "", {}, "sha512-YSpexBL/k4bfliAzMrRqn3M6+it02LutVyhVpDeMKrC/O9+pCe/5s8U2hYKa2vFLD5/vHhsKc8sOn/qGqII8Kg=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], } } 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..962009f 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.9", + "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", @@ -12,6 +12,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "google-auth-library": "^10.6.2", "gpt-tokenizer": "^2.9.0" }, "devDependencies": { 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..0b35707 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" | "xhigh" 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..ae76854 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,171 @@ 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_CLAUDE_ALIAS_PROVIDER) overrides.push("CCP_CLAUDE_ALIAS_PROVIDER (env)") + else if (fromFile.routing?.claudeAliasProvider) overrides.push("routing.claudeAliasProvider (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_GEMINI_MODEL) overrides.push("CCP_GEMINI_MODEL (env)") + else if (fromFile.gemini?.model) overrides.push("gemini.model (config)") + + if (cfg.env.CCP_GEMINI_SMALL_FAST_MODEL) overrides.push("CCP_GEMINI_SMALL_FAST_MODEL (env)") + else if (fromFile.gemini?.smallFastModel) overrides.push("gemini.smallFastModel (config)") + + if (cfg.env.CCP_GEMINI_OAUTH_CREDS_PATH) overrides.push("CCP_GEMINI_OAUTH_CREDS_PATH (env)") + else if (fromFile.gemini?.oauthCredsPath) overrides.push("gemini.oauthCredsPath (config)") + + if (cfg.env.CCP_GEMINI_ENABLE_FALLBACK) overrides.push("CCP_GEMINI_ENABLE_FALLBACK (env)") + else if (fromFile.gemini?.enableFallback !== undefined) overrides.push("gemini.enableFallback (config)") + + if (cfg.env.CCP_GEMINI_ENABLE_GOOGLE_ONE_CREDITS) { + overrides.push("CCP_GEMINI_ENABLE_GOOGLE_ONE_CREDITS (env)") + } else if (fromFile.gemini?.enableGoogleOneCredits !== undefined) { + overrides.push("gemini.enableGoogleOneCredits (config)") + } + + if (cfg.env.CCP_GEMINI_DEFAULT_EFFORT) overrides.push("CCP_GEMINI_DEFAULT_EFFORT (env)") + else if (fromFile.gemini?.defaultEffort) overrides.push("gemini.defaultEffort (config)") + + if (cfg.env.CCP_GEMINI_CODE_ASSIST_ENDPOINT) overrides.push("CCP_GEMINI_CODE_ASSIST_ENDPOINT (env)") + else if (fromFile.gemini?.endpoint) overrides.push("gemini.endpoint (config)") + + if (cfg.env.CCP_GEMINI_CODE_ASSIST_API_VERSION) overrides.push("CCP_GEMINI_CODE_ASSIST_API_VERSION (env)") + else if (fromFile.gemini?.apiVersion) overrides.push("gemini.apiVersion (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..311d776 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,253 @@ +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, + codexDefaultEffort, + claudeAliasProvider, + geminiModel, + geminiSmallFastModel, + geminiOauthCredsPath, + geminiEnableFallback, + geminiEnableGoogleOneCredits, + geminiDefaultEffort, + 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(codexDefaultEffort()).toBeUndefined() + expect(claudeAliasProvider()).toBe("none") + expect(geminiModel()).toBeUndefined() + expect(geminiSmallFastModel()).toBeUndefined() + expect(geminiOauthCredsPath()).toBeUndefined() + expect(geminiEnableFallback()).toBe(true) + expect(geminiEnableGoogleOneCredits()).toBe(false) + expect(geminiDefaultEffort()).toBeUndefined() + 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("kimi.oauthHost from config.json", () => { + writeFileSync( + configPath, + JSON.stringify({ kimi: { oauthHost: "https://auth.example.com" } }), + ) + setEnv({}) + expect(kimiOauthHost()).toBe("https://auth.example.com") + }) + + it("routing and gemini settings from config.json", () => { + writeFileSync( + configPath, + JSON.stringify({ + routing: { claudeAliasProvider: "gemini" }, + gemini: { + model: "gemini-3.1-pro-preview", + smallFastModel: "gemini-3-flash-preview", + oauthCredsPath: "/tmp/gemini-oauth.json", + enableFallback: false, + enableGoogleOneCredits: true, + defaultEffort: "max", + }, + }), + ) + setEnv({}) + expect(claudeAliasProvider()).toBe("gemini") + expect(geminiModel()).toBe("gemini-3.1-pro-preview") + expect(geminiSmallFastModel()).toBe("gemini-3-flash-preview") + expect(geminiOauthCredsPath()).toBe("/tmp/gemini-oauth.json") + expect(geminiEnableFallback()).toBe(false) + expect(geminiEnableGoogleOneCredits()).toBe(true) + expect(geminiDefaultEffort()).toBe("max") + }) + + 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_DEFAULT_EFFORT env wins over config", () => { + writeFileSync( + configPath, + JSON.stringify({ codex: { defaultEffort: "medium" } }), + ) + setEnv({ CCP_CODEX_DEFAULT_EFFORT: "high" }) + expect(codexDefaultEffort()).toBe("high") + }) + + it("CCP_CLAUDE_ALIAS_PROVIDER env wins over config", () => { + writeFileSync( + configPath, + JSON.stringify({ routing: { claudeAliasProvider: "gemini" } }), + ) + setEnv({ CCP_CLAUDE_ALIAS_PROVIDER: "codex" }) + expect(claudeAliasProvider()).toBe("codex") + }) + + it("CCP_GEMINI_ENABLE_FALLBACK env can disable fallback", () => { + writeFileSync(configPath, JSON.stringify({ gemini: { enableFallback: true } })) + setEnv({ CCP_GEMINI_ENABLE_FALLBACK: "false" }) + expect(geminiEnableFallback()).toBe(false) + }) + + it("CCP_GEMINI_ENABLE_GOOGLE_ONE_CREDITS env can enable credit usage", () => { + writeFileSync(configPath, JSON.stringify({ gemini: { enableGoogleOneCredits: false } })) + setEnv({ CCP_GEMINI_ENABLE_GOOGLE_ONE_CREDITS: "1" }) + expect(geminiEnableGoogleOneCredits()).toBe(true) + }) + + 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_DEFAULT_EFFORT env falls through to file value", () => { + writeFileSync(configPath, JSON.stringify({ codex: { defaultEffort: "low" } })) + setEnv({ CCP_CODEX_DEFAULT_EFFORT: "" }) + expect(codexDefaultEffort()).toBe("low") + }) + + 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..3a9b5a3 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,354 @@ +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 + routing?: { + claudeAliasProvider?: string + } + codex?: { + originator?: string + userAgent?: string + model?: string + effort?: string + defaultEffort?: string + } + gemini?: { + model?: string + smallFastModel?: string + oauthCredsPath?: string + enableFallback?: boolean + enableGoogleOneCredits?: boolean + defaultEffort?: string + endpoint?: string + apiVersion?: 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 routing = validateStringSection("routing", ["claudeAliasProvider"], { + claudeAliasProvider: "string", + }) + if (routing) out.routing = routing + + const codex = validateStringSection("codex", ["originator", "userAgent", "model", "effort", "defaultEffort"], { + originator: "string", + userAgent: "string", + model: "string", + effort: "string", + defaultEffort: "string", + }) + if (codex) out.codex = codex + + const gemini = validateStringSection( + "gemini", + [ + "model", + "smallFastModel", + "oauthCredsPath", + "enableFallback", + "enableGoogleOneCredits", + "defaultEffort", + "endpoint", + "apiVersion", + ], + { + model: "string", + smallFastModel: "string", + oauthCredsPath: "string", + enableFallback: "boolean", + enableGoogleOneCredits: "boolean", + defaultEffort: "string", + endpoint: "string", + apiVersion: "string", + }, + ) + if (gemini) out.gemini = gemini + + 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 type ClaudeAliasProvider = "codex" | "gemini" | "kimi" | "none" + +const CLAUDE_ALIAS_PROVIDERS = new Set([ + "codex", + "gemini", + "kimi", + "none", +]) + +export function claudeAliasProvider(): ClaudeAliasProvider { + const c = getConfig() + const raw = emptyOrUnset(c.env.CCP_CLAUDE_ALIAS_PROVIDER) ?? + emptyOrUnset(c.file.routing?.claudeAliasProvider) ?? + "none" + if (CLAUDE_ALIAS_PROVIDERS.has(raw as ClaudeAliasProvider)) { + return raw as ClaudeAliasProvider + } + process.stderr.write( + `claude-code-proxy: ignoring invalid Claude alias provider "${raw}"; expected one of ${Array.from(CLAUDE_ALIAS_PROVIDERS).join(", ")}\n`, + ) + return "none" +} + +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 model/effort escape hatches). +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 codexDefaultEffort(): string | undefined { + const c = getConfig() + return emptyOrUnset(c.env.CCP_CODEX_DEFAULT_EFFORT) ?? emptyOrUnset(c.file.codex?.defaultEffort) +} + +export function geminiModel(): string | undefined { + const c = getConfig() + return emptyOrUnset(c.env.CCP_GEMINI_MODEL) ?? emptyOrUnset(c.file.gemini?.model) +} + +export function geminiSmallFastModel(): string | undefined { + const c = getConfig() + return emptyOrUnset(c.env.CCP_GEMINI_SMALL_FAST_MODEL) ?? + emptyOrUnset(c.file.gemini?.smallFastModel) +} + +export function geminiOauthCredsPath(): string | undefined { + const c = getConfig() + return emptyOrUnset(c.env.CCP_GEMINI_OAUTH_CREDS_PATH) ?? + emptyOrUnset(c.file.gemini?.oauthCredsPath) +} + +export function geminiEnableFallback(): boolean { + const c = getConfig() + const raw = emptyOrUnset(c.env.CCP_GEMINI_ENABLE_FALLBACK) + if (raw !== undefined) return !["0", "false", "no", "off"].includes(raw.toLowerCase()) + return c.file.gemini?.enableFallback ?? true +} + +export function geminiEnableGoogleOneCredits(): boolean { + const c = getConfig() + const raw = emptyOrUnset(c.env.CCP_GEMINI_ENABLE_GOOGLE_ONE_CREDITS) + if (raw !== undefined) return !["0", "false", "no", "off"].includes(raw.toLowerCase()) + return c.file.gemini?.enableGoogleOneCredits ?? false +} + +export function geminiDefaultEffort(): string | undefined { + const c = getConfig() + return emptyOrUnset(c.env.CCP_GEMINI_DEFAULT_EFFORT) ?? + emptyOrUnset(c.file.gemini?.defaultEffort) +} + +export function geminiEndpoint(): string { + const c = getConfig() + return c.env.CCP_GEMINI_CODE_ASSIST_ENDPOINT ?? + c.file.gemini?.endpoint ?? + "https://cloudcode-pa.googleapis.com" +} + +export function geminiApiVersion(): string { + const c = getConfig() + return c.env.CCP_GEMINI_CODE_ASSIST_API_VERSION ?? + c.file.gemini?.apiVersion ?? + "v1internal" +} + +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/auth/constants.ts b/src/providers/codex/auth/constants.ts similarity index 73% rename from src/auth/constants.ts rename to src/providers/codex/auth/constants.ts index 1daf900..bceaa9b 100644 --- a/src/auth/constants.ts +++ b/src/providers/codex/auth/constants.ts @@ -1,7 +1,8 @@ export const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" export const ISSUER = "https://auth.openai.com" export const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" +export const CODEX_USAGE_ENDPOINT = "https://chatgpt.com/backend-api/codex/usage" 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 57% rename from src/codex/client.ts rename to src/providers/codex/client.ts index 9046a67..73f2f03 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 { 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,20 +73,24 @@ 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`) } log.debug("posting to codex", { @@ -80,14 +98,13 @@ async function doFetch( model: body.model, inputCount: body.input.length, toolCount: body.tools?.length ?? 0, - sessionId: opts.sessionId, }) return fetch(CODEX_API_ENDPOINT, { 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.ts b/src/providers/codex/index.ts new file mode 100644 index 0000000..b7ee03b --- /dev/null +++ b/src/providers/codex/index.ts @@ -0,0 +1,370 @@ +import type { AnthropicRequest } from "../../anthropic/schema.ts" +import type { Provider, RequestContext, CliHandlers } from "../types.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 { 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" }, + }) +} + +async function handleCountTokens(body: AnthropicRequest, ctx: RequestContext): Promise { + const log = ctx.childLogger("provider.codex") + const resolvedModel = resolveModel(body.model) + const translated = translateRequest({ ...body, model: resolvedModel }) + 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 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 = 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, + requestedEffort: body.output_config?.effort ?? null, + hasContextManagement: contextManagement !== undefined, + promptCacheKey: translated.prompt_cache_key, + reasoningEffort: translated.reasoning?.effort ?? null, + }) + 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(["gpt-5.2", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini", "gpt-5.5"]), + 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..c16fbfc --- /dev/null +++ b/src/providers/codex/translate/model-allowlist.test.ts @@ -0,0 +1,56 @@ +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 } 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") + }) +}) diff --git a/src/providers/codex/translate/model-allowlist.ts b/src/providers/codex/translate/model-allowlist.ts new file mode 100644 index 0000000..213b5f1 --- /dev/null +++ b/src/providers/codex/translate/model-allowlist.ts @@ -0,0 +1,43 @@ +import { codexModel } from "../../../config.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 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 function resolveModel(model: string): string { + // 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 override = codexModel() + if (override !== undefined) return override + + return MODEL_ALIASES.get(model) ?? 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/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..8199b0e --- /dev/null +++ b/src/providers/codex/translate/request.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "bun:test" +import type { AnthropicRequest } from "../../../anthropic/schema.ts" +import { loadConfig } from "../../../config.ts" +import { translateRequest } from "./request.ts" + +const baseRequest: AnthropicRequest = { + model: "claude-sonnet-4-6", + messages: [{ role: "user", content: "hello" }], +} + +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("maps Claude max effort to Codex xhigh reasoning effort", () => { + const translated = translateRequest({ + ...baseRequest, + output_config: { effort: "max" }, + }) + + expect(translated.reasoning).toEqual({ effort: "xhigh" }) + expect(translated.include).toEqual(["reasoning.encrypted_content"]) + }) + + it("maps max effort override to Codex xhigh reasoning effort", () => { + loadConfig({ env: { CCP_CODEX_EFFORT: "max" }, forceReload: true }) + try { + const translated = translateRequest(baseRequest) + + expect(translated.reasoning).toEqual({ effort: "xhigh" }) + expect(translated.include).toEqual(["reasoning.encrypted_content"]) + } finally { + loadConfig({ env: {}, forceReload: true }) + } + }) + + it("uses the default effort only when Claude Code does not send effort", () => { + loadConfig({ env: { CCP_CODEX_DEFAULT_EFFORT: "high" }, forceReload: true }) + try { + const translated = translateRequest(baseRequest) + + expect(translated.reasoning).toEqual({ effort: "high" }) + expect(translated.include).toEqual(["reasoning.encrypted_content"]) + } finally { + loadConfig({ env: {}, forceReload: true }) + } + }) + + it("lets Claude Code request effort win over the default effort", () => { + loadConfig({ env: { CCP_CODEX_DEFAULT_EFFORT: "high" }, forceReload: true }) + try { + const translated = translateRequest({ + ...baseRequest, + output_config: { effort: "low" }, + }) + + expect(translated.reasoning).toEqual({ effort: "low" }) + } finally { + loadConfig({ env: {}, forceReload: true }) + } + }) + + 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 70% rename from src/translate/request.ts rename to src/providers/codex/translate/request.ts index 326455e..9b1de44 100644 --- a/src/translate/request.ts +++ b/src/providers/codex/translate/request.ts @@ -5,8 +5,14 @@ import type { AnthropicRequest, AnthropicTextBlock, AnthropicTool, -} from "../anthropic/schema.ts" +} from "../../../anthropic/schema.ts" +import { codexDefaultEffort, codexEffort } from "../../../config.ts" +export type Effort = "none" | "low" | "medium" | "high" | "xhigh" +type EffortOverride = Effort | "max" + +// 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 +24,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?: string prompt_cache_key?: string - reasoning?: { effort?: "low" | "medium" | "high" } text?: { verbosity?: "low" | "medium" | "high" format?: @@ -30,6 +37,7 @@ export interface ResponsesRequest { | { type: "json_object" } | { type: "json_schema"; name: string; schema: unknown; strict?: boolean } } + client_metadata?: Record } export type ResponsesInputItem = @@ -67,6 +75,52 @@ export interface TranslateOptions { sessionId?: string } +const VALID_EFFORTS = new Set(["none", "low", "medium", "high", "xhigh", "max"]) + +const ANTHROPIC_EFFORTS = new Set(["low", "medium", "high", "max", "xhigh"]) + +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 normalizeCodexEffort(effort: EffortOverride): Effort { + return effort === "max" ? "xhigh" : effort +} + +function validateEffortOverride(name: string, effort: string): EffortOverride { + if (!VALID_EFFORTS.has(effort as EffortOverride)) { + throw new Error( + `Invalid ${name}: "${effort}". Must be one of: ${Array.from(VALID_EFFORTS).join(", ")}`, + ) + } + return effort as EffortOverride +} + +function resolveEffort(effort?: Effort): Effort | undefined { + const override = codexEffort() + if (override !== undefined) { + return normalizeCodexEffort(validateEffortOverride("effort override", override)) + } + + const defaultEffort = codexDefaultEffort() + if (effort === undefined && defaultEffort !== undefined) { + return normalizeCodexEffort(validateEffortOverride("default effort", defaultEffort)) + } + + return effort +} + export function translateRequest(req: AnthropicRequest, opts: TranslateOptions = {}): ResponsesRequest { const instructions = buildInstructions(req.system) const input = buildInput(req.messages) @@ -88,7 +142,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 +149,12 @@ 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 } + 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 +174,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 +238,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 +248,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/codex/usage.test.ts b/src/providers/codex/usage.test.ts new file mode 100644 index 0000000..22d8b53 --- /dev/null +++ b/src/providers/codex/usage.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, afterEach } from "bun:test" +import { normalizeCodexUsage, resetCodexUsageCacheForTests } from "./usage.ts" + +afterEach(() => { + resetCodexUsageCacheForTests() +}) + +describe("normalizeCodexUsage", () => { + it("maps Codex usage windows to proxy status shape", () => { + const usage = normalizeCodexUsage({ + plan_type: "pro", + rate_limit: { + limit_reached: false, + primary_window: { + used_percent: 3, + limit_window_seconds: 18000, + reset_at: 1777932010, + }, + secondary_window: { + used_percent: 39, + limit_window_seconds: 604800, + reset_at: 1777982406, + }, + }, + }) + + expect(usage.provider).toBe("codex") + expect(usage.stale).toBe(false) + expect(usage.plan_type).toBe("pro") + expect(usage.rate_limit_reached).toBe(false) + expect(usage.rate_limits.primary).toEqual({ + used_percent: 3, + window_minutes: 300, + resets_at: 1777932010, + }) + expect(usage.rate_limits.secondary).toEqual({ + used_percent: 39, + window_minutes: 10080, + resets_at: 1777982406, + }) + }) + + it("falls back to reset_after_seconds when reset_at is missing", () => { + const before = Math.floor(Date.now() / 1000) + const usage = normalizeCodexUsage({ + rate_limit: { + primary_window: { + used_percent: 12, + limit_window_seconds: 18000, + reset_after_seconds: 60, + }, + }, + }) + + expect(usage.rate_limits.primary?.used_percent).toBe(12) + expect(usage.rate_limits.primary?.window_minutes).toBe(300) + expect(usage.rate_limits.primary?.resets_at).toBeGreaterThanOrEqual(before + 60) + expect(usage.rate_limits.primary?.resets_at).toBeLessThanOrEqual(before + 61) + expect(usage.rate_limits.secondary).toBeNull() + }) +}) diff --git a/src/providers/codex/usage.ts b/src/providers/codex/usage.ts new file mode 100644 index 0000000..a98a2fb --- /dev/null +++ b/src/providers/codex/usage.ts @@ -0,0 +1,198 @@ +import { codexOriginator, codexUserAgent } from "../../config.ts" +import type { Logger } from "../../log.ts" +import { CODEX_USAGE_ENDPOINT, ORIGINATOR as ORIGINATOR_DEFAULT } from "./auth/constants.ts" +import { forceRefresh, getAuth } from "./auth/manager.ts" + +declare const BUILD_VERSION: string | undefined +const PROXY_VERSION = typeof BUILD_VERSION === "string" ? BUILD_VERSION : "dev" +const CACHE_TTL_MS = 120_000 +const FETCH_TIMEOUT_MS = 10_000 + +export interface CodexUsageWindow { + used_percent: number + window_minutes: number | null + resets_at: number | null +} + +export interface CodexUsageSnapshot { + provider: "codex" + updated_at: number + stale: boolean + plan_type: string | null + rate_limit_reached: boolean | null + rate_limits: { + primary: CodexUsageWindow | null + secondary: CodexUsageWindow | null + } +} + +interface CacheEntry { + snapshot: CodexUsageSnapshot + fetchedAtMs: number +} + +let cached: CacheEntry | undefined +let inflight: Promise | undefined + +export async function getCodexUsage( + log: Logger, + opts: { signal?: AbortSignal } = {}, +): Promise { + const now = Date.now() + if (cached && now - cached.fetchedAtMs < CACHE_TTL_MS) { + return withStale(cached.snapshot, false) + } + + if (cached) { + void refreshCodexUsage(log).catch((err) => { + log.warn("background usage refresh failed", { err: String(err) }) + }) + return withStale(cached.snapshot, true) + } + + return refreshCodexUsage(log, opts.signal) +} + +async function refreshCodexUsage(log: Logger, signal?: AbortSignal): Promise { + if (inflight) return inflight + inflight = fetchCodexUsage(log, signal) + .then((snapshot) => { + cached = { snapshot: withStale(snapshot, false), fetchedAtMs: Date.now() } + return withStale(snapshot, false) + }) + .finally(() => { + inflight = undefined + }) + return inflight +} + +async function fetchCodexUsage(log: Logger, signal?: AbortSignal): Promise { + let auth = await getAuth() + let resp = await doFetchUsage(auth.access, auth.accountId, signal) + + if (resp.status === 401) { + log.warn("usage endpoint got 401, refreshing token", {}) + auth = await forceRefresh() + resp = await doFetchUsage(auth.access, auth.accountId, signal) + } + + if (!resp.ok) { + const text = await safeText(resp) + throw new CodexUsageError(resp.status, "Codex usage request failed", text) + } + + const raw = await resp.json() + return normalizeCodexUsage(raw) +} + +async function doFetchUsage( + accessToken: string, + accountId: string | undefined, + signal?: AbortSignal, +): Promise { + const controller = new AbortController() + let timedOut = false + const timeout = setTimeout(() => { + timedOut = true + controller.abort() + }, FETCH_TIMEOUT_MS) + const abort = () => controller.abort() + if (signal?.aborted) controller.abort() + else signal?.addEventListener("abort", abort, { once: true }) + + const headers = new Headers({ + accept: "application/json", + authorization: `Bearer ${accessToken}`, + 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) + + try { + return await fetch(CODEX_USAGE_ENDPOINT, { headers, signal: controller.signal }) + } catch (err) { + if (timedOut) throw new CodexUsageError(504, "Codex usage request timed out") + throw err + } finally { + clearTimeout(timeout) + signal?.removeEventListener("abort", abort) + } +} + +export function normalizeCodexUsage(raw: unknown): CodexUsageSnapshot { + const root = asRecord(raw) + const rateLimit = asRecord(root.rate_limit) + const nowSeconds = Math.floor(Date.now() / 1000) + return { + provider: "codex", + updated_at: nowSeconds, + stale: false, + plan_type: stringOrNull(root.plan_type), + rate_limit_reached: booleanOrNull(rateLimit.limit_reached), + rate_limits: { + primary: normalizeWindow(rateLimit.primary_window, nowSeconds), + secondary: normalizeWindow(rateLimit.secondary_window, nowSeconds), + }, + } +} + +function normalizeWindow(raw: unknown, nowSeconds: number): CodexUsageWindow | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null + const window = raw as Record + const usedPercent = numberOrNull(window.used_percent) + const windowSeconds = numberOrNull(window.limit_window_seconds) + const resetAt = numberOrNull(window.reset_at) + const resetAfter = numberOrNull(window.reset_after_seconds) + return { + used_percent: usedPercent ?? 0, + window_minutes: windowSeconds === null ? null : Math.round(windowSeconds / 60), + resets_at: resetAt ?? (resetAfter === null ? null : nowSeconds + resetAfter), + } +} + +function withStale(snapshot: CodexUsageSnapshot, stale: boolean): CodexUsageSnapshot { + return { ...snapshot, stale } +} + +function asRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {} + return value as Record +} + +function numberOrNull(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null +} + +function booleanOrNull(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" ? value : null +} + +async function safeText(resp: Response): Promise { + try { + return await resp.text() + } catch { + return "" + } +} + +export class CodexUsageError extends Error { + constructor( + public status: number, + message: string, + public detail?: string, + ) { + super(message) + this.name = "CodexUsageError" + } +} + +export function resetCodexUsageCacheForTests(): void { + cached = undefined + inflight = undefined +} diff --git a/src/providers/gemini/auth/oauth.ts b/src/providers/gemini/auth/oauth.ts new file mode 100644 index 0000000..78d8d3d --- /dev/null +++ b/src/providers/gemini/auth/oauth.ts @@ -0,0 +1,284 @@ +import { createServer } from "node:http" +import { constants as fsConstants, promises as fs } from "node:fs" +import { dirname, join, resolve } from "node:path" +import { homedir } from "node:os" +import net from "node:net" +import { OAuth2Client, type Credentials } from "google-auth-library" +import { geminiOauthCredsPath } from "../../../config.ts" + +const OAUTH_SCOPE = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", +] + +export function authPath(): string { + return expandHome(geminiOauthCredsPath() ?? "~/.gemini/oauth_creds.json") +} + +export async function loadCredentials(): Promise { + try { + return JSON.parse(await fs.readFile(authPath(), "utf8")) as Credentials + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined + throw err + } +} + +export async function saveCredentials(credentials: Credentials): Promise { + const path = authPath() + await fs.mkdir(dirname(path), { recursive: true }) + await fs.writeFile(path, JSON.stringify(credentials, null, 2), { mode: 0o600 }) + await fs.chmod(path, 0o600).catch(() => {}) +} + +export async function clearCredentials(): Promise { + await fs.unlink(authPath()).catch((err) => { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err + }) +} + +export async function authClient(): Promise { + const credentials = await loadCredentials() + if (!credentials) { + throw new GeminiAuthError( + `Not authenticated. Run: claude-code-proxy gemini auth login. Expected credentials at ${authPath()}`, + ) + } + const oauthClient = await loadOauthClientConfig() + + const client = new OAuth2Client({ + clientId: oauthClient.clientId, + clientSecret: oauthClient.clientSecret, + }) + client.setCredentials(credentials) + client.on("tokens", async (tokens) => { + await saveCredentials({ + ...client.credentials, + ...tokens, + refresh_token: tokens.refresh_token ?? client.credentials.refresh_token, + }) + }) + return client +} + +export async function accessToken(): Promise { + const client = await authClient() + const token = await client.getAccessToken() + if (!token.token) { + throw new GeminiAuthError("Gemini OAuth credentials did not produce an access token") + } + return token.token +} + +export async function runBrowserLogin(): Promise { + const oauthClient = await loadOauthClientConfig() + const port = await getAvailablePort() + const redirectUri = `http://127.0.0.1:${port}/oauth2callback` + const client = new OAuth2Client({ + clientId: oauthClient.clientId, + clientSecret: oauthClient.clientSecret, + redirectUri, + }) + const state = crypto.randomUUID() + const authUrl = client.generateAuthUrl({ + redirect_uri: redirectUri, + access_type: "offline", + scope: OAUTH_SCOPE, + state, + prompt: "consent", + }) + + console.log("Opening Google OAuth in your browser.") + console.log(authUrl) + + const callback = waitForOAuthCallback(port, state, redirectUri, client) + await openBrowser(authUrl).catch(() => {}) + const tokens = await callback + await saveCredentials(tokens) + console.log(`Auth saved in ${authPath()}`) +} + +export async function printStatus(): Promise { + const credentials = await loadCredentials() + if (!credentials) { + console.log("Not authenticated") + console.log(`Storage: ${authPath()}`) + process.exit(1) + } + const client = await authClient() + const token = await client.getAccessToken() + if (!token.token) { + console.log("Not authenticated") + console.log(`Storage: ${authPath()}`) + process.exit(1) + } + const expiry = client.credentials.expiry_date ?? credentials.expiry_date + console.log("Authenticated") + if (expiry) { + const seconds = Math.floor((expiry - Date.now()) / 1000) + console.log(`Expires: ${new Date(expiry).toISOString()} (in ${seconds}s)`) + } + console.log(`Scope: ${client.credentials.scope ?? credentials.scope ?? "(none)"}`) + console.log(`Storage: ${authPath()}`) +} + +async function waitForOAuthCallback( + port: number, + state: string, + redirectUri: string, + client: OAuth2Client, +): Promise { + return new Promise((resolve, reject) => { + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`) + if (url.pathname !== "/oauth2callback") { + res.writeHead(404) + res.end("Not found") + return + } + if (url.searchParams.get("state") !== state) { + throw new Error("OAuth state mismatch") + } + const code = url.searchParams.get("code") + if (!code) { + throw new Error(url.searchParams.get("error_description") ?? "No OAuth code returned") + } + const { tokens } = await client.getToken({ code, redirect_uri: redirectUri }) + client.setCredentials(tokens) + res.writeHead(200, { "content-type": "text/plain" }) + res.end("Authentication succeeded. You can close this tab.") + resolve(tokens) + } catch (err) { + res.writeHead(500, { "content-type": "text/plain" }) + res.end(`Authentication failed: ${String(err)}`) + reject(err) + } finally { + server.close() + } + }) + server.on("error", reject) + server.listen(port, "127.0.0.1") + }) +} + +async function openBrowser(url: string): Promise { + const command = + process.platform === "darwin" + ? ["open", url] + : process.platform === "win32" + ? ["cmd", "/c", "start", "", url] + : ["xdg-open", url] + const child = Bun.spawn(command, { stdout: "ignore", stderr: "ignore" }) + await child.exited +} + +function getAvailablePort(): Promise { + const fromEnv = process.env.OAUTH_CALLBACK_PORT + if (fromEnv) { + const port = Number(fromEnv) + if (Number.isInteger(port) && port > 0 && port < 65536) return Promise.resolve(port) + } + + return new Promise((resolve, reject) => { + const server = net.createServer() + server.listen(0, "127.0.0.1") + server.on("listening", () => { + const addr = server.address() + if (!addr || typeof addr === "string") { + reject(new Error("Could not allocate OAuth callback port")) + return + } + const port = addr.port + server.close(() => resolve(port)) + }) + server.on("error", reject) + }) +} + +function expandHome(path: string): string { + if (path === "~") return homedir() + if (path.startsWith("~/")) return join(homedir(), path.slice(2)) + return path +} + +interface OAuthClientConfig { + clientId: string + clientSecret: string +} + +async function loadOauthClientConfig(): Promise { + const fromEnv = { + clientId: process.env.CCP_GEMINI_OAUTH_CLIENT_ID, + clientSecret: process.env.CCP_GEMINI_OAUTH_CLIENT_SECRET, + } + if (fromEnv.clientId && fromEnv.clientSecret) return fromEnv as OAuthClientConfig + + const fromCli = await readOauthClientConfigFromGeminiCli() + if (fromCli) return fromCli + + throw new GeminiAuthError( + "Could not locate Gemini CLI OAuth client config. Install Gemini CLI or set CCP_GEMINI_OAUTH_CLIENT_ID and CCP_GEMINI_OAUTH_CLIENT_SECRET.", + ) +} + +async function readOauthClientConfigFromGeminiCli(): Promise { + const dirs = await geminiCliBundleDirs() + for (const dir of dirs) { + let files: string[] + try { + files = await fs.readdir(dir) + } catch { + continue + } + for (const file of files) { + if (!/^chunk-.*\.js$/.test(file)) continue + const text = await fs.readFile(join(dir, file), "utf8").catch(() => "") + const clientId = text.match(/OAUTH_CLIENT_ID\s*=\s*"([^"]+)"/)?.[1] + const clientSecret = text.match(/OAUTH_CLIENT_SECRET\s*=\s*"([^"]+)"/)?.[1] + if (clientId && clientSecret) return { clientId, clientSecret } + } + } + return undefined +} + +async function geminiCliBundleDirs(): Promise { + const dirs = new Set() + const bin = await findOnPath("gemini") + if (bin) { + const realBin = await fs.realpath(bin).catch(() => bin) + dirs.add(resolve(dirname(realBin), "..", "libexec", "lib", "node_modules", "@google", "gemini-cli", "bundle")) + } + + for (const prefix of ["/opt/homebrew", "/usr/local"]) { + const cellar = join(prefix, "Cellar", "gemini-cli") + const versions = await fs.readdir(cellar).catch(() => []) + for (const version of versions) { + dirs.add(join(cellar, version, "libexec", "lib", "node_modules", "@google", "gemini-cli", "bundle")) + } + } + + return [...dirs] +} + +async function findOnPath(name: string): Promise { + const path = process.env.PATH ?? "" + for (const dir of path.split(":")) { + if (!dir) continue + const candidate = join(dir, name) + try { + await fs.access(candidate, fsConstants.X_OK) + return candidate + } catch { + } + } + return undefined +} + +export class GeminiAuthError extends Error { + constructor(message: string) { + super(message) + this.name = "GeminiAuthError" + } +} diff --git a/src/providers/gemini/client.test.ts b/src/providers/gemini/client.test.ts new file mode 100644 index 0000000..0da936c --- /dev/null +++ b/src/providers/gemini/client.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "bun:test" +import { geminiRetryAfter } from "./client.ts" + +describe("geminiRetryAfter", () => { + it("extracts reset seconds from Code Assist JSON errors", () => { + expect( + geminiRetryAfter( + JSON.stringify({ + error: { + message: "You have exhausted your capacity on this model. Your quota will reset after 45s.", + }, + }), + ), + ).toBe("45") + }) + + it("extracts reset minutes from plain text", () => { + expect(geminiRetryAfter("Usage limit reached. Your quota will reset after 2 minutes.")).toBe( + "120", + ) + }) + + it("returns undefined when there is no reset hint", () => { + expect(geminiRetryAfter("rate limited")).toBeUndefined() + }) +}) diff --git a/src/providers/gemini/client.ts b/src/providers/gemini/client.ts new file mode 100644 index 0000000..be33cb0 --- /dev/null +++ b/src/providers/gemini/client.ts @@ -0,0 +1,248 @@ +import { geminiApiVersion, geminiEndpoint } from "../../config.ts" +import type { Logger } from "../../log.ts" +import type { RequestContext } from "../types.ts" +import type { + CodeAssistCountTokensRequest, + CodeAssistGenerateRequest, +} from "./translate/request.ts" +import { accessToken, GeminiAuthError } from "./auth/oauth.ts" +import { retryOn429 } from "../retry.ts" + +export interface GeminiResponse { + body: ReadableStream + status: number + headers: Headers + requestStartTime: number +} + +export interface GeminiSetup { + project: string + googleOneCreditBalance: number | null +} + +export class GeminiError extends Error { + constructor( + public status: number, + message: string, + public detail?: string, + public meta?: { retryAfter?: string }, + ) { + super(message) + this.name = "GeminiError" + } +} + +let setupCache: Promise | undefined + +export async function setupGemini(ctx: RequestContext): Promise { + setupCache ??= doSetupGemini(ctx).catch((err) => { + setupCache = undefined + throw err + }) + return setupCache +} + +async function doSetupGemini(ctx: RequestContext): Promise { + const project = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID || undefined + const metadata = { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + duetProject: project, + } + const response = await postJson( + "loadCodeAssist", + { + cloudaicompanionProject: project, + metadata, + }, + ctx, + ctx.childLogger("gemini.setup"), + ) + const body = response as { + cloudaicompanionProject?: string + currentTier?: { id?: string; name?: string; hasOnboardedPreviously?: boolean } + paidTier?: { + id?: string + name?: string + availableCredits?: Array<{ creditType?: string; creditAmount?: string }> + } + ineligibleTiers?: Array<{ reasonMessage?: string }> + } + const resolvedProject = body.cloudaicompanionProject ?? project + if (!resolvedProject) { + const reason = body.ineligibleTiers?.map((t) => t.reasonMessage).filter(Boolean).join(", ") + throw new GeminiError( + 400, + "Project required", + reason || + "Gemini Code Assist did not return a project. Set GOOGLE_CLOUD_PROJECT or run Gemini CLI onboarding.", + ) + } + const googleOneCreditBalance = googleOneCredits(body.paidTier) + ctx.childLogger("gemini.setup").debug("setup complete", { + project: resolvedProject, + googleOneCreditBalance, + }) + return { project: resolvedProject, googleOneCreditBalance } +} + +export async function postGeminiStream( + body: CodeAssistGenerateRequest, + ctx: RequestContext, +): Promise { + const log = ctx.childLogger("gemini.client") + return retryOn429(() => attemptPostGeminiStream(body, ctx, log), { + log, + signal: ctx.signal, + classify: (err) => + err instanceof GeminiError && err.status === 429 + ? { retryAfter: err.meta?.retryAfter } + : undefined, + }) +} + +export async function countGeminiTokens( + body: CodeAssistCountTokensRequest, + ctx: RequestContext, +): Promise { + const log = ctx.childLogger("gemini.client") + const response = await postJson("countTokens", body, ctx, log) + const totalTokens = (response as { totalTokens?: number }).totalTokens + return typeof totalTokens === "number" ? totalTokens : 0 +} + +async function attemptPostGeminiStream( + body: CodeAssistGenerateRequest, + ctx: RequestContext, + log: Logger, +): Promise { + const requestStartTime = Date.now() + const resp = await doFetch("streamGenerateContent", body, ctx, log, true) + + if (resp.status === 429) { + const text = await safeText(resp) + const retryAfter = resp.headers.get("retry-after") || geminiRetryAfter(text) + throw new GeminiError(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 GeminiError(resp.status, type, text) + } + + if (!resp.body) throw new GeminiError(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 postJson( + method: string, + body: unknown, + ctx: RequestContext, + log: Logger, +): Promise { + const resp = await doFetch(method, body, ctx, log, false) + if (!resp.ok) { + const text = await safeText(resp) + const type = resp.status === 401 || resp.status === 403 ? "Unauthorized" : "Upstream error" + throw new GeminiError(resp.status, type, text) + } + return resp.json() +} + +async function doFetch( + method: string, + body: unknown, + ctx: RequestContext, + log: Logger, + stream: boolean, +): Promise { + let token: string + try { + token = await accessToken() + } catch (err) { + if (err instanceof GeminiAuthError) { + throw new GeminiError(401, "Unauthorized", err.message) + } + throw err + } + + const url = `${baseUrl()}:${method}${stream ? "?alt=sse" : ""}` + const bodyJson = JSON.stringify(body) + log.debug("posting to gemini", { + url, + method, + stream, + requestBodyBytes: new TextEncoder().encode(bodyJson).length, + }) + + return fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: stream ? "text/event-stream" : "application/json", + Authorization: `Bearer ${token}`, + }, + body: bodyJson, + signal: ctx.signal, + }) +} + +function baseUrl(): string { + return `${geminiEndpoint().replace(/\/$/, "")}/${geminiApiVersion()}` +} + +async function safeText(resp: Response): Promise { + try { + return await resp.text() + } catch { + return "" + } +} + +function googleOneCredits( + paidTier?: { availableCredits?: Array<{ creditType?: string; creditAmount?: string }> }, +): number | null { + const credits = paidTier?.availableCredits?.filter((c) => c.creditType === "GOOGLE_ONE_AI") + if (!credits?.length) return null + return credits.reduce((sum, credit) => { + const amount = Number.parseInt(credit.creditAmount ?? "0", 10) + return sum + (Number.isFinite(amount) ? amount : 0) + }, 0) +} + +export function geminiRetryAfter(detail: string): string | undefined { + try { + const parsed = JSON.parse(detail) as { error?: { message?: string } } + const fromJson = retryAfterFromMessage(parsed.error?.message) + if (fromJson) return fromJson + } catch { + // Fall through to regex scan below. + } + return retryAfterFromMessage(detail) +} + +function retryAfterFromMessage(message: unknown): string | undefined { + if (typeof message !== "string") return undefined + const match = /reset\s+after\s+(\d+(?:\.\d+)?)\s*(ms|msec|milliseconds?|s|sec|seconds?|m|min|minutes?)\b/i.exec( + message, + ) + if (!match) return undefined + const value = Number.parseFloat(match[1] ?? "") + if (!Number.isFinite(value)) return undefined + const unit = (match[2] ?? "s").toLowerCase() + if (unit.startsWith("m") && !unit.startsWith("ms") && !unit.startsWith("msec")) { + return String(Math.ceil(value * 60)) + } + if (unit.startsWith("ms") || unit.startsWith("msec") || unit.startsWith("millisecond")) { + return String(Math.ceil(value / 1000)) + } + return String(Math.ceil(value)) +} diff --git a/src/providers/gemini/count-tokens.ts b/src/providers/gemini/count-tokens.ts new file mode 100644 index 0000000..e06e1d9 --- /dev/null +++ b/src/providers/gemini/count-tokens.ts @@ -0,0 +1,60 @@ +import { encode } from "gpt-tokenizer/model/gpt-4o" +import type { AnthropicRequest } from "../../anthropic/schema.ts" +import type { GeminiGenerateRequest, GeminiPart } from "./translate/request.ts" +import { buildSystemInstruction, normalizeContent } from "./translate/request.ts" + +const IMAGE_TOKEN_ESTIMATE = 3000 + +export function countTokens(req: AnthropicRequest): number { + let total = 0 + const system = buildSystemInstruction(req.system) + if (system) total += countParts(system.parts) + + for (const msg of req.messages) { + for (const block of normalizeContent(msg.content)) { + if (block.type === "text") total += encode(block.text).length + else if (block.type === "image") total += IMAGE_TOKEN_ESTIMATE + else if (block.type === "thinking") total += encode(block.thinking).length + 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(JSON.stringify(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: GeminiGenerateRequest): number { + let total = 0 + if (req.config?.systemInstruction) total += countParts(req.config.systemInstruction.parts) + for (const content of req.contents) total += countParts(content.parts) + for (const tool of req.config?.tools ?? []) { + total += encode(JSON.stringify(tool)).length + } + total += req.contents.length * 4 + return total +} + +function countParts(parts: GeminiPart[]): number { + let total = 0 + for (const part of parts) { + if ("text" in part) total += encode(part.text).length + else if ("inlineData" in part || "fileData" in part) total += IMAGE_TOKEN_ESTIMATE + else if ("functionCall" in part) total += encode(JSON.stringify(part.functionCall)).length + else if ("functionResponse" in part) { + total += encode(JSON.stringify(part.functionResponse.response ?? {})).length + if (part.functionResponse.parts) total += countParts(part.functionResponse.parts) + } + } + return total +} diff --git a/src/providers/gemini/index.ts b/src/providers/gemini/index.ts new file mode 100644 index 0000000..d09494b --- /dev/null +++ b/src/providers/gemini/index.ts @@ -0,0 +1,284 @@ +import type { AnthropicRequest } from "../../anthropic/schema.ts" +import { geminiEnableFallback, geminiEnableGoogleOneCredits, logVerbose } from "../../config.ts" +import type { CliHandlers, Provider, RequestContext } from "../types.ts" +import { countTokens, countTranslatedTokens } from "./count-tokens.ts" +import { + countGeminiTokens, + GeminiError, + postGeminiStream, + setupGemini, +} from "./client.ts" +import { + assertAllowedModel, + fallbackChain, + GEMINI_DEFAULT_MODEL, + GEMINI_SMALL_FAST_MODEL, + isGoogleOneCreditEligibleModel, + ModelNotAllowedError, + resolveModel, +} from "./translate/model-allowlist.ts" +import { + toCodeAssistCountTokensRequest, + toCodeAssistGenerateRequest, + 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 { + authPath, + clearCredentials, + printStatus, + runBrowserLogin, +} from "./auth/oauth.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.gemini") + 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 }) + let tokens = countTranslatedTokens(translated) + try { + tokens = await countGeminiTokens(toCodeAssistCountTokensRequest(translated), ctx) + } catch (err) { + log.warn("count_tokens upstream failed; using local estimate", { err: String(err) }) + } + 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.gemini") + 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, + requestedEffort: body.output_config?.effort ?? null, + }) + if (logVerbose()) log.debug("anthropic request body", { body }) + + const requestedModel = resolveModel(body.model) + try { + assertAllowedModel(requestedModel) + } catch (err) { + if (err instanceof ModelNotAllowedError) { + return jsonError( + 400, + "invalid_request_error", + `Model "${body.model}" resolves to unsupported model "${err.model}"`, + ) + } + throw err + } + + const setup = await setupGemini(ctx) + const enableGoogleOneCredits = + geminiEnableGoogleOneCredits() && + setup.googleOneCreditBalance !== null && + setup.googleOneCreditBalance >= 50 + let upstream + let resolvedModel = requestedModel + const modelsToTry = geminiEnableFallback() ? fallbackChain(requestedModel) : [requestedModel] + for (const candidateModel of modelsToTry) { + const translated = translateRequest({ ...body, model: candidateModel }, { sessionId: ctx.sessionId }) + const enabledCreditTypes = + enableGoogleOneCredits && isGoogleOneCreditEligibleModel(candidateModel) + ? ["GOOGLE_ONE_AI"] + : undefined + const localInputTokens = logVerbose() ? countTokens(body) : undefined + const translatedInputTokens = logVerbose() ? countTranslatedTokens(translated) : undefined + log.debug("translated request", { + requestedModel: body.model, + candidateModel, + messageCount: translated.contents.length, + toolCount: translated.config?.tools?.[0]?.functionDeclarations.length ?? 0, + localInputTokens, + translatedInputTokens, + promptCacheKey: ctx.sessionId, + thinkingConfig: translated.config?.generationConfig?.thinkingConfig ?? null, + maxOutputTokens: translated.config?.generationConfig?.maxOutputTokens, + googleOneCreditsEnabled: Boolean(enabledCreditTypes?.length), + }) + if (logVerbose()) log.debug("translated request body", { body: translated }) + + try { + upstream = await postGeminiStream( + toCodeAssistGenerateRequest(translated, { + project: setup.project, + userPromptId: crypto.randomUUID(), + sessionId: ctx.sessionId, + enabledCreditTypes, + }), + ctx, + ) + resolvedModel = candidateModel + break + } catch (err) { + if ( + err instanceof GeminiError && + shouldTryFallback(err) && + candidateModel !== modelsToTry.at(-1) + ) { + log.warn("gemini model failed; trying fallback", { + model: candidateModel, + status: err.status, + detail: err.detail, + nextModel: modelsToTry[modelsToTry.indexOf(candidateModel) + 1], + }) + continue + } + if (err instanceof GeminiError) { + return geminiErrorResponse(err, log) + } + throw err + } + } + + if (!upstream) { + return jsonError(502, "api_error", "Gemini request did not produce an upstream response") + } + + if (wantStream) { + const stream = translateStream(upstream.body, { + messageId, + model: resolvedModel, + log: ctx.childLogger("gemini.stream"), + requestStartTime: upstream.requestStartTime, + onFinish: (finish) => { + const mappedUsage = finish.usage ? mapUsageToAnthropic(finish.usage) : undefined + log.debug("stream finish", { + stopReason: finish.stopReason, + upstreamInputTokens: finish.usage?.promptTokenCount ?? 0, + upstreamOutputTokens: finish.usage?.candidatesTokenCount ?? 0, + upstreamCachedInputTokens: finish.usage?.cachedContentTokenCount ?? 0, + upstreamThoughtTokens: finish.usage?.thoughtsTokenCount ?? 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: resolvedModel, + log: ctx.childLogger("gemini.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 + } +} + +function shouldTryFallback(err: GeminiError): boolean { + if (err.status === 429 || err.status === 404 || err.status === 503) return true + if (err.status >= 500 && err.status <= 599) return true + if (err.status === 400) { + const text = `${err.detail ?? ""} ${err.message}`.toLowerCase() + return text.includes("model") || text.includes("unsupported") || text.includes("not found") + } + return false +} + +function geminiErrorResponse(err: GeminiError, log: ReturnType): Response { + log.warn("gemini 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) +} + +const cli: CliHandlers = { + async login() { + await runBrowserLogin() + }, + async status() { + await printStatus() + }, + async logout() { + await clearCredentials() + console.log(`Logged out (${authPath()})`) + }, +} + +export const geminiProvider: Provider = { + name: "gemini", + supportedModels: new Set([ + GEMINI_DEFAULT_MODEL, + "gemini-3-pro-preview", + GEMINI_SMALL_FAST_MODEL, + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ]), + handleMessages, + handleCountTokens, + cli, +} diff --git a/src/providers/gemini/translate/accumulate.ts b/src/providers/gemini/translate/accumulate.ts new file mode 100644 index 0000000..22dcb7b --- /dev/null +++ b/src/providers/gemini/translate/accumulate.ts @@ -0,0 +1,151 @@ +import type { Logger } from "../../../log.ts" +import { mapUsageToAnthropic, reduceUpstream, type GeminiUsage } from "./reducer.ts" +import { makeThinkingSignature } from "../../kimi/translate/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?: GeminiUsage +} + +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: GeminiUsage | 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/gemini/translate/model-allowlist.ts b/src/providers/gemini/translate/model-allowlist.ts new file mode 100644 index 0000000..d220e47 --- /dev/null +++ b/src/providers/gemini/translate/model-allowlist.ts @@ -0,0 +1,50 @@ +import { geminiModel } from "../../../config.ts" + +export const GEMINI_DEFAULT_MODEL = "gemini-3.1-pro-preview" +export const GEMINI_SMALL_FAST_MODEL = "gemini-3-flash-preview" + +export const ALLOWED_MODELS = new Set([ + GEMINI_DEFAULT_MODEL, + "gemini-3-pro-preview", + GEMINI_SMALL_FAST_MODEL, + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", +]) + +export const FALLBACK_MODELS: Record = { + "gemini-3.1-pro-preview": ["gemini-3-pro-preview", "gemini-2.5-pro"], + "gemini-3-pro-preview": ["gemini-2.5-pro"], + "gemini-3-flash-preview": ["gemini-2.5-flash"], +} + +const GOOGLE_ONE_CREDIT_MODELS = new Set([ + GEMINI_DEFAULT_MODEL, + "gemini-3-pro-preview", + GEMINI_SMALL_FAST_MODEL, +]) + +export function resolveModel(model: string): string { + return geminiModel() ?? model +} + +export function fallbackChain(model: string): string[] { + return [model, ...(FALLBACK_MODELS[model] ?? [])] +} + +export function assertAllowedModel(model: string): void { + if (!ALLOWED_MODELS.has(model)) { + throw new ModelNotAllowedError(model) + } +} + +export function isGoogleOneCreditEligibleModel(model: string): boolean { + return GOOGLE_ONE_CREDIT_MODELS.has(model) +} + +export class ModelNotAllowedError extends Error { + constructor(public model: string) { + super(`Model not allowed: ${model}`) + this.name = "ModelNotAllowedError" + } +} diff --git a/src/providers/gemini/translate/reducer.test.ts b/src/providers/gemini/translate/reducer.test.ts new file mode 100644 index 0000000..4a6f679 --- /dev/null +++ b/src/providers/gemini/translate/reducer.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it } from "bun:test" +import { encodeSseEvent } from "../../../sse.ts" +import { reduceUpstream, type ReducerEvent } from "./reducer.ts" +import { translateRequest } from "./request.ts" +import { clearGeminiToolSignatures } from "./signature-cache.ts" + +const encoder = new TextEncoder() + +function streamFromEvents(events: unknown[]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const event of events) controller.enqueue(encoder.encode(encodeSseEvent("message", event))) + controller.close() + }, + }) +} + +async function collect(events: unknown[]): Promise { + const collected: ReducerEvent[] = [] + for await (const event of reduceUpstream(streamFromEvents(events))) collected.push(event) + return collected +} + +describe("reduceUpstream", () => { + beforeEach(() => { + clearGeminiToolSignatures() + }) + + it("passes valid function calls through as tool calls", async () => { + const events = await collect([ + { + response: { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + id: "call_1", + name: "Read", + args: { file_path: "a.ts" }, + }, + }, + ], + }, + finishReason: "STOP", + }, + ], + usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 2 }, + }, + }, + ]) + + expect(events).toEqual([ + { kind: "tool-start", index: 0, id: "call_1", name: "Read" }, + { kind: "tool-delta", index: 0, partialJson: '{"file_path":"a.ts"}' }, + { kind: "tool-stop", index: 0 }, + { + kind: "finish", + stopReason: "tool_use", + usage: { promptTokenCount: 5, candidatesTokenCount: 2 }, + }, + ]) + }) + + it("preserves Gemini tool call thought signatures for the next request", async () => { + const thoughtSignature = "x".repeat(64) + await collect([ + { + response: { + candidates: [ + { + content: { + parts: [ + { + thoughtSignature, + functionCall: { + id: "call_1", + name: "Read", + args: { file_path: "a.ts" }, + }, + }, + ], + }, + finishReason: "STOP", + }, + ], + }, + }, + ]) + + const translated = translateRequest({ + model: "gemini-3.1-pro-preview", + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "call_1", + name: "Read", + input: { file_path: "a.ts" }, + }, + ], + }, + ], + }) + + expect(translated.contents[0]?.parts[0]).toEqual({ + functionCall: { + id: "call_1", + name: "Read", + args: { file_path: "a.ts" }, + }, + thoughtSignature, + }) + }) + + it("turns invalid AskUserQuestion calls into assistant text", async () => { + const question = "Hello! It looks like you are testing the models. What would you like to plan today?" + const events = await collect([ + { + response: { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + id: "call_2", + name: "AskUserQuestion", + args: { + questions: [ + { + question, + multiSelect: false, + options: [{ label: "Other", description: "Provide details about the task to plan." }], + header: "Goal", + }, + ], + }, + }, + }, + ], + }, + finishReason: "STOP", + }, + ], + }, + }, + ]) + + expect(events).toEqual([ + { kind: "text-start", index: 0 }, + { kind: "text-delta", index: 0, text: question }, + { kind: "text-stop", index: 0 }, + { kind: "finish", stopReason: "end_turn", usage: undefined }, + ]) + }) + + it("does not report tool_use when Gemini reports a malformed function call without an emitted tool", async () => { + const events = await collect([ + { + response: { + candidates: [{ finishReason: "MALFORMED_FUNCTION_CALL" }], + }, + }, + ]) + + expect(events).toEqual([{ kind: "finish", stopReason: "end_turn", usage: undefined }]) + }) +}) diff --git a/src/providers/gemini/translate/reducer.ts b/src/providers/gemini/translate/reducer.ts new file mode 100644 index 0000000..ab83577 --- /dev/null +++ b/src/providers/gemini/translate/reducer.ts @@ -0,0 +1,230 @@ +import { parseSseStream } from "../../../sse.ts" +import type { Logger } from "../../../log.ts" +import { cacheGeminiToolSignature } from "./signature-cache.ts" + +export class UpstreamStreamError extends Error { + constructor( + public kind: "rate_limit" | "failed", + message: string, + public retryAfterSeconds?: number, + ) { + super(message) + this.name = "UpstreamStreamError" + } +} + +export interface GeminiUsage { + promptTokenCount?: number + candidatesTokenCount?: number + totalTokenCount?: number + cachedContentTokenCount?: number + thoughtsTokenCount?: 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: GeminiUsage | undefined } + +interface GeminiStreamChunk { + response?: { + candidates?: GeminiCandidate[] + usageMetadata?: GeminiUsage + } + error?: { message?: string; status?: string; code?: number } +} + +interface GeminiCandidate { + content?: { + parts?: GeminiResponsePart[] + } + finishReason?: string +} + +type GeminiResponsePart = + | { text?: string; thought?: boolean } + | { functionCall?: { id?: string; name?: string; args?: unknown }; thoughtSignature?: string } + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function invalidAskUserQuestionFallback(name: string, args: unknown): string | undefined { + if (name !== "AskUserQuestion") return undefined + + if (!isRecord(args)) return "What would you like to do next?" + const questions = args.questions + if (!Array.isArray(questions) || questions.length === 0 || questions.length > 3) { + return "What would you like to do next?" + } + + let firstQuestionText: string | undefined + let hasInvalidShape = false + + for (const question of questions) { + if (!isRecord(question)) { + hasInvalidShape = true + continue + } + + if (firstQuestionText === undefined && typeof question.question === "string") { + firstQuestionText = question.question.trim() + } + + const options = question.options + if ( + typeof question.header !== "string" || + typeof question.question !== "string" || + !Array.isArray(options) || + options.length < 2 || + options.length > 3 || + options.some( + (option) => + !isRecord(option) || + typeof option.label !== "string" || + typeof option.description !== "string", + ) + ) { + hasInvalidShape = true + } + } + + if (!hasInvalidShape) return undefined + return firstQuestionText || "What would you like to do next?" +} + +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 + let sawToolCalls = false + let finalUsage: GeminiUsage | undefined + let finishReason: string | 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 } + } + } + + for await (const evt of parseSseStream(upstream)) { + if (!evt.data) continue + let chunk: GeminiStreamChunk + try { + chunk = JSON.parse(evt.data) as GeminiStreamChunk + } catch (err) { + log?.warn("upstream sse: invalid json", { err: String(err), preview: evt.data.slice(0, 200) }) + continue + } + if (stats) stats.chunkCount++ + + if (chunk.error) { + throw new UpstreamStreamError("failed", chunk.error.message || "Upstream error") + } + + const response = chunk.response + if (!response) continue + if (response.usageMetadata) finalUsage = response.usageMetadata + + const candidate = response.candidates?.[0] + if (!candidate) continue + if (candidate.finishReason) finishReason = candidate.finishReason + + for (const part of candidate.content?.parts ?? []) { + if ("text" in part && typeof part.text === "string" && part.text.length > 0) { + if (part.thought) { + if (thinkingIndex === undefined) { + thinkingIndex = nextBlockIndex++ + yield { kind: "thinking-start", index: thinkingIndex } + } + yield { kind: "thinking-delta", index: thinkingIndex, text: part.text } + } else { + yield* closeThinking() + if (textIndex === undefined) { + textIndex = nextBlockIndex++ + yield { kind: "text-start", index: textIndex } + } + yield { kind: "text-delta", index: textIndex, text: part.text } + } + } + + if ("functionCall" in part && part.functionCall) { + yield* closeThinking() + yield* closeText() + const name = part.functionCall.name ?? "tool" + const fallbackText = invalidAskUserQuestionFallback(name, part.functionCall.args) + if (fallbackText) { + log?.warn("gemini upstream: dropping invalid AskUserQuestion tool call") + const index = nextBlockIndex++ + yield { kind: "text-start", index } + yield { kind: "text-delta", index, text: fallbackText } + yield { kind: "text-stop", index } + continue + } + + const id = part.functionCall.id ?? `call_${crypto.randomUUID().replace(/-/g, "")}` + cacheGeminiToolSignature(id, part.thoughtSignature) + const args = JSON.stringify(part.functionCall.args ?? {}) + const index = nextBlockIndex++ + sawToolCalls = true + yield { kind: "tool-start", index, id, name } + if (args) yield { kind: "tool-delta", index, partialJson: args } + yield { kind: "tool-stop", index } + } + } + } + + yield* closeThinking() + yield* closeText() + + const stopReason: StopReason = + finishReason === "MAX_TOKENS" + ? "max_tokens" + : sawToolCalls + ? "tool_use" + : "end_turn" + + yield { kind: "finish", stopReason, usage: finalUsage } +} + +export function mapUsageToAnthropic(u: GeminiUsage | undefined): { + input_tokens: number + output_tokens: number + cache_creation_input_tokens: number + cache_read_input_tokens: number +} { + const cached = u?.cachedContentTokenCount ?? 0 + const totalPrompt = u?.promptTokenCount ?? 0 + return { + input_tokens: Math.max(0, totalPrompt - cached), + output_tokens: (u?.candidatesTokenCount ?? 0) + (u?.thoughtsTokenCount ?? 0), + cache_creation_input_tokens: 0, + cache_read_input_tokens: cached, + } +} diff --git a/src/providers/gemini/translate/request.test.ts b/src/providers/gemini/translate/request.test.ts new file mode 100644 index 0000000..ab45814 --- /dev/null +++ b/src/providers/gemini/translate/request.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it } from "bun:test" +import { loadConfig } from "../../../config.ts" +import { toCodeAssistGenerateRequest, translateRequest } from "./request.ts" +import type { AnthropicRequest } from "../../../anthropic/schema.ts" + +afterEach(() => { + loadConfig({ forceReload: true }) +}) + +describe("translateRequest", () => { + it("maps Claude messages, tools, and tool results to Gemini contents", () => { + const req: AnthropicRequest = { + model: "gemini-3.1-pro-preview", + system: "Be concise.", + messages: [ + { role: "user", content: "Read the file." }, + { + role: "assistant", + content: [ + { type: "tool_use", id: "toolu_1", name: "Read", input: { file_path: "a.ts" } }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "hello" }], + }, + ], + tools: [{ name: "Read", input_schema: { type: "object" } }], + max_tokens: 1000, + stream: true, + } + + const translated = translateRequest(req) + + expect(translated.config?.systemInstruction?.parts[0]).toEqual({ text: "Be concise." }) + expect(translated.config?.tools?.[0]?.functionDeclarations[0]).toMatchObject({ + name: "Read", + parametersJsonSchema: { type: "object" }, + }) + expect(translated.contents[1]?.parts[0]).toEqual({ + functionCall: { id: "toolu_1", name: "Read", args: { file_path: "a.ts" } }, + }) + expect(translated.contents[2]?.parts[0]).toEqual({ + functionResponse: { + id: "toolu_1", + name: "Read", + response: { output: "hello" }, + }, + }) + }) + + it("maps max effort to Gemini 3 high thinking level", () => { + const req: AnthropicRequest = { + model: "gemini-3.1-pro-preview", + messages: [{ role: "user", content: "think" }], + output_config: { effort: "max" }, + } + + const translated = translateRequest(req) + + expect(translated.config?.generationConfig?.thinkingConfig).toEqual({ + includeThoughts: true, + thinkingLevel: "HIGH", + }) + }) + + it("uses Gemini default effort when request effort is absent", () => { + loadConfig({ + env: { CCP_GEMINI_DEFAULT_EFFORT: "medium" }, + configPath: "/tmp/claude-code-proxy-gemini-request-test-does-not-exist.json", + forceReload: true, + }) + const req: AnthropicRequest = { + model: "gemini-3.1-pro-preview", + messages: [{ role: "user", content: "think" }], + } + + const translated = translateRequest(req) + + expect(translated.config?.generationConfig?.thinkingConfig).toEqual({ + includeThoughts: true, + thinkingLevel: "MEDIUM", + }) + }) + + it("sets Code Assist enabled credit types only when provided", () => { + const translated = translateRequest({ + model: "gemini-3.1-pro-preview", + messages: [{ role: "user", content: "hello" }], + }) + + expect( + toCodeAssistGenerateRequest(translated, { + project: "project", + userPromptId: "prompt", + enabledCreditTypes: ["GOOGLE_ONE_AI"], + }).enabled_credit_types, + ).toEqual(["GOOGLE_ONE_AI"]) + + expect( + toCodeAssistGenerateRequest(translated, { + project: "project", + userPromptId: "prompt", + }).enabled_credit_types, + ).toBeUndefined() + }) +}) diff --git a/src/providers/gemini/translate/request.ts b/src/providers/gemini/translate/request.ts new file mode 100644 index 0000000..89eb7df --- /dev/null +++ b/src/providers/gemini/translate/request.ts @@ -0,0 +1,404 @@ +import type { + AnthropicContentBlock, + AnthropicImageBlock, + AnthropicMessage, + AnthropicRequest, + AnthropicTextBlock, + AnthropicTool, +} from "../../../anthropic/schema.ts" +import { geminiDefaultEffort } from "../../../config.ts" +import { geminiToolSignature } from "./signature-cache.ts" + +export type GeminiEffort = "none" | "low" | "medium" | "high" | "max" | "xhigh" + +export interface GeminiGenerateRequest { + model: string + contents: GeminiContent[] + config?: GeminiConfig +} + +export interface CodeAssistGenerateRequest { + model: string + project: string + user_prompt_id: string + enabled_credit_types?: string[] + request: { + contents: GeminiContent[] + systemInstruction?: GeminiContent + tools?: GeminiTool[] + toolConfig?: GeminiToolConfig + generationConfig?: GeminiGenerationConfig + session_id?: string + } +} + +export interface CodeAssistCountTokensRequest { + request: { + model: string + contents: GeminiContent[] + } +} + +export interface GeminiConfig { + systemInstruction?: GeminiContent + tools?: GeminiTool[] + toolConfig?: GeminiToolConfig + generationConfig?: GeminiGenerationConfig +} + +export interface GeminiGenerationConfig { + maxOutputTokens?: number + temperature?: number + topP?: number + responseMimeType?: string + responseJsonSchema?: unknown + thinkingConfig?: GeminiThinkingConfig +} + +export interface GeminiThinkingConfig { + includeThoughts?: boolean + thinkingLevel?: "LOW" | "MEDIUM" | "HIGH" + thinkingBudget?: number +} + +export interface GeminiContent { + role: "user" | "model" + parts: GeminiPart[] +} + +export type GeminiPart = + | { text: string; thought?: boolean; thoughtSignature?: string } + | { inlineData: { mimeType: string; data: string } } + | { fileData: { fileUri: string; mimeType?: string } } + | { functionCall: { id?: string; name: string; args: unknown }; thoughtSignature?: string } + | { functionResponse: { id?: string; name: string; response: unknown; parts?: GeminiPart[] } } + +export interface GeminiTool { + functionDeclarations: GeminiFunctionDeclaration[] +} + +export interface GeminiFunctionDeclaration { + name: string + description?: string + parametersJsonSchema: unknown +} + +export interface GeminiToolConfig { + functionCallingConfig: { + mode: "AUTO" | "ANY" | "NONE" + allowedFunctionNames?: string[] + } +} + +export interface TranslateOptions { + sessionId?: string +} + +const VALID_EFFORTS = new Set([ + "none", + "low", + "medium", + "high", + "max", + "xhigh", +]) + +const ANTHROPIC_EFFORTS = new Set(["low", "medium", "high", "max", "xhigh"]) + +export function translateRequest( + req: AnthropicRequest, + opts: TranslateOptions = {}, +): GeminiGenerateRequest { + assertValidAnthropicEffort(req.output_config?.effort) + const toolNamesById = new Map() + const contents = buildContents(req.messages, toolNamesById) + const tools = req.tools?.map(toGeminiTool) + const systemInstruction = buildSystemInstruction(req.system) + const generationConfig = buildGenerationConfig(req) + const toolConfig = mapToolChoice(req.tool_choice) + + const config: GeminiConfig = {} + if (systemInstruction) config.systemInstruction = systemInstruction + if (tools && tools.length) config.tools = [{ functionDeclarations: tools }] + if (toolConfig) config.toolConfig = toolConfig + if (generationConfig) config.generationConfig = generationConfig + + return { + model: req.model, + contents, + ...(Object.keys(config).length ? { config } : {}), + } +} + +export function toCodeAssistGenerateRequest( + req: GeminiGenerateRequest, + opts: { + project: string + userPromptId: string + sessionId?: string + enabledCreditTypes?: string[] + } = { + project: "", + userPromptId: "", + }, +): CodeAssistGenerateRequest { + return { + model: req.model, + project: opts.project, + user_prompt_id: opts.userPromptId, + ...(opts.enabledCreditTypes?.length ? { enabled_credit_types: opts.enabledCreditTypes } : {}), + request: { + contents: req.contents, + systemInstruction: req.config?.systemInstruction, + tools: req.config?.tools, + toolConfig: req.config?.toolConfig, + generationConfig: req.config?.generationConfig, + session_id: opts.sessionId, + }, + } +} + +export function toCodeAssistCountTokensRequest( + req: GeminiGenerateRequest, +): CodeAssistCountTokensRequest { + return { + request: { + model: `models/${req.model}`, + contents: req.contents, + }, + } +} + +export function buildSystemInstruction( + system: AnthropicRequest["system"], +): GeminiContent | 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 { role: "user", parts: [{ text: texts.join("\n\n") }] } +} + +function buildContents( + messages: AnthropicMessage[], + toolNamesById: Map, +): GeminiContent[] { + const out: GeminiContent[] = [] + for (const msg of messages) { + const blocks = normalizeContent(msg.content) + if (msg.role === "user") { + pushUserContents(out, blocks, toolNamesById) + } else { + pushAssistantContent(out, blocks, toolNamesById) + } + } + return out +} + +function pushUserContents( + out: GeminiContent[], + blocks: AnthropicContentBlock[], + toolNamesById: Map, +): void { + let parts: GeminiPart[] = [] + const flush = () => { + if (!parts.length) return + out.push({ role: "user", parts }) + parts = [] + } + + for (const block of blocks) { + if (block.type === "text") { + parts.push({ text: block.text }) + } else if (block.type === "image") { + parts.push(imageToGeminiPart(block)) + } else if (block.type === "tool_result") { + flush() + const name = toolNamesById.get(block.tool_use_id) ?? "tool" + out.push({ + role: "user", + parts: toolResultParts(name, block.tool_use_id, block.content, block.is_error), + }) + } + } + flush() +} + +function pushAssistantContent( + out: GeminiContent[], + blocks: AnthropicContentBlock[], + toolNamesById: Map, +): void { + const parts: GeminiPart[] = [] + for (const block of blocks) { + if (block.type === "text") { + if (block.text) parts.push({ text: block.text }) + } else if (block.type === "thinking") { + if (block.thinking) { + parts.push({ + text: block.thinking, + thought: true, + ...(block.signature ? { thoughtSignature: block.signature } : {}), + }) + } + } else if (block.type === "tool_use") { + toolNamesById.set(block.id, block.name) + const thoughtSignature = geminiToolSignature(block.id) + parts.push({ + functionCall: { + id: block.id, + name: block.name, + args: block.input ?? {}, + }, + ...(thoughtSignature ? { thoughtSignature } : {}), + }) + } + } + if (parts.length) out.push({ role: "model", parts }) +} + +export function normalizeContent(content: AnthropicMessage["content"]): AnthropicContentBlock[] { + if (typeof content === "string") return [{ type: "text", text: content }] + return content +} + +function imageToGeminiPart(block: Extract): GeminiPart { + if (block.source.type === "base64") { + return { + inlineData: { + mimeType: block.source.media_type, + data: block.source.data, + }, + } + } + return { fileData: { fileUri: block.source.url } } +} + +function toolResultParts( + name: string, + callId: string, + content: string | Array, + isError: boolean | undefined, +): GeminiPart[] { + const prefix = isError ? "[tool execution error]\n" : "" + if (typeof content === "string") { + return [ + { + functionResponse: { + id: callId, + name, + response: { output: prefix + content }, + }, + }, + ] + } + + const textParts: string[] = [] + const mediaParts: GeminiPart[] = [] + for (const block of content) { + if (block.type === "text") textParts.push(block.text) + else mediaParts.push(imageToGeminiPart(block)) + } + return [ + { + functionResponse: { + id: callId, + name, + response: { output: prefix + textParts.join("\n") }, + ...(mediaParts.length ? { parts: mediaParts } : {}), + }, + }, + ] +} + +function toGeminiTool(tool: AnthropicTool): GeminiFunctionDeclaration { + return { + name: tool.name, + description: tool.description, + parametersJsonSchema: tool.input_schema, + } +} + +function mapToolChoice(choice: AnthropicRequest["tool_choice"]): GeminiToolConfig | undefined { + if (!choice || choice.type === "auto") return undefined + if (choice.type === "none") { + return { functionCallingConfig: { mode: "NONE" } } + } + if (choice.type === "any") { + return { functionCallingConfig: { mode: "ANY" } } + } + return { + functionCallingConfig: { + mode: "ANY", + ...(choice.name ? { allowedFunctionNames: [choice.name] } : {}), + }, + } +} + +function buildGenerationConfig(req: AnthropicRequest): GeminiGenerationConfig | undefined { + const out: GeminiGenerationConfig = {} + if (req.max_tokens && req.max_tokens > 0) out.maxOutputTokens = req.max_tokens + if (req.temperature !== undefined) out.temperature = req.temperature + if (req.top_p !== undefined) out.topP = req.top_p + const fmt = req.output_config?.format + if (fmt?.type === "json_schema") { + out.responseMimeType = "application/json" + out.responseJsonSchema = fmt.schema + } + + const effort = resolveEffort(req.output_config?.effort) + if (effort && effort !== "none") { + out.thinkingConfig = toThinkingConfig(effort, req.model) + } + + return Object.keys(out).length ? out : undefined +} + +function assertValidAnthropicEffort(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 resolveEffort( + effort: NonNullable["effort"], +): GeminiEffort | undefined { + if (effort !== undefined) return normalizeEffort(effort) + const defaultEffort = geminiDefaultEffort() + if (defaultEffort !== undefined) return normalizeEffort(defaultEffort) + return undefined +} + +function normalizeEffort(effort: string): GeminiEffort { + if (!VALID_EFFORTS.has(effort as GeminiEffort)) { + throw new Error( + `Invalid Gemini effort: "${effort}". Must be one of: ${Array.from(VALID_EFFORTS).join(", ")}`, + ) + } + return effort as GeminiEffort +} + +function toThinkingConfig(effort: GeminiEffort, model: string): GeminiThinkingConfig { + if (model.startsWith("gemini-3")) { + return { + includeThoughts: true, + thinkingLevel: effort === "low" ? "LOW" : effort === "medium" ? "MEDIUM" : "HIGH", + } + } + + return { + includeThoughts: true, + thinkingBudget: + effort === "low" + ? 1024 + : effort === "medium" + ? 4096 + : -1, + } +} diff --git a/src/providers/gemini/translate/signature-cache.ts b/src/providers/gemini/translate/signature-cache.ts new file mode 100644 index 0000000..5d30e1f --- /dev/null +++ b/src/providers/gemini/translate/signature-cache.ts @@ -0,0 +1,31 @@ +const MIN_SIGNATURE_LENGTH = 50 +const SIGNATURE_TTL_MS = 2 * 60 * 60 * 1000 + +interface SignatureEntry { + signature: string + expiresAt: number +} + +const toolSignatures = new Map() + +export function cacheGeminiToolSignature(toolUseId: string, signature: unknown): void { + if (typeof signature !== "string" || signature.length < MIN_SIGNATURE_LENGTH) return + toolSignatures.set(toolUseId, { + signature, + expiresAt: Date.now() + SIGNATURE_TTL_MS, + }) +} + +export function geminiToolSignature(toolUseId: string): string | undefined { + const entry = toolSignatures.get(toolUseId) + if (!entry) return undefined + if (entry.expiresAt <= Date.now()) { + toolSignatures.delete(toolUseId) + return undefined + } + return entry.signature +} + +export function clearGeminiToolSignatures(): void { + toolSignatures.clear() +} diff --git a/src/providers/gemini/translate/stream.ts b/src/providers/gemini/translate/stream.ts new file mode 100644 index 0000000..63266d7 --- /dev/null +++ b/src/providers/gemini/translate/stream.ts @@ -0,0 +1,210 @@ +import { encodeSseEvent } from "../../../sse.ts" +import type { Logger } from "../../../log.ts" +import { mapUsageToAnthropic, reduceUpstream, UpstreamStreamError, type GeminiUsage } from "./reducer.ts" +import { makeThinkingSignature } from "../../kimi/translate/signature.ts" + +function isAbortError(err: unknown): boolean { + return err instanceof Error && err.name === "AbortError" +} + +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: GeminiUsage | 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("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 { + } + } + }, + }) +} 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..960cf4f --- /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", "xhigh"]) + +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" || effort === "xhigh") 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/model-routing.test.ts b/src/providers/model-routing.test.ts new file mode 100644 index 0000000..b4a65dd --- /dev/null +++ b/src/providers/model-routing.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { loadConfig } from "../config.ts" +import { resolveModelRoute } from "./model-routing.ts" + +function setAliasProvider(provider?: string) { + loadConfig({ + env: provider ? { CCP_CLAUDE_ALIAS_PROVIDER: provider } : {}, + configPath: "/tmp/claude-code-proxy-model-routing-test-does-not-exist.json", + forceReload: true, + }) +} + +beforeEach(() => { + setAliasProvider(undefined) +}) + +afterEach(() => { + loadConfig({ forceReload: true }) +}) + +describe("resolveModelRoute", () => { + it("does not route Claude aliases when alias routing is disabled", () => { + expect(resolveModelRoute("claude-opus-4-7")).toBeUndefined() + }) + + it("routes Claude aliases to Codex models", () => { + setAliasProvider("codex") + expect(resolveModelRoute("opus")).toMatchObject({ + provider: "codex", + model: "gpt-5.5", + }) + expect(resolveModelRoute("claude-sonnet-4-6")).toMatchObject({ + provider: "codex", + model: "gpt-5.4", + }) + expect(resolveModelRoute("claude-haiku-4-5-20251001")).toMatchObject({ + provider: "codex", + model: "gpt-5.4-mini", + }) + }) + + it("routes Claude aliases to Gemini models", () => { + setAliasProvider("gemini") + expect(resolveModelRoute("claude-opus-4-7")).toMatchObject({ + provider: "gemini", + model: "gemini-3.1-pro-preview", + }) + expect(resolveModelRoute("claude-sonnet-4-6")).toMatchObject({ + provider: "gemini", + model: "gemini-3-pro-preview", + }) + expect(resolveModelRoute("haiku")).toMatchObject({ + provider: "gemini", + model: "gemini-3-flash-preview", + }) + }) + + it("routes Claude aliases to Kimi without changing the requested model", () => { + setAliasProvider("kimi") + expect(resolveModelRoute("claude-opus-4-7")).toMatchObject({ + provider: "kimi", + model: "claude-opus-4-7", + }) + }) + + it("leaves direct provider model ids alone", () => { + setAliasProvider("gemini") + expect(resolveModelRoute("gemini-3.1-pro-preview")).toBeUndefined() + expect(resolveModelRoute("gpt-5.5")).toBeUndefined() + }) +}) diff --git a/src/providers/model-routing.ts b/src/providers/model-routing.ts new file mode 100644 index 0000000..4051c1b --- /dev/null +++ b/src/providers/model-routing.ts @@ -0,0 +1,51 @@ +import { claudeAliasProvider, type ClaudeAliasProvider } from "../config.ts" + +export interface RoutedModel { + provider: "codex" | "gemini" | "kimi" + model: string + aliasProvider: ClaudeAliasProvider +} + +type ClaudeAlias = "haiku" | "sonnet" | "opus" + +const CODEX_ALIAS_MODELS: Record = { + haiku: "gpt-5.4-mini", + sonnet: "gpt-5.4", + opus: "gpt-5.5", +} + +const GEMINI_ALIAS_MODELS: Record = { + haiku: "gemini-3-flash-preview", + sonnet: "gemini-3-pro-preview", + opus: "gemini-3.1-pro-preview", +} + +export function resolveModelRoute(model: string): RoutedModel | undefined { + const alias = claudeAlias(model) + if (!alias) return undefined + + const aliasProvider = claudeAliasProvider() + if (aliasProvider === "none") return undefined + + if (aliasProvider === "codex") { + return { provider: "codex", model: CODEX_ALIAS_MODELS[alias], aliasProvider } + } + if (aliasProvider === "gemini") { + return { provider: "gemini", model: GEMINI_ALIAS_MODELS[alias], aliasProvider } + } + return { provider: "kimi", model, aliasProvider } +} + +function claudeAlias(model: string): ClaudeAlias | undefined { + const normalized = model.toLowerCase() + if (normalized === "haiku" || normalized.startsWith("claude-haiku-4-5")) { + return "haiku" + } + if (normalized === "sonnet" || normalized.startsWith("claude-sonnet-4-6")) { + return "sonnet" + } + if (normalized === "opus" || normalized.startsWith("claude-opus-4-7")) { + return "opus" + } + return undefined +} diff --git a/src/providers/registry.ts b/src/providers/registry.ts new file mode 100644 index 0000000..9a82209 --- /dev/null +++ b/src/providers/registry.ts @@ -0,0 +1,47 @@ +import type { Provider } from "./types.ts" +import { codexProvider } from "./codex/index.ts" +import { geminiProvider } from "./gemini/index.ts" +import { kimiProvider } from "./kimi/index.ts" + +const PROVIDERS: Record = { + codex: codexProvider, + gemini: geminiProvider, + 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..ca90c46 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,19 +1,26 @@ -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, getProvider, providerForModel } from "./providers/registry.ts" +import { resolveModelRoute } from "./providers/model-routing.ts" +import { CodexUsageError, getCodexUsage } from "./providers/codex/usage.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 +30,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(), @@ -43,136 +61,179 @@ export function startServer(opts: ServeOptions): { stop: () => void; port: numbe async function route(req: Request, url: URL, reqId: string): Promise { if (url.pathname === "/healthz") { - return new Response(JSON.stringify({ ok: true }), { - headers: { "content-type": "application/json" }, - }) + return jsonResponse({ ok: true }) + } + + if (req.method === "GET" && url.pathname === "/_claude-code-proxy/usage") { + const ctx = buildCtx(req, reqId, "codex") + try { + return jsonResponse(await getCodexUsage(ctx.childLogger("provider.codex.usage"), { + signal: req.signal, + })) + } catch (err) { + if (err instanceof CodexUsageError) { + const type = + err.status === 401 || err.status === 403 ? "authentication_error" : "api_error" + return jsonError(err.status, type, err.detail || err.message) + } + throw err + } } 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. +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()}`, + ) } - - 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", - }, + const originalModel = body.model + body.model = normalizeIncomingModel(body.model) + const routed = resolveModelRoute(body.model) + if (routed) { + rootLog.info("model route", { + reqId, + originalModel, + normalizedModel: body.model, + aliasProvider: routed.aliasProvider, + routedProvider: routed.provider, + routedModel: routed.model, }) + body.model = routed.model + return getProvider(routed.provider) + } + 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 +} + +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 { @@ -181,3 +242,10 @@ function jsonError(status: number, type: string, message: string): Response { headers: { "content-type": "application/json" }, }) } + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }) +} 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" - } -}