File size: 5,652 Bytes
dcedb25 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 | import { describe, expect, it } from 'vitest';
import type { Request } from 'express';
import { classifyClientAgent } from '../../lib/client-classifier.js';
function request(path: string, headers: Record<string, string> = {}): Request {
return {
originalUrl: path,
headers,
} as unknown as Request;
}
describe('client agent classification', () => {
it('prefers distinctive headers and protocol paths', () => {
expect(classifyClientAgent(request('/v1/messages', {
'x-claude-code-session-id': 'session',
}))).toBe('claude-code');
expect(classifyClientAgent(request('/v1/responses'))).toBe('codex');
expect(classifyClientAgent(request('/v1beta/models/x:generateContent'))).toBe('gemini-cli');
expect(classifyClientAgent(request('/api/chat'))).toBe('ollama-client');
expect(classifyClientAgent(request('/v1/t/revocable/responses'))).toBe('codex');
expect(classifyClientAgent(request('/v1/t/revocable/api/chat'))).toBe('ollama-client');
});
it('recognizes common coding-agent user agents', () => {
expect(classifyClientAgent(request('/v1/chat/completions', {
'user-agent': 'opencode/1.2.3',
}))).toBe('opencode');
expect(classifyClientAgent(request('/v1/chat/completions', {
'user-agent': 'aider/0.80',
}))).toBe('aider');
expect(classifyClientAgent(request('/v1/chat/completions', {
'user-agent': 'Kilo-Code/4.2',
}))).toBe('kilo-code');
expect(classifyClientAgent(request('/v1/chat/completions', {
'user-agent': 'crush/0.8',
}))).toBe('crush');
expect(classifyClientAgent(request('/v1/chat/completions', {
'user-agent': 'deepseek-harness/0.3.1 (+https://github.com/deepseek-ai/deepseek-harness)',
}))).toBe('deepseek-harness');
expect(classifyClientAgent(request('/v1/chat/completions', {
'user-agent': 'atomcode/5.0.9',
}))).toBe('atomcode');
expect(classifyClientAgent(request('/v1/chat/completions', {
'user-agent': 'RooCode/3.50.4',
}))).toBe('roo');
});
it('recognizes OpenClaw by the static header its generated provider sends', () => {
// A custom provider in OpenClaw gets the bare openai-node UA, so the
// generator sets `headers: { User-Agent: 'openclaw' }`; the bare word and
// the `openclaw/<version>` form OpenClaw uses natively both count.
expect(classifyClientAgent(request('/v1/chat/completions', {
'user-agent': 'openclaw',
}))).toBe('openclaw');
expect(classifyClientAgent(request('/v1/chat/completions', {
'user-agent': 'openclaw/2026.9.4',
}))).toBe('openclaw');
// The unmodified SDK UA still files under openai-sdk, so a hand-written
// provider without the header is not misattributed.
expect(classifyClientAgent(request('/v1/chat/completions', {
'user-agent': 'OpenAI/JS 7.8.0',
}))).toBe('openai-sdk');
});
it('recognizes Hermes Agent by its header and its model-probe UA', () => {
expect(classifyClientAgent(request('/v1/chat/completions', {
'user-agent': 'hermes-agent',
}))).toBe('hermes-agent');
expect(classifyClientAgent(request('/v1/models', {
'user-agent': 'hermes-cli/0.21.3',
}))).toBe('hermes-agent');
});
it('recognizes Pi by its header and its native UA without catching other `pi` substrings', () => {
expect(classifyClientAgent(request('/v1/chat/completions', {
'user-agent': 'pi-coding-agent',
}))).toBe('pi');
expect(classifyClientAgent(request('/v1/chat/completions', {
'user-agent': 'pi/0.99.2 (darwin; node/v24.4.0; arm64)',
}))).toBe('pi');
expect(classifyClientAgent(request('/v1/chat/completions', {
'user-agent': 'OpenAI/JS 6.10.0',
}))).toBe('openai-sdk');
expect(classifyClientAgent(request('/v1/chat/completions', {
'user-agent': 'my-api-client/1.0',
}))).not.toBe('pi');
});
it('recognizes Reasonix by the UA it sends to every endpoint', () => {
expect(classifyClientAgent(request('/v1/chat/completions', {
'user-agent': 'Reasonix/2.29.0',
}))).toBe('reasonix');
// Its web_fetch tool is not a model request from the agent loop.
expect(classifyClientAgent(request('/v1/chat/completions', {
'user-agent': 'reasonix-web-fetch/1.0',
}))).not.toBe('reasonix');
});
it('separates MiMo Code from the OpenCode it derives from', () => {
expect(classifyClientAgent(request('/v1/chat/completions', {
'user-agent': 'mimo/0.4.0',
}))).toBe('mimo-code');
expect(classifyClientAgent(request('/v1/chat/completions', {
'user-agent': 'MiMo-Code/0.4.0 (opencode)',
}))).toBe('mimo-code');
expect(classifyClientAgent(request('/v1/chat/completions', {
'user-agent': 'opencode/1.2.3',
}))).toBe('opencode');
});
it('recognizes Claude Code by its real claude-cli user agent', () => {
expect(classifyClientAgent(request('/v1/messages', {
'user-agent': 'claude-cli/1.0.62 (external, cli)',
}))).toBe('claude-code');
expect(classifyClientAgent(request('/v1/messages', {
'x-session-id': 'affinity',
}))).toBe('claude-code');
expect(classifyClientAgent(request('/v1/messages'))).toBe('anthropic-sdk');
});
it('lets user agents beat wire-format fallbacks', () => {
// Qwen Code speaks the Gemini wire; it must not count as gemini-cli.
expect(classifyClientAgent(request('/v1beta/models/x:generateContent', {
'user-agent': 'QwenCode/0.5 qwen-code',
}))).toBe('qwen-code');
// A plain openai SDK on /v1/responses is not Codex.
expect(classifyClientAgent(request('/v1/responses', {
'user-agent': 'OpenAI/NodeJS/4.52.0',
}))).toBe('openai-sdk');
});
});
|