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');
  });
});