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 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 {
|
function isClaudeSurface(path: string): boolean {
|
||||||
const normalizedPath = path.trim().toLowerCase();
|
const normalizedPath = path.trim().toLowerCase();
|
||||||
@@ -13,6 +14,33 @@ function isClaudeSurface(path: string): boolean {
|
|||||||
|| normalizedPath === '/v1/messages/count_tokens';
|
|| 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 {
|
export function extractClaudeCodeSessionId(userId: string): string | null {
|
||||||
const trimmed = userId.trim();
|
const trimmed = userId.trim();
|
||||||
if (!claudeCodeUserIdPattern.test(trimmed)) return null;
|
if (!claudeCodeUserIdPattern.test(trimmed)) return null;
|
||||||
@@ -36,18 +64,15 @@ export const claudeCodeCliProfile: CliProfileDefinition = {
|
|||||||
},
|
},
|
||||||
detect(input) {
|
detect(input) {
|
||||||
if (!isClaudeSurface(input.downstreamPath)) return null;
|
if (!isClaudeSurface(input.downstreamPath)) return null;
|
||||||
if (!isRecord(input.body) || !isRecord(input.body.metadata)) return null;
|
const userId = isRecord(input.body) && isRecord(input.body.metadata) && typeof input.body.metadata.user_id === 'string'
|
||||||
|
|
||||||
const userId = typeof input.body.metadata.user_id === 'string'
|
|
||||||
? input.body.metadata.user_id.trim()
|
? input.body.metadata.user_id.trim()
|
||||||
: '';
|
: '';
|
||||||
const sessionId = userId ? extractClaudeCodeSessionId(userId) : null;
|
const sessionId = userId ? extractClaudeCodeSessionId(userId) : null;
|
||||||
if (!sessionId) return null;
|
if (!sessionId && !hasClaudeCodeHeaderFingerprint(input.headers)) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: 'claude_code',
|
id: 'claude_code',
|
||||||
sessionId,
|
...(sessionId ? { sessionId, traceHint: sessionId } : {}),
|
||||||
traceHint: sessionId,
|
|
||||||
clientAppId: 'claude_code',
|
clientAppId: 'claude_code',
|
||||||
clientAppName: 'Claude Code',
|
clientAppName: 'Claude Code',
|
||||||
clientConfidence: 'exact',
|
clientConfidence: 'exact',
|
||||||
|
|||||||
@@ -130,9 +130,9 @@ function matchesHeaderPrefixes(value: string | string[] | null, prefixes: readon
|
|||||||
|
|
||||||
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 === '/v1/responses'
|
||||||
|| normalizedPath === '/v1/chat/completions'
|
|| normalizedPath.startsWith('/v1/responses/')
|
||||||
|| normalizedPath.startsWith('/v1/messages');
|
|| normalizedPath === '/v1/chat/completions';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function detectCodexOfficialClientApp(
|
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', () => {
|
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',
|
||||||
@@ -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', () => {
|
it('detects Gemini CLI internal routes and exposes Gemini CLI capability flags', () => {
|
||||||
expect(detectCliProfile({
|
expect(detectCliProfile({
|
||||||
downstreamPath: '/v1internal:countTokens',
|
downstreamPath: '/v1internal:countTokens',
|
||||||
|
|||||||
+40
-2
@@ -1,8 +1,8 @@
|
|||||||
import { detectCliProfile } from '../../proxy-core/cliProfiles/registry.js';
|
import { detectCliProfile } from './cliProfiles/registry.js';
|
||||||
import type {
|
import type {
|
||||||
CliProfileClientConfidence,
|
CliProfileClientConfidence,
|
||||||
CliProfileId,
|
CliProfileId,
|
||||||
} from '../../proxy-core/cliProfiles/types.js';
|
} from './cliProfiles/types.js';
|
||||||
|
|
||||||
export type DownstreamClientKind = CliProfileId;
|
export type DownstreamClientKind = CliProfileId;
|
||||||
export type DownstreamClientConfidence = CliProfileClientConfidence;
|
export type DownstreamClientConfidence = CliProfileClientConfidence;
|
||||||
@@ -21,6 +21,7 @@ type NormalizedClientHeaders = Record<string, string[]>;
|
|||||||
type DownstreamClientBodySummary = {
|
type DownstreamClientBodySummary = {
|
||||||
topLevelKeys: string[];
|
topLevelKeys: string[];
|
||||||
metadataUserId: string | null;
|
metadataUserId: string | null;
|
||||||
|
hasOpenCodeSystemPrompt: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DownstreamClientFingerprintInput = {
|
type DownstreamClientFingerprintInput = {
|
||||||
@@ -122,20 +123,57 @@ function buildBodySummary(body: unknown): DownstreamClientBodySummary {
|
|||||||
return {
|
return {
|
||||||
topLevelKeys: [],
|
topLevelKeys: [],
|
||||||
metadataUserId: null,
|
metadataUserId: null,
|
||||||
|
hasOpenCodeSystemPrompt: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadataUserId = isRecord(body.metadata) && typeof body.metadata.user_id === 'string'
|
const metadataUserId = isRecord(body.metadata) && typeof body.metadata.user_id === 'string'
|
||||||
? body.metadata.user_id.trim() || null
|
? body.metadata.user_id.trim() || null
|
||||||
: 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 {
|
return {
|
||||||
topLevelKeys: Object.keys(body).sort((left, right) => left.localeCompare(right)),
|
topLevelKeys: Object.keys(body).sort((left, right) => left.localeCompare(right)),
|
||||||
metadataUserId,
|
metadataUserId,
|
||||||
|
hasOpenCodeSystemPrompt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const appFingerprintRegistry: DownstreamClientFingerprintRule[] = [
|
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',
|
id: 'cherry_studio',
|
||||||
name: 'Cherry Studio',
|
name: 'Cherry Studio',
|
||||||
@@ -47,7 +47,7 @@ import {
|
|||||||
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
|
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
|
||||||
import { getObservedResponseMeta } from '../firstByteTimeout.js';
|
import { getObservedResponseMeta } from '../firstByteTimeout.js';
|
||||||
import { getRuntimeResponseReader, readRuntimeResponseText } from '../executors/types.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 { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||||
import { shouldAbortSameSiteEndpointFallback } from '../../services/proxyRetryPolicy.js';
|
import { shouldAbortSameSiteEndpointFallback } from '../../services/proxyRetryPolicy.js';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
wrapGeminiCliRequest,
|
wrapGeminiCliRequest,
|
||||||
} from '../../routes/proxy/geminiCliCompat.js';
|
} from '../../routes/proxy/geminiCliCompat.js';
|
||||||
import { dispatchRuntimeRequest } from '../../services/runtimeDispatch.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 { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||||
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
|
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
|
||||||
import { getRuntimeResponseReader, readRuntimeResponseText } from '../executors/types.js';
|
import { getRuntimeResponseReader, readRuntimeResponseText } from '../executors/types.js';
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ import {
|
|||||||
sanitizeCompactResponsesRequestBody,
|
sanitizeCompactResponsesRequestBody,
|
||||||
shouldFallbackCompactResponsesToResponses,
|
shouldFallbackCompactResponsesToResponses,
|
||||||
} from '../capabilities/responsesCompact.js';
|
} from '../capabilities/responsesCompact.js';
|
||||||
import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
import { detectDownstreamClientContext } from '../downstreamClientContext.js';
|
||||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||||
import { shouldAbortSameSiteEndpointFallback } from '../../services/proxyRetryPolicy.js';
|
import { shouldAbortSameSiteEndpointFallback } from '../../services/proxyRetryPolicy.js';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js';
|
|||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
||||||
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.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 { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||||
import { dispatchRuntimeRequest } from '../../services/runtimeDispatch.js';
|
import { dispatchRuntimeRequest } from '../../services/runtimeDispatch.js';
|
||||||
import type { BuiltEndpointRequest } from '../orchestration/endpointFlow.js';
|
import type { BuiltEndpointRequest } from '../orchestration/endpointFlow.js';
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { detectProxyFailure } from './proxyFailureJudge.js';
|
|||||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||||
import { getProxyAuthContext } from '../../middleware/auth.js';
|
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||||
import { buildUpstreamUrl } from './upstreamUrl.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 { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||||
import { fetchWithObservedFirstByte, getObservedResponseMeta } from '../../proxy-core/firstByteTimeout.js';
|
import { fetchWithObservedFirstByte, getObservedResponseMeta } from '../../proxy-core/firstByteTimeout.js';
|
||||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ function readSource(relativePath: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('downstreamClientContext architecture boundaries', () => {
|
describe('downstreamClientContext architecture boundaries', () => {
|
||||||
it('keeps protocol-level client metadata on cli profiles instead of importing individual profile helpers', () => {
|
it('keeps shared downstream client detection outside the route layer and avoids individual profile helper imports', () => {
|
||||||
const source = readSource('./downstreamClientContext.ts');
|
const source = readSource('../../proxy-core/downstreamClientContext.ts');
|
||||||
|
|
||||||
expect(source).toContain("from '../../proxy-core/cliProfiles/registry.js'");
|
expect(source).toContain("from './cliProfiles/registry.js'");
|
||||||
expect(source).toContain("from '../../proxy-core/cliProfiles/types.js'");
|
expect(source).toContain("from './cliProfiles/types.js'");
|
||||||
expect(source).not.toContain("from '../../proxy-core/cliProfiles/codexProfile.js'");
|
expect(source).not.toContain("from './cliProfiles/codexProfile.js'");
|
||||||
expect(source).not.toContain("from '../../proxy-core/cliProfiles/claudeCodeProfile.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]');
|
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 () => {
|
it('keeps invalid Claude metadata.user_id requests generic in failure logs', async () => {
|
||||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
error: {
|
error: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { extractClaudeCodeSessionId } from '../../proxy-core/cliProfiles/claudeCodeProfile.js';
|
import { extractClaudeCodeSessionId } from '../../proxy-core/cliProfiles/claudeCodeProfile.js';
|
||||||
import { isCodexResponsesSurface } from '../../proxy-core/cliProfiles/codexProfile.js';
|
import { isCodexResponsesSurface } from '../../proxy-core/cliProfiles/codexProfile.js';
|
||||||
import { detectDownstreamClientContext } from './downstreamClientContext.js';
|
import { detectDownstreamClientContext } from '../../proxy-core/downstreamClientContext.js';
|
||||||
|
|
||||||
describe('extractClaudeCodeSessionId', () => {
|
describe('extractClaudeCodeSessionId', () => {
|
||||||
it('extracts session uuid from axonhub-compatible Claude Code user ids', () => {
|
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', () => {
|
it('falls back to generic when Claude metadata.user_id is missing or invalid', () => {
|
||||||
expect(detectDownstreamClientContext({
|
expect(detectDownstreamClientContext({
|
||||||
downstreamPath: '/v1/messages',
|
downstreamPath: '/v1/messages',
|
||||||
@@ -253,4 +274,39 @@ describe('detectDownstreamClientContext', () => {
|
|||||||
clientConfidence: 'heuristic',
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
|||||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||||
import { getProxyAuthContext } from '../../middleware/auth.js';
|
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||||
import { buildUpstreamUrl } from './upstreamUrl.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 { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||||
import { fetchWithObservedFirstByte, getObservedResponseMeta } from '../../proxy-core/firstByteTimeout.js';
|
import { fetchWithObservedFirstByte, getObservedResponseMeta } from '../../proxy-core/firstByteTimeout.js';
|
||||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.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 { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
|
||||||
import { getProxyAuthContext } from '../../middleware/auth.js';
|
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||||
import { buildUpstreamUrl } from './upstreamUrl.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 { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||||
import { fetchWithObservedFirstByte, getObservedResponseMeta } from '../../proxy-core/firstByteTimeout.js';
|
import { fetchWithObservedFirstByte, getObservedResponseMeta } from '../../proxy-core/firstByteTimeout.js';
|
||||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { composeProxyLogMessage } from './logPathMeta.js';
|
|||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
import { getProxyAuthContext } from '../../middleware/auth.js';
|
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||||
import { buildUpstreamUrl } from './upstreamUrl.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 { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||||
import { fetchWithObservedFirstByte, getObservedResponseMeta } from '../../proxy-core/firstByteTimeout.js';
|
import { fetchWithObservedFirstByte, getObservedResponseMeta } from '../../proxy-core/firstByteTimeout.js';
|
||||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user