From 076acd9c83f056f94440604c0d6059ccb78c1269 Mon Sep 17 00:00:00 2001 From: Nick Sullivan Date: Wed, 3 Jun 2026 15:20:34 -0500 Subject: [PATCH 1/3] feat(claude): configurable OAuth billing entrypoint via CLAUDE_CC_ENTRYPOINT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic meters `cli`-labelled third-party OAuth traffic against the account's extra-usage balance ("You're out of extra usage" 400s), while the Agent SDK entrypoint (`sdk-cli`) counts as plan usage. This affects even the official Claude Code (anthropics/claude-code#45203). Add an opt-in `CLAUDE_CC_ENTRYPOINT` env var (`cli`|`sdk-cli`, default `cli` — no behavior change), routed through one helper that sets both the `cc_entrypoint` field of `x-anthropic-billing-header` and the matching `(external, )` claude-cli User-Agent suffix together, across all native Claude OAuth sites (executor, identity bootstrap, oauth provider). Same wire image the CC-Compatible provider already uses. Only the native Claude OAuth path is affected; API-key requests are unchanged. Adds unit tests (default / sdk-cli / explicit-cli / whitespace / invalid-fallback). (cherry picked from commit fe2eb721ec1d0cb24bd6b1d0f221411a52780997) (cherry picked from commit 7cc20669deb07638308505c4ea3c4a92732635c3) --- .env.example | 13 ++++++++ open-sse/config/anthropicHeaders.ts | 47 +++++++++++++++++++++++++- open-sse/executors/base.ts | 5 +-- open-sse/executors/claudeIdentity.ts | 3 +- src/lib/oauth/providers/claude.ts | 3 +- tests/unit/claude-entrypoint.test.ts | 49 ++++++++++++++++++++++++++++ 6 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 tests/unit/claude-entrypoint.test.ts diff --git a/.env.example b/.env.example index 4cb877425cc3..c94b8d39eae2 100644 --- a/.env.example +++ b/.env.example @@ -1066,6 +1066,19 @@ CLAUDE_USER_AGENT="claude-cli/2.1.219 (external, cli)" # CLAUDE_DISABLE_TOOL_NAME_CLOAK=false CODEX_USER_AGENT="codex-cli/0.144.1 (Windows 10.0.26200; x64)" GITHUB_USER_AGENT="GitHubCopilotChat/0.54.0" + +# Anthropic billing "entrypoint" label for native Claude OAuth (subscription) +# requests. Sets the cc_entrypoint field of x-anthropic-billing-header AND the +# "(external, )" claude-cli User-Agent suffix together, so the wire +# image stays consistent. Values: +# cli — official Claude Code CLI (default; current behavior) +# sdk-cli — Claude Agent SDK (same wire image as the CC-Compatible provider) +# Anthropic currently meters some cli-labelled third-party OAuth traffic against +# the account's *extra usage* balance instead of plan limits +# (anthropics/claude-code#45203). If subscription requests fail with +# "You're out of extra usage", set this to sdk-cli. API-key requests are +# unaffected. Used by: open-sse/config/anthropicHeaders.ts (getClaudeEntrypoint). +# CLAUDE_CC_ENTRYPOINT=cli ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0" KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0" # KIRO_VERIFY_FULL_CRC=false # opt-in: full per-frame message CRC validation on the Kiro event stream (debug corrupted streams; prelude CRC + TLS already protect framing) diff --git a/open-sse/config/anthropicHeaders.ts b/open-sse/config/anthropicHeaders.ts index 6a98e4aa98ae..233306371a1e 100644 --- a/open-sse/config/anthropicHeaders.ts +++ b/open-sse/config/anthropicHeaders.ts @@ -1,4 +1,5 @@ import { + type ClaudeCodeEntrypoint, CLAUDE_CODE_CLIENT_BILLING_VERSION, CLAUDE_CODE_CLIENT_BUILD_REVISION, CLAUDE_CODE_CLIENT_VERSION, @@ -141,6 +142,50 @@ export function normalizeAnthropicHeaderVariants(headers: Record export const CLAUDE_CLI_VERSION = CLAUDE_CODE_CLIENT_VERSION; export const CLAUDE_CLI_BUILD_REVISION = CLAUDE_CODE_CLIENT_BUILD_REVISION; export const CLAUDE_CLI_BILLING_VERSION = CLAUDE_CODE_CLIENT_BILLING_VERSION; -export const CLAUDE_CLI_USER_AGENT = getClaudeCodeUserAgent("cli"); + +/** + * Anthropic billing "entrypoint" label sent on native Claude OAuth requests: + * the `cc_entrypoint=` field of `x-anthropic-billing-header` and the + * `(external, )` suffix of the claude-cli User-Agent. + * + * - `cli` — mirrors the official Claude Code CLI (default; current behavior). + * - `sdk-cli` — mirrors the Claude Agent SDK. + * + * Anthropic currently meters some `cli`-labelled third-party OAuth traffic + * against the account's *extra usage* balance instead of plan limits + * (see anthropics/claude-code#45203). Operators whose subscription requests get + * rejected with "You're out of extra usage" can set `CLAUDE_CC_ENTRYPOINT=sdk-cli` + * to route through the Agent SDK entrypoint, which is currently classified as + * plan usage. + * + * FORK PATCH (CLAUDE_CC_ENTRYPOINT). Upstream >=3.8.49 owns the wire constants + * and the `ClaudeCodeEntrypoint` type + `getClaudeCodeUserAgent(entrypoint)` + * builder; this only adds the env-var override on top of them. + */ +export type ClaudeEntrypoint = ClaudeCodeEntrypoint; +const VALID_CLAUDE_ENTRYPOINTS: readonly ClaudeEntrypoint[] = ["cli", "sdk-cli"]; +let warnedInvalidClaudeEntrypoint = false; + +export function getClaudeEntrypoint(): ClaudeEntrypoint { + const raw = process.env.CLAUDE_CC_ENTRYPOINT?.trim(); + if (!raw) return "cli"; + if ((VALID_CLAUDE_ENTRYPOINTS as readonly string[]).includes(raw)) { + return raw as ClaudeEntrypoint; + } + if (!warnedInvalidClaudeEntrypoint) { + warnedInvalidClaudeEntrypoint = true; + console.warn( + `[claude] Ignoring invalid CLAUDE_CC_ENTRYPOINT="${raw}" (expected "cli" or "sdk-cli"); using "cli".` + ); + } + return "cli"; +} + +/** Builds the claude-cli User-Agent with the configured entrypoint suffix. */ +export function claudeCliUserAgent(): string { + return getClaudeCodeUserAgent(getClaudeEntrypoint()); +} + +export const CLAUDE_CLI_USER_AGENT = claudeCliUserAgent(); export const CLAUDE_CLI_STAINLESS_PACKAGE_VERSION = CLAUDE_CODE_SDK_PACKAGE_VERSION; export const CLAUDE_CLI_STAINLESS_RUNTIME_VERSION = CLAUDE_CODE_RUNTIME_VERSION; diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index d30e122d1cd2..6abb0326fc2a 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -114,6 +114,7 @@ import { sanitizeReasoningEffortForProvider } from "./base/reasoningEffort.ts"; // Reasoning-effort sanitation extracted to a pure leaf; re-exported for external // importers (mimoThinking service + tests) that import it from "./base.ts". export { sanitizeReasoningEffortForProvider } from "./base/reasoningEffort.ts"; +import { getClaudeEntrypoint, claudeCliUserAgent } from "../config/anthropicHeaders.ts"; /** * Sanitizes a custom API path to prevent path traversal attacks. @@ -1125,7 +1126,7 @@ export class BaseExecutor { // system[0] (billing) and system[1] (sentinel) must not carry // cache_control — that belongs on upstream prompt blocks at [2..]. - const billingLine = `x-anthropic-billing-header: cc_version=${CLAUDE_CLI_BILLING_VERSION}; cc_entrypoint=cli; cch=00000;`; + const billingLine = `x-anthropic-billing-header: cc_version=${CLAUDE_CLI_BILLING_VERSION}; cc_entrypoint=${getClaudeEntrypoint()}; cch=00000;`; const SENTINEL = "You are Claude Code, Anthropic's official CLI for Claude."; const sysBlocks: Array> = Array.isArray(tb.system) @@ -1195,7 +1196,7 @@ export class BaseExecutor { ), "anthropic-dangerous-direct-browser-access": "true", "x-app": "cli", - "User-Agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`, + "User-Agent": claudeCliUserAgent(), "X-Stainless-Package-Version": CLAUDE_CODE_STAINLESS_VERSION, "X-Stainless-Timeout": "600", "accept-encoding": "gzip, deflate, br, zstd", diff --git a/open-sse/executors/claudeIdentity.ts b/open-sse/executors/claudeIdentity.ts index c9544c6743fc..36f9ffd854f8 100644 --- a/open-sse/executors/claudeIdentity.ts +++ b/open-sse/executors/claudeIdentity.ts @@ -9,6 +9,7 @@ */ import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { claudeCliUserAgent } from "../config/anthropicHeaders.ts"; import { CLAUDE_CODE_CLIENT_VERSION, @@ -156,7 +157,7 @@ export async function fetchClaudeBootstrap(accessToken: string): Promise void) { + if (value === undefined) delete process.env.CLAUDE_CC_ENTRYPOINT; + else process.env.CLAUDE_CC_ENTRYPOINT = value; + try { + fn(); + } finally { + if (ORIGINAL === undefined) delete process.env.CLAUDE_CC_ENTRYPOINT; + else process.env.CLAUDE_CC_ENTRYPOINT = ORIGINAL; + } +} + +test("getClaudeEntrypoint defaults to cli when unset", () => { + withEntrypoint(undefined, () => { + assert.equal(getClaudeEntrypoint(), "cli"); + assert.equal(claudeCliUserAgent("2.1.158"), "claude-cli/2.1.158 (external, cli)"); + }); +}); + +test("getClaudeEntrypoint honors sdk-cli (cc_entrypoint + UA stay consistent)", () => { + withEntrypoint("sdk-cli", () => { + assert.equal(getClaudeEntrypoint(), "sdk-cli"); + assert.equal(claudeCliUserAgent("2.1.158"), "claude-cli/2.1.158 (external, sdk-cli)"); + }); +}); + +test("getClaudeEntrypoint honors explicit cli", () => { + withEntrypoint("cli", () => { + assert.equal(getClaudeEntrypoint(), "cli"); + }); +}); + +test("getClaudeEntrypoint trims surrounding whitespace", () => { + withEntrypoint(" sdk-cli ", () => { + assert.equal(getClaudeEntrypoint(), "sdk-cli"); + }); +}); + +test("getClaudeEntrypoint falls back to cli on an invalid value", () => { + withEntrypoint("bogus", () => { + assert.equal(getClaudeEntrypoint(), "cli"); + assert.equal(claudeCliUserAgent("2.1.158"), "claude-cli/2.1.158 (external, cli)"); + }); +}); From e91eb8d8af7a4f1f636e2b1710f29b87713771a2 Mon Sep 17 00:00:00 2001 From: Nick Sullivan Date: Mon, 3 Aug 2026 16:44:21 -0500 Subject: [PATCH 2/3] test(claude): align entrypoint patch with current client version --- open-sse/executors/claudeIdentity.ts | 2 +- src/lib/oauth/providers/claude.ts | 2 +- tests/unit/claude-entrypoint.test.ts | 12 ++++++++---- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/open-sse/executors/claudeIdentity.ts b/open-sse/executors/claudeIdentity.ts index 36f9ffd854f8..3de50261eee2 100644 --- a/open-sse/executors/claudeIdentity.ts +++ b/open-sse/executors/claudeIdentity.ts @@ -157,7 +157,7 @@ export async function fetchClaudeBootstrap(accessToken: string): Promise void) { test("getClaudeEntrypoint defaults to cli when unset", () => { withEntrypoint(undefined, () => { assert.equal(getClaudeEntrypoint(), "cli"); - assert.equal(claudeCliUserAgent("2.1.158"), "claude-cli/2.1.158 (external, cli)"); + assert.equal(claudeCliUserAgent(), `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`); }); }); test("getClaudeEntrypoint honors sdk-cli (cc_entrypoint + UA stay consistent)", () => { withEntrypoint("sdk-cli", () => { assert.equal(getClaudeEntrypoint(), "sdk-cli"); - assert.equal(claudeCliUserAgent("2.1.158"), "claude-cli/2.1.158 (external, sdk-cli)"); + assert.equal(claudeCliUserAgent(), `claude-cli/${CLAUDE_CLI_VERSION} (external, sdk-cli)`); }); }); @@ -44,6 +48,6 @@ test("getClaudeEntrypoint trims surrounding whitespace", () => { test("getClaudeEntrypoint falls back to cli on an invalid value", () => { withEntrypoint("bogus", () => { assert.equal(getClaudeEntrypoint(), "cli"); - assert.equal(claudeCliUserAgent("2.1.158"), "claude-cli/2.1.158 (external, cli)"); + assert.equal(claudeCliUserAgent(), `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`); }); }); From b4afb6a7b9d940bb62ec9d7339334f7c4ce2a264 Mon Sep 17 00:00:00 2001 From: Nick Sullivan Date: Mon, 3 Aug 2026 16:56:44 -0500 Subject: [PATCH 3/3] fix(claude): scope entrypoint override to OAuth wire identity --- open-sse/config/anthropicHeaders.ts | 4 +++- open-sse/executors/base.ts | 6 ++++++ tests/unit/claude-entrypoint.test.ts | 6 ++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/open-sse/config/anthropicHeaders.ts b/open-sse/config/anthropicHeaders.ts index 233306371a1e..f16fa448e24f 100644 --- a/open-sse/config/anthropicHeaders.ts +++ b/open-sse/config/anthropicHeaders.ts @@ -186,6 +186,8 @@ export function claudeCliUserAgent(): string { return getClaudeCodeUserAgent(getClaudeEntrypoint()); } -export const CLAUDE_CLI_USER_AGENT = claudeCliUserAgent(); +// Static registry/API-key identity remains the official CLI wire image. +// CLAUDE_CC_ENTRYPOINT applies only at native Claude OAuth call sites. +export const CLAUDE_CLI_USER_AGENT = getClaudeCodeUserAgent("cli"); export const CLAUDE_CLI_STAINLESS_PACKAGE_VERSION = CLAUDE_CODE_SDK_PACKAGE_VERSION; export const CLAUDE_CLI_STAINLESS_RUNTIME_VERSION = CLAUDE_CODE_RUNTIME_VERSION; diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 6abb0326fc2a..150fc3221d51 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1330,6 +1330,12 @@ export class BaseExecutor { } mergeUpstreamExtraHeaders(finalHeaders, upstreamExtraHeaders); + // The OAuth billing entrypoint and Claude CLI User-Agent are one wire + // identity. Operator/model extra headers are merged above for all + // providers, but must not split those two fields on native Claude OAuth. + if (this.provider === "claude" && hasClaudeOAuthToken) { + setUserAgentHeader(finalHeaders, claudeCliUserAgent()); + } if (this.provider === "cline" || this.provider === "clinepass") { applyClineProtocolHeaders(finalHeaders, { taskId: headers["X-Task-ID"], diff --git a/tests/unit/claude-entrypoint.test.ts b/tests/unit/claude-entrypoint.test.ts index 973f2ba6a542..964b4210cb9a 100644 --- a/tests/unit/claude-entrypoint.test.ts +++ b/tests/unit/claude-entrypoint.test.ts @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + CLAUDE_CLI_USER_AGENT, CLAUDE_CLI_VERSION, getClaudeEntrypoint, claudeCliUserAgent, @@ -30,6 +31,11 @@ test("getClaudeEntrypoint honors sdk-cli (cc_entrypoint + UA stay consistent)", withEntrypoint("sdk-cli", () => { assert.equal(getClaudeEntrypoint(), "sdk-cli"); assert.equal(claudeCliUserAgent(), `claude-cli/${CLAUDE_CLI_VERSION} (external, sdk-cli)`); + assert.equal( + CLAUDE_CLI_USER_AGENT, + `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`, + "static provider headers must remain CLI-labelled" + ); }); });