diff --git a/src/server/proxy-core/cliProfiles/claudeCodeProfile.ts b/src/server/proxy-core/cliProfiles/claudeCodeProfile.ts index 0f67218..63dcd2f 100644 --- a/src/server/proxy-core/cliProfiles/claudeCodeProfile.ts +++ b/src/server/proxy-core/cliProfiles/claudeCodeProfile.ts @@ -5,6 +5,7 @@ function isRecord(value: unknown): value is Record { } const claudeCodeUserIdPattern = /^user_[0-9a-f]{64}_account__session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const claudeCodeUserAgentPattern = /^claude-cli\/\d+\.\d+\.\d+/i; function isClaudeSurface(path: string): boolean { const normalizedPath = path.trim().toLowerCase(); @@ -13,6 +14,33 @@ function isClaudeSurface(path: string): boolean { || normalizedPath === '/v1/messages/count_tokens'; } +function getHeaderValue(headers: Record | undefined, targetKey: string): string | null { + if (!headers) return null; + const normalizedTarget = targetKey.trim().toLowerCase(); + for (const [rawKey, rawValue] of Object.entries(headers)) { + if (rawKey.trim().toLowerCase() !== normalizedTarget) continue; + if (typeof rawValue === 'string') { + const trimmed = rawValue.trim(); + return trimmed || null; + } + if (!Array.isArray(rawValue)) continue; + for (const item of rawValue) { + if (typeof item !== 'string') continue; + const trimmed = item.trim(); + if (trimmed) return trimmed; + } + } + return null; +} + +function hasClaudeCodeHeaderFingerprint(headers: Record | undefined): boolean { + const userAgent = getHeaderValue(headers, 'user-agent') || ''; + if (!claudeCodeUserAgentPattern.test(userAgent)) return false; + if (!getHeaderValue(headers, 'anthropic-beta')) return false; + if (!getHeaderValue(headers, 'anthropic-version')) return false; + return (getHeaderValue(headers, 'x-app') || '').trim().toLowerCase() === 'cli'; +} + export function extractClaudeCodeSessionId(userId: string): string | null { const trimmed = userId.trim(); if (!claudeCodeUserIdPattern.test(trimmed)) return null; @@ -36,18 +64,15 @@ export const claudeCodeCliProfile: CliProfileDefinition = { }, detect(input) { if (!isClaudeSurface(input.downstreamPath)) return null; - if (!isRecord(input.body) || !isRecord(input.body.metadata)) return null; - - const userId = typeof input.body.metadata.user_id === 'string' + const userId = isRecord(input.body) && isRecord(input.body.metadata) && typeof input.body.metadata.user_id === 'string' ? input.body.metadata.user_id.trim() : ''; const sessionId = userId ? extractClaudeCodeSessionId(userId) : null; - if (!sessionId) return null; + if (!sessionId && !hasClaudeCodeHeaderFingerprint(input.headers)) return null; return { id: 'claude_code', - sessionId, - traceHint: sessionId, + ...(sessionId ? { sessionId, traceHint: sessionId } : {}), clientAppId: 'claude_code', clientAppName: 'Claude Code', clientConfidence: 'exact', diff --git a/src/server/proxy-core/cliProfiles/codexProfile.ts b/src/server/proxy-core/cliProfiles/codexProfile.ts index 291f34a..09df8ba 100644 --- a/src/server/proxy-core/cliProfiles/codexProfile.ts +++ b/src/server/proxy-core/cliProfiles/codexProfile.ts @@ -130,9 +130,9 @@ function matchesHeaderPrefixes(value: string | string[] | null, prefixes: readon function isCodexPath(path: string): boolean { const normalizedPath = path.trim().toLowerCase(); - return normalizedPath.startsWith('/v1/responses') - || normalizedPath === '/v1/chat/completions' - || normalizedPath.startsWith('/v1/messages'); + return normalizedPath === '/v1/responses' + || normalizedPath.startsWith('/v1/responses/') + || normalizedPath === '/v1/chat/completions'; } export function detectCodexOfficialClientApp( diff --git a/src/server/proxy-core/cliProfiles/registry.test.ts b/src/server/proxy-core/cliProfiles/registry.test.ts index 89b66a2..809d3aa 100644 --- a/src/server/proxy-core/cliProfiles/registry.test.ts +++ b/src/server/proxy-core/cliProfiles/registry.test.ts @@ -70,6 +70,24 @@ describe('detectCliProfile', () => { }); }); + it('does not classify non-responses siblings as Codex requests', () => { + expect(detectCliProfile({ + downstreamPath: '/v1/responsesfoo', + headers: { + 'openai-beta': 'responses-2025-03-11', + }, + })).toEqual({ + id: 'generic', + capabilities: { + supportsResponsesCompact: false, + supportsResponsesWebsocketIncremental: false, + preservesContinuation: false, + supportsCountTokens: false, + echoesTurnState: false, + }, + }); + }); + it('detects Claude Code requests on the count_tokens surface and exposes token counting support', () => { expect(detectCliProfile({ downstreamPath: '/v1/messages/count_tokens', @@ -95,6 +113,34 @@ describe('detectCliProfile', () => { }); }); + it('detects Claude Code messages requests from claude-cli headers even when metadata.user_id is unavailable', () => { + expect(detectCliProfile({ + downstreamPath: '/v1/messages', + headers: { + 'user-agent': 'claude-cli/2.1.63 (external, cli)', + 'anthropic-beta': 'claude-code-20250219,oauth-2025-04-20', + 'anthropic-version': '2023-06-01', + 'x-app': 'cli', + 'x-stainless-lang': 'js', + }, + body: { + model: 'claude-sonnet-4-5', + }, + })).toEqual({ + id: 'claude_code', + clientAppId: 'claude_code', + clientAppName: 'Claude Code', + clientConfidence: 'exact', + capabilities: { + supportsResponsesCompact: false, + supportsResponsesWebsocketIncremental: false, + preservesContinuation: true, + supportsCountTokens: true, + echoesTurnState: false, + }, + }); + }); + it('detects Gemini CLI internal routes and exposes Gemini CLI capability flags', () => { expect(detectCliProfile({ downstreamPath: '/v1internal:countTokens', diff --git a/src/server/routes/proxy/downstreamClientContext.ts b/src/server/proxy-core/downstreamClientContext.ts similarity index 83% rename from src/server/routes/proxy/downstreamClientContext.ts rename to src/server/proxy-core/downstreamClientContext.ts index 56fa75e..306a296 100644 --- a/src/server/routes/proxy/downstreamClientContext.ts +++ b/src/server/proxy-core/downstreamClientContext.ts @@ -1,8 +1,8 @@ -import { detectCliProfile } from '../../proxy-core/cliProfiles/registry.js'; +import { detectCliProfile } from './cliProfiles/registry.js'; import type { CliProfileClientConfidence, CliProfileId, -} from '../../proxy-core/cliProfiles/types.js'; +} from './cliProfiles/types.js'; export type DownstreamClientKind = CliProfileId; export type DownstreamClientConfidence = CliProfileClientConfidence; @@ -21,6 +21,7 @@ type NormalizedClientHeaders = Record; type DownstreamClientBodySummary = { topLevelKeys: string[]; metadataUserId: string | null; + hasOpenCodeSystemPrompt: boolean; }; type DownstreamClientFingerprintInput = { @@ -122,20 +123,57 @@ function buildBodySummary(body: unknown): DownstreamClientBodySummary { return { topLevelKeys: [], metadataUserId: null, + hasOpenCodeSystemPrompt: false, }; } const metadataUserId = isRecord(body.metadata) && typeof body.metadata.user_id === 'string' ? body.metadata.user_id.trim() || null : null; + const systemPromptTexts = ( + typeof body.system === 'string' + ? [body.system] + : Array.isArray(body.system) + ? body.system.map((entry) => { + if (typeof entry === 'string') return entry; + if (!isRecord(entry)) return ''; + return typeof entry.text === 'string' ? entry.text : ''; + }) + : [] + ).filter((entry) => entry.trim().length > 0); + const normalizedSystemPrompt = systemPromptTexts + .join('\n') + .trim() + .toLowerCase(); + const hasOpenCodeSystemPrompt = normalizedSystemPrompt.includes('you are opencode, an interactive cli tool') + || normalizedSystemPrompt.includes('file called opencode.md'); return { topLevelKeys: Object.keys(body).sort((left, right) => left.localeCompare(right)), metadataUserId, + hasOpenCodeSystemPrompt, }; } const appFingerprintRegistry: DownstreamClientFingerprintRule[] = [ + { + id: 'opencode', + name: 'OpenCode', + priority: 110, + match(input) { + const hasTitle = headerIncludes(input.headers, 'x-title', 'opencode'); + const hasReferer = headerIncludes(input.headers, 'http-referer', 'opencode.ai') + || headerIncludes(input.headers, 'referer', 'opencode.ai') + || headerIncludes(input.headers, 'origin', 'opencode.ai'); + const hasUserAgent = headerIncludes(input.headers, 'user-agent', 'opencode/'); + + if (hasTitle || hasReferer || hasUserAgent) { + return 'exact'; + } + + return input.bodySummary.hasOpenCodeSystemPrompt ? 'heuristic' : null; + }, + }, { id: 'cherry_studio', name: 'Cherry Studio', diff --git a/src/server/proxy-core/surfaces/chatSurface.ts b/src/server/proxy-core/surfaces/chatSurface.ts index a53435a..76e27fd 100644 --- a/src/server/proxy-core/surfaces/chatSurface.ts +++ b/src/server/proxy-core/surfaces/chatSurface.ts @@ -47,7 +47,7 @@ import { import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js'; import { getObservedResponseMeta } from '../firstByteTimeout.js'; import { getRuntimeResponseReader, readRuntimeResponseText } from '../executors/types.js'; -import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js'; +import { detectDownstreamClientContext } from '../downstreamClientContext.js'; import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js'; import { shouldAbortSameSiteEndpointFallback } from '../../services/proxyRetryPolicy.js'; import { diff --git a/src/server/proxy-core/surfaces/geminiSurface.ts b/src/server/proxy-core/surfaces/geminiSurface.ts index a245304..58cd858 100644 --- a/src/server/proxy-core/surfaces/geminiSurface.ts +++ b/src/server/proxy-core/surfaces/geminiSurface.ts @@ -36,7 +36,7 @@ import { wrapGeminiCliRequest, } from '../../routes/proxy/geminiCliCompat.js'; import { dispatchRuntimeRequest } from '../../services/runtimeDispatch.js'; -import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js'; +import { detectDownstreamClientContext, type DownstreamClientContext } from '../downstreamClientContext.js'; import { insertProxyLog } from '../../services/proxyLogStore.js'; import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js'; import { getRuntimeResponseReader, readRuntimeResponseText } from '../executors/types.js'; diff --git a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts index 732a945..93559ae 100644 --- a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts +++ b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts @@ -62,7 +62,7 @@ import { sanitizeCompactResponsesRequestBody, shouldFallbackCompactResponsesToResponses, } from '../capabilities/responsesCompact.js'; -import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js'; +import { detectDownstreamClientContext } from '../downstreamClientContext.js'; import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js'; import { shouldAbortSameSiteEndpointFallback } from '../../services/proxyRetryPolicy.js'; import { diff --git a/src/server/proxy-core/surfaces/sharedSurface.ts b/src/server/proxy-core/surfaces/sharedSurface.ts index ec2c1c1..f062b06 100644 --- a/src/server/proxy-core/surfaces/sharedSurface.ts +++ b/src/server/proxy-core/surfaces/sharedSurface.ts @@ -9,7 +9,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js'; import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js'; -import type { DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js'; +import type { DownstreamClientContext } from '../downstreamClientContext.js'; import { insertProxyLog } from '../../services/proxyLogStore.js'; import { dispatchRuntimeRequest } from '../../services/runtimeDispatch.js'; import type { BuiltEndpointRequest } from '../orchestration/endpointFlow.js'; diff --git a/src/server/routes/proxy/completions.ts b/src/server/routes/proxy/completions.ts index 1f227b6..56ba881 100644 --- a/src/server/routes/proxy/completions.ts +++ b/src/server/routes/proxy/completions.ts @@ -16,7 +16,7 @@ import { detectProxyFailure } from './proxyFailureJudge.js'; import { resolveProxyLogBilling } from './proxyBilling.js'; import { getProxyAuthContext } from '../../middleware/auth.js'; import { buildUpstreamUrl } from './upstreamUrl.js'; -import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js'; +import { detectDownstreamClientContext, type DownstreamClientContext } from '../../proxy-core/downstreamClientContext.js'; import { insertProxyLog } from '../../services/proxyLogStore.js'; import { fetchWithObservedFirstByte, getObservedResponseMeta } from '../../proxy-core/firstByteTimeout.js'; import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js'; diff --git a/src/server/routes/proxy/downstreamClientContext.architecture.test.ts b/src/server/routes/proxy/downstreamClientContext.architecture.test.ts index d562f67..8eb3ece 100644 --- a/src/server/routes/proxy/downstreamClientContext.architecture.test.ts +++ b/src/server/routes/proxy/downstreamClientContext.architecture.test.ts @@ -6,12 +6,12 @@ function readSource(relativePath: string): string { } describe('downstreamClientContext architecture boundaries', () => { - it('keeps protocol-level client metadata on cli profiles instead of importing individual profile helpers', () => { - const source = readSource('./downstreamClientContext.ts'); + it('keeps shared downstream client detection outside the route layer and avoids individual profile helper imports', () => { + const source = readSource('../../proxy-core/downstreamClientContext.ts'); - expect(source).toContain("from '../../proxy-core/cliProfiles/registry.js'"); - expect(source).toContain("from '../../proxy-core/cliProfiles/types.js'"); - expect(source).not.toContain("from '../../proxy-core/cliProfiles/codexProfile.js'"); - expect(source).not.toContain("from '../../proxy-core/cliProfiles/claudeCodeProfile.js'"); + expect(source).toContain("from './cliProfiles/registry.js'"); + expect(source).toContain("from './cliProfiles/types.js'"); + expect(source).not.toContain("from './cliProfiles/codexProfile.js'"); + expect(source).not.toContain("from './cliProfiles/claudeCodeProfile.js'"); }); }); diff --git a/src/server/routes/proxy/downstreamClientContext.routes.test.ts b/src/server/routes/proxy/downstreamClientContext.routes.test.ts index f661c57..16d06f7 100644 --- a/src/server/routes/proxy/downstreamClientContext.routes.test.ts +++ b/src/server/routes/proxy/downstreamClientContext.routes.test.ts @@ -245,6 +245,42 @@ describe('downstream client context route logging', () => { expect(insertedLog.errorMessage).toContain('[downstream:/v1/messages]'); }); + it('keeps claude-cli header-based /v1/messages requests on the Claude Code family even when metadata.user_id is absent', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + error: { + message: 'messages failed', + type: 'upstream_error', + }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/messages', + headers: { + 'user-agent': 'claude-cli/2.1.63 (external, cli)', + 'anthropic-beta': 'claude-code-20250219,oauth-2025-04-20', + 'anthropic-version': '2023-06-01', + 'x-app': 'cli', + 'x-stainless-lang': 'js', + }, + payload: { + model: 'claude-opus-4-6', + max_tokens: 256, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(response.statusCode).toBe(400); + expect(dbValuesMock).toHaveBeenCalled(); + const insertedLog = dbValuesMock.mock.calls.at(-1)?.[0]; + expect(insertedLog.errorMessage).toContain('[client:claude_code]'); + expect(insertedLog.errorMessage).toContain('[downstream:/v1/messages]'); + expect(insertedLog.errorMessage).not.toContain('[client:codex]'); + }); + it('keeps invalid Claude metadata.user_id requests generic in failure logs', async () => { fetchMock.mockResolvedValue(new Response(JSON.stringify({ error: { diff --git a/src/server/routes/proxy/downstreamClientContext.test.ts b/src/server/routes/proxy/downstreamClientContext.test.ts index 9b2b26e..baa3a20 100644 --- a/src/server/routes/proxy/downstreamClientContext.test.ts +++ b/src/server/routes/proxy/downstreamClientContext.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { extractClaudeCodeSessionId } from '../../proxy-core/cliProfiles/claudeCodeProfile.js'; import { isCodexResponsesSurface } from '../../proxy-core/cliProfiles/codexProfile.js'; -import { detectDownstreamClientContext } from './downstreamClientContext.js'; +import { detectDownstreamClientContext } from '../../proxy-core/downstreamClientContext.js'; describe('extractClaudeCodeSessionId', () => { it('extracts session uuid from axonhub-compatible Claude Code user ids', () => { @@ -165,6 +165,27 @@ describe('detectDownstreamClientContext', () => { }); }); + it('recognizes Claude Code from claude-cli request headers before Codex heuristics on /v1/messages', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/messages', + headers: { + 'user-agent': 'claude-cli/2.1.63 (external, cli)', + 'anthropic-beta': 'claude-code-20250219,oauth-2025-04-20', + 'anthropic-version': '2023-06-01', + 'x-app': 'cli', + 'x-stainless-lang': 'js', + }, + body: { + model: 'claude-sonnet-4-5', + }, + })).toEqual({ + clientKind: 'claude_code', + clientAppId: 'claude_code', + clientAppName: 'Claude Code', + clientConfidence: 'exact', + }); + }); + it('falls back to generic when Claude metadata.user_id is missing or invalid', () => { expect(detectDownstreamClientContext({ downstreamPath: '/v1/messages', @@ -253,4 +274,39 @@ describe('detectDownstreamClientContext', () => { clientConfidence: 'heuristic', }); }); + + it('marks OpenCode anthropic prompts as an app-level heuristic without changing protocol family', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/messages', + body: { + model: 'claude-sonnet-4-5', + system: [ + { + type: 'text', + text: 'You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. If the current working directory contains a file called OpenCode.md, it will be automatically added to your context.', + }, + ], + }, + })).toEqual({ + clientKind: 'generic', + clientAppId: 'opencode', + clientAppName: 'OpenCode', + clientConfidence: 'heuristic', + }); + }); + + it('recognizes OpenCode anthropic prompts when system is provided as a plain string', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/messages', + body: { + model: 'claude-sonnet-4-5', + system: 'You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. If the current working directory contains a file called OpenCode.md, it will be automatically added to your context.', + }, + })).toEqual({ + clientKind: 'generic', + clientAppId: 'opencode', + clientAppName: 'OpenCode', + clientConfidence: 'heuristic', + }); + }); }); diff --git a/src/server/routes/proxy/embeddings.ts b/src/server/routes/proxy/embeddings.ts index 857fd91..1b076df 100644 --- a/src/server/routes/proxy/embeddings.ts +++ b/src/server/routes/proxy/embeddings.ts @@ -15,7 +15,7 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { resolveProxyLogBilling } from './proxyBilling.js'; import { getProxyAuthContext } from '../../middleware/auth.js'; import { buildUpstreamUrl } from './upstreamUrl.js'; -import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js'; +import { detectDownstreamClientContext, type DownstreamClientContext } from '../../proxy-core/downstreamClientContext.js'; import { insertProxyLog } from '../../services/proxyLogStore.js'; import { fetchWithObservedFirstByte, getObservedResponseMeta } from '../../proxy-core/firstByteTimeout.js'; import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js'; diff --git a/src/server/routes/proxy/images.ts b/src/server/routes/proxy/images.ts index 96093ee..7b84c5c 100644 --- a/src/server/routes/proxy/images.ts +++ b/src/server/routes/proxy/images.ts @@ -14,7 +14,7 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js'; import { getProxyAuthContext } from '../../middleware/auth.js'; import { buildUpstreamUrl } from './upstreamUrl.js'; -import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js'; +import { detectDownstreamClientContext, type DownstreamClientContext } from '../../proxy-core/downstreamClientContext.js'; import { insertProxyLog } from '../../services/proxyLogStore.js'; import { fetchWithObservedFirstByte, getObservedResponseMeta } from '../../proxy-core/firstByteTimeout.js'; import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js'; diff --git a/src/server/routes/proxy/search.ts b/src/server/routes/proxy/search.ts index e2a5594..fc4c157 100644 --- a/src/server/routes/proxy/search.ts +++ b/src/server/routes/proxy/search.ts @@ -12,7 +12,7 @@ import { composeProxyLogMessage } from './logPathMeta.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { getProxyAuthContext } from '../../middleware/auth.js'; import { buildUpstreamUrl } from './upstreamUrl.js'; -import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js'; +import { detectDownstreamClientContext, type DownstreamClientContext } from '../../proxy-core/downstreamClientContext.js'; import { insertProxyLog } from '../../services/proxyLogStore.js'; import { fetchWithObservedFirstByte, getObservedResponseMeta } from '../../proxy-core/firstByteTimeout.js'; import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';