From fe2eb721ec1d0cb24bd6b1d0f221411a52780997 Mon Sep 17 00:00:00 2001 From: Nick Sullivan Date: Wed, 3 Jun 2026 15:20:34 -0500 Subject: [PATCH] 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). --- .env.example | 13 +++++++ open-sse/config/anthropicHeaders.ts | 43 ++++++++++++++++++++++- 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 | 52 ++++++++++++++++++++++++++++ 6 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 tests/unit/claude-entrypoint.test.ts diff --git a/.env.example b/.env.example index 0af00dcd5440..81478f27dfc9 100644 --- a/.env.example +++ b/.env.example @@ -699,6 +699,19 @@ CLAUDE_USER_AGENT="claude-cli/2.1.158 (external, cli)" # stream with a misleading 400 out-of-extra-usage placeholder. Set to true to # forward the original names verbatim (debugging only). # CLAUDE_DISABLE_TOOL_NAME_CLOAK=false + +# 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 CODEX_USER_AGENT="codex-cli/0.132.0 (Windows 10.0.26200; x64)" GITHUB_USER_AGENT="GitHubCopilotChat/0.45.1" ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0" diff --git a/open-sse/config/anthropicHeaders.ts b/open-sse/config/anthropicHeaders.ts index 091b00a6882e..49348bc68748 100644 --- a/open-sse/config/anthropicHeaders.ts +++ b/open-sse/config/anthropicHeaders.ts @@ -30,6 +30,47 @@ export const ANTHROPIC_BETA_CLAUDE_OAUTH = [ ].join(","); export const CLAUDE_CLI_VERSION = "2.1.158"; -export const CLAUDE_CLI_USER_AGENT = `claude-cli/${CLAUDE_CLI_VERSION} (external, 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. This is the same wire image OmniRoute's CC-Compatible provider + * already uses (`claude-cli/2.1.158 (external, sdk-cli)`). + */ +export type ClaudeEntrypoint = "cli" | "sdk-cli"; +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(version: string): string { + return `claude-cli/${version} (external, ${getClaudeEntrypoint()})`; +} + +export const CLAUDE_CLI_USER_AGENT = claudeCliUserAgent(CLAUDE_CLI_VERSION); export const CLAUDE_CLI_STAINLESS_PACKAGE_VERSION = "0.94.0"; export const CLAUDE_CLI_STAINLESS_RUNTIME_VERSION = "v24.3.0"; diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 6324f7bb9408..624fe3af6224 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -52,6 +52,7 @@ import { stainlessRuntimeVersion, stripProxyToolPrefix, } from "./claudeIdentity.ts"; +import { getClaudeEntrypoint, claudeCliUserAgent } from "../config/anthropicHeaders.ts"; /** * Sanitizes a custom API path to prevent path traversal attacks. @@ -949,7 +950,7 @@ export class BaseExecutor { // cache_control — that belongs on upstream prompt blocks at [2..]. const dayStamp = new Date().toISOString().slice(0, 10); const buildHash = buildHashFor(CLAUDE_CODE_VERSION, dayStamp); - const billingLine = `x-anthropic-billing-header: cc_version=${CLAUDE_CODE_VERSION}.${buildHash}; cc_entrypoint=cli; cch=00000;`; + const billingLine = `x-anthropic-billing-header: cc_version=${CLAUDE_CODE_VERSION}.${buildHash}; cc_entrypoint=${getClaudeEntrypoint()}; cch=00000;`; const SENTINEL = "You are Claude Code, Anthropic's official CLI for Claude."; const sysBlocks: Array> = Array.isArray(tb.system) @@ -1009,7 +1010,7 @@ export class BaseExecutor { "anthropic-beta": selectBetaFlags(tb), "anthropic-dangerous-direct-browser-access": "true", "x-app": "cli", - "User-Agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`, + "User-Agent": claudeCliUserAgent(CLAUDE_CODE_VERSION), "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 dc3140b1b759..c4a3a7ac4d17 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"; // ---------- Versions ------------------------------------------------------ @@ -151,7 +152,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)"); + }); +});