File size: 6,416 Bytes
077865a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
import { describe, it, expect, beforeAll } from 'vitest';
import type { Express } from 'express';
import { createApp } from '../../app.js';
import { initDb, getDb, getUnifiedApiKey } from '../../db/index.js';
import { routeRequest, setRoutingStrategy } from '../../services/router.js';
import { encrypt } from '../../lib/crypto.js';

async function post(app: Express, path: string, body: any, key: string) {
  const server = app.listen(0);
  const addr = server.address() as any;
  const res = await fetch(`http://127.0.0.1:${addr.port}${path}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
    body: JSON.stringify(body),
  });
  const text = await res.text();
  server.close();
  let json: any = null;
  try { json = JSON.parse(text); } catch {}
  return { status: res.status, body: json };
}

const WEATHER_TOOL = {
  type: 'function',
  function: {
    name: 'get_weather',
    description: 'Get the weather for a city',
    parameters: { type: 'object', properties: { city: { type: 'string' } } },
  },
};

const TOOLS_CHAT = {
  messages: [{ role: 'user', content: 'what is the weather in Berlin?' }],
  tools: [WEATHER_TOOL],
};

const TOOLS_RESPONSES = {
  input: 'what is the weather in Berlin?',
  tools: [{
    type: 'function',
    name: 'get_weather',
    description: 'Get the weather for a city',
    parameters: { type: 'object', properties: { city: { type: 'string' } } },
  }],
};

describe('Tools-aware routing', () => {
  let app: Express;
  let key: string;

  beforeAll(() => {
    process.env.ENCRYPTION_KEY = '0'.repeat(64);
    initDb(':memory:');
    app = createApp();
    key = getUnifiedApiKey();
  });

  it('flags tool-capable families and leaves the known-bad ones unflagged', () => {
    const db = getDb();
    const flag = (modelId: string) =>
      (db.prepare('SELECT supports_tools FROM models WHERE model_id = ?').get(modelId) as { supports_tools: number } | undefined)?.supports_tools;

    // Verified tool-callers from the live benchmark stay eligible.
    expect(flag('openai/gpt-oss-120b')).toBe(1);
    expect(flag('gemini-2.5-flash')).toBe(1);
    expect(flag('llama-3.3-70b-versatile')).toBe(1);

    // hermes-3 emits tool calls as text — must NOT ride the llama-3 rule.
    const hermes = db.prepare("SELECT supports_tools FROM models WHERE model_id LIKE '%hermes-3%'").all() as { supports_tools: number }[];
    for (const h of hermes) expect(h.supports_tools).toBe(0);

    // gemma must NOT ride the gemini rule.
    const gemma = db.prepare("SELECT supports_tools FROM models WHERE LOWER(model_id) LIKE '%gemma%'").all() as { supports_tools: number }[];
    for (const g of gemma) expect(g.supports_tools).toBe(0);

    // Sanity: the flag splits the catalog (some 1s, some 0s).
    const on = (db.prepare('SELECT COUNT(*) c FROM models WHERE supports_tools = 1').get() as { c: number }).c;
    const off = (db.prepare('SELECT COUNT(*) c FROM models WHERE supports_tools = 0').get() as { c: number }).c;
    expect(on).toBeGreaterThanOrEqual(5);
    expect(off).toBeGreaterThan(0);
  });

  it('routeRequest skips non-tool models when requireTools is set', () => {
    const db = getDb();
    setRoutingStrategy('priority');

    // One key for google, whose catalog holds both a non-tool model (gemma)
    // and tool-capable ones (gemini). Put gemma at the top of the chain.
    const { encrypted, iv, authTag } = encrypt('test-google-key');
    db.prepare(`
      INSERT INTO api_keys (platform, label, encrypted_key, iv, auth_tag, status, enabled)
      VALUES ('google', 'test', ?, ?, ?, 'healthy', 1)
    `).run(encrypted, iv, authTag);

    const gemma = db.prepare("SELECT id FROM models WHERE platform = 'google' AND LOWER(model_id) LIKE '%gemma%' AND enabled = 1").get() as { id: number } | undefined;
    expect(gemma).toBeDefined();
    db.prepare('UPDATE fallback_config SET priority = 0, enabled = 1 WHERE model_db_id = ?').run(gemma!.id);

    // Plain request takes the chain head: gemma.
    const plain = routeRequest(1000);
    expect(plain.modelId.toLowerCase()).toContain('gemma');

    // Tool-bearing request must skip past gemma to a tool-capable model.
    const tooled = routeRequest(1000, undefined, undefined, false, true);
    expect(tooled.modelId.toLowerCase()).not.toContain('gemma');
    const flag = db.prepare('SELECT supports_tools FROM models WHERE id = ?').get(tooled.modelDbId) as { supports_tools: number };
    expect(flag.supports_tools).toBe(1);

    db.prepare('DELETE FROM api_keys').run();
  });

  it('lets a tool request through routing when a tool-capable model is enabled (no 422)', async () => {
    // No provider keys exist, so routing exhausts → 429/503. The point: it is
    // NOT the 422 "no tools model" error, proving the precheck passed.
    const { status, body } = await post(app, '/v1/chat/completions', TOOLS_CHAT, key);
    expect(status).not.toBe(422);
    expect(body?.error?.code).not.toBe('no_tools_model');
  });

  it('rejects a tool request with a clear 422 when no tool-capable model is enabled', async () => {
    getDb().prepare('UPDATE models SET enabled = 0 WHERE supports_tools = 1').run();

    const { status, body } = await post(app, '/v1/chat/completions', TOOLS_CHAT, key);
    expect(status).toBe(422);
    expect(body.error.code).toBe('no_tools_model');
    expect(body.error.type).toBe('invalid_request_error');

    getDb().prepare('UPDATE models SET enabled = 1 WHERE supports_tools = 1').run();
  });

  it('applies the same gate on /v1/responses (Codex path)', async () => {
    getDb().prepare('UPDATE models SET enabled = 0 WHERE supports_tools = 1').run();

    const { status, body } = await post(app, '/v1/responses', TOOLS_RESPONSES, key);
    expect(status).toBe(422);
    expect(body.error.code).toBe('no_tools_model');

    getDb().prepare('UPDATE models SET enabled = 1 WHERE supports_tools = 1').run();
  });

  it('does not apply the tools gate to a plain chat request', async () => {
    getDb().prepare('UPDATE models SET enabled = 0 WHERE supports_tools = 1').run();
    const { status, body } = await post(app, '/v1/chat/completions', {
      messages: [{ role: 'user', content: 'hello' }],
    }, key);
    expect(status).not.toBe(422);
    expect(body?.error?.code).not.toBe('no_tools_model');
    getDb().prepare('UPDATE models SET enabled = 1 WHERE supports_tools = 1').run();
  });
});