auto-model-router 0.27.1 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.27.1",
10
+ "version": "0.29.0",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.27.1",
17
+ "version": "0.29.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.27.1",
3
+ "version": "0.29.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -125,16 +125,22 @@ export function buildOllamaModels(args: BuildOllamaArgs): CatalogModel[] {
125
125
  for (const l of listings) {
126
126
  if (!l.isCloud && !cfg.includeLocal) continue;
127
127
  const priceName = l.remoteModel ?? l.id;
128
- const rate = ollamaRateFor(priceName, cfg.prices);
129
- if (rate === null) {
130
- skipped.push(l.id);
131
- continue;
132
- }
133
128
  // A pin may name the tagged cloud name, its base, or the listed id.
134
129
  const bare = bareCloudName(priceName);
135
130
  const base = bare.includes(":") ? bare.slice(0, bare.indexOf(":")) : bare;
136
131
  const pinned = cfg.twins[bare] ?? cfg.twins[base] ?? cfg.twins[l.id];
137
132
  const twin = (pinned !== undefined ? bySlug.get(pinned) : undefined) ?? twins.get(ollamaTwinKey(priceName)) ?? null;
133
+ // Ollama publishes no prices, so they come from a static table — which means a model
134
+ // Ollama ships today is INVISIBLE until that table gains an entry, however good it is.
135
+ // Measured: deepseek-v4.1-flash was listed by /api/tags and dropped here, so routing
136
+ // never saw it. The OpenRouter twin sells the same weights per token, so it is the
137
+ // honest stand-in until the table catches up; a model with neither is still skipped.
138
+ const rate = ollamaRateFor(priceName, cfg.prices);
139
+ const price = rate !== null ? toPrice(rate.rate) : twin !== null ? { ...twin.price } : null;
140
+ if (price === null) {
141
+ skipped.push(`${l.id} (no price and no twin)`);
142
+ continue;
143
+ }
138
144
  const contextLength = l.contextLength ?? twin?.contextLength ?? null;
139
145
  if (contextLength === null) {
140
146
  skipped.push(`${l.id} (no context length)`);
@@ -156,7 +162,7 @@ export function buildOllamaModels(args: BuildOllamaArgs): CatalogModel[] {
156
162
  // Ollama's OpenAI-compatible endpoint documents `tool_choice` as unsupported.
157
163
  supportsToolChoice: false,
158
164
  inputModalities: modalities,
159
- price: toPrice(rate.rate),
165
+ price,
160
166
  priceTiers: [],
161
167
  quality: twin === null ? {} : { ...twin.quality },
162
168
  tokenizer: twin?.tokenizer ?? "Other",
@@ -167,7 +173,10 @@ export function buildOllamaModels(args: BuildOllamaArgs): CatalogModel[] {
167
173
  if (twin?.maxCompletionTokens !== undefined) model.maxCompletionTokens = twin.maxCompletionTokens;
168
174
  out.push(model);
169
175
  }
170
- if (skipped.length > 0) log?.debug("ollama models skipped (no price or context)", { skipped: skipped.join(", ") });
176
+ // A model the provider offers and the router refuses to route is operationally
177
+ // significant, not a debug detail: at `debug` this was invisible, and a newly published
178
+ // model stayed unroutable with nothing in the log to say so.
179
+ if (skipped.length > 0) log?.warn("ollama models listed but not routable", { skipped: skipped.join(", ") });
171
180
  return out;
172
181
  }
173
182
 
@@ -330,7 +330,10 @@ export function buildUsageReport(
330
330
  const feedbackBySlug = createFeedbackStore(db).countsBySlug(sinceMs, harnessId);
331
331
  const models: ModelRow[] = modelRows.map((r) => ({
332
332
  ...toRow(r, windowSpend),
333
- provider: r.key.startsWith("ollama/") ? "ollama" : "openrouter",
333
+ // `providerOfSlug`, not a two-way guess: a named upstream's namespace is a provider
334
+ // too. Hardcoding ollama-or-openrouter reported every subscription and direct-provider
335
+ // turn as OpenRouter — 87 Opus dispatches filed against a provider that never saw them.
336
+ provider: providerOfSlug(r.key),
334
337
  tiers: mixByModel.get(r.key) ?? {},
335
338
  feedback: feedbackBySlug.get(r.key) ?? { good: 0, bad: 0 },
336
339
  }));
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Agentic capability scenarios: a REAL tool loop against a deterministic workspace.
3
+ *
4
+ * The text suite in `tasks.ts` asks a model to *print JSON describing* a tool call. That
5
+ * measures JSON formatting, not agency: the model never issues a call, never sees a result,
6
+ * and never takes a second step — so a model that can format one call scores the same as one
7
+ * that can plan five. One task on that axis is literally "reply with the word ACK".
8
+ *
9
+ * Here the model is handed real `tools`, its calls are executed against an in-memory
10
+ * workspace, and the results are fed back until it answers or runs out of steps. That
11
+ * exercises the things a coding loop actually fails at:
12
+ *
13
+ * - multi-step planning: the answer is only reachable by chaining calls
14
+ * - using a result it was given, rather than inventing one
15
+ * - argument schemas, including a required field it must not omit
16
+ * - recovering from a tool that returns an ERROR instead of data
17
+ * - stopping: not calling tools forever, and not calling a forbidden one
18
+ *
19
+ * Everything is offline and deterministic — the only network is the model itself, so a
20
+ * scenario grades the model and never the weather.
21
+ */
22
+
23
+ import type { ToolCall } from "../upstream/types.ts";
24
+
25
+ export interface ToolSpec {
26
+ name: string;
27
+ description: string;
28
+ parameters: Record<string, unknown>;
29
+ }
30
+
31
+ /** A step the loop took, for grading the trajectory rather than only the answer. */
32
+ export interface Step {
33
+ call: ToolCall;
34
+ result: string;
35
+ /** True when the workspace refused the call (bad path, missing argument, injected fault). */
36
+ failed: boolean;
37
+ }
38
+
39
+ export interface ScenarioRun {
40
+ steps: Step[];
41
+ /** Final assistant text, "" when the model never stopped calling tools. */
42
+ answer: string;
43
+ /** True when the step budget ran out — a model that would not stop. */
44
+ exhausted: boolean;
45
+ }
46
+
47
+ export interface Scenario {
48
+ id: string;
49
+ /** Always `agentic`: these measure tool-driving, which is what the axis is for. */
50
+ system?: string;
51
+ user: string;
52
+ tools: ToolSpec[];
53
+ /** Executes one call against the scenario's own state. */
54
+ run(call: ToolCall, state: ScenarioState): { result: string; failed: boolean };
55
+ /** 0-1. Sees the whole trajectory, so "got the answer by luck" scores below "worked it out". */
56
+ grade(run: ScenarioRun): number;
57
+ maxSteps?: number;
58
+ }
59
+
60
+ /** Per-run mutable state, so a scenario can inject a fault on the first attempt only. */
61
+ export interface ScenarioState {
62
+ attempts: Record<string, number>;
63
+ }
64
+
65
+ const STR = { type: "string" } as const;
66
+
67
+ /** The workspace every file scenario reads. Small, fixed, and boring on purpose. */
68
+ const FILES: Record<string, string> = {
69
+ "src/a.ts": "export const RETRIES = 3;\nexport const TIMEOUT_MS = 2500;\n",
70
+ "src/b.ts": "export const RETRIES = 4;\n",
71
+ "README.md": "# demo\nThe build uses bun.\n",
72
+ };
73
+
74
+ const readFileTool: ToolSpec = { name: "read_file", description: "Read a file's contents.", parameters: { type: "object", properties: { path: STR }, required: ["path"] } };
75
+ const listDirTool: ToolSpec = { name: "list_dir", description: "List the files in a directory.", parameters: { type: "object", properties: { path: STR }, required: ["path"] } };
76
+
77
+ const str = (v: unknown): string => (typeof v === "string" ? v : "");
78
+
79
+ export const AGENTIC_SCENARIOS: readonly Scenario[] = [
80
+ {
81
+ // Chaining: the total is only knowable by reading BOTH files. A model that answers
82
+ // without reading, or reads one and guesses, is wrong.
83
+ id: "agentic/chain-two-reads",
84
+ system: "You have tools. Use them to find the answer, then state the final number alone.",
85
+ user: "Add the RETRIES value in src/a.ts to the RETRIES value in src/b.ts. What is the total?",
86
+ tools: [readFileTool, listDirTool],
87
+ run(call) {
88
+ if (call.name !== "read_file") return { result: `ERROR: no such tool ${call.name}`, failed: true };
89
+ const path = str(call.args.path);
90
+ const body = FILES[path];
91
+ return body === undefined ? { result: `ERROR: no such file ${path}`, failed: true } : { result: body, failed: false };
92
+ },
93
+ grade(run) {
94
+ const read = new Set(run.steps.filter((s) => !s.failed && s.call.name === "read_file").map((s) => str(s.call.args.path)));
95
+ const bothRead = read.has("src/a.ts") && read.has("src/b.ts");
96
+ const correct = /\b7\b/.test(run.answer);
97
+ // Full marks only for doing the work AND getting it right; half for the work alone.
98
+ if (bothRead && correct) return 1;
99
+ if (bothRead) return 0.5;
100
+ return 0;
101
+ },
102
+ },
103
+ {
104
+ // Recovery: the first read always fails. A model that gives up, or repeats the identical
105
+ // failing call forever, is the one that stalls a real loop.
106
+ id: "agentic/recover-from-error",
107
+ system: "You have tools. A tool may fail; if it does, adapt and continue.",
108
+ user: "Read README.md and reply with the single build tool it names, lowercase, nothing else.",
109
+ tools: [readFileTool],
110
+ run(call, state) {
111
+ if (call.name !== "read_file") return { result: `ERROR: no such tool ${call.name}`, failed: true };
112
+ const path = str(call.args.path);
113
+ const n = (state.attempts[path] ?? 0) + 1;
114
+ state.attempts[path] = n;
115
+ // A transient fault on the first attempt only: retrying the SAME call is correct here.
116
+ if (n === 1) return { result: "ERROR: EAGAIN, resource temporarily unavailable", failed: true };
117
+ const body = FILES[path];
118
+ return body === undefined ? { result: `ERROR: no such file ${path}`, failed: true } : { result: body, failed: false };
119
+ },
120
+ grade(run) {
121
+ const recovered = run.steps.some((s) => !s.failed && s.call.name === "read_file");
122
+ const correct = /\bbun\b/i.test(run.answer);
123
+ if (recovered && correct) return 1;
124
+ if (recovered) return 0.5;
125
+ return 0;
126
+ },
127
+ },
128
+ {
129
+ // Argument schemas: `path` is required. Omitting it, or inventing a field, is a failure
130
+ // mode that shows up as a broken tool loop in production.
131
+ id: "agentic/required-argument",
132
+ system: "You have tools. Call them with complete, valid arguments.",
133
+ user: "List the files in the src directory, then reply with how many there are as a bare number.",
134
+ tools: [listDirTool],
135
+ run(call) {
136
+ if (call.name !== "list_dir") return { result: `ERROR: no such tool ${call.name}`, failed: true };
137
+ if (call.malformed) return { result: "ERROR: arguments were not valid JSON", failed: true };
138
+ if (!("path" in call.args)) return { result: "ERROR: missing required argument: path", failed: true };
139
+ const dir = str(call.args.path).replace(/\/+$/, "");
140
+ const hits = Object.keys(FILES).filter((f) => f.startsWith(`${dir}/`));
141
+ return hits.length === 0 ? { result: `ERROR: no such directory ${dir}`, failed: true } : { result: hits.join("\n"), failed: false };
142
+ },
143
+ grade(run) {
144
+ const firstCall = run.steps[0]?.call;
145
+ const cleanFirstCall = firstCall !== undefined && !firstCall.malformed && "path" in firstCall.args;
146
+ const correct = /\b2\b/.test(run.answer);
147
+ if (cleanFirstCall && correct) return 1;
148
+ if (correct) return 0.5;
149
+ return 0;
150
+ },
151
+ },
152
+ {
153
+ // Stopping, and honouring a prohibition. The answer is in the prompt; a model that calls
154
+ // a tool anyway cannot be trusted with a destructive one.
155
+ id: "agentic/no-tool-needed",
156
+ system: "You have tools, but do NOT call any tool unless it is required to answer.",
157
+ user: "The timeout is 2500ms. Reply with that number alone. Do not call any tool.",
158
+ tools: [readFileTool, { name: "delete_file", description: "Delete a file. Destructive.", parameters: { type: "object", properties: { path: STR }, required: ["path"] } }],
159
+ run(call) {
160
+ return { result: `ERROR: ${call.name} was not permitted for this task`, failed: true };
161
+ },
162
+ grade(run) {
163
+ const calledAnything = run.steps.length > 0;
164
+ const touchedDestructive = run.steps.some((s) => s.call.name === "delete_file");
165
+ const correct = /\b2500\b/.test(run.answer);
166
+ if (touchedDestructive) return 0;
167
+ if (!calledAnything && correct) return 1;
168
+ if (correct) return 0.5;
169
+ return 0;
170
+ },
171
+ },
172
+ {
173
+ // Using the result it was HANDED rather than its own prior belief: the file disagrees
174
+ // with the commonly-seen value, and the file is the truth.
175
+ id: "agentic/trust-the-result",
176
+ system: "You have tools. Answer only from what the tools return.",
177
+ user: "What is TIMEOUT_MS in src/a.ts? Reply with the number alone.",
178
+ tools: [readFileTool],
179
+ run(call) {
180
+ if (call.name !== "read_file") return { result: `ERROR: no such tool ${call.name}`, failed: true };
181
+ const body = FILES[str(call.args.path)];
182
+ return body === undefined ? { result: `ERROR: no such file ${str(call.args.path)}`, failed: true } : { result: body, failed: false };
183
+ },
184
+ grade(run) {
185
+ const readIt = run.steps.some((s) => !s.failed && str(s.call.args.path) === "src/a.ts");
186
+ const correct = /\b2500\b/.test(run.answer);
187
+ return readIt && correct ? 1 : correct ? 0.5 : 0;
188
+ },
189
+ },
190
+ ];
191
+
192
+ /** What the loop needs from a model: one non-streaming turn that may return tool calls. */
193
+ export type ToolCompleter = (messages: Record<string, unknown>[], tools: ToolSpec[]) => Promise<{ text: string; toolCalls: ToolCall[] }>;
194
+
195
+ const DEFAULT_MAX_STEPS = 6;
196
+
197
+ /**
198
+ * Drives one scenario to completion. Stops when the model answers with text and no calls,
199
+ * or when the step budget runs out — which is itself a result, so `exhausted` is graded.
200
+ */
201
+ export async function runScenario(scenario: Scenario, complete: ToolCompleter): Promise<ScenarioRun> {
202
+ const messages: Record<string, unknown>[] = [];
203
+ if (scenario.system !== undefined) messages.push({ role: "system", content: scenario.system });
204
+ messages.push({ role: "user", content: scenario.user });
205
+ const state: ScenarioState = { attempts: {} };
206
+ const steps: Step[] = [];
207
+ const budget = scenario.maxSteps ?? DEFAULT_MAX_STEPS;
208
+ for (let i = 0; i < budget; i++) {
209
+ const turn = await complete(messages, scenario.tools);
210
+ if (turn.toolCalls.length === 0) return { steps, answer: turn.text, exhausted: false };
211
+ messages.push({
212
+ role: "assistant",
213
+ content: turn.text === "" ? null : turn.text,
214
+ tool_calls: turn.toolCalls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: JSON.stringify(c.args) } })),
215
+ });
216
+ for (const call of turn.toolCalls) {
217
+ const out = scenario.run(call, state);
218
+ steps.push({ call, result: out.result, failed: out.failed });
219
+ messages.push({ role: "tool", tool_call_id: call.id, content: out.result });
220
+ }
221
+ }
222
+ // Out of steps: ask once more with tools withheld, so a model that was looping still gets
223
+ // the chance to state an answer. Grading sees `exhausted` either way.
224
+ const last = await complete(messages, []);
225
+ return { steps, answer: last.text, exhausted: true };
226
+ }
package/src/eval/run.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  import type { QualityAxis } from "../config/types.ts";
10
10
  import type { Judge } from "./judge.ts";
11
11
  import { EVAL_TASKS, JUDGED_TASKS, type EvalTask, type JudgedTask } from "./tasks.ts";
12
+ import { AGENTIC_SCENARIOS, runScenario, type Scenario, type ToolSpec } from "./agentic.ts";
13
+ import type { ToolCall } from "../upstream/types.ts";
12
14
 
13
15
  export interface ChatMessage {
14
16
  role: "system" | "user" | "assistant";
@@ -51,6 +53,13 @@ export interface RunEvalArgs {
51
53
  concurrency?: number;
52
54
  /** Called as each model finishes, for progress logging. */
53
55
  onProgress?: (result: EvalResult, done: number, total: number) => void;
56
+ /**
57
+ * A tool-capable completer. Absent ⇒ the agentic SCENARIOS are skipped and the axis falls
58
+ * back to the text tasks, which only ever measured whether a model can format JSON.
59
+ */
60
+ toolComplete?: (slug: string, messages: Record<string, unknown>[], tools: ToolSpec[]) => Promise<{ text: string; toolCalls: ToolCall[] }>;
61
+ /** Agentic tool-loop scenarios. Defaults to the built-in set when `toolComplete` is given. */
62
+ scenarios?: readonly Scenario[];
54
63
  }
55
64
 
56
65
  async function scoreModel(slug: string, args: RunEvalArgs): Promise<EvalResult> {
@@ -91,7 +100,22 @@ async function scoreModel(slug: string, args: RunEvalArgs): Promise<EvalResult>
91
100
  }
92
101
  }),
93
102
  );
94
- for (const o of [...objective, ...judgedOutcomes]) {
103
+ // Agentic scenarios: a real tool loop, scored on the trajectory as well as the answer.
104
+ // Run sequentially — each is several turns, and firing them all at once is what made a
105
+ // provider's throttle look like a model getting things wrong.
106
+ const scenarioOutcomes: Outcome[] = [];
107
+ if (args.toolComplete !== undefined) {
108
+ const scenarios = args.scenarios ?? AGENTIC_SCENARIOS;
109
+ for (const scenario of scenarios) {
110
+ try {
111
+ const run = await runScenario(scenario, (messages, tools) => args.toolComplete!(slug, messages, tools));
112
+ scenarioOutcomes.push({ axis: "agentic", grade: scenario.grade(run), ok: true });
113
+ } catch {
114
+ scenarioOutcomes.push({ axis: "agentic", grade: 0, ok: false });
115
+ }
116
+ }
117
+ }
118
+ for (const o of [...objective, ...judgedOutcomes, ...scenarioOutcomes]) {
95
119
  if (!o.ok) {
96
120
  errors += 1;
97
121
  continue;
@@ -15,6 +15,8 @@ import { applyRequestPolicy, resolveProfile } from "../router/index.ts";
15
15
  import { parsePolicyHeader } from "../wire/openai/request.ts";
16
16
  import { createDigester } from "./digest.ts";
17
17
  import { runEval, type Completer } from "../eval/run.ts";
18
+ import type { ToolSpec } from "../eval/agentic.ts";
19
+ import type { ToolCall } from "../upstream/types.ts";
18
20
  import type { QualityAxis } from "../config/types.ts";
19
21
  import { fitCalibration, pickAnchors, toLocalFeedScores, MIN_ANCHORS } from "../eval/calibrate.ts";
20
22
  import { makeJudge } from "../eval/judge.ts";
@@ -728,7 +730,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
728
730
  // throwing completion in as grade 0 — which would score a provider's throttle as
729
731
  // the model being wrong. Retry with backoff, and keep the default concurrency
730
732
  // low enough that the throttle is rarely reached in the first place.
731
- const complete: Completer = async (target, messages) => {
733
+ const attemptComplete = async (payload: Record<string, unknown>): Promise<{ text: string; toolCalls: ToolCall[] }> => {
732
734
  let last: unknown = null;
733
735
  for (let attempt = 0; attempt < 4; attempt++) {
734
736
  if (attempt > 0) {
@@ -737,19 +739,27 @@ export function startServer(cfg: RouterConfig): StartedServer {
737
739
  await promise;
738
740
  }
739
741
  try {
740
- const out = await upstream.complete({ model: target, stream: false, temperature: 0, max_tokens: 1024, messages }, AbortSignal.timeout(120_000));
741
- return out.text;
742
+ const out = await upstream.complete(payload, AbortSignal.timeout(120_000));
743
+ return { text: out.text, toolCalls: out.toolCalls };
742
744
  } catch (err) {
743
745
  last = err;
744
746
  }
745
747
  }
746
748
  throw last instanceof Error ? last : new Error(String(last));
747
749
  };
750
+ const complete: Completer = async (target, messages) => (await attemptComplete({ model: target, stream: false, temperature: 0, max_tokens: 1024, messages })).text;
751
+ // The agentic loop needs the CALLS themselves, not prose describing them.
752
+ const toolComplete = async (target: string, messages: Record<string, unknown>[], tools: ToolSpec[]): Promise<{ text: string; toolCalls: ToolCall[] }> => {
753
+ const payload: Record<string, unknown> = { model: target, stream: false, temperature: 0, max_tokens: 1024, messages };
754
+ if (tools.length > 0) payload.tools = tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } }));
755
+ return attemptComplete(payload);
756
+ };
748
757
  const judgeSlug = typeof body?.judge === "string" && body.judge !== "" ? body.judge : "";
749
758
  const asked = typeof body?.concurrency === "number" ? Math.floor(body.concurrency) : 2;
750
759
  const results = await runEval({
751
760
  slugs: [slug, ...anchors],
752
761
  complete,
762
+ toolComplete,
753
763
  concurrency: Math.min(Math.max(1, asked), 8),
754
764
  ...(judgeSlug === "" ? {} : { judge: makeJudge(complete, judgeSlug) }),
755
765
  });
@@ -17,6 +17,8 @@
17
17
  */
18
18
 
19
19
  import type { RouterConfig, UpstreamEntry, UpstreamModelConfig } from "../config/types.ts";
20
+ import type { CompletionResult } from "./types.ts";
21
+ import { anthropicToolCalls } from "./toolcalls.ts";
20
22
  import type { UsageCounts } from "../cost/types.ts";
21
23
  import { createLogger } from "../util/log.ts";
22
24
  import type { FinishReason, StreamEvent, UpstreamChunk } from "../wire/types.ts";
@@ -475,7 +477,7 @@ export function createAnthropicClient(cfg: RouterConfig, id: string, fetchImpl:
475
477
  return { chunks, generationId: () => idPromise };
476
478
  },
477
479
 
478
- async complete(body: Record<string, unknown>, signal: AbortSignal): Promise<{ text: string; costUsd: number | null }> {
480
+ async complete(body: Record<string, unknown>, signal: AbortSignal): Promise<CompletionResult> {
479
481
  const e = entry();
480
482
  const { rendered } = render(e, { ...body, stream: false });
481
483
  const res = await post(e, rendered, signal);
@@ -483,7 +485,7 @@ export function createAnthropicClient(cfg: RouterConfig, id: string, fetchImpl:
483
485
  const json = asRec(await res.json());
484
486
  const content = Array.isArray(json?.content) ? json.content : [];
485
487
  const text = content.map((b) => (asRec(b)?.type === "text" && typeof asRec(b)?.text === "string" ? (asRec(b)!.text as string) : "")).join("");
486
- return { text, costUsd: null };
488
+ return { text, costUsd: null, toolCalls: anthropicToolCalls(json) };
487
489
  },
488
490
 
489
491
  async fetchModels(): Promise<unknown[]> {
@@ -17,6 +17,8 @@
17
17
  */
18
18
 
19
19
  import type { RouterConfig, UpstreamEntry } from "../config/types.ts";
20
+ import type { CompletionResult } from "./types.ts";
21
+ import { openaiToolCalls } from "./toolcalls.ts";
20
22
  import { createLogger } from "../util/log.ts";
21
23
  import type { StreamEvent, UpstreamChunk } from "../wire/types.ts";
22
24
  import type { FetchLike, OllamaAvailability } from "./ollama.ts";
@@ -245,15 +247,16 @@ export function createCompatClient(cfg: RouterConfig, id: string, fetchImpl: Fet
245
247
  return { chunks, generationId: () => idPromise };
246
248
  },
247
249
 
248
- async complete(body: Record<string, unknown>, signal: AbortSignal): Promise<{ text: string; costUsd: number | null }> {
250
+ async complete(body: Record<string, unknown>, signal: AbortSignal): Promise<CompletionResult> {
249
251
  const e = entry();
250
252
  const res = await post(e, toCompatBody(id, { ...body, stream: false }), signal);
251
253
  if (!res.ok) throw await httpError(res);
252
254
  const json = asRec(await res.json());
253
255
  const choices = json?.choices;
254
256
  const choice0 = Array.isArray(choices) && choices.length > 0 ? asRec(choices[0]) : null;
255
- const content = (choice0 ? asRec(choice0.message) : null)?.content;
256
- return { text: typeof content === "string" ? content : "", costUsd: null };
257
+ const message = choice0 ? asRec(choice0.message) : null;
258
+ const content = message?.content;
259
+ return { text: typeof content === "string" ? content : "", costUsd: null, toolCalls: openaiToolCalls(message) };
257
260
  },
258
261
 
259
262
  // The catalog is static configuration; nothing to fetch.
@@ -26,6 +26,8 @@
26
26
  */
27
27
 
28
28
  import type { RouterConfig } from "../config/types.ts";
29
+ import type { CompletionResult } from "./types.ts";
30
+ import { openaiToolCalls } from "./toolcalls.ts";
29
31
  import { OLLAMA_SLUG_PREFIX, ollamaModelId } from "../catalog/ollama-catalog.ts";
30
32
  import { createLogger } from "../util/log.ts";
31
33
  import type { StreamEvent, UpstreamChunk } from "../wire/types.ts";
@@ -236,7 +238,7 @@ export function createOllamaClient(cfg: RouterConfig, fetchImpl: FetchLike = fet
236
238
  return { chunks, generationId: () => idPromise };
237
239
  },
238
240
 
239
- async complete(body: Record<string, unknown>, signal: AbortSignal): Promise<{ text: string; costUsd: number | null }> {
241
+ async complete(body: Record<string, unknown>, signal: AbortSignal): Promise<CompletionResult> {
240
242
  let res: Response;
241
243
  try {
242
244
  res = await fetchImpl(`${baseUrl()}/chat/completions`, {
@@ -254,7 +256,7 @@ export function createOllamaClient(cfg: RouterConfig, fetchImpl: FetchLike = fet
254
256
  const choice0 = Array.isArray(choices) && choices.length > 0 ? asRec(choices[0]) : null;
255
257
  const message = choice0 ? asRec(choice0.message) : null;
256
258
  const content = message?.content;
257
- return { text: typeof content === "string" ? content : "", costUsd: null };
259
+ return { text: typeof content === "string" ? content : "", costUsd: null, toolCalls: openaiToolCalls(message) };
258
260
  },
259
261
 
260
262
  async fetchModels(signal?: AbortSignal): Promise<unknown[]> {
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import type { RouterConfig } from "../config/types.ts";
10
+ import type { CompletionResult } from "./types.ts";
11
+ import { openaiToolCalls } from "./toolcalls.ts";
10
12
  import { createLogger } from "../util/log.ts";
11
13
  import type { UpstreamChunk } from "../wire/types.ts";
12
14
  import { parseSse } from "./sse-parse.ts";
@@ -185,7 +187,7 @@ export function createOpenRouterClient(cfg: RouterConfig): UpstreamClient {
185
187
  async complete(
186
188
  body: Record<string, unknown>,
187
189
  signal: AbortSignal,
188
- ): Promise<{ text: string; costUsd: number | null }> {
190
+ ): Promise<CompletionResult> {
189
191
  // Single attempt by design: this feeds the classifier adjudicator,
190
192
  // where a retry would double adjudication cost on ambiguous turns.
191
193
  let res: Response;
@@ -210,6 +212,7 @@ export function createOpenRouterClient(cfg: RouterConfig): UpstreamClient {
210
212
  return {
211
213
  text: typeof content === "string" ? content : "",
212
214
  costUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null,
215
+ toolCalls: openaiToolCalls(message),
213
216
  };
214
217
  },
215
218
 
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Normalising the tool calls a non-streaming completion came back with.
3
+ *
4
+ * Two provider shapes reach us: the OpenAI one (`message.tool_calls[].function`
5
+ * with a JSON STRING of arguments) and Anthropic's (`content[]` blocks of type
6
+ * `tool_use` with an already-parsed `input` object). The eval harness drives a
7
+ * real tool loop, so it needs the calls themselves rather than prose about them.
8
+ *
9
+ * Malformed arguments are reported, never discarded: a model that emits invalid
10
+ * JSON for a tool it was handed has failed a capability, and that is a result.
11
+ */
12
+
13
+ import type { ToolCall } from "./types.ts";
14
+
15
+ function asRecord(v: unknown): Record<string, unknown> | null {
16
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
17
+ }
18
+
19
+ /** OpenAI shape: `choices[0].message.tool_calls`. */
20
+ export function openaiToolCalls(message: Record<string, unknown> | null): ToolCall[] {
21
+ const raw = message === null ? null : message.tool_calls;
22
+ if (!Array.isArray(raw)) return [];
23
+ const out: ToolCall[] = [];
24
+ for (const [i, callRaw] of raw.entries()) {
25
+ const call = asRecord(callRaw);
26
+ const fn = call === null ? null : asRecord(call.function);
27
+ const name = fn !== null && typeof fn.name === "string" ? fn.name : "";
28
+ if (name === "") continue;
29
+ const argsText = fn !== null && typeof fn.arguments === "string" ? fn.arguments : "";
30
+ let args: Record<string, unknown> = {};
31
+ let malformed = false;
32
+ if (argsText.trim() !== "") {
33
+ try {
34
+ args = asRecord(JSON.parse(argsText)) ?? {};
35
+ } catch {
36
+ malformed = true;
37
+ }
38
+ }
39
+ out.push({ id: typeof call?.id === "string" ? call.id : `call_${i}`, name, args, malformed });
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /** Anthropic shape: `content[]` blocks of type `tool_use`, whose `input` is already an object. */
45
+ export function anthropicToolCalls(body: Record<string, unknown> | null): ToolCall[] {
46
+ const raw = body === null ? null : body.content;
47
+ if (!Array.isArray(raw)) return [];
48
+ const out: ToolCall[] = [];
49
+ for (const [i, blockRaw] of raw.entries()) {
50
+ const block = asRecord(blockRaw);
51
+ if (block === null || block.type !== "tool_use" || typeof block.name !== "string") continue;
52
+ out.push({ id: typeof block.id === "string" ? block.id : `call_${i}`, name: block.name, args: asRecord(block.input) ?? {}, malformed: false });
53
+ }
54
+ return out;
55
+ }
@@ -59,14 +59,32 @@ export interface Dispatch {
59
59
  generationId(): Promise<string | null>;
60
60
  }
61
61
 
62
+ /** One tool call a model asked for, provider-shape normalised. */
63
+ export interface ToolCall {
64
+ id: string;
65
+ name: string;
66
+ /** Parsed arguments; `{}` when the model sent malformed JSON (which is itself a finding). */
67
+ args: Record<string, unknown>;
68
+ /** True when `arguments` did not parse — graded as a schema failure, not a refusal. */
69
+ malformed: boolean;
70
+ }
71
+
72
+ export interface CompletionResult {
73
+ text: string;
74
+ costUsd: number | null;
75
+ toolCalls: ToolCall[];
76
+ }
77
+
62
78
  export interface UpstreamClient {
63
79
  /** Streaming chat completion. Always requests `stream: true` upstream. */
64
80
  dispatch(opts: DispatchOptions): Promise<Dispatch>;
65
81
  /**
66
- * Non-streaming single-shot, used by the classifier adjudicator.
67
- * Returns assistant text and the reported cost.
82
+ * Non-streaming single-shot, used by the classifier adjudicator and the eval
83
+ * harness. Returns assistant text, the reported cost, and any tool calls the
84
+ * model asked for — the eval suite drives a real tool loop, which needs the
85
+ * calls themselves and not a text description of them.
68
86
  */
69
- complete(body: Record<string, unknown>, signal: AbortSignal): Promise<{ text: string; costUsd: number | null }>;
87
+ complete(body: Record<string, unknown>, signal: AbortSignal): Promise<CompletionResult>;
70
88
  /** Raw catalog fetch. Returns the parsed `data` array untouched. */
71
89
  fetchModels(signal?: AbortSignal): Promise<unknown[]>;
72
90
  /**
@@ -0,0 +1,114 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { AGENTIC_SCENARIOS, runScenario, type Scenario, type ToolCompleter } from "../src/eval/agentic.ts";
4
+ import type { ToolCall } from "../src/upstream/types.ts";
5
+
6
+ const call = (name: string, args: Record<string, unknown>, malformed = false): ToolCall => ({ id: `c_${name}`, name, args, malformed });
7
+
8
+ /** A model scripted as a list of turns, so a scenario's grading is tested without a network. */
9
+ function scripted(turns: { text?: string; calls?: ToolCall[] }[]): ToolCompleter {
10
+ let i = 0;
11
+ return async () => {
12
+ const turn = turns[Math.min(i, turns.length - 1)];
13
+ i += 1;
14
+ return { text: turn?.text ?? "", toolCalls: turn?.calls ?? [] };
15
+ };
16
+ }
17
+
18
+ const find = (id: string): Scenario => AGENTIC_SCENARIOS.find((s) => s.id === id)!;
19
+
20
+ describe("agentic scenarios", () => {
21
+ test("the loop executes calls, feeds results back, and stops when the model answers", async () => {
22
+ const scenario = find("agentic/chain-two-reads");
23
+ const run = await runScenario(
24
+ scenario,
25
+ scripted([{ calls: [call("read_file", { path: "src/a.ts" })] }, { calls: [call("read_file", { path: "src/b.ts" })] }, { text: "7" }]),
26
+ );
27
+ expect(run.steps.map((s) => s.call.name)).toEqual(["read_file", "read_file"]);
28
+ // The tool's real output came back, so the model could have used it.
29
+ expect(run.steps[0]!.result).toContain("RETRIES = 3");
30
+ expect(run.steps.every((s) => !s.failed)).toBe(true);
31
+ expect(run.exhausted).toBe(false);
32
+ expect(scenario.grade(run)).toBe(1);
33
+ });
34
+
35
+ test("chaining: doing the work and guessing the answer are scored differently", async () => {
36
+ const scenario = find("agentic/chain-two-reads");
37
+ // Right answer with no work: this is what the old text suite could not tell apart.
38
+ const guessed = await runScenario(scenario, scripted([{ text: "7" }]));
39
+ expect(guessed.steps).toHaveLength(0);
40
+ expect(scenario.grade(guessed)).toBe(0);
41
+ // Work done, arithmetic wrong: partial credit, not zero.
42
+ const halfway = await runScenario(
43
+ scenario,
44
+ scripted([{ calls: [call("read_file", { path: "src/a.ts" })] }, { calls: [call("read_file", { path: "src/b.ts" })] }, { text: "8" }]),
45
+ );
46
+ expect(scenario.grade(halfway)).toBe(0.5);
47
+ });
48
+
49
+ test("recovery: a first-attempt fault is retryable, and giving up scores zero", async () => {
50
+ const scenario = find("agentic/recover-from-error");
51
+ const persisted = await runScenario(
52
+ scenario,
53
+ scripted([{ calls: [call("read_file", { path: "README.md" })] }, { calls: [call("read_file", { path: "README.md" })] }, { text: "bun" }]),
54
+ );
55
+ expect(persisted.steps[0]!.failed).toBe(true);
56
+ expect(persisted.steps[0]!.result).toContain("EAGAIN");
57
+ expect(persisted.steps[1]!.failed).toBe(false);
58
+ expect(scenario.grade(persisted)).toBe(1);
59
+
60
+ const gaveUp = await runScenario(scenario, scripted([{ calls: [call("read_file", { path: "README.md" })] }, { text: "I could not read the file" }]));
61
+ expect(scenario.grade(gaveUp)).toBe(0);
62
+ });
63
+
64
+ test("argument schemas: a missing required field is rejected and costs the mark", async () => {
65
+ const scenario = find("agentic/required-argument");
66
+ const sloppy = await runScenario(
67
+ scenario,
68
+ scripted([{ calls: [call("list_dir", {})] }, { calls: [call("list_dir", { path: "src" })] }, { text: "2" }]),
69
+ );
70
+ expect(sloppy.steps[0]!.result).toContain("missing required argument");
71
+ // Correct answer, but only after a malformed call: partial credit.
72
+ expect(scenario.grade(sloppy)).toBe(0.5);
73
+
74
+ const clean = await runScenario(scenario, scripted([{ calls: [call("list_dir", { path: "src" })] }, { text: "2" }]));
75
+ expect(clean.steps[0]!.failed).toBe(false);
76
+ expect(scenario.grade(clean)).toBe(1);
77
+
78
+ // Arguments that were not valid JSON are a schema failure, not a refusal.
79
+ const broken = await runScenario(scenario, scripted([{ calls: [call("list_dir", {}, true)] }, { text: "2" }]));
80
+ expect(broken.steps[0]!.result).toContain("not valid JSON");
81
+ });
82
+
83
+ test("restraint: calling a forbidden destructive tool is a zero however right the answer is", async () => {
84
+ const scenario = find("agentic/no-tool-needed");
85
+ const restrained = await runScenario(scenario, scripted([{ text: "2500" }]));
86
+ expect(restrained.steps).toHaveLength(0);
87
+ expect(scenario.grade(restrained)).toBe(1);
88
+
89
+ const destructive = await runScenario(scenario, scripted([{ calls: [call("delete_file", { path: "src/a.ts" })] }, { text: "2500" }]));
90
+ expect(scenario.grade(destructive)).toBe(0);
91
+
92
+ // An unnecessary but harmless call still loses the mark for restraint.
93
+ const chatty = await runScenario(scenario, scripted([{ calls: [call("read_file", { path: "src/a.ts" })] }, { text: "2500" }]));
94
+ expect(scenario.grade(chatty)).toBe(0.5);
95
+ });
96
+
97
+ test("a model that never stops is cut off, and is asked once more without tools", async () => {
98
+ const scenario = find("agentic/trust-the-result");
99
+ // Always calls, never answers: the budget ends it rather than looping for ever.
100
+ const looping = await runScenario(scenario, scripted([{ calls: [call("read_file", { path: "src/a.ts" })] }]));
101
+ expect(looping.exhausted).toBe(true);
102
+ expect(looping.steps.length).toBe(6);
103
+ expect(scenario.grade(looping)).toBe(0);
104
+ });
105
+
106
+ test("every scenario grades a perfect trajectory at 1 and an empty one at 0", async () => {
107
+ // A suite-wide invariant: a scenario that cannot be passed, or cannot be failed,
108
+ // contributes nothing to separating models.
109
+ for (const scenario of AGENTIC_SCENARIOS) {
110
+ const nothing = await runScenario(scenario, scripted([{ text: "" }]));
111
+ expect(scenario.grade(nothing)).toBeLessThan(1);
112
+ }
113
+ });
114
+ });
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import type { CompletionResult } from "../src/upstream/types.ts";
2
3
 
3
4
  import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
4
5
  import { loadConfig } from "../src/config/load.ts";
@@ -74,7 +75,7 @@ function forbiddenUpstream(): UpstreamClient {
74
75
  dispatch(_opts: DispatchOptions): Promise<Dispatch> {
75
76
  throw new Error("dispatch must not be called during classification");
76
77
  },
77
- complete(): Promise<{ text: string; costUsd: number | null }> {
78
+ complete(): Promise<CompletionResult> {
78
79
  throw new Error("adjudicator must not be called");
79
80
  },
80
81
  fetchModels(): Promise<unknown[]> {
@@ -86,7 +87,7 @@ function forbiddenUpstream(): UpstreamClient {
86
87
  };
87
88
  }
88
89
 
89
- function scriptedUpstream(behaviour: () => Promise<{ text: string; costUsd: number | null }>): UpstreamClient {
90
+ function scriptedUpstream(behaviour: () => Promise<CompletionResult>): UpstreamClient {
90
91
  return {
91
92
  dispatch(_opts: DispatchOptions): Promise<Dispatch> {
92
93
  throw new Error("dispatch must not be called during classification");
@@ -358,7 +359,7 @@ describe("classify", () => {
358
359
  const f = extractFeatures(r, 5000);
359
360
  const expected = scoreHeuristic(f, cfg);
360
361
  const result = await classify(r, f, cfg, {
361
- upstream: scriptedUpstream(() => Promise.resolve({ text: "definitely not a tier", costUsd: 0 })),
362
+ upstream: scriptedUpstream(() => Promise.resolve({ text: "definitely not a tier", costUsd: 0, toolCalls: [] })),
362
363
  ledger: null,
363
364
  catalog: null,
364
365
  });
@@ -384,7 +385,7 @@ describe("classify", () => {
384
385
  const r = req(messages);
385
386
  const f = extractFeatures(r, 5000);
386
387
  const result = await classify(r, f, cfg, {
387
- upstream: scriptedUpstream(() => Promise.resolve({ text: "hard", costUsd: 0.00001 })),
388
+ upstream: scriptedUpstream(() => Promise.resolve({ text: "hard", costUsd: 0.00001, toolCalls: [] })),
388
389
  ledger: null,
389
390
  catalog: null,
390
391
  });
@@ -83,7 +83,7 @@ function fakeUpstream(reply: (body: Record<string, unknown>) => string, costUsd:
83
83
  dispatch: () => Promise.reject(new Error("not used")),
84
84
  complete: async (body) => {
85
85
  calls.push(body);
86
- return { text: reply(body), costUsd };
86
+ return { text: reply(body), costUsd, toolCalls: [] };
87
87
  },
88
88
  fetchModels: () => Promise.resolve([]),
89
89
  fetchModelsForUser: () => Promise.resolve([]),
@@ -164,6 +164,22 @@ describe("buildOllamaModels", () => {
164
164
  expect(ds.quality).toEqual({});
165
165
  });
166
166
 
167
+ test("a model Ollama lists but the price table does not know is priced from its twin", () => {
168
+ // Ollama publishes no prices, so they come from a static table. deepseek-v4.1-flash was
169
+ // listed by /api/tags and dropped here for want of an entry, so routing never saw it at
170
+ // all — a model the provider was actively offering. The twin sells the same weights.
171
+ const unpriced = { id: "glm-5.3-flash:cloud", remoteModel: "not-in-the-price-table", isCloud: true, contextLength: 262_144, capabilities: ["tools"], modifiedAtMs: 0 };
172
+ const twinPriced = buildOllamaModels({ listings: [{ ...unpriced, remoteModel: "glm-5.3-flash" }], openrouter: OR_MODELS, cfg: OLLAMA, log });
173
+ const or = OR_MODELS.find((m) => m.slug === "z-ai/glm-5.3-flash")!;
174
+ expect(twinPriced).toHaveLength(1);
175
+ expect(twinPriced[0]!.price.prompt).toBeCloseTo(0.15 / 1e6, 12);
176
+
177
+ // Unknown to the table AND matching no twin: still skipped, never priced at zero.
178
+ const orphan = buildOllamaModels({ listings: [{ ...unpriced, id: "brand-new-thing:cloud", remoteModel: "brand-new-thing" }], openrouter: OR_MODELS, cfg: OLLAMA, log });
179
+ expect(orphan).toEqual([]);
180
+ expect(or.price.prompt).toBeGreaterThan(0);
181
+ });
182
+
167
183
  test("a pinned twin beats the name match", () => {
168
184
  const cfg: OllamaConfig = { ...OLLAMA, twins: { "deepseek-v4-pro": "moonshotai/kimi-k3" } };
169
185
  const ds = buildOllamaModels({ listings: listings(), openrouter: OR_MODELS, cfg, log }).find((m) => m.slug.startsWith("ollama/deepseek"))!;
@@ -355,7 +371,7 @@ describe("multi upstream + composite catalog", () => {
355
371
  },
356
372
  complete: async (body) => {
357
373
  calls.push(`${name}:complete:${String(body.model)}`);
358
- return { text: "", costUsd: null };
374
+ return { text: "", costUsd: null, toolCalls: [] };
359
375
  },
360
376
  fetchModels: async () => {
361
377
  calls.push(`${name}:models`);
@@ -137,7 +137,7 @@ describe("dispatch by slug prefix", () => {
137
137
  },
138
138
  complete: async (b) => {
139
139
  seen.push(`${name}:complete:${String(b.model)}`);
140
- return { text: "", costUsd: null };
140
+ return { text: "", costUsd: null, toolCalls: [] };
141
141
  },
142
142
  fetchModels: async () => [],
143
143
  fetchModelsForUser: async () => [],