auto-model-router 0.28.0 → 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.
- package/.omp-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/src/cost/report.ts +4 -1
- package/src/eval/agentic.ts +226 -0
- package/src/eval/run.ts +25 -1
- package/src/server/http.ts +13 -3
- package/src/upstream/anthropic.ts +4 -2
- package/src/upstream/compat.ts +6 -3
- package/src/upstream/ollama.ts +4 -2
- package/src/upstream/openrouter.ts +4 -1
- package/src/upstream/toolcalls.ts +55 -0
- package/src/upstream/types.ts +21 -3
- package/test/agentic.test.ts +114 -0
- package/test/classify.test.ts +5 -4
- package/test/digest.test.ts +1 -1
- package/test/ollama.test.ts +1 -1
- package/test/upstreams.test.ts +1 -1
|
@@ -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.
|
|
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.
|
|
17
|
+
"version": "0.29.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/package.json
CHANGED
package/src/cost/report.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/src/server/http.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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<
|
|
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[]> {
|
package/src/upstream/compat.ts
CHANGED
|
@@ -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<
|
|
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
|
|
256
|
-
|
|
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.
|
package/src/upstream/ollama.ts
CHANGED
|
@@ -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<
|
|
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<
|
|
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
|
+
}
|
package/src/upstream/types.ts
CHANGED
|
@@ -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
|
|
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<
|
|
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
|
+
});
|
package/test/classify.test.ts
CHANGED
|
@@ -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<
|
|
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<
|
|
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
|
});
|
package/test/digest.test.ts
CHANGED
|
@@ -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([]),
|
package/test/ollama.test.ts
CHANGED
|
@@ -371,7 +371,7 @@ describe("multi upstream + composite catalog", () => {
|
|
|
371
371
|
},
|
|
372
372
|
complete: async (body) => {
|
|
373
373
|
calls.push(`${name}:complete:${String(body.model)}`);
|
|
374
|
-
return { text: "", costUsd: null };
|
|
374
|
+
return { text: "", costUsd: null, toolCalls: [] };
|
|
375
375
|
},
|
|
376
376
|
fetchModels: async () => {
|
|
377
377
|
calls.push(`${name}:models`);
|
package/test/upstreams.test.ts
CHANGED
|
@@ -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 () => [],
|