[codex] fix downstream client detection boundaries (#429)

* fix downstream client detection boundaries

* address downstream client detection review comments
This commit is contained in:
Cita
2026-04-05 22:38:00 +08:00
committed by GitHub
parent 193f3e7181
commit 6feda0e7ba
15 changed files with 227 additions and 26 deletions
+1 -1
View File
@@ -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';
@@ -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'");
});
});
@@ -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: {
@@ -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',
});
});
});
@@ -1,248 +0,0 @@
import { detectCliProfile } from '../../proxy-core/cliProfiles/registry.js';
import type {
CliProfileClientConfidence,
CliProfileId,
} from '../../proxy-core/cliProfiles/types.js';
export type DownstreamClientKind = CliProfileId;
export type DownstreamClientConfidence = CliProfileClientConfidence;
export type DownstreamClientContext = {
clientKind: DownstreamClientKind;
sessionId?: string;
traceHint?: string;
clientAppId?: string;
clientAppName?: string;
clientConfidence?: DownstreamClientConfidence;
};
type NormalizedClientHeaders = Record<string, string[]>;
type DownstreamClientBodySummary = {
topLevelKeys: string[];
metadataUserId: string | null;
};
type DownstreamClientFingerprintInput = {
downstreamPath: string;
headers: NormalizedClientHeaders;
bodySummary: DownstreamClientBodySummary;
};
type DownstreamClientFingerprintRule = {
id: string;
name: string;
priority: number;
match(input: DownstreamClientFingerprintInput): DownstreamClientConfidence | null;
};
type DownstreamResolvedClientApp = {
clientAppId: string;
clientAppName: string;
clientConfidence: DownstreamClientConfidence;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function normalizeHeaderValues(value: unknown): string[] {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed ? [trimmed] : [];
}
if (!Array.isArray(value)) return [];
return value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
function normalizeHeaders(headers?: Record<string, unknown>): NormalizedClientHeaders {
if (!headers) return {};
const normalized: NormalizedClientHeaders = {};
for (const [rawKey, rawValue] of Object.entries(headers)) {
const key = rawKey.trim().toLowerCase();
if (!key) continue;
const values = normalizeHeaderValues(rawValue);
if (values.length === 0) continue;
normalized[key] = normalized[key]
? [...normalized[key], ...values]
: values;
}
return normalized;
}
function headerEquals(headers: NormalizedClientHeaders, key: string, expected: string): boolean {
const normalizedExpected = expected.trim().toLowerCase();
return (headers[key.trim().toLowerCase()] || []).some((value) => value.trim().toLowerCase() === normalizedExpected);
}
function headerIncludes(headers: NormalizedClientHeaders, key: string, expectedFragment: string): boolean {
const normalizedExpected = expectedFragment.trim().toLowerCase();
return (headers[key.trim().toLowerCase()] || []).some((value) => value.trim().toLowerCase().includes(normalizedExpected));
}
function normalizeClientDisplayName(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
return trimmed.length <= 120 ? trimmed : trimmed.slice(0, 120).trim() || null;
}
function normalizeClientAppId(value: string): string | null {
const normalized = value.trim().toLowerCase();
return normalized || null;
}
function parseExplicitClientSelfReportValue(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
if (isRecord(parsed)) {
for (const key of ['client', 'name', 'app']) {
const raw = parsed[key];
if (typeof raw !== 'string') continue;
const normalized = normalizeClientDisplayName(raw);
if (normalized) return normalized;
}
}
} catch {
return normalizeClientDisplayName(trimmed);
}
return null;
}
function buildBodySummary(body: unknown): DownstreamClientBodySummary {
if (!isRecord(body)) {
return {
topLevelKeys: [],
metadataUserId: null,
};
}
const metadataUserId = isRecord(body.metadata) && typeof body.metadata.user_id === 'string'
? body.metadata.user_id.trim() || null
: null;
return {
topLevelKeys: Object.keys(body).sort((left, right) => left.localeCompare(right)),
metadataUserId,
};
}
const appFingerprintRegistry: DownstreamClientFingerprintRule[] = [
{
id: 'cherry_studio',
name: 'Cherry Studio',
priority: 100,
match(input) {
const hasTitle = headerEquals(input.headers, 'x-title', 'Cherry Studio');
const hasReferer = headerEquals(input.headers, 'http-referer', 'https://cherry-ai.com')
|| headerEquals(input.headers, 'referer', 'https://cherry-ai.com');
if (hasTitle && hasReferer) {
return 'exact';
}
const weakSignals = [
headerIncludes(input.headers, 'user-agent', 'cherrystudio'),
headerIncludes(input.headers, 'x-title', 'cherry studio'),
headerIncludes(input.headers, 'http-referer', 'cherry-ai.com'),
headerIncludes(input.headers, 'referer', 'cherry-ai.com'),
];
return weakSignals.some(Boolean) ? 'heuristic' : null;
},
},
];
function detectDownstreamClientFingerprint(input: {
downstreamPath: string;
headers?: Record<string, unknown>;
body?: unknown;
}) {
const fingerprintInput: DownstreamClientFingerprintInput = {
downstreamPath: input.downstreamPath,
headers: normalizeHeaders(input.headers),
bodySummary: buildBodySummary(input.body),
};
let matchedRule: DownstreamClientFingerprintRule | null = null;
let matchedConfidence: DownstreamClientConfidence | null = null;
for (const rule of appFingerprintRegistry) {
const confidence = rule.match(fingerprintInput);
if (!confidence) continue;
if (!matchedRule || rule.priority > matchedRule.priority) {
matchedRule = rule;
matchedConfidence = confidence;
}
}
if (!matchedRule || !matchedConfidence) {
return null;
}
return {
clientAppId: matchedRule.id,
clientAppName: matchedRule.name,
clientConfidence: matchedConfidence,
};
}
function detectExplicitClientSelfReport(headers: NormalizedClientHeaders): DownstreamResolvedClientApp | null {
for (const value of headers['x-openai-client-user-agent'] || []) {
const clientAppName = parseExplicitClientSelfReportValue(value);
if (!clientAppName) continue;
return {
clientAppId: normalizeClientAppId(clientAppName) || 'self_reported_client',
clientAppName,
clientConfidence: 'exact',
};
}
for (const value of headers['user-agent'] || []) {
const normalized = value.trim().toLowerCase();
if (!normalized.startsWith('openclaw/')) continue;
return {
clientAppId: 'openclaw',
clientAppName: 'OpenClaw',
clientConfidence: 'exact',
};
}
return null;
}
export function detectDownstreamClientContext(input: {
downstreamPath: string;
headers?: Record<string, unknown>;
body?: unknown;
}): DownstreamClientContext {
const detected = detectCliProfile(input);
const normalizedHeaders = normalizeHeaders(input.headers);
const explicitSelfReport = detectExplicitClientSelfReport(normalizedHeaders);
const fingerprint = detectDownstreamClientFingerprint(input);
const profileClientApp = fingerprint || explicitSelfReport
? null
: (
detected.clientAppId && detected.clientAppName
? {
clientAppId: detected.clientAppId,
clientAppName: detected.clientAppName,
...(detected.clientConfidence ? { clientConfidence: detected.clientConfidence } : {}),
}
: null
);
return {
clientKind: detected.id,
...(detected.sessionId ? { sessionId: detected.sessionId } : {}),
...(detected.traceHint ? { traceHint: detected.traceHint } : {}),
...(explicitSelfReport || fingerprint || profileClientApp || {}),
};
}
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';