forked from boos/BoosAPI
[codex] fix downstream client detection boundaries (#429)
* fix downstream client detection boundaries * address downstream client detection review comments
This commit is contained in:
@@ -5,6 +5,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
const claudeCodeUserIdPattern = /^user_[0-9a-f]{64}_account__session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const claudeCodeUserAgentPattern = /^claude-cli\/\d+\.\d+\.\d+/i;
|
||||
|
||||
function isClaudeSurface(path: string): boolean {
|
||||
const normalizedPath = path.trim().toLowerCase();
|
||||
@@ -13,6 +14,33 @@ function isClaudeSurface(path: string): boolean {
|
||||
|| normalizedPath === '/v1/messages/count_tokens';
|
||||
}
|
||||
|
||||
function getHeaderValue(headers: Record<string, unknown> | undefined, targetKey: string): string | null {
|
||||
if (!headers) return null;
|
||||
const normalizedTarget = targetKey.trim().toLowerCase();
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
if (rawKey.trim().toLowerCase() !== normalizedTarget) continue;
|
||||
if (typeof rawValue === 'string') {
|
||||
const trimmed = rawValue.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
if (!Array.isArray(rawValue)) continue;
|
||||
for (const item of rawValue) {
|
||||
if (typeof item !== 'string') continue;
|
||||
const trimmed = item.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasClaudeCodeHeaderFingerprint(headers: Record<string, unknown> | undefined): boolean {
|
||||
const userAgent = getHeaderValue(headers, 'user-agent') || '';
|
||||
if (!claudeCodeUserAgentPattern.test(userAgent)) return false;
|
||||
if (!getHeaderValue(headers, 'anthropic-beta')) return false;
|
||||
if (!getHeaderValue(headers, 'anthropic-version')) return false;
|
||||
return (getHeaderValue(headers, 'x-app') || '').trim().toLowerCase() === 'cli';
|
||||
}
|
||||
|
||||
export function extractClaudeCodeSessionId(userId: string): string | null {
|
||||
const trimmed = userId.trim();
|
||||
if (!claudeCodeUserIdPattern.test(trimmed)) return null;
|
||||
@@ -36,18 +64,15 @@ export const claudeCodeCliProfile: CliProfileDefinition = {
|
||||
},
|
||||
detect(input) {
|
||||
if (!isClaudeSurface(input.downstreamPath)) return null;
|
||||
if (!isRecord(input.body) || !isRecord(input.body.metadata)) return null;
|
||||
|
||||
const userId = typeof input.body.metadata.user_id === 'string'
|
||||
const userId = isRecord(input.body) && isRecord(input.body.metadata) && typeof input.body.metadata.user_id === 'string'
|
||||
? input.body.metadata.user_id.trim()
|
||||
: '';
|
||||
const sessionId = userId ? extractClaudeCodeSessionId(userId) : null;
|
||||
if (!sessionId) return null;
|
||||
if (!sessionId && !hasClaudeCodeHeaderFingerprint(input.headers)) return null;
|
||||
|
||||
return {
|
||||
id: 'claude_code',
|
||||
sessionId,
|
||||
traceHint: sessionId,
|
||||
...(sessionId ? { sessionId, traceHint: sessionId } : {}),
|
||||
clientAppId: 'claude_code',
|
||||
clientAppName: 'Claude Code',
|
||||
clientConfidence: 'exact',
|
||||
|
||||
@@ -130,9 +130,9 @@ function matchesHeaderPrefixes(value: string | string[] | null, prefixes: readon
|
||||
|
||||
function isCodexPath(path: string): boolean {
|
||||
const normalizedPath = path.trim().toLowerCase();
|
||||
return normalizedPath.startsWith('/v1/responses')
|
||||
|| normalizedPath === '/v1/chat/completions'
|
||||
|| normalizedPath.startsWith('/v1/messages');
|
||||
return normalizedPath === '/v1/responses'
|
||||
|| normalizedPath.startsWith('/v1/responses/')
|
||||
|| normalizedPath === '/v1/chat/completions';
|
||||
}
|
||||
|
||||
export function detectCodexOfficialClientApp(
|
||||
|
||||
@@ -70,6 +70,24 @@ describe('detectCliProfile', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not classify non-responses siblings as Codex requests', () => {
|
||||
expect(detectCliProfile({
|
||||
downstreamPath: '/v1/responsesfoo',
|
||||
headers: {
|
||||
'openai-beta': 'responses-2025-03-11',
|
||||
},
|
||||
})).toEqual({
|
||||
id: 'generic',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: false,
|
||||
supportsResponsesWebsocketIncremental: false,
|
||||
preservesContinuation: false,
|
||||
supportsCountTokens: false,
|
||||
echoesTurnState: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('detects Claude Code requests on the count_tokens surface and exposes token counting support', () => {
|
||||
expect(detectCliProfile({
|
||||
downstreamPath: '/v1/messages/count_tokens',
|
||||
@@ -95,6 +113,34 @@ describe('detectCliProfile', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('detects Claude Code messages requests from claude-cli headers even when metadata.user_id is unavailable', () => {
|
||||
expect(detectCliProfile({
|
||||
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({
|
||||
id: 'claude_code',
|
||||
clientAppId: 'claude_code',
|
||||
clientAppName: 'Claude Code',
|
||||
clientConfidence: 'exact',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: false,
|
||||
supportsResponsesWebsocketIncremental: false,
|
||||
preservesContinuation: true,
|
||||
supportsCountTokens: true,
|
||||
echoesTurnState: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('detects Gemini CLI internal routes and exposes Gemini CLI capability flags', () => {
|
||||
expect(detectCliProfile({
|
||||
downstreamPath: '/v1internal:countTokens',
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { detectCliProfile } from './cliProfiles/registry.js';
|
||||
import type {
|
||||
CliProfileClientConfidence,
|
||||
CliProfileId,
|
||||
} from './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;
|
||||
hasOpenCodeSystemPrompt: boolean;
|
||||
};
|
||||
|
||||
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,
|
||||
hasOpenCodeSystemPrompt: false,
|
||||
};
|
||||
}
|
||||
|
||||
const metadataUserId = isRecord(body.metadata) && typeof body.metadata.user_id === 'string'
|
||||
? body.metadata.user_id.trim() || null
|
||||
: null;
|
||||
const systemPromptTexts = (
|
||||
typeof body.system === 'string'
|
||||
? [body.system]
|
||||
: Array.isArray(body.system)
|
||||
? body.system.map((entry) => {
|
||||
if (typeof entry === 'string') return entry;
|
||||
if (!isRecord(entry)) return '';
|
||||
return typeof entry.text === 'string' ? entry.text : '';
|
||||
})
|
||||
: []
|
||||
).filter((entry) => entry.trim().length > 0);
|
||||
const normalizedSystemPrompt = systemPromptTexts
|
||||
.join('\n')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const hasOpenCodeSystemPrompt = normalizedSystemPrompt.includes('you are opencode, an interactive cli tool')
|
||||
|| normalizedSystemPrompt.includes('file called opencode.md');
|
||||
|
||||
return {
|
||||
topLevelKeys: Object.keys(body).sort((left, right) => left.localeCompare(right)),
|
||||
metadataUserId,
|
||||
hasOpenCodeSystemPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
const appFingerprintRegistry: DownstreamClientFingerprintRule[] = [
|
||||
{
|
||||
id: 'opencode',
|
||||
name: 'OpenCode',
|
||||
priority: 110,
|
||||
match(input) {
|
||||
const hasTitle = headerIncludes(input.headers, 'x-title', 'opencode');
|
||||
const hasReferer = headerIncludes(input.headers, 'http-referer', 'opencode.ai')
|
||||
|| headerIncludes(input.headers, 'referer', 'opencode.ai')
|
||||
|| headerIncludes(input.headers, 'origin', 'opencode.ai');
|
||||
const hasUserAgent = headerIncludes(input.headers, 'user-agent', 'opencode/');
|
||||
|
||||
if (hasTitle || hasReferer || hasUserAgent) {
|
||||
return 'exact';
|
||||
}
|
||||
|
||||
return input.bodySummary.hasOpenCodeSystemPrompt ? 'heuristic' : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
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 || {}),
|
||||
};
|
||||
}
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
|
||||
import { getObservedResponseMeta } from '../firstByteTimeout.js';
|
||||
import { getRuntimeResponseReader, readRuntimeResponseText } from '../executors/types.js';
|
||||
import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||
import { detectDownstreamClientContext } from '../downstreamClientContext.js';
|
||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import { shouldAbortSameSiteEndpointFallback } from '../../services/proxyRetryPolicy.js';
|
||||
import {
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
wrapGeminiCliRequest,
|
||||
} from '../../routes/proxy/geminiCliCompat.js';
|
||||
import { dispatchRuntimeRequest } from '../../services/runtimeDispatch.js';
|
||||
import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||
import { detectDownstreamClientContext, type DownstreamClientContext } from '../downstreamClientContext.js';
|
||||
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
|
||||
import { getRuntimeResponseReader, readRuntimeResponseText } from '../executors/types.js';
|
||||
|
||||
@@ -62,7 +62,7 @@ import {
|
||||
sanitizeCompactResponsesRequestBody,
|
||||
shouldFallbackCompactResponsesToResponses,
|
||||
} from '../capabilities/responsesCompact.js';
|
||||
import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||
import { detectDownstreamClientContext } from '../downstreamClientContext.js';
|
||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import { shouldAbortSameSiteEndpointFallback } from '../../services/proxyRetryPolicy.js';
|
||||
import {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
||||
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
|
||||
import type { DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||
import type { DownstreamClientContext } from '../downstreamClientContext.js';
|
||||
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||
import { dispatchRuntimeRequest } from '../../services/runtimeDispatch.js';
|
||||
import type { BuiltEndpointRequest } from '../orchestration/endpointFlow.js';
|
||||
|
||||
Reference in New Issue
Block a user