Merge pull request #201 from cita-777/codex/fix-client-detection

[codex] improve downstream client detection
This commit is contained in:
Cita
2026-03-20 15:42:28 +08:00
committed by GitHub
4 changed files with 224 additions and 2 deletions
@@ -1,5 +1,21 @@
import type { CliProfileDefinition, DetectCliProfileInput } from './types.js'; 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 { function headerValueToString(value: unknown): string | null {
if (typeof value === 'string') { if (typeof value === 'string') {
const trimmed = value.trim(); const trimmed = value.trim();
@@ -38,6 +54,18 @@ function hasHeaderPrefix(headers: Record<string, unknown> | 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 { function isCodexPath(path: string): boolean {
const normalizedPath = path.trim().toLowerCase(); const normalizedPath = path.trim().toLowerCase();
return normalizedPath.startsWith('/v1/responses') return normalizedPath.startsWith('/v1/responses')
@@ -62,7 +90,8 @@ export function isCodexRequest(input: DetectCliProfileInput): boolean {
if (!headers) return false; if (!headers) return false;
const originator = getHeaderValue(headers, 'originator'); 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 (getHeaderValue(headers, 'openai-beta')) return true;
if (hasHeaderPrefix(headers, 'x-stainless-')) return true; if (hasHeaderPrefix(headers, 'x-stainless-')) return true;
if (getCodexSessionId(headers)) return true; if (getCodexSessionId(headers)) return true;
@@ -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', () => { it('detects Claude Code requests on the count_tokens surface and exposes token counting support', () => {
expect(detectCliProfile({ expect(detectCliProfile({
downstreamPath: '/v1/messages/count_tokens', downstreamPath: '/v1/messages/count_tokens',
@@ -33,6 +33,16 @@ describe('isCodexResponsesSurface', () => {
})).toBe(true); })).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', () => { it('returns false for generic responses clients', () => {
expect(isCodexResponsesSurface({ expect(isCodexResponsesSurface({
'content-type': 'application/json', 'content-type': 'application/json',
@@ -50,6 +60,9 @@ describe('detectDownstreamClientContext', () => {
}, },
})).toEqual({ })).toEqual({
clientKind: 'codex', clientKind: 'codex',
clientAppId: 'codex_cli_rs',
clientAppName: 'Codex CLI',
clientConfidence: 'exact',
sessionId: 'codex-session-123', sessionId: 'codex-session-123',
traceHint: 'codex-session-123', traceHint: 'codex-session-123',
}); });
@@ -63,6 +76,37 @@ describe('detectDownstreamClientContext', () => {
}, },
})).toEqual({ })).toEqual({
clientKind: 'codex', 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, body,
})).toEqual({ })).toEqual({
clientKind: 'claude_code', clientKind: 'claude_code',
clientAppId: 'claude_code',
clientAppName: 'Claude Code',
clientConfidence: 'exact',
sessionId: 'f25958b8-e75c-455d-8b40-f006d87cc2a4', sessionId: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
traceHint: 'f25958b8-e75c-455d-8b40-f006d87cc2a4', traceHint: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
}); });
@@ -128,6 +175,9 @@ describe('detectDownstreamClientContext', () => {
}, },
})).toEqual({ })).toEqual({
clientKind: 'gemini_cli', clientKind: 'gemini_cli',
clientAppId: 'gemini_cli',
clientAppName: 'Gemini CLI',
clientConfidence: 'exact',
}); });
}); });
@@ -37,6 +37,64 @@ type DownstreamClientFingerprintRule = {
match(input: DownstreamClientFingerprintInput): DownstreamClientConfidence | null; 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<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value); 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)); 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 { function buildBodySummary(body: unknown): DownstreamClientBodySummary {
if (!isRecord(body)) { if (!isRecord(body)) {
return { 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<string, unknown>;
}): 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<string, unknown>): boolean { export function isCodexResponsesSurface(headers?: Record<string, unknown>): boolean {
return isCodexResponsesSurfaceViaProfile(headers); return isCodexResponsesSurfaceViaProfile(headers);
} }
@@ -173,10 +284,14 @@ export function detectDownstreamClientContext(input: {
}): DownstreamClientContext { }): DownstreamClientContext {
const detected = detectCliProfile(input); const detected = detectCliProfile(input);
const fingerprint = detectDownstreamClientFingerprint(input); const fingerprint = detectDownstreamClientFingerprint(input);
const protocolClientApp = fingerprint ? null : detectProtocolClientApp({
clientKind: detected.id,
headers: input.headers,
});
return { return {
clientKind: detected.id, clientKind: detected.id,
...(detected.sessionId ? { sessionId: detected.sessionId } : {}), ...(detected.sessionId ? { sessionId: detected.sessionId } : {}),
...(detected.traceHint ? { traceHint: detected.traceHint } : {}), ...(detected.traceHint ? { traceHint: detected.traceHint } : {}),
...(fingerprint || {}), ...(fingerprint || protocolClientApp || {}),
}; };
} }