diff --git a/src/services/api/client.ts b/src/services/api/client.ts index 5bf9b506d..e6f9b11a0 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -15,7 +15,7 @@ import { standardEffortToOpenAI, type OpenAIEffortLevel, } from 'src/utils/effort.js' -import { getUserAgent } from 'src/utils/http.js' +import { getProviderApiUserAgent } from 'src/utils/http.js' import { getSmallFastModel } from 'src/utils/model/model.js' import { getAPIProvider, @@ -41,10 +41,12 @@ import { getXaiBaseUrlOverride, resolveEnvOnlyProviderRouteId, } from '../../integrations/routeMetadata.js' +import { resolveProfileRoute } from '../../integrations/profileResolver.js' import { shouldUseFirstPartyAnthropicAuth, type ProviderOverride, } from './authRouting.js' +import { getActiveProviderProfile } from '../../utils/providerProfiles.js' const importRuntimeModule = new Function( 'specifier', @@ -163,6 +165,22 @@ function applyXaiEnvOnlyDefaults(): void { delete process.env.OPENAI_AUTH_HEADER_VALUE } +function getAppliedProviderProfileRouteId(): string | undefined { + if (process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED !== '1') { + return undefined + } + + const activeProfile = getActiveProviderProfile() + if ( + !activeProfile || + activeProfile.id !== process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID + ) { + return undefined + } + + return resolveProfileRoute(activeProfile.provider).routeId +} + export async function getAnthropicClient({ apiKey, maxRetries, @@ -189,10 +207,17 @@ export async function getAnthropicClient({ const containerId = process.env.CLAUDE_CODE_CONTAINER_ID const remoteSessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID const clientApp = process.env.CLAUDE_AGENT_SDK_CLIENT_APP + const shouldUseFirstPartyAuth = + shouldUseFirstPartyAnthropicAuth(providerOverride) const customHeaders = getCustomHeaders() const defaultHeaders: { [key: string]: string } = { 'x-app': 'cli', - 'User-Agent': getUserAgent(), + 'User-Agent': getProviderApiUserAgent({ + isFirstParty: shouldUseFirstPartyAuth, + providerRouteId: providerOverride + ? undefined + : getAppliedProviderProfileRouteId(), + }), 'X-Claude-Code-Session-Id': getSessionId(), ...customHeaders, ...(containerId ? { 'x-claude-remote-container-id': containerId } : {}), @@ -216,9 +241,6 @@ export async function getAnthropicClient({ defaultHeaders['x-anthropic-additional-protection'] = 'true' } - const shouldUseFirstPartyAuth = - shouldUseFirstPartyAnthropicAuth(providerOverride) - if (shouldUseFirstPartyAuth) { logForDebugging('[API:auth] OAuth token check starting') await checkAndRefreshOAuthTokenIfNeeded() diff --git a/src/services/mcp/client.ts b/src/services/mcp/client.ts index 06e911e8a..59a034346 100644 --- a/src/services/mcp/client.ts +++ b/src/services/mcp/client.ts @@ -74,6 +74,7 @@ import { getMCPUserAgent } from '../../utils/http.js' import { maybeNotifyIDEConnected } from '../../utils/ide.js' import { maybeResizeAndDownsampleImageBuffer } from '../../utils/imageResizer.js' import { logMCPDebug, logMCPError } from '../../utils/log.js' +import { getPublicBuildVersion } from '../../utils/userAgent.js' import { getBinaryBlobSavedMessage, getFormatDescription, @@ -1006,7 +1007,7 @@ export const connectToServer = memoize( // gate features on the upstream client identifier. name: 'claude-code', title: 'OpenClaude', - version: MACRO.VERSION ?? 'unknown', + version: getPublicBuildVersion(), description: 'OpenClaude — coding-agent CLI for any LLM provider', websiteUrl: PRODUCT_URL, }, @@ -3343,7 +3344,7 @@ export async function setupSdkMcpClients( // gate features on the upstream client identifier. name: 'claude-code', title: 'OpenClaude', - version: MACRO.VERSION ?? 'unknown', + version: getPublicBuildVersion(), description: 'OpenClaude — coding-agent CLI for any LLM provider', websiteUrl: PRODUCT_URL, }, diff --git a/src/utils/http.test.ts b/src/utils/http.test.ts new file mode 100644 index 000000000..9954b1497 --- /dev/null +++ b/src/utils/http.test.ts @@ -0,0 +1,102 @@ +import { afterEach, expect, test } from 'bun:test' +import { getProviderApiUserAgent, getUserAgent } from './http.js' + +const originalMacro = (globalThis as Record).MACRO +const compatibilityVersion = '99.0.0' +const publicBuildVersion = '1.2.3-open' +const originalEnv = { + USER_TYPE: process.env.USER_TYPE, + CLAUDE_CODE_ENTRYPOINT: process.env.CLAUDE_CODE_ENTRYPOINT, + CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, +} + +afterEach(() => { + ;(globalThis as Record).MACRO = originalMacro + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } +}) + +test('uses claude-cli token for first-party API compatibility', () => { + ;(globalThis as Record).MACRO = { + VERSION: compatibilityVersion, + DISPLAY_VERSION: publicBuildVersion, + } + process.env.USER_TYPE = 'test-user' + process.env.CLAUDE_CODE_ENTRYPOINT = 'cli' + + expect(getUserAgent()).toContain(`claude-cli/${compatibilityVersion}`) + expect(getUserAgent()).not.toContain(`claude-cli/${publicBuildVersion}`) +}) + +test('uses claude-cli token for anthropic-owned endpoints even with third-party provider', () => { + ;(globalThis as Record).MACRO = { + VERSION: compatibilityVersion, + DISPLAY_VERSION: publicBuildVersion, + } + process.env.USER_TYPE = 'test-user' + process.env.CLAUDE_CODE_ENTRYPOINT = 'cli' + process.env.CLAUDE_CODE_USE_OPENAI = '1' + + expect(getUserAgent()).toContain(`claude-cli/${compatibilityVersion}`) + expect(getUserAgent()).not.toContain(`claude-cli/${publicBuildVersion}`) +}) + +test('uses openclaude-cli token for non-first-party provider API traffic', () => { + ;(globalThis as Record).MACRO = { + VERSION: compatibilityVersion, + DISPLAY_VERSION: publicBuildVersion, + } + process.env.USER_TYPE = 'test-user' + process.env.CLAUDE_CODE_ENTRYPOINT = 'cli' + process.env.CLAUDE_CODE_USE_OPENAI = '1' + + expect(getProviderApiUserAgent()).toContain( + `openclaude-cli/${publicBuildVersion}`, + ) + expect(getProviderApiUserAgent()).not.toContain( + `openclaude-cli/${compatibilityVersion}`, + ) +}) + +test('uses explicit first-party override for provider-routed api traffic', () => { + ;(globalThis as Record).MACRO = { + VERSION: compatibilityVersion, + DISPLAY_VERSION: publicBuildVersion, + } + process.env.USER_TYPE = 'test-user' + process.env.CLAUDE_CODE_ENTRYPOINT = 'cli' + process.env.CLAUDE_CODE_USE_OPENAI = '1' + + expect(getProviderApiUserAgent({ isFirstParty: true })).toContain( + `claude-cli/${compatibilityVersion}`, + ) + expect(getProviderApiUserAgent({ isFirstParty: true })).not.toContain( + `claude-cli/${publicBuildVersion}`, + ) +}) + +test('uses claude-cli token for Kimi Code provider profile traffic', () => { + ;(globalThis as Record).MACRO = { + VERSION: compatibilityVersion, + DISPLAY_VERSION: publicBuildVersion, + } + process.env.USER_TYPE = 'test-user' + process.env.CLAUDE_CODE_ENTRYPOINT = 'cli' + process.env.CLAUDE_CODE_USE_OPENAI = '1' + + expect( + getProviderApiUserAgent({ + providerRouteId: 'kimi-code', + }), + ).toContain(`claude-cli/${compatibilityVersion}`) + expect( + getProviderApiUserAgent({ + providerRouteId: 'kimi-code', + }), + ).not.toContain('openclaude-cli/') +}) diff --git a/src/utils/http.ts b/src/utils/http.ts index afbe10730..94dae91c3 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -11,10 +11,11 @@ import { isClaudeAISubscriber, } from './auth.js' import { getAPIProvider } from './model/providers.js' -import { getClaudeCodeUserAgent } from './userAgent.js' +import { getClaudeCodeUserAgent, getPublicBuildVersion } from './userAgent.js' import { getWorkload } from './workloadContext.js' -// WARNING: We rely on `claude-cli` in the user agent for log filtering. +// WARNING: Anthropic-owned endpoints rely on the `claude-cli` token for +// backend/log filtering compatibility. // Please do NOT change this without making sure that logging also gets updated! export function getUserAgent(): string { const agentSdkVersion = process.env.CLAUDE_AGENT_SDK_VERSION @@ -35,6 +36,32 @@ export function getUserAgent(): string { return `claude-cli/${MACRO.VERSION} (${process.env.USER_TYPE}, ${process.env.CLAUDE_CODE_ENTRYPOINT ?? 'cli'}${agentSdkVersion}${clientApp}${workloadSuffix})` } +// Provider-routed API requests can use OpenClaude branding as long as +// Anthropic first-party traffic keeps the compatibility token above. +export function getProviderApiUserAgent( + options?: { isFirstParty?: boolean; providerRouteId?: string }, +): string { + const agentSdkVersion = process.env.CLAUDE_AGENT_SDK_VERSION + ? `, agent-sdk/${process.env.CLAUDE_AGENT_SDK_VERSION}` + : '' + const clientApp = process.env.CLAUDE_AGENT_SDK_CLIENT_APP + ? `, client-app/${process.env.CLAUDE_AGENT_SDK_CLIENT_APP}` + : '' + const workload = getWorkload() + const workloadSuffix = workload ? `, workload/${workload}` : '' + const isFirstParty = options?.isFirstParty ?? getAPIProvider() === 'firstParty' + // Kimi Code currently expects the upstream-compatible client token. + const requiresCompatibilityIdentity = + isFirstParty || options?.providerRouteId === 'kimi-code' + const productName = requiresCompatibilityIdentity + ? 'claude-cli' + : 'openclaude-cli' + const version = requiresCompatibilityIdentity + ? MACRO.VERSION + : getPublicBuildVersion() + return `${productName}/${version} (${process.env.USER_TYPE}, ${process.env.CLAUDE_CODE_ENTRYPOINT ?? 'cli'}${agentSdkVersion}${clientApp}${workloadSuffix})` +} + export function getMCPUserAgent(): string { const parts: string[] = [] if (process.env.CLAUDE_CODE_ENTRYPOINT) { @@ -47,7 +74,7 @@ export function getMCPUserAgent(): string { parts.push(`client-app/${process.env.CLAUDE_AGENT_SDK_CLIENT_APP}`) } const suffix = parts.length > 0 ? ` (${parts.join(', ')})` : '' - return `claude-code/${MACRO.VERSION}${suffix}` + return `claude-code/${getPublicBuildVersion()}${suffix}` } // User-Agent for WebFetch requests to arbitrary sites. `Claude-User` is diff --git a/src/utils/userAgent.test.ts b/src/utils/userAgent.test.ts new file mode 100644 index 000000000..f98089366 --- /dev/null +++ b/src/utils/userAgent.test.ts @@ -0,0 +1,27 @@ +import { afterEach, expect, test } from 'bun:test' +import { getClaudeCodeUserAgent, getPublicBuildVersion } from './userAgent.js' + +const originalMacro = (globalThis as Record).MACRO + +afterEach(() => { + ;(globalThis as Record).MACRO = originalMacro +}) + +test('prefers DISPLAY_VERSION for public build version strings', () => { + ;(globalThis as Record).MACRO = { + VERSION: '99.0.0', + DISPLAY_VERSION: '0.7.0', + } + + expect(getPublicBuildVersion()).toBe('0.7.0') + expect(getClaudeCodeUserAgent()).toBe('claude-code/99.0.0') +}) + +test('falls back to VERSION when DISPLAY_VERSION is unavailable', () => { + ;(globalThis as Record).MACRO = { + VERSION: '0.7.0-dev', + } + + expect(getPublicBuildVersion()).toBe('0.7.0-dev') + expect(getClaudeCodeUserAgent()).toBe('claude-code/0.7.0-dev') +}) diff --git a/src/utils/userAgent.ts b/src/utils/userAgent.ts index 5608de66c..007a2c47f 100644 --- a/src/utils/userAgent.ts +++ b/src/utils/userAgent.ts @@ -5,6 +5,10 @@ * import without pulling in auth.ts and its transitive dependency tree. */ +export function getPublicBuildVersion(): string { + return MACRO.DISPLAY_VERSION || MACRO.VERSION +} + export function getClaudeCodeUserAgent(): string { return `claude-code/${MACRO.VERSION}` }