forked from boos/BoosAPI
Merge pull request #201 from cita-777/codex/fix-client-detection
[codex] improve downstream client detection
This commit is contained in:
@@ -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<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 {
|
||||
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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<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 {
|
||||
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 || {}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user