auto-model-router 0.18.0 → 0.20.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/README.md +117 -17
- package/package.json +1 -1
- package/src/cli/connect.ts +131 -2
- package/src/cli/harnesses.ts +277 -0
- package/src/cost/ledger.ts +9 -2
- package/src/cost/types.ts +7 -0
- package/src/cost/views.ts +27 -10
- package/src/index.ts +1 -0
- package/src/server/http.ts +3 -1
- package/src/server/turn.ts +4 -0
- package/src/util/sqlite.ts +12 -1
- package/test/connect-harnesses.test.ts +299 -0
- package/test/fixtures/connect/cline-providers.json +16 -0
- package/test/fixtures/connect/continue-config.yaml +14 -0
- package/test/fixtures/connect/opencode.json +15 -0
- package/test/fixtures/harness/cline-cli-connected.json +817 -0
- package/test/harness-requests.test.ts +13 -2
- package/test/migrations.test.ts +10 -2
- package/test/trust-attribution.test.ts +2 -2
- package/test/turn.test.ts +23 -0
- package/test/views.test.ts +39 -13
- package/tools/capture-proxy.ts +1 -1
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The harness configurations `connect` writes beyond the five it has always
|
|
3
|
+
* written (omp, Hermes, Codex, Aider, Claude Code), and the snippets it prints
|
|
4
|
+
* for the ones it deliberately does not write.
|
|
5
|
+
*
|
|
6
|
+
* The line between the two is confidence, not effort. A harness whose config
|
|
7
|
+
* FILE and keys are documented — and, where the harness could be run here, seen
|
|
8
|
+
* to read them — gets an automated path: a pure merge over the file's text, so
|
|
9
|
+
* `connect` edits its own keys and leaves every other one alone. A harness
|
|
10
|
+
* whose provider settings live in application state (an editor's `globalState`
|
|
11
|
+
* database, a vendor's web account page) has no file to edit honestly, so it
|
|
12
|
+
* gets a snippet the user pastes into its settings UI instead. An invented key
|
|
13
|
+
* writes a file that silently does nothing and then reports success, which is
|
|
14
|
+
* worse for the user than a printed instruction that works.
|
|
15
|
+
*
|
|
16
|
+
* Every merge returns `null` when nothing changes — so a second `connect` is a
|
|
17
|
+
* no-op — and also when the file cannot be parsed: a file we do not understand
|
|
18
|
+
* is not ours to rewrite, and the caller says so rather than guessing.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { isMap, isScalar, isSeq, parseDocument, type Document, type YAMLSeq } from "yaml";
|
|
22
|
+
|
|
23
|
+
import { SCOPE_ENV } from "../context/scope.ts";
|
|
24
|
+
|
|
25
|
+
/** The provider id every automated harness registers the router under. */
|
|
26
|
+
export const PROVIDER_ID = "auto-model-router";
|
|
27
|
+
|
|
28
|
+
/** The virtual models a router serves; `auto` is the one each harness is pointed at. */
|
|
29
|
+
export const PROFILE_IDS = ["auto", "auto-cheap", "auto-max"] as const;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The headers a harness's provider entry sends on every turn.
|
|
33
|
+
*
|
|
34
|
+
* `X-Omp-Harness` is what splits budgets, reports and toasts per harness, so it
|
|
35
|
+
* is always here. The agentdox scope is not: these files hold LITERAL header
|
|
36
|
+
* values — unlike omp's models.yml, none of these harnesses resolves a value
|
|
37
|
+
* that names an environment variable — so a machine-wide file could only pin one
|
|
38
|
+
* project onto every workspace. It is written only when `connect --scope` asked
|
|
39
|
+
* for exactly that; otherwise the scope is left to the remote's own default.
|
|
40
|
+
*/
|
|
41
|
+
export function harnessHeaders(harness: string, scope: string): Record<string, string> {
|
|
42
|
+
return { "X-Omp-Harness": harness, ...(scope !== "" && scope !== SCOPE_ENV ? { "X-Agentdox-Scope": scope } : {}) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A harness `connect` will not write a file for: what to set, and where. */
|
|
46
|
+
export interface ManualSnippet {
|
|
47
|
+
/** The harness's display name, as it appears in the report. */
|
|
48
|
+
harness: string;
|
|
49
|
+
/** Why there is no file to write — one line, shown beside the name. */
|
|
50
|
+
reason: string;
|
|
51
|
+
/** The instruction lines, already formatted for a terminal. */
|
|
52
|
+
lines: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// OpenCode — automated
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Merges the router into OpenCode's `opencode.json`.
|
|
61
|
+
*
|
|
62
|
+
* OpenCode reaches any OpenAI-compatible endpoint through a `provider` entry
|
|
63
|
+
* backed by the `@ai-sdk/openai-compatible` npm package, which it installs
|
|
64
|
+
* itself; `options` is handed to that package verbatim, which is where the base
|
|
65
|
+
* URL, the key and the per-request headers go. This is the shape the README
|
|
66
|
+
* documents, verified live against opencode 1.18 — the captured request,
|
|
67
|
+
* `test/fixtures/harness/opencode.json`, carries the `X-Omp-Harness` header
|
|
68
|
+
* that `options.headers` put there.
|
|
69
|
+
*
|
|
70
|
+
* `model` is pointed at our `auto` so the harness comes up on the router rather
|
|
71
|
+
* than leaving the user to find it in the picker; every other key in the file —
|
|
72
|
+
* `$schema`, the theme, MCP servers, other providers — is carried through.
|
|
73
|
+
*/
|
|
74
|
+
export function mergeOpenCodeConfig(before: string, url: string, key: string, scope = ""): string | null {
|
|
75
|
+
const root = parseJsonObject(before);
|
|
76
|
+
if (root === null) return null;
|
|
77
|
+
const providers = { ...((root.provider as Record<string, unknown> | undefined) ?? {}) };
|
|
78
|
+
providers[PROVIDER_ID] = {
|
|
79
|
+
npm: "@ai-sdk/openai-compatible",
|
|
80
|
+
name: "auto-model-router",
|
|
81
|
+
options: { baseURL: `${url}/v1`, apiKey: key, headers: harnessHeaders("opencode", scope) },
|
|
82
|
+
models: Object.fromEntries(PROFILE_IDS.map((id) => [id, { name: id }])),
|
|
83
|
+
};
|
|
84
|
+
const next = { ...root, provider: providers, model: `${PROVIDER_ID}/auto` };
|
|
85
|
+
const after = `${JSON.stringify(next, null, 2)}\n`;
|
|
86
|
+
return after === before ? null : after;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Cline — automated
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/** The provider id Cline files an OpenAI-compatible endpoint under; `cline auth -p openai` resolves to it. */
|
|
94
|
+
const CLINE_PROVIDER = "openai-compatible";
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Merges the router into Cline's `~/.cline/data/settings/providers.json`.
|
|
98
|
+
*
|
|
99
|
+
* The shape was captured by running `cline auth -p openai -b <base> -k <key> -m
|
|
100
|
+
* auto` — the command the README documents — against an isolated `--data-dir`
|
|
101
|
+
* on cline 3.0.61: a `version`, the `lastUsedProvider`, and one entry per
|
|
102
|
+
* provider holding `settings`, an `updatedAt` stamp and a `tokenSource`. One
|
|
103
|
+
* file serves the CLI and, since the extension's settings migration, the VS
|
|
104
|
+
* Code extension too, which is also how Cline reaches the router inside an
|
|
105
|
+
* editor that has no provider settings of its own (see windsurfSnippet).
|
|
106
|
+
*
|
|
107
|
+
* Writing the keys rather than shelling out to `cline auth` buys two things
|
|
108
|
+
* the command cannot give: `--dry-run` can show the change, and `settings.headers`
|
|
109
|
+
* gets written. That map is in the on-disk schema but has no CLI flag, and it is
|
|
110
|
+
* what finally gives Cline a harness id — verified live by pointing cline 3.0.61
|
|
111
|
+
* at a recording server, whose capture is `test/fixtures/harness/cline-cli-connected.json`.
|
|
112
|
+
*
|
|
113
|
+
* `updatedAt` is deliberately NOT refreshed when the settings already match: a
|
|
114
|
+
* stamp that moved on every run would make `connect` write a different file each
|
|
115
|
+
* time, which is the one thing every path here promises not to do.
|
|
116
|
+
*/
|
|
117
|
+
export function mergeClineProviders(before: string, url: string, key: string, nowIso: string, scope = ""): string | null {
|
|
118
|
+
const root = parseJsonObject(before);
|
|
119
|
+
if (root === null) return null;
|
|
120
|
+
const providers = { ...((root.providers as Record<string, unknown> | undefined) ?? {}) };
|
|
121
|
+
const entry = (providers[CLINE_PROVIDER] as Record<string, unknown> | undefined) ?? {};
|
|
122
|
+
const settings = { provider: CLINE_PROVIDER, apiKey: key, model: "auto", baseUrl: `${url}/v1`, headers: harnessHeaders("cline", scope) };
|
|
123
|
+
if (JSON.stringify(entry.settings) === JSON.stringify(settings) && root.lastUsedProvider === CLINE_PROVIDER) return null;
|
|
124
|
+
providers[CLINE_PROVIDER] = { ...entry, settings, updatedAt: nowIso, tokenSource: entry.tokenSource ?? "manual" };
|
|
125
|
+
// `version` leads the file cline writes; spreading root after it keeps that order on a file that has one.
|
|
126
|
+
const next = { version: 1, ...root, lastUsedProvider: CLINE_PROVIDER, modes: root.modes ?? {}, providers };
|
|
127
|
+
return `${JSON.stringify(next, null, 2)}\n`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Continue — automated
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
/** The `name:` our model entries carry in Continue's config, and how they are found again on a re-run. */
|
|
135
|
+
const CONTINUE_PREFIX = PROVIDER_ID;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Merges the router's three profiles into Continue's `~/.continue/config.yaml`.
|
|
139
|
+
*
|
|
140
|
+
* Continue's documented assistant file is a YAML map with `name`, `version` and
|
|
141
|
+
* `schema` at the top and a `models:` list under it; an OpenAI-compatible
|
|
142
|
+
* endpoint is a model entry with `provider: openai` and `apiBase`, `apiKey`,
|
|
143
|
+
* `roles` and `requestOptions.headers` — the last is what carries the harness
|
|
144
|
+
* id. Continue was not run here, so this is the documented shape rather than an
|
|
145
|
+
* observed one; it is automated because the file, its location and its keys are
|
|
146
|
+
* all published, which is the bar (nothing about it is inferred).
|
|
147
|
+
*
|
|
148
|
+
* The edit goes through the YAML *document*, not a re-serialisation of parsed
|
|
149
|
+
* data, so a hand-written config keeps its comments, key order and quoting and
|
|
150
|
+
* only our own entries move. Entries are matched by `name`, so a re-run with a
|
|
151
|
+
* new key replaces them in place instead of appending a second copy.
|
|
152
|
+
*/
|
|
153
|
+
export function mergeContinueConfig(before: string, url: string, key: string, scope = ""): string | null {
|
|
154
|
+
let doc: Document.Parsed;
|
|
155
|
+
try {
|
|
156
|
+
doc = parseDocument(before);
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
if (doc.errors.length > 0) return null;
|
|
161
|
+
// A new file is seeded in the schema's own order rather than assembled key by
|
|
162
|
+
// key, which would leave the three required fields trailing the model list.
|
|
163
|
+
if (before.trim() === "") doc = parseDocument(`name: ${PROVIDER_ID}\nversion: 0.0.1\nschema: v1\nmodels:\n`);
|
|
164
|
+
if (!isMap(doc.contents)) return null;
|
|
165
|
+
// Required by the schema, and only supplied when the file does not already say otherwise.
|
|
166
|
+
if (doc.get("name") === undefined) doc.set("name", PROVIDER_ID);
|
|
167
|
+
if (doc.get("version") === undefined) doc.set("version", "0.0.1");
|
|
168
|
+
if (doc.get("schema") === undefined) doc.set("schema", "v1");
|
|
169
|
+
const models = doc.get("models", true);
|
|
170
|
+
// `models:` with nothing under it parses to a null SCALAR node, not to null itself.
|
|
171
|
+
if (models === undefined || models === null || (isScalar(models) && models.value === null)) doc.set("models", doc.createNode([]));
|
|
172
|
+
else if (!isSeq(models)) return null;
|
|
173
|
+
const seq = doc.get("models", true) as YAMLSeq<unknown>;
|
|
174
|
+
seq.flow = false; // an empty seq is created in flow style, which would drag every entry onto one line
|
|
175
|
+
const wanted = PROFILE_IDS.map((id) =>
|
|
176
|
+
doc.createNode({
|
|
177
|
+
name: id === "auto" ? CONTINUE_PREFIX : `${CONTINUE_PREFIX}-${id.replace("auto-", "")}`,
|
|
178
|
+
provider: "openai",
|
|
179
|
+
model: id,
|
|
180
|
+
apiBase: `${url}/v1`,
|
|
181
|
+
apiKey: key,
|
|
182
|
+
roles: ["chat", "edit", "apply", "summarize"],
|
|
183
|
+
capabilities: ["tool_use", "image_input"],
|
|
184
|
+
requestOptions: { headers: harnessHeaders("continue", scope) },
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
const nameOf = (item: unknown): string => (isMap(item) ? String(item.get("name") ?? "") : "");
|
|
188
|
+
for (const node of wanted) {
|
|
189
|
+
const name = nameOf(node);
|
|
190
|
+
const at = seq.items.findIndex((item) => nameOf(item) === name);
|
|
191
|
+
if (at >= 0) seq.items[at] = node;
|
|
192
|
+
else seq.items.push(node);
|
|
193
|
+
}
|
|
194
|
+
const after = doc.toString();
|
|
195
|
+
return after === before ? null : after;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Manual paths
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The values every OpenAI-compatible settings form asks for, as lines.
|
|
204
|
+
*
|
|
205
|
+
* Shared by the manual harnesses because the substance is identical in each —
|
|
206
|
+
* a base URL, a key and a model id — and only the form differs. One renderer
|
|
207
|
+
* means the three recipes cannot drift apart from each other or from what the
|
|
208
|
+
* automated paths write.
|
|
209
|
+
*/
|
|
210
|
+
function providerFields(url: string, key: string): string[] {
|
|
211
|
+
return [`base URL: ${url}/v1`, `API key: ${key}`, `model: auto (also auto-cheap, auto-max)`];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Cursor.
|
|
216
|
+
*
|
|
217
|
+
* Cursor's OpenAI override is an application setting in the editor's own state
|
|
218
|
+
* database, not a documented file: what lives under `~/.cursor` is MCP servers
|
|
219
|
+
* and rules, and the official key documentation describes only the settings
|
|
220
|
+
* pane. So the values are printed instead of written.
|
|
221
|
+
*
|
|
222
|
+
* The second line is the one that saves an afternoon: Cursor routes chat
|
|
223
|
+
* through its own servers with the key attached, so the override only works
|
|
224
|
+
* against a router the internet can reach. A loopback or LAN router cannot
|
|
225
|
+
* serve Cursor however it is configured — which is worth saying plainly, since
|
|
226
|
+
* every other harness here is happy with `127.0.0.1`.
|
|
227
|
+
*/
|
|
228
|
+
export function cursorSnippet(url: string, key: string): ManualSnippet {
|
|
229
|
+
const reachable = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\]|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(url);
|
|
230
|
+
return {
|
|
231
|
+
harness: "Cursor",
|
|
232
|
+
reason: "its OpenAI override lives in the editor's settings, not a file",
|
|
233
|
+
lines: [
|
|
234
|
+
"Cursor Settings → Models → OpenAI API Key: enable the base-URL override, then",
|
|
235
|
+
...providerFields(url, key),
|
|
236
|
+
"Add `auto` as a custom model and select it. Cursor sends no custom header,",
|
|
237
|
+
"so its turns carry no X-Omp-Harness id and share the unnamed budget.",
|
|
238
|
+
...(reachable ? ["WARNING: Cursor proxies chat through its own servers, so this URL must be", "reachable from the internet — a loopback or LAN router will never answer it."] : []),
|
|
239
|
+
],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Windsurf.
|
|
245
|
+
*
|
|
246
|
+
* Windsurf's own files under `~/.codeium/<channel>` cover MCP servers, rules
|
|
247
|
+
* and skills; the model provider is not among them, and its bring-your-own-key
|
|
248
|
+
* page takes first-party provider keys rather than an arbitrary base URL —
|
|
249
|
+
* there is no field for one to write. So the useful answer is not a snippet of
|
|
250
|
+
* Windsurf settings at all but the extension route: Windsurf is a VS Code fork,
|
|
251
|
+
* and the Cline extension reads the same `providers.json` `connect` has already
|
|
252
|
+
* written, so installing it is the whole configuration.
|
|
253
|
+
*/
|
|
254
|
+
export function windsurfSnippet(clineConfigured: boolean): ManualSnippet {
|
|
255
|
+
return {
|
|
256
|
+
harness: "Windsurf",
|
|
257
|
+
reason: "it has no custom base-URL field; reach the router through an extension",
|
|
258
|
+
lines: [
|
|
259
|
+
"Windsurf's own provider settings take first-party keys, not a base URL.",
|
|
260
|
+
"Install the Cline extension in Windsurf: it reads the same providers.json",
|
|
261
|
+
clineConfigured ? "this connect just wrote, so it needs no further setup." : "connect writes — re-run `connect --harness cline` once Cline is installed.",
|
|
262
|
+
"The Continue extension works the same way against ~/.continue/config.yaml.",
|
|
263
|
+
],
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Parses a JSON object file, treating an empty file as `{}` and anything else as not ours. */
|
|
268
|
+
function parseJsonObject(before: string): Record<string, unknown> | null {
|
|
269
|
+
if (before.trim() === "") return {};
|
|
270
|
+
try {
|
|
271
|
+
const parsed = JSON.parse(before) as unknown;
|
|
272
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
273
|
+
return parsed as Record<string, unknown>;
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
package/src/cost/ledger.ts
CHANGED
|
@@ -99,6 +99,7 @@ export interface LedgerRow {
|
|
|
99
99
|
upstream_generation_id: string | null;
|
|
100
100
|
error: string | null;
|
|
101
101
|
prompt_tokens_saved: number | null;
|
|
102
|
+
scope: string | null;
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
interface TrustRow {
|
|
@@ -274,6 +275,9 @@ export function toEntry(row: LedgerRow): LedgerEntry {
|
|
|
274
275
|
upstreamGenerationId: row.upstream_generation_id,
|
|
275
276
|
error: row.error,
|
|
276
277
|
promptTokensSaved: row.prompt_tokens_saved ?? 0,
|
|
278
|
+
// Optional under exactOptionalPropertyTypes: an old row (or a scopeless
|
|
279
|
+
// turn) simply has no `scope`, rather than an explicit undefined.
|
|
280
|
+
...(row.scope === null || row.scope === undefined ? {} : { scope: row.scope }),
|
|
277
281
|
};
|
|
278
282
|
}
|
|
279
283
|
|
|
@@ -319,8 +323,8 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
319
323
|
id, created_at_ms, conversation_key, session_id, turn, requested_model, harness_id, omp_session_id, slug, served_slug,
|
|
320
324
|
tier, classification_source, reasons, predicted_usd, reported_usd, usage, cost_breakdown,
|
|
321
325
|
attempt, escalation_signal, latency_ms, ttft_ms, finish_reason, wasted, upstream_generation_id, error,
|
|
322
|
-
error_kind, features, score, confidence, task, classifier_reasons, explored_from, hold_arm, prompt_tokens_saved
|
|
323
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
326
|
+
error_kind, features, score, confidence, task, classifier_reasons, explored_from, hold_arm, prompt_tokens_saved, scope
|
|
327
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
324
328
|
);
|
|
325
329
|
const calibrationStmt = db.query(
|
|
326
330
|
`INSERT INTO token_calibration (tokenizer, est_bytes, actual_tokens, samples) VALUES (?, ?, ?, 1)
|
|
@@ -473,6 +477,9 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
473
477
|
entry.exploredFrom,
|
|
474
478
|
entry.holdArm,
|
|
475
479
|
entry.promptTokensSaved,
|
|
480
|
+
// A turn that carried no scope stores NULL, exactly as every row
|
|
481
|
+
// written before v18 did; "" and absent are the same fact.
|
|
482
|
+
entry.scope === undefined || entry.scope === "" ? null : entry.scope,
|
|
476
483
|
);
|
|
477
484
|
// Always consume the pending estimate, even when the turn failed, so a
|
|
478
485
|
// dead turn's bytes can never pair with a later turn's tokens. Only
|
package/src/cost/types.ts
CHANGED
|
@@ -146,6 +146,13 @@ export interface LedgerEntry {
|
|
|
146
146
|
error: string | null;
|
|
147
147
|
/** Prompt tokens removed by compaction before dispatch. 0 when none. NULL before v12. */
|
|
148
148
|
promptTokensSaved: number;
|
|
149
|
+
/**
|
|
150
|
+
* The agentdox context scope this turn carried — what the bridge resolved
|
|
151
|
+
* for it (the request's `X-Agentdox-Scope`, or `context.defaultScope`).
|
|
152
|
+
* Absent, or empty, when the turn carried none, and stored as NULL; a front
|
|
153
|
+
* door charges the row's spend back to that project with it. NULL before v18.
|
|
154
|
+
*/
|
|
155
|
+
scope?: string;
|
|
149
156
|
/**
|
|
150
157
|
* The catalog model that served, for the cost split. The ledger can price
|
|
151
158
|
* OpenRouter slugs from its own cached catalog payload; a model from another
|
package/src/cost/views.ts
CHANGED
|
@@ -44,6 +44,8 @@ export interface ExportRow {
|
|
|
44
44
|
day: string;
|
|
45
45
|
harnessId: string;
|
|
46
46
|
slug: string;
|
|
47
|
+
/** The agentdox context scope the turns carried; "" for rows that carried none (every row before v18). */
|
|
48
|
+
scope: string;
|
|
47
49
|
provider: string;
|
|
48
50
|
dispatches: number;
|
|
49
51
|
promptTokens: number;
|
|
@@ -125,12 +127,21 @@ export function decisionEntries(db: Database, filter: DecisionFilter): DecisionE
|
|
|
125
127
|
return entries.map((e) => ({ ...e, feedback: verdicts.get(e.id) ?? [] }));
|
|
126
128
|
}
|
|
127
129
|
|
|
128
|
-
/**
|
|
129
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Spend (reported where present, predicted otherwise) since `sinceMs`, digest calls included
|
|
132
|
+
* as the ledger counts them. `contextScope`, when given, narrows to the turns that carried
|
|
133
|
+
* exactly that agentdox scope — what a front door charges back to one project.
|
|
134
|
+
*/
|
|
135
|
+
export function spendUsdSince(db: Database, sinceMs: number, harness: HarnessScope, contextScope?: string): number {
|
|
130
136
|
const s = scope(harness, "harness_id");
|
|
131
137
|
if (s === null) return 0;
|
|
132
|
-
const where = ["created_at_ms >= $since", ...s.sql]
|
|
133
|
-
const
|
|
138
|
+
const where = ["created_at_ms >= $since", ...s.sql];
|
|
139
|
+
const bind: Record<string, string | number> = { $since: sinceMs, ...s.bind };
|
|
140
|
+
if (contextScope !== undefined && contextScope !== "") {
|
|
141
|
+
where.push("scope = $scope");
|
|
142
|
+
bind.$scope = contextScope;
|
|
143
|
+
}
|
|
144
|
+
const row = db.query(`SELECT COALESCE(SUM(${USD}), 0) AS usd FROM ledger WHERE ${where.join(" AND ")}`).get(bind) as { usd: number };
|
|
134
145
|
return row.usd;
|
|
135
146
|
}
|
|
136
147
|
|
|
@@ -163,14 +174,19 @@ export function feedbackView(db: Database, sinceMs: number, harness: HarnessScop
|
|
|
163
174
|
return { byModel, recent };
|
|
164
175
|
}
|
|
165
176
|
|
|
166
|
-
/**
|
|
177
|
+
/**
|
|
178
|
+
* One row per UTC day, harness, served model and context scope since `sinceMs`; digest calls
|
|
179
|
+
* are excluded as in the report. The scope splits a harness's day by project, so a front door
|
|
180
|
+
* can charge each project its own share; rows from before v18 (and turns that carried no
|
|
181
|
+
* scope) group under "".
|
|
182
|
+
*/
|
|
167
183
|
export function exportRows(db: Database, sinceMs: number, harness: HarnessScope): ExportRow[] {
|
|
168
184
|
const s = scope(harness, "harness_id");
|
|
169
185
|
if (s === null) return [];
|
|
170
186
|
const where = ["created_at_ms >= $since", "requested_model <> 'digest'", ...s.sql].join(" AND ");
|
|
171
187
|
const rows = db
|
|
172
188
|
.query(
|
|
173
|
-
`SELECT strftime('%Y-%m-%d', created_at_ms / 1000, 'unixepoch') AS day, harness_id, COALESCE(served_slug, slug) AS slug,
|
|
189
|
+
`SELECT strftime('%Y-%m-%d', created_at_ms / 1000, 'unixepoch') AS day, harness_id, COALESCE(served_slug, slug) AS slug, COALESCE(scope, '') AS scope,
|
|
174
190
|
COUNT(*) AS dispatches,
|
|
175
191
|
COALESCE(SUM(json_extract(usage, '$.promptTokens')), 0) AS prompt_tokens,
|
|
176
192
|
COALESCE(SUM(json_extract(usage, '$.cachedTokens')), 0) AS cached_tokens,
|
|
@@ -178,13 +194,14 @@ export function exportRows(db: Database, sinceMs: number, harness: HarnessScope)
|
|
|
178
194
|
COALESCE(SUM(${USD}), 0) AS spend,
|
|
179
195
|
SUM(CASE WHEN escalation_signal IS NOT NULL THEN 1 ELSE 0 END) AS escalations,
|
|
180
196
|
SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS errors
|
|
181
|
-
FROM ledger WHERE ${where} GROUP BY day, harness_id, slug ORDER BY day ASC, harness_id ASC, spend DESC`,
|
|
197
|
+
FROM ledger WHERE ${where} GROUP BY day, harness_id, slug, scope ORDER BY day ASC, harness_id ASC, spend DESC`,
|
|
182
198
|
)
|
|
183
|
-
.all({ $since: sinceMs, ...s.bind }) as { day: string; harness_id: string; slug: string; dispatches: number; prompt_tokens: number; cached_tokens: number; completion_tokens: number; spend: number; escalations: number; errors: number }[];
|
|
199
|
+
.all({ $since: sinceMs, ...s.bind }) as { day: string; harness_id: string; slug: string; scope: string; dispatches: number; prompt_tokens: number; cached_tokens: number; completion_tokens: number; spend: number; escalations: number; errors: number }[];
|
|
184
200
|
return rows.map((r) => ({
|
|
185
201
|
day: r.day,
|
|
186
202
|
harnessId: r.harness_id,
|
|
187
203
|
slug: r.slug,
|
|
204
|
+
scope: r.scope,
|
|
188
205
|
provider: providerOfSlug(r.slug),
|
|
189
206
|
dispatches: r.dispatches,
|
|
190
207
|
promptTokens: r.prompt_tokens,
|
|
@@ -196,7 +213,7 @@ export function exportRows(db: Database, sinceMs: number, harness: HarnessScope)
|
|
|
196
213
|
}));
|
|
197
214
|
}
|
|
198
215
|
|
|
199
|
-
export const EXPORT_COLUMNS = ["day", "harness", "model", "provider", "dispatches", "prompt_tokens", "cached_tokens", "completion_tokens", "spend_usd", "escalations", "errors"] as const;
|
|
216
|
+
export const EXPORT_COLUMNS = ["day", "harness", "model", "provider", "dispatches", "prompt_tokens", "cached_tokens", "completion_tokens", "spend_usd", "escalations", "errors", "scope"] as const;
|
|
200
217
|
|
|
201
218
|
export function csvCell(v: string | number): string {
|
|
202
219
|
const s = String(v);
|
|
@@ -206,7 +223,7 @@ export function csvCell(v: string | number): string {
|
|
|
206
223
|
/** CSV of export rows; spend to 6 decimals so sub-cent rows survive. */
|
|
207
224
|
export function exportCsv(rows: readonly ExportRow[]): string {
|
|
208
225
|
const lines = [EXPORT_COLUMNS.join(",")];
|
|
209
|
-
for (const r of rows) lines.push([r.day, r.harnessId, r.slug, r.provider, r.dispatches, r.promptTokens, r.cachedTokens, r.completionTokens, r.spendUsd.toFixed(6), r.escalations, r.errors].map(csvCell).join(","));
|
|
226
|
+
for (const r of rows) lines.push([r.day, r.harnessId, r.slug, r.provider, r.dispatches, r.promptTokens, r.cachedTokens, r.completionTokens, r.spendUsd.toFixed(6), r.escalations, r.errors, r.scope].map(csvCell).join(","));
|
|
210
227
|
return `${lines.join("\n")}\n`;
|
|
211
228
|
}
|
|
212
229
|
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ Usage: auto-model-router <command> [options]
|
|
|
29
29
|
report Usage analytics: providers, models, tiers, cost, speed, cache hit rate
|
|
30
30
|
export One row per day, harness and model as CSV (--json for rows)
|
|
31
31
|
connect Point this machine at a remote router (--url with --key[, --refresh-token] or --setup-token <one-time token from a team>; --scope pins one project for the whole machine (default: each workspace's own); --profile persists the environment and, from the compiled executable, PATH; the remote's skills and its MCP endpoint are installed for Claude Code and omp)
|
|
32
|
+
--harness omp,hermes,codex,aider,claude,opencode,cline,continue,cursor,windsurf restricts it; cursor and windsurf are printed, not written
|
|
32
33
|
refresh Trade the refresh token for a new access key and re-write every harness config (--force: even when not near expiry)
|
|
33
34
|
token Print an access key that is good right now, refreshing first if needed (for a harness key-helper)
|
|
34
35
|
models Show what each complexity tier would consider, and why
|
package/src/server/http.ts
CHANGED
|
@@ -527,7 +527,9 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
527
527
|
// budget check needs when it cannot read the ledger file.
|
|
528
528
|
const since = Number.parseInt(url.searchParams.get("sinceMs") ?? "", 10);
|
|
529
529
|
if (!Number.isFinite(since)) return wireErrorResponse({ status: 400, code: "invalid_request_error", message: "sinceMs required" });
|
|
530
|
-
|
|
530
|
+
// `scope` narrows to one agentdox context scope: a project's own spend.
|
|
531
|
+
const contextScope = url.searchParams.get("scope") ?? "";
|
|
532
|
+
return json({ sinceMs: since, usd: spendUsdSince(db, since, harnessScopeParam(url.searchParams.get("harness")), contextScope), ...(contextScope === "" ? {} : { scope: contextScope }) });
|
|
531
533
|
}
|
|
532
534
|
if (req.method === "GET" && url.pathname === "/v1/router/feedback") {
|
|
533
535
|
const days = clampDays(url.searchParams.get("days"), 30);
|
package/src/server/turn.ts
CHANGED
|
@@ -304,6 +304,10 @@ export async function runTurn(
|
|
|
304
304
|
turn: turnNumber,
|
|
305
305
|
requestedModel: req.requestedModel,
|
|
306
306
|
harnessId: req.harnessId,
|
|
307
|
+
// The context scope this turn carried, so a front door can charge the
|
|
308
|
+
// row back to a project. The resolved one the bridge used, header or
|
|
309
|
+
// configured default; "" stores as NULL.
|
|
310
|
+
scope: doxScope,
|
|
307
311
|
ompSessionId: req.ompSessionId,
|
|
308
312
|
slug: decision.slug,
|
|
309
313
|
servedSlug,
|
package/src/util/sqlite.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { mkdirSync } from "node:fs";
|
|
|
18
18
|
import { dirname } from "node:path";
|
|
19
19
|
|
|
20
20
|
/** Bump when a migration is added; guarded below so reopening never regresses it. */
|
|
21
|
-
const USER_VERSION =
|
|
21
|
+
const USER_VERSION = 18;
|
|
22
22
|
|
|
23
23
|
const MIGRATIONS = `
|
|
24
24
|
CREATE TABLE IF NOT EXISTS catalog_cache (
|
|
@@ -286,6 +286,16 @@ const MIGRATE_V17 = `
|
|
|
286
286
|
ALTER TABLE conversations ADD COLUMN upgrade_deferred_tier TEXT;
|
|
287
287
|
`;
|
|
288
288
|
|
|
289
|
+
// v18: ledger records the agentdox context scope the turn carried — the scope
|
|
290
|
+
// the bridge resolved for it (the request header, or the configured default).
|
|
291
|
+
// A front door charges spend back to a project with it: the team edition names
|
|
292
|
+
// a project's scope on every turn, so `scope` is the only column that says
|
|
293
|
+
// which project a row belongs to. NULL on every row written before this, and
|
|
294
|
+
// on any turn that carried no scope at all; there is nothing to backfill from.
|
|
295
|
+
const MIGRATE_V18 = `
|
|
296
|
+
ALTER TABLE ledger ADD COLUMN scope TEXT;
|
|
297
|
+
`;
|
|
298
|
+
|
|
289
299
|
// v9: benchmark_cache holds the external benchmark feeds (Artificial Analysis,
|
|
290
300
|
// BenchLM) that backfill quality scores OpenRouter leaves unpublished. It is a
|
|
291
301
|
// whole new table, created idempotently by the MIGRATIONS block above, so there
|
|
@@ -327,6 +337,7 @@ export function openDb(path: string): Database {
|
|
|
327
337
|
if (!ledgerCols.some((c) => c.name === "explored_from")) db.exec(MIGRATE_V7);
|
|
328
338
|
if (!ledgerCols.some((c) => c.name === "hold_arm")) db.exec(MIGRATE_V8);
|
|
329
339
|
if (!ledgerCols.some((c) => c.name === "prompt_tokens_saved")) db.exec(MIGRATE_V12);
|
|
340
|
+
if (!ledgerCols.some((c) => c.name === "scope")) db.exec(MIGRATE_V18);
|
|
330
341
|
const convCols = db.query("PRAGMA table_info(conversations)").all() as { name: string }[];
|
|
331
342
|
if (!convCols.some((c) => c.name === "context_version")) db.exec(MIGRATE_V11);
|
|
332
343
|
if (!convCols.some((c) => c.name === "compaction_plan")) db.exec(MIGRATE_V13);
|