From 2dcdb81fc00435f522e08e6ec8a699a97dfc4c3e Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 19 Apr 2026 10:28:55 +0300 Subject: [PATCH 01/98] readme: focus intro on chatgpt plus/pro value Lead the README with the main user benefit: using Claude Code with an existing ChatGPT Plus or Pro subscription instead of being pushed toward Codex or Opencode. This removes low-level protocol details from the opening so the first screen explains why someone would use the project before it explains how it works. --- README.md | 85 ++++++++++++++++++++----------------------------------- 1 file changed, 31 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 0f9d8dc..0d8f16e 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,27 @@ # claude-codex-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-codex-proxy` lets you use +[Claude Code](https://www.anthropic.com/claude-code) with your ChatGPT Plus or +Pro subscription. -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. +If you want Claude Code's UX with its TUI, slash commands, hooks, skills, and +plugins, but you don't want to be limited to the Codex CLI or Opencode, this +proxy makes Claude Code speak to OpenAI's Codex Responses backend using your +ChatGPT OAuth session. [Quick start](#quick-start) · [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 @@ -106,8 +109,6 @@ Or set it persistently in `~/.claude/settings.json`: } ``` - - ## Supported models Set `ANTHROPIC_MODEL` to a model your ChatGPT subscription is allowed to use. @@ -122,8 +123,8 @@ Also verified working on this project: - `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. +`"The 'gpt-4.1' model is not supported when using Codex with a ChatGPT account."`. +The proxy surfaces that verbatim. ## How it works @@ -151,30 +152,6 @@ sequenceDiagram 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 | @@ -217,7 +194,7 @@ automatically once the tokens are saved. claude-codex-proxy 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. @@ -277,11 +254,11 @@ 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 @@ -296,7 +273,7 @@ Settings are environment variables on the proxy process, not a config file. ### Files -- `$XDG_STATE_HOME/claude-codex-proxy/proxy.log` — JSON-lines log, rotated at 20 +- `$XDG_STATE_HOME/claude-codex-proxy/proxy.log`: JSON-lines log, rotated at 20 MiB. Secrets (`authorization`, `access`, `refresh`, `id_token`, `ChatGPT-Account-Id`, …) are redacted before write. @@ -313,8 +290,8 @@ Settings are environment variables on the proxy process, not a config file. - **Reasoning blocks:** not forwarded to Claude Code (dropped), even if the upstream model produced them. - **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. + forwarded to Codex like any other structured-output request. This 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. @@ -327,8 +304,8 @@ bun src/cli.ts serve # run locally tail -f ~/.local/state/claude-codex-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 @@ -337,9 +314,9 @@ bun build ./src/cli.ts --compile --outfile ~/.local/bin/claude-codex-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 From d7d0acdfc05bd3ccf87bf18f234c8dcb79bca26c Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 19 Apr 2026 11:10:17 +0300 Subject: [PATCH 02/98] ci: bump github actions to v5 actions/checkout, actions/upload-artifact, and actions/download-artifact v4 run on Node.js 20, which GitHub deprecated. Bump to v5 to silence the deprecation warnings in release runs. --- .github/workflows/release.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9fc7c95..dd503b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -47,7 +47,7 @@ jobs: 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,10 +75,10 @@ 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 token: ${{ secrets.RELEASE_TOKEN }} From 1943336ab5a7f8a0beb930718c2ff354c1023ea3 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 19 Apr 2026 11:13:13 +0300 Subject: [PATCH 03/98] install: ad-hoc codesign binary on macOS Apple Silicon kills unsigned binaries with SIGKILL, so the verification step at the end of the install script failed with "Killed: 9" even after removing the com.apple.quarantine xattr. Bun's compiled binary ships unsigned, and the ubuntu-based release runner can't codesign darwin output. Run `codesign --remove-signature` then `codesign --sign -` on the installed binary so it gets an ad-hoc signature macOS accepts. --- scripts/install.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 5004a44..c9e731f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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}" From bba0ac8520e0ce5e614813d3a789cd739273814b Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 19 Apr 2026 11:34:33 +0300 Subject: [PATCH 04/98] add model aliases for claude-style names This lets proxy users keep portable Claude-oriented model names in settings and skills while still targeting the small set of OpenAI models allowed by the Codex OAuth backend. It fixes cases like cheap subagents requesting haiku or skills pinning sonnet and failing before the request reaches upstream. The proxy now resolves a small explicit alias map before allowlist validation and upstream dispatch, while continuing to report the originally requested model back to Claude Code. The README documents the supported aliases and clarifies that they are convenience shorthands rather than semantic model matches. --- README.md | 24 +++++++++++++++++++++--- src/server.ts | 12 ++++++++---- src/translate/model-allowlist.ts | 14 ++++++++++++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0d8f16e..a413456 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,27 @@ Also verified working on this project: - `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 that verbatim. +The proxy also accepts a small set of convenience aliases and resolves them +before calling the upstream Codex backend: + +- `haiku`, `claude-haiku-4-5`, `claude-haiku-4-5-20251001` → `gpt-5.4-mini` +- `sonnet`, `claude-sonnet-4-6` → `gpt-5.4` +- `opus`, `claude-opus-4-7` → `gpt-5.4` + +These aliases are only shorthand for portability and cheaper subagent configs; +they are not semantic equivalents of the Claude models they resemble. + +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. + +For example, you can now point Claude Code or a subagent at the proxy with: + +```sh +ANTHROPIC_MODEL=haiku claude +``` + +and the proxy will send `gpt-5.4-mini` upstream. ## How it works diff --git a/src/server.ts b/src/server.ts index 2f1d73e..eb7c87f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,6 @@ import { createLogger, logDir } from "./log.ts" import type { AnthropicRequest } from "./anthropic/schema.ts" -import { assertAllowedModel, ModelNotAllowedError } from "./translate/model-allowlist.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" @@ -87,22 +87,26 @@ async function handleMessages(req: Request, reqId: string): Promise { }) if (VERBOSE) log.debug("anthropic request body", { reqId, body }) + const resolvedModel = resolveModel(body.model) + try { - assertAllowedModel(body.model) + assertAllowedModel(resolvedModel) } catch (err) { if (err instanceof ModelNotAllowedError) { return jsonError( 400, "invalid_request_error", - `Model "${err.model}" is not in the Codex OAuth allowlist`, + `Model "${body.model}" resolves to unsupported model "${err.model}"`, ) } throw err } - const translated = translateRequest(body, { sessionId }) + const translated = translateRequest({ ...body, model: resolvedModel }, { sessionId }) log.debug("translated request", { reqId, + requestedModel: body.model, + resolvedModel, inputItems: translated.input.length, tools: translated.tools?.length ?? 0, hasInstructions: !!translated.instructions, diff --git a/src/translate/model-allowlist.ts b/src/translate/model-allowlist.ts index c561967..5ff3744 100644 --- a/src/translate/model-allowlist.ts +++ b/src/translate/model-allowlist.ts @@ -5,6 +5,20 @@ export const ALLOWED_MODELS = new Set([ "gpt-5.4-mini", ]) +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.4"], + ["claude-opus-4-7", "gpt-5.4"], +]) + +export function resolveModel(model: string): string { + return MODEL_ALIASES.get(model) ?? model +} + export function assertAllowedModel(model: string): void { if (!ALLOWED_MODELS.has(model)) { throw new ModelNotAllowedError(model) From 8e0a6534e0d5e4157191c68809e44a65f2d588f1 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 19 Apr 2026 12:00:45 +0300 Subject: [PATCH 05/98] fix malformed streamed read tool args GPT-5.4 sometimes emits Read tool calls with an empty string for the optional pages field, and Claude Code rejects that payload before the tool can run. This repairs that compatibility gap in the proxy instead of relying on the model to always satisfy the schema. The reducer now buffers Read tool argument deltas until the tool block is complete, removes pages when it is the empty string, and then emits the sanitized JSON once. Other tools keep their existing streaming behavior, so the fix stays narrow and preserves the normal incremental path outside Read. --- src/translate/reducer.ts | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/src/translate/reducer.ts b/src/translate/reducer.ts index 67ee75e..b0f8532 100644 --- a/src/translate/reducer.ts +++ b/src/translate/reducer.ts @@ -45,9 +45,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 @@ -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") { From 26bcdb03de5e215c0d921f28fcb358d3c70f587c Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 19 Apr 2026 12:03:24 +0300 Subject: [PATCH 06/98] readme: add claude code screenshot The README opened with text only, which made it harder to immediately show what this proxy enables in practice. Add a screenshot near the top so visitors can see Claude Code running through the proxy before they get into setup details. The image is stored as a lossless WebP under meta/ to keep the repo size down without degrading the terminal UI capture. The README references that asset near the intro so it appears prominently on the project page. --- README.md | 7 +++++-- meta/claude-code-screenshot.webp | Bin 0 -> 148358 bytes 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 meta/claude-code-screenshot.webp diff --git a/README.md b/README.md index a413456..5530ea7 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ plugins, but you don't want to be limited to the Codex CLI or Opencode, this proxy makes Claude Code speak to OpenAI's Codex Responses backend using your ChatGPT OAuth session. +Claude Code running through claude-codex-proxy + [Quick start](#quick-start) · [How it works](#how-it-works) · [Configuration](#configuration) · [Limitations](#limitations) @@ -133,8 +135,9 @@ These aliases are only shorthand for portability and cheaper subagent configs; they are not semantic equivalents of the Claude models they resemble. 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. +like +`"The 'gpt-4.1' model is not supported when using Codex with a ChatGPT account."`. +The proxy surfaces that verbatim. For example, you can now point Claude Code or a subagent at the proxy with: diff --git a/meta/claude-code-screenshot.webp b/meta/claude-code-screenshot.webp new file mode 100644 index 0000000000000000000000000000000000000000..d996a8db9ec5883be0c1597d58b104179465643b GIT binary patch literal 148358 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%0A@KE2qVZ){Az=zPXrAgHTdPsGd1 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
mw-m>%Fcd#>Ii)^6B#RLR!!Q}PK3ZZLs? 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 Date: Sun, 19 Apr 2026 13:10:51 +0300 Subject: [PATCH 07/98] advertise proxy identity upstream Stop sending the Codex CLI originator string on upstream requests and identify this project as claude-codex-proxy instead. This keeps the proxy accurate about what it is rather than pretending to be codex-tui or opencode. Update the README sequence diagram to reflect the actual originator header value so the documented request shape matches the implementation. --- README.md | 2 +- src/auth/constants.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5530ea7..3a8700a 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ sequenceDiagram 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 + P->>CGX: POST /responses
Bearer + ChatGPT-Account-Id + originator=claude-codex-proxy 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-->>CC: Anthropic SSE
(message_start, content_block_*, message_delta, message_stop) diff --git a/src/auth/constants.ts b/src/auth/constants.ts index 1daf900..ed8557f 100644 --- a/src/auth/constants.ts +++ b/src/auth/constants.ts @@ -3,5 +3,5 @@ export const ISSUER = "https://auth.openai.com" export const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" export const OAUTH_PORT = 1455 export const OAUTH_REDIRECT_URI = `http://localhost:${OAUTH_PORT}/auth/callback` -export const ORIGINATOR = "codex-tui" +export const ORIGINATOR = "claude-codex-proxy" export const REFRESH_MARGIN_MS = 5 * 60 * 1000 From 31970f30068c1b2e8e6f1cec556039e074f53505 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 19 Apr 2026 13:12:30 +0300 Subject: [PATCH 08/98] add CHANGELOG.md --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..096a79c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## v0.0.1 (2026-04-19) + +Initial release. From d900cde5fc1d1c15d3b2f1f2cd4d798598876238 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 19 Apr 2026 13:17:17 +0300 Subject: [PATCH 09/98] 0.0.2 --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 096a79c..31674cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 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/package.json b/package.json index 7a329f9..1236794 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-codex-proxy", - "version": "0.0.1", + "version": "0.0.2", "private": true, "type": "module", "bin": { From 8c453ff21e5373811164027f24c421515fea58c5 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 19 Apr 2026 13:20:08 +0300 Subject: [PATCH 10/98] add license --- LICENSE | 21 +++++++++++++++++++++ package.json | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 LICENSE 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/package.json b/package.json index 1236794..5506814 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "claude-codex-proxy", "version": "0.0.2", - "private": true, + "license": "MIT", "type": "module", "bin": { "claude-codex-proxy": "./src/cli.ts" From e550aa3af078b97870c4fce45cd282675df869fa Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 19 Apr 2026 13:22:54 +0300 Subject: [PATCH 11/98] update README --- README.md | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3a8700a..216a6e3 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,6 @@ [Claude Code](https://www.anthropic.com/claude-code) with your ChatGPT Plus or Pro subscription. -If you want Claude Code's UX with its TUI, slash commands, hooks, skills, and -plugins, but you don't want to be limited to the Codex CLI or Opencode, this -proxy makes Claude Code speak to OpenAI's Codex Responses backend using your -ChatGPT OAuth session. - Claude Code running through claude-codex-proxy [Quick start](#quick-start) · [How it works](#how-it-works) · @@ -44,15 +39,6 @@ curl -fsSL https://raw.githubusercontent.com/raine/claude-codex-proxy/main/scrip **Manual:** download a prebuilt binary for your platform from the [releases page](https://github.com/raine/claude-codex-proxy/releases). -**From source** (requires [Bun](https://bun.sh) 1.3+): - -```sh -git clone https://github.com/raine/claude-codex-proxy -cd claude-codex-proxy -bun install -bun src/cli.ts --version -``` - ### 2. Authenticate with ChatGPT Open a browser (PKCE flow): @@ -301,7 +287,8 @@ Settings are environment variables on the proxy process, not a config file. ## 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. + the same gray area OpenCode occupies, although OpenAI seems to be cool with + OpenCode. 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 From ead48d07c9448c9b61e29109d25aca5037d3dfa8 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 19 Apr 2026 22:27:31 +0300 Subject: [PATCH 12/98] share normalization between request and count paths Claude Code uses /v1/messages/count_tokens to decide when to compact, so counting the raw Anthropic request could drift from the prompt shape the proxy actually sends upstream. That made the estimator include data such as the rotating billing header even though the request translator strips it before the Codex call. Reuse the existing request-normalization helpers for system text, message content, and tool-result flattening inside the local token counter. This keeps counting aligned with the translation path without changing the proxy's upstream behavior, and should reduce premature compaction caused by systematic mismatch between the two code paths. --- src/count-tokens.ts | 39 ++++++++++++++++++--------------------- src/translate/request.ts | 6 +++--- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/src/count-tokens.ts b/src/count-tokens.ts index 217fd3d..e976d24 100644 --- a/src/count-tokens.ts +++ b/src/count-tokens.ts @@ -1,38 +1,35 @@ import { encode } from "gpt-tokenizer/model/gpt-4o" import type { AnthropicRequest } from "./anthropic/schema.ts" +import { buildInstructions, normalizeContent, toolResultToString } from "./translate/request.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 - } + const instructions = buildInstructions(req.system) + if (instructions) total += encode(instructions).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 + const blocks = normalizeContent(msg.content) + for (const block of blocks) { + if (block.type === "text") { + total += encode(block.text).length + } else if (block.type === "image") { + const value = block.source.type === "url" ? block.source.url : `data:${block.source.media_type};base64,${block.source.data}` + total += encode(value).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(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 } - // Rough per-message overhead + total += req.messages.length * 4 return total } diff --git a/src/translate/request.ts b/src/translate/request.ts index 326455e..ec5a393 100644 --- a/src/translate/request.ts +++ b/src/translate/request.ts @@ -117,7 +117,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 +181,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 +191,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 From 98866616575827470ab8461132905fdeac46f8e2 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 19 Apr 2026 22:32:06 +0300 Subject: [PATCH 13/98] add compaction telemetry logging We still need to understand why Claude Code compacts earlier than expected through the proxy, and the shared-normalization change alone does not explain whether the remaining drift comes from local counting, translated prompt shape, or upstream usage. Without side-by-side numbers the logs are not enough to tell which layer is responsible. Add opt-in telemetry behind CCP_LOG_COMPACTION so the proxy can log the local /count_tokens result, the translated prompt estimate, and the final upstream usage for the same turn. This keeps normal runs quiet while making it much simpler to compare Claude Code's compaction inputs against what the proxy actually sends and what Codex reports back. --- src/count-tokens.ts | 34 +++++++++++++++++++++ src/server.ts | 68 +++++++++++++++++++++++++++++++++++++++-- src/translate/stream.ts | 7 ++++- 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/count-tokens.ts b/src/count-tokens.ts index e976d24..ef8df5f 100644 --- a/src/count-tokens.ts +++ b/src/count-tokens.ts @@ -1,5 +1,6 @@ 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" export function countTokens(req: AnthropicRequest): number { @@ -33,3 +34,36 @@ export function countTokens(req: AnthropicRequest): number { 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 += encode(part.image_url).length + } + } + } 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 + } + + total += req.input.length * 4 + return total +} diff --git a/src/server.ts b/src/server.ts index eb7c87f..cd82fc1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,10 +5,11 @@ 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 { countTokens, countTranslatedTokens } from "./count-tokens.ts" const log = createLogger("server") const VERBOSE = !!process.env.CCP_LOG_VERBOSE +const LOG_COMPACTION = !!process.env.CCP_LOG_COMPACTION export interface ServeOptions { port: number @@ -52,6 +53,16 @@ async function route(req: Request, url: URL, reqId: string): Promise { const body = (await req.json()) as AnthropicRequest const tokens = countTokens(body) log.debug("count_tokens", { reqId, tokens }) + if (LOG_COMPACTION) { + log.info("compaction telemetry", { + reqId, + path: url.pathname, + model: body.model, + tokens, + messageCount: body.messages?.length ?? 0, + toolCount: body.tools?.length ?? 0, + }) + } return new Response(JSON.stringify({ input_tokens: tokens }), { headers: { "content-type": "application/json" }, }) @@ -103,6 +114,8 @@ async function handleMessages(req: Request, reqId: string): Promise { } const translated = translateRequest({ ...body, model: resolvedModel }, { sessionId }) + const localInputTokens = LOG_COMPACTION ? countTokens(body) : undefined + const translatedInputTokens = LOG_COMPACTION ? countTranslatedTokens(translated) : undefined log.debug("translated request", { reqId, requestedModel: body.model, @@ -113,6 +126,20 @@ async function handleMessages(req: Request, reqId: string): Promise { promptCacheKey: translated.prompt_cache_key, }) if (VERBOSE) log.debug("translated request body", { reqId, body: translated }) + if (LOG_COMPACTION) { + log.info("compaction telemetry", { + reqId, + phase: "translated_request", + requestedModel: body.model, + resolvedModel, + localInputTokens, + translatedInputTokens, + inputItems: translated.input.length, + toolCount: translated.tools?.length ?? 0, + hasInstructions: !!translated.instructions, + sessionId, + }) + } let upstream try { @@ -139,7 +166,28 @@ async function handleMessages(req: Request, reqId: string): Promise { } if (wantStream) { - const stream = translateStream(upstream.body, { messageId, model: body.model }) + const stream = translateStream(upstream.body, { + messageId, + model: body.model, + onFinish: LOG_COMPACTION + ? (finish) => { + log.info("compaction telemetry", { + reqId, + phase: "upstream_finish", + mode: "stream", + requestedModel: body.model, + resolvedModel, + localInputTokens, + translatedInputTokens, + upstreamInputTokens: finish.usage?.input_tokens ?? 0, + upstreamOutputTokens: finish.usage?.output_tokens ?? 0, + upstreamCachedInputTokens: finish.usage?.input_tokens_details?.cached_tokens ?? 0, + stopReason: finish.stopReason, + sessionId, + }) + } + : undefined, + }) return new Response(stream, { status: 200, headers: { @@ -152,6 +200,22 @@ async function handleMessages(req: Request, reqId: string): Promise { try { const result = await accumulateResponse(upstream.body, { messageId, model: body.model }) + if (LOG_COMPACTION) { + log.info("compaction telemetry", { + reqId, + phase: "upstream_finish", + mode: "non_stream", + requestedModel: body.model, + resolvedModel, + localInputTokens, + translatedInputTokens, + upstreamInputTokens: result.usage.input_tokens, + upstreamOutputTokens: result.usage.output_tokens, + upstreamCachedInputTokens: result.usage.cache_read_input_tokens, + stopReason: result.stop_reason, + sessionId, + }) + } return new Response(JSON.stringify(result), { headers: { "content-type": "application/json" }, }) diff --git a/src/translate/stream.ts b/src/translate/stream.ts index a551282..89c6c78 100644 --- a/src/translate/stream.ts +++ b/src/translate/stream.ts @@ -14,7 +14,11 @@ const log = createLogger("translate.stream") */ export function translateStream( upstream: ReadableStream, - opts: { messageId: string; model: string }, + opts: { + messageId: string + model: string + onFinish?: (finish: { stopReason: "end_turn" | "tool_use" | "max_tokens"; usage?: Parameters[0] }) => void + }, ): ReadableStream { const encoder = new TextEncoder() return new ReadableStream({ @@ -93,6 +97,7 @@ export function translateStream( 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 }, From 92206060936d21111ac133376c0eca1bf0247cf5 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 19 Apr 2026 22:34:54 +0300 Subject: [PATCH 14/98] log active tools on stream abort AbortError logs did not identify which in-flight tool request was being canceled, which made the Read failures hard to correlate in proxy logs. Thread reqId and sessionId into the streaming translator and track active tool ids and names while SSE events are in flight. Include that context in upstream stream error and stream translation error logs so canceled Read calls can be matched back to a specific request. This does not change translated responses or tool behavior. It only adds diagnostic context to proxy logging. --- src/server.ts | 2 ++ src/translate/stream.ts | 24 ++++++++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index cd82fc1..04b8b81 100644 --- a/src/server.ts +++ b/src/server.ts @@ -169,6 +169,8 @@ async function handleMessages(req: Request, reqId: string): Promise { const stream = translateStream(upstream.body, { messageId, model: body.model, + reqId, + sessionId, onFinish: LOG_COMPACTION ? (finish) => { log.info("compaction telemetry", { diff --git a/src/translate/stream.ts b/src/translate/stream.ts index 89c6c78..fe78c4f 100644 --- a/src/translate/stream.ts +++ b/src/translate/stream.ts @@ -17,6 +17,8 @@ export function translateStream( opts: { messageId: string model: string + reqId: string + sessionId?: string onFinish?: (finish: { stopReason: "end_turn" | "tool_use" | "max_tokens"; usage?: Parameters[0] }) => void }, ): ReadableStream { @@ -26,6 +28,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 @@ -73,6 +76,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", @@ -93,6 +97,7 @@ 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": @@ -108,8 +113,17 @@ 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 }) + log.warn("upstream stream error", { + reqId: opts.reqId, + sessionId: opts.sessionId, + kind: err.kind, + message: err.message, + activeToolNames, + activeToolCalls, + }) ensureMessageStart() emit("error", { type: "error", @@ -119,7 +133,13 @@ export function translateStream( }, }) } else { - log.error("stream translation error", { err: String(err) }) + log.error("stream translation error", { + reqId: opts.reqId, + sessionId: opts.sessionId, + err: String(err), + activeToolNames, + activeToolCalls, + }) ensureMessageStart() emit("error", { type: "error", From e88c774ac129ad80dcf3925c6dbebd09ba013d9a Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 19 Apr 2026 22:38:08 +0300 Subject: [PATCH 15/98] use verbose logging for compaction telemetry The new compaction telemetry does not need its own environment variable, and a separate flag makes the logging behavior harder to discover while we are still actively debugging the proxy. The existing verbose mode is already the place where we opt into noisier request and stream diagnostics. Fold the compaction telemetry into CCP_LOG_VERBOSE so one switch enables the full debugging view. This keeps the runtime configuration simpler and matches how the rest of the proxy's detailed logging is controlled. --- src/server.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/server.ts b/src/server.ts index 04b8b81..1b68f2b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,7 +9,6 @@ import { countTokens, countTranslatedTokens } from "./count-tokens.ts" const log = createLogger("server") const VERBOSE = !!process.env.CCP_LOG_VERBOSE -const LOG_COMPACTION = !!process.env.CCP_LOG_COMPACTION export interface ServeOptions { port: number @@ -53,7 +52,7 @@ async function route(req: Request, url: URL, reqId: string): Promise { const body = (await req.json()) as AnthropicRequest const tokens = countTokens(body) log.debug("count_tokens", { reqId, tokens }) - if (LOG_COMPACTION) { + if (VERBOSE) { log.info("compaction telemetry", { reqId, path: url.pathname, @@ -114,8 +113,8 @@ async function handleMessages(req: Request, reqId: string): Promise { } const translated = translateRequest({ ...body, model: resolvedModel }, { sessionId }) - const localInputTokens = LOG_COMPACTION ? countTokens(body) : undefined - const translatedInputTokens = LOG_COMPACTION ? countTranslatedTokens(translated) : undefined + const localInputTokens = VERBOSE ? countTokens(body) : undefined + const translatedInputTokens = VERBOSE ? countTranslatedTokens(translated) : undefined log.debug("translated request", { reqId, requestedModel: body.model, @@ -126,7 +125,7 @@ async function handleMessages(req: Request, reqId: string): Promise { promptCacheKey: translated.prompt_cache_key, }) if (VERBOSE) log.debug("translated request body", { reqId, body: translated }) - if (LOG_COMPACTION) { + if (VERBOSE) { log.info("compaction telemetry", { reqId, phase: "translated_request", @@ -171,7 +170,7 @@ async function handleMessages(req: Request, reqId: string): Promise { model: body.model, reqId, sessionId, - onFinish: LOG_COMPACTION + onFinish: VERBOSE ? (finish) => { log.info("compaction telemetry", { reqId, @@ -202,7 +201,7 @@ async function handleMessages(req: Request, reqId: string): Promise { try { const result = await accumulateResponse(upstream.body, { messageId, model: body.model }) - if (LOG_COMPACTION) { + if (VERBOSE) { log.info("compaction telemetry", { reqId, phase: "upstream_finish", From 44ae8b62ff3acc0485f526ba8e415f18a19eae8a Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 19 Apr 2026 23:08:00 +0300 Subject: [PATCH 16/98] add per-session compaction timeline telemetry Capture per-session request sequencing in verbose compaction logs so we can see which /v1/messages/count_tokens result Claude Code saw before each translated request and upstream completion. This makes it possible to reconstruct the compaction decision boundary from logs instead of inferring it indirectly from isolated samples. The implementation keeps lightweight in-memory snapshots per session ID, recording the latest count_tokens and messages request metadata and linking them through sessionSeq and previous request IDs. This does not change request handling or upstream payloads; it only enriches existing verbose telemetry. --- src/server.ts | 104 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 96 insertions(+), 8 deletions(-) diff --git a/src/server.ts b/src/server.ts index 1b68f2b..b0e0e07 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,6 +14,45 @@ export interface ServeOptions { port: number } +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 { + seq: number + lastCount?: SessionCountSnapshot + lastMessage?: SessionMessageSnapshot +} + +const sessionTimeline = new Map() + +function nextSessionTimeline(sessionId?: string): { + state?: SessionTimelineState + sessionSeq?: number +} { + if (!sessionId) return {} + let state = sessionTimeline.get(sessionId) + if (!state) { + state = { seq: 0 } + sessionTimeline.set(sessionId, state) + } + state.seq += 1 + return { state, sessionSeq: state.seq } +} + export function startServer(opts: ServeOptions): { stop: () => void; port: number } { const server = Bun.serve({ hostname: "127.0.0.1", @@ -51,15 +90,37 @@ async function route(req: Request, url: URL, reqId: string): Promise { if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") { const body = (await req.json()) as AnthropicRequest const tokens = countTokens(body) + const sessionId = req.headers.get("x-claude-code-session-id") || undefined + const { state, sessionSeq } = nextSessionTimeline(sessionId) + const messageCount = body.messages?.length ?? 0 + const toolCount = body.tools?.length ?? 0 log.debug("count_tokens", { reqId, tokens }) + if (state) { + state.lastCount = { + reqId, + model: body.model, + messageCount, + toolCount, + tokens, + } + } if (VERBOSE) { log.info("compaction telemetry", { reqId, + phase: "count_tokens", path: url.pathname, + sessionId, + sessionSeq, model: body.model, tokens, - messageCount: body.messages?.length ?? 0, - toolCount: body.tools?.length ?? 0, + 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 }), { @@ -83,14 +144,17 @@ async function handleMessages(req: Request, reqId: string): Promise { } const sessionId = req.headers.get("x-claude-code-session-id") || undefined + const { state, sessionSeq } = nextSessionTimeline(sessionId) 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", { reqId, model: body.model, - messageCount: body.messages?.length, - toolCount: body.tools?.length ?? 0, + messageCount, + toolCount, stream: wantStream, sessionId, hasJsonSchemaFormat: body.output_config?.format?.type === "json_schema", @@ -115,6 +179,16 @@ async function handleMessages(req: Request, reqId: string): Promise { const translated = translateRequest({ ...body, model: resolvedModel }, { sessionId }) const localInputTokens = VERBOSE ? countTokens(body) : undefined const translatedInputTokens = VERBOSE ? countTranslatedTokens(translated) : undefined + if (state) { + state.lastMessage = { + reqId, + model: body.model, + messageCount, + toolCount, + localInputTokens, + translatedInputTokens, + } + } log.debug("translated request", { reqId, requestedModel: body.model, @@ -129,14 +203,22 @@ async function handleMessages(req: Request, reqId: string): Promise { log.info("compaction telemetry", { reqId, phase: "translated_request", + sessionId, + sessionSeq, requestedModel: body.model, resolvedModel, + messageCount, + toolCount, localInputTokens, translatedInputTokens, inputItems: translated.input.length, - toolCount: translated.tools?.length ?? 0, + translatedToolCount: translated.tools?.length ?? 0, hasInstructions: !!translated.instructions, - sessionId, + previousCountReqId: state?.lastCount?.reqId, + previousCountModel: state?.lastCount?.model, + previousCountTokens: state?.lastCount?.tokens, + previousCountMessageCount: state?.lastCount?.messageCount, + previousCountToolCount: state?.lastCount?.toolCount, }) } @@ -176,15 +258,18 @@ async function handleMessages(req: Request, reqId: string): Promise { reqId, phase: "upstream_finish", mode: "stream", + sessionId, + sessionSeq, requestedModel: body.model, resolvedModel, + messageCount, + toolCount, localInputTokens, translatedInputTokens, upstreamInputTokens: finish.usage?.input_tokens ?? 0, upstreamOutputTokens: finish.usage?.output_tokens ?? 0, upstreamCachedInputTokens: finish.usage?.input_tokens_details?.cached_tokens ?? 0, stopReason: finish.stopReason, - sessionId, }) } : undefined, @@ -206,15 +291,18 @@ async function handleMessages(req: Request, reqId: string): Promise { reqId, phase: "upstream_finish", mode: "non_stream", + sessionId, + sessionSeq, requestedModel: body.model, resolvedModel, + messageCount, + toolCount, localInputTokens, translatedInputTokens, upstreamInputTokens: result.usage.input_tokens, upstreamOutputTokens: result.usage.output_tokens, upstreamCachedInputTokens: result.usage.cache_read_input_tokens, stopReason: result.stop_reason, - sessionId, }) } return new Response(JSON.stringify(result), { From f594ef021e81a86393c0126202626ca04923ad42 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 19 Apr 2026 23:10:19 +0300 Subject: [PATCH 17/98] fix cached token usage mapping Claude Code decides when to compact from the last response usage plus new-message estimates. The proxy was passing Codex input_tokens through unchanged while also exposing cached_tokens as cache_read_input_tokens, which made Claude Code count cached prompt tokens twice once prompt caching started to hit. Normalize the usage mapping to Anthropic semantics by subtracting cached prompt tokens from input_tokens and reporting them only through cache_read_input_tokens. This keeps downstream context-window calculations aligned with the real prompt size without changing request translation or stream behavior. There are no intended breaking changes beyond lower reported context usage on cached turns. The local /v1/messages/count_tokens path is still only an estimate, but the main auto-compact path should no longer inflate after cache hits. --- src/translate/reducer.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/translate/reducer.ts b/src/translate/reducer.ts index b0f8532..071b47c 100644 --- a/src/translate/reducer.ts +++ b/src/translate/reducer.ts @@ -251,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, } } From 8c000902264dcf4472daa11a4cd115574984cbef Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 19 Apr 2026 23:13:41 +0300 Subject: [PATCH 18/98] improve count_tokens estimation Claude Code calls /v1/messages/count_tokens for some sizing and warning paths, but the proxy was estimating tokens from the raw Anthropic-shaped request instead of the translated Codex payload. That let the local estimate drift from what the proxy actually sends upstream, especially for structured output requests. Count the translated request shape instead so the estimate includes the same instructions, input items, tools, tool choice, and JSON schema format that the upstream API sees. Also stop treating inline images as raw base64 text, which could wildly overestimate prompt size and trigger premature warnings when image content was present. This is still a local estimate rather than a provider-native count endpoint, so small drift is still possible. The intended behavior change is better alignment between proxy-side token warnings and the real upstream prompt shape. --- src/count-tokens.ts | 23 +++++++++++++++++++---- src/server.ts | 5 ++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/count-tokens.ts b/src/count-tokens.ts index ef8df5f..db0c5d0 100644 --- a/src/count-tokens.ts +++ b/src/count-tokens.ts @@ -3,6 +3,8 @@ 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) @@ -14,8 +16,7 @@ export function countTokens(req: AnthropicRequest): number { if (block.type === "text") { total += encode(block.text).length } else if (block.type === "image") { - const value = block.source.type === "url" ? block.source.url : `data:${block.source.media_type};base64,${block.source.data}` - total += encode(value).length + total += IMAGE_TOKEN_ESTIMATE } else if (block.type === "tool_use") { total += encode(block.name).length total += encode(JSON.stringify(block.input ?? {})).length @@ -35,7 +36,9 @@ export function countTokens(req: AnthropicRequest): number { return total } -export function countTranslatedTokens(req: Pick): number { +export function countTranslatedTokens( + req: Pick, +): number { let total = 0 if (req.instructions) total += encode(req.instructions).length @@ -45,7 +48,7 @@ export function countTranslatedTokens(req: Pick { if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") { const body = (await req.json()) as AnthropicRequest - const tokens = countTokens(body) + const resolvedModel = resolveModel(body.model) + const translated = translateRequest({ ...body, model: resolvedModel }) + const tokens = countTranslatedTokens(translated) const sessionId = req.headers.get("x-claude-code-session-id") || undefined const { state, sessionSeq } = nextSessionTimeline(sessionId) const messageCount = body.messages?.length ?? 0 @@ -112,6 +114,7 @@ async function route(req: Request, url: URL, reqId: string): Promise { sessionId, sessionSeq, model: body.model, + resolvedModel, tokens, messageCount, toolCount, From 178c8e086be321ef66cd550347aea697e0a29d9d Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 20 Apr 2026 20:07:06 +0300 Subject: [PATCH 19/98] preserve output budget and log usage drift Claude Code sends max_tokens and then uses the returned assistant usage to decide when to auto-compact. The proxy was dropping that output budget entirely, which let the upstream choose its own longer completion limit and could inflate output_tokens enough to trigger proactive compaction earlier than the direct Anthropic path. Pass the Anthropic max_tokens field through as the upstream max_output_tokens setting, and extend verbose telemetry so one run can show whether remaining drift comes from hidden reasoning tokens or lost context-management behavior. The new logs include requested vs translated output limits, whether context_management was present, raw upstream reasoning token counts, and the mapped Anthropic usage totals Claude Code actually sees. This change does not alter normal response translation beyond preserving Claude Code's requested output budget. The extra telemetry is only emitted in verbose mode and is intended to confirm or rule out the remaining compaction hypotheses without changing user-facing behavior. --- src/server.ts | 51 +++++++++++++++++++++++++++++++++---- src/translate/accumulate.ts | 37 +++++++++++++++++---------- src/translate/request.ts | 2 ++ 3 files changed, 72 insertions(+), 18 deletions(-) diff --git a/src/server.ts b/src/server.ts index c0312ec..b0ff6f6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,6 +4,7 @@ import { assertAllowedModel, ModelNotAllowedError, resolveModel } from "./transl 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 "./codex/client.ts" import { countTokens, countTranslatedTokens } from "./count-tokens.ts" @@ -31,6 +32,20 @@ interface SessionMessageSnapshot { translatedInputTokens?: number } +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 + ) +} + interface SessionTimelineState { seq: number lastCount?: SessionCountSnapshot @@ -160,6 +175,8 @@ async function handleMessages(req: Request, reqId: string): Promise { toolCount, stream: wantStream, sessionId, + requestedMaxTokens: body.max_tokens, + hasContextManagement: body.context_management !== undefined, hasJsonSchemaFormat: body.output_config?.format?.type === "json_schema", }) if (VERBOSE) log.debug("anthropic request body", { reqId, body }) @@ -199,6 +216,9 @@ async function handleMessages(req: Request, reqId: string): Promise { inputItems: translated.input.length, tools: translated.tools?.length ?? 0, hasInstructions: !!translated.instructions, + requestedMaxTokens: body.max_tokens, + translatedMaxOutputTokens: translated.max_output_tokens, + hasContextManagement: body.context_management !== undefined, promptCacheKey: translated.prompt_cache_key, }) if (VERBOSE) log.debug("translated request body", { reqId, body: translated }) @@ -217,6 +237,9 @@ async function handleMessages(req: Request, reqId: string): Promise { inputItems: translated.input.length, translatedToolCount: translated.tools?.length ?? 0, hasInstructions: !!translated.instructions, + requestedMaxTokens: body.max_tokens, + translatedMaxOutputTokens: translated.max_output_tokens, + hasContextManagement: body.context_management !== undefined, previousCountReqId: state?.lastCount?.reqId, previousCountModel: state?.lastCount?.model, previousCountTokens: state?.lastCount?.tokens, @@ -257,6 +280,7 @@ async function handleMessages(req: Request, reqId: string): Promise { sessionId, onFinish: VERBOSE ? (finish) => { + const mappedUsage = finish.usage ? mapUsageToAnthropic(finish.usage) : undefined log.info("compaction telemetry", { reqId, phase: "upstream_finish", @@ -269,9 +293,18 @@ async function handleMessages(req: Request, reqId: string): Promise { toolCount, localInputTokens, translatedInputTokens, + requestedMaxTokens: body.max_tokens, + translatedMaxOutputTokens: translated.max_output_tokens, + hasContextManagement: body.context_management !== undefined, 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, }) } @@ -302,13 +335,21 @@ async function handleMessages(req: Request, reqId: string): Promise { toolCount, localInputTokens, translatedInputTokens, - upstreamInputTokens: result.usage.input_tokens, - upstreamOutputTokens: result.usage.output_tokens, - upstreamCachedInputTokens: result.usage.cache_read_input_tokens, - stopReason: result.stop_reason, + requestedMaxTokens: body.max_tokens, + translatedMaxOutputTokens: translated.max_output_tokens, + hasContextManagement: body.context_management !== undefined, + 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), { + return new Response(JSON.stringify(result.response), { headers: { "content-type": "application/json" }, }) } catch (err) { diff --git a/src/translate/accumulate.ts b/src/translate/accumulate.ts index 9e1ae95..14fb047 100644 --- a/src/translate/accumulate.ts +++ b/src/translate/accumulate.ts @@ -1,4 +1,5 @@ import { mapUsageToAnthropic, reduceUpstream } from "./reducer.ts" +import type { CodexUsage } from "./reducer.ts" export { UpstreamStreamError } from "./reducer.ts" @@ -21,6 +22,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 @@ -30,7 +36,7 @@ export interface AnthropicNonStreamResponse { export async function accumulateResponse( upstream: ReadableStream, opts: { messageId: string; model: string }, -): Promise { +): Promise { type Block = | { kind: "text"; text: string } | { kind: "tool"; id: string; name: string; args: string } @@ -39,6 +45,7 @@ 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)) { switch (e.kind) { @@ -65,6 +72,7 @@ export async function accumulateResponse( break case "finish": stopReason = e.stopReason + rawUsage = e.usage usage = mapUsageToAnthropic(e.usage) break } @@ -88,18 +96,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/translate/request.ts b/src/translate/request.ts index ec5a393..8d2b50f 100644 --- a/src/translate/request.ts +++ b/src/translate/request.ts @@ -12,6 +12,7 @@ export interface ResponsesRequest { instructions?: string input: ResponsesInputItem[] tools?: ResponsesTool[] + max_output_tokens?: number tool_choice?: | "auto" | "none" @@ -95,6 +96,7 @@ export function translateRequest(req: AnthropicRequest, opts: TranslateOptions = } if (instructions) out.instructions = instructions if (tools && tools.length) out.tools = tools + if (req.max_tokens) out.max_output_tokens = req.max_tokens if (opts.sessionId) out.prompt_cache_key = opts.sessionId const effort = req.output_config?.effort if (effort) out.reasoning = { effort } From 52c2f2c80c8c1ef7bea02f695f8a67c34e6e0583 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 20 Apr 2026 20:22:32 +0300 Subject: [PATCH 20/98] log context_management payloads We already tracked whether context_management was present, but that was not enough to verify which edits Claude Code was actually sending. Add the exact incoming context_management payload to verbose compaction telemetry so proxy logs can be grepped directly for the request-side context pruning instructions. This keeps normal request handling unchanged and only enriches verbose diagnostics. The goal is to confirm whether Anthropic-side context management is active in the sessions that compact early, which helps separate request-shape drift from missing API-native context pruning behavior. --- src/server.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/server.ts b/src/server.ts index b0ff6f6..b9e1cd8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -167,6 +167,7 @@ async function handleMessages(req: Request, reqId: string): Promise { const wantStream = body.stream !== false const messageCount = body.messages?.length ?? 0 const toolCount = body.tools?.length ?? 0 + const contextManagement = body.context_management log.debug("anthropic request", { reqId, @@ -176,7 +177,7 @@ async function handleMessages(req: Request, reqId: string): Promise { stream: wantStream, sessionId, requestedMaxTokens: body.max_tokens, - hasContextManagement: body.context_management !== undefined, + hasContextManagement: contextManagement !== undefined, hasJsonSchemaFormat: body.output_config?.format?.type === "json_schema", }) if (VERBOSE) log.debug("anthropic request body", { reqId, body }) @@ -218,7 +219,7 @@ async function handleMessages(req: Request, reqId: string): Promise { hasInstructions: !!translated.instructions, requestedMaxTokens: body.max_tokens, translatedMaxOutputTokens: translated.max_output_tokens, - hasContextManagement: body.context_management !== undefined, + hasContextManagement: contextManagement !== undefined, promptCacheKey: translated.prompt_cache_key, }) if (VERBOSE) log.debug("translated request body", { reqId, body: translated }) @@ -239,7 +240,8 @@ async function handleMessages(req: Request, reqId: string): Promise { hasInstructions: !!translated.instructions, requestedMaxTokens: body.max_tokens, translatedMaxOutputTokens: translated.max_output_tokens, - hasContextManagement: body.context_management !== undefined, + hasContextManagement: contextManagement !== undefined, + contextManagement, previousCountReqId: state?.lastCount?.reqId, previousCountModel: state?.lastCount?.model, previousCountTokens: state?.lastCount?.tokens, @@ -295,7 +297,8 @@ async function handleMessages(req: Request, reqId: string): Promise { translatedInputTokens, requestedMaxTokens: body.max_tokens, translatedMaxOutputTokens: translated.max_output_tokens, - hasContextManagement: body.context_management !== undefined, + 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, @@ -337,7 +340,8 @@ async function handleMessages(req: Request, reqId: string): Promise { translatedInputTokens, requestedMaxTokens: body.max_tokens, translatedMaxOutputTokens: translated.max_output_tokens, - hasContextManagement: body.context_management !== undefined, + 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, From bc13d3c1b4d88f0382a4dcd2db28fef7aab0c937 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 20 Apr 2026 20:34:18 +0300 Subject: [PATCH 21/98] remove unsupported max_output_tokens The ChatGPT Codex responses endpoint rejects the max_output_tokens field, so the recent output-budget passthrough caused normal requests to fail with a 400 unsupported-parameter error. That broke ordinary Claude Code turns immediately instead of just changing compaction behavior. Remove the unsupported field from the translated upstream request and drop the matching telemetry field that depended on it. We still log the incoming Anthropic max_tokens value for debugging, but the proxy no longer tries to enforce it upstream until we know the correct supported parameter for this backend. This restores request compatibility with the current upstream endpoint. The compaction investigation remains open, but the output-budget hypothesis needs a backend-compatible implementation before it can be tested safely. --- src/server.ts | 4 ---- src/translate/request.ts | 2 -- 2 files changed, 6 deletions(-) diff --git a/src/server.ts b/src/server.ts index b9e1cd8..90e79a1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -218,7 +218,6 @@ async function handleMessages(req: Request, reqId: string): Promise { tools: translated.tools?.length ?? 0, hasInstructions: !!translated.instructions, requestedMaxTokens: body.max_tokens, - translatedMaxOutputTokens: translated.max_output_tokens, hasContextManagement: contextManagement !== undefined, promptCacheKey: translated.prompt_cache_key, }) @@ -239,7 +238,6 @@ async function handleMessages(req: Request, reqId: string): Promise { translatedToolCount: translated.tools?.length ?? 0, hasInstructions: !!translated.instructions, requestedMaxTokens: body.max_tokens, - translatedMaxOutputTokens: translated.max_output_tokens, hasContextManagement: contextManagement !== undefined, contextManagement, previousCountReqId: state?.lastCount?.reqId, @@ -296,7 +294,6 @@ async function handleMessages(req: Request, reqId: string): Promise { localInputTokens, translatedInputTokens, requestedMaxTokens: body.max_tokens, - translatedMaxOutputTokens: translated.max_output_tokens, hasContextManagement: contextManagement !== undefined, contextManagement, upstreamInputTokens: finish.usage?.input_tokens ?? 0, @@ -339,7 +336,6 @@ async function handleMessages(req: Request, reqId: string): Promise { localInputTokens, translatedInputTokens, requestedMaxTokens: body.max_tokens, - translatedMaxOutputTokens: translated.max_output_tokens, hasContextManagement: contextManagement !== undefined, contextManagement, upstreamInputTokens: result.rawUsage?.input_tokens ?? 0, diff --git a/src/translate/request.ts b/src/translate/request.ts index 8d2b50f..ec5a393 100644 --- a/src/translate/request.ts +++ b/src/translate/request.ts @@ -12,7 +12,6 @@ export interface ResponsesRequest { instructions?: string input: ResponsesInputItem[] tools?: ResponsesTool[] - max_output_tokens?: number tool_choice?: | "auto" | "none" @@ -96,7 +95,6 @@ export function translateRequest(req: AnthropicRequest, opts: TranslateOptions = } if (instructions) out.instructions = instructions if (tools && tools.length) out.tools = tools - if (req.max_tokens) out.max_output_tokens = req.max_tokens if (opts.sessionId) out.prompt_cache_key = opts.sessionId const effort = req.output_config?.effort if (effort) out.reasoning = { effort } From a8d110d641cdd737ac39db7e2b99306d9708cfc9 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 20 Apr 2026 20:46:18 +0300 Subject: [PATCH 22/98] make reasoning include conditional The proxy was always requesting reasoning.encrypted_content from the upstream responses API, even when the translated request did not enable reasoning. That diverged from Codex client behavior and added unnecessary reasoning-specific request shape to ordinary turns. Only attach the include field when output_config.effort produces a translated reasoning config, and add a focused translator test that locks in both paths. This keeps non-reasoning requests cleaner while preserving encrypted reasoning content when reasoning is actually enabled. --- src/translate/request.test.ts | 27 +++++++++++++++++++++++++++ src/translate/request.ts | 6 ++++-- 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 src/translate/request.test.ts diff --git a/src/translate/request.test.ts b/src/translate/request.test.ts new file mode 100644 index 0000000..c1d43f2 --- /dev/null +++ b/src/translate/request.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "bun:test" +import type { AnthropicRequest } from "../anthropic/schema.ts" +import { translateRequest } from "./request.ts" + +describe("translateRequest", () => { + const baseRequest: AnthropicRequest = { + model: "claude-sonnet-4-6", + messages: [{ role: "user", content: "hello" }], + } + + 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"]) + }) +}) diff --git a/src/translate/request.ts b/src/translate/request.ts index ec5a393..3bf554f 100644 --- a/src/translate/request.ts +++ b/src/translate/request.ts @@ -88,7 +88,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, @@ -97,7 +96,10 @@ export function translateRequest(req: AnthropicRequest, opts: TranslateOptions = 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 } + if (effort) { + out.reasoning = { effort } + out.include = ["reasoning.encrypted_content"] + } return out } From 0391e466046831734fdfc5162ecda42d132f8a1f Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 20 Apr 2026 20:59:07 +0300 Subject: [PATCH 23/98] log upstream header telemetry Add verbose compaction telemetry for the upstream OpenAI-Model and X-Reasoning-Included response headers so proxy logs capture the requested model, resolved model, and actual serving model together. This helps investigate compaction drift and usage mismatches by showing when upstream routing differs from the proxy's resolved model and whether the server reports that reasoning is already included in its accounting. --- src/server.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/server.ts b/src/server.ts index 90e79a1..f4618d2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -46,6 +46,16 @@ function usageWindowTokens(usage: { ) } +function upstreamHeaderSnapshot(headers: Headers): { + serverModel?: string + serverReasoningIncluded: boolean +} { + return { + serverModel: headers.get("OpenAI-Model") || undefined, + serverReasoningIncluded: headers.get("X-Reasoning-Included") === "true", + } +} + interface SessionTimelineState { seq: number lastCount?: SessionCountSnapshot @@ -273,6 +283,7 @@ async function handleMessages(req: Request, reqId: string): Promise { } if (wantStream) { + const { serverModel, serverReasoningIncluded } = upstreamHeaderSnapshot(upstream.headers) const stream = translateStream(upstream.body, { messageId, model: body.model, @@ -289,6 +300,8 @@ async function handleMessages(req: Request, reqId: string): Promise { sessionSeq, requestedModel: body.model, resolvedModel, + serverModel, + serverReasoningIncluded, messageCount, toolCount, localInputTokens, @@ -323,6 +336,7 @@ async function handleMessages(req: Request, reqId: string): Promise { try { const result = await accumulateResponse(upstream.body, { messageId, model: body.model }) if (VERBOSE) { + const { serverModel, serverReasoningIncluded } = upstreamHeaderSnapshot(upstream.headers) log.info("compaction telemetry", { reqId, phase: "upstream_finish", @@ -331,6 +345,8 @@ async function handleMessages(req: Request, reqId: string): Promise { sessionSeq, requestedModel: body.model, resolvedModel, + serverModel, + serverReasoningIncluded, messageCount, toolCount, localInputTokens, From ec766ef13002d70652871e2337e5b015d5567e3a Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 20 Apr 2026 21:12:41 +0300 Subject: [PATCH 24/98] tighten responses request schema Document the upstream request type as a constrained Codex ResponsesApiRequest shape and add a regression test that locks the translated top-level fields to that allowlist. This reduces request-shape drift after the max_output_tokens regression and makes future upstream field additions more deliberate by requiring source support or an explicit verified change. --- src/translate/request.test.ts | 55 +++++++++++++++++++++++++++++++---- src/translate/request.ts | 6 +++- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/translate/request.test.ts b/src/translate/request.test.ts index c1d43f2..43af7af 100644 --- a/src/translate/request.test.ts +++ b/src/translate/request.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it } from "bun:test" import type { AnthropicRequest } from "../anthropic/schema.ts" import { translateRequest } from "./request.ts" -describe("translateRequest", () => { - const baseRequest: AnthropicRequest = { - model: "claude-sonnet-4-6", - messages: [{ role: "user", content: "hello" }], - } +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) @@ -24,4 +24,49 @@ describe("translateRequest", () => { expect(translated.reasoning).toEqual({ effort: "medium" }) expect(translated.include).toEqual(["reasoning.encrypted_content"]) }) + + 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/translate/request.ts index 3bf554f..cbd1498 100644 --- a/src/translate/request.ts +++ b/src/translate/request.ts @@ -7,6 +7,8 @@ import type { AnthropicTool, } from "../anthropic/schema.ts" +// 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 +20,12 @@ export interface ResponsesRequest { | "required" | { type: "function"; name: string } parallel_tool_calls?: boolean + reasoning?: { effort?: "low" | "medium" | "high"; 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 +33,7 @@ export interface ResponsesRequest { | { type: "json_object" } | { type: "json_schema"; name: string; schema: unknown; strict?: boolean } } + client_metadata?: Record } export type ResponsesInputItem = From 73c4c4568c9e7e427029af0e1fa53ef05b399758 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 20 Apr 2026 21:22:11 +0300 Subject: [PATCH 25/98] fix reasoning-included telemetry semantics Treat X-Reasoning-Included as a presence flag in verbose telemetry so the proxy matches Codex's header handling instead of requiring the literal string value true. This keeps compaction investigation logs accurate when upstream includes the header with any non-empty value and avoids false negatives in the server-side reasoning signal. --- src/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server.ts b/src/server.ts index f4618d2..c5f7c83 100644 --- a/src/server.ts +++ b/src/server.ts @@ -52,7 +52,7 @@ function upstreamHeaderSnapshot(headers: Headers): { } { return { serverModel: headers.get("OpenAI-Model") || undefined, - serverReasoningIncluded: headers.get("X-Reasoning-Included") === "true", + serverReasoningIncluded: headers.has("X-Reasoning-Included"), } } From c849c736f91cefd310d2c1bb9fa89611eb16f538 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 20 Apr 2026 21:27:32 +0300 Subject: [PATCH 26/98] readme: document manual compaction workaround Users running Claude Code through the proxy can see premature auto-compaction when Claude Code assumes a smaller local context window than the upstream Codex model actually supports. That behavior is confusing because the backend may still have room while the client decides to compact early. Document the practical workaround of setting DISABLE_AUTO_COMPACT=1 so users can keep full history longer and compact manually with /compact when needed. The note also calls out the tradeoff: disabling proactive compaction can lead to prompt-too-long failures if the session is allowed to grow too far. This is a documentation-only change with no runtime behavior impact. --- README.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/README.md b/README.md index 216a6e3..4ff0388 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,45 @@ Or set it persistently in `~/.claude/settings.json`: } ``` +### 5. Optional: disable Claude Code auto-compact + +Claude Code decides auto-compaction locally based on the model context window it +thinks it has. If your upstream Codex model supports a larger window than +Claude Code assumes, Claude Code may compact earlier than necessary. + +As a workaround, you can disable only automatic compaction and keep manual +`/compact` available: + +```sh +DISABLE_AUTO_COMPACT=1 \ +ANTHROPIC_BASE_URL=http://localhost:18765 \ +ANTHROPIC_AUTH_TOKEN=unused \ +ANTHROPIC_MODEL=gpt-5.4 \ +CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ + claude +``` + +Or add it to `~/.claude/settings.json`: + +```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, + "DISABLE_AUTO_COMPACT": 1 + } +} +``` + +Tradeoffs: + +- Claude Code will stop proactively compacting before a turn. +- Manual `/compact` still works. +- If you let the session grow too far, you may hit prompt-too-long failures + instead of a graceful auto-compact. + ## Supported models Set `ANTHROPIC_MODEL` to a model your ChatGPT subscription is allowed to use. From 70cf463186320a95585e5971720f03704d73f1a6 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 20 Apr 2026 22:11:49 +0300 Subject: [PATCH 27/98] rename to claude-code-proxy and restructure for multi-provider support Rename the project from claude-codex-proxy to claude-code-proxy to reflect that the Codex ChatGPT backend will be one of several supported providers (Kimi is next, per history/2026-04-20-kimi-proxy-research-notes.md). Restructure the codebase around a Provider abstraction so each backend owns its own translation, upstream client, auth, and CLI surface: - Move src/codex/, src/auth/, src/translate/ into src/providers/codex/. - Promote src/translate/sse.ts to src/sse.ts (generic SSE plumbing is shared; translation is not). - Introduce src/providers/types.ts (Provider interface) and src/providers/registry.ts (provider lookup via CCP_PROVIDER env or --provider flag, default codex). - Move handleMessages + handleCountTokens bodies and their telemetry (including session timeline) into src/providers/codex/index.ts. The session timeline tracks codex-specific usage fields, so it belongs in the provider. - server.ts becomes provider-agnostic: it parses requests, tracks a session sequence number, and dispatches to the selected provider. - CLI commands are provider-namespaced: `codex auth login|device|status |logout`. Storage path moves to `~/.config/claude-code-proxy/codex/` and the macOS keychain service to `claude-code-proxy.codex` so each provider keeps its own credentials. Breaking changes: - Previously-stored credentials at the old path are ignored; users must run `claude-code-proxy codex auth login` again. - The log directory moves to `$XDG_STATE_HOME/claude-code-proxy/`. - Homebrew tap and install script URLs change; the GitHub repo itself needs to be renamed (and the homebrew-claude-codex-proxy tap repo) outside this commit. --- .github/workflows/release.yml | 36 +- README.md | 79 ++-- package.json | 4 +- scripts/install.sh | 24 +- src/cli.ts | 129 +++--- src/log.ts | 2 +- src/{ => providers/codex}/auth/constants.ts | 2 +- src/{ => providers/codex}/auth/device.ts | 0 src/{ => providers/codex}/auth/jwt.ts | 0 src/{ => providers/codex}/auth/manager.ts | 2 +- src/{ => providers/codex}/auth/pkce.ts | 0 src/{ => providers/codex}/auth/token-store.ts | 4 +- src/{ => providers}/codex/client.ts | 8 +- src/{ => providers/codex}/count-tokens.ts | 2 +- src/providers/codex/index.ts | 385 ++++++++++++++++++ .../codex}/translate/accumulate.ts | 0 .../codex}/translate/model-allowlist.ts | 0 .../codex}/translate/reducer.ts | 4 +- .../codex}/translate/request.test.ts | 2 +- .../codex}/translate/request.ts | 2 +- src/{ => providers/codex}/translate/stream.ts | 4 +- src/providers/registry.ts | 21 + src/providers/types.ts | 22 + src/server.ts | 376 ++--------------- src/{translate => }/sse.ts | 0 25 files changed, 628 insertions(+), 480 deletions(-) rename src/{ => providers/codex}/auth/constants.ts (88%) rename src/{ => providers/codex}/auth/device.ts (100%) rename src/{ => providers/codex}/auth/jwt.ts (100%) rename src/{ => providers/codex}/auth/manager.ts (98%) rename src/{ => providers/codex}/auth/pkce.ts (100%) rename src/{ => providers/codex}/auth/token-store.ts (95%) rename src/{ => providers}/codex/client.ts (92%) rename src/{ => providers/codex}/count-tokens.ts (97%) create mode 100644 src/providers/codex/index.ts rename src/{ => providers/codex}/translate/accumulate.ts (100%) rename src/{ => providers/codex}/translate/model-allowlist.ts (100%) rename src/{ => providers/codex}/translate/reducer.ts (98%) rename src/{ => providers/codex}/translate/request.test.ts (96%) rename src/{ => providers/codex}/translate/request.ts (99%) rename src/{ => providers/codex}/translate/stream.ts (98%) create mode 100644 src/providers/registry.ts create mode 100644 src/providers/types.ts rename src/{translate => }/sse.ts (100%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd503b5..adb780f 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: @@ -80,7 +80,7 @@ jobs: merge-multiple: true - 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/README.md b/README.md index 4ff0388..62ea70f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# claude-codex-proxy +# claude-code-proxy -`claude-codex-proxy` lets you use +`claude-code-proxy` lets you use [Claude Code](https://www.anthropic.com/claude-code) with your ChatGPT Plus or Pro subscription. -Claude Code running through claude-codex-proxy +Claude Code running through claude-code-proxy [Quick start](#quick-start) · [How it works](#how-it-works) · [Configuration](#configuration) · [Limitations](#limitations) @@ -27,30 +27,30 @@ ended up forking it and applying some patches, but would much rather not do it. **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. Authenticate with ChatGPT Open a browser (PKCE flow): ```sh -claude-codex-proxy auth login +claude-code-proxy codex auth login ``` Or, on a headless machine (device code flow): ```sh -claude-codex-proxy auth device +claude-code-proxy codex auth device ``` Either command prints a URL. Sign in with your **ChatGPT Plus/Pro account**. On @@ -60,17 +60,17 @@ locally for reuse by the proxy. Verify it stuck: ```sh -claude-codex-proxy auth status +claude-code-proxy codex auth status ``` ### 3. Start the proxy ```sh -claude-codex-proxy serve +claude-code-proxy serve ``` Defaults to `http://127.0.0.1:18765` (loopback only). Override with -`PORT=11435 claude-codex-proxy serve`. +`PORT=11435 claude-code-proxy serve`. ### 4. Point Claude Code at it @@ -178,7 +178,7 @@ and the proxy will send `gpt-5.4-mini` upstream. sequenceDiagram autonumber participant CC as Claude Code - participant P as claude-codex-proxy + participant P as claude-code-proxy participant AUTH as auth.openai.com participant CGX as chatgpt.com/
backend-api/codex @@ -192,7 +192,7 @@ sequenceDiagram 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=claude-codex-proxy + P->>CGX: POST /responses
Bearer + ChatGPT-Account-Id + originator=claude-code-proxy 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-->>CC: Anthropic SSE
(message_start, content_block_*, message_delta, message_stop) @@ -200,35 +200,37 @@ sequenceDiagram ## 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` (default 18765) | +| [`codex auth login`](#auth-login) | Browser OAuth (PKCE) | +| [`codex auth device`](#auth-device) | Device-code OAuth (headless) | +| [`codex auth status`](#auth-status) | Show account ID and token expiry | +| [`codex auth logout`](#auth-logout) | Delete stored auth credentials | --- ### `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. +startup. Refuses to start traffic until `codex auth login` (or `codex auth +device`) has stored a token. + +Selects a provider with `CCP_PROVIDER` (default `codex`). --- -### `auth login` +### `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 @@ -237,7 +239,7 @@ 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 @@ -246,27 +248,27 @@ token file includes the extracted `chatgpt_account_id` so the proxy can set the --- -### `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: @@ -283,16 +285,16 @@ 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. --- @@ -313,13 +315,14 @@ Settings are environment variables on the proxy process, not a config file. | Variable | Default | Purpose | | ----------------- | ---------------- | -------------------------------------------------- | | `PORT` | `18765` | Proxy listen port | +| `CCP_PROVIDER` | `codex` | Upstream provider (`codex`) | | `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 | ### 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. @@ -348,7 +351,7 @@ Settings are environment variables on the proxy process, not a config file. ```sh bunx tsc --noEmit # typecheck bun src/cli.ts serve # run locally -tail -f ~/.local/state/claude-codex-proxy/proxy.log | jq . +tail -f ~/.local/state/claude-code-proxy/proxy.log | jq . ``` **Install a compiled dev build globally:** compile the current working tree to a @@ -356,7 +359,7 @@ 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 diff --git a/package.json b/package.json index 5506814..fe5f8fd 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { - "name": "claude-codex-proxy", + "name": "claude-code-proxy", "version": "0.0.2", "license": "MIT", "type": "module", "bin": { - "claude-codex-proxy": "./src/cli.ts" + "claude-code-proxy": "./src/cli.ts" }, "scripts": { "start": "bun run src/cli.ts serve", diff --git a/scripts/install.sh b/scripts/install.sh index c9e731f..2aede83 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" @@ -243,7 +243,7 @@ verify_installation() { echo "" echo "Get started:" - echo " ${BIN_NAME} auth login # authenticate with your ChatGPT account" + echo " ${BIN_NAME} codex auth login # authenticate with your ChatGPT account" echo " ${BIN_NAME} serve # start the proxy" echo "" echo "Documentation: https://github.com/${REPO}" diff --git a/src/cli.ts b/src/cli.ts index 1e0775f..6b62788 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,10 +1,8 @@ #!/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 { 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,74 +10,89 @@ 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"`) + + if (!first || first === "serve") { + const providerName = argFlag(rest, "--provider") ?? process.env.CCP_PROVIDER ?? "codex" + const provider = getProvider(providerName) + const port = Number(process.env.PORT ?? 18765) + startServer({ port, provider }) + console.log(`Proxy listening on http://localhost:${port} (provider: ${provider.name})`) + 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"`) + if (provider.name === "codex") { console.log(` export ANTHROPIC_MODEL="gpt-5.4"`) - console.log(` export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1"`) - return } - 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(` 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 argFlag(args: string[], flag: string): string | undefined { + const i = args.indexOf(flag) + if (i === -1) return undefined + return args[i + 1] +} + function usageAndExit(): never { + const providers = listProviders().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 [--provider ${providers}] Run proxy (PORT env, default 18765) + 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} +Default provider: ${process.env.CCP_PROVIDER ?? "codex"} (override with CCP_PROVIDER or --provider) `) process.exit(2) } diff --git a/src/log.ts b/src/log.ts index 2510bfd..8da9a8e 100644 --- a/src/log.ts +++ b/src/log.ts @@ -21,7 +21,7 @@ const REDACT_KEYS = new Set([ function stateDir(): string { const base = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state") - return join(base, "claude-codex-proxy") + return join(base, "claude-code-proxy") } export function logDir(): string { diff --git a/src/auth/constants.ts b/src/providers/codex/auth/constants.ts similarity index 88% rename from src/auth/constants.ts rename to src/providers/codex/auth/constants.ts index ed8557f..eadfda2 100644 --- a/src/auth/constants.ts +++ b/src/providers/codex/auth/constants.ts @@ -3,5 +3,5 @@ export const ISSUER = "https://auth.openai.com" export const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" export const OAUTH_PORT = 1455 export const OAUTH_REDIRECT_URI = `http://localhost:${OAUTH_PORT}/auth/callback` -export const ORIGINATOR = "claude-codex-proxy" +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 98% rename from src/auth/manager.ts rename to src/providers/codex/auth/manager.ts index 2ed98df..f157a67 100644 --- a/src/auth/manager.ts +++ b/src/providers/codex/auth/manager.ts @@ -8,7 +8,7 @@ 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()) { diff --git a/src/auth/pkce.ts b/src/providers/codex/auth/pkce.ts similarity index 100% rename from src/auth/pkce.ts rename to src/providers/codex/auth/pkce.ts diff --git a/src/auth/token-store.ts b/src/providers/codex/auth/token-store.ts similarity index 95% rename from src/auth/token-store.ts rename to src/providers/codex/auth/token-store.ts index b3b6ed7..ae85d48 100644 --- a/src/auth/token-store.ts +++ b/src/providers/codex/auth/token-store.ts @@ -9,9 +9,9 @@ export interface StoredAuth { accountId?: string } -const DIR = join(homedir(), ".config", "claude-codex-proxy") +const DIR = join(homedir(), ".config", "claude-code-proxy", "codex") const FILE = join(DIR, "auth.json") -const KEYCHAIN_SERVICE = "claude-codex-proxy" +const KEYCHAIN_SERVICE = "claude-code-proxy.codex" const KEYCHAIN_ACCOUNT = "auth" export async function loadAuth(): Promise { diff --git a/src/codex/client.ts b/src/providers/codex/client.ts similarity index 92% rename from src/codex/client.ts rename to src/providers/codex/client.ts index 9046a67..b02d6b7 100644 --- a/src/codex/client.ts +++ b/src/providers/codex/client.ts @@ -1,7 +1,7 @@ -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" +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") diff --git a/src/count-tokens.ts b/src/providers/codex/count-tokens.ts similarity index 97% rename from src/count-tokens.ts rename to src/providers/codex/count-tokens.ts index db0c5d0..6920069 100644 --- a/src/count-tokens.ts +++ b/src/providers/codex/count-tokens.ts @@ -1,5 +1,5 @@ import { encode } from "gpt-tokenizer/model/gpt-4o" -import type { AnthropicRequest } from "./anthropic/schema.ts" +import type { AnthropicRequest } from "../../anthropic/schema.ts" import type { ResponsesRequest } from "./translate/request.ts" import { buildInstructions, normalizeContent, toolResultToString } from "./translate/request.ts" diff --git a/src/providers/codex/index.ts b/src/providers/codex/index.ts new file mode 100644 index 0000000..3a16720 --- /dev/null +++ b/src/providers/codex/index.ts @@ -0,0 +1,385 @@ +import type { AnthropicRequest } from "../../anthropic/schema.ts" +import type { Provider, RequestContext, CliHandlers } from "../types.ts" +import { createLogger } from "../../log.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" + +const log = createLogger("provider.codex") +const VERBOSE = !!process.env.CCP_LOG_VERBOSE + +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 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", { reqId: ctx.reqId, tokens }) + if (state) { + state.lastCount = { + reqId: ctx.reqId, + model: body.model, + messageCount, + toolCount, + tokens, + } + } + if (VERBOSE) { + log.info("compaction telemetry", { + reqId: ctx.reqId, + phase: "count_tokens", + sessionId: ctx.sessionId, + sessionSeq: ctx.sessionSeq, + 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 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", { + reqId: ctx.reqId, + model: body.model, + messageCount, + toolCount, + stream: wantStream, + sessionId: ctx.sessionId, + requestedMaxTokens: body.max_tokens, + hasContextManagement: contextManagement !== undefined, + hasJsonSchemaFormat: body.output_config?.format?.type === "json_schema", + }) + if (VERBOSE) log.debug("anthropic request body", { reqId: ctx.reqId, 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 = VERBOSE ? countTokens(body) : undefined + const translatedInputTokens = VERBOSE ? countTranslatedTokens(translated) : undefined + if (state) { + state.lastMessage = { + reqId: ctx.reqId, + model: body.model, + messageCount, + toolCount, + localInputTokens, + translatedInputTokens, + } + } + log.debug("translated request", { + reqId: ctx.reqId, + requestedModel: body.model, + resolvedModel, + inputItems: translated.input.length, + tools: translated.tools?.length ?? 0, + hasInstructions: !!translated.instructions, + requestedMaxTokens: body.max_tokens, + hasContextManagement: contextManagement !== undefined, + promptCacheKey: translated.prompt_cache_key, + }) + if (VERBOSE) log.debug("translated request body", { reqId: ctx.reqId, body: translated }) + if (VERBOSE) { + log.info("compaction telemetry", { + reqId: ctx.reqId, + phase: "translated_request", + sessionId: ctx.sessionId, + sessionSeq: ctx.sessionSeq, + 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, { sessionId: ctx.sessionId, signal: ctx.signal }) + } catch (err) { + if (err instanceof CodexError) { + log.warn("codex error", { reqId: ctx.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 + } + + if (wantStream) { + const { serverModel, serverReasoningIncluded } = upstreamHeaderSnapshot(upstream.headers) + const stream = translateStream(upstream.body, { + messageId, + model: body.model, + reqId: ctx.reqId, + sessionId: ctx.sessionId, + onFinish: VERBOSE + ? (finish) => { + const mappedUsage = finish.usage ? mapUsageToAnthropic(finish.usage) : undefined + log.info("compaction telemetry", { + reqId: ctx.reqId, + phase: "upstream_finish", + mode: "stream", + sessionId: ctx.sessionId, + sessionSeq: ctx.sessionSeq, + 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 }) + if (VERBOSE) { + const { serverModel, serverReasoningIncluded } = upstreamHeaderSnapshot(upstream.headers) + log.info("compaction telemetry", { + reqId: ctx.reqId, + phase: "upstream_finish", + mode: "non_stream", + sessionId: ctx.sessionId, + sessionSeq: ctx.sessionSeq, + 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)", { + reqId: ctx.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(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", + handleMessages, + handleCountTokens, + cli, +} diff --git a/src/translate/accumulate.ts b/src/providers/codex/translate/accumulate.ts similarity index 100% rename from src/translate/accumulate.ts rename to src/providers/codex/translate/accumulate.ts diff --git a/src/translate/model-allowlist.ts b/src/providers/codex/translate/model-allowlist.ts similarity index 100% rename from src/translate/model-allowlist.ts rename to src/providers/codex/translate/model-allowlist.ts diff --git a/src/translate/reducer.ts b/src/providers/codex/translate/reducer.ts similarity index 98% rename from src/translate/reducer.ts rename to src/providers/codex/translate/reducer.ts index 071b47c..34ce0dd 100644 --- a/src/translate/reducer.ts +++ b/src/providers/codex/translate/reducer.ts @@ -1,5 +1,5 @@ -import { parseSseStream } from "./sse.ts" -import { createLogger } from "../log.ts" +import { parseSseStream } from "../../../sse.ts" +import { createLogger } from "../../../log.ts" const log = createLogger("translate.reducer") const VERBOSE = !!process.env.CCP_LOG_VERBOSE diff --git a/src/translate/request.test.ts b/src/providers/codex/translate/request.test.ts similarity index 96% rename from src/translate/request.test.ts rename to src/providers/codex/translate/request.test.ts index 43af7af..7362234 100644 --- a/src/translate/request.test.ts +++ b/src/providers/codex/translate/request.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import type { AnthropicRequest } from "../anthropic/schema.ts" +import type { AnthropicRequest } from "../../../anthropic/schema.ts" import { translateRequest } from "./request.ts" const baseRequest: AnthropicRequest = { diff --git a/src/translate/request.ts b/src/providers/codex/translate/request.ts similarity index 99% rename from src/translate/request.ts rename to src/providers/codex/translate/request.ts index cbd1498..ac0f115 100644 --- a/src/translate/request.ts +++ b/src/providers/codex/translate/request.ts @@ -5,7 +5,7 @@ import type { AnthropicRequest, AnthropicTextBlock, AnthropicTool, -} from "../anthropic/schema.ts" +} from "../../../anthropic/schema.ts" // 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. diff --git a/src/translate/stream.ts b/src/providers/codex/translate/stream.ts similarity index 98% rename from src/translate/stream.ts rename to src/providers/codex/translate/stream.ts index fe78c4f..a9dd438 100644 --- a/src/translate/stream.ts +++ b/src/providers/codex/translate/stream.ts @@ -1,5 +1,5 @@ -import { encodeSseEvent } from "./sse.ts" -import { createLogger } from "../log.ts" +import { encodeSseEvent } from "../../../sse.ts" +import { createLogger } from "../../../log.ts" import { mapUsageToAnthropic, reduceUpstream, UpstreamStreamError } from "./reducer.ts" const log = createLogger("translate.stream") diff --git a/src/providers/registry.ts b/src/providers/registry.ts new file mode 100644 index 0000000..bbcb39b --- /dev/null +++ b/src/providers/registry.ts @@ -0,0 +1,21 @@ +import type { Provider } from "./types.ts" +import { codexProvider } from "./codex/index.ts" + +const PROVIDERS: Record = { + codex: codexProvider, +} + +export function getProvider(name?: string): Provider { + const key = name ?? process.env.CCP_PROVIDER ?? "codex" + const p = PROVIDERS[key] + if (!p) { + throw new Error( + `Unknown provider: ${key}. Available: ${Object.keys(PROVIDERS).join(", ")}`, + ) + } + return p +} + +export function listProviders(): string[] { + return Object.keys(PROVIDERS) +} diff --git a/src/providers/types.ts b/src/providers/types.ts new file mode 100644 index 0000000..390fb05 --- /dev/null +++ b/src/providers/types.ts @@ -0,0 +1,22 @@ +import type { AnthropicRequest } from "../anthropic/schema.ts" + +export interface RequestContext { + reqId: string + sessionId?: string + sessionSeq?: number + signal: AbortSignal +} + +export interface CliHandlers { + login?: () => Promise + device?: () => Promise + status: () => Promise + logout: () => Promise +} + +export interface Provider { + name: string + 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 c5f7c83..66fb0fd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,84 +1,25 @@ import { createLogger, logDir } from "./log.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 { CodexError, postCodex } from "./codex/client.ts" -import { countTokens, countTranslatedTokens } from "./count-tokens.ts" +import type { Provider, RequestContext } from "./providers/types.ts" const log = createLogger("server") -const VERBOSE = !!process.env.CCP_LOG_VERBOSE export interface ServeOptions { port: number + provider: Provider } -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 -} - -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 - ) -} +const sessionSeqs = new Map() -function upstreamHeaderSnapshot(headers: Headers): { - serverModel?: string - serverReasoningIncluded: boolean -} { - return { - serverModel: headers.get("OpenAI-Model") || undefined, - serverReasoningIncluded: headers.has("X-Reasoning-Included"), - } -} - -interface SessionTimelineState { - seq: number - lastCount?: SessionCountSnapshot - lastMessage?: SessionMessageSnapshot -} - -const sessionTimeline = new Map() - -function nextSessionTimeline(sessionId?: string): { - state?: SessionTimelineState - sessionSeq?: number -} { - if (!sessionId) return {} - let state = sessionTimeline.get(sessionId) - if (!state) { - state = { seq: 0 } - sessionTimeline.set(sessionId, state) - } - state.seq += 1 - return { state, sessionSeq: state.seq } +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 { provider } = opts const server = Bun.serve({ hostname: "127.0.0.1", port: opts.port, @@ -87,9 +28,15 @@ 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 }) + log.info("request", { + reqId, + method: req.method, + path: url.pathname, + query: url.search, + provider: provider.name, + }) try { - const resp = await route(req, url, reqId) + const resp = await route(req, url, reqId, provider) log.info("response", { reqId, status: resp.status, ms: Date.now() - start }) return resp } catch (err) { @@ -98,297 +45,54 @@ export function startServer(opts: ServeOptions): { stop: () => void; port: numbe } }, }) - log.info("server listening", { port: server.port, logDir: logDir() }) + log.info("server listening", { port: server.port, provider: provider.name, logDir: logDir() }) return { port: Number(server.port), stop: () => server.stop(), } } -async function route(req: Request, url: URL, reqId: string): Promise { +async function route(req: Request, url: URL, reqId: string, provider: Provider): Promise { if (url.pathname === "/healthz") { - return new Response(JSON.stringify({ ok: true }), { + return new Response(JSON.stringify({ ok: true, provider: provider.name }), { headers: { "content-type": "application/json" }, }) } if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") { - const body = (await req.json()) as AnthropicRequest - const resolvedModel = resolveModel(body.model) - const translated = translateRequest({ ...body, model: resolvedModel }) - const tokens = countTranslatedTokens(translated) + const body = await parseJsonBody(req) + if (body instanceof Response) return body const sessionId = req.headers.get("x-claude-code-session-id") || undefined - const { state, sessionSeq } = nextSessionTimeline(sessionId) - const messageCount = body.messages?.length ?? 0 - const toolCount = body.tools?.length ?? 0 - log.debug("count_tokens", { reqId, tokens }) - if (state) { - state.lastCount = { - reqId, - model: body.model, - messageCount, - toolCount, - tokens, - } - } - if (VERBOSE) { - log.info("compaction telemetry", { - reqId, - phase: "count_tokens", - path: url.pathname, - sessionId, - sessionSeq, - 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, - }) + const ctx: RequestContext = { + reqId, + sessionId, + sessionSeq: nextSessionSeq(sessionId), + signal: req.signal, } - return new Response(JSON.stringify({ input_tokens: tokens }), { - headers: { "content-type": "application/json" }, - }) + return provider.handleCountTokens(body, ctx) } if (req.method === "POST" && url.pathname === "/v1/messages") { - return handleMessages(req, reqId) - } - - 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}`) - } - - const sessionId = req.headers.get("x-claude-code-session-id") || undefined - const { state, sessionSeq } = nextSessionTimeline(sessionId) - 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 - - log.debug("anthropic request", { - reqId, - model: body.model, - messageCount, - toolCount, - stream: wantStream, - sessionId, - requestedMaxTokens: body.max_tokens, - hasContextManagement: contextManagement !== undefined, - hasJsonSchemaFormat: body.output_config?.format?.type === "json_schema", - }) - if (VERBOSE) log.debug("anthropic request body", { reqId, 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 }) - const localInputTokens = VERBOSE ? countTokens(body) : undefined - const translatedInputTokens = VERBOSE ? countTranslatedTokens(translated) : undefined - if (state) { - state.lastMessage = { - reqId, - model: body.model, - messageCount, - toolCount, - localInputTokens, - translatedInputTokens, - } - } - log.debug("translated request", { - reqId, - requestedModel: body.model, - resolvedModel, - inputItems: translated.input.length, - tools: translated.tools?.length ?? 0, - hasInstructions: !!translated.instructions, - requestedMaxTokens: body.max_tokens, - hasContextManagement: contextManagement !== undefined, - promptCacheKey: translated.prompt_cache_key, - }) - if (VERBOSE) log.debug("translated request body", { reqId, body: translated }) - if (VERBOSE) { - log.info("compaction telemetry", { + const body = await parseJsonBody(req) + if (body instanceof Response) return body + const sessionId = req.headers.get("x-claude-code-session-id") || undefined + const ctx: RequestContext = { reqId, - phase: "translated_request", sessionId, - sessionSeq, - 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, { 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) + sessionSeq: nextSessionSeq(sessionId), + signal: req.signal, } - throw err + return provider.handleMessages(body, ctx) } - if (wantStream) { - const { serverModel, serverReasoningIncluded } = upstreamHeaderSnapshot(upstream.headers) - const stream = translateStream(upstream.body, { - messageId, - model: body.model, - reqId, - sessionId, - onFinish: VERBOSE - ? (finish) => { - const mappedUsage = finish.usage ? mapUsageToAnthropic(finish.usage) : undefined - log.info("compaction telemetry", { - reqId, - phase: "upstream_finish", - mode: "stream", - sessionId, - sessionSeq, - 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", - }, - }) - } + return jsonError(404, "not_found", `No route for ${req.method} ${url.pathname}`) +} +async function parseJsonBody(req: Request): Promise { try { - const result = await accumulateResponse(upstream.body, { messageId, model: body.model }) - if (VERBOSE) { - const { serverModel, serverReasoningIncluded } = upstreamHeaderSnapshot(upstream.headers) - log.info("compaction telemetry", { - reqId, - phase: "upstream_finish", - mode: "non_stream", - sessionId, - sessionSeq, - 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" }, - }) + 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(502, "api_error", err.message) - } - throw err + return jsonError(400, "invalid_request_error", `Invalid JSON: ${err}`) } } 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 From 7e682639899f6533112e61a2dd281c0813e30b44 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 20 Apr 2026 22:20:06 +0300 Subject: [PATCH 28/98] kimi: scaffold provider with device-code login and token refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new "kimi" provider under src/providers/kimi/ that currently implements only the auth surface. Chat handlers return 501 until the translate layer lands. Auth details (reverse-engineered from kimi-cli 1.37.0): - Device-code grant against https://auth.kimi.com (no PKCE, no scope). - Persistent device_id stored under ~/.config/claude-code-proxy/kimi/; bound into the issued JWT, must be stable across sessions. - Masquerades as kimi-cli via User-Agent and X-Msh-* fingerprint headers — the server appears to use these on both auth and chat endpoints. - Tokens stored in macOS Keychain (or file elsewhere), mirroring the codex provider's token-store shape. - Refresh manager: single-flight in-memory cache, refresh 5 min before expiry (default TTL is 15 min), handles rotating refresh tokens, retries 429/5xx with exponential backoff, clears local state on 401/403. CLI: claude-code-proxy kimi auth login claude-code-proxy kimi auth status claude-code-proxy kimi auth logout --- src/providers/kimi/auth/constants.ts | 7 ++ src/providers/kimi/auth/device-id.ts | 22 ++++ src/providers/kimi/auth/headers.ts | 30 ++++++ src/providers/kimi/auth/jwt.ts | 23 +++++ src/providers/kimi/auth/login.ts | 75 ++++++++++++++ src/providers/kimi/auth/manager.ts | 136 +++++++++++++++++++++++++ src/providers/kimi/auth/token-store.ts | 109 ++++++++++++++++++++ src/providers/kimi/index.ts | 51 ++++++++++ src/providers/registry.ts | 2 + 9 files changed, 455 insertions(+) create mode 100644 src/providers/kimi/auth/constants.ts create mode 100644 src/providers/kimi/auth/device-id.ts create mode 100644 src/providers/kimi/auth/headers.ts create mode 100644 src/providers/kimi/auth/jwt.ts create mode 100644 src/providers/kimi/auth/login.ts create mode 100644 src/providers/kimi/auth/manager.ts create mode 100644 src/providers/kimi/auth/token-store.ts create mode 100644 src/providers/kimi/index.ts diff --git a/src/providers/kimi/auth/constants.ts b/src/providers/kimi/auth/constants.ts new file mode 100644 index 0000000..32d5d3d --- /dev/null +++ b/src/providers/kimi/auth/constants.ts @@ -0,0 +1,7 @@ +export const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098" +export const OAUTH_HOST = process.env.KIMI_OAUTH_HOST ?? "https://auth.kimi.com" +export const API_BASE_URL = process.env.KIMI_BASE_URL ?? "https://api.kimi.com/coding/v1" +// 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..f42ac7d --- /dev/null +++ b/src/providers/kimi/auth/device-id.ts @@ -0,0 +1,22 @@ +import { mkdir, readFile, writeFile, chmod } from "node:fs/promises" +import { dirname, join } from "node:path" +import { homedir } from "node:os" + +const PATH = join(homedir(), ".config", "claude-code-proxy", "kimi", "device_id") + +export async function getDeviceId(): Promise { + try { + const raw = await readFile(PATH, "utf8") + const trimmed = raw.trim() + if (trimmed) return trimmed + } catch (err: any) { + if (err?.code !== "ENOENT") throw err + } + const id = crypto.randomUUID().replace(/-/g, "") + await mkdir(dirname(PATH), { recursive: true }) + await writeFile(PATH, id, { encoding: "utf8", mode: 0o600 }) + try { + await chmod(PATH, 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..bebdf60 --- /dev/null +++ b/src/providers/kimi/auth/headers.ts @@ -0,0 +1,30 @@ +import { hostname, release, arch, platform } from "node:os" +import { KIMI_CLI_VERSION } from "./constants.ts" +import { getDeviceId } from "./device-id.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": `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..48a8901 --- /dev/null +++ b/src/providers/kimi/auth/login.ts @@ -0,0 +1,75 @@ +import { CLIENT_ID, OAUTH_HOST } 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(`${OAUTH_HOST}/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(`${OAUTH_HOST}/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..bd148b3 --- /dev/null +++ b/src/providers/kimi/auth/manager.ts @@ -0,0 +1,136 @@ +import { CLIENT_ID, OAUTH_HOST, 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 + +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 { + 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(`${OAUTH_HOST}/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()) as TokenResponse + 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)) { + const text = await resp.text().catch(() => "") + throw new Error(`Token refresh failed: ${resp.status} ${text}`) + } + + 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: ${lastErr}`) +} + +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..cf7878e --- /dev/null +++ b/src/providers/kimi/auth/token-store.ts @@ -0,0 +1,109 @@ +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 + scope?: string + userId?: string +} + +const DIR = join(homedir(), ".config", "claude-code-proxy", "kimi") +const FILE = join(DIR, "auth.json") +const KEYCHAIN_SERVICE = "claude-code-proxy.kimi" +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 {} + 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/providers/kimi/index.ts b/src/providers/kimi/index.ts new file mode 100644 index 0000000..1621ea0 --- /dev/null +++ b/src/providers/kimi/index.ts @@ -0,0 +1,51 @@ +import type { Provider, CliHandlers, RequestContext } from "../types.ts" +import type { AnthropicRequest } from "../../anthropic/schema.ts" +import { runDeviceLogin } from "./auth/login.ts" +import { persistInitialTokens } from "./auth/manager.ts" +import { loadAuth, clearAuth, authPath } from "./auth/token-store.ts" + +function notImplemented(_body: AnthropicRequest, _ctx: RequestContext): Promise { + return Promise.resolve( + new Response( + JSON.stringify({ + type: "error", + error: { type: "api_error", message: "kimi provider: chat not implemented yet" }, + }), + { status: 501, headers: { "content-type": "application/json" } }, + ), + ) +} + +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", + handleMessages: notImplemented, + handleCountTokens: notImplemented, + cli, +} diff --git a/src/providers/registry.ts b/src/providers/registry.ts index bbcb39b..f2860da 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1,8 +1,10 @@ import type { Provider } from "./types.ts" import { codexProvider } from "./codex/index.ts" +import { kimiProvider } from "./kimi/index.ts" const PROVIDERS: Record = { codex: codexProvider, + kimi: kimiProvider, } export function getProvider(name?: string): Provider { From 6a31954a04727fc4b46ee6f6080dde35e15c12d6 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 20 Apr 2026 22:36:06 +0300 Subject: [PATCH 29/98] kimi: implement chat translation and wire up handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills out the translate layer so the kimi provider can serve real /v1/messages traffic from Claude Code. Confirmed working end-to-end: device login, token refresh on 401, streaming with thinking blocks forwarded, tool calls, and per-turn reasoning_effort honoring Claude Code's /effort setting. Translate layer (Anthropic Messages <-> Kimi OpenAI-style): - request.ts: system blocks -> role="system"; splits user messages on tool_result boundaries into separate role="tool" entries; assistant text + tool_use blocks collapse to a single assistant message; images pass through as image_url parts in both user and tool content (Kimi accepts this — verified against kimi-cli's ReadMediaFile capture). Sends reasoning_effort (from output_config.effort, default "high") and thinking:{type:"enabled"} unless the client disables thinking. - reducer.ts: OpenAI chat.completion.chunk stream -> typed ReducerEvents. Opens a thinking block on first reasoning_content delta, closes it when content/tool_calls arrive, streams tool-call arguments as input_json_delta fragments keyed by upstream tool_calls[].index. - stream.ts: emits Anthropic SSE events (message_start, thinking_delta, text_delta, input_json_delta, message_delta with mapped usage). - accumulate.ts: non-streaming variant producing a full Anthropic Message. - model-allowlist.ts: every alias (haiku/sonnet/opus/claude-*) collapses to kimi-for-coding — the only id the /coding/v1/models endpoint exposes (display_name: Kimi-k2.6). Client + wiring: - client.ts: POSTs to /coding/v1/chat/completions with Bearer + X-Msh-* fingerprint headers, triggers forceRefresh on 401 and retries once, surfaces 429 with retry-after. - count-tokens.ts: gpt-tokenizer approximation over the translated request; good enough for Claude Code's compaction heuristic. - index.ts: full handleMessages / handleCountTokens, logs reasoning_effort / thinking / max_tokens for debugging. - cli.ts: prints ANTHROPIC_MODEL="kimi-for-coding" hint when the provider is kimi. --- src/cli.ts | 2 + src/providers/kimi/client.ts | 104 +++++++ src/providers/kimi/count-tokens.ts | 79 ++++++ src/providers/kimi/index.ts | 163 ++++++++++- src/providers/kimi/translate/accumulate.ts | 123 ++++++++ .../kimi/translate/model-allowlist.ts | 32 +++ src/providers/kimi/translate/reducer.ts | 216 ++++++++++++++ src/providers/kimi/translate/request.ts | 267 ++++++++++++++++++ src/providers/kimi/translate/stream.ts | 173 ++++++++++++ 9 files changed, 1148 insertions(+), 11 deletions(-) create mode 100644 src/providers/kimi/client.ts create mode 100644 src/providers/kimi/count-tokens.ts create mode 100644 src/providers/kimi/translate/accumulate.ts create mode 100644 src/providers/kimi/translate/model-allowlist.ts create mode 100644 src/providers/kimi/translate/reducer.ts create mode 100644 src/providers/kimi/translate/request.ts create mode 100644 src/providers/kimi/translate/stream.ts diff --git a/src/cli.ts b/src/cli.ts index 6b62788..5b44bb5 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -31,6 +31,8 @@ async function main() { console.log(` export ANTHROPIC_AUTH_TOKEN="anything"`) if (provider.name === "codex") { console.log(` export ANTHROPIC_MODEL="gpt-5.4"`) + } else if (provider.name === "kimi") { + console.log(` export ANTHROPIC_MODEL="kimi-for-coding"`) } console.log(` export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1"`) return diff --git a/src/providers/kimi/client.ts b/src/providers/kimi/client.ts new file mode 100644 index 0000000..29dea5b --- /dev/null +++ b/src/providers/kimi/client.ts @@ -0,0 +1,104 @@ +import { API_BASE_URL } from "./auth/constants.ts" +import { commonHeaders } from "./auth/headers.ts" +import { forceRefresh, getAuth, KimiAuthUnauthorizedError } from "./auth/manager.ts" +import { createLogger } from "../../log.ts" +import type { KimiChatRequest } from "./translate/request.ts" + +const log = createLogger("kimi.client") + +export interface KimiPostOptions { + sessionId?: string + signal?: AbortSignal +} + +export interface KimiResponse { + body: ReadableStream + status: number + headers: Headers +} + +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, + opts: KimiPostOptions = {}, +): Promise { + let auth = await getAuth() + let resp = await doFetch(auth.access, body, opts) + + if (resp.status === 401) { + log.warn("got 401, refreshing token", {}) + try { + auth = await forceRefresh() + resp = await doFetch(auth.access, body, opts) + } 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") + + return { body: resp.body, status: resp.status, headers: resp.headers } +} + +async function doFetch( + accessToken: string, + body: KimiChatRequest, + opts: KimiPostOptions, +): Promise { + const fp = await commonHeaders() + const headers = new Headers({ + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + ...fp, + }) + + log.debug("posting to kimi", { + url: `${API_BASE_URL}/chat/completions`, + model: body.model, + messageCount: body.messages.length, + toolCount: body.tools?.length ?? 0, + sessionId: opts.sessionId, + }) + + return fetch(`${API_BASE_URL}/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: opts.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..8819f91 --- /dev/null +++ b/src/providers/kimi/count-tokens.ts @@ -0,0 +1,79 @@ +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 + } + } + } + + 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 + 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 index 1621ea0..b02830f 100644 --- a/src/providers/kimi/index.ts +++ b/src/providers/kimi/index.ts @@ -1,19 +1,160 @@ import type { Provider, CliHandlers, RequestContext } from "../types.ts" import type { AnthropicRequest } from "../../anthropic/schema.ts" +import { createLogger } from "../../log.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 { 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" -function notImplemented(_body: AnthropicRequest, _ctx: RequestContext): Promise { - return Promise.resolve( - new Response( - JSON.stringify({ - type: "error", - error: { type: "api_error", message: "kimi provider: chat not implemented yet" }, - }), - { status: 501, headers: { "content-type": "application/json" } }, - ), +const log = createLogger("provider.kimi") +const VERBOSE = !!process.env.CCP_LOG_VERBOSE + +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 resolvedModel = resolveModel(body.model) + const translated = translateRequest({ ...body, model: resolvedModel }) + const tokens = countTranslatedTokens(translated) + log.debug("count_tokens", { reqId: ctx.reqId, tokens }) + return new Response(JSON.stringify({ input_tokens: tokens }), { + headers: { "content-type": "application/json" }, + }) +} + +async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Promise { + 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", { + reqId: ctx.reqId, + model: body.model, + messageCount, + toolCount, + stream: wantStream, + sessionId: ctx.sessionId, + requestedMaxTokens: body.max_tokens, + }) + if (VERBOSE) log.debug("anthropic request body", { reqId: ctx.reqId, 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 = VERBOSE ? countTokens(body) : undefined + const translatedInputTokens = VERBOSE ? countTranslatedTokens(translated) : undefined + log.debug("translated request", { + reqId: ctx.reqId, + 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 (VERBOSE) log.debug("translated request body", { reqId: ctx.reqId, body: translated }) + + let upstream + try { + upstream = await postKimi(translated, { sessionId: ctx.sessionId, signal: ctx.signal }) + } catch (err) { + if (err instanceof KimiError) { + log.warn("kimi error", { reqId: ctx.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 + } + + if (wantStream) { + const stream = translateStream(upstream.body, { + messageId, + model: body.model, + reqId: ctx.reqId, + sessionId: ctx.sessionId, + }) + 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 }) + 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)", { + reqId: ctx.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(502, "api_error", err.message) + } + throw err + } } const cli: CliHandlers = { @@ -45,7 +186,7 @@ const cli: CliHandlers = { export const kimiProvider: Provider = { name: "kimi", - handleMessages: notImplemented, - handleCountTokens: notImplemented, + 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..083cb47 --- /dev/null +++ b/src/providers/kimi/translate/accumulate.ts @@ -0,0 +1,123 @@ +import { mapUsageToAnthropic, reduceUpstream, type KimiUsage } from "./reducer.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 } + | { 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 +} + +export async function accumulateResponse( + upstream: ReadableStream, + opts: { messageId: string; model: string }, +): 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 + + for await (const e of reduceUpstream(upstream)) { + 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 + 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 + break + } + case "tool-start": + 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 }) + } 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 }) + } + } + + 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..23a7bf7 --- /dev/null +++ b/src/providers/kimi/translate/reducer.ts @@ -0,0 +1,216 @@ +import { parseSseStream } from "../../../sse.ts" +import { createLogger } from "../../../log.ts" + +const log = createLogger("kimi.reducer") +const VERBOSE = !!process.env.CCP_LOG_VERBOSE + +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 async function* reduceUpstream( + upstream: ReadableStream, +): 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 { + continue + } + + if (VERBOSE) { + const d = chunk.choices?.[0]?.delta + log.debug("upstream chunk", { + hasReasoning: typeof d?.reasoning_content === "string", + hasContent: typeof d?.content === "string" && d.content.length > 0, + toolCalls: d?.tool_calls?.length, + finish: chunk.choices?.[0]?.finish_reason, + }) + } + + 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..8ca9178 --- /dev/null +++ b/src/providers/kimi/translate/request.ts @@ -0,0 +1,267 @@ +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 KimiMessage = + | { role: "system"; content: string } + | { role: "user"; content: string | KimiUserContentPart[] } + | { + role: "assistant" + content?: string | null + tool_calls?: KimiAssistantToolCall[] + } + | { 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 + +export function translateRequest( + req: AnthropicRequest, + opts: TranslateOptions = {}, +): KimiChatRequest { + const messages = buildMessages(req) + const tools = req.tools?.map(toKimiTool) + + const thinkingDisabled = req.thinking?.type === "disabled" + const effort = req.output_config?.effort ?? "high" + + const out: KimiChatRequest = { + model: req.model, + messages, + stream: true, + stream_options: { include_usage: true }, + max_tokens: clampMaxTokens(req.max_tokens), + } + if (!thinkingDisabled) { + out.reasoning_effort = effort + out.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) +} + +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 toolCalls: KimiAssistantToolCall[] = [] + for (const block of blocks) { + if (block.type === "text") { + if (block.text) textParts.push(block.text) + } else if (block.type === "tool_use") { + toolCalls.push({ + id: block.id, + type: "function", + function: { + name: block.name, + arguments: JSON.stringify(block.input ?? {}), + }, + }) + } + // thinking / image blocks from the assistant are dropped — Kimi's + // assistant schema does not express them. + } + + if (!textParts.length && !toolCalls.length) return + + const msg: Extract = { role: "assistant" } + if (textParts.length) msg.content = textParts.join("") + else msg.content = "" + 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/stream.ts b/src/providers/kimi/translate/stream.ts new file mode 100644 index 0000000..232380d --- /dev/null +++ b/src/providers/kimi/translate/stream.ts @@ -0,0 +1,173 @@ +import { encodeSseEvent } from "../../../sse.ts" +import { createLogger } from "../../../log.ts" +import { mapUsageToAnthropic, reduceUpstream, UpstreamStreamError } from "./reducer.ts" + +const log = createLogger("kimi.stream") + +/** + * 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 + reqId: string + sessionId?: string + 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" }) + } + + try { + for await (const e of reduceUpstream(upstream)) { + 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": + 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_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": + 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": + 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() + 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 (err instanceof UpstreamStreamError) { + log.warn("upstream stream error", { + reqId: opts.reqId, + sessionId: opts.sessionId, + 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 { + log.error("stream translation error", { + reqId: opts.reqId, + sessionId: opts.sessionId, + err: String(err), + activeToolNames, + activeToolCalls, + }) + ensureMessageStart() + emit("error", { + type: "error", + error: { type: "api_error", message: String(err) }, + }) + } + } finally { + controller.close() + } + }, + }) +} From a1b8c7137d3a7c3e6cd7a06018c38dd1dc3ba679 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 20 Apr 2026 22:50:39 +0300 Subject: [PATCH 30/98] route requests to providers by model instead of a single-provider default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each provider now declares a supportedModels set listing the concrete wire ids it claims (codex: gpt-5.2 / gpt-5.3-codex / gpt-5.4 / gpt-5.4-mini; kimi: kimi-for-coding / kimi-k2.6 / k2.6). On each /v1/messages and /v1/messages/count_tokens request the server looks up body.model in the registry and dispatches to the matching provider. Unknown model ids return HTTP 400 with the full list of supported ids per provider — there is no implicit default, so CCP_PROVIDER is gone. This lets one `serve` process handle both providers at once; users switch upstream purely by changing ANTHROPIC_MODEL. The removed cross-provider aliases (haiku / sonnet / opus / claude-*) were ambiguous and silently collapsed to whichever provider was the current default; they're dropped to force an explicit choice. Startup banner now enumerates the model → provider mapping and prints a hint for ANTHROPIC_SMALL_FAST_MODEL too, since Claude Code sends its hardcoded haiku id for background / title-gen calls and those would also 400 without pointing at a concrete model. --- src/cli.ts | 52 +++++++++++++----------- src/providers/codex/index.ts | 1 + src/providers/kimi/index.ts | 1 + src/providers/registry.ts | 30 ++++++++++++-- src/providers/types.ts | 6 +++ src/server.ts | 78 ++++++++++++++++++++++++++---------- 6 files changed, 119 insertions(+), 49 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 5b44bb5..f873bf1 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,12 @@ #!/usr/bin/env bun import { startServer } from "./server.ts" import { createLogger, logDir } from "./log.ts" -import { getProvider, listProviders } from "./providers/registry.ts" +import { + allProviders, + allSupportedModels, + getProvider, + listProviders, +} from "./providers/registry.ts" import type { CliHandlers } from "./providers/types.ts" declare const BUILD_VERSION: string | undefined @@ -19,21 +24,22 @@ async function main() { } if (!first || first === "serve") { - const providerName = argFlag(rest, "--provider") ?? process.env.CCP_PROVIDER ?? "codex" - const provider = getProvider(providerName) const port = Number(process.env.PORT ?? 18765) - startServer({ port, provider }) - console.log(`Proxy listening on http://localhost:${port} (provider: ${provider.name})`) + 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("Providers are selected per-request by ANTHROPIC_MODEL:") + for (const p of allProviders()) { + const models = [...p.supportedModels].join(", ") + console.log(` ${p.name}: ${models}`) + } + 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"`) - if (provider.name === "codex") { - console.log(` export ANTHROPIC_MODEL="gpt-5.4"`) - } else if (provider.name === "kimi") { - console.log(` export ANTHROPIC_MODEL="kimi-for-coding"`) - } + console.log(` export ANTHROPIC_MODEL="kimi-for-coding" # or gpt-5.4, etc.`) + console.log(` export ANTHROPIC_SMALL_FAST_MODEL="kimi-for-coding" # background / title-gen`) console.log(` export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1"`) return } @@ -77,24 +83,22 @@ async function runProviderCommand(name: string, cli: CliHandlers, args: string[] } } -function argFlag(args: string[], flag: string): string | undefined { - const i = args.indexOf(flag) - if (i === -1) return undefined - return args[i + 1] -} - function usageAndExit(): never { const providers = listProviders().join("|") + const models = allSupportedModels() + .map((m) => `${m.model} (${m.provider})`) + .join(", ") console.log(`Usage: - claude-code-proxy serve [--provider ${providers}] Run proxy (PORT env, default 18765) - 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 + claude-code-proxy serve Run proxy (PORT env, 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} -Default provider: ${process.env.CCP_PROVIDER ?? "codex"} (override with CCP_PROVIDER or --provider) +Models: ${models} `) process.exit(2) } diff --git a/src/providers/codex/index.ts b/src/providers/codex/index.ts index 3a16720..1f43ed1 100644 --- a/src/providers/codex/index.ts +++ b/src/providers/codex/index.ts @@ -379,6 +379,7 @@ const cli: CliHandlers = { export const codexProvider: Provider = { name: "codex", + supportedModels: new Set(["gpt-5.2", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini"]), handleMessages, handleCountTokens, cli, diff --git a/src/providers/kimi/index.ts b/src/providers/kimi/index.ts index b02830f..d334f51 100644 --- a/src/providers/kimi/index.ts +++ b/src/providers/kimi/index.ts @@ -186,6 +186,7 @@ const cli: CliHandlers = { export const kimiProvider: Provider = { name: "kimi", + supportedModels: new Set(["kimi-for-coding", "kimi-k2.6", "k2.6"]), handleMessages, handleCountTokens, cli, diff --git a/src/providers/registry.ts b/src/providers/registry.ts index f2860da..25de82b 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -7,12 +7,11 @@ const PROVIDERS: Record = { kimi: kimiProvider, } -export function getProvider(name?: string): Provider { - const key = name ?? process.env.CCP_PROVIDER ?? "codex" - const p = PROVIDERS[key] +export function getProvider(name: string): Provider { + const p = PROVIDERS[name] if (!p) { throw new Error( - `Unknown provider: ${key}. Available: ${Object.keys(PROVIDERS).join(", ")}`, + `Unknown provider: ${name}. Available: ${Object.keys(PROVIDERS).join(", ")}`, ) } return p @@ -21,3 +20,26 @@ export function getProvider(name?: string): Provider { 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/types.ts b/src/providers/types.ts index 390fb05..bc9733e 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -16,6 +16,12 @@ export interface CliHandlers { 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 66fb0fd..6f2da8c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,12 +1,12 @@ import { createLogger, logDir } from "./log.ts" import type { AnthropicRequest } from "./anthropic/schema.ts" import type { Provider, RequestContext } from "./providers/types.ts" +import { allSupportedModels, providerForModel } from "./providers/registry.ts" const log = createLogger("server") export interface ServeOptions { port: number - provider: Provider } const sessionSeqs = new Map() @@ -19,7 +19,6 @@ function nextSessionSeq(sessionId?: string): number | undefined { } export function startServer(opts: ServeOptions): { stop: () => void; port: number } { - const { provider } = opts const server = Bun.serve({ hostname: "127.0.0.1", port: opts.port, @@ -33,10 +32,9 @@ export function startServer(opts: ServeOptions): { stop: () => void; port: numbe method: req.method, path: url.pathname, query: url.search, - provider: provider.name, }) try { - const resp = await route(req, url, reqId, provider) + const resp = await route(req, url, reqId) log.info("response", { reqId, status: resp.status, ms: Date.now() - start }) return resp } catch (err) { @@ -45,16 +43,16 @@ export function startServer(opts: ServeOptions): { stop: () => void; port: numbe } }, }) - log.info("server listening", { port: server.port, provider: provider.name, logDir: logDir() }) + log.info("server listening", { port: server.port, logDir: logDir() }) return { port: Number(server.port), stop: () => server.stop(), } } -async function route(req: Request, url: URL, reqId: string, provider: Provider): Promise { +async function route(req: Request, url: URL, reqId: string): Promise { if (url.pathname === "/healthz") { - return new Response(JSON.stringify({ ok: true, provider: provider.name }), { + return new Response(JSON.stringify({ ok: true }), { headers: { "content-type": "application/json" }, }) } @@ -62,32 +60,70 @@ async function route(req: Request, url: URL, reqId: string, provider: Provider): if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") { const body = await parseJsonBody(req) if (body instanceof Response) return body - const sessionId = req.headers.get("x-claude-code-session-id") || undefined - const ctx: RequestContext = { - reqId, - sessionId, - sessionSeq: nextSessionSeq(sessionId), - signal: req.signal, - } + const provider = routeProvider(body, reqId) + if (provider instanceof Response) return provider + const ctx = buildCtx(req, reqId) + log.info("dispatch", { reqId, provider: provider.name, model: body.model }) return provider.handleCountTokens(body, ctx) } if (req.method === "POST" && url.pathname === "/v1/messages") { const body = await parseJsonBody(req) if (body instanceof Response) return body - const sessionId = req.headers.get("x-claude-code-session-id") || undefined - const ctx: RequestContext = { - reqId, - sessionId, - sessionSeq: nextSessionSeq(sessionId), - signal: req.signal, - } + const provider = routeProvider(body, reqId) + if (provider instanceof Response) return provider + const ctx = buildCtx(req, reqId) + log.info("dispatch", { reqId, provider: provider.name, model: body.model }) return provider.handleMessages(body, ctx) } return jsonError(404, "not_found", `No route for ${req.method} ${url.pathname}`) } +function buildCtx(req: Request, reqId: string): RequestContext { + const sessionId = req.headers.get("x-claude-code-session-id") || undefined + return { + reqId, + sessionId, + sessionSeq: nextSessionSeq(sessionId), + signal: req.signal, + } +} + +function routeProvider(body: AnthropicRequest, reqId: string): Provider | Response { + if (!body.model) { + return jsonError( + 400, + "invalid_request_error", + `Missing "model" in request body. ${knownModelsMessage()}`, + ) + } + const provider = providerForModel(body.model) + if (!provider) { + log.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 { return (await req.json()) as AnthropicRequest From cbc5f35a220ce68bf9e379d010fd024a2c320b24 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 20 Apr 2026 22:51:00 +0300 Subject: [PATCH 31/98] readme: reorganize for multi-provider and document small fast model Rewrites the README so Codex and Kimi are both first-class providers instead of Codex-only with Kimi bolted on. Structural changes: - Intro mentions both ChatGPT Plus/Pro and kimi.com subscriptions. - New top-level Providers section with per-provider subsections: upstream endpoint, supported wire model ids, auth-command table, and provider-specific behavior (Kimi's reasoning-effort forwarding and thinking-block pass-through, Codex's alias list). - Step 2 of quick start is now "pick a provider and authenticate", showing codex and kimi login flows side by side. - Steps 3 and 4 updated for model-based routing: one `serve` handles both providers and ANTHROPIC_MODEL picks the upstream. - Commands section adds "Kimi auth commands" alongside the expanded "Codex auth commands" block (login / device / status / logout with example output and PKCE + refresh behavior notes kept intact). - How-it-works mermaid diagram generalized to either OAuth host and upstream. - Configuration table drops CCP_PROVIDER, adds KIMI_OAUTH_HOST / KIMI_BASE_URL; Files section lists per-provider credential paths plus the persistent kimi device_id file. - Limitations split per provider: image-in-tool-result (Codex placeholder vs Kimi pass-through) and reasoning blocks (Codex dropped vs Kimi forwarded as thinking content). Also documents ANTHROPIC_SMALL_FAST_MODEL. Claude Code hardcodes a haiku model id for background / title-gen calls, which would 400 under the new model-based routing. The README now shows it in every example alongside ANTHROPIC_MODEL. Related projects section adds consult-llm-mcp. --- README.md | 289 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 196 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index 62ea70f..90031df 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # claude-code-proxy `claude-code-proxy` lets you use -[Claude Code](https://www.anthropic.com/claude-code) with your ChatGPT Plus or -Pro subscription. +[Claude Code](https://www.anthropic.com/claude-code) with your **ChatGPT +Plus/Pro** subscription or your **Kimi Code** (kimi.com) account. 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? @@ -39,47 +40,80 @@ curl -fsSL https://raw.githubusercontent.com/raine/claude-code-proxy/main/script **Manual:** download a prebuilt binary for your platform from the [releases page](https://github.com/raine/claude-code-proxy/releases). -### 2. Authenticate with ChatGPT +### 2. Pick a provider and authenticate -Open a browser (PKCE flow): +The proxy supports two upstream providers. Pick one and run its login flow; the +proxy will refuse to start traffic until a token is stored. + +**Codex (ChatGPT Plus/Pro):** ```sh -claude-code-proxy codex auth login +claude-code-proxy codex auth login # browser OAuth (PKCE) +# or, on a headless machine: +claude-code-proxy codex auth device # device-code flow ``` -Or, on a headless machine (device code flow): +Sign in with your **ChatGPT Plus/Pro account**, not an OpenAI API account. + +**Kimi (kimi.com Kimi Code):** ```sh -claude-code-proxy codex auth device +claude-code-proxy kimi auth login # device-code flow (prints URL + code) ``` -Either command prints a URL. Sign in with your **ChatGPT Plus/Pro account**. On -macOS, credentials are stored in Keychain. On other platforms, they are stored -locally for reuse by the proxy. +Sign in with your **kimi.com account**. The verification URL is displayed; open +it in any browser, confirm the code, and the CLI polls until done. -Verify it stuck: +On macOS credentials go to Keychain; on other platforms they are written to +`~/.config/claude-code-proxy//auth.json` (mode 0600). + +Verify: ```sh claude-code-proxy codex auth status +claude-code-proxy kimi auth status ``` ### 3. Start the proxy ```sh -claude-code-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-code-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** +- `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 \ +ANTHROPIC_SMALL_FAST_MODEL=gpt-5.4-mini \ +CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ + claude + +# Kimi +ANTHROPIC_BASE_URL=http://localhost:18765 \ +ANTHROPIC_AUTH_TOKEN=unused \ +ANTHROPIC_MODEL=kimi-for-coding \ +ANTHROPIC_SMALL_FAST_MODEL=kimi-for-coding \ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ claude ``` @@ -92,6 +126,7 @@ Or set it persistently in `~/.claude/settings.json`: "ANTHROPIC_BASE_URL": "http://127.0.0.1:18765", "ANTHROPIC_AUTH_TOKEN": "unused", "ANTHROPIC_MODEL": "gpt-5.4", + "ANTHROPIC_SMALL_FAST_MODEL": "gpt-5.4-mini", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1 } } @@ -100,17 +135,17 @@ Or set it persistently in `~/.claude/settings.json`: ### 5. Optional: disable Claude Code auto-compact Claude Code decides auto-compaction locally based on the model context window it -thinks it has. If your upstream Codex model supports a larger window than -Claude Code assumes, Claude Code may compact earlier than necessary. +thinks it has. If the upstream model supports a larger window than Claude Code +assumes, it may compact earlier than necessary. -As a workaround, you can disable only automatic compaction and keep manual -`/compact` available: +Disable only automatic compaction while keeping manual `/compact` available: ```sh DISABLE_AUTO_COMPACT=1 \ ANTHROPIC_BASE_URL=http://localhost:18765 \ ANTHROPIC_AUTH_TOKEN=unused \ ANTHROPIC_MODEL=gpt-5.4 \ +ANTHROPIC_SMALL_FAST_MODEL=gpt-5.4-mini \ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ claude ``` @@ -123,6 +158,7 @@ Or add it to `~/.claude/settings.json`: "ANTHROPIC_BASE_URL": "http://127.0.0.1:18765", "ANTHROPIC_AUTH_TOKEN": "unused", "ANTHROPIC_MODEL": "gpt-5.4", + "ANTHROPIC_SMALL_FAST_MODEL": "gpt-5.4-mini", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1, "DISABLE_AUTO_COMPACT": 1 } @@ -136,7 +172,11 @@ Tradeoffs: - If you let the session grow too far, you may hit prompt-too-long failures instead of a graceful auto-compact. -## Supported models +## 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**: @@ -144,33 +184,48 @@ 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` -The proxy also accepts a small set of convenience aliases and resolves them -before calling the upstream Codex backend: - -- `haiku`, `claude-haiku-4-5`, `claude-haiku-4-5-20251001` → `gpt-5.4-mini` -- `sonnet`, `claude-sonnet-4-6` → `gpt-5.4` -- `opus`, `claude-opus-4-7` → `gpt-5.4` - -These aliases are only shorthand for portability and cheaper subagent configs; -they are not semantic equivalents of the Claude models they resemble. - 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. -For example, you can now point Claude Code or a subagent at the proxy with: +Auth: -```sh -ANTHROPIC_MODEL=haiku claude -``` +| 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 | -and the proxy will send `gpt-5.4-mini` upstream. +### Kimi (Kimi Code) + +Upstream: `https://api.kimi.com/coding/v1/chat/completions` (OpenAI-style +chat-completions). + +Only one wire model is exposed: `kimi-for-coding` (its display name in kimi-cli +is **Kimi-k2.6**, 256k context, supports reasoning + image input + video input). +`kimi-k2.6` and `k2.6` are accepted as aliases for the same wire id. + +Reasoning effort: Claude Code's `output_config.effort` value (the one you see in +the UI as `◐ medium · /effort`) is forwarded as Kimi's `reasoning_effort` (`low` +/ `medium` / `high`). Thinking blocks from the upstream model are forwarded to +Claude Code and rendered as thinking content. If Claude Code disables thinking, +the proxy drops both `reasoning_effort` and the `thinking: {type: "enabled"}` +flag before forwarding. + +Auth: + +| Command | What it does | +| ------------------ | ------------------------------------- | +| `kimi auth login` | Device-code OAuth via `auth.kimi.com` | +| `kimi auth status` | Show user ID + token expiry | +| `kimi auth logout` | Delete stored credentials | ## How it works @@ -179,34 +234,32 @@ sequenceDiagram autonumber participant CC as Claude Code participant P as claude-code-proxy - participant AUTH as auth.openai.com - participant CGX as chatgpt.com/
backend-api/codex + 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=claude-code-proxy - 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) ``` ## Commands -| Command | Description | -| ----------------------------------- | ----------------------------------------- | -| [`serve`](#serve) | Start the proxy on `PORT` (default 18765) | -| [`codex auth login`](#auth-login) | Browser OAuth (PKCE) | -| [`codex auth device`](#auth-device) | Device-code OAuth (headless) | -| [`codex auth status`](#auth-status) | Show account ID and token expiry | -| [`codex auth logout`](#auth-logout) | Delete stored auth credentials | +| Command | Description | +| --------------------------------------------------- | ------------------------- | +| [`serve`](#serve) | Start the proxy on `PORT` | +| `codex auth login` / `device` / `status` / `logout` | Codex OAuth management | +| `kimi auth login` / `status` / `logout` | Kimi OAuth management | --- @@ -222,15 +275,16 @@ 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 `codex auth login` (or `codex auth -device`) has stored a token. - -Selects a provider with `CCP_PROVIDER` (default `codex`). +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. --- -### `codex 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 @@ -246,9 +300,7 @@ 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. ---- - -### `codex 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 @@ -260,9 +312,7 @@ claude-code-proxy codex auth device Useful over SSH, inside a container, or on any host that can't open a browser. ---- - -### `codex 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. @@ -283,9 +333,7 @@ The proxy refreshes the access token 5 minutes before expiry with a single-flight guard, so concurrent requests never trigger stampedes of refresh calls. ---- - -### `codex 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. @@ -298,6 +346,47 @@ 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. + +--- + ### Endpoints The proxy speaks enough of the Anthropic API for Claude Code: @@ -312,45 +401,57 @@ The proxy speaks enough of the Anthropic API for Claude Code: Settings are environment variables on the proxy process, not a config file. -| Variable | Default | Purpose | -| ----------------- | ---------------- | -------------------------------------------------- | -| `PORT` | `18765` | Proxy listen port | -| `CCP_PROVIDER` | `codex` | Upstream provider (`codex`) | -| `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 | 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 | +| `KIMI_OAUTH_HOST` | `https://auth.kimi.com` | Override Kimi's OAuth host (debugging only) | +| `KIMI_BASE_URL` | `https://api.kimi.com/coding/v1` | Override Kimi's API base URL | ### Files -- `$XDG_STATE_HOME/claude-code-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/codex/auth.json` — codex tokens (non-macOS; macOS + uses Keychain under service `claude-code-proxy.codex`). +- `~/.config/claude-code-proxy/kimi/auth.json` — kimi tokens (non-macOS; macOS + uses Keychain under service `claude-code-proxy.kimi`). +- `~/.config/claude-code-proxy/kimi/device_id` — persistent UUID bound into the + Kimi JWT at login. Reused for the lifetime of the install. ## Limitations -- **Terms of service:** using the Codex backend from a non-official client is - the same gray area OpenCode occupies, although OpenAI seems to be cool with - OpenCode. 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. This costs a + forwarded upstream like any other structured-output request. This 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. +- **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 +bunx tsc --noEmit # typecheck +bun src/cli.ts serve # run locally (routes all providers) tail -f ~/.local/state/claude-code-proxy/proxy.log | jq . ``` @@ -370,3 +471,5 @@ bun build ./src/cli.ts --compile --outfile ~/.local/bin/claude-code-proxy hunk-level git staging for AI agents - [workmux](https://github.com/raine/workmux): manage parallel AI coding tasks in separate git worktrees with tmux +- [consult-llm-mcp](https://github.com/raine/consult-llm-mcp): MCP server for + consulting external LLMs (Gemini, Codex, etc.) from inside Claude Code From e12c2caf9cbb353c33a1cfab59b3c416d2c469c9 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 20 Apr 2026 22:53:14 +0300 Subject: [PATCH 32/98] add kimi.com authentication option to install script --- scripts/install.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 2aede83..01663d4 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -243,8 +243,9 @@ verify_installation() { echo "" echo "Get started:" - echo " ${BIN_NAME} codex 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 "" From f21e01995a30cb24f33bc1815fbf2bf5956c9559 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 20 Apr 2026 22:57:15 +0300 Subject: [PATCH 33/98] 0.0.3 --- CHANGELOG.md | 10 ++++++++++ package.json | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31674cc..dfb7f15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 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-*` diff --git a/package.json b/package.json index fe5f8fd..933eb27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-proxy", - "version": "0.0.2", + "version": "0.0.3", "license": "MIT", "type": "module", "bin": { From e616d6cdfd57631560b58d7a10229f7f11acc8b2 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 20 Apr 2026 23:18:04 +0300 Subject: [PATCH 34/98] kimi: round-trip reasoning content as anthropic thinking blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kimi enforces that when thinking is enabled, every prior assistant message carrying tool_calls must also carry reasoning_content. Our proxy previously sent thinking: enabled unconditionally and discarded the reasoning_content we received, so turn 2 of any tool-using conversation 400'd with "thinking is enabled but reasoning_content is missing in assistant tool call message at index N". End-to-end round-trip: - Promote AnthropicThinkingBlock to a first-class content block type. - Outbound (Kimi -> Claude Code): thinking block already streams as content_block_start/delta/stop; add a signature_delta event just before content_block_stop so Claude Code receives the full native wire shape. - Non-stream path embeds the signature inline on the thinking block. Stream and non-stream share makeThinkingSignature(messageId, index): base64url("ccp:kimi:v1::"). Deterministic per (message, block) so the value is stable for debugging; treated by Claude Code as an opaque passthrough. - Inbound (Claude Code -> Kimi): pushAssistantMessage now extracts thinking blocks from the assistant turn and concatenates them with \n\n into a single reasoning_content string on the same OpenAI-shape assistant message that carries tool_calls. Multiple interleaved thinking blocks can't preserve exact pairwise ordering — Kimi only accepts one reasoning_content per message. Gating and degradation: - translateRequest only sets reasoning_effort + thinking: {enabled} when req.thinking?.type === "enabled" (previously enabled by default unless explicitly disabled). If Claude Code didn't opt in, thinking blocks in history are stripped before forwarding. - Default reasoning_effort is "medium" when the client doesn't specify output_config.effort. - If thinking was requested but any prior assistant tool_call turn lacks reasoning_content in the history we degrade this request to non-thinking rather than sending malformed history. Logged as "thinking degraded" warn. Other: - translateRequest now returns { body, thinking: { requested, effective, degraded } }; handleMessages logs all three for telemetry. - countTokens includes thinking blocks in the anthropic-side estimate, and countTranslatedTokens includes reasoning_content, so Claude Code's compaction math stays consistent when thinking is forwarded. --- src/anthropic/schema.ts | 7 ++ src/providers/kimi/count-tokens.ts | 3 + src/providers/kimi/index.ts | 29 ++++--- src/providers/kimi/translate/accumulate.ts | 10 ++- src/providers/kimi/translate/request.ts | 89 ++++++++++++++++------ src/providers/kimi/translate/signature.ts | 7 ++ src/providers/kimi/translate/stream.ts | 13 ++++ 7 files changed, 124 insertions(+), 34 deletions(-) create mode 100644 src/providers/kimi/translate/signature.ts diff --git a/src/anthropic/schema.ts b/src/anthropic/schema.ts index 9743c13..bda93bf 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" diff --git a/src/providers/kimi/count-tokens.ts b/src/providers/kimi/count-tokens.ts index 8819f91..5698740 100644 --- a/src/providers/kimi/count-tokens.ts +++ b/src/providers/kimi/count-tokens.ts @@ -24,6 +24,8 @@ export function countTokens(req: AnthropicRequest): number { 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 } } } @@ -53,6 +55,7 @@ export function countTranslatedTokens(req: KimiChatRequest): number { } } 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 diff --git a/src/providers/kimi/index.ts b/src/providers/kimi/index.ts index d334f51..51c9497 100644 --- a/src/providers/kimi/index.ts +++ b/src/providers/kimi/index.ts @@ -28,7 +28,7 @@ function jsonError(status: number, type: string, message: string): Response { async function handleCountTokens(body: AnthropicRequest, ctx: RequestContext): Promise { const resolvedModel = resolveModel(body.model) const translated = translateRequest({ ...body, model: resolvedModel }) - const tokens = countTranslatedTokens(translated) + const tokens = countTranslatedTokens(translated.body) log.debug("count_tokens", { reqId: ctx.reqId, tokens }) return new Response(JSON.stringify({ input_tokens: tokens }), { headers: { "content-type": "application/json" }, @@ -71,25 +71,34 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom { sessionId: ctx.sessionId }, ) const localInputTokens = VERBOSE ? countTokens(body) : undefined - const translatedInputTokens = VERBOSE ? countTranslatedTokens(translated) : undefined + const translatedInputTokens = VERBOSE ? countTranslatedTokens(translated.body) : undefined + if (translated.thinking.degraded) { + log.warn("thinking degraded", { + reqId: ctx.reqId, + sessionId: ctx.sessionId, + reason: "prior assistant tool_call turn has no reasoning_content", + }) + } log.debug("translated request", { reqId: ctx.reqId, requestedModel: body.model, resolvedModel, - messageCount: translated.messages.length, - toolCount: translated.tools?.length ?? 0, + messageCount: translated.body.messages.length, + toolCount: translated.body.tools?.length ?? 0, localInputTokens, translatedInputTokens, - promptCacheKey: translated.prompt_cache_key, - reasoningEffort: translated.reasoning_effort, - thinking: translated.thinking?.type, - maxTokens: translated.max_tokens, + promptCacheKey: translated.body.prompt_cache_key, + reasoningEffort: translated.body.reasoning_effort, + thinkingRequested: translated.thinking.requested, + thinkingEffective: translated.thinking.effective, + thinkingDegraded: translated.thinking.degraded, + maxTokens: translated.body.max_tokens, }) - if (VERBOSE) log.debug("translated request body", { reqId: ctx.reqId, body: translated }) + if (VERBOSE) log.debug("translated request body", { reqId: ctx.reqId, body: translated.body }) let upstream try { - upstream = await postKimi(translated, { sessionId: ctx.sessionId, signal: ctx.signal }) + upstream = await postKimi(translated.body, { sessionId: ctx.sessionId, signal: ctx.signal }) } catch (err) { if (err instanceof KimiError) { log.warn("kimi error", { reqId: ctx.reqId, status: err.status, detail: err.detail }) diff --git a/src/providers/kimi/translate/accumulate.ts b/src/providers/kimi/translate/accumulate.ts index 083cb47..b9e8447 100644 --- a/src/providers/kimi/translate/accumulate.ts +++ b/src/providers/kimi/translate/accumulate.ts @@ -1,4 +1,5 @@ import { mapUsageToAnthropic, reduceUpstream, type KimiUsage } from "./reducer.ts" +import { makeThinkingSignature } from "./signature.ts" export { UpstreamStreamError } from "./reducer.ts" @@ -9,7 +10,7 @@ export interface AnthropicNonStreamResponse { model: string content: Array< | { type: "text"; text: string } - | { type: "thinking"; thinking: 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 @@ -88,7 +89,12 @@ export async function accumulateResponse( const b = blocks.get(i) if (!b) continue if (b.kind === "thinking") { - if (b.text) content.push({ type: "thinking", thinking: b.text }) + 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 { diff --git a/src/providers/kimi/translate/request.ts b/src/providers/kimi/translate/request.ts index 8ca9178..008049a 100644 --- a/src/providers/kimi/translate/request.ts +++ b/src/providers/kimi/translate/request.ts @@ -29,14 +29,17 @@ export type KimiToolChoice = | "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[] } - | { - role: "assistant" - content?: string | null - tool_calls?: KimiAssistantToolCall[] - } + | KimiAssistantMessage | { role: "tool"; tool_call_id: string; content: string | KimiToolResultPart[] } export type KimiUserContentPart = @@ -62,17 +65,31 @@ export interface TranslateOptions { sessionId?: string } +export interface TranslateResult { + body: KimiChatRequest + thinking: { + requested: boolean + effective: boolean + degraded: boolean + } +} + const DEFAULT_MAX_TOKENS = 32000 export function translateRequest( req: AnthropicRequest, opts: TranslateOptions = {}, -): KimiChatRequest { - const messages = buildMessages(req) - const tools = req.tools?.map(toKimiTool) +): TranslateResult { + const requested = req.thinking?.type === "enabled" + // Kimi rejects requests where any prior assistant-tool-call turn lacks + // reasoning_content while thinking is enabled. If the history doesn't + // carry reasoning we must degrade this turn to non-thinking rather than + // 400 the user. + const degraded = requested && hasToolCallTurnWithoutThinking(req.messages) + const effective = requested && !degraded - const thinkingDisabled = req.thinking?.type === "disabled" - const effort = req.output_config?.effort ?? "high" + const messages = buildMessages(req, { includeReasoning: effective }) + const tools = req.tools?.map(toKimiTool) const out: KimiChatRequest = { model: req.model, @@ -81,15 +98,30 @@ export function translateRequest( stream_options: { include_usage: true }, max_tokens: clampMaxTokens(req.max_tokens), } - if (!thinkingDisabled) { - out.reasoning_effort = effort + if (effective) { + out.reasoning_effort = req.output_config?.effort ?? "medium" out.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 + return { + body: out, + thinking: { requested, effective, degraded }, + } +} + +function hasToolCallTurnWithoutThinking(messages: AnthropicMessage[]): boolean { + for (const msg of messages) { + if (msg.role !== "assistant") continue + const blocks = normalizeContent(msg.content) + const hasToolCall = blocks.some((b) => b.type === "tool_use") + if (!hasToolCall) continue + const hasThinking = blocks.some((b) => b.type === "thinking" && (b as { thinking?: string }).thinking) + if (!hasThinking) return true + } + return false } function clampMaxTokens(requested: number | undefined): number { @@ -125,7 +157,10 @@ export function buildSystemMessage(system: AnthropicRequest["system"]): string | return texts.join("\n\n") } -function buildMessages(req: AnthropicRequest): KimiMessage[] { +function buildMessages( + req: AnthropicRequest, + opts: { includeReasoning: boolean }, +): KimiMessage[] { const out: KimiMessage[] = [] const system = buildSystemMessage(req.system) if (system) out.push({ role: "system", content: system }) @@ -135,7 +170,7 @@ function buildMessages(req: AnthropicRequest): KimiMessage[] { if (msg.role === "user") { pushUserMessages(out, blocks) } else { - pushAssistantMessage(out, blocks) + pushAssistantMessage(out, blocks, opts.includeReasoning) } } return out @@ -180,12 +215,19 @@ function pushUserMessages(out: KimiMessage[], blocks: AnthropicContentBlock[]): flushBuffer() } -function pushAssistantMessage(out: KimiMessage[], blocks: AnthropicContentBlock[]): void { +function pushAssistantMessage( + out: KimiMessage[], + blocks: AnthropicContentBlock[], + includeReasoning: boolean, +): 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 (includeReasoning && block.thinking) thinkingParts.push(block.thinking) } else if (block.type === "tool_use") { toolCalls.push({ id: block.id, @@ -196,15 +238,18 @@ function pushAssistantMessage(out: KimiMessage[], blocks: AnthropicContentBlock[ }, }) } - // thinking / image blocks from the assistant are dropped — Kimi's - // assistant schema does not express them. + // image blocks from the assistant are dropped — Kimi's assistant + // schema does not express them. } - if (!textParts.length && !toolCalls.length) return + if (!textParts.length && !toolCalls.length && !thinkingParts.length) return - const msg: Extract = { role: "assistant" } - if (textParts.length) msg.content = textParts.join("") - else msg.content = "" + 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) } 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 index 232380d..c0f792d 100644 --- a/src/providers/kimi/translate/stream.ts +++ b/src/providers/kimi/translate/stream.ts @@ -1,6 +1,7 @@ import { encodeSseEvent } from "../../../sse.ts" import { createLogger } from "../../../log.ts" import { mapUsageToAnthropic, reduceUpstream, UpstreamStreamError } from "./reducer.ts" +import { makeThinkingSignature } from "./signature.ts" const log = createLogger("kimi.stream") @@ -74,6 +75,18 @@ export function translateStream( }) 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": From ba2551ea416ff6dbb21d203362988ec1d9642cb4 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 20 Apr 2026 23:21:50 +0300 Subject: [PATCH 35/98] kimi: always enable thinking and always round-trip reasoning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit gated reasoning_effort + thinking: enabled on req.thinking?.type === "enabled", assuming Kimi's reasoning_content contract was request-flag-gated. It isn't — kimi-for-coding is a reasoning model and its server enforces the contract at the model level: every prior assistant tool_call message must carry reasoning_content regardless of whether the current request set thinking: enabled. Evidence from logs: a request with thinkingRequested=false, thinkingEffective=false, thinkingDegraded=false still 400'd with "thinking is enabled but reasoning_content is missing in assistant tool call message at index 3". Remove the gating, degradation, and history-stripping. Always send reasoning_effort + thinking: {enabled} to Kimi, always emit thinking blocks (with signature_delta) back to Claude Code, always translate incoming thinking blocks to reasoning_content on the assistant message. translateRequest returns KimiChatRequest directly again. The round-trip plumbing added in the previous commit is what actually fixes the problem; this commit removes the guard that was preventing it from running. --- src/providers/kimi/index.ts | 29 ++++------- src/providers/kimi/translate/request.ts | 65 ++++++------------------- 2 files changed, 25 insertions(+), 69 deletions(-) diff --git a/src/providers/kimi/index.ts b/src/providers/kimi/index.ts index 51c9497..d334f51 100644 --- a/src/providers/kimi/index.ts +++ b/src/providers/kimi/index.ts @@ -28,7 +28,7 @@ function jsonError(status: number, type: string, message: string): Response { async function handleCountTokens(body: AnthropicRequest, ctx: RequestContext): Promise { const resolvedModel = resolveModel(body.model) const translated = translateRequest({ ...body, model: resolvedModel }) - const tokens = countTranslatedTokens(translated.body) + const tokens = countTranslatedTokens(translated) log.debug("count_tokens", { reqId: ctx.reqId, tokens }) return new Response(JSON.stringify({ input_tokens: tokens }), { headers: { "content-type": "application/json" }, @@ -71,34 +71,25 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom { sessionId: ctx.sessionId }, ) const localInputTokens = VERBOSE ? countTokens(body) : undefined - const translatedInputTokens = VERBOSE ? countTranslatedTokens(translated.body) : undefined - if (translated.thinking.degraded) { - log.warn("thinking degraded", { - reqId: ctx.reqId, - sessionId: ctx.sessionId, - reason: "prior assistant tool_call turn has no reasoning_content", - }) - } + const translatedInputTokens = VERBOSE ? countTranslatedTokens(translated) : undefined log.debug("translated request", { reqId: ctx.reqId, requestedModel: body.model, resolvedModel, - messageCount: translated.body.messages.length, - toolCount: translated.body.tools?.length ?? 0, + messageCount: translated.messages.length, + toolCount: translated.tools?.length ?? 0, localInputTokens, translatedInputTokens, - promptCacheKey: translated.body.prompt_cache_key, - reasoningEffort: translated.body.reasoning_effort, - thinkingRequested: translated.thinking.requested, - thinkingEffective: translated.thinking.effective, - thinkingDegraded: translated.thinking.degraded, - maxTokens: translated.body.max_tokens, + promptCacheKey: translated.prompt_cache_key, + reasoningEffort: translated.reasoning_effort, + thinking: translated.thinking?.type, + maxTokens: translated.max_tokens, }) - if (VERBOSE) log.debug("translated request body", { reqId: ctx.reqId, body: translated.body }) + if (VERBOSE) log.debug("translated request body", { reqId: ctx.reqId, body: translated }) let upstream try { - upstream = await postKimi(translated.body, { sessionId: ctx.sessionId, signal: ctx.signal }) + upstream = await postKimi(translated, { sessionId: ctx.sessionId, signal: ctx.signal }) } catch (err) { if (err instanceof KimiError) { log.warn("kimi error", { reqId: ctx.reqId, status: err.status, detail: err.detail }) diff --git a/src/providers/kimi/translate/request.ts b/src/providers/kimi/translate/request.ts index 008049a..b74d694 100644 --- a/src/providers/kimi/translate/request.ts +++ b/src/providers/kimi/translate/request.ts @@ -65,30 +65,19 @@ export interface TranslateOptions { sessionId?: string } -export interface TranslateResult { - body: KimiChatRequest - thinking: { - requested: boolean - effective: boolean - degraded: boolean - } -} - 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 = {}, -): TranslateResult { - const requested = req.thinking?.type === "enabled" - // Kimi rejects requests where any prior assistant-tool-call turn lacks - // reasoning_content while thinking is enabled. If the history doesn't - // carry reasoning we must degrade this turn to non-thinking rather than - // 400 the user. - const degraded = requested && hasToolCallTurnWithoutThinking(req.messages) - const effective = requested && !degraded - - const messages = buildMessages(req, { includeReasoning: effective }) +): KimiChatRequest { + const messages = buildMessages(req) const tools = req.tools?.map(toKimiTool) const out: KimiChatRequest = { @@ -97,31 +86,14 @@ export function translateRequest( stream: true, stream_options: { include_usage: true }, max_tokens: clampMaxTokens(req.max_tokens), - } - if (effective) { - out.reasoning_effort = req.output_config?.effort ?? "medium" - out.thinking = { type: "enabled" } + reasoning_effort: req.output_config?.effort ?? "medium", + 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 { - body: out, - thinking: { requested, effective, degraded }, - } -} - -function hasToolCallTurnWithoutThinking(messages: AnthropicMessage[]): boolean { - for (const msg of messages) { - if (msg.role !== "assistant") continue - const blocks = normalizeContent(msg.content) - const hasToolCall = blocks.some((b) => b.type === "tool_use") - if (!hasToolCall) continue - const hasThinking = blocks.some((b) => b.type === "thinking" && (b as { thinking?: string }).thinking) - if (!hasThinking) return true - } - return false + return out } function clampMaxTokens(requested: number | undefined): number { @@ -157,10 +129,7 @@ export function buildSystemMessage(system: AnthropicRequest["system"]): string | return texts.join("\n\n") } -function buildMessages( - req: AnthropicRequest, - opts: { includeReasoning: boolean }, -): KimiMessage[] { +function buildMessages(req: AnthropicRequest): KimiMessage[] { const out: KimiMessage[] = [] const system = buildSystemMessage(req.system) if (system) out.push({ role: "system", content: system }) @@ -170,7 +139,7 @@ function buildMessages( if (msg.role === "user") { pushUserMessages(out, blocks) } else { - pushAssistantMessage(out, blocks, opts.includeReasoning) + pushAssistantMessage(out, blocks) } } return out @@ -215,11 +184,7 @@ function pushUserMessages(out: KimiMessage[], blocks: AnthropicContentBlock[]): flushBuffer() } -function pushAssistantMessage( - out: KimiMessage[], - blocks: AnthropicContentBlock[], - includeReasoning: boolean, -): void { +function pushAssistantMessage(out: KimiMessage[], blocks: AnthropicContentBlock[]): void { const textParts: string[] = [] const thinkingParts: string[] = [] const toolCalls: KimiAssistantToolCall[] = [] @@ -227,7 +192,7 @@ function pushAssistantMessage( if (block.type === "text") { if (block.text) textParts.push(block.text) } else if (block.type === "thinking") { - if (includeReasoning && block.thinking) thinkingParts.push(block.thinking) + if (block.thinking) thinkingParts.push(block.thinking) } else if (block.type === "tool_use") { toolCalls.push({ id: block.id, From 8b715184ebd5e855b6d3ea49a695b3837b45e6c0 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 20 Apr 2026 23:35:55 +0300 Subject: [PATCH 36/98] add child() to logger and thread request context through provider internals Every log line emitted during a single HTTP request now automatically includes reqId, sessionId, sessionSeq, and provider without every log call having to remember to include them. Previously, inner helpers (kimi/codex client, reducer, stream translator) used module-global loggers and logged without any request identifiers. That made it impossible to correlate their output with the request that triggered it. Approach: explicit request scope carrying a child logger factory. - Add child() to the Logger interface and base-fields support to createLogger(). Per-call fields override base bindings. - Replace AsyncLocalStorage (rejected after consulting Gemini + Codex: ALS has real edge cases with async generators and would silently lose context on refactors). - Add childLogger(service) to RequestContext. It returns a new Logger with the request bindings (reqId, sessionId, sessionSeq, provider) pre-bound. Each module that logs calls ctx.childLogger("service.name") at function entry and uses the resulting logger normally. - Remove all module-global createLogger() calls from request-path files. Auth manager keeps its own logger (token refresh is not request-scoped). - Clean up redundant reqId/sessionId fields now that the logger carries them. --- src/log.ts | 17 +++++++--- src/providers/codex/client.ts | 32 +++++++++---------- src/providers/codex/index.ts | 35 ++++++--------------- src/providers/codex/translate/accumulate.ts | 5 +-- src/providers/codex/translate/reducer.ts | 4 +-- src/providers/codex/translate/stream.ts | 17 +++------- src/providers/kimi/client.ts | 23 ++++++-------- src/providers/kimi/index.ts | 23 ++++++-------- src/providers/kimi/translate/accumulate.ts | 6 ++-- src/providers/kimi/translate/reducer.ts | 4 +-- src/providers/kimi/translate/stream.ts | 17 +++------- src/providers/types.ts | 3 ++ src/server.ts | 28 ++++++++++------- 13 files changed, 93 insertions(+), 121 deletions(-) diff --git a/src/log.ts b/src/log.ts index 8da9a8e..0db75cd 100644 --- a/src/log.ts +++ b/src/log.ts @@ -112,13 +112,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/providers/codex/client.ts b/src/providers/codex/client.ts index b02d6b7..c98b248 100644 --- a/src/providers/codex/client.ts +++ b/src/providers/codex/client.ts @@ -1,15 +1,9 @@ import { CODEX_API_ENDPOINT, ORIGINATOR } from "./auth/constants.ts" import { forceRefresh, getAuth } from "./auth/manager.ts" -import { createLogger } from "../../log.ts" +import type { Logger } from "../../log.ts" +import type { RequestContext } from "../types.ts" import type { ResponsesRequest } from "./translate/request.ts" -const log = createLogger("codex.client") - -export interface CodexPostOptions { - sessionId?: string - signal?: AbortSignal -} - export interface CodexResponse { body: ReadableStream status: number @@ -18,16 +12,17 @@ export interface CodexResponse { export async function postCodex( body: ResponsesRequest, - opts: CodexPostOptions = {}, + ctx: RequestContext, ): Promise { + const log = ctx.childLogger("codex.client") 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,7 +54,9 @@ 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", @@ -69,10 +66,10 @@ async function doFetch( "openai-beta": "responses=experimental", }) 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 +77,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/index.ts b/src/providers/codex/index.ts index 1f43ed1..cf6f8d3 100644 --- a/src/providers/codex/index.ts +++ b/src/providers/codex/index.ts @@ -1,6 +1,5 @@ import type { AnthropicRequest } from "../../anthropic/schema.ts" import type { Provider, RequestContext, CliHandlers } from "../types.ts" -import { createLogger } from "../../log.ts" import { assertAllowedModel, ModelNotAllowedError, @@ -17,7 +16,6 @@ import { runDeviceLogin } from "./auth/device.ts" import { persistInitialTokens } from "./auth/manager.ts" import { loadAuth, authPath, clearAuth } from "./auth/token-store.ts" -const log = createLogger("provider.codex") const VERBOSE = !!process.env.CCP_LOG_VERBOSE interface SessionCountSnapshot { @@ -86,13 +84,14 @@ function jsonError(status: number, type: string, message: string): Response { } 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", { reqId: ctx.reqId, tokens }) + log.debug("count_tokens", { tokens }) if (state) { state.lastCount = { reqId: ctx.reqId, @@ -104,10 +103,7 @@ async function handleCountTokens(body: AnthropicRequest, ctx: RequestContext): P } if (VERBOSE) { log.info("compaction telemetry", { - reqId: ctx.reqId, phase: "count_tokens", - sessionId: ctx.sessionId, - sessionSeq: ctx.sessionSeq, model: body.model, resolvedModel, tokens, @@ -127,6 +123,7 @@ async function handleCountTokens(body: AnthropicRequest, ctx: RequestContext): P } 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 @@ -135,17 +132,15 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom const state = sessionState(ctx.sessionId) log.debug("anthropic request", { - reqId: ctx.reqId, model: body.model, messageCount, toolCount, stream: wantStream, - sessionId: ctx.sessionId, requestedMaxTokens: body.max_tokens, hasContextManagement: contextManagement !== undefined, hasJsonSchemaFormat: body.output_config?.format?.type === "json_schema", }) - if (VERBOSE) log.debug("anthropic request body", { reqId: ctx.reqId, body }) + if (VERBOSE) log.debug("anthropic request body", { body }) const resolvedModel = resolveModel(body.model) @@ -176,7 +171,6 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom } } log.debug("translated request", { - reqId: ctx.reqId, requestedModel: body.model, resolvedModel, inputItems: translated.input.length, @@ -186,13 +180,10 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom hasContextManagement: contextManagement !== undefined, promptCacheKey: translated.prompt_cache_key, }) - if (VERBOSE) log.debug("translated request body", { reqId: ctx.reqId, body: translated }) + if (VERBOSE) log.debug("translated request body", { body: translated }) if (VERBOSE) { log.info("compaction telemetry", { - reqId: ctx.reqId, phase: "translated_request", - sessionId: ctx.sessionId, - sessionSeq: ctx.sessionSeq, requestedModel: body.model, resolvedModel, messageCount, @@ -215,10 +206,10 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom let upstream try { - upstream = await postCodex(translated, { sessionId: ctx.sessionId, signal: ctx.signal }) + upstream = await postCodex(translated, ctx) } catch (err) { if (err instanceof CodexError) { - log.warn("codex error", { reqId: ctx.reqId, status: err.status, detail: err.detail }) + 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 @@ -242,17 +233,13 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom const stream = translateStream(upstream.body, { messageId, model: body.model, - reqId: ctx.reqId, - sessionId: ctx.sessionId, + log: ctx.childLogger("codex.stream"), onFinish: VERBOSE ? (finish) => { const mappedUsage = finish.usage ? mapUsageToAnthropic(finish.usage) : undefined log.info("compaction telemetry", { - reqId: ctx.reqId, phase: "upstream_finish", mode: "stream", - sessionId: ctx.sessionId, - sessionSeq: ctx.sessionSeq, requestedModel: body.model, resolvedModel, serverModel, @@ -289,15 +276,12 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom } try { - const result = await accumulateResponse(upstream.body, { messageId, model: body.model }) + const result = await accumulateResponse(upstream.body, { messageId, model: body.model, log: ctx.childLogger("codex.accumulate") }) if (VERBOSE) { const { serverModel, serverReasoningIncluded } = upstreamHeaderSnapshot(upstream.headers) log.info("compaction telemetry", { - reqId: ctx.reqId, phase: "upstream_finish", mode: "non_stream", - sessionId: ctx.sessionId, - sessionSeq: ctx.sessionSeq, requestedModel: body.model, resolvedModel, serverModel, @@ -326,7 +310,6 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom } catch (err) { if (err instanceof UpstreamStreamError) { log.warn("upstream stream error (non-streaming)", { - reqId: ctx.reqId, kind: err.kind, message: err.message, }) diff --git a/src/providers/codex/translate/accumulate.ts b/src/providers/codex/translate/accumulate.ts index 14fb047..712e606 100644 --- a/src/providers/codex/translate/accumulate.ts +++ b/src/providers/codex/translate/accumulate.ts @@ -1,5 +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" @@ -35,7 +36,7 @@ export interface AccumulatedResponse { */ export async function accumulateResponse( upstream: ReadableStream, - opts: { messageId: string; model: string }, + opts: { messageId: string; model: string; log: Logger }, ): Promise { type Block = | { kind: "text"; text: string } @@ -47,7 +48,7 @@ export async function accumulateResponse( 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: "" }) diff --git a/src/providers/codex/translate/reducer.ts b/src/providers/codex/translate/reducer.ts index 34ce0dd..9140e68 100644 --- a/src/providers/codex/translate/reducer.ts +++ b/src/providers/codex/translate/reducer.ts @@ -1,7 +1,6 @@ import { parseSseStream } from "../../../sse.ts" -import { createLogger } from "../../../log.ts" +import type { Logger } from "../../../log.ts" -const log = createLogger("translate.reducer") const VERBOSE = !!process.env.CCP_LOG_VERBOSE export class UpstreamStreamError extends Error { @@ -79,6 +78,7 @@ function sanitizeToolArgs(name: string, args: string): string { */ export async function* reduceUpstream( upstream: ReadableStream, + log: Logger, ): AsyncGenerator { const blocksByOutputIndex = new Map() const itemIdToOutputIndex = new Map() diff --git a/src/providers/codex/translate/stream.ts b/src/providers/codex/translate/stream.ts index a9dd438..ba29961 100644 --- a/src/providers/codex/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 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. @@ -17,8 +15,7 @@ export function translateStream( opts: { messageId: string model: string - reqId: string - sessionId?: string + log: Logger onFinish?: (finish: { stopReason: "end_turn" | "tool_use" | "max_tokens"; usage?: Parameters[0] }) => void }, ): ReadableStream { @@ -55,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() @@ -116,9 +113,7 @@ export function translateStream( const activeToolNames = Array.from(activeTools.values(), (tool) => tool.name) const activeToolCalls = Array.from(activeTools.values()) if (err instanceof UpstreamStreamError) { - log.warn("upstream stream error", { - reqId: opts.reqId, - sessionId: opts.sessionId, + opts.log.warn("upstream stream error", { kind: err.kind, message: err.message, activeToolNames, @@ -133,9 +128,7 @@ export function translateStream( }, }) } else { - log.error("stream translation error", { - reqId: opts.reqId, - sessionId: opts.sessionId, + opts.log.error("stream translation error", { err: String(err), activeToolNames, activeToolCalls, diff --git a/src/providers/kimi/client.ts b/src/providers/kimi/client.ts index 29dea5b..c141472 100644 --- a/src/providers/kimi/client.ts +++ b/src/providers/kimi/client.ts @@ -1,16 +1,10 @@ import { API_BASE_URL } from "./auth/constants.ts" import { commonHeaders } from "./auth/headers.ts" import { forceRefresh, getAuth, KimiAuthUnauthorizedError } from "./auth/manager.ts" -import { createLogger } from "../../log.ts" +import type { Logger } from "../../log.ts" +import type { RequestContext } from "../types.ts" import type { KimiChatRequest } from "./translate/request.ts" -const log = createLogger("kimi.client") - -export interface KimiPostOptions { - sessionId?: string - signal?: AbortSignal -} - export interface KimiResponse { body: ReadableStream status: number @@ -31,16 +25,17 @@ export class KimiError extends Error { export async function postKimi( body: KimiChatRequest, - opts: KimiPostOptions = {}, + ctx: RequestContext, ): Promise { + const log = ctx.childLogger("kimi.client") let auth = await getAuth() - let resp = await doFetch(auth.access, body, opts) + 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, opts) + resp = await doFetch(auth.access, body, log, ctx.signal) } catch (err) { if (err instanceof KimiAuthUnauthorizedError) { throw new KimiError(401, "Unauthorized", err.message) @@ -69,7 +64,8 @@ export async function postKimi( async function doFetch( accessToken: string, body: KimiChatRequest, - opts: KimiPostOptions, + log: Logger, + signal?: AbortSignal, ): Promise { const fp = await commonHeaders() const headers = new Headers({ @@ -84,14 +80,13 @@ async function doFetch( model: body.model, messageCount: body.messages.length, toolCount: body.tools?.length ?? 0, - sessionId: opts.sessionId, }) return fetch(`${API_BASE_URL}/chat/completions`, { method: "POST", headers, body: JSON.stringify(body), - signal: opts.signal, + signal, }) } diff --git a/src/providers/kimi/index.ts b/src/providers/kimi/index.ts index d334f51..a164665 100644 --- a/src/providers/kimi/index.ts +++ b/src/providers/kimi/index.ts @@ -1,6 +1,5 @@ import type { Provider, CliHandlers, RequestContext } from "../types.ts" import type { AnthropicRequest } from "../../anthropic/schema.ts" -import { createLogger } from "../../log.ts" import { assertAllowedModel, ModelNotAllowedError, @@ -15,7 +14,6 @@ import { runDeviceLogin } from "./auth/login.ts" import { persistInitialTokens } from "./auth/manager.ts" import { loadAuth, clearAuth, authPath } from "./auth/token-store.ts" -const log = createLogger("provider.kimi") const VERBOSE = !!process.env.CCP_LOG_VERBOSE function jsonError(status: number, type: string, message: string): Response { @@ -26,31 +24,31 @@ function jsonError(status: number, type: string, message: string): Response { } 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", { reqId: ctx.reqId, tokens }) + 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", { - reqId: ctx.reqId, model: body.model, messageCount, toolCount, stream: wantStream, - sessionId: ctx.sessionId, requestedMaxTokens: body.max_tokens, }) - if (VERBOSE) log.debug("anthropic request body", { reqId: ctx.reqId, body }) + if (VERBOSE) log.debug("anthropic request body", { body }) const resolvedModel = resolveModel(body.model) try { @@ -73,7 +71,6 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom const localInputTokens = VERBOSE ? countTokens(body) : undefined const translatedInputTokens = VERBOSE ? countTranslatedTokens(translated) : undefined log.debug("translated request", { - reqId: ctx.reqId, requestedModel: body.model, resolvedModel, messageCount: translated.messages.length, @@ -85,14 +82,14 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom thinking: translated.thinking?.type, maxTokens: translated.max_tokens, }) - if (VERBOSE) log.debug("translated request body", { reqId: ctx.reqId, body: translated }) + if (VERBOSE) log.debug("translated request body", { body: translated }) let upstream try { - upstream = await postKimi(translated, { sessionId: ctx.sessionId, signal: ctx.signal }) + upstream = await postKimi(translated, ctx) } catch (err) { if (err instanceof KimiError) { - log.warn("kimi error", { reqId: ctx.reqId, status: err.status, detail: err.detail }) + 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 @@ -115,8 +112,7 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom const stream = translateStream(upstream.body, { messageId, model: body.model, - reqId: ctx.reqId, - sessionId: ctx.sessionId, + log: ctx.childLogger("kimi.stream"), }) return new Response(stream, { status: 200, @@ -129,14 +125,13 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom } try { - const result = await accumulateResponse(upstream.body, { messageId, model: body.model }) + 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)", { - reqId: ctx.reqId, kind: err.kind, message: err.message, }) diff --git a/src/providers/kimi/translate/accumulate.ts b/src/providers/kimi/translate/accumulate.ts index b9e8447..1d55a40 100644 --- a/src/providers/kimi/translate/accumulate.ts +++ b/src/providers/kimi/translate/accumulate.ts @@ -28,9 +28,11 @@ export interface AccumulatedResponse { rawUsage?: KimiUsage } +import type { Logger } from "../../../log.ts" + export async function accumulateResponse( upstream: ReadableStream, - opts: { messageId: string; model: string }, + opts: { messageId: string; model: string; log: Logger }, ): Promise { type Block = | { kind: "thinking"; text: string } @@ -43,7 +45,7 @@ export async function accumulateResponse( let usage: ReturnType | undefined let rawUsage: KimiUsage | undefined - for await (const e of reduceUpstream(upstream)) { + for await (const e of reduceUpstream(upstream, opts.log)) { switch (e.kind) { case "thinking-start": blocks.set(e.index, { kind: "thinking", text: "" }) diff --git a/src/providers/kimi/translate/reducer.ts b/src/providers/kimi/translate/reducer.ts index 23a7bf7..6279f7a 100644 --- a/src/providers/kimi/translate/reducer.ts +++ b/src/providers/kimi/translate/reducer.ts @@ -1,7 +1,6 @@ import { parseSseStream } from "../../../sse.ts" -import { createLogger } from "../../../log.ts" +import type { Logger } from "../../../log.ts" -const log = createLogger("kimi.reducer") const VERBOSE = !!process.env.CCP_LOG_VERBOSE export class UpstreamStreamError extends Error { @@ -70,6 +69,7 @@ interface ToolSlot { */ export async function* reduceUpstream( upstream: ReadableStream, + log: Logger, ): AsyncGenerator { let nextBlockIndex = 0 let thinkingIndex: number | undefined diff --git a/src/providers/kimi/translate/stream.ts b/src/providers/kimi/translate/stream.ts index c0f792d..4206c11 100644 --- a/src/providers/kimi/translate/stream.ts +++ b/src/providers/kimi/translate/stream.ts @@ -1,10 +1,8 @@ import { encodeSseEvent } from "../../../sse.ts" -import { createLogger } from "../../../log.ts" +import type { Logger } from "../../../log.ts" import { mapUsageToAnthropic, reduceUpstream, UpstreamStreamError } from "./reducer.ts" import { makeThinkingSignature } from "./signature.ts" -const log = createLogger("kimi.stream") - /** * Translate a Kimi chat-completions SSE stream into Anthropic SSE events. * HTTP status 200 is already committed before the first upstream event is @@ -16,8 +14,7 @@ export function translateStream( opts: { messageId: string model: string - reqId: string - sessionId?: string + log: Logger onFinish?: (finish: { stopReason: "end_turn" | "tool_use" | "max_tokens" usage?: Parameters[0] @@ -57,7 +54,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 "thinking-start": ensureMessageStart() @@ -148,9 +145,7 @@ export function translateStream( const activeToolNames = Array.from(activeTools.values(), (t) => t.name) const activeToolCalls = Array.from(activeTools.values()) if (err instanceof UpstreamStreamError) { - log.warn("upstream stream error", { - reqId: opts.reqId, - sessionId: opts.sessionId, + opts.log.warn("upstream stream error", { kind: err.kind, message: err.message, activeToolNames, @@ -165,9 +160,7 @@ export function translateStream( }, }) } else { - log.error("stream translation error", { - reqId: opts.reqId, - sessionId: opts.sessionId, + opts.log.error("stream translation error", { err: String(err), activeToolNames, activeToolCalls, diff --git a/src/providers/types.ts b/src/providers/types.ts index bc9733e..d3bb3d7 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -1,10 +1,13 @@ 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 { diff --git a/src/server.ts b/src/server.ts index 6f2da8c..2afe17f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,9 +1,10 @@ import { createLogger, logDir } from "./log.ts" + import type { AnthropicRequest } from "./anthropic/schema.ts" import type { Provider, RequestContext } from "./providers/types.ts" import { allSupportedModels, providerForModel } from "./providers/registry.ts" -const log = createLogger("server") +const rootLog = createLogger("server") export interface ServeOptions { port: number @@ -27,7 +28,7 @@ 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", { + rootLog.info("request", { reqId, method: req.method, path: url.pathname, @@ -35,15 +36,15 @@ export function startServer(opts: ServeOptions): { stop: () => void; port: numbe }) try { const resp = await route(req, url, reqId) - log.info("response", { reqId, status: resp.status, ms: Date.now() - start }) + rootLog.info("response", { reqId, status: resp.status, ms: Date.now() - start }) return resp } catch (err) { - log.error("handler error", { reqId, err: String(err), stack: (err as Error)?.stack }) + 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(), @@ -62,8 +63,8 @@ async function route(req: Request, url: URL, reqId: string): Promise { if (body instanceof Response) return body const provider = routeProvider(body, reqId) if (provider instanceof Response) return provider - const ctx = buildCtx(req, reqId) - log.info("dispatch", { reqId, provider: provider.name, model: body.model }) + const ctx = buildCtx(req, reqId, provider.name) + ctx.childLogger("server").info("dispatch", { model: body.model }) return provider.handleCountTokens(body, ctx) } @@ -72,21 +73,24 @@ async function route(req: Request, url: URL, reqId: string): Promise { if (body instanceof Response) return body const provider = routeProvider(body, reqId) if (provider instanceof Response) return provider - const ctx = buildCtx(req, reqId) - log.info("dispatch", { reqId, provider: provider.name, model: body.model }) + 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}`) } -function buildCtx(req: Request, reqId: string): RequestContext { +function buildCtx(req: Request, reqId: string, providerName: string): RequestContext { const sessionId = req.headers.get("x-claude-code-session-id") || undefined + const sessionSeq = nextSessionSeq(sessionId) + const bindings = { reqId, sessionId, sessionSeq, provider: providerName } return { reqId, sessionId, - sessionSeq: nextSessionSeq(sessionId), + sessionSeq, signal: req.signal, + childLogger: (service) => createLogger(service, bindings), } } @@ -100,7 +104,7 @@ function routeProvider(body: AnthropicRequest, reqId: string): Provider | Respon } const provider = providerForModel(body.model) if (!provider) { - log.warn("unknown model", { reqId, model: body.model }) + rootLog.warn("unknown model", { reqId, model: body.model }) return jsonError( 400, "invalid_request_error", From 465533ef005c78e2d29b47a63d0c840125faa8ed Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 20 Apr 2026 23:37:42 +0300 Subject: [PATCH 37/98] 0.0.4 --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfb7f15..d8d19c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 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 diff --git a/package.json b/package.json index 933eb27..66c1efb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-proxy", - "version": "0.0.3", + "version": "0.0.4", "license": "MIT", "type": "module", "bin": { From 1148ef1ea25d69ff75d9f5124926b87a12cba697 Mon Sep 17 00:00:00 2001 From: Omar Abdulla Date: Wed, 22 Apr 2026 23:00:07 +0300 Subject: [PATCH 38/98] add model and effort override env vars Adds CLAUDE_CODEX_PROXY_OPEN_AI_MODEL_OVERRIDE and CLAUDE_CODEX_PROXY_OPEN_AI_EFFORT_OVERRIDE to force specific model and effort settings across all proxy requests. Useful when using agent-teams where the harness doesn't reliably respect model/effort instructions. Also expands the Effort type to include "none" and "xhigh" to match the upstream Codex Responses API reasoning effort values ("none", "low", "medium", "high", "xhigh"). --- src/anthropic/schema.ts | 2 +- .../codex/translate/model-allowlist.ts | 14 +++++++++++ src/providers/codex/translate/request.ts | 24 +++++++++++++++++-- src/providers/kimi/translate/request.ts | 12 +++++++++- 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/anthropic/schema.ts b/src/anthropic/schema.ts index bda93bf..916a628 100644 --- a/src/anthropic/schema.ts +++ b/src/anthropic/schema.ts @@ -61,7 +61,7 @@ export interface AnthropicRequest { stream?: boolean thinking?: { type: string; [k: string]: unknown } output_config?: { - effort?: "low" | "medium" | "high" + effort?: "low" | "medium" | "high" | "xhigh" format?: { type: "json_schema"; schema: unknown; name?: string; strict?: boolean } } context_management?: unknown diff --git a/src/providers/codex/translate/model-allowlist.ts b/src/providers/codex/translate/model-allowlist.ts index 5ff3744..580d57e 100644 --- a/src/providers/codex/translate/model-allowlist.ts +++ b/src/providers/codex/translate/model-allowlist.ts @@ -16,6 +16,20 @@ export const MODEL_ALIASES = new Map([ ]) export function resolveModel(model: string): string { + // The CLAUDE_CODEX_PROXY_OPEN_AI_MODEL_OVERRIDE environment variable allows + // for the model that's used to be overridden so that regardless of whatever + // model is being requested by the harness, the model which is provided in + // that env var is always returned. + // + // This is useful in cases where you just want the claude-code harness to use + // a specific model all across the way. + if ( + process.env.CLAUDE_CODEX_PROXY_OPEN_AI_MODEL_OVERRIDE !== undefined && + process.env.CLAUDE_CODEX_PROXY_OPEN_AI_MODEL_OVERRIDE !== "" + ) { + return process.env.CLAUDE_CODEX_PROXY_OPEN_AI_MODEL_OVERRIDE + } + return MODEL_ALIASES.get(model) ?? model } diff --git a/src/providers/codex/translate/request.ts b/src/providers/codex/translate/request.ts index ac0f115..6899243 100644 --- a/src/providers/codex/translate/request.ts +++ b/src/providers/codex/translate/request.ts @@ -7,6 +7,8 @@ import type { AnthropicTool, } from "../../../anthropic/schema.ts" +export type Effort = "none" | "low" | "medium" | "high" | "xhigh" + // 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 { @@ -20,7 +22,7 @@ export interface ResponsesRequest { | "required" | { type: "function"; name: string } parallel_tool_calls?: boolean - reasoning?: { effort?: "low" | "medium" | "high"; summary?: unknown } + reasoning?: { effort?: Effort; summary?: unknown } store: false stream: true include?: string[] @@ -71,6 +73,24 @@ export interface TranslateOptions { sessionId?: string } +function resolveEffort(effort?: Effort): Effort | undefined { + // The CLAUDE_CODEX_PROXY_OPEN_AI_EFFORT_OVERRIDE environment variable allows + // for the effort that's used to be overridden so that regardless of whatever + // effort is being requested by the harness, the effort which is provided in + // that env var is always returned. + // + // This is useful in cases where you just want the claude-code harness to use + // a specific effort all across the way. + if ( + process.env.CLAUDE_CODEX_PROXY_OPEN_AI_EFFORT_OVERRIDE !== undefined && + process.env.CLAUDE_CODEX_PROXY_OPEN_AI_EFFORT_OVERRIDE !== "" + ) { + return process.env.CLAUDE_CODEX_PROXY_OPEN_AI_EFFORT_OVERRIDE as Effort + } + + return effort +} + export function translateRequest(req: AnthropicRequest, opts: TranslateOptions = {}): ResponsesRequest { const instructions = buildInstructions(req.system) const input = buildInput(req.messages) @@ -99,7 +119,7 @@ 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 + const effort = resolveEffort(req.output_config?.effort) if (effort) { out.reasoning = { effort } out.include = ["reasoning.encrypted_content"] diff --git a/src/providers/kimi/translate/request.ts b/src/providers/kimi/translate/request.ts index b74d694..5535436 100644 --- a/src/providers/kimi/translate/request.ts +++ b/src/providers/kimi/translate/request.ts @@ -86,7 +86,7 @@ export function translateRequest( stream: true, stream_options: { include_usage: true }, max_tokens: clampMaxTokens(req.max_tokens), - reasoning_effort: req.output_config?.effort ?? "medium", + reasoning_effort: mapReasoningEffort(req.output_config?.effort), thinking: { type: "enabled" }, } if (tools && tools.length) out.tools = tools @@ -101,6 +101,16 @@ function clampMaxTokens(requested: number | undefined): number { return Math.min(requested, DEFAULT_MAX_TOKENS) } +// Kimi's reasoning_effort is capped at "high"; collapse the proxy's "xhigh" +// extension 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 === "xhigh") return "high" + return effort ?? "medium" +} + function mapToolChoice(choice: AnthropicRequest["tool_choice"]): KimiToolChoice { if (!choice) return "auto" switch (choice.type) { From 74e775086aaa7bb8f903fbbb40a5e1aaa608c9eb Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Wed, 22 Apr 2026 23:06:56 +0300 Subject: [PATCH 39/98] rename model override env var to CCP_CODEX_MODEL --- src/providers/codex/translate/model-allowlist.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/providers/codex/translate/model-allowlist.ts b/src/providers/codex/translate/model-allowlist.ts index 580d57e..7e048ac 100644 --- a/src/providers/codex/translate/model-allowlist.ts +++ b/src/providers/codex/translate/model-allowlist.ts @@ -16,18 +16,14 @@ export const MODEL_ALIASES = new Map([ ]) export function resolveModel(model: string): string { - // The CLAUDE_CODEX_PROXY_OPEN_AI_MODEL_OVERRIDE environment variable allows - // for the model that's used to be overridden so that regardless of whatever - // model is being requested by the harness, the model which is provided in - // that env var is always returned. - // - // This is useful in cases where you just want the claude-code harness to use - // a specific model all across the way. + // The CCP_CODEX_MODEL environment variable overrides the model so that + // regardless of whatever model is requested by the harness, the provided + // model is always used. if ( - process.env.CLAUDE_CODEX_PROXY_OPEN_AI_MODEL_OVERRIDE !== undefined && - process.env.CLAUDE_CODEX_PROXY_OPEN_AI_MODEL_OVERRIDE !== "" + process.env.CCP_CODEX_MODEL !== undefined && + process.env.CCP_CODEX_MODEL !== "" ) { - return process.env.CLAUDE_CODEX_PROXY_OPEN_AI_MODEL_OVERRIDE + return process.env.CCP_CODEX_MODEL } return MODEL_ALIASES.get(model) ?? model From 86924ef64636d3ac70c5df5fafb46eeb6a5fae82 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Wed, 22 Apr 2026 23:07:01 +0300 Subject: [PATCH 40/98] map max effort to xhigh for codex and high for kimi --- src/anthropic/schema.ts | 2 +- src/providers/kimi/translate/request.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/anthropic/schema.ts b/src/anthropic/schema.ts index 916a628..c707cb6 100644 --- a/src/anthropic/schema.ts +++ b/src/anthropic/schema.ts @@ -61,7 +61,7 @@ export interface AnthropicRequest { stream?: boolean thinking?: { type: string; [k: string]: unknown } output_config?: { - effort?: "low" | "medium" | "high" | "xhigh" + effort?: "low" | "medium" | "high" | "max" format?: { type: "json_schema"; schema: unknown; name?: string; strict?: boolean } } context_management?: unknown diff --git a/src/providers/kimi/translate/request.ts b/src/providers/kimi/translate/request.ts index 5535436..0b55c3f 100644 --- a/src/providers/kimi/translate/request.ts +++ b/src/providers/kimi/translate/request.ts @@ -101,13 +101,13 @@ function clampMaxTokens(requested: number | undefined): number { return Math.min(requested, DEFAULT_MAX_TOKENS) } -// Kimi's reasoning_effort is capped at "high"; collapse the proxy's "xhigh" -// extension to "high" since Kimi has no stronger tier, and default to "medium" -// when no effort is requested. +// 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 === "xhigh") return "high" + if (effort === "max") return "high" return effort ?? "medium" } From eda2d832891e09adc33400e3d0dbd5e51178ee00 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Wed, 22 Apr 2026 23:07:05 +0300 Subject: [PATCH 41/98] rename effort override env var and add validation --- src/providers/codex/translate/request.ts | 34 +++++++++++++----------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/providers/codex/translate/request.ts b/src/providers/codex/translate/request.ts index 6899243..febcfe7 100644 --- a/src/providers/codex/translate/request.ts +++ b/src/providers/codex/translate/request.ts @@ -73,24 +73,28 @@ export interface TranslateOptions { sessionId?: string } -function resolveEffort(effort?: Effort): Effort | undefined { - // The CLAUDE_CODEX_PROXY_OPEN_AI_EFFORT_OVERRIDE environment variable allows - // for the effort that's used to be overridden so that regardless of whatever - // effort is being requested by the harness, the effort which is provided in - // that env var is always returned. - // - // This is useful in cases where you just want the claude-code harness to use - // a specific effort all across the way. - if ( - process.env.CLAUDE_CODEX_PROXY_OPEN_AI_EFFORT_OVERRIDE !== undefined && - process.env.CLAUDE_CODEX_PROXY_OPEN_AI_EFFORT_OVERRIDE !== "" - ) { - return process.env.CLAUDE_CODEX_PROXY_OPEN_AI_EFFORT_OVERRIDE as Effort - } +const VALID_EFFORTS = new Set(["none", "low", "medium", "high", "xhigh"]) +function toCodexEffort( + effort: NonNullable["effort"], +): Effort | undefined { + if (effort === "max") return "xhigh" return effort } +function resolveEffort(effort?: Effort): Effort | undefined { + const override = process.env.CCP_CODEX_EFFORT + if (override === undefined || override === "") { + return effort + } + if (!VALID_EFFORTS.has(override as Effort)) { + throw new Error( + `Invalid effort override: "${override}". Must be one of: ${Array.from(VALID_EFFORTS).join(", ")}`, + ) + } + return override as Effort +} + export function translateRequest(req: AnthropicRequest, opts: TranslateOptions = {}): ResponsesRequest { const instructions = buildInstructions(req.system) const input = buildInput(req.messages) @@ -119,7 +123,7 @@ 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 = resolveEffort(req.output_config?.effort) + const effort = resolveEffort(toCodexEffort(req.output_config?.effort)) if (effort) { out.reasoning = { effort } out.include = ["reasoning.encrypted_content"] From fdd2e9a78e54b97ec0df7c028be3868e8df18bfe Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Wed, 22 Apr 2026 23:23:20 +0300 Subject: [PATCH 42/98] validate incoming effort values at runtime --- src/providers/codex/translate/request.ts | 11 +++++++++++ src/providers/kimi/translate/request.ts | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/providers/codex/translate/request.ts b/src/providers/codex/translate/request.ts index febcfe7..a7ae079 100644 --- a/src/providers/codex/translate/request.ts +++ b/src/providers/codex/translate/request.ts @@ -75,6 +75,16 @@ export interface TranslateOptions { const VALID_EFFORTS = new Set(["none", "low", "medium", "high", "xhigh"]) +const ANTHROPIC_EFFORTS = new Set(["low", "medium", "high", "max"]) + +function assertValidEffort(effort: unknown): void { + if (effort !== undefined && !ANTHROPIC_EFFORTS.has(effort as string)) { + throw new Error( + `Invalid output_config.effort: "${effort}". Must be one of: ${Array.from(ANTHROPIC_EFFORTS).join(", ")}`, + ) + } +} + function toCodexEffort( effort: NonNullable["effort"], ): Effort | undefined { @@ -123,6 +133,7 @@ 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 + assertValidEffort(req.output_config?.effort) const effort = resolveEffort(toCodexEffort(req.output_config?.effort)) if (effort) { out.reasoning = { effort } diff --git a/src/providers/kimi/translate/request.ts b/src/providers/kimi/translate/request.ts index 0b55c3f..470b79f 100644 --- a/src/providers/kimi/translate/request.ts +++ b/src/providers/kimi/translate/request.ts @@ -80,6 +80,7 @@ export function translateRequest( const messages = buildMessages(req) const tools = req.tools?.map(toKimiTool) + assertValidEffort(req.output_config?.effort) const out: KimiChatRequest = { model: req.model, messages, @@ -101,6 +102,16 @@ function clampMaxTokens(requested: number | undefined): number { return Math.min(requested, DEFAULT_MAX_TOKENS) } +const ANTHROPIC_EFFORTS = new Set(["low", "medium", "high", "max"]) + +function assertValidEffort(effort: unknown): void { + if (effort !== undefined && !ANTHROPIC_EFFORTS.has(effort as string)) { + throw new Error( + `Invalid output_config.effort: "${effort}". Must be one of: ${Array.from(ANTHROPIC_EFFORTS).join(", ")}`, + ) + } +} + // Kimi's reasoning_effort is capped at "high"; collapse the proxy's "max" // to "high" since Kimi has no stronger tier, and default to "medium" when no // effort is requested. From d01f546b940348bcbdd19f5a40fe088f7eaeea9c Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Wed, 22 Apr 2026 23:23:25 +0300 Subject: [PATCH 43/98] readme: document CCP_CODEX_MODEL and CCP_CODEX_EFFORT env vars --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 90031df..54618cf 100644 --- a/README.md +++ b/README.md @@ -409,6 +409,8 @@ Settings are environment variables on the proxy process, not a config file. | `CCP_LOG_VERBOSE` | unset | Log full request/response bodies + every SSE event | | `KIMI_OAUTH_HOST` | `https://auth.kimi.com` | Override Kimi's OAuth host (debugging only) | | `KIMI_BASE_URL` | `https://api.kimi.com/coding/v1` | Override Kimi's API base URL | +| `CCP_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`| unset | Force all Codex requests to this reasoning effort (`none`, `low`, `medium`, `high`, `xhigh`) | ### Files From c9f95df3951c2f67d050705c5c0a7a716548472d Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Tue, 21 Apr 2026 10:54:30 +0300 Subject: [PATCH 44/98] kimi: always log finish field in upstream chunk debug output Ensures the finish field is present (null when not applicable) instead of\nundefined for usage-only chunks, making log parsing more reliable. --- src/providers/kimi/translate/reducer.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/providers/kimi/translate/reducer.ts b/src/providers/kimi/translate/reducer.ts index 6279f7a..a249ed5 100644 --- a/src/providers/kimi/translate/reducer.ts +++ b/src/providers/kimi/translate/reducer.ts @@ -67,9 +67,14 @@ interface ToolSlot { * 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, log: Logger, + stats?: ReducerStats, ): AsyncGenerator { let nextBlockIndex = 0 let thinkingIndex: number | undefined @@ -114,15 +119,18 @@ export async function* reduceUpstream( } if (VERBOSE) { - const d = chunk.choices?.[0]?.delta + const choice = chunk.choices?.[0] + const d = choice?.delta log.debug("upstream chunk", { hasReasoning: typeof d?.reasoning_content === "string", hasContent: typeof d?.content === "string" && d.content.length > 0, toolCalls: d?.tool_calls?.length, - finish: chunk.choices?.[0]?.finish_reason, + finish: choice?.finish_reason ?? null, }) } + if (stats) stats.chunkCount++ + if (chunk.error) { throw new UpstreamStreamError("failed", chunk.error.message || "Upstream error") } From 9788fc176a2b3fb1fa4d69bd7fbfdf02c3f2e3fe Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Tue, 21 Apr 2026 10:54:39 +0300 Subject: [PATCH 45/98] kimi: log upstream HTTP response status Adds debug logging for the upstream API response status after a successful\nfetch, making it easier to distinguish connection failures from API errors. --- src/providers/kimi/client.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/providers/kimi/client.ts b/src/providers/kimi/client.ts index c141472..860f6ea 100644 --- a/src/providers/kimi/client.ts +++ b/src/providers/kimi/client.ts @@ -58,6 +58,8 @@ export async function postKimi( if (!resp.body) throw new KimiError(500, "Upstream returned no body") + log.debug("upstream response", { status: resp.status }) + return { body: resp.body, status: resp.status, headers: resp.headers } } From 3fcc9a38add817345d667b80d979c1eef035930b Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Tue, 21 Apr 2026 10:54:55 +0300 Subject: [PATCH 46/98] kimi: add stream and accumulation summary logging Tracks timing (time to first chunk, total stream duration), content metrics\n(reasoning chars, content chars, tool count), and chunk counts for both\nstreaming and non-streaming paths. Logs a summary at completion for\ndebuggability. --- src/providers/kimi/translate/accumulate.ts | 26 +++++++++++++++++--- src/providers/kimi/translate/stream.ts | 28 +++++++++++++++++++++- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/providers/kimi/translate/accumulate.ts b/src/providers/kimi/translate/accumulate.ts index 1d55a40..5efef66 100644 --- a/src/providers/kimi/translate/accumulate.ts +++ b/src/providers/kimi/translate/accumulate.ts @@ -44,8 +44,12 @@ export async function accumulateResponse( 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, opts.log)) { + for await (const e of reduceUpstream(upstream, opts.log, stats)) { switch (e.kind) { case "thinking-start": blocks.set(e.index, { kind: "thinking", text: "" }) @@ -53,7 +57,10 @@ export async function accumulateResponse( break case "thinking-delta": { const b = blocks.get(e.index) - if (b?.kind === "thinking") b.text += e.text + if (b?.kind === "thinking") { + b.text += e.text + reasoningChars += e.text.length + } break } case "text-start": @@ -62,10 +69,14 @@ export async function accumulateResponse( break case "text-delta": { const b = blocks.get(e.index) - if (b?.kind === "text") b.text += e.text + 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 @@ -110,6 +121,15 @@ export async function accumulateResponse( } } + opts.log.debug("accumulate summary", { + chunkCount: stats.chunkCount, + reasoningChars, + contentChars, + toolCount, + stopReason, + usage: rawUsage, + }) + return { rawUsage, response: { diff --git a/src/providers/kimi/translate/stream.ts b/src/providers/kimi/translate/stream.ts index 4206c11..e4dc228 100644 --- a/src/providers/kimi/translate/stream.ts +++ b/src/providers/kimi/translate/stream.ts @@ -53,8 +53,19 @@ export function translateStream( 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, opts.log)) { + for await (const e of reduceUpstream(upstream, opts.log, stats)) { + if (firstChunkAt === undefined) firstChunkAt = Date.now() + switch (e.kind) { case "thinking-start": ensureMessageStart() @@ -65,6 +76,7 @@ export function translateStream( }) break case "thinking-delta": + reasoningChars += e.text.length emit("content_block_delta", { type: "content_block_delta", index: e.index, @@ -95,6 +107,7 @@ export function translateStream( }) break case "text-delta": + contentChars += e.text.length emit("content_block_delta", { type: "content_block_delta", index: e.index, @@ -105,6 +118,7 @@ export function translateStream( 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", { @@ -131,6 +145,8 @@ export function translateStream( break case "finish": ensureMessageStart() + finishUsage = e.usage + finishStopReason = e.stopReason opts.onFinish?.({ stopReason: e.stopReason, usage: e.usage }) emit("message_delta", { type: "message_delta", @@ -172,6 +188,16 @@ export function translateStream( }) } } finally { + opts.log.debug("stream summary", { + chunkCount: stats.chunkCount, + firstChunkMs: firstChunkAt ? firstChunkAt - streamStart : undefined, + totalMs: Date.now() - streamStart, + reasoningChars, + contentChars, + toolCount, + stopReason: finishStopReason, + usage: finishUsage, + }) controller.close() } }, From ec0cf548fb836a38a283341fe94a6904d7e50965 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Tue, 21 Apr 2026 10:55:17 +0300 Subject: [PATCH 47/98] kimi: always log input tokens and add stream finish usage logging Removes the VERBOSE gate on input token counting so token counts are always\navailable in logs. Adds an onFinish handler to the streaming path that logs\nupstream and mapped token usage (input, output, cached, reasoning). --- src/providers/kimi/index.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/providers/kimi/index.ts b/src/providers/kimi/index.ts index a164665..4bb1966 100644 --- a/src/providers/kimi/index.ts +++ b/src/providers/kimi/index.ts @@ -8,6 +8,7 @@ import { 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" @@ -68,8 +69,8 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom { ...body, model: resolvedModel }, { sessionId: ctx.sessionId }, ) - const localInputTokens = VERBOSE ? countTokens(body) : undefined - const translatedInputTokens = VERBOSE ? countTranslatedTokens(translated) : undefined + const localInputTokens = countTokens(body) + const translatedInputTokens = countTranslatedTokens(translated) log.debug("translated request", { requestedModel: body.model, resolvedModel, @@ -113,6 +114,23 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom messageId, model: body.model, log: ctx.childLogger("kimi.stream"), + 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, From 5e89d530a0f75da14f7551343f70fe562056de4b Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Tue, 21 Apr 2026 11:41:16 +0300 Subject: [PATCH 48/98] kimi: track request start time for accurate TTFB metrics Replace the misleading firstChunkMs (which only measured from stream start, not request start) with three separate timing fields: - timeToHeadersMs: logged when the upstream HTTP response headers arrive, showing true API round-trip time - timeToFirstChunkMs: from request start to first parsed SSE chunk - streamDurationMs: from first chunk to stream end - totalMs: now measured from request start instead of stream start Also log requestBodyBytes when posting to Kimi to help debug latency correlations with payload size. --- src/providers/kimi/client.ts | 13 ++++++++++--- src/providers/kimi/index.ts | 1 + src/providers/kimi/translate/stream.ts | 10 ++++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/providers/kimi/client.ts b/src/providers/kimi/client.ts index 860f6ea..6486a68 100644 --- a/src/providers/kimi/client.ts +++ b/src/providers/kimi/client.ts @@ -9,6 +9,7 @@ export interface KimiResponse { body: ReadableStream status: number headers: Headers + requestStartTime: number } export class KimiError extends Error { @@ -29,6 +30,7 @@ export async function postKimi( ): Promise { const log = ctx.childLogger("kimi.client") let auth = await getAuth() + const requestStartTime = Date.now() let resp = await doFetch(auth.access, body, log, ctx.signal) if (resp.status === 401) { @@ -58,9 +60,12 @@ export async function postKimi( if (!resp.body) throw new KimiError(500, "Upstream returned no body") - log.debug("upstream response", { status: resp.status }) + log.debug("upstream response", { + status: resp.status, + timeToHeadersMs: Date.now() - requestStartTime, + }) - return { body: resp.body, status: resp.status, headers: resp.headers } + return { body: resp.body, status: resp.status, headers: resp.headers, requestStartTime } } async function doFetch( @@ -77,17 +82,19 @@ async function doFetch( ...fp, }) + const bodyJson = JSON.stringify(body) log.debug("posting to kimi", { url: `${API_BASE_URL}/chat/completions`, model: body.model, messageCount: body.messages.length, toolCount: body.tools?.length ?? 0, + requestBodyBytes: new TextEncoder().encode(bodyJson).length, }) return fetch(`${API_BASE_URL}/chat/completions`, { method: "POST", headers, - body: JSON.stringify(body), + body: bodyJson, signal, }) } diff --git a/src/providers/kimi/index.ts b/src/providers/kimi/index.ts index 4bb1966..6f2635b 100644 --- a/src/providers/kimi/index.ts +++ b/src/providers/kimi/index.ts @@ -114,6 +114,7 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom 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", { diff --git a/src/providers/kimi/translate/stream.ts b/src/providers/kimi/translate/stream.ts index e4dc228..692e87b 100644 --- a/src/providers/kimi/translate/stream.ts +++ b/src/providers/kimi/translate/stream.ts @@ -15,6 +15,7 @@ export function translateStream( messageId: string model: string log: Logger + requestStartTime?: number onFinish?: (finish: { stopReason: "end_turn" | "tool_use" | "max_tokens" usage?: Parameters[0] @@ -188,10 +189,15 @@ export function translateStream( }) } } finally { + const now = Date.now() + const timeToFirstChunkMs = opts.requestStartTime && firstChunkAt + ? firstChunkAt - opts.requestStartTime + : undefined opts.log.debug("stream summary", { chunkCount: stats.chunkCount, - firstChunkMs: firstChunkAt ? firstChunkAt - streamStart : undefined, - totalMs: Date.now() - streamStart, + timeToFirstChunkMs, + streamDurationMs: firstChunkAt ? now - firstChunkAt : undefined, + totalMs: opts.requestStartTime ? now - opts.requestStartTime : now - streamStart, reasoningChars, contentChars, toolCount, From 8d73543819773a051e39778d2d9fd5b055f72c45 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Tue, 21 Apr 2026 11:44:44 +0300 Subject: [PATCH 49/98] kimi: fix missing KimiUsage import in stream.ts Resolves a pre-existing tsc error that caused typecheck to fail. --- src/providers/kimi/translate/stream.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/kimi/translate/stream.ts b/src/providers/kimi/translate/stream.ts index 692e87b..a6b9d74 100644 --- a/src/providers/kimi/translate/stream.ts +++ b/src/providers/kimi/translate/stream.ts @@ -1,6 +1,6 @@ import { encodeSseEvent } from "../../../sse.ts" import type { Logger } from "../../../log.ts" -import { mapUsageToAnthropic, reduceUpstream, UpstreamStreamError } from "./reducer.ts" +import { mapUsageToAnthropic, reduceUpstream, UpstreamStreamError, type KimiUsage } from "./reducer.ts" import { makeThinkingSignature } from "./signature.ts" /** From 12923164e3dbb031b9ab65f83a6f22c2480e6117 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Tue, 21 Apr 2026 12:10:57 +0300 Subject: [PATCH 50/98] kimi: remove per-chunk debug logging from reducer The VERBOSE-mode upstream chunk logging produced 454 identical debug lines for a single request. Transition and aggregate stats are already captured by the stream summary and structured events, so per-chunk granularity added noise without actionable signal. Also removes the now-unused Logger parameter from reduceUpstream. --- src/providers/kimi/translate/accumulate.ts | 2 +- src/providers/kimi/translate/reducer.ts | 13 ------------- src/providers/kimi/translate/stream.ts | 2 +- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/src/providers/kimi/translate/accumulate.ts b/src/providers/kimi/translate/accumulate.ts index 5efef66..2f8eb3f 100644 --- a/src/providers/kimi/translate/accumulate.ts +++ b/src/providers/kimi/translate/accumulate.ts @@ -49,7 +49,7 @@ export async function accumulateResponse( let toolCount = 0 const stats = { chunkCount: 0 } - for await (const e of reduceUpstream(upstream, opts.log, stats)) { + for await (const e of reduceUpstream(upstream, stats)) { switch (e.kind) { case "thinking-start": blocks.set(e.index, { kind: "thinking", text: "" }) diff --git a/src/providers/kimi/translate/reducer.ts b/src/providers/kimi/translate/reducer.ts index a249ed5..cb62203 100644 --- a/src/providers/kimi/translate/reducer.ts +++ b/src/providers/kimi/translate/reducer.ts @@ -1,5 +1,4 @@ import { parseSseStream } from "../../../sse.ts" -import type { Logger } from "../../../log.ts" const VERBOSE = !!process.env.CCP_LOG_VERBOSE @@ -73,7 +72,6 @@ export interface ReducerStats { export async function* reduceUpstream( upstream: ReadableStream, - log: Logger, stats?: ReducerStats, ): AsyncGenerator { let nextBlockIndex = 0 @@ -118,17 +116,6 @@ export async function* reduceUpstream( continue } - if (VERBOSE) { - const choice = chunk.choices?.[0] - const d = choice?.delta - log.debug("upstream chunk", { - hasReasoning: typeof d?.reasoning_content === "string", - hasContent: typeof d?.content === "string" && d.content.length > 0, - toolCalls: d?.tool_calls?.length, - finish: choice?.finish_reason ?? null, - }) - } - if (stats) stats.chunkCount++ if (chunk.error) { diff --git a/src/providers/kimi/translate/stream.ts b/src/providers/kimi/translate/stream.ts index a6b9d74..c3739ea 100644 --- a/src/providers/kimi/translate/stream.ts +++ b/src/providers/kimi/translate/stream.ts @@ -64,7 +64,7 @@ export function translateStream( const stats = { chunkCount: 0 } try { - for await (const e of reduceUpstream(upstream, opts.log, stats)) { + for await (const e of reduceUpstream(upstream, stats)) { if (firstChunkAt === undefined) firstChunkAt = Date.now() switch (e.kind) { From af60bee5fc3e86be4ce2fbee898bf2ac86adfedc Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Tue, 21 Apr 2026 12:16:05 +0300 Subject: [PATCH 51/98] add request_completed log and handle abort errors gracefully Wrap streaming responses so we log a true end-to-end completion time after the body finishes, not just when headers are returned. AbortError (client disconnect) is now logged as info instead of surfacing as a 500 or stream translation error. Also guards controller.close() against double-close when the stream was already cancelled. --- src/providers/kimi/translate/stream.ts | 14 ++++++- src/server.ts | 52 +++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/providers/kimi/translate/stream.ts b/src/providers/kimi/translate/stream.ts index c3739ea..90c3b8f 100644 --- a/src/providers/kimi/translate/stream.ts +++ b/src/providers/kimi/translate/stream.ts @@ -3,6 +3,10 @@ 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 @@ -161,7 +165,9 @@ export function translateStream( } catch (err) { const activeToolNames = Array.from(activeTools.values(), (t) => t.name) const activeToolCalls = Array.from(activeTools.values()) - if (err instanceof UpstreamStreamError) { + 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, @@ -204,7 +210,11 @@ export function translateStream( stopReason: finishStopReason, usage: finishUsage, }) - controller.close() + try { + controller.close() + } catch { + // ignore if controller already errored or cancelled + } } }, }) diff --git a/src/server.ts b/src/server.ts index 2afe17f..54b56c6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -36,9 +36,15 @@ export function startServer(opts: ServeOptions): { stop: () => void; port: numbe }) try { const resp = await route(req, url, reqId) - rootLog.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) { + 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)) } @@ -136,6 +142,48 @@ async function parseJsonBody(req: Request): Promise } } +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) + } + }, + cancel() { + reader.cancel().catch(() => {}) + }, + }) + return new Response(stream, { + status: resp.status, + statusText: resp.statusText, + headers: resp.headers, + }) +} + function jsonError(status: number, type: string, message: string): Response { return new Response(JSON.stringify({ type: "error", error: { type, message } }), { status, From b603d22f159799f01b822c7be16fe0476f26ecdd Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Tue, 21 Apr 2026 15:02:55 +0300 Subject: [PATCH 52/98] kimi: add claude-sonnet-4-6 as accepted model alias Claude Code sends model: "claude-sonnet-4-6" when using fast mode with Sonnet 4.6. The proxy already resolved this alias to kimi-for-coding internally, but the model was missing from supportedModels, so the registry returned "unknown model" with 400 before the request ever reached the kimi provider. Add claude-sonnet-4-6 to kimiProvider.supportedModels so it appears in the startup banner and routes correctly to the kimi upstream. --- src/providers/kimi/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/kimi/index.ts b/src/providers/kimi/index.ts index 6f2635b..b6156b9 100644 --- a/src/providers/kimi/index.ts +++ b/src/providers/kimi/index.ts @@ -200,7 +200,7 @@ const cli: CliHandlers = { export const kimiProvider: Provider = { name: "kimi", - supportedModels: new Set(["kimi-for-coding", "kimi-k2.6", "k2.6"]), + supportedModels: new Set(["kimi-for-coding", "kimi-k2.6", "k2.6", "claude-sonnet-4-6"]), handleMessages, handleCountTokens, cli, From a809c245c671a65a1735e8818a1ae42d30dacf84 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Tue, 21 Apr 2026 22:47:05 +0300 Subject: [PATCH 53/98] kimi: add missing model aliases to supportedModels The Claude Code harness validates agent-requested models against Provider.supportedModels before sending requests to the provider. The Explore agent uses claude-haiku-4-5-20251001, which Kimi already mapped via ALIAS_TARGETS but did not expose in supportedModels, causing a 400 "Unknown model" error. Add all alias names from the allowlist to supportedModels so harness validation passes and they can be resolved to kimi-for-coding downstream. --- src/providers/kimi/index.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/providers/kimi/index.ts b/src/providers/kimi/index.ts index b6156b9..1112ee1 100644 --- a/src/providers/kimi/index.ts +++ b/src/providers/kimi/index.ts @@ -200,7 +200,18 @@ const cli: CliHandlers = { export const kimiProvider: Provider = { name: "kimi", - supportedModels: new Set(["kimi-for-coding", "kimi-k2.6", "k2.6", "claude-sonnet-4-6"]), + 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, From 5c4d5a9862a06fbdaae8377191454d822b8ead6d Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Wed, 22 Apr 2026 23:31:05 +0300 Subject: [PATCH 54/98] update changelog for v0.0.5 Adds changelog entry covering: - CCP_CODEX_MODEL and CCP_CODEX_EFFORT env vars for Codex overrides - New model aliases (claude-sonnet-4-6, etc.) - Improved request logging with usage metrics and TTFB - Graceful handling of client disconnections during streaming --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8d19c5..7dcca55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 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 From b5520ada0dda0421be485480497fa2742e77f309 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Wed, 22 Apr 2026 23:31:27 +0300 Subject: [PATCH 55/98] 0.0.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 66c1efb..1cd6371 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-proxy", - "version": "0.0.4", + "version": "0.0.5", "license": "MIT", "type": "module", "bin": { From e0ab95f12f7678de0007023aac431655ff9a8855 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Wed, 22 Apr 2026 23:37:26 +0300 Subject: [PATCH 56/98] readme: document toggling between proxy and direct anthropic Add a section describing a small wrapper script + flag-file toggle so users who still have an Anthropic subscription can switch between routing through the proxy (Codex/Kimi) and talking to Anthropic directly without editing settings.json each time. Addresses the use case from issue #4 of keeping the paid Anthropic plan usable alongside the proxy. --- README.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/README.md b/README.md index 54618cf..73c69cf 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,59 @@ Tradeoffs: - If you let the session grow too far, you may hit prompt-too-long failures instead of a graceful auto-compact. +## 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" + export ANTHROPIC_SMALL_FAST_MODEL="gpt-5.4-mini" + export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1" + export DISABLE_AUTO_COMPACT=1 +fi + +exec "$HOME/.local/bin/claude" "$@" +``` + +Adjust the exec path if the real `claude` binary lives elsewhere on your +system (e.g. `$(bun pm bin -g)/claude`, `$HOME/.claude/local/claude`). + +`claude-proxy-toggle` (anywhere on your `PATH`): + +```bash +#!/bin/bash +# Toggle claude-code-proxy routing for the claude wrapper. +set -euo pipefail + +flag="$HOME/.claude/claude-code-proxy-enabled" + +if [ -f "$flag" ]; then + rm "$flag" + echo "proxy: off" +else + mkdir -p "$(dirname "$flag")" + touch "$flag" + echo "proxy: on" +fi +``` + +Run `claude-proxy-toggle` to flip between routing through the proxy (Codex / +Kimi) and talking to Anthropic directly. New or continued `claude` sessions pick up +the change immediately; existing sessions keep whatever they started with. + ## Providers ### Codex (ChatGPT) From 66f957a74816462d86a99ec6424916f9538ec5f5 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Fri, 24 Apr 2026 20:04:51 +0300 Subject: [PATCH 57/98] support [1m] model suffix for larger context windows Claude Code uses a [1m] suffix convention on model names to signal a 1M-token context window instead of the 200K default. Without it, auto-compact fires too early for upstream models like GPT-5.4 (400K+) and Kimi (256K). Claude Code strips the suffix before sending requests, so the proxy never sees it. Added server-side normalization anyway as defense-in-depth in case a future version or a different client includes it. --- README.md | 65 ++++++++++++++++----------------------------------- src/cli.ts | 4 ++-- src/server.ts | 10 ++++++++ 3 files changed, 32 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 73c69cf..b699648 100644 --- a/README.md +++ b/README.md @@ -104,16 +104,16 @@ would 400 because no provider claims it, so set # Codex ANTHROPIC_BASE_URL=http://localhost:18765 \ ANTHROPIC_AUTH_TOKEN=unused \ -ANTHROPIC_MODEL=gpt-5.4 \ -ANTHROPIC_SMALL_FAST_MODEL=gpt-5.4-mini \ +ANTHROPIC_MODEL=gpt-5.4[1m] \ +ANTHROPIC_SMALL_FAST_MODEL=gpt-5.4-mini[1m] \ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ claude # Kimi ANTHROPIC_BASE_URL=http://localhost:18765 \ ANTHROPIC_AUTH_TOKEN=unused \ -ANTHROPIC_MODEL=kimi-for-coding \ -ANTHROPIC_SMALL_FAST_MODEL=kimi-for-coding \ +ANTHROPIC_MODEL=kimi-for-coding[1m] \ +ANTHROPIC_SMALL_FAST_MODEL=kimi-for-coding[1m] \ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ claude ``` @@ -125,52 +125,28 @@ 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", - "ANTHROPIC_SMALL_FAST_MODEL": "gpt-5.4-mini", + "ANTHROPIC_MODEL": "gpt-5.4[1m]", + "ANTHROPIC_SMALL_FAST_MODEL": "gpt-5.4-mini[1m]", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1 } } ``` -### 5. Optional: disable Claude Code auto-compact +### 5. Context window size -Claude Code decides auto-compaction locally based on the model context window it -thinks it has. If the upstream model supports a larger window than Claude Code -assumes, it may compact earlier than necessary. +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. -Disable only automatic compaction while keeping manual `/compact` available: +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. -```sh -DISABLE_AUTO_COMPACT=1 \ -ANTHROPIC_BASE_URL=http://localhost:18765 \ -ANTHROPIC_AUTH_TOKEN=unused \ -ANTHROPIC_MODEL=gpt-5.4 \ -ANTHROPIC_SMALL_FAST_MODEL=gpt-5.4-mini \ -CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ - claude -``` - -Or add it to `~/.claude/settings.json`: - -```json -{ - "env": { - "ANTHROPIC_BASE_URL": "http://127.0.0.1:18765", - "ANTHROPIC_AUTH_TOKEN": "unused", - "ANTHROPIC_MODEL": "gpt-5.4", - "ANTHROPIC_SMALL_FAST_MODEL": "gpt-5.4-mini", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1, - "DISABLE_AUTO_COMPACT": 1 - } -} -``` - -Tradeoffs: - -- Claude Code will stop proactively compacting before a turn. -- Manual `/compact` still works. -- If you let the session grow too far, you may hit prompt-too-long failures - instead of a graceful auto-compact. +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 @@ -190,10 +166,9 @@ the default. 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" - export ANTHROPIC_SMALL_FAST_MODEL="gpt-5.4-mini" + 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 DISABLE_AUTO_COMPACT=1 fi exec "$HOME/.local/bin/claude" "$@" diff --git a/src/cli.ts b/src/cli.ts index f873bf1..3705186 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -38,8 +38,8 @@ async function main() { 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" # or gpt-5.4, etc.`) - console.log(` export ANTHROPIC_SMALL_FAST_MODEL="kimi-for-coding" # background / title-gen`) + 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 } diff --git a/src/server.ts b/src/server.ts index 54b56c6..32c5c1b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -100,6 +100,15 @@ function buildCtx(req: Request, reqId: string, providerName: string): RequestCon } } +// 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, "") +} + function routeProvider(body: AnthropicRequest, reqId: string): Provider | Response { if (!body.model) { return jsonError( @@ -108,6 +117,7 @@ function routeProvider(body: AnthropicRequest, reqId: string): Provider | Respon `Missing "model" in request body. ${knownModelsMessage()}`, ) } + body.model = normalizeIncomingModel(body.model) const provider = providerForModel(body.model) if (!provider) { rootLog.warn("unknown model", { reqId, model: body.model }) From 4f4598fe4f953d767ade6e5056c5a93665b1951f Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sat, 25 Apr 2026 08:49:14 +0300 Subject: [PATCH 58/98] codex: add gpt-5.5 support GPT-5.5 was released April 23, 2026 with a 1M token context window. Add gpt-5.5 to the allowed model list and supported models set. Update the opus/claude-opus-4-7 alias to point to gpt-5.5 as it's the new flagship model, analogous to Claude Opus. References #6 --- src/providers/codex/index.ts | 2 +- src/providers/codex/translate/model-allowlist.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/providers/codex/index.ts b/src/providers/codex/index.ts index cf6f8d3..4d12659 100644 --- a/src/providers/codex/index.ts +++ b/src/providers/codex/index.ts @@ -362,7 +362,7 @@ const cli: CliHandlers = { export const codexProvider: Provider = { name: "codex", - supportedModels: new Set(["gpt-5.2", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini"]), + 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/providers/codex/translate/model-allowlist.ts b/src/providers/codex/translate/model-allowlist.ts index 7e048ac..bc9636a 100644 --- a/src/providers/codex/translate/model-allowlist.ts +++ b/src/providers/codex/translate/model-allowlist.ts @@ -3,6 +3,7 @@ export const ALLOWED_MODELS = new Set([ "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini", + "gpt-5.5", ]) export const MODEL_ALIASES = new Map([ @@ -11,8 +12,8 @@ export const MODEL_ALIASES = new Map([ ["claude-haiku-4-5-20251001", "gpt-5.4-mini"], ["sonnet", "gpt-5.4"], ["claude-sonnet-4-6", "gpt-5.4"], - ["opus", "gpt-5.4"], - ["claude-opus-4-7", "gpt-5.4"], + ["opus", "gpt-5.5"], + ["claude-opus-4-7", "gpt-5.5"], ]) export function resolveModel(model: string): string { From 7ae07b080236afd478e7ccc9029ae545a851a92f Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sat, 25 Apr 2026 08:58:03 +0300 Subject: [PATCH 59/98] changelog: add v0.0.6 entry Documents gpt-5.5 support, opus alias update, [1m] suffix handling, and README improvements since v0.0.5. --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dcca55..a40089a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 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 From 3671f801d059507393942f4bc107d19a14570a7f Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sat, 25 Apr 2026 08:58:16 +0300 Subject: [PATCH 60/98] 0.0.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1cd6371..02e07f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-proxy", - "version": "0.0.5", + "version": "0.0.6", "license": "MIT", "type": "module", "bin": { From a0220417cc23ba62cece44a2375760bca08b2ecf Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sat, 25 Apr 2026 09:43:47 +0300 Subject: [PATCH 61/98] replace security CLI keychain calls with bun:ffi The security CLI's -w flag passes OAuth tokens (access + refresh) as process arguments, making them visible in ps/proc_pidinfo for the lifetime of the subprocess. On macOS this window is short (~50ms) but any process table sampler can catch them. Replace all three operations (add/get/delete) with direct calls to the macOS Security framework via bun:ffi, using the SecKeychain* APIs. Tokens now never appear in argv and the keychain is accessed entirely in-process. Also removed the redundant chmod call after writeFile on the Linux path; writeFile's mode option applies the permissions atomically at fd creation. --- src/keychain.ts | 135 ++++++++++++++++++++++++ src/providers/codex/auth/token-store.ts | 51 +-------- src/providers/kimi/auth/token-store.ts | 57 +--------- 3 files changed, 144 insertions(+), 99 deletions(-) create mode 100644 src/keychain.ts diff --git a/src/keychain.ts b/src/keychain.ts new file mode 100644 index 0000000..1da28a9 --- /dev/null +++ b/src/keychain.ts @@ -0,0 +1,135 @@ +import { dlopen, FFIType, ptr, toArrayBuffer } from "bun:ffi" + +const errSecSuccess = 0 +const errSecItemNotFound = -25300 +const errSecDuplicateItem = -25299 + +function readPtr(buf: Uint8Array): number { + return Number(new DataView(buf.buffer, buf.byteOffset).getBigUint64(0, true)) +} + +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) + sym().SecKeychainItemDelete(itemRef) + sym().CFRelease(itemRef) +} + +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/providers/codex/auth/token-store.ts b/src/providers/codex/auth/token-store.ts index ae85d48..5f1b479 100644 --- a/src/providers/codex/auth/token-store.ts +++ b/src/providers/codex/auth/token-store.ts @@ -1,6 +1,7 @@ import { mkdir, readFile, writeFile, chmod, unlink, rename } from "node:fs/promises" import { dirname, join } from "node:path" import { homedir } from "node:os" +import { keychainGet, keychainSet, keychainDelete } from "../../../keychain.ts" export interface StoredAuth { access: string @@ -16,10 +17,7 @@ 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 - }) + const raw = keychainGet(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) if (!raw) return undefined return JSON.parse(raw) as StoredAuth } @@ -35,37 +33,19 @@ export async function loadAuth(): Promise { 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), - ]) + keychainSet(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, 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 - }, - ) + keychainDelete(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) return } @@ -79,26 +59,3 @@ export async function clearAuth(): Promise { 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/providers/kimi/auth/token-store.ts b/src/providers/kimi/auth/token-store.ts index cf7878e..72eaed2 100644 --- a/src/providers/kimi/auth/token-store.ts +++ b/src/providers/kimi/auth/token-store.ts @@ -1,6 +1,7 @@ -import { mkdir, readFile, writeFile, chmod, unlink, rename } from "node:fs/promises" +import { mkdir, readFile, writeFile, unlink, rename } from "node:fs/promises" import { dirname, join } from "node:path" import { homedir } from "node:os" +import { keychainGet, keychainSet, keychainDelete } from "../../../keychain.ts" export interface StoredAuth { access: string @@ -17,10 +18,7 @@ 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 - }) + const raw = keychainGet(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) if (!raw) return undefined return JSON.parse(raw) as StoredAuth } @@ -36,35 +34,19 @@ export async function loadAuth(): Promise { 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), - ]) + keychainSet(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, 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 {} 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 - }, - ) + keychainDelete(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) return } @@ -78,32 +60,3 @@ export async function clearAuth(): Promise { 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 } -} From 24a03941d20bfc74edde9706b4ec2ac94ab681e4 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sat, 25 Apr 2026 09:45:51 +0300 Subject: [PATCH 62/98] bind oauth callback server to 127.0.0.1 The local HTTP server that receives the OAuth redirect callback was binding to all interfaces (0.0.0.0). This exposes the unauthenticated callback endpoint to anyone on the same network during the ~5 minute auth window. State validation prevents completing the flow, but binding to loopback removes the exposure entirely. --- src/providers/codex/auth/pkce.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/codex/auth/pkce.ts b/src/providers/codex/auth/pkce.ts index 92eda7a..5dfcd82 100644 --- a/src/providers/codex/auth/pkce.ts +++ b/src/providers/codex/auth/pkce.ts @@ -109,7 +109,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) From 99cdee474669f095e2b92d9ed6fc592e5172e478 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sat, 25 Apr 2026 09:49:53 +0300 Subject: [PATCH 63/98] fix case-insensitive log redaction REDACT_KEYS used exact-case matching, so keys like AUTHORIZATION or X-Api-Key would bypass redaction. Fixed by lowercasing each object key before the Set lookup, and consolidating the duplicate entries (authorization/Authorization, ChatGPT-Account-Id/chatgpt-account-id) down to their lowercase forms since the lookup is now case-insensitive. --- src/log.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/log.ts b/src/log.ts index 0db75cd..a3fb059 100644 --- a/src/log.ts +++ b/src/log.ts @@ -6,7 +6,6 @@ import { homedir } from "node:os" const MAX_LOG_BYTES = 20 * 1024 * 1024 // 20 MiB const REDACT_KEYS = new Set([ "authorization", - "Authorization", "access", "access_token", "refresh", @@ -14,7 +13,6 @@ const REDACT_KEYS = new Set([ "id_token", "code", "code_verifier", - "ChatGPT-Account-Id", "chatgpt-account-id", "x-api-key", ]) @@ -76,7 +74,7 @@ function redact(value: unknown, depth = 0): unknown { 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) From f4df6084aaedcf7eb3ad3061438e3ff0c423c2b0 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sat, 25 Apr 2026 09:50:29 +0300 Subject: [PATCH 64/98] create log file with 0600 permissions createWriteStream without an explicit mode defaults to 0644 (world- readable). The log file can contain request metadata and verbose request/response bodies when CCP_LOG_VERBOSE is set, so it should only be readable by the owning user. --- src/log.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/log.ts b/src/log.ts index a3fb059..c5fec66 100644 --- a/src/log.ts +++ b/src/log.ts @@ -34,7 +34,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 } From 4f620d97a57c182d134e3ee0d258a1b90152120e Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sat, 25 Apr 2026 09:54:54 +0300 Subject: [PATCH 65/98] remove unused chmod import from token-store --- src/providers/codex/auth/token-store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/codex/auth/token-store.ts b/src/providers/codex/auth/token-store.ts index 5f1b479..17743b1 100644 --- a/src/providers/codex/auth/token-store.ts +++ b/src/providers/codex/auth/token-store.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile, chmod, unlink, rename } from "node:fs/promises" +import { mkdir, readFile, writeFile, unlink, rename } from "node:fs/promises" import { dirname, join } from "node:path" import { homedir } from "node:os" import { keychainGet, keychainSet, keychainDelete } from "../../../keychain.ts" From f0866013add7fcff57fb7e36847834f840408eee Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sat, 25 Apr 2026 10:17:02 +0300 Subject: [PATCH 66/98] pkce: fix modulo bias in verifier generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generateRandomString mapped random bytes mod 66 (the alphabet length). Since 256 is not divisible by 66, the first 58 characters were ~1.5x more likely than the last 8. Replaced with base64url-encoded random bytes — no alphabet mapping, no bias. --- src/providers/codex/auth/pkce.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/providers/codex/auth/pkce.ts b/src/providers/codex/auth/pkce.ts index 5dfcd82..c39991c 100644 --- a/src/providers/codex/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 = "" From 62f8857647eb11dcebb51d371cae12b7454ee6dd Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sat, 25 Apr 2026 10:17:02 +0300 Subject: [PATCH 67/98] strip upstream response body from token refresh errors refreshNow interpolated the raw upstream response body into the Error message, which ended up in proxy.log and stderr via String(err). The body could contain account metadata or token hints. Status code alone is sufficient for diagnostics. --- src/providers/codex/auth/manager.ts | 2 +- src/providers/kimi/auth/manager.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/providers/codex/auth/manager.ts b/src/providers/codex/auth/manager.ts index f157a67..3936b11 100644 --- a/src/providers/codex/auth/manager.ts +++ b/src/providers/codex/auth/manager.ts @@ -44,7 +44,7 @@ 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()}`) + if (!resp.ok) throw new Error(`Token refresh failed: ${resp.status}`) const tokens = (await resp.json()) as TokenResponse const accountId = extractAccountId(tokens) || current.accountId const next: StoredAuth = { diff --git a/src/providers/kimi/auth/manager.ts b/src/providers/kimi/auth/manager.ts index bd148b3..8e97278 100644 --- a/src/providers/kimi/auth/manager.ts +++ b/src/providers/kimi/auth/manager.ts @@ -119,15 +119,14 @@ async function refreshNow(current: StoredAuth): Promise { } if (!RETRYABLE_STATUSES.has(resp.status)) { - const text = await resp.text().catch(() => "") - throw new Error(`Token refresh failed: ${resp.status} ${text}`) + 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: ${lastErr}`) + throw new Error(`Token refresh failed after ${MAX_REFRESH_ATTEMPTS} attempts`) } function backoff(attempt: number): Promise { From 9dcdf322b93bdb175f2a94552b0ac793ab7ae547 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sat, 25 Apr 2026 10:17:02 +0300 Subject: [PATCH 68/98] server: strip content-encoding/length/transfer-encoding before forwarding streams wrapStreamResponse forwarded resp.headers verbatim. Bun decompresses response bodies transparently, so if an upstream sends Content-Encoding: gzip the downstream client would receive uncompressed bytes with a compression header, causing decode errors. content-length and transfer-encoding are similarly invalidated after re-streaming. --- src/server.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/server.ts b/src/server.ts index 32c5c1b..a0f21e1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -187,13 +187,25 @@ function wrapStreamResponse( 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: resp.headers, + 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 { return new Response(JSON.stringify({ type: "error", error: { type, message } }), { status, From c0a71279e60192dd0542d6f8c3255f255b1afd1b Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sat, 25 Apr 2026 10:17:02 +0300 Subject: [PATCH 69/98] server: redact query-string params in request logs url.search was logged as an opaque string, bypassing the key-based redactor. A client sending credentials via query params (e.g. ?access_token=...) would have them written to proxy.log and stderr. Export REDACT_KEYS from log.ts and apply it to each query param by key. --- src/log.ts | 2 +- src/server.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/log.ts b/src/log.ts index c5fec66..8462e1e 100644 --- a/src/log.ts +++ b/src/log.ts @@ -4,7 +4,7 @@ import { join } from "node:path" import { homedir } from "node:os" const MAX_LOG_BYTES = 20 * 1024 * 1024 // 20 MiB -const REDACT_KEYS = new Set([ +export const REDACT_KEYS = new Set([ "authorization", "access", "access_token", diff --git a/src/server.ts b/src/server.ts index a0f21e1..960847b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,4 @@ -import { createLogger, logDir } from "./log.ts" +import { createLogger, logDir, REDACT_KEYS } from "./log.ts" import type { AnthropicRequest } from "./anthropic/schema.ts" import type { Provider, RequestContext } from "./providers/types.ts" @@ -32,7 +32,7 @@ export function startServer(opts: ServeOptions): { stop: () => void; port: numbe reqId, method: req.method, path: url.pathname, - query: url.search, + ...(url.search ? { query: redactedQuery(url) } : {}), }) try { const resp = await route(req, url, reqId) From fda318b4f1c6d57a0466de81361f4d651a64656c Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sat, 25 Apr 2026 10:17:02 +0300 Subject: [PATCH 70/98] auth: create config directories with 0700 on Linux mkdir was called without a mode, leaving directory permissions subject to the process umask. With a default umask of 0022 the result is 0755, making auth.json world-traversable even though the file itself is 0600. On shared Unix hosts this is a meaningful exposure. --- src/providers/codex/auth/token-store.ts | 2 +- src/providers/kimi/auth/token-store.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/providers/codex/auth/token-store.ts b/src/providers/codex/auth/token-store.ts index 17743b1..889e15e 100644 --- a/src/providers/codex/auth/token-store.ts +++ b/src/providers/codex/auth/token-store.ts @@ -37,7 +37,7 @@ export async function saveAuth(auth: StoredAuth): Promise { return } - await mkdir(dirname(FILE), { recursive: true }) + await mkdir(dirname(FILE), { recursive: true, mode: 0o700 }) const tmp = `${FILE}.${process.pid}.${Date.now()}.tmp` await writeFile(tmp, JSON.stringify(auth, null, 2), { encoding: "utf8", mode: 0o600 }) await rename(tmp, FILE) diff --git a/src/providers/kimi/auth/token-store.ts b/src/providers/kimi/auth/token-store.ts index 72eaed2..0e75bf6 100644 --- a/src/providers/kimi/auth/token-store.ts +++ b/src/providers/kimi/auth/token-store.ts @@ -38,7 +38,7 @@ export async function saveAuth(auth: StoredAuth): Promise { return } - await mkdir(dirname(FILE), { recursive: true }) + await mkdir(dirname(FILE), { recursive: true, mode: 0o700 }) const tmp = `${FILE}.${process.pid}.${Date.now()}.tmp` await writeFile(tmp, JSON.stringify(auth, null, 2), { encoding: "utf8", mode: 0o600 }) await rename(tmp, FILE) From 8f0590e7de4b5811dc178921ac0cc0c7ecd717ae Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sat, 25 Apr 2026 10:17:02 +0300 Subject: [PATCH 71/98] log: fix rotation thrashing when rename fails maybeRotate closed the stream before attempting rename. If rename threw, the stream was left as undefined and the file still exceeded MAX_LOG_BYTES. Every subsequent log write would re-open the file, re-trigger maybeRotate, close the stream again, and fail the rename again. Fix: rename first, then close the stream; the surrounding catch block suppresses any rename error and leaves the stream intact. --- src/log.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/log.ts b/src/log.ts index 8462e1e..10f1d00 100644 --- a/src/log.ts +++ b/src/log.ts @@ -47,11 +47,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 { From 9ee3b82904fdccb85ba56d08e7064212e85d79e1 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sat, 25 Apr 2026 12:28:35 +0300 Subject: [PATCH 72/98] keychain: fix bun:ffi Pointer type errors readPtr returned a plain number, but Bun FFI types pointer arguments as the nominal Pointer type ({ __pointer__: null }). At runtime a pointer is just a number, so cast through unknown to satisfy the type checker without changing the runtime behavior. --- src/keychain.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/keychain.ts b/src/keychain.ts index 1da28a9..54b19d0 100644 --- a/src/keychain.ts +++ b/src/keychain.ts @@ -1,11 +1,11 @@ -import { dlopen, FFIType, ptr, toArrayBuffer } from "bun:ffi" +import { dlopen, FFIType, ptr, toArrayBuffer, type Pointer } from "bun:ffi" const errSecSuccess = 0 const errSecItemNotFound = -25300 const errSecDuplicateItem = -25299 -function readPtr(buf: Uint8Array): number { - return Number(new DataView(buf.buffer, buf.byteOffset).getBigUint64(0, true)) +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 From 7258ffeaa5d830d9ac00d40e3359779a648d20eb Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sat, 25 Apr 2026 12:36:49 +0300 Subject: [PATCH 73/98] 0.0.7 --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a40089a..f95334b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 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 diff --git a/package.json b/package.json index 02e07f3..ac08ae8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-proxy", - "version": "0.0.6", + "version": "0.0.7", "license": "MIT", "type": "module", "bin": { From 72878d1b93e42cd6109e93446524830f1eee284b Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 26 Apr 2026 10:03:42 +0300 Subject: [PATCH 74/98] readme: rename consult-llm-mcp to consult-llm in related projects The project was renamed from consult-llm-mcp to consult-llm. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b699648..41569c4 100644 --- a/README.md +++ b/README.md @@ -501,5 +501,5 @@ bun build ./src/cli.ts --compile --outfile ~/.local/bin/claude-code-proxy hunk-level git staging for AI agents - [workmux](https://github.com/raine/workmux): manage parallel AI coding tasks in separate git worktrees with tmux -- [consult-llm-mcp](https://github.com/raine/consult-llm-mcp): MCP server for - consulting external LLMs (Gemini, Codex, etc.) from inside Claude Code +- [consult-llm](https://github.com/raine/consult-llm): Consult other AI models + from your agent workflow From 6f4f58c0c2a8338c7ccea545aa23588adb85bebe Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 26 Apr 2026 18:45:10 +0300 Subject: [PATCH 75/98] sse: log warning when upstream emits invalid json Both reducers previously silenced JSON.parse errors and continued through the stream. If every upstream event was corrupt the translator would still emit a normal finish event, producing an apparent empty success instead of surfacing the upstream malfunction. Threading a logger through reduceUpstream lets the reducers warn (with a truncated preview) when a chunk fails to parse. This is purely diagnostic; the skip behavior is unchanged. --- src/providers/codex/translate/reducer.ts | 3 ++- src/providers/kimi/translate/accumulate.ts | 2 +- src/providers/kimi/translate/reducer.ts | 5 ++++- src/providers/kimi/translate/stream.ts | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/providers/codex/translate/reducer.ts b/src/providers/codex/translate/reducer.ts index 9140e68..81c246d 100644 --- a/src/providers/codex/translate/reducer.ts +++ b/src/providers/codex/translate/reducer.ts @@ -92,7 +92,8 @@ 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 || "" diff --git a/src/providers/kimi/translate/accumulate.ts b/src/providers/kimi/translate/accumulate.ts index 2f8eb3f..3572043 100644 --- a/src/providers/kimi/translate/accumulate.ts +++ b/src/providers/kimi/translate/accumulate.ts @@ -49,7 +49,7 @@ export async function accumulateResponse( let toolCount = 0 const stats = { chunkCount: 0 } - for await (const e of reduceUpstream(upstream, stats)) { + for await (const e of reduceUpstream(upstream, stats, opts.log)) { switch (e.kind) { case "thinking-start": blocks.set(e.index, { kind: "thinking", text: "" }) diff --git a/src/providers/kimi/translate/reducer.ts b/src/providers/kimi/translate/reducer.ts index cb62203..3d6eec8 100644 --- a/src/providers/kimi/translate/reducer.ts +++ b/src/providers/kimi/translate/reducer.ts @@ -1,4 +1,5 @@ import { parseSseStream } from "../../../sse.ts" +import type { Logger } from "../../../log.ts" const VERBOSE = !!process.env.CCP_LOG_VERBOSE @@ -73,6 +74,7 @@ export interface ReducerStats { export async function* reduceUpstream( upstream: ReadableStream, stats?: ReducerStats, + log?: Logger, ): AsyncGenerator { let nextBlockIndex = 0 let thinkingIndex: number | undefined @@ -112,7 +114,8 @@ export async function* reduceUpstream( let chunk: StreamChunk try { chunk = JSON.parse(data) as StreamChunk - } catch { + } catch (err) { + log?.warn("upstream sse: invalid json", { err: String(err), preview: data.slice(0, 200) }) continue } diff --git a/src/providers/kimi/translate/stream.ts b/src/providers/kimi/translate/stream.ts index 90c3b8f..b615329 100644 --- a/src/providers/kimi/translate/stream.ts +++ b/src/providers/kimi/translate/stream.ts @@ -68,7 +68,7 @@ export function translateStream( const stats = { chunkCount: 0 } try { - for await (const e of reduceUpstream(upstream, stats)) { + for await (const e of reduceUpstream(upstream, stats, opts.log)) { if (firstChunkAt === undefined) firstChunkAt = Date.now() switch (e.kind) { From 7f13e8819928bb6e8bdce275ed332e3fe2420e5c Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 26 Apr 2026 18:45:16 +0300 Subject: [PATCH 76/98] auth: validate oauth token responses before persisting Both providers cast resp.json() directly to TokenResponse and stored the result. A malformed response with a missing access_token, missing refresh_token, or non-numeric expires_in would be saved without complaint and only manifest later as Bearer undefined upstream calls or as a token whose expiry computes to NaN (refreshed every request). Validate the shape on refresh and on initial login so a bad response fails loudly with a clear message instead of poisoning the token store. --- src/providers/codex/auth/manager.ts | 15 ++++++++++++++- src/providers/kimi/auth/manager.ts | 15 ++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/providers/codex/auth/manager.ts b/src/providers/codex/auth/manager.ts index 3936b11..5325343 100644 --- a/src/providers/codex/auth/manager.ts +++ b/src/providers/codex/auth/manager.ts @@ -2,6 +2,17 @@ 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 @@ -45,7 +56,8 @@ async function refreshNow(current: StoredAuth): Promise { }).toString(), }) if (!resp.ok) throw new Error(`Token refresh failed: ${resp.status}`) - const tokens = (await resp.json()) as TokenResponse + 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/providers/kimi/auth/manager.ts b/src/providers/kimi/auth/manager.ts index 8e97278..2dfbbe6 100644 --- a/src/providers/kimi/auth/manager.ts +++ b/src/providers/kimi/auth/manager.ts @@ -10,6 +10,17 @@ 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 @@ -50,6 +61,7 @@ export async function forceRefresh(): Promise { } export async function persistInitialTokens(tokens: TokenResponse): Promise { + validateTokenResponse(tokens) const auth = tokensToStored(tokens) await saveAuth(auth) cached = auth @@ -97,7 +109,8 @@ async function refreshNow(current: StoredAuth): Promise { } if (resp.status === 200) { - const tokens = (await resp.json()) as TokenResponse + const tokens = await resp.json() + validateTokenResponse(tokens) const next: StoredAuth = { ...tokensToStored(tokens), refresh: tokens.refresh_token || current.refresh, From fe2f4eb8fbd6f2c4e5f5914df7c0c4be0586ed70 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 26 Apr 2026 18:45:21 +0300 Subject: [PATCH 77/98] keychain: throw when SecKeychainItemDelete fails keychainDelete previously discarded the status from SecKeychainItemDelete, so a failed deletion would still report success to callers. A failed logout could leave credentials in the keychain while telling the user everything was cleared. Surface non-success statuses as a thrown KeychainError so callers can see the failure. --- src/keychain.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/keychain.ts b/src/keychain.ts index 54b19d0..5a318c5 100644 --- a/src/keychain.ts +++ b/src/keychain.ts @@ -124,8 +124,9 @@ export function keychainDelete(service: string, account: string): void { if (s !== errSecSuccess) throw keychainError("find for delete", s) const itemRef = readPtr(itemBuf) - sym().SecKeychainItemDelete(itemRef) + const delStatus = sym().SecKeychainItemDelete(itemRef) as number sym().CFRelease(itemRef) + if (delStatus !== errSecSuccess) throw keychainError("delete", delStatus) } function keychainError(op: string, code: number): Error { From 504ac13b0b172cdb0cecf63d53427cb09774c028 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Thu, 30 Apr 2026 09:08:56 +0300 Subject: [PATCH 78/98] make originator and user-agent configurable via env vars Reading the originator header for codex requests and the user-agent header for both codex and kimi from env vars allows users to avoid fingerprinting by upstream providers. The originator header (claude-code-proxy by default) is a unique marker that can be used to identify proxy traffic. - CCP_ORIGINATOR: overrides the originator header sent to codex (default: claude-code-proxy) - CCP_USER_AGENT: overrides the user-agent header for both codex and kimi (default for codex: unset; default for kimi: KimiCLI/) --- src/providers/codex/client.ts | 6 ++++-- src/providers/kimi/auth/headers.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/providers/codex/client.ts b/src/providers/codex/client.ts index c98b248..42f867a 100644 --- a/src/providers/codex/client.ts +++ b/src/providers/codex/client.ts @@ -1,4 +1,4 @@ -import { CODEX_API_ENDPOINT, ORIGINATOR } from "./auth/constants.ts" +import { CODEX_API_ENDPOINT, ORIGINATOR as ORIGINATOR_DEFAULT } from "./auth/constants.ts" import { forceRefresh, getAuth } from "./auth/manager.ts" import type { Logger } from "../../log.ts" import type { RequestContext } from "../types.ts" @@ -62,9 +62,11 @@ async function doFetch( "Content-Type": "application/json", accept: "text/event-stream", authorization: `Bearer ${accessToken}`, - originator: ORIGINATOR, + originator: process.env.CCP_ORIGINATOR ?? ORIGINATOR_DEFAULT, "openai-beta": "responses=experimental", }) + const userAgent = process.env.CCP_USER_AGENT + if (userAgent) headers.set("User-Agent", userAgent) if (accountId) headers.set("ChatGPT-Account-Id", accountId) if (sessionId) { headers.set("session_id", sessionId) diff --git a/src/providers/kimi/auth/headers.ts b/src/providers/kimi/auth/headers.ts index bebdf60..62104d5 100644 --- a/src/providers/kimi/auth/headers.ts +++ b/src/providers/kimi/auth/headers.ts @@ -25,6 +25,6 @@ export async function commonHeaders(): Promise> { "X-Msh-Device-Model": asciiOnly(deviceModel()), "X-Msh-Os-Version": asciiOnly(release()), "X-Msh-Device-Id": deviceId, - "User-Agent": `KimiCLI/${KIMI_CLI_VERSION}`, + "User-Agent": process.env.CCP_USER_AGENT ?? `KimiCLI/${KIMI_CLI_VERSION}`, } } From 2cbdb72c91d1b977da284a818d716165105e644a Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Thu, 30 Apr 2026 09:09:46 +0300 Subject: [PATCH 79/98] make originator and user-agent configurable per provider provider-specific env vars take precedence over the general ones: - CCP_CODEX_ORIGINATOR > CCP_ORIGINATOR > claude-code-proxy - CCP_CODEX_USER_AGENT > CCP_USER_AGENT > unset - CCP_KIMI_USER_AGENT > CCP_USER_AGENT > KimiCLI/ --- src/providers/codex/client.ts | 4 ++-- src/providers/kimi/auth/headers.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/providers/codex/client.ts b/src/providers/codex/client.ts index 42f867a..52542fa 100644 --- a/src/providers/codex/client.ts +++ b/src/providers/codex/client.ts @@ -62,10 +62,10 @@ async function doFetch( "Content-Type": "application/json", accept: "text/event-stream", authorization: `Bearer ${accessToken}`, - originator: process.env.CCP_ORIGINATOR ?? ORIGINATOR_DEFAULT, + originator: process.env.CCP_CODEX_ORIGINATOR ?? process.env.CCP_ORIGINATOR ?? ORIGINATOR_DEFAULT, "openai-beta": "responses=experimental", }) - const userAgent = process.env.CCP_USER_AGENT + const userAgent = process.env.CCP_CODEX_USER_AGENT ?? process.env.CCP_USER_AGENT if (userAgent) headers.set("User-Agent", userAgent) if (accountId) headers.set("ChatGPT-Account-Id", accountId) if (sessionId) { diff --git a/src/providers/kimi/auth/headers.ts b/src/providers/kimi/auth/headers.ts index 62104d5..1439a51 100644 --- a/src/providers/kimi/auth/headers.ts +++ b/src/providers/kimi/auth/headers.ts @@ -25,6 +25,6 @@ export async function commonHeaders(): Promise> { "X-Msh-Device-Model": asciiOnly(deviceModel()), "X-Msh-Os-Version": asciiOnly(release()), "X-Msh-Device-Id": deviceId, - "User-Agent": process.env.CCP_USER_AGENT ?? `KimiCLI/${KIMI_CLI_VERSION}`, + "User-Agent": process.env.CCP_KIMI_USER_AGENT ?? process.env.CCP_USER_AGENT ?? `KimiCLI/${KIMI_CLI_VERSION}`, } } From 2e585cf88b01ce1d4516140d682f4ee5539f4914 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Thu, 30 Apr 2026 09:10:17 +0300 Subject: [PATCH 80/98] readme: document originator and user-agent env vars --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 41569c4..4b3bcb4 100644 --- a/README.md +++ b/README.md @@ -439,6 +439,11 @@ Settings are environment variables on the proxy process, not a config file. | `KIMI_BASE_URL` | `https://api.kimi.com/coding/v1` | Override Kimi's API base URL | | `CCP_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`| unset | Force all Codex requests to this reasoning effort (`none`, `low`, `medium`, `high`, `xhigh`) | +| `CCP_CODEX_ORIGINATOR` | `claude-code-proxy` | Override the `originator` header sent to Codex | +| `CCP_CODEX_USER_AGENT` | unset | Override the `User-Agent` header sent to Codex (not sent by default) | +| `CCP_KIMI_USER_AGENT` | `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` | ### Files From 8f126fd9cafa6e23f10e1545231affcf0496a60e Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Thu, 30 Apr 2026 09:12:08 +0300 Subject: [PATCH 81/98] set default user-agent for codex to claude-code-proxy/ uses BUILD_VERSION compile-time define with "dev" fallback, same pattern as cli.ts. --- README.md | 30 +++++++++++++++--------------- src/providers/codex/client.ts | 4 +++- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 4b3bcb4..5f21c55 100644 --- a/README.md +++ b/README.md @@ -429,21 +429,21 @@ The proxy speaks enough of the Anthropic API for Claude Code: Settings are environment variables on the proxy process, not a config file. -| 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 | -| `KIMI_OAUTH_HOST` | `https://auth.kimi.com` | Override Kimi's OAuth host (debugging only) | -| `KIMI_BASE_URL` | `https://api.kimi.com/coding/v1` | Override Kimi's API base URL | -| `CCP_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`| unset | Force all Codex requests to this reasoning effort (`none`, `low`, `medium`, `high`, `xhigh`) | -| `CCP_CODEX_ORIGINATOR` | `claude-code-proxy` | Override the `originator` header sent to Codex | -| `CCP_CODEX_USER_AGENT` | unset | Override the `User-Agent` header sent to Codex (not sent by default) | -| `CCP_KIMI_USER_AGENT` | `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` | +| 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 | +| `KIMI_OAUTH_HOST` | `https://auth.kimi.com` | Override Kimi's OAuth host (debugging only) | +| `KIMI_BASE_URL` | `https://api.kimi.com/coding/v1` | Override Kimi's API base URL | +| `CCP_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` | unset | Force all Codex requests to this reasoning effort (`none`, `low`, `medium`, `high`, `xhigh`) | +| `CCP_CODEX_ORIGINATOR` | `claude-code-proxy` | Override the `originator` header sent to Codex | +| `CCP_CODEX_USER_AGENT` | `claude-code-proxy/` | Override the `User-Agent` header sent to Codex | +| `CCP_KIMI_USER_AGENT` | `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` | ### Files diff --git a/src/providers/codex/client.ts b/src/providers/codex/client.ts index 52542fa..6fbe767 100644 --- a/src/providers/codex/client.ts +++ b/src/providers/codex/client.ts @@ -1,4 +1,6 @@ import { CODEX_API_ENDPOINT, ORIGINATOR as ORIGINATOR_DEFAULT } from "./auth/constants.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" @@ -65,7 +67,7 @@ async function doFetch( originator: process.env.CCP_CODEX_ORIGINATOR ?? process.env.CCP_ORIGINATOR ?? ORIGINATOR_DEFAULT, "openai-beta": "responses=experimental", }) - const userAgent = process.env.CCP_CODEX_USER_AGENT ?? process.env.CCP_USER_AGENT + const userAgent = process.env.CCP_CODEX_USER_AGENT ?? process.env.CCP_USER_AGENT ?? `claude-code-proxy/${PROXY_VERSION}` if (userAgent) headers.set("User-Agent", userAgent) if (accountId) headers.set("ChatGPT-Account-Id", accountId) if (sessionId) { From e83734f2fc8de65f115049b5cad94b607704c27e Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Thu, 30 Apr 2026 09:14:40 +0300 Subject: [PATCH 82/98] strip v prefix from build version in release workflow the git tag is v0.0.7 but the version string should be 0.0.7. the homebrew formula already strips it, but the BUILD_VERSION define used by --version and the default user-agent was including the v. --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index adb780f..f095b9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,7 @@ 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: | From 1956d7fe3f7f2ec871060e7f10d534b96a503308 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Thu, 30 Apr 2026 09:10:33 +0300 Subject: [PATCH 83/98] add exponential backoff retry on upstream 429 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Codex or Kimi upstream returns 429, retry up to 3 times with exponential backoff (2s, 4s, 8s, capped at 30s) before propagating the 429 to the client. Honor the upstream Retry-After header (numeric seconds or HTTP-date), but if it exceeds the 30s budget, give up immediately rather than capping silently. Shared logic lives in src/providers/retry.ts so both providers stay in sync. Sleep is cancellable via the request signal so a client disconnect doesn't keep us waiting. Mid-stream rate-limit errors (UpstreamStreamError in the SSE accumulator) are intentionally left untouched — by then the HTTP response has already started. --- src/providers/codex/client.ts | 16 ++++ src/providers/kimi/client.ts | 16 ++++ src/providers/retry.test.ts | 162 ++++++++++++++++++++++++++++++++++ src/providers/retry.ts | 85 ++++++++++++++++++ 4 files changed, 279 insertions(+) create mode 100644 src/providers/retry.test.ts create mode 100644 src/providers/retry.ts diff --git a/src/providers/codex/client.ts b/src/providers/codex/client.ts index 6fbe767..0f2cee0 100644 --- a/src/providers/codex/client.ts +++ b/src/providers/codex/client.ts @@ -5,6 +5,7 @@ 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 @@ -17,6 +18,21 @@ export async function postCodex( 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, log, ctx.signal, ctx.sessionId) diff --git a/src/providers/kimi/client.ts b/src/providers/kimi/client.ts index 6486a68..07d27b7 100644 --- a/src/providers/kimi/client.ts +++ b/src/providers/kimi/client.ts @@ -4,6 +4,7 @@ import { forceRefresh, getAuth, KimiAuthUnauthorizedError } from "./auth/manager 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 @@ -29,6 +30,21 @@ export async function postKimi( 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) diff --git a/src/providers/retry.test.ts b/src/providers/retry.test.ts new file mode 100644 index 0000000..4318fd8 --- /dev/null +++ b/src/providers/retry.test.ts @@ -0,0 +1,162 @@ +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 exponential backoff without retry-after", () => { + expect(computeBackoffDelay(0).waitMs).toBe(RETRY_INITIAL_DELAY_MS) + expect(computeBackoffDelay(1).waitMs).toBe(RETRY_INITIAL_DELAY_MS * 2) + expect(computeBackoffDelay(2).waitMs).toBe(RETRY_INITIAL_DELAY_MS * 4) + }) + + it("caps exponential backoff at max delay", () => { + expect(computeBackoffDelay(20).waitMs).toBe(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", () => { + expect(computeBackoffDelay(0, "1abc").waitMs).toBe(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..b42cd2b --- /dev/null +++ b/src/providers/retry.ts @@ -0,0 +1,85 @@ +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) + return { waitMs: Math.min(exp, RETRY_MAX_DELAY_MS), 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) + } + } +} From 7a37664f55ce486cf52cf4a524e3a1bb9b57dd41 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Thu, 30 Apr 2026 09:12:26 +0300 Subject: [PATCH 84/98] retry: add equal jitter to exponential backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without jitter, concurrent requests hitting the same upstream rate limit would all retry at exactly 2s/4s/8s and recreate the spike. Apply equal jitter (random in [cap/2, cap]) to the exponential fallback only — explicit Retry-After delays are still honored as-is. --- src/providers/retry.test.ts | 20 ++++++++++++++------ src/providers/retry.ts | 6 +++++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/providers/retry.test.ts b/src/providers/retry.test.ts index 4318fd8..b7178da 100644 --- a/src/providers/retry.test.ts +++ b/src/providers/retry.test.ts @@ -17,14 +17,20 @@ const silentLog = { } describe("computeBackoffDelay", () => { - it("uses exponential backoff without retry-after", () => { - expect(computeBackoffDelay(0).waitMs).toBe(RETRY_INITIAL_DELAY_MS) - expect(computeBackoffDelay(1).waitMs).toBe(RETRY_INITIAL_DELAY_MS * 2) - expect(computeBackoffDelay(2).waitMs).toBe(RETRY_INITIAL_DELAY_MS * 4) + 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", () => { - expect(computeBackoffDelay(20).waitMs).toBe(RETRY_MAX_DELAY_MS) + 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", () => { @@ -38,7 +44,9 @@ describe("computeBackoffDelay", () => { }) it("rejects non-numeric retry-after garbage", () => { - expect(computeBackoffDelay(0, "1abc").waitMs).toBe(RETRY_INITIAL_DELAY_MS) + 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", () => { diff --git a/src/providers/retry.ts b/src/providers/retry.ts index b42cd2b..99a9abe 100644 --- a/src/providers/retry.ts +++ b/src/providers/retry.ts @@ -27,7 +27,11 @@ export function computeBackoffDelay(attempt: number, retryAfter?: string): Backo } } const exp = RETRY_INITIAL_DELAY_MS * Math.pow(RETRY_BACKOFF_FACTOR, attempt) - return { waitMs: Math.min(exp, RETRY_MAX_DELAY_MS), exceedsBudget: false } + 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 { From 419661362dd658bb827341f2b33328d0b7ec3d09 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Thu, 30 Apr 2026 09:37:50 +0300 Subject: [PATCH 85/98] add config.json as alternative to env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings can now come from a JSON file at: macOS: ~/.config/claude-code-proxy/config.json (deliberately not ~/Library — matches where auth tokens live on macOS) other: ${XDG_CONFIG_HOME:-$HOME/.config}/claude-code-proxy/config.json Precedence per setting is env var > config file > built-in default. All existing env-var-only setups keep working unchanged, including the fallback chains (CCP_CODEX_USER_AGENT > CCP_USER_AGENT > ...). Implementation notes: - src/paths.ts centralizes XDG-aware directory resolution. macOS opts out of XDG_CONFIG_HOME (because tokens have always been at ~/.config/claude-code-proxy on macOS) but still honors XDG_STATE_HOME for the log dir, preserving the prior log.ts behavior. - src/config.ts loads config.json once (lazy, cached). A malformed file warns on stderr and falls back to defaults; per-key type mismatches warn and skip the bad value without affecting the rest. - Token stores and the kimi device_id file now resolve via configDir(). To avoid logging existing users out when XDG_CONFIG_HOME is set, both loadAuth() and getDeviceId() also try the legacy ~/.config/claude-code-proxy//... path as a read-only fallback. - Empty-string env semantics are preserved exactly: legacy `??` callers treat "" as a real value; CCP_CODEX_MODEL and CCP_CODEX_EFFORT continue to treat "" as unset (the long-standing escape hatch). - KIMI_OAUTH_HOST and KIMI_BASE_URL move from import-time constants to thin getter functions so config.json values apply at runtime. README's Configuration section now documents the new file alongside the env-var table and lists the JSON key for each setting. --- README.md | 85 +++++-- src/cli.ts | 5 +- src/config.test.ts | 176 ++++++++++++++ src/config.ts | 230 ++++++++++++++++++ src/log.ts | 14 +- src/paths.test.ts | 42 ++++ src/paths.ts | 46 ++++ src/providers/codex/auth/token-store.ts | 40 ++- src/providers/codex/client.ts | 5 +- src/providers/codex/index.ts | 19 +- .../codex/translate/model-allowlist.test.ts | 56 +++++ .../codex/translate/model-allowlist.ts | 17 +- src/providers/codex/translate/reducer.ts | 5 +- src/providers/codex/translate/request.ts | 5 +- src/providers/kimi/auth/constants.ts | 12 +- src/providers/kimi/auth/device-id.ts | 30 ++- src/providers/kimi/auth/headers.ts | 3 +- src/providers/kimi/auth/login.ts | 6 +- src/providers/kimi/auth/manager.ts | 4 +- src/providers/kimi/auth/token-store.ts | 40 ++- src/providers/kimi/client.ts | 6 +- src/providers/kimi/index.ts | 7 +- src/providers/kimi/translate/reducer.ts | 2 - 23 files changed, 742 insertions(+), 113 deletions(-) create mode 100644 src/config.test.ts create mode 100644 src/config.ts create mode 100644 src/paths.test.ts create mode 100644 src/paths.ts create mode 100644 src/providers/codex/translate/model-allowlist.test.ts diff --git a/README.md b/README.md index 5f21c55..2bd2e31 100644 --- a/README.md +++ b/README.md @@ -427,35 +427,74 @@ The proxy speaks enough of the Anthropic API for Claude Code: ## Configuration -Settings are environment variables on the proxy process, not a config file. - -| 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 | -| `KIMI_OAUTH_HOST` | `https://auth.kimi.com` | Override Kimi's OAuth host (debugging only) | -| `KIMI_BASE_URL` | `https://api.kimi.com/coding/v1` | Override Kimi's API base URL | -| `CCP_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` | unset | Force all Codex requests to this reasoning effort (`none`, `low`, `medium`, `high`, `xhigh`) | -| `CCP_CODEX_ORIGINATOR` | `claude-code-proxy` | Override the `originator` header sent to Codex | -| `CCP_CODEX_USER_AGENT` | `claude-code-proxy/` | Override the `User-Agent` header sent to Codex | -| `CCP_KIMI_USER_AGENT` | `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` | +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" + }, + "kimi": { + "userAgent": "KimiCLI/1.37.0", + "oauthHost": "https://auth.kimi.com", + "baseUrl": "https://api.kimi.com/coding/v1" + }, + "log": { + "stderr": false, + "verbose": false + } +} +``` + +| 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 | +| `KIMI_OAUTH_HOST` | `kimi.oauthHost` | `https://auth.kimi.com` | Override Kimi's OAuth host (debugging only) | +| `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 (`none`, `low`, `medium`, `high`, `xhigh`) | +| `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-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/codex/auth.json` — codex tokens (non-macOS; macOS - uses Keychain under service `claude-code-proxy.codex`). -- `~/.config/claude-code-proxy/kimi/auth.json` — kimi tokens (non-macOS; macOS - uses Keychain under service `claude-code-proxy.kimi`). -- `~/.config/claude-code-proxy/kimi/device_id` — persistent UUID bound into the - Kimi JWT at login. Reused for the lifetime of the install. +- `~/.config/claude-code-proxy/config.json` (macOS) or + `${XDG_CONFIG_HOME:-$HOME/.config}/claude-code-proxy/config.json` — optional + configuration file (see table above). +- `${XDG_CONFIG_HOME:-$HOME/.config}/claude-code-proxy/codex/auth.json` — codex + tokens (non-macOS; macOS uses Keychain under service + `claude-code-proxy.codex`). Pre-existing files at the legacy path + `~/.config/claude-code-proxy/codex/auth.json` are read as a fallback so + existing logins survive setting `XDG_CONFIG_HOME`. +- `${XDG_CONFIG_HOME:-$HOME/.config}/claude-code-proxy/kimi/auth.json` — kimi + tokens (non-macOS; macOS uses Keychain under service + `claude-code-proxy.kimi`). Same legacy-path fallback as above. +- `${XDG_CONFIG_HOME:-$HOME/.config}/claude-code-proxy/kimi/device_id` — + persistent UUID bound into the Kimi JWT at login. Reused for the lifetime + of the install. ## Limitations diff --git a/src/cli.ts b/src/cli.ts index 3705186..db10f85 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { startServer } from "./server.ts" import { createLogger, logDir } from "./log.ts" +import { port as configPort } from "./config.ts" import { allProviders, allSupportedModels, @@ -24,7 +25,7 @@ async function main() { } if (!first || first === "serve") { - const port = Number(process.env.PORT ?? 18765) + const port = configPort() startServer({ port }) console.log(`Proxy listening on http://localhost:${port}`) console.log(`Logs: ${logDir()}/proxy.log`) @@ -89,7 +90,7 @@ function usageAndExit(): never { .map((m) => `${m.model} (${m.provider})`) .join(", ") console.log(`Usage: - claude-code-proxy serve Run proxy (PORT env, default 18765) + 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 diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..3ab96ba --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,176 @@ +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, + 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(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("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_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 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 KIMI_OAUTH_HOST env is a valid value (legacy ?? semantics)", () => { + setEnv({ 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..03edea5 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,230 @@ +import { readFileSync } from "node:fs" +import { join } from "node:path" +import { configDir } from "./paths.ts" + +// Config precedence per setting: +// provider-specific env > generic-fallback env (where one exists) > config.json > default +// +// The config file is parsed once on first access and cached. Empty strings +// from either env or the file are treated as "unset" so they fall through +// to the next layer (matches existing CCP_CODEX_MODEL behavior). + +export interface FileConfig { + port?: number + codex?: { + originator?: string + userAgent?: string + model?: string + effort?: string + } + kimi?: { + userAgent?: string + oauthHost?: string + baseUrl?: string + } + log?: { + stderr?: boolean + verbose?: boolean + } +} + +interface LoadedConfig { + file: FileConfig + env: NodeJS.ProcessEnv +} + +interface LoadOptions { + configPath?: string + env?: NodeJS.ProcessEnv + forceReload?: boolean +} + +let cached: LoadedConfig | undefined + +// Most env-var consumers historically used `??` semantics — empty string is +// a real value that wins. Only CCP_CODEX_MODEL and CCP_CODEX_EFFORT had +// explicit empty-string-as-unset handling in the legacy code, so only those +// getters use emptyOrUnset. +function emptyOrUnset(v: string | undefined): string | undefined { + return v === undefined || v === "" ? undefined : v +} + +function warnInvalid(key: string, expected: string, got: unknown): void { + process.stderr.write( + `claude-code-proxy: ignoring config.json key "${key}": expected ${expected}, got ${typeof got}\n`, + ) +} + +function validate(raw: unknown): FileConfig { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {} + const r = raw as Record + const out: FileConfig = {} + + if (r.port !== undefined) { + if (typeof r.port === "number" && Number.isFinite(r.port)) out.port = r.port + else warnInvalid("port", "number", r.port) + } + + const validateStringSection = ( + key: K, + keys: ReadonlyArray>, + types: Record, + ): NonNullable | undefined => { + if (r[key] === undefined) return undefined + const sec = r[key] + if (!sec || typeof sec !== "object" || Array.isArray(sec)) { + warnInvalid(key, "object", sec) + return undefined + } + const acc: Record = {} + for (const k of keys) { + const v = (sec as Record)[k as string] + if (v === undefined) continue + const expected = types[k as string] + if (expected && typeof v === expected) acc[k as string] = v + else warnInvalid(`${key}.${String(k)}`, expected ?? "unknown", v) + } + return acc as NonNullable + } + + const codex = validateStringSection("codex", ["originator", "userAgent", "model", "effort"], { + originator: "string", + userAgent: "string", + model: "string", + effort: "string", + }) + if (codex) out.codex = codex + + const kimi = validateStringSection("kimi", ["userAgent", "oauthHost", "baseUrl"], { + userAgent: "string", + oauthHost: "string", + baseUrl: "string", + }) + if (kimi) out.kimi = kimi + + const log = validateStringSection("log", ["stderr", "verbose"], { + stderr: "boolean", + verbose: "boolean", + }) + if (log) out.log = log + + return out +} + +export function loadConfig(opts: LoadOptions = {}): LoadedConfig { + if (cached && !opts.forceReload && !opts.configPath && !opts.env) { + return cached + } + const env = opts.env ?? process.env + const path = opts.configPath ?? join(configDir(), "config.json") + let file: FileConfig = {} + try { + const raw = readFileSync(path, "utf8") + try { + file = validate(JSON.parse(raw)) + } catch (err) { + process.stderr.write( + `claude-code-proxy: failed to parse ${path} (${(err as Error).message}); using defaults\n`, + ) + } + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + process.stderr.write( + `claude-code-proxy: failed to read ${path} (${(err as Error).message}); using defaults\n`, + ) + } + } + const result: LoadedConfig = { file, env } + // Always update the cache when forceReload is requested (lets tests + // install a custom env+path under the same singleton other modules read). + if (opts.forceReload || (!opts.configPath && !opts.env)) cached = result + return result +} + +export function getConfig(): LoadedConfig { + return cached ?? loadConfig() +} + +// Per-setting getters. Each encodes its precedence chain explicitly. + +// Preserves legacy `Number(process.env.PORT ?? 18765)` semantics: an env-set +// PORT of empty string parsed to NaN under the old code (effectively broken), +// so we treat it as unset rather than returning NaN. +export function port(): number { + const c = getConfig() + const envPort = c.env.PORT + if (envPort !== undefined && envPort !== "") { + const n = Number(envPort) + if (Number.isFinite(n)) return n + } + return c.file.port ?? 18765 +} + +export function codexOriginator(defaultValue: string): string { + const c = getConfig() + return ( + c.env.CCP_CODEX_ORIGINATOR ?? + c.env.CCP_ORIGINATOR ?? + c.file.codex?.originator ?? + defaultValue + ) +} + +export function codexUserAgent(defaultValue: string): string { + const c = getConfig() + return ( + c.env.CCP_CODEX_USER_AGENT ?? + c.env.CCP_USER_AGENT ?? + c.file.codex?.userAgent ?? + defaultValue + ) +} + +// Returns undefined when neither env nor file specifies a value. Empty +// string in env is intentionally treated as "unset" (preserves the +// long-standing CCP_CODEX_MODEL escape hatch). +export function codexModel(): string | undefined { + const c = getConfig() + return emptyOrUnset(c.env.CCP_CODEX_MODEL) ?? emptyOrUnset(c.file.codex?.model) +} + +export function codexEffort(): string | undefined { + const c = getConfig() + return emptyOrUnset(c.env.CCP_CODEX_EFFORT) ?? emptyOrUnset(c.file.codex?.effort) +} + +export function 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.KIMI_OAUTH_HOST ?? c.file.kimi?.oauthHost ?? "https://auth.kimi.com" +} + +export function kimiBaseUrl(): string { + const c = getConfig() + return c.env.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/log.ts b/src/log.ts index 10f1d00..169ef20 100644 --- a/src/log.ts +++ b/src/log.ts @@ -1,7 +1,8 @@ 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 export const REDACT_KEYS = new Set([ @@ -17,11 +18,6 @@ export const REDACT_KEYS = new Set([ "x-api-key", ]) -function stateDir(): string { - const base = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state") - return join(base, "claude-code-proxy") -} - export function logDir(): string { return stateDir() } @@ -61,13 +57,11 @@ 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 @@ -100,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") } } diff --git a/src/paths.test.ts b/src/paths.test.ts new file mode 100644 index 0000000..ca3c55a --- /dev/null +++ b/src/paths.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "bun:test" +import { resolveConfigDir, resolveStateDir } from "./paths.ts" + +describe("resolveConfigDir", () => { + it("uses ~/.config on darwin even when XDG_CONFIG_HOME is set", () => { + expect( + resolveConfigDir({ platform: "darwin", env: { XDG_CONFIG_HOME: "/x" }, home: "/home/u" }), + ).toBe("/home/u/.config/claude-code-proxy") + }) + + it("honors XDG_CONFIG_HOME on linux", () => { + expect( + resolveConfigDir({ platform: "linux", env: { XDG_CONFIG_HOME: "/x" }, home: "/home/u" }), + ).toBe("/x/claude-code-proxy") + }) + + it("falls back to $HOME/.config on linux without XDG_CONFIG_HOME", () => { + expect(resolveConfigDir({ platform: "linux", env: {}, home: "/home/u" })).toBe( + "/home/u/.config/claude-code-proxy", + ) + }) +}) + +describe("resolveStateDir", () => { + it("honors XDG_STATE_HOME on darwin (preserves pre-existing log.ts behavior)", () => { + expect( + resolveStateDir({ platform: "darwin", env: { XDG_STATE_HOME: "/x" }, home: "/home/u" }), + ).toBe("/x/claude-code-proxy") + }) + + it("falls back to $HOME/.local/state on darwin without XDG_STATE_HOME", () => { + expect(resolveStateDir({ platform: "darwin", env: {}, home: "/home/u" })).toBe( + "/home/u/.local/state/claude-code-proxy", + ) + }) + + it("honors XDG_STATE_HOME on linux", () => { + expect( + resolveStateDir({ platform: "linux", env: { XDG_STATE_HOME: "/x" }, home: "/home/u" }), + ).toBe("/x/claude-code-proxy") + }) +}) diff --git a/src/paths.ts b/src/paths.ts new file mode 100644 index 0000000..3d40164 --- /dev/null +++ b/src/paths.ts @@ -0,0 +1,46 @@ +import { homedir } from "node:os" +import { join } from "node:path" + +export interface DirResolverEnv { + platform: NodeJS.Platform + env: NodeJS.ProcessEnv + home: string +} + +function defaults(): DirResolverEnv { + return { platform: process.platform, env: process.env, home: homedir() } +} + +// macOS deliberately uses ~/.config/ rather than honoring XDG_CONFIG_HOME +// (which would redirect to ~/Library/Application Support). This matches where +// auth tokens have always been stored on macOS. +export function resolveConfigDir(deps: DirResolverEnv): string { + if (deps.platform === "darwin") { + return join(deps.home, ".config", "claude-code-proxy") + } + const base = deps.env.XDG_CONFIG_HOME || join(deps.home, ".config") + return join(base, "claude-code-proxy") +} + +// XDG_STATE_HOME is honored on every platform (including macOS) — that's the +// pre-config.json behavior of log.ts and is documented in the README. +export function resolveStateDir(deps: DirResolverEnv): string { + const base = deps.env.XDG_STATE_HOME || join(deps.home, ".local", "state") + return join(base, "claude-code-proxy") +} + +// Legacy (pre-config.json) auth/device-id path. Always ~/.config regardless +// of XDG_CONFIG_HOME — this is the directory token stores hardcoded before +// configDir() existed. Used as a read-only fallback so existing logins keep +// working after upgrade. +export function legacyConfigDir(deps: DirResolverEnv = defaults()): string { + return join(deps.home, ".config", "claude-code-proxy") +} + +export function configDir(): string { + return resolveConfigDir(defaults()) +} + +export function stateDir(): string { + return resolveStateDir(defaults()) +} diff --git a/src/providers/codex/auth/token-store.ts b/src/providers/codex/auth/token-store.ts index 889e15e..542c7b4 100644 --- a/src/providers/codex/auth/token-store.ts +++ b/src/providers/codex/auth/token-store.ts @@ -1,7 +1,7 @@ import { mkdir, readFile, writeFile, unlink, rename } from "node:fs/promises" import { dirname, join } from "node:path" -import { homedir } from "node:os" import { keychainGet, keychainSet, keychainDelete } from "../../../keychain.ts" +import { configDir, legacyConfigDir } from "../../../paths.ts" export interface StoredAuth { access: string @@ -10,8 +10,12 @@ export interface StoredAuth { accountId?: string } -const DIR = join(homedir(), ".config", "claude-code-proxy", "codex") -const FILE = join(DIR, "auth.json") +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" @@ -22,8 +26,17 @@ export async function loadAuth(): Promise { 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(FILE, "utf8") + const raw = await readFile(legacy, "utf8") return JSON.parse(raw) as StoredAuth } catch (err: any) { if (err?.code === "ENOENT") return undefined @@ -37,10 +50,11 @@ export async function saveAuth(auth: StoredAuth): Promise { return } - await mkdir(dirname(FILE), { recursive: true, mode: 0o700 }) - const tmp = `${FILE}.${process.pid}.${Date.now()}.tmp` + 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, FILE) + await rename(tmp, path) } export async function clearAuth(): Promise { @@ -49,13 +63,15 @@ export async function clearAuth(): Promise { return } - try { - await unlink(FILE) - } catch (err: any) { - if (err?.code !== "ENOENT") throw err + 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 + return process.platform === "darwin" ? "macOS Keychain" : file() } diff --git a/src/providers/codex/client.ts b/src/providers/codex/client.ts index 0f2cee0..73f2f03 100644 --- a/src/providers/codex/client.ts +++ b/src/providers/codex/client.ts @@ -1,4 +1,5 @@ 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" @@ -80,10 +81,10 @@ async function doFetch( "Content-Type": "application/json", accept: "text/event-stream", authorization: `Bearer ${accessToken}`, - originator: process.env.CCP_CODEX_ORIGINATOR ?? process.env.CCP_ORIGINATOR ?? ORIGINATOR_DEFAULT, + originator: codexOriginator(ORIGINATOR_DEFAULT), "openai-beta": "responses=experimental", }) - const userAgent = process.env.CCP_CODEX_USER_AGENT ?? process.env.CCP_USER_AGENT ?? `claude-code-proxy/${PROXY_VERSION}` + const userAgent = codexUserAgent(`claude-code-proxy/${PROXY_VERSION}`) if (userAgent) headers.set("User-Agent", userAgent) if (accountId) headers.set("ChatGPT-Account-Id", accountId) if (sessionId) { diff --git a/src/providers/codex/index.ts b/src/providers/codex/index.ts index 4d12659..b41ff79 100644 --- a/src/providers/codex/index.ts +++ b/src/providers/codex/index.ts @@ -15,8 +15,7 @@ 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" - -const VERBOSE = !!process.env.CCP_LOG_VERBOSE +import { logVerbose } from "../../config.ts" interface SessionCountSnapshot { reqId: string @@ -101,7 +100,7 @@ async function handleCountTokens(body: AnthropicRequest, ctx: RequestContext): P tokens, } } - if (VERBOSE) { + if (logVerbose()) { log.info("compaction telemetry", { phase: "count_tokens", model: body.model, @@ -140,7 +139,7 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom hasContextManagement: contextManagement !== undefined, hasJsonSchemaFormat: body.output_config?.format?.type === "json_schema", }) - if (VERBOSE) log.debug("anthropic request body", { body }) + if (logVerbose()) log.debug("anthropic request body", { body }) const resolvedModel = resolveModel(body.model) @@ -158,8 +157,8 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom } const translated = translateRequest({ ...body, model: resolvedModel }, { sessionId: ctx.sessionId }) - const localInputTokens = VERBOSE ? countTokens(body) : undefined - const translatedInputTokens = VERBOSE ? countTranslatedTokens(translated) : undefined + const localInputTokens = logVerbose() ? countTokens(body) : undefined + const translatedInputTokens = logVerbose() ? countTranslatedTokens(translated) : undefined if (state) { state.lastMessage = { reqId: ctx.reqId, @@ -180,8 +179,8 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom hasContextManagement: contextManagement !== undefined, promptCacheKey: translated.prompt_cache_key, }) - if (VERBOSE) log.debug("translated request body", { body: translated }) - if (VERBOSE) { + if (logVerbose()) log.debug("translated request body", { body: translated }) + if (logVerbose()) { log.info("compaction telemetry", { phase: "translated_request", requestedModel: body.model, @@ -234,7 +233,7 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom messageId, model: body.model, log: ctx.childLogger("codex.stream"), - onFinish: VERBOSE + onFinish: logVerbose() ? (finish) => { const mappedUsage = finish.usage ? mapUsageToAnthropic(finish.usage) : undefined log.info("compaction telemetry", { @@ -277,7 +276,7 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom try { const result = await accumulateResponse(upstream.body, { messageId, model: body.model, log: ctx.childLogger("codex.accumulate") }) - if (VERBOSE) { + if (logVerbose()) { const { serverModel, serverReasoningIncluded } = upstreamHeaderSnapshot(upstream.headers) log.info("compaction telemetry", { phase: "upstream_finish", 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 index bc9636a..213b5f1 100644 --- a/src/providers/codex/translate/model-allowlist.ts +++ b/src/providers/codex/translate/model-allowlist.ts @@ -1,3 +1,5 @@ +import { codexModel } from "../../../config.ts" + export const ALLOWED_MODELS = new Set([ "gpt-5.2", "gpt-5.3-codex", @@ -17,15 +19,12 @@ export const MODEL_ALIASES = new Map([ ]) export function resolveModel(model: string): string { - // The CCP_CODEX_MODEL environment variable overrides the model so that - // regardless of whatever model is requested by the harness, the provided - // model is always used. - if ( - process.env.CCP_CODEX_MODEL !== undefined && - process.env.CCP_CODEX_MODEL !== "" - ) { - return process.env.CCP_CODEX_MODEL - } + // 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 } diff --git a/src/providers/codex/translate/reducer.ts b/src/providers/codex/translate/reducer.ts index 81c246d..e151531 100644 --- a/src/providers/codex/translate/reducer.ts +++ b/src/providers/codex/translate/reducer.ts @@ -1,7 +1,6 @@ import { parseSseStream } from "../../../sse.ts" import type { Logger } from "../../../log.ts" - -const VERBOSE = !!process.env.CCP_LOG_VERBOSE +import { logVerbose } from "../../../config.ts" export class UpstreamStreamError extends Error { constructor( @@ -98,7 +97,7 @@ export async function* reduceUpstream( } 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) { diff --git a/src/providers/codex/translate/request.ts b/src/providers/codex/translate/request.ts index a7ae079..74c4f5b 100644 --- a/src/providers/codex/translate/request.ts +++ b/src/providers/codex/translate/request.ts @@ -6,6 +6,7 @@ import type { AnthropicTextBlock, AnthropicTool, } from "../../../anthropic/schema.ts" +import { codexEffort } from "../../../config.ts" export type Effort = "none" | "low" | "medium" | "high" | "xhigh" @@ -93,8 +94,8 @@ function toCodexEffort( } function resolveEffort(effort?: Effort): Effort | undefined { - const override = process.env.CCP_CODEX_EFFORT - if (override === undefined || override === "") { + const override = codexEffort() + if (override === undefined) { return effort } if (!VALID_EFFORTS.has(override as Effort)) { diff --git a/src/providers/kimi/auth/constants.ts b/src/providers/kimi/auth/constants.ts index 32d5d3d..8c2553f 100644 --- a/src/providers/kimi/auth/constants.ts +++ b/src/providers/kimi/auth/constants.ts @@ -1,6 +1,14 @@ +import { kimiBaseUrl, kimiOauthHost } from "../../../config.ts" + export const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098" -export const OAUTH_HOST = process.env.KIMI_OAUTH_HOST ?? "https://auth.kimi.com" -export const API_BASE_URL = process.env.KIMI_BASE_URL ?? "https://api.kimi.com/coding/v1" +// Read lazily so config.json values apply. KIMI_OAUTH_HOST / 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" diff --git a/src/providers/kimi/auth/device-id.ts b/src/providers/kimi/auth/device-id.ts index f42ac7d..570e9d9 100644 --- a/src/providers/kimi/auth/device-id.ts +++ b/src/providers/kimi/auth/device-id.ts @@ -1,22 +1,30 @@ import { mkdir, readFile, writeFile, chmod } from "node:fs/promises" import { dirname, join } from "node:path" -import { homedir } from "node:os" +import { configDir, legacyConfigDir } from "../../../paths.ts" -const PATH = join(homedir(), ".config", "claude-code-proxy", "kimi", "device_id") +function path(): string { + return join(configDir(), "kimi", "device_id") +} +function legacyPath(): string { + return join(legacyConfigDir(), "kimi", "device_id") +} export async function getDeviceId(): Promise { - try { - const raw = await readFile(PATH, "utf8") - const trimmed = raw.trim() - if (trimmed) return trimmed - } catch (err: any) { - if (err?.code !== "ENOENT") throw err + 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, "") - await mkdir(dirname(PATH), { recursive: true }) - await writeFile(PATH, id, { encoding: "utf8", mode: 0o600 }) + const target = path() + await mkdir(dirname(target), { recursive: true }) + await writeFile(target, id, { encoding: "utf8", mode: 0o600 }) try { - await chmod(PATH, 0o600) + await chmod(target, 0o600) } catch {} return id } diff --git a/src/providers/kimi/auth/headers.ts b/src/providers/kimi/auth/headers.ts index 1439a51..21ecb53 100644 --- a/src/providers/kimi/auth/headers.ts +++ b/src/providers/kimi/auth/headers.ts @@ -1,6 +1,7 @@ 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() @@ -25,6 +26,6 @@ export async function commonHeaders(): Promise> { "X-Msh-Device-Model": asciiOnly(deviceModel()), "X-Msh-Os-Version": asciiOnly(release()), "X-Msh-Device-Id": deviceId, - "User-Agent": process.env.CCP_KIMI_USER_AGENT ?? process.env.CCP_USER_AGENT ?? `KimiCLI/${KIMI_CLI_VERSION}`, + "User-Agent": kimiUserAgent(`KimiCLI/${KIMI_CLI_VERSION}`), } } diff --git a/src/providers/kimi/auth/login.ts b/src/providers/kimi/auth/login.ts index 48a8901..43c018e 100644 --- a/src/providers/kimi/auth/login.ts +++ b/src/providers/kimi/auth/login.ts @@ -1,4 +1,4 @@ -import { CLIENT_ID, OAUTH_HOST } from "./constants.ts" +import { CLIENT_ID, oauthHost } from "./constants.ts" import { commonHeaders } from "./headers.ts" export interface TokenResponse { @@ -24,7 +24,7 @@ const POLL_SAFETY_MARGIN_MS = 500 export async function runDeviceLogin(): Promise { const headers = await commonHeaders() - const initResp = await fetch(`${OAUTH_HOST}/api/oauth/device_authorization`, { + 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(), @@ -39,7 +39,7 @@ export async function runDeviceLogin(): Promise { console.log(`Code: ${auth.user_code}\n`) while (true) { - const resp = await fetch(`${OAUTH_HOST}/api/oauth/token`, { + const resp = await fetch(`${oauthHost()}/api/oauth/token`, { method: "POST", headers: { ...headers, "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ diff --git a/src/providers/kimi/auth/manager.ts b/src/providers/kimi/auth/manager.ts index 2dfbbe6..dff90e4 100644 --- a/src/providers/kimi/auth/manager.ts +++ b/src/providers/kimi/auth/manager.ts @@ -1,4 +1,4 @@ -import { CLIENT_ID, OAUTH_HOST, REFRESH_MARGIN_MS } from "./constants.ts" +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" @@ -92,7 +92,7 @@ async function refreshNow(current: StoredAuth): Promise { for (let attempt = 0; attempt < MAX_REFRESH_ATTEMPTS; attempt++) { let resp: Response try { - resp = await fetch(`${OAUTH_HOST}/api/oauth/token`, { + resp = await fetch(`${oauthHost()}/api/oauth/token`, { method: "POST", headers: { ...headers, "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ diff --git a/src/providers/kimi/auth/token-store.ts b/src/providers/kimi/auth/token-store.ts index 0e75bf6..6bae7d3 100644 --- a/src/providers/kimi/auth/token-store.ts +++ b/src/providers/kimi/auth/token-store.ts @@ -1,7 +1,7 @@ import { mkdir, readFile, writeFile, unlink, rename } from "node:fs/promises" import { dirname, join } from "node:path" -import { homedir } from "node:os" import { keychainGet, keychainSet, keychainDelete } from "../../../keychain.ts" +import { configDir, legacyConfigDir } from "../../../paths.ts" export interface StoredAuth { access: string @@ -11,8 +11,12 @@ export interface StoredAuth { userId?: string } -const DIR = join(homedir(), ".config", "claude-code-proxy", "kimi") -const FILE = join(DIR, "auth.json") +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" @@ -23,8 +27,17 @@ export async function loadAuth(): Promise { 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(FILE, "utf8") + const raw = await readFile(legacy, "utf8") return JSON.parse(raw) as StoredAuth } catch (err: any) { if (err?.code === "ENOENT") return undefined @@ -38,10 +51,11 @@ export async function saveAuth(auth: StoredAuth): Promise { return } - await mkdir(dirname(FILE), { recursive: true, mode: 0o700 }) - const tmp = `${FILE}.${process.pid}.${Date.now()}.tmp` + 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, FILE) + await rename(tmp, path) } export async function clearAuth(): Promise { @@ -50,13 +64,15 @@ export async function clearAuth(): Promise { return } - try { - await unlink(FILE) - } catch (err: any) { - if (err?.code !== "ENOENT") throw err + 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 + return process.platform === "darwin" ? "macOS Keychain" : file() } diff --git a/src/providers/kimi/client.ts b/src/providers/kimi/client.ts index 07d27b7..ca1ff0a 100644 --- a/src/providers/kimi/client.ts +++ b/src/providers/kimi/client.ts @@ -1,4 +1,4 @@ -import { API_BASE_URL } from "./auth/constants.ts" +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" @@ -100,14 +100,14 @@ async function doFetch( const bodyJson = JSON.stringify(body) log.debug("posting to kimi", { - url: `${API_BASE_URL}/chat/completions`, + url: `${apiBaseUrl()}/chat/completions`, model: body.model, messageCount: body.messages.length, toolCount: body.tools?.length ?? 0, requestBodyBytes: new TextEncoder().encode(bodyJson).length, }) - return fetch(`${API_BASE_URL}/chat/completions`, { + return fetch(`${apiBaseUrl()}/chat/completions`, { method: "POST", headers, body: bodyJson, diff --git a/src/providers/kimi/index.ts b/src/providers/kimi/index.ts index 1112ee1..56ddbce 100644 --- a/src/providers/kimi/index.ts +++ b/src/providers/kimi/index.ts @@ -14,8 +14,7 @@ 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" - -const VERBOSE = !!process.env.CCP_LOG_VERBOSE +import { logVerbose } from "../../config.ts" function jsonError(status: number, type: string, message: string): Response { return new Response(JSON.stringify({ type: "error", error: { type, message } }), { @@ -49,7 +48,7 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom stream: wantStream, requestedMaxTokens: body.max_tokens, }) - if (VERBOSE) log.debug("anthropic request body", { body }) + if (logVerbose()) log.debug("anthropic request body", { body }) const resolvedModel = resolveModel(body.model) try { @@ -83,7 +82,7 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom thinking: translated.thinking?.type, maxTokens: translated.max_tokens, }) - if (VERBOSE) log.debug("translated request body", { body: translated }) + if (logVerbose()) log.debug("translated request body", { body: translated }) let upstream try { diff --git a/src/providers/kimi/translate/reducer.ts b/src/providers/kimi/translate/reducer.ts index 3d6eec8..841e2f9 100644 --- a/src/providers/kimi/translate/reducer.ts +++ b/src/providers/kimi/translate/reducer.ts @@ -1,8 +1,6 @@ import { parseSseStream } from "../../../sse.ts" import type { Logger } from "../../../log.ts" -const VERBOSE = !!process.env.CCP_LOG_VERBOSE - export class UpstreamStreamError extends Error { constructor( public kind: "rate_limit" | "failed", From 1a4a437e473b6f7f4ecd710bd0b87dba5931cbc6 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Thu, 30 Apr 2026 09:44:49 +0300 Subject: [PATCH 86/98] changelog: add v0.0.8 entry --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f95334b..dbc8853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 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 +- Stopped including the `v` prefix in build version strings + ## v0.0.7 (2026-04-25) - Some security hardening inspired by [#5](https://github.com/raine/claude-code-proxy/pull/5) From 70729b6f12f87b982fb32a22102c9dc0c85ba57d Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Thu, 30 Apr 2026 09:45:21 +0300 Subject: [PATCH 87/98] 0.0.8 --- CHANGELOG.md | 1 - package.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbc8853..4f1f9dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,6 @@ (`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 -- Stopped including the `v` prefix in build version strings ## v0.0.7 (2026-04-25) diff --git a/package.json b/package.json index ac08ae8..ddae796 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-proxy", - "version": "0.0.7", + "version": "0.0.8", "license": "MIT", "type": "module", "bin": { From 268232a6a8c0f08663defcc23442677b97f8a579 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Thu, 30 Apr 2026 09:49:34 +0300 Subject: [PATCH 88/98] log active configuration on startup shows config file path when present and lists any non-default settings with their source (env or config). --- src/cli.ts | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index db10f85..5d6d75c 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,10 @@ #!/usr/bin/env bun import { startServer } from "./server.ts" import { createLogger, logDir } from "./log.ts" -import { port as configPort } from "./config.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, @@ -29,6 +32,7 @@ async function main() { 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()) { @@ -104,6 +108,52 @@ Models: ${models} process.exit(2) } +function printConfigSummary(): void { + const cfg = getConfig() + const fromFile = cfg.file + const overrides: string[] = [] + + const configPath = join(configDir(), "config.json") + if (existsSync(configPath)) { + console.log(`Config: ${configPath}`) + } + + if (cfg.env.CCP_CODEX_ORIGINATOR) overrides.push("CCP_CODEX_ORIGINATOR (env)") + else if (fromFile.codex?.originator) overrides.push("codex.originator (config)") + + if (cfg.env.CCP_CODEX_USER_AGENT) overrides.push("CCP_CODEX_USER_AGENT (env)") + else if (cfg.env.CCP_USER_AGENT) overrides.push("CCP_USER_AGENT (env)") + else if (fromFile.codex?.userAgent) overrides.push("codex.userAgent (config)") + + if (cfg.env.CCP_KIMI_USER_AGENT) overrides.push("CCP_KIMI_USER_AGENT (env)") + else if (fromFile.kimi?.userAgent) overrides.push("kimi.userAgent (config)") + + if (cfg.env.CCP_CODEX_MODEL) overrides.push("CCP_CODEX_MODEL (env)") + else if (fromFile.codex?.model) overrides.push("codex.model (config)") + + if (cfg.env.CCP_CODEX_EFFORT) overrides.push("CCP_CODEX_EFFORT (env)") + else if (fromFile.codex?.effort) overrides.push("codex.effort (config)") + + if (cfg.env.CCP_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.KIMI_OAUTH_HOST) overrides.push("KIMI_OAUTH_HOST (env)") + else if (fromFile.kimi?.oauthHost) overrides.push("kimi.oauthHost (config)") + + if (cfg.env.KIMI_BASE_URL) overrides.push("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) From 43f785fe5d3cc874d8f343f5b8f2e2aeb0bfc00e Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Thu, 30 Apr 2026 10:26:47 +0300 Subject: [PATCH 89/98] readme: recommend disabling non-streaming fallback Document the Claude Code environment variable that disables streaming-to-non-streaming fallback when routing through the proxy. The proxy always uses streaming upstream requests, so the fallback path does not avoid upstream streaming and can retry after a partial tool-use stream in a way that duplicates tool calls. --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2bd2e31..c400ce9 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ 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 @@ -115,9 +116,16 @@ 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 ``` +`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 @@ -127,7 +135,8 @@ Or set it persistently in `~/.claude/settings.json`: "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_NONESSENTIAL_TRAFFIC": 1, + "CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK": 1 } } ``` @@ -169,6 +178,7 @@ if [ -f "$HOME/.claude/claude-code-proxy-enabled" ]; then 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" "$@" From e188a60ba83adddf95084919a43048e0d8911d33 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 3 May 2026 19:06:53 +0300 Subject: [PATCH 90/98] rename KIMI_OAUTH_HOST and KIMI_BASE_URL to CCP_ prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consistency: all other project-specific env vars use the CCP_ prefix. These were the only two that didn't. No backward compat needed — these are debugging-only overrides unlikely to be set by anyone. --- README.md | 4 ++-- src/cli.ts | 4 ++-- src/config.test.ts | 4 ++-- src/config.ts | 4 ++-- src/providers/kimi/auth/constants.ts | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index c400ce9..0916959 100644 --- a/README.md +++ b/README.md @@ -472,8 +472,8 @@ directory the auth tokens use, deliberately not `~/Library`) and at | `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 | -| `KIMI_OAUTH_HOST` | `kimi.oauthHost` | `https://auth.kimi.com` | Override Kimi's OAuth host (debugging only) | -| `KIMI_BASE_URL` | `kimi.baseUrl` | `https://api.kimi.com/coding/v1` | Override Kimi's API base URL | +| `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 (`none`, `low`, `medium`, `high`, `xhigh`) | | `CCP_CODEX_ORIGINATOR` | `codex.originator` | `claude-code-proxy` | Override the `originator` header sent to Codex | diff --git a/src/cli.ts b/src/cli.ts index 5d6d75c..685519a 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -140,10 +140,10 @@ function printConfigSummary(): void { 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.KIMI_OAUTH_HOST) overrides.push("KIMI_OAUTH_HOST (env)") + 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.KIMI_BASE_URL) overrides.push("KIMI_BASE_URL (env)") + 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) { diff --git a/src/config.test.ts b/src/config.test.ts index 3ab96ba..8444ca9 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -141,8 +141,8 @@ describe("empty env-string compatibility", () => { expect(codexUserAgent("default-ua")).toBe("") }) - it("empty KIMI_OAUTH_HOST env is a valid value (legacy ?? semantics)", () => { - setEnv({ KIMI_OAUTH_HOST: "" }) + it("empty CCP_KIMI_OAUTH_HOST env is a valid value (legacy ?? semantics)", () => { + setEnv({ CCP_KIMI_OAUTH_HOST: "" }) expect(kimiOauthHost()).toBe("") }) diff --git a/src/config.ts b/src/config.ts index 03edea5..ecf50df 100644 --- a/src/config.ts +++ b/src/config.ts @@ -205,12 +205,12 @@ export function kimiUserAgent(defaultValue: string): string { export function kimiOauthHost(): string { const c = getConfig() - return c.env.KIMI_OAUTH_HOST ?? c.file.kimi?.oauthHost ?? "https://auth.kimi.com" + 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.KIMI_BASE_URL ?? c.file.kimi?.baseUrl ?? "https://api.kimi.com/coding/v1" + 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 diff --git a/src/providers/kimi/auth/constants.ts b/src/providers/kimi/auth/constants.ts index 8c2553f..58fbf9a 100644 --- a/src/providers/kimi/auth/constants.ts +++ b/src/providers/kimi/auth/constants.ts @@ -1,7 +1,7 @@ import { kimiBaseUrl, kimiOauthHost } from "../../../config.ts" export const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098" -// Read lazily so config.json values apply. KIMI_OAUTH_HOST / KIMI_BASE_URL +// 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() From b773e32ade6a1aea5ec1ebd422316e4bb27306f1 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 3 May 2026 19:07:59 +0300 Subject: [PATCH 91/98] changelog: add v0.0.9 entry Document the user-facing changes for the pending v0.0.9 release so readers can see the startup config visibility improvements, Kimi env var rename, and Claude Code fallback guidance in one place. --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f1f9dd..759d54b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v0.0.9 (2026-05-03) + +- The server now prints the active config file path and configured overrides on startup, making it easier to confirm which settings are in effect. +- Kimi debugging overrides now use `CCP_KIMI_OAUTH_HOST` and `CCP_KIMI_BASE_URL`, matching the proxy's `CCP_` environment variable naming. +- The setup examples now recommend disabling Claude Code's non-streaming fallback to avoid duplicate tool calls after interrupted streams. + ## v0.0.8 (2026-04-30) - Added exponential backoff retry on upstream 429 errors, respecting From bd2ad0e8dfc057ececa1fd75c9adb96298d24552 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Sun, 3 May 2026 20:03:56 +0300 Subject: [PATCH 92/98] 0.0.9 --- CHANGELOG.md | 2 -- package.json | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 759d54b..2b16c4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,7 @@ ## v0.0.9 (2026-05-03) -- The server now prints the active config file path and configured overrides on startup, making it easier to confirm which settings are in effect. - Kimi debugging overrides now use `CCP_KIMI_OAUTH_HOST` and `CCP_KIMI_BASE_URL`, matching the proxy's `CCP_` environment variable naming. -- The setup examples now recommend disabling Claude Code's non-streaming fallback to avoid duplicate tool calls after interrupted streams. ## v0.0.8 (2026-04-30) diff --git a/package.json b/package.json index ddae796..93d8ea2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-proxy", - "version": "0.0.8", + "version": "0.0.9", "license": "MIT", "type": "module", "bin": { From e940dabc8133353cb4eeaf65a8e4bbcec623ba13 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Wed, 6 May 2026 22:05:00 +0300 Subject: [PATCH 93/98] add codex service tier override This lets users request Codex service tiers from config or environment without hand-editing requests. The fast tier follows Codex's own behavior by accepting fast as the config value while sending priority to the upstream API. The request translator validates supported service tier values and keeps the wire request constrained to the tiers currently supported by Codex source. References #9 --- CHANGELOG.md | 6 ++++ README.md | 34 ++++++++++--------- src/cli.ts | 3 ++ src/config.test.ts | 20 +++++++++++ src/config.ts | 9 ++++- src/providers/codex/translate/request.test.ts | 29 +++++++++++++++- src/providers/codex/translate/request.ts | 20 +++++++++-- 7 files changed, 101 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b16c4b..089cd43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- Added `codex.serviceTier` / `CCP_CODEX_SERVICE_TIER` to request Codex service + tiers. The Codex-style `fast` value is sent upstream as `priority`, matching + the first-party Codex client. + ## 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. diff --git a/README.md b/README.md index 0916959..b44c6e2 100644 --- a/README.md +++ b/README.md @@ -452,7 +452,8 @@ directory the auth tokens use, deliberately not `~/Library`) and at "originator": "claude-code-proxy", "userAgent": "claude-code-proxy/dev", "model": "gpt-5.4", - "effort": "medium" + "effort": "medium", + "serviceTier": "fast" }, "kimi": { "userAgent": "KimiCLI/1.37.0", @@ -466,21 +467,22 @@ directory the auth tokens use, deliberately not `~/Library`) and at } ``` -| Variable | Config key | Default | Purpose | -| ---------------------- | ------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------- | -| `PORT` | `port` | `18765` | Proxy listen port | -| `XDG_STATE_HOME` | — | `~/.local/state` | Base dir for `proxy.log` | -| `CCP_LOG_STDERR` | `log.stderr` | unset | Also mirror log lines to stderr | -| `CCP_LOG_VERBOSE` | `log.verbose` | unset | Log full request/response bodies + every SSE event | -| `CCP_KIMI_OAUTH_HOST` | `kimi.oauthHost` | `https://auth.kimi.com` | Override Kimi's OAuth host (debugging only) | -| `CCP_KIMI_BASE_URL` | `kimi.baseUrl` | `https://api.kimi.com/coding/v1` | Override Kimi's API base URL | -| `CCP_CODEX_MODEL` | `codex.model` | unset | Force all Codex requests to this model (`gpt-5.2`, `gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`) | -| `CCP_CODEX_EFFORT` | `codex.effort` | unset | Force all Codex requests to this reasoning effort (`none`, `low`, `medium`, `high`, `xhigh`) | -| `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` | +| Variable | Config key | Default | Purpose | +| ------------------------ | ------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------- | +| `PORT` | `port` | `18765` | Proxy listen port | +| `XDG_STATE_HOME` | — | `~/.local/state` | Base dir for `proxy.log` | +| `CCP_LOG_STDERR` | `log.stderr` | unset | Also mirror log lines to stderr | +| `CCP_LOG_VERBOSE` | `log.verbose` | unset | Log full request/response bodies + every SSE event | +| `CCP_KIMI_OAUTH_HOST` | `kimi.oauthHost` | `https://auth.kimi.com` | Override Kimi's OAuth host (debugging only) | +| `CCP_KIMI_BASE_URL` | `kimi.baseUrl` | `https://api.kimi.com/coding/v1` | Override Kimi's API base URL | +| `CCP_CODEX_MODEL` | `codex.model` | unset | Force all Codex requests to this model (`gpt-5.2`, `gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`) | +| `CCP_CODEX_EFFORT` | `codex.effort` | unset | Force all Codex requests to this reasoning effort (`none`, `low`, `medium`, `high`, `xhigh`) | +| `CCP_CODEX_SERVICE_TIER` | `codex.serviceTier` | unset | Force all Codex requests to this service tier (`fast`, `priority`, `flex`) | +| `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 diff --git a/src/cli.ts b/src/cli.ts index 685519a..495349b 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -134,6 +134,9 @@ function printConfigSummary(): void { if (cfg.env.CCP_CODEX_EFFORT) overrides.push("CCP_CODEX_EFFORT (env)") else if (fromFile.codex?.effort) overrides.push("codex.effort (config)") + if (cfg.env.CCP_CODEX_SERVICE_TIER) overrides.push("CCP_CODEX_SERVICE_TIER (env)") + else if (fromFile.codex?.serviceTier) overrides.push("codex.serviceTier (config)") + if (cfg.env.CCP_LOG_VERBOSE !== undefined) overrides.push("CCP_LOG_VERBOSE (env)") else if (fromFile.log?.verbose) overrides.push("log.verbose (config)") diff --git a/src/config.test.ts b/src/config.test.ts index 8444ca9..43bf511 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -9,6 +9,7 @@ import { codexUserAgent, codexModel, codexEffort, + codexServiceTier, kimiUserAgent, kimiOauthHost, kimiBaseUrl, @@ -43,6 +44,7 @@ describe("config defaults", () => { expect(codexUserAgent("default-ua")).toBe("default-ua") expect(codexModel()).toBeUndefined() expect(codexEffort()).toBeUndefined() + expect(codexServiceTier()).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") @@ -67,6 +69,12 @@ describe("file overrides default", () => { expect(codexUserAgent("default")).toBe("ccp/file") }) + it("codex.serviceTier from config.json", () => { + writeFileSync(configPath, JSON.stringify({ codex: { serviceTier: "fast" } })) + setEnv({}) + expect(codexServiceTier()).toBe("fast") + }) + it("kimi.oauthHost from config.json", () => { writeFileSync( configPath, @@ -99,6 +107,12 @@ describe("env overrides file", () => { expect(codexUserAgent("default")).toBe("from-env") }) + it("CCP_CODEX_SERVICE_TIER env wins over config", () => { + writeFileSync(configPath, JSON.stringify({ codex: { serviceTier: "flex" } })) + setEnv({ CCP_CODEX_SERVICE_TIER: "fast" }) + expect(codexServiceTier()).toBe("fast") + }) + it("CCP_USER_AGENT env (generic fallback) is preferred over file", () => { writeFileSync( configPath, @@ -128,6 +142,12 @@ describe("empty-string semantics", () => { expect(codexModel()).toBeUndefined() }) + it("empty CCP_CODEX_SERVICE_TIER env falls through to file value", () => { + writeFileSync(configPath, JSON.stringify({ codex: { serviceTier: "flex" } })) + setEnv({ CCP_CODEX_SERVICE_TIER: "" }) + expect(codexServiceTier()).toBe("flex") + }) + it("empty PORT env falls through to file value", () => { writeFileSync(configPath, JSON.stringify({ port: 33333 })) setEnv({ PORT: "" }) diff --git a/src/config.ts b/src/config.ts index ecf50df..daae3b7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,7 @@ export interface FileConfig { userAgent?: string model?: string effort?: string + serviceTier?: string } kimi?: { userAgent?: string @@ -87,11 +88,12 @@ function validate(raw: unknown): FileConfig { return acc as NonNullable } - const codex = validateStringSection("codex", ["originator", "userAgent", "model", "effort"], { + const codex = validateStringSection("codex", ["originator", "userAgent", "model", "effort", "serviceTier"], { originator: "string", userAgent: "string", model: "string", effort: "string", + serviceTier: "string", }) if (codex) out.codex = codex @@ -193,6 +195,11 @@ export function codexEffort(): string | undefined { return emptyOrUnset(c.env.CCP_CODEX_EFFORT) ?? emptyOrUnset(c.file.codex?.effort) } +export function codexServiceTier(): string | undefined { + const c = getConfig() + return emptyOrUnset(c.env.CCP_CODEX_SERVICE_TIER) ?? emptyOrUnset(c.file.codex?.serviceTier) +} + export function kimiUserAgent(defaultValue: string): string { const c = getConfig() return ( diff --git a/src/providers/codex/translate/request.test.ts b/src/providers/codex/translate/request.test.ts index 7362234..ceb5739 100644 --- a/src/providers/codex/translate/request.test.ts +++ b/src/providers/codex/translate/request.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "bun:test" +import { afterEach, describe, expect, it } from "bun:test" +import { loadConfig } from "../../../config.ts" import type { AnthropicRequest } from "../../../anthropic/schema.ts" import { translateRequest } from "./request.ts" @@ -7,6 +8,10 @@ const baseRequest: AnthropicRequest = { messages: [{ role: "user", content: "hello" }], } +afterEach(() => { + loadConfig({ forceReload: true }) +}) + describe("translateRequest", () => { it("omits reasoning include when reasoning is not enabled", () => { const translated = translateRequest(baseRequest) @@ -25,6 +30,28 @@ describe("translateRequest", () => { expect(translated.include).toEqual(["reasoning.encrypted_content"]) }) + it("normalizes fast service tier to upstream priority", () => { + loadConfig({ env: { CCP_CODEX_SERVICE_TIER: "fast" }, forceReload: true }) + + const translated = translateRequest(baseRequest) + + expect(translated.service_tier).toBe("priority") + }) + + it("passes flex service tier through", () => { + loadConfig({ env: { CCP_CODEX_SERVICE_TIER: "flex" }, forceReload: true }) + + const translated = translateRequest(baseRequest) + + expect(translated.service_tier).toBe("flex") + }) + + it("rejects invalid service tier overrides", () => { + loadConfig({ env: { CCP_CODEX_SERVICE_TIER: "standard" }, forceReload: true }) + + expect(() => translateRequest(baseRequest)).toThrow('Invalid service tier override: "standard"') + }) + it("returns only the expected top-level upstream request fields", () => { const translated = translateRequest({ ...baseRequest, diff --git a/src/providers/codex/translate/request.ts b/src/providers/codex/translate/request.ts index 74c4f5b..58824ea 100644 --- a/src/providers/codex/translate/request.ts +++ b/src/providers/codex/translate/request.ts @@ -6,9 +6,10 @@ import type { AnthropicTextBlock, AnthropicTool, } from "../../../anthropic/schema.ts" -import { codexEffort } from "../../../config.ts" +import { codexEffort, codexServiceTier } from "../../../config.ts" export type Effort = "none" | "low" | "medium" | "high" | "xhigh" +export type ServiceTier = "priority" | "flex" // 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. @@ -27,7 +28,7 @@ export interface ResponsesRequest { store: false stream: true include?: string[] - service_tier?: string + service_tier?: ServiceTier prompt_cache_key?: string text?: { verbosity?: "low" | "medium" | "high" @@ -78,6 +79,8 @@ const VALID_EFFORTS = new Set(["none", "low", "medium", "high", "xhigh"] const ANTHROPIC_EFFORTS = new Set(["low", "medium", "high", "max"]) +const VALID_SERVICE_TIERS = new Set(["fast", "priority", "flex"]) + function assertValidEffort(effort: unknown): void { if (effort !== undefined && !ANTHROPIC_EFFORTS.has(effort as string)) { throw new Error( @@ -106,6 +109,17 @@ function resolveEffort(effort?: Effort): Effort | undefined { return override as Effort } +function resolveServiceTier(): ServiceTier | undefined { + const tier = codexServiceTier() + if (tier === undefined) return undefined + if (!VALID_SERVICE_TIERS.has(tier)) { + throw new Error( + `Invalid service tier override: "${tier}". Must be one of: ${Array.from(VALID_SERVICE_TIERS).join(", ")}`, + ) + } + return tier === "flex" ? "flex" : "priority" +} + export function translateRequest(req: AnthropicRequest, opts: TranslateOptions = {}): ResponsesRequest { const instructions = buildInstructions(req.system) const input = buildInput(req.messages) @@ -134,6 +148,8 @@ 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 serviceTier = resolveServiceTier() + if (serviceTier) out.service_tier = serviceTier assertValidEffort(req.output_config?.effort) const effort = resolveEffort(toCodexEffort(req.output_config?.effort)) if (effort) { From 55b4f5c8fc7942186cdd0c739d0dcbb8adad5e07 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Wed, 6 May 2026 22:17:15 +0300 Subject: [PATCH 94/98] add codex fast model aliases This lets users select Codex fast mode per request by using model names such as gpt-5.4-fast, avoiding a proxy restart for service tier changes. Fast aliases are registered for routing, normalized back to the base upstream model, and translated to the same priority service tier used by first-party Codex. Explicit service tier config still takes precedence. --- CHANGELOG.md | 2 + README.md | 11 +++- src/providers/codex/index.test.ts | 60 +++++++++++++++++++ src/providers/codex/index.ts | 42 ++++++++++--- .../codex/translate/model-allowlist.test.ts | 23 ++++++- .../codex/translate/model-allowlist.ts | 23 ++++++- src/providers/codex/translate/request.test.ts | 20 ++++++- src/providers/codex/translate/request.ts | 26 +++++--- src/providers/registry.test.ts | 12 ++++ src/server.ts | 2 +- 10 files changed, 197 insertions(+), 24 deletions(-) create mode 100644 src/providers/codex/index.test.ts create mode 100644 src/providers/registry.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 089cd43..a2a70f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - Added `codex.serviceTier` / `CCP_CODEX_SERVICE_TIER` to request Codex service tiers. The Codex-style `fast` value is sent upstream as `priority`, matching the first-party Codex client. +- Added `-fast` Codex model aliases such as `gpt-5.4-fast[1m]`, which request + fast mode per request without restarting the proxy. ## v0.0.9 (2026-05-03) diff --git a/README.md b/README.md index b44c6e2..511ae71 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ upstream for each request is chosen from `ANTHROPIC_MODEL`. `ANTHROPIC_MODEL` selects the provider: -- `gpt-5.4`, `gpt-5.3-codex`, `gpt-5.4-mini`, `gpt-5.2` → **codex** +- `gpt-5.5`, `gpt-5.4`, `gpt-5.3-codex`, `gpt-5.4-mini`, `gpt-5.2` → **codex** - `kimi-for-coding`, `kimi-k2.6`, `k2.6` → **kimi** An unknown model returns a 400 listing the supported ids. There is no @@ -217,6 +217,11 @@ the change immediately; existing sessions keep whatever they started with. Upstream: `https://chatgpt.com/backend-api/codex/responses` (Responses API). Set `ANTHROPIC_MODEL` to a model your ChatGPT subscription is allowed to use. +Append `-fast` to a Codex model name to request Codex fast mode for that request +without restarting the proxy. For example, `gpt-5.4-fast[1m]` is sent upstream as +model `gpt-5.4` with `service_tier: "priority"`. An explicit +`codex.serviceTier` / `CCP_CODEX_SERVICE_TIER` override still takes precedence. + Confirmed working on **Plus**: - `gpt-5.4` @@ -475,9 +480,9 @@ directory the auth tokens use, deliberately not `~/Library`) and at | `CCP_LOG_VERBOSE` | `log.verbose` | unset | Log full request/response bodies + every SSE event | | `CCP_KIMI_OAUTH_HOST` | `kimi.oauthHost` | `https://auth.kimi.com` | Override Kimi's OAuth host (debugging only) | | `CCP_KIMI_BASE_URL` | `kimi.baseUrl` | `https://api.kimi.com/coding/v1` | Override Kimi's API base URL | -| `CCP_CODEX_MODEL` | `codex.model` | unset | Force all Codex requests to this model (`gpt-5.2`, `gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`) | +| `CCP_CODEX_MODEL` | `codex.model` | unset | Force all Codex requests to this model (`gpt-5.2`, `gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`) | | `CCP_CODEX_EFFORT` | `codex.effort` | unset | Force all Codex requests to this reasoning effort (`none`, `low`, `medium`, `high`, `xhigh`) | -| `CCP_CODEX_SERVICE_TIER` | `codex.serviceTier` | unset | Force all Codex requests to this service tier (`fast`, `priority`, `flex`) | +| `CCP_CODEX_SERVICE_TIER` | `codex.serviceTier` | unset | Force all Codex requests to this service tier (`fast`/`priority`, `flex`; `fast` is sent upstream as `priority`) | | `CCP_CODEX_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 | diff --git a/src/providers/codex/index.test.ts b/src/providers/codex/index.test.ts new file mode 100644 index 0000000..d00a7d4 --- /dev/null +++ b/src/providers/codex/index.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it } from "bun:test" +import type { RequestContext } from "../types.ts" +import { loadConfig } from "../../config.ts" +import { codexProvider } from "./index.ts" + +const ctx: RequestContext = { + reqId: "test-req", + signal: new AbortController().signal, + childLogger: () => ({ + debug() {}, + info() {}, + warn() {}, + error() {}, + child() { + return this + }, + }), +} + +afterEach(() => { + loadConfig({ forceReload: true }) +}) + +describe("codexProvider", () => { + it("returns 400 for invalid service tier config during token counting", async () => { + loadConfig({ env: { CCP_CODEX_SERVICE_TIER: "standard" }, forceReload: true }) + + const resp = await codexProvider.handleCountTokens( + { model: "gpt-5.4", messages: [{ role: "user", content: "hello" }] }, + ctx, + ) + + expect(resp.status).toBe(400) + expect(await resp.json()).toEqual({ + type: "error", + error: { + type: "invalid_request_error", + message: 'Invalid service tier override: "standard". Must be one of: fast, priority, flex', + }, + }) + }) + + it("returns 400 for invalid forced model during token counting", async () => { + loadConfig({ env: { CCP_CODEX_MODEL: "gpt-4.1" }, forceReload: true }) + + const resp = await codexProvider.handleCountTokens( + { model: "gpt-5.4", messages: [{ role: "user", content: "hello" }] }, + ctx, + ) + + expect(resp.status).toBe(400) + expect(await resp.json()).toEqual({ + type: "error", + error: { + type: "invalid_request_error", + message: 'Model "gpt-5.4" resolves to unsupported model "gpt-4.1"', + }, + }) + }) +}) diff --git a/src/providers/codex/index.ts b/src/providers/codex/index.ts index b41ff79..3676c4d 100644 --- a/src/providers/codex/index.ts +++ b/src/providers/codex/index.ts @@ -1,11 +1,13 @@ import type { AnthropicRequest } from "../../anthropic/schema.ts" import type { Provider, RequestContext, CliHandlers } from "../types.ts" import { + ALLOWED_MODELS, assertAllowedModel, + FAST_MODEL_ALIASES, ModelNotAllowedError, - resolveModel, + resolveModelRequest, } from "./translate/model-allowlist.ts" -import { translateRequest } from "./translate/request.ts" +import { InvalidServiceTierError, translateRequest } from "./translate/request.ts" import { translateStream } from "./translate/stream.ts" import { accumulateResponse, UpstreamStreamError } from "./translate/accumulate.ts" import { mapUsageToAnthropic } from "./translate/reducer.ts" @@ -82,10 +84,29 @@ function jsonError(status: number, type: string, message: string): Response { }) } +function invalidServiceTierResponse(err: InvalidServiceTierError): Response { + return jsonError(400, "invalid_request_error", err.message) +} + async function handleCountTokens(body: AnthropicRequest, ctx: RequestContext): Promise { const log = ctx.childLogger("provider.codex") - const resolvedModel = resolveModel(body.model) - const translated = translateRequest({ ...body, model: resolvedModel }) + const resolved = resolveModelRequest(body.model) + const resolvedModel = resolved.model + let translated + try { + assertAllowedModel(resolvedModel) + translated = translateRequest({ ...body, model: resolvedModel }, { serviceTier: resolved.serviceTier }) + } catch (err) { + if (err instanceof ModelNotAllowedError) { + return jsonError( + 400, + "invalid_request_error", + `Model "${body.model}" resolves to unsupported model "${err.model}"`, + ) + } + if (err instanceof InvalidServiceTierError) return invalidServiceTierResponse(err) + throw err + } const tokens = countTranslatedTokens(translated) const messageCount = body.messages?.length ?? 0 const toolCount = body.tools?.length ?? 0 @@ -141,10 +162,16 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom }) if (logVerbose()) log.debug("anthropic request body", { body }) - const resolvedModel = resolveModel(body.model) + const resolved = resolveModelRequest(body.model) + const resolvedModel = resolved.model + let translated try { assertAllowedModel(resolvedModel) + translated = translateRequest( + { ...body, model: resolvedModel }, + { sessionId: ctx.sessionId, serviceTier: resolved.serviceTier }, + ) } catch (err) { if (err instanceof ModelNotAllowedError) { return jsonError( @@ -153,10 +180,9 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom `Model "${body.model}" resolves to unsupported model "${err.model}"`, ) } + if (err instanceof InvalidServiceTierError) return invalidServiceTierResponse(err) 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) { @@ -361,7 +387,7 @@ const cli: CliHandlers = { 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"]), + supportedModels: new Set([...ALLOWED_MODELS, ...FAST_MODEL_ALIASES]), handleMessages, handleCountTokens, cli, diff --git a/src/providers/codex/translate/model-allowlist.test.ts b/src/providers/codex/translate/model-allowlist.test.ts index c16fbfc..2137d69 100644 --- a/src/providers/codex/translate/model-allowlist.test.ts +++ b/src/providers/codex/translate/model-allowlist.test.ts @@ -2,7 +2,7 @@ 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 { resolveModel, resolveModelRequest } from "./model-allowlist.ts" import { loadConfig } from "../../../config.ts" afterEach(() => { @@ -53,4 +53,25 @@ describe("resolveModel", () => { loadConfig({ env: { CCP_CODEX_MODEL: "" }, forceReload: true }) expect(resolveModel("sonnet")).toBe("gpt-5.4") }) + + it("detects fast model aliases", () => { + loadConfig({ env: {}, forceReload: true }) + expect(resolveModelRequest("gpt-5.4-fast")).toEqual({ + model: "gpt-5.4", + serviceTier: "priority", + }) + }) + + it("model override preserves fast model alias service tier", () => { + loadConfig({ env: { CCP_CODEX_MODEL: "gpt-5.5" }, forceReload: true }) + expect(resolveModelRequest("gpt-5.4-fast")).toEqual({ + model: "gpt-5.5", + serviceTier: "priority", + }) + }) + + it("does not strip unsupported fast-looking model names", () => { + loadConfig({ env: {}, forceReload: true }) + expect(resolveModelRequest("gpt-4.1-fast")).toEqual({ model: "gpt-4.1-fast" }) + }) }) diff --git a/src/providers/codex/translate/model-allowlist.ts b/src/providers/codex/translate/model-allowlist.ts index 213b5f1..e045763 100644 --- a/src/providers/codex/translate/model-allowlist.ts +++ b/src/providers/codex/translate/model-allowlist.ts @@ -1,4 +1,5 @@ import { codexModel } from "../../../config.ts" +import type { ServiceTier } from "./request.ts" export const ALLOWED_MODELS = new Set([ "gpt-5.2", @@ -8,6 +9,10 @@ export const ALLOWED_MODELS = new Set([ "gpt-5.5", ]) +export const FAST_MODEL_ALIASES = new Set( + Array.from(ALLOWED_MODELS, (model) => `${model}-fast`), +) + export const MODEL_ALIASES = new Map([ ["haiku", "gpt-5.4-mini"], ["claude-haiku-4-5", "gpt-5.4-mini"], @@ -18,15 +23,29 @@ export const MODEL_ALIASES = new Map([ ["claude-opus-4-7", "gpt-5.5"], ]) +export interface ResolvedModel { + model: string + serviceTier?: ServiceTier +} + export function resolveModel(model: string): string { + return resolveModelRequest(model).model +} + +export function resolveModelRequest(model: string): ResolvedModel { // CCP_CODEX_MODEL (env) or codex.model (config.json) overrides the model // so that regardless of whatever model is requested by the harness, the // provided model is always used. Empty values fall through to alias // resolution. + const alias = MODEL_ALIASES.get(model) ?? model + const isFastAlias = FAST_MODEL_ALIASES.has(alias) + const modelWithoutTier = isFastAlias ? alias.slice(0, -"-fast".length) : alias const override = codexModel() - if (override !== undefined) return override - return MODEL_ALIASES.get(model) ?? model + return { + model: override ?? modelWithoutTier, + ...(isFastAlias ? { serviceTier: "priority" } : {}), + } } export function assertAllowedModel(model: string): void { diff --git a/src/providers/codex/translate/request.test.ts b/src/providers/codex/translate/request.test.ts index ceb5739..efb4f96 100644 --- a/src/providers/codex/translate/request.test.ts +++ b/src/providers/codex/translate/request.test.ts @@ -1,8 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test" import { loadConfig } from "../../../config.ts" import type { AnthropicRequest } from "../../../anthropic/schema.ts" -import { translateRequest } from "./request.ts" - +import { InvalidServiceTierError, translateRequest } from "./request.ts" const baseRequest: AnthropicRequest = { model: "claude-sonnet-4-6", messages: [{ role: "user", content: "hello" }], @@ -46,9 +45,26 @@ describe("translateRequest", () => { expect(translated.service_tier).toBe("flex") }) + it("uses model service tier when no override is set", () => { + loadConfig({ env: {}, forceReload: true }) + + const translated = translateRequest(baseRequest, { serviceTier: "priority" }) + + expect(translated.service_tier).toBe("priority") + }) + + it("service tier override takes precedence over model service tier", () => { + loadConfig({ env: { CCP_CODEX_SERVICE_TIER: "flex" }, forceReload: true }) + + const translated = translateRequest(baseRequest, { serviceTier: "priority" }) + + expect(translated.service_tier).toBe("flex") + }) + it("rejects invalid service tier overrides", () => { loadConfig({ env: { CCP_CODEX_SERVICE_TIER: "standard" }, forceReload: true }) + expect(() => translateRequest(baseRequest)).toThrow(InvalidServiceTierError) expect(() => translateRequest(baseRequest)).toThrow('Invalid service tier override: "standard"') }) diff --git a/src/providers/codex/translate/request.ts b/src/providers/codex/translate/request.ts index 58824ea..74ddf08 100644 --- a/src/providers/codex/translate/request.ts +++ b/src/providers/codex/translate/request.ts @@ -11,6 +11,15 @@ import { codexEffort, codexServiceTier } from "../../../config.ts" export type Effort = "none" | "low" | "medium" | "high" | "xhigh" export type ServiceTier = "priority" | "flex" +export class InvalidServiceTierError extends Error { + constructor(public serviceTier: string) { + super( + `Invalid service tier override: "${serviceTier}". Must be one of: ${Array.from(VALID_SERVICE_TIERS).join(", ")}`, + ) + this.name = "InvalidServiceTierError" + } +} + // Keep this aligned to the upstream Codex ResponsesApiRequest field set. // Do not add plausible-looking top-level fields without source support or a confirmed live test. export interface ResponsesRequest { @@ -73,6 +82,7 @@ export interface ResponsesTool { export interface TranslateOptions { sessionId?: string + serviceTier?: ServiceTier } const VALID_EFFORTS = new Set(["none", "low", "medium", "high", "xhigh"]) @@ -109,17 +119,19 @@ function resolveEffort(effort?: Effort): Effort | undefined { return override as Effort } -function resolveServiceTier(): ServiceTier | undefined { - const tier = codexServiceTier() - if (tier === undefined) return undefined +function normalizeServiceTier(tier: string): ServiceTier { if (!VALID_SERVICE_TIERS.has(tier)) { - throw new Error( - `Invalid service tier override: "${tier}". Must be one of: ${Array.from(VALID_SERVICE_TIERS).join(", ")}`, - ) + throw new InvalidServiceTierError(tier) } return tier === "flex" ? "flex" : "priority" } +function resolveServiceTier(modelServiceTier?: ServiceTier): ServiceTier | undefined { + const tier = codexServiceTier() + if (tier === undefined) return modelServiceTier + return normalizeServiceTier(tier) +} + export function translateRequest(req: AnthropicRequest, opts: TranslateOptions = {}): ResponsesRequest { const instructions = buildInstructions(req.system) const input = buildInput(req.messages) @@ -148,7 +160,7 @@ 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 serviceTier = resolveServiceTier() + const serviceTier = resolveServiceTier(opts.serviceTier) if (serviceTier) out.service_tier = serviceTier assertValidEffort(req.output_config?.effort) const effort = resolveEffort(toCodexEffort(req.output_config?.effort)) diff --git a/src/providers/registry.test.ts b/src/providers/registry.test.ts new file mode 100644 index 0000000..bd318f5 --- /dev/null +++ b/src/providers/registry.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "bun:test" +import { providerForModel } from "./registry.ts" +import { normalizeIncomingModel } from "../server.ts" + +describe("provider routing", () => { + it("routes fast Codex model aliases after context suffix normalization", () => { + const model = normalizeIncomingModel("gpt-5.4-fast[1m]") + + expect(model).toBe("gpt-5.4-fast") + expect(providerForModel(model)?.name).toBe("codex") + }) +}) diff --git a/src/server.ts b/src/server.ts index 960847b..7fa73e3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -105,7 +105,7 @@ function buildCtx(req: Request, reqId: string, providerName: string): RequestCon // 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 { +export function normalizeIncomingModel(model: string): string { return model.replace(/\[1m\]$/i, "") } From 7906e8b8d76f03df301e5f231d0b86bac9c49572 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Wed, 6 May 2026 22:05:21 +0300 Subject: [PATCH 95/98] add codex base url override Allow Codex traffic to target a configured Responses endpoint instead of always posting to the built-in chatgpt.com URL. This lets local debugging proxies, load balancers, and managed egress paths receive the same request body and headers without requiring a fork or binary patch. The new codex.baseUrl setting follows the existing config precedence and is reported in the startup override summary when active. --- README.md | 36 ++++++++++++++++++----------------- src/cli.ts | 3 +++ src/config.test.ts | 24 +++++++++++++++++++++++ src/config.ts | 25 +++++++++++++++++------- src/providers/codex/client.ts | 8 +++++--- 5 files changed, 69 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 511ae71..2c3c4d6 100644 --- a/README.md +++ b/README.md @@ -458,7 +458,8 @@ directory the auth tokens use, deliberately not `~/Library`) and at "userAgent": "claude-code-proxy/dev", "model": "gpt-5.4", "effort": "medium", - "serviceTier": "fast" + "serviceTier": "fast", + "baseUrl": "https://chatgpt.com/backend-api/codex/responses" }, "kimi": { "userAgent": "KimiCLI/1.37.0", @@ -472,22 +473,23 @@ directory the auth tokens use, deliberately not `~/Library`) and at } ``` -| Variable | Config key | Default | Purpose | -| ------------------------ | ------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------- | -| `PORT` | `port` | `18765` | Proxy listen port | -| `XDG_STATE_HOME` | — | `~/.local/state` | Base dir for `proxy.log` | -| `CCP_LOG_STDERR` | `log.stderr` | unset | Also mirror log lines to stderr | -| `CCP_LOG_VERBOSE` | `log.verbose` | unset | Log full request/response bodies + every SSE event | -| `CCP_KIMI_OAUTH_HOST` | `kimi.oauthHost` | `https://auth.kimi.com` | Override Kimi's OAuth host (debugging only) | -| `CCP_KIMI_BASE_URL` | `kimi.baseUrl` | `https://api.kimi.com/coding/v1` | Override Kimi's API base URL | -| `CCP_CODEX_MODEL` | `codex.model` | unset | Force all Codex requests to this model (`gpt-5.2`, `gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`) | -| `CCP_CODEX_EFFORT` | `codex.effort` | unset | Force all Codex requests to this reasoning effort (`none`, `low`, `medium`, `high`, `xhigh`) | -| `CCP_CODEX_SERVICE_TIER` | `codex.serviceTier` | unset | Force all Codex requests to this service tier (`fast`/`priority`, `flex`; `fast` is sent upstream as `priority`) | -| `CCP_CODEX_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` | +| Variable | Config key | Default | Purpose | +| ------------------------ | ------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `PORT` | `port` | `18765` | Proxy listen port | +| `XDG_STATE_HOME` | — | `~/.local/state` | Base dir for `proxy.log` | +| `CCP_LOG_STDERR` | `log.stderr` | unset | Also mirror log lines to stderr | +| `CCP_LOG_VERBOSE` | `log.verbose` | unset | Log full request/response bodies + every SSE event | +| `CCP_KIMI_OAUTH_HOST` | `kimi.oauthHost` | `https://auth.kimi.com` | Override Kimi's OAuth host (debugging only) | +| `CCP_KIMI_BASE_URL` | `kimi.baseUrl` | `https://api.kimi.com/coding/v1` | Override Kimi's API base URL | +| `CCP_CODEX_MODEL` | `codex.model` | unset | Force all Codex requests to this model (`gpt-5.2`, `gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`) | +| `CCP_CODEX_EFFORT` | `codex.effort` | unset | Force all Codex requests to this reasoning effort (`none`, `low`, `medium`, `high`, `xhigh`) | +| `CCP_CODEX_SERVICE_TIER` | `codex.serviceTier` | unset | Force all Codex requests to this service tier (`fast`/`priority`, `flex`; `fast` is sent upstream as `priority`) | +| `CCP_CODEX_BASE_URL` | `codex.baseUrl` | `https://chatgpt.com/backend-api/codex/responses` | Override the Codex Responses endpoint | +| `CCP_CODEX_ORIGINATOR` | `codex.originator` | `claude-code-proxy` | Override the `originator` header sent to Codex | +| `CCP_CODEX_USER_AGENT` | `codex.userAgent` | `claude-code-proxy/` | Override the `User-Agent` header sent to Codex | +| `CCP_KIMI_USER_AGENT` | `kimi.userAgent` | `KimiCLI/1.37.0` | Override the `User-Agent` header sent to Kimi | +| `CCP_ORIGINATOR` | — | `claude-code-proxy` | Fallback for `CCP_CODEX_ORIGINATOR` | +| `CCP_USER_AGENT` | — | unset | Fallback for `CCP_CODEX_USER_AGENT` and `CCP_KIMI_USER_AGENT` | A malformed `config.json` is reported on stderr and ignored; defaults are used in its place. Invalid types for individual keys are warned and skipped without diff --git a/src/cli.ts b/src/cli.ts index 495349b..68be8a9 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -137,6 +137,9 @@ function printConfigSummary(): void { if (cfg.env.CCP_CODEX_SERVICE_TIER) overrides.push("CCP_CODEX_SERVICE_TIER (env)") else if (fromFile.codex?.serviceTier) overrides.push("codex.serviceTier (config)") + if (cfg.env.CCP_CODEX_BASE_URL) overrides.push("CCP_CODEX_BASE_URL (env)") + else if (fromFile.codex?.baseUrl) overrides.push("codex.baseUrl (config)") + if (cfg.env.CCP_LOG_VERBOSE !== undefined) overrides.push("CCP_LOG_VERBOSE (env)") else if (fromFile.log?.verbose) overrides.push("log.verbose (config)") diff --git a/src/config.test.ts b/src/config.test.ts index 43bf511..de64414 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -10,6 +10,7 @@ import { codexModel, codexEffort, codexServiceTier, + codexBaseUrl, kimiUserAgent, kimiOauthHost, kimiBaseUrl, @@ -45,6 +46,7 @@ describe("config defaults", () => { expect(codexModel()).toBeUndefined() expect(codexEffort()).toBeUndefined() expect(codexServiceTier()).toBeUndefined() + expect(codexBaseUrl("default-codex-url")).toBe("default-codex-url") expect(kimiUserAgent("default-kimi-ua")).toBe("default-kimi-ua") expect(kimiOauthHost()).toBe("https://auth.kimi.com") expect(kimiBaseUrl()).toBe("https://api.kimi.com/coding/v1") @@ -75,6 +77,19 @@ describe("file overrides default", () => { expect(codexServiceTier()).toBe("fast") }) + it("codex.baseUrl from config.json", () => { + writeFileSync( + configPath, + JSON.stringify({ + codex: { baseUrl: "http://127.0.0.1:2455/backend-api/codex/responses" }, + }), + ) + setEnv({}) + expect(codexBaseUrl("default")).toBe( + "http://127.0.0.1:2455/backend-api/codex/responses", + ) + }) + it("kimi.oauthHost from config.json", () => { writeFileSync( configPath, @@ -113,6 +128,15 @@ describe("env overrides file", () => { expect(codexServiceTier()).toBe("fast") }) + it("CCP_CODEX_BASE_URL env wins over config", () => { + writeFileSync( + configPath, + JSON.stringify({ codex: { baseUrl: "http://127.0.0.1:2455/file" } }), + ) + setEnv({ CCP_CODEX_BASE_URL: "http://127.0.0.1:2455/env" }) + expect(codexBaseUrl("default")).toBe("http://127.0.0.1:2455/env") + }) + it("CCP_USER_AGENT env (generic fallback) is preferred over file", () => { writeFileSync( configPath, diff --git a/src/config.ts b/src/config.ts index daae3b7..221ba4b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,6 +17,7 @@ export interface FileConfig { model?: string effort?: string serviceTier?: string + baseUrl?: string } kimi?: { userAgent?: string @@ -88,13 +89,18 @@ function validate(raw: unknown): FileConfig { return acc as NonNullable } - const codex = validateStringSection("codex", ["originator", "userAgent", "model", "effort", "serviceTier"], { - originator: "string", - userAgent: "string", - model: "string", - effort: "string", - serviceTier: "string", - }) + const codex = validateStringSection( + "codex", + ["originator", "userAgent", "model", "effort", "serviceTier", "baseUrl"], + { + originator: "string", + userAgent: "string", + model: "string", + effort: "string", + serviceTier: "string", + baseUrl: "string", + }, + ) if (codex) out.codex = codex const kimi = validateStringSection("kimi", ["userAgent", "oauthHost", "baseUrl"], { @@ -200,6 +206,11 @@ export function codexServiceTier(): string | undefined { return emptyOrUnset(c.env.CCP_CODEX_SERVICE_TIER) ?? emptyOrUnset(c.file.codex?.serviceTier) } +export function codexBaseUrl(defaultValue: string): string { + const c = getConfig() + return c.env.CCP_CODEX_BASE_URL ?? c.file.codex?.baseUrl ?? defaultValue +} + export function kimiUserAgent(defaultValue: string): string { const c = getConfig() return ( diff --git a/src/providers/codex/client.ts b/src/providers/codex/client.ts index 73f2f03..9d605f0 100644 --- a/src/providers/codex/client.ts +++ b/src/providers/codex/client.ts @@ -1,5 +1,5 @@ import { CODEX_API_ENDPOINT, ORIGINATOR as ORIGINATOR_DEFAULT } from "./auth/constants.ts" -import { codexOriginator, codexUserAgent } from "../../config.ts" +import { codexBaseUrl, codexOriginator, codexUserAgent } from "../../config.ts" declare const BUILD_VERSION: string | undefined const PROXY_VERSION = typeof BUILD_VERSION === "string" ? BUILD_VERSION : "dev" import { forceRefresh, getAuth } from "./auth/manager.ts" @@ -93,14 +93,16 @@ async function doFetch( headers.set("x-codex-window-id", `${sessionId}:0`) } + const codexUrl = codexBaseUrl(CODEX_API_ENDPOINT) + log.debug("posting to codex", { - url: CODEX_API_ENDPOINT, + url: codexUrl, model: body.model, inputCount: body.input.length, toolCount: body.tools?.length ?? 0, }) - return fetch(CODEX_API_ENDPOINT, { + return fetch(codexUrl, { method: "POST", headers, body: JSON.stringify(body), From 0f0a3da6fb61cbda4b9e800c68407364e19beada Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Wed, 6 May 2026 22:58:10 +0300 Subject: [PATCH 96/98] changelog: add v0.0.10 entry Document the user-facing Codex changes accumulated since v0.0.9 so the release notes cover service tiers, per-request fast model aliases, and the new upstream endpoint override. --- CHANGELOG.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2a70f8..286823c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,10 @@ # Changelog -## Unreleased +## v0.0.10 (2026-05-06) -- Added `codex.serviceTier` / `CCP_CODEX_SERVICE_TIER` to request Codex service - tiers. The Codex-style `fast` value is sent upstream as `priority`, matching - the first-party Codex client. -- Added `-fast` Codex model aliases such as `gpt-5.4-fast[1m]`, which request - fast mode per request without restarting the proxy. +- Codex requests can now use `codex.serviceTier` or `CCP_CODEX_SERVICE_TIER` to request a service tier; `fast` is sent upstream as `priority`. +- Codex model names can now include `-fast`, such as `gpt-5.4-fast[1m]`, to request fast mode per request without restarting the proxy. +- Codex's upstream endpoint can now be overridden with `codex.baseUrl` or `CCP_CODEX_BASE_URL`. ## v0.0.9 (2026-05-03) From 1fab5d8e4559c936a08781d5c7476d47d9589ce7 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Wed, 6 May 2026 23:00:50 +0300 Subject: [PATCH 97/98] 0.0.10 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 93d8ea2..956a07f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-proxy", - "version": "0.0.9", + "version": "0.0.10", "license": "MIT", "type": "module", "bin": { From 87f93b0dfab5d1f184dcd0c7337e1a78c51b3240 Mon Sep 17 00:00:00 2001 From: torbitmatsudakh <283110459+torbitmatsudakh@users.noreply.github.com> Date: Sat, 9 May 2026 16:17:05 +0800 Subject: [PATCH 98/98] feat(anthropic): add native Anthropic provider with full Claude 4 model coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "anthropic" provider that forwards claude-* requests directly to api.anthropic.com using the Claude Pro OAuth token Claude Code stores in the macOS keychain. Registered in providers/registry.ts so the model router resolves claude-* names to this provider instead of returning "unknown model" 400s. Supported models cover the full Claude 4 family including legacy names that Claude Code 2.1.x still emits via /model and background tasks: - claude-opus-4-7, claude-opus-4-6 - claude-sonnet-4-6, claude-sonnet-4-5 - claude-haiku-4-5, claude-haiku-4-5-20251001, claude-haiku-4-4 Authentication is read-only against the Claude Code keychain entry — the proxy never stores or refreshes tokens itself; refresh is delegated to Claude Code. Streaming and count_tokens endpoints are passed through without translation since the request/response shape already matches Anthropic's API. This unblocks running gpt-5.5 (codex) and claude-opus-4-7 (anthropic) in the same Claude Code session — pick the right model per task via /model. --- src/providers/anthropic/auth/token-store.ts | 37 ++++++ src/providers/anthropic/client.ts | 34 ++++++ src/providers/anthropic/index.ts | 123 ++++++++++++++++++++ src/providers/registry.ts | 2 + 4 files changed, 196 insertions(+) create mode 100644 src/providers/anthropic/auth/token-store.ts create mode 100644 src/providers/anthropic/client.ts create mode 100644 src/providers/anthropic/index.ts diff --git a/src/providers/anthropic/auth/token-store.ts b/src/providers/anthropic/auth/token-store.ts new file mode 100644 index 0000000..b656c49 --- /dev/null +++ b/src/providers/anthropic/auth/token-store.ts @@ -0,0 +1,37 @@ +import { keychainGet } from "../../../keychain.ts" +import { userInfo } from "node:os" + +const KEYCHAIN_SERVICE = "Claude Code-credentials" + +interface ClaudeOAuthToken { + accessToken: string + refreshToken: string + expiresAt?: number +} + +interface ClaudeCredentials { + claudeAiOauth: ClaudeOAuthToken +} + +export function loadToken(): string | undefined { + const account = userInfo().username + let raw: string | undefined + try { + raw = keychainGet(KEYCHAIN_SERVICE, account) + } catch { + return undefined + } + if (!raw) return undefined + try { + const creds = JSON.parse(raw) as ClaudeCredentials + return creds.claudeAiOauth?.accessToken + } catch { + return undefined + } +} + +export function tokenStatus(): { found: boolean; account: string } { + const account = userInfo().username + const token = loadToken() + return { found: !!token, account } +} diff --git a/src/providers/anthropic/client.ts b/src/providers/anthropic/client.ts new file mode 100644 index 0000000..2cfc63e --- /dev/null +++ b/src/providers/anthropic/client.ts @@ -0,0 +1,34 @@ +import { loadToken } from "./auth/token-store.ts" + +export const ANTHROPIC_BASE_URL = "https://api.anthropic.com" + +export class AnthropicError extends Error { + constructor( + public readonly status: number, + public readonly detail: string, + ) { + super(`Anthropic ${status}: ${detail}`) + } +} + +export async function postAnthropic( + path: string, + body: unknown, + signal?: AbortSignal, +): Promise { + const token = loadToken() + if (!token) { + throw new AnthropicError(401, "No Claude Code credentials found in keychain. Run Claude Code at least once to authenticate.") + } + + return fetch(`${ANTHROPIC_BASE_URL}${path}`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify(body), + signal, + }) +} diff --git a/src/providers/anthropic/index.ts b/src/providers/anthropic/index.ts new file mode 100644 index 0000000..a164d6e --- /dev/null +++ b/src/providers/anthropic/index.ts @@ -0,0 +1,123 @@ +import type { Provider, RequestContext, CliHandlers } from "../types.ts" +import type { AnthropicRequest } from "../../anthropic/schema.ts" +import { postAnthropic, AnthropicError } from "./client.ts" +import { tokenStatus } from "./auth/token-store.ts" + +// These are the real Anthropic model IDs. By claiming them here, requests +// using these models are routed to api.anthropic.com instead of codex/kimi. +export const SUPPORTED_MODELS = new Set([ + // Latest generation + "claude-opus-4-7", + "claude-sonnet-4-6", + "claude-haiku-4-5", + "claude-haiku-4-5-20251001", + // Previous generation (still routed to api.anthropic.com when explicitly requested + // via /model command in Claude Code; older Claude Code versions also default to these) + "claude-opus-4-6", + "claude-sonnet-4-5", + "claude-haiku-4-4", +]) + +function jsonError(status: number, type: string, message: string): Response { + return new Response(JSON.stringify({ type: "error", error: { type, message } }), { + status, + headers: { "content-type": "application/json" }, + }) +} + +// Strip Claude Code-specific extension fields that the official API doesn't accept +function stripExtensions(body: AnthropicRequest): Omit { + const { context_management: _, output_config: __, ...clean } = body + return clean +} + +async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Promise { + const log = ctx.childLogger("provider.anthropic") + log.debug("forwarding to anthropic", { model: body.model }) + + let upstream: Response + try { + upstream = await postAnthropic("/v1/messages", stripExtensions(body), ctx.signal) + } catch (err) { + if (err instanceof AnthropicError) { + log.warn("anthropic error", { status: err.status, detail: err.detail }) + const type = err.status === 401 || err.status === 403 ? "authentication_error" : "api_error" + if (err.status === 429) { + return jsonError(429, "rate_limit_error", err.detail) + } + return jsonError(err.status, type, err.detail) + } + throw err + } + + if (!upstream.ok && upstream.status !== 200) { + const body_text = await upstream.text() + log.warn("anthropic upstream error", { status: upstream.status, body: body_text }) + // Pass through the error response as-is — it's already Anthropic format + return new Response(body_text, { + status: upstream.status, + headers: { "content-type": "application/json" }, + }) + } + + // Pass through response as-is (already in Anthropic format, streaming or not) + const headers = new Headers() + headers.set("content-type", upstream.headers.get("content-type") ?? "application/json") + if (upstream.headers.has("cache-control")) { + headers.set("cache-control", upstream.headers.get("cache-control")!) + } + + return new Response(upstream.body, { + status: upstream.status, + headers, + }) +} + +async function handleCountTokens(body: AnthropicRequest, ctx: RequestContext): Promise { + const log = ctx.childLogger("provider.anthropic") + log.debug("count_tokens via anthropic", { model: body.model }) + + // count_tokens endpoint only accepts a subset of fields; also strip CC extensions + const { max_tokens: _, stream: __, ...countBody } = stripExtensions(body) as typeof body & { max_tokens?: unknown; stream?: unknown } + + let upstream: Response + try { + upstream = await postAnthropic("/v1/messages/count_tokens", countBody, ctx.signal) + } catch (err) { + if (err instanceof AnthropicError) { + return jsonError(err.status, "api_error", err.detail) + } + throw err + } + + const text = await upstream.text() + return new Response(text, { + status: upstream.status, + headers: { "content-type": "application/json" }, + }) +} + +const cli: CliHandlers = { + async status() { + const { found, account } = tokenStatus() + if (!found) { + console.log(`No Claude Code credentials found for account "${account}"`) + console.log("Run Claude Code at least once to authenticate, then retry.") + process.exit(1) + } + console.log(`Account: ${account}`) + console.log("Token: found in keychain (managed by Claude Code)") + console.log("Note: token refresh is handled automatically by Claude Code") + }, + async logout() { + console.log("Anthropic tokens are managed by Claude Code — use Claude Code to log out.") + }, +} + +export const anthropicProvider: Provider = { + name: "anthropic", + supportedModels: SUPPORTED_MODELS, + handleMessages, + handleCountTokens, + cli, +} diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 25de82b..e7ab9c2 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1,8 +1,10 @@ import type { Provider } from "./types.ts" import { codexProvider } from "./codex/index.ts" import { kimiProvider } from "./kimi/index.ts" +import { anthropicProvider } from "./anthropic/index.ts" const PROVIDERS: Record = { + anthropic: anthropicProvider, codex: codexProvider, kimi: kimiProvider, }