Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
11 changes: 6 additions & 5 deletions src/services/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
refreshAndGetAwsCredentials,
refreshGcpCredentialsIfNeeded,
} from 'src/utils/auth.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,
Expand Down Expand Up @@ -175,10 +175,14 @@ 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,
}),
'X-Claude-Code-Session-Id': getSessionId(),
...customHeaders,
...(containerId ? { 'x-claude-remote-container-id': containerId } : {}),
Expand All @@ -202,9 +206,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()
Expand Down
5 changes: 3 additions & 2 deletions src/services/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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,
},
Expand Down
89 changes: 89 additions & 0 deletions src/utils/http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { afterEach, expect, test } from 'bun:test'
import { getProviderApiUserAgent, getUserAgent } from './http.js'

const originalMacro = (globalThis as Record<string, unknown>).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<string, unknown>).MACRO = originalMacro
if (originalEnv.USER_TYPE === undefined) {
delete process.env.USER_TYPE
} else {
process.env.USER_TYPE = originalEnv.USER_TYPE
}
if (originalEnv.CLAUDE_CODE_ENTRYPOINT === undefined) {
delete process.env.CLAUDE_CODE_ENTRYPOINT
} else {
process.env.CLAUDE_CODE_ENTRYPOINT = originalEnv.CLAUDE_CODE_ENTRYPOINT
}
if (originalEnv.CLAUDE_CODE_USE_OPENAI === undefined) {
delete process.env.CLAUDE_CODE_USE_OPENAI
} else {
process.env.CLAUDE_CODE_USE_OPENAI = originalEnv.CLAUDE_CODE_USE_OPENAI
}
})

test('uses claude-cli token for first-party API compatibility', () => {
;(globalThis as Record<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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}`,
)
})
26 changes: 23 additions & 3 deletions src/utils/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,6 +36,25 @@ 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 },
): 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'
const productName = isFirstParty ? 'claude-cli' : 'openclaude-cli'
const version = isFirstParty ? 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) {
Expand All @@ -47,7 +67,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
Expand Down
27 changes: 27 additions & 0 deletions src/utils/userAgent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { afterEach, expect, test } from 'bun:test'
import { getClaudeCodeUserAgent, getPublicBuildVersion } from './userAgent.js'

const originalMacro = (globalThis as Record<string, unknown>).MACRO

afterEach(() => {
;(globalThis as Record<string, unknown>).MACRO = originalMacro
})

test('prefers DISPLAY_VERSION for public build version strings', () => {
;(globalThis as Record<string, unknown>).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<string, unknown>).MACRO = {
VERSION: '0.7.0-dev',
}

expect(getPublicBuildVersion()).toBe('0.7.0-dev')
expect(getClaudeCodeUserAgent()).toBe('claude-code/0.7.0-dev')
})
4 changes: 4 additions & 0 deletions src/utils/userAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
}
Loading