From 0068200f2dec7d3d9ac4646a488f82bf046a34a4 Mon Sep 17 00:00:00 2001 From: cita Date: Fri, 20 Mar 2026 15:31:21 +0800 Subject: [PATCH] Improve downstream client detection --- .../proxy-core/cliProfiles/codexProfile.ts | 31 ++++- .../proxy-core/cliProfiles/registry.test.ts | 28 +++++ .../proxy/downstreamClientContext.test.ts | 50 ++++++++ .../routes/proxy/downstreamClientContext.ts | 117 +++++++++++++++++- 4 files changed, 224 insertions(+), 2 deletions(-) diff --git a/src/server/proxy-core/cliProfiles/codexProfile.ts b/src/server/proxy-core/cliProfiles/codexProfile.ts index 36a8780..8799843 100644 --- a/src/server/proxy-core/cliProfiles/codexProfile.ts +++ b/src/server/proxy-core/cliProfiles/codexProfile.ts @@ -1,5 +1,21 @@ import type { CliProfileDefinition, DetectCliProfileInput } from './types.js'; +const CODEX_OFFICIAL_CLIENT_USER_AGENT_PREFIXES = [ + 'codex_cli_rs/', + 'codex_vscode/', + 'codex_app/', + 'codex_chatgpt_desktop/', + 'codex_atlas/', + 'codex_exec/', + 'codex_sdk_ts/', + 'codex ', +]; + +const CODEX_OFFICIAL_CLIENT_ORIGINATOR_PREFIXES = [ + 'codex_', + 'codex ', +]; + function headerValueToString(value: unknown): string | null { if (typeof value === 'string') { const trimmed = value.trim(); @@ -38,6 +54,18 @@ function hasHeaderPrefix(headers: Record | undefined, prefix: s }); } +function matchesHeaderPrefixes(value: string | null, prefixes: string[]): boolean { + const normalizedValue = value?.trim().toLowerCase() || ''; + if (!normalizedValue) return false; + + return prefixes.some((prefix) => { + const normalizedPrefix = prefix.trim().toLowerCase(); + if (!normalizedPrefix) return false; + return normalizedValue.startsWith(normalizedPrefix) + || normalizedValue.includes(normalizedPrefix); + }); +} + function isCodexPath(path: string): boolean { const normalizedPath = path.trim().toLowerCase(); return normalizedPath.startsWith('/v1/responses') @@ -62,7 +90,8 @@ export function isCodexRequest(input: DetectCliProfileInput): boolean { if (!headers) return false; const originator = getHeaderValue(headers, 'originator'); - if (originator?.toLowerCase() === 'codex_cli_rs') return true; + if (matchesHeaderPrefixes(originator, CODEX_OFFICIAL_CLIENT_ORIGINATOR_PREFIXES)) return true; + if (matchesHeaderPrefixes(getHeaderValue(headers, 'user-agent'), CODEX_OFFICIAL_CLIENT_USER_AGENT_PREFIXES)) return true; if (getHeaderValue(headers, 'openai-beta')) return true; if (hasHeaderPrefix(headers, 'x-stainless-')) return true; if (getCodexSessionId(headers)) return true; diff --git a/src/server/proxy-core/cliProfiles/registry.test.ts b/src/server/proxy-core/cliProfiles/registry.test.ts index df9eb4b..519daac 100644 --- a/src/server/proxy-core/cliProfiles/registry.test.ts +++ b/src/server/proxy-core/cliProfiles/registry.test.ts @@ -39,6 +39,34 @@ describe('detectCliProfile', () => { }); }); + it('detects broader Codex official-client headers from user-agent and originator prefixes', () => { + expect(detectCliProfile({ + downstreamPath: '/v1/responses', + headers: { + 'user-agent': 'Mozilla/5.0 codex_chatgpt_desktop/1.2.3', + }, + })).toMatchObject({ + id: 'codex', + capabilities: { + supportsResponsesWebsocketIncremental: true, + echoesTurnState: true, + }, + }); + + expect(detectCliProfile({ + downstreamPath: '/v1/responses', + headers: { + originator: 'codex_exec', + }, + })).toMatchObject({ + id: 'codex', + capabilities: { + supportsResponsesWebsocketIncremental: true, + echoesTurnState: true, + }, + }); + }); + it('detects Claude Code requests on the count_tokens surface and exposes token counting support', () => { expect(detectCliProfile({ downstreamPath: '/v1/messages/count_tokens', diff --git a/src/server/routes/proxy/downstreamClientContext.test.ts b/src/server/routes/proxy/downstreamClientContext.test.ts index fd3a4b2..80af6e3 100644 --- a/src/server/routes/proxy/downstreamClientContext.test.ts +++ b/src/server/routes/proxy/downstreamClientContext.test.ts @@ -33,6 +33,16 @@ describe('isCodexResponsesSurface', () => { })).toBe(true); }); + it('detects broader Codex official-client family headers from user-agent and originator prefixes', () => { + expect(isCodexResponsesSurface({ + 'user-agent': 'Mozilla/5.0 codex_chatgpt_desktop/1.2.3', + })).toBe(true); + + expect(isCodexResponsesSurface({ + originator: 'codex_vscode', + })).toBe(true); + }); + it('returns false for generic responses clients', () => { expect(isCodexResponsesSurface({ 'content-type': 'application/json', @@ -50,6 +60,9 @@ describe('detectDownstreamClientContext', () => { }, })).toEqual({ clientKind: 'codex', + clientAppId: 'codex_cli_rs', + clientAppName: 'Codex CLI', + clientConfidence: 'exact', sessionId: 'codex-session-123', traceHint: 'codex-session-123', }); @@ -63,6 +76,37 @@ describe('detectDownstreamClientContext', () => { }, })).toEqual({ clientKind: 'codex', + clientAppId: 'codex', + clientAppName: 'Codex', + clientConfidence: 'heuristic', + }); + }); + + it('recognizes broader Codex official-client user-agent families without requiring stainless headers', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/responses', + headers: { + 'user-agent': 'Mozilla/5.0 codex_chatgpt_desktop/1.2.3', + }, + })).toEqual({ + clientKind: 'codex', + clientAppId: 'codex_chatgpt_desktop', + clientAppName: 'Codex Desktop', + clientConfidence: 'exact', + }); + }); + + it('recognizes broader Codex official-client originator prefixes', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/responses', + headers: { + originator: 'codex_exec', + }, + })).toEqual({ + clientKind: 'codex', + clientAppId: 'codex_exec', + clientAppName: 'Codex Exec', + clientConfidence: 'exact', }); }); @@ -79,6 +123,9 @@ describe('detectDownstreamClientContext', () => { body, })).toEqual({ clientKind: 'claude_code', + clientAppId: 'claude_code', + clientAppName: 'Claude Code', + clientConfidence: 'exact', sessionId: 'f25958b8-e75c-455d-8b40-f006d87cc2a4', traceHint: 'f25958b8-e75c-455d-8b40-f006d87cc2a4', }); @@ -128,6 +175,9 @@ describe('detectDownstreamClientContext', () => { }, })).toEqual({ clientKind: 'gemini_cli', + clientAppId: 'gemini_cli', + clientAppName: 'Gemini CLI', + clientConfidence: 'exact', }); }); diff --git a/src/server/routes/proxy/downstreamClientContext.ts b/src/server/routes/proxy/downstreamClientContext.ts index f568f25..66e2f7a 100644 --- a/src/server/routes/proxy/downstreamClientContext.ts +++ b/src/server/routes/proxy/downstreamClientContext.ts @@ -37,6 +37,64 @@ type DownstreamClientFingerprintRule = { match(input: DownstreamClientFingerprintInput): DownstreamClientConfidence | null; }; +type DownstreamProtocolClientApp = { + clientAppId: string; + clientAppName: string; + clientConfidence: DownstreamClientConfidence; +}; + +type HeaderPrefixMatcherRule = { + id: string; + name: string; + userAgentPrefixes?: string[]; + originatorPrefixes?: string[]; +}; + +const codexOfficialClientAppRules: HeaderPrefixMatcherRule[] = [ + { + id: 'codex_cli_rs', + name: 'Codex CLI', + userAgentPrefixes: ['codex_cli_rs/'], + originatorPrefixes: ['codex_cli_rs'], + }, + { + id: 'codex_vscode', + name: 'Codex VSCode', + userAgentPrefixes: ['codex_vscode/'], + originatorPrefixes: ['codex_vscode'], + }, + { + id: 'codex_app', + name: 'Codex App', + userAgentPrefixes: ['codex_app/'], + originatorPrefixes: ['codex_app'], + }, + { + id: 'codex_chatgpt_desktop', + name: 'Codex Desktop', + userAgentPrefixes: ['codex_chatgpt_desktop/', 'codex desktop/'], + originatorPrefixes: ['codex_chatgpt_desktop', 'codex desktop'], + }, + { + id: 'codex_atlas', + name: 'Codex Atlas', + userAgentPrefixes: ['codex_atlas/'], + originatorPrefixes: ['codex_atlas'], + }, + { + id: 'codex_exec', + name: 'Codex Exec', + userAgentPrefixes: ['codex_exec/'], + originatorPrefixes: ['codex_exec'], + }, + { + id: 'codex_sdk_ts', + name: 'Codex SDK TS', + userAgentPrefixes: ['codex_sdk_ts/'], + originatorPrefixes: ['codex_sdk_ts'], + }, +]; + function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } @@ -80,6 +138,10 @@ function headerIncludes(headers: NormalizedClientHeaders, key: string, expectedF return (headers[key.trim().toLowerCase()] || []).some((value) => value.trim().toLowerCase().includes(normalizedExpected)); } +function headerMatchesPrefixes(headers: NormalizedClientHeaders, key: string, prefixes: string[]): boolean { + return prefixes.some((prefix) => headerIncludes(headers, key, prefix)); +} + function buildBodySummary(body: unknown): DownstreamClientBodySummary { if (!isRecord(body)) { return { @@ -158,6 +220,55 @@ function detectDownstreamClientFingerprint(input: { }; } +function detectCodexOfficialClientApp(headers: NormalizedClientHeaders): DownstreamProtocolClientApp | null { + for (const rule of codexOfficialClientAppRules) { + const matchesOriginator = Array.isArray(rule.originatorPrefixes) + && headerMatchesPrefixes(headers, 'originator', rule.originatorPrefixes); + const matchesUserAgent = Array.isArray(rule.userAgentPrefixes) + && headerMatchesPrefixes(headers, 'user-agent', rule.userAgentPrefixes); + + if (!matchesOriginator && !matchesUserAgent) continue; + + return { + clientAppId: rule.id, + clientAppName: rule.name, + clientConfidence: 'exact', + }; + } + + return null; +} + +function detectProtocolClientApp(input: { + clientKind: DownstreamClientKind; + headers?: Record; +}): DownstreamProtocolClientApp | null { + switch (input.clientKind) { + case 'claude_code': + return { + clientAppId: 'claude_code', + clientAppName: 'Claude Code', + clientConfidence: 'exact', + }; + case 'gemini_cli': + return { + clientAppId: 'gemini_cli', + clientAppName: 'Gemini CLI', + clientConfidence: 'exact', + }; + case 'codex': { + const headers = normalizeHeaders(input.headers); + return detectCodexOfficialClientApp(headers) || { + clientAppId: 'codex', + clientAppName: 'Codex', + clientConfidence: 'heuristic', + }; + } + default: + return null; + } +} + export function isCodexResponsesSurface(headers?: Record): boolean { return isCodexResponsesSurfaceViaProfile(headers); } @@ -173,10 +284,14 @@ export function detectDownstreamClientContext(input: { }): DownstreamClientContext { const detected = detectCliProfile(input); const fingerprint = detectDownstreamClientFingerprint(input); + const protocolClientApp = fingerprint ? null : detectProtocolClientApp({ + clientKind: detected.id, + headers: input.headers, + }); return { clientKind: detected.id, ...(detected.sessionId ? { sessionId: detected.sessionId } : {}), ...(detected.traceHint ? { traceHint: detected.traceHint } : {}), - ...(fingerprint || {}), + ...(fingerprint || protocolClientApp || {}), }; }