File size: 4,621 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
import type { Request } from 'express';

export const CLIENT_AGENTS = [
  'claude-code',
  'codex',
  'cline',
  'roo',
  'continue',
  'aider',
  'opencode',
  'goose',
  'qwen-code',
  'kilo-code',
  'crush',
  'deepseek-harness',
  'mimo-code',
  'atomcode',
  'openclaw',
  'hermes-agent',
  'pi',
  'reasonix',
  'cursor',
  'gemini-cli',
  'zed',
  'jetbrains',
  'ollama-client',
  'openai-sdk',
  'anthropic-sdk',
  'unknown',
] as const;

export type ClientAgent = (typeof CLIENT_AGENTS)[number];

function header(req: Request, name: string): string {
  const raw = req.headers[name];
  return (Array.isArray(raw) ? raw[0] : raw)?.toLowerCase() ?? '';
}

/**
 * Best-effort classifier from stable protocol/header signals first, then UA.
 * It intentionally returns a coarse enum: analytics should remain useful when
 * a client revs its version string or wraps an underlying SDK.
 */
export function classifyClientAgent(req: Request): ClientAgent {
  const path = (req.originalUrl ?? req.url ?? '').split('?')[0].toLowerCase();
  const tokenizedPath = path.replace(/^\/v1\/t\/[^/]+/, '');
  const ua = header(req, 'user-agent');

  // Strongest signal first: the dedicated session header only Claude Code sets.
  if (header(req, 'x-claude-code-session-id')) return 'claude-code';

  // UA signals must beat wire-format fallbacks: Qwen Code speaks the Gemini
  // wire on /v1beta, and a plain openai SDK can call /v1/responses — neither
  // should be counted as the surface's flagship client. Claude Code's real UA
  // is `claude-cli/x.y.z (external, cli)`.
  if (/claude[- ]?code|\bclaude-cli\b/.test(ua)) return 'claude-code';
  if (/\bcodex\b/.test(ua)) return 'codex';
  if (/qwen[- ]?code|\bqwen-cli\b/.test(ua)) return 'qwen-code';
  if (/gemini[- /]?cli/.test(ua)) return 'gemini-cli';
  if (/kilo[- ]?code|\bkilocode\b/.test(ua)) return 'kilo-code';
  if (/\bcrush(?:\/|\s|$)/.test(ua)) return 'crush';
  // `deepseek-harness/<version> (+https://github.com/deepseek-ai/deepseek-harness)`
  if (/deepseek[- ]?harness|\bdsh\//.test(ua)) return 'deepseek-harness';
  // MiMo Code is an OpenCode derivative and can carry either name in its UA,
  // so it has to be tested before the plain `opencode` rule below.
  if (/mimo[- ]?code|\bmimo\//.test(ua)) return 'mimo-code';
  // `atomcode/<version>`
  if (/\batomcode\b/.test(ua)) return 'atomcode';
  // OpenClaw names itself only through the static provider header our
  // generator writes (`openclaw`); its own `openclaw/<version>` UA is reserved
  // for endpoints it recognises.
  if (/\bopenclaw\b/.test(ua)) return 'openclaw';
  // Hermes Agent likewise only names itself to hosts it knows; the generator's
  // `default_headers` sends `hermes-agent`, and its /v1/models probe sends
  // `hermes-cli/<version>` on its own.
  if (/hermes[- ]?agent|\bhermes-cli\b/.test(ua)) return 'hermes-agent';
  // Pi likewise only names itself to hosts it knows; the generator's provider
  // header sends `pi-coding-agent`, and Pi's own UA is
  // `pi/<version> (<platform>; node/<version>; <arch>)`.
  if (/\bpi-coding-agent\b|^pi\/\d/.test(ua)) return 'pi';
  // Reasonix sends `Reasonix/<version>` to every endpoint on its own.
  if (/^reasonix\//.test(ua)) return 'reasonix';
  if (/opencode/.test(ua)) return 'opencode';
  if (/\bcline\b/.test(ua)) return 'cline';
  // The Roo Code extension and CLI send `RooCode/<version>`.
  if (/\broo[- /]/.test(ua) || /roo ?code/.test(ua)) return 'roo';
  if (/continue(?:\.dev)?/.test(ua)) return 'continue';
  if (/\baider\b/.test(ua)) return 'aider';
  if (/\bgoose\b/.test(ua)) return 'goose';
  if (/\bcursor\b/.test(ua)) return 'cursor';
  if (/\bzed\b/.test(ua)) return 'zed';
  if (/jetbrains|intellij|pycharm|webstorm/.test(ua)) return 'jetbrains';
  if (/\bollama\b/.test(ua)) return 'ollama-client';
  if (/anthropic|claude-sdk/.test(ua)) return 'anthropic-sdk';
  if (/openai|langchain|litellm/.test(ua)) return 'openai-sdk';

  // Wire-format fallbacks for clients with no recognizable UA.
  if (path.startsWith('/v1/responses') || tokenizedPath.startsWith('/responses')) return 'codex';
  if (path.startsWith('/v1beta/')) return 'gemini-cli';
  if (path.startsWith('/v1/messages')) {
    // anthropic.ts session affinity treats x-session-id as a Claude Code
    // signal on this surface; anything else Anthropic-shaped stays coarse.
    return header(req, 'x-session-id') ? 'claude-code' : 'anthropic-sdk';
  }
  if (tokenizedPath.startsWith('/api/chat')
      || tokenizedPath.startsWith('/api/generate')
      || tokenizedPath.startsWith('/api/tags')) return 'ollama-client';
  return 'unknown';
}