Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion open-sse/config/anthropicHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ export function claudeCliUserAgent(version: string): string {
return `claude-cli/${version} (external, ${getClaudeEntrypoint()})`;
}

export const CLAUDE_CLI_USER_AGENT = claudeCliUserAgent(CLAUDE_CLI_VERSION);
// Static registry constant — always "cli" regardless of CLAUDE_CC_ENTRYPOINT.
// getClaudeCliHeaders() uses this for API-key connections; the dynamic entrypoint
// only applies to native Claude OAuth call sites (see base.ts ccHeaders block).
export const CLAUDE_CLI_USER_AGENT = `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`;
export const CLAUDE_CLI_STAINLESS_PACKAGE_VERSION = "0.94.0";
export const CLAUDE_CLI_STAINLESS_RUNTIME_VERSION = "v24.3.0";
16 changes: 10 additions & 6 deletions open-sse/executors/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -939,14 +939,11 @@ export class BaseExecutor {

const seed = activeCredentials?.accessToken || activeCredentials?.apiKey || "anon";
const psd = activeCredentials?.providerSpecificData as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;

let identitySource:
| "upstream-metadata"
| "upstream-header"
| "synthesized"
| "synthesized-cloaked" = "synthesized";
"upstream-metadata" | "upstream-header" | "synthesized" | "synthesized-cloaked" =
"synthesized";
let sessionId: string;
let deviceId: string;
let accountUUID: string;
Expand Down Expand Up @@ -1181,6 +1178,13 @@ export class BaseExecutor {
}

mergeUpstreamExtraHeaders(finalHeaders, upstreamExtraHeaders);
// Reassert the configured OAuth billing UA after extra-headers merge.
// Operators can set a custom User-Agent in upstreamExtraHeaders, which
// mergeUpstreamExtraHeaders will apply — but for native Claude OAuth the
// User-Agent must match cc_entrypoint to keep billing header + UA in sync.
if (this.provider === "claude" && hasClaudeOAuthToken) {
finalHeaders["User-Agent"] = claudeCliUserAgent(CLAUDE_CODE_VERSION);
Comment thread
TechNickAI marked this conversation as resolved.
Outdated
}
const serializedBody = prl.parseBody(bodyString);
// #4307 — Preserve the non-enumerable tool-name cloak/remap reverse map
// (`_toolNameMap`, set on the live `transformedBody` by
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/claude-entrypoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import assert from "node:assert/strict";
import {
getClaudeEntrypoint,
claudeCliUserAgent,
CLAUDE_CLI_USER_AGENT,
CLAUDE_CLI_VERSION,
} from "../../open-sse/config/anthropicHeaders.ts";
import { mergeUpstreamExtraHeaders } from "../../open-sse/executors/base/headers.ts";

const ORIGINAL = process.env.CLAUDE_CC_ENTRYPOINT;

Expand Down Expand Up @@ -50,3 +53,37 @@ test("getClaudeEntrypoint falls back to cli on an invalid value", () => {
assert.equal(claudeCliUserAgent("2.1.158"), "claude-cli/2.1.158 (external, cli)");
});
});

// Regression guard: CLAUDE_CLI_USER_AGENT is used in getClaudeCliHeaders() for
// API-key connections. It must always be "cli" — never reflect CLAUDE_CC_ENTRYPOINT,
// which is an OAuth-only billing identity knob. Changing this to use claudeCliUserAgent()
// (dynamic) would leak the sdk-cli entrypoint into non-OAuth credential surfaces.
test("CLAUDE_CLI_USER_AGENT is always cli regardless of CLAUDE_CC_ENTRYPOINT", () => {
assert.equal(CLAUDE_CLI_USER_AGENT, `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`);
assert.ok(
!CLAUDE_CLI_USER_AGENT.includes("sdk-cli"),
"static registry UA must never carry sdk-cli"
);
});

// Regression guard: mergeUpstreamExtraHeaders can override User-Agent with a custom
// value set by the operator. For native Claude OAuth, the cc_entrypoint and User-Agent
// must remain in sync — so the dynamic claudeCliUserAgent() must be reasserted after
// the extra-headers merge (see base.ts). This test documents the override mechanism.
test("mergeUpstreamExtraHeaders can override User-Agent; claudeCliUserAgent reassertion restores OAuth UA", () => {
const version = "4.0.0";
const headers: Record<string, string> = {
"User-Agent": claudeCliUserAgent(version),
};
const expected = `claude-cli/${version} (external, ${getClaudeEntrypoint()})`;
assert.equal(headers["User-Agent"], expected);

// Operator sets a custom User-Agent via upstream extra headers — mergeUpstreamExtraHeaders
// will apply it, overriding the OAuth billing UA.
mergeUpstreamExtraHeaders(headers, { "User-Agent": "custom-proxy/1.0" });
assert.equal(headers["User-Agent"], "custom-proxy/1.0");

// The fix in base.ts reasserts claudeCliUserAgent after the merge for claude+OAuth paths.
headers["User-Agent"] = claudeCliUserAgent(version);
assert.equal(headers["User-Agent"], expected, "reassertion restores the OAuth billing UA");
});
Loading