[codex] fix downstream client detection boundaries (#429)
* fix downstream client detection boundaries * address downstream client detection review comments
This commit is contained in:
@@ -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 || {}),
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user