auto-model-router 0.2.32 → 0.3.1
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 +225 -29
- package/docs/review-2026-09-05.md +267 -0
- package/omp-extension/configure-logic.ts +71 -15
- package/omp-extension/pi-coding-agent.d.ts +79 -2
- package/omp-extension/report-hub.ts +376 -0
- package/omp-extension/report-logic.ts +117 -0
- package/omp-extension/router-configure.ts +203 -51
- package/omp-extension/router-url.ts +52 -0
- package/omp-extension/toast-logic.ts +14 -2
- package/package.json +1 -1
- package/src/catalog/composite.ts +97 -0
- package/src/catalog/ollama-catalog.ts +309 -0
- package/src/catalog/ollama-prices.ts +85 -0
- package/src/catalog/openrouter-catalog.ts +39 -1
- package/src/catalog/types.ts +31 -1
- package/src/cli/args.ts +1 -0
- package/src/cli/config-wizard.ts +190 -28
- package/src/cli/explain.ts +2 -4
- package/src/cli/models.ts +2 -4
- package/src/cli/report.ts +37 -0
- package/src/config/defaults.ts +46 -2
- package/src/config/load.ts +25 -1
- package/src/config/omp-credentials.ts +31 -7
- package/src/config/schema.ts +28 -0
- package/src/config/types.ts +120 -2
- package/src/cost/cache-estimate.ts +52 -0
- package/src/cost/ledger.ts +73 -4
- package/src/cost/report.ts +351 -0
- package/src/cost/types.ts +39 -1
- package/src/index.ts +5 -8
- package/src/router/candidates.ts +52 -4
- package/src/router/classify.ts +33 -6
- package/src/router/features.ts +13 -1
- package/src/router/select.ts +55 -8
- package/src/router/state.ts +6 -2
- package/src/router/tier-plan.ts +49 -11
- package/src/router/types.ts +10 -0
- package/src/server/http.ts +50 -6
- package/src/server/providers.ts +54 -0
- package/src/server/turn.ts +138 -34
- package/src/tokens/estimate.ts +16 -0
- package/src/upstream/multi.ts +26 -0
- package/src/upstream/ollama-usage.ts +163 -0
- package/src/upstream/ollama.ts +275 -0
- package/src/upstream/openrouter.ts +19 -1
- package/src/upstream/types.ts +2 -0
- package/src/util/sqlite.ts +25 -1
- package/test/cache-estimate.test.ts +48 -0
- package/test/catalog.test.ts +44 -0
- package/test/classify.test.ts +41 -5
- package/test/compaction.test.ts +1 -0
- package/test/config-wizard.test.ts +77 -1
- package/test/configure-logic.test.ts +129 -33
- package/test/embed-lifecycle.test.ts +1 -0
- package/test/failover.test.ts +148 -3
- package/test/features.test.ts +35 -0
- package/test/http-resilience.test.ts +24 -0
- package/test/ollama.test.ts +521 -0
- package/test/omp-credentials.test.ts +43 -1
- package/test/report-hub.test.ts +343 -0
- package/test/report-logic.test.ts +93 -0
- package/test/report.test.ts +233 -0
- package/test/select.test.ts +151 -1
- package/test/tier-plan.test.ts +159 -1
- package/test/toast-logic.test.ts +11 -2
- package/test/tokens.test.ts +71 -1
- package/test/trust-attribution.test.ts +2 -2
- package/test/turn.test.ts +173 -7
- package/tools/recompute-ollama-cache.ts +129 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ollama Cloud transport: Ollama's OpenAI-compatible `/v1/chat/completions`,
|
|
3
|
+
* either on ollama.com directly (API key) or through a local daemon that
|
|
4
|
+
* proxies `:cloud` models under the signed-in account.
|
|
5
|
+
*
|
|
6
|
+
* Same shape as the OpenRouter client, with the differences Ollama's
|
|
7
|
+
* compatibility layer imposes applied to the rendered body just before it
|
|
8
|
+
* goes out:
|
|
9
|
+
*
|
|
10
|
+
* - the `ollama/` catalog prefix is stripped from `model`;
|
|
11
|
+
* - `models[]` (OpenRouter's fallback cascade) and `session_id` are removed;
|
|
12
|
+
* - `tool_choice` is removed (documented as unsupported);
|
|
13
|
+
* - OpenRouter's `reasoning: {effort}` object becomes `reasoning_effort`;
|
|
14
|
+
* - `cache_control` markers are stripped from content parts (Anthropic-style
|
|
15
|
+
* breakpoints mean nothing here and could be rejected);
|
|
16
|
+
* - `stream_options.include_usage` is set so the final chunk carries usage.
|
|
17
|
+
*
|
|
18
|
+
* Ollama returns no `usage.cost`, so the ledger's predicted figure stands in
|
|
19
|
+
* as the reported one; the catalog price is the published per-token rate.
|
|
20
|
+
*
|
|
21
|
+
* Plan limits are the other difference. Ollama meters cloud usage against
|
|
22
|
+
* monthly credits and a per-plan concurrency cap (1/3/10), so 402 and 429 are
|
|
23
|
+
* routine, not exceptional. Both trip a circuit breaker: the composite catalog
|
|
24
|
+
* hides every Ollama model while it is open, so a turn falls straight through
|
|
25
|
+
* to OpenRouter instead of paying a doomed dispatch first.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { RouterConfig } from "../config/types.ts";
|
|
29
|
+
import { OLLAMA_SLUG_PREFIX, ollamaModelId } from "../catalog/ollama-catalog.ts";
|
|
30
|
+
import { createLogger } from "../util/log.ts";
|
|
31
|
+
import type { StreamEvent, UpstreamChunk } from "../wire/types.ts";
|
|
32
|
+
import { parseSse } from "./sse-parse.ts";
|
|
33
|
+
import { UpstreamError, type Dispatch, type DispatchOptions, type UpstreamClient, type UpstreamErrorKind } from "./types.ts";
|
|
34
|
+
|
|
35
|
+
/** Circuit-breaker view the catalog consults. */
|
|
36
|
+
export interface OllamaAvailability {
|
|
37
|
+
/** False while a quota/rate-limit cooldown is in force. */
|
|
38
|
+
available(): boolean;
|
|
39
|
+
/** Epoch ms the cooldown ends, or null when available. */
|
|
40
|
+
cooldownUntilMs(): number | null;
|
|
41
|
+
/** Why the breaker is open, for /health. */
|
|
42
|
+
lastTrip(): { kind: UpstreamErrorKind; atMs: number; message: string } | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface OllamaClient extends UpstreamClient, OllamaAvailability {}
|
|
46
|
+
|
|
47
|
+
function asRec(v: unknown): Record<string, unknown> | null {
|
|
48
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const REASONING_EFFORT_MAP: Record<string, string> = {
|
|
52
|
+
minimal: "low",
|
|
53
|
+
low: "low",
|
|
54
|
+
medium: "medium",
|
|
55
|
+
high: "high",
|
|
56
|
+
xhigh: "high",
|
|
57
|
+
max: "high",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Rewrites an OpenRouter-shaped request body into what Ollama accepts. Pure. */
|
|
61
|
+
export function toOllamaBody(body: Record<string, unknown>): Record<string, unknown> {
|
|
62
|
+
const out: Record<string, unknown> = { ...body };
|
|
63
|
+
if (typeof out.model === "string") out.model = ollamaModelId(out.model);
|
|
64
|
+
delete out.models;
|
|
65
|
+
delete out.session_id;
|
|
66
|
+
delete out.tool_choice;
|
|
67
|
+
delete out.stream_options;
|
|
68
|
+
const reasoning = asRec(out.reasoning);
|
|
69
|
+
delete out.reasoning;
|
|
70
|
+
delete out.reasoning_effort;
|
|
71
|
+
if (reasoning !== null) {
|
|
72
|
+
if (reasoning.enabled === false) {
|
|
73
|
+
// Nothing: omitting the field is "no explicit effort" on Ollama.
|
|
74
|
+
} else if (typeof reasoning.effort === "string") {
|
|
75
|
+
const mapped = REASONING_EFFORT_MAP[reasoning.effort];
|
|
76
|
+
if (mapped !== undefined) out.reasoning_effort = mapped;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(out.messages)) {
|
|
80
|
+
out.messages = out.messages.map((m) => {
|
|
81
|
+
const msg = asRec(m);
|
|
82
|
+
if (msg === null || !Array.isArray(msg.content)) return m;
|
|
83
|
+
return {
|
|
84
|
+
...msg,
|
|
85
|
+
content: msg.content.map((part) => {
|
|
86
|
+
const p = asRec(part);
|
|
87
|
+
if (p === null || !("cache_control" in p)) return part;
|
|
88
|
+
const { cache_control: _dropped, ...rest } = p;
|
|
89
|
+
return rest;
|
|
90
|
+
}),
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (out.stream === true) out.stream_options = { include_usage: true };
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** HTTP status → error kind. 402/429 are plan limits, not model faults. */
|
|
99
|
+
export function classifyOllamaStatus(status: number, body: unknown): UpstreamError {
|
|
100
|
+
const rec = asRec(body);
|
|
101
|
+
const errRec = rec ? asRec(rec.error) : null;
|
|
102
|
+
const msg = errRec?.message ?? rec?.message ?? rec?.error;
|
|
103
|
+
const message = typeof msg === "string" && msg !== "" ? msg : `Ollama HTTP ${status}`;
|
|
104
|
+
const fail = (kind: UpstreamErrorKind, retryable: boolean): UpstreamError => new UpstreamError(kind, status, message, retryable, body);
|
|
105
|
+
if (status === 401) return fail("auth", false);
|
|
106
|
+
// Out of credits (or a plan gate): the account, not the model. Fail over.
|
|
107
|
+
if (status === 402) return fail("quota", true);
|
|
108
|
+
if (status === 403) return /credit|quota|plan|limit|billing/i.test(message) ? fail("quota", true) : fail("moderation", true);
|
|
109
|
+
if (status === 429) return fail("rate_limit", true);
|
|
110
|
+
if (status === 404) return fail("model_unavailable", true);
|
|
111
|
+
if (status === 400) {
|
|
112
|
+
if (/context|too many tokens|token limit/i.test(message)) return fail("context_length", false);
|
|
113
|
+
return fail("invalid_request", /support|unsupported|does not/i.test(message));
|
|
114
|
+
}
|
|
115
|
+
if (status >= 500) return fail("upstream_error", true);
|
|
116
|
+
return fail("upstream_error", status === 408);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function transportError(err: unknown): UpstreamError {
|
|
120
|
+
if (err instanceof UpstreamError) return err;
|
|
121
|
+
const name = err instanceof Error ? err.name : "";
|
|
122
|
+
if (name === "TimeoutError") return new UpstreamError("timeout", 0, "Ollama request timed out", true);
|
|
123
|
+
if (name === "AbortError") return new UpstreamError("aborted", 0, "request aborted", false);
|
|
124
|
+
return new UpstreamError("network", 0, err instanceof Error ? err.message : String(err), true);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Minimal fetch surface, injectable for tests. */
|
|
128
|
+
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
129
|
+
|
|
130
|
+
export function createOllamaClient(cfg: RouterConfig, fetchImpl: FetchLike = fetch): OllamaClient {
|
|
131
|
+
const o = cfg.ollama;
|
|
132
|
+
const baseUrl = o.baseUrl.replace(/\/+$/, "");
|
|
133
|
+
const log = createLogger(cfg.logLevel);
|
|
134
|
+
let cooldownUntil = 0;
|
|
135
|
+
let lastTrip: { kind: UpstreamErrorKind; atMs: number; message: string } | null = null;
|
|
136
|
+
|
|
137
|
+
const trip = (err: UpstreamError): void => {
|
|
138
|
+
const ms = err.kind === "quota" ? o.quotaCooldownMs : err.kind === "rate_limit" ? o.rateLimitCooldownMs : 0;
|
|
139
|
+
if (ms <= 0) return;
|
|
140
|
+
cooldownUntil = Math.max(cooldownUntil, Date.now() + ms);
|
|
141
|
+
lastTrip = { kind: err.kind, atMs: Date.now(), message: err.message };
|
|
142
|
+
log.warn("ollama cloud unavailable; routing around it", { kind: err.kind, cooldownMs: ms, message: err.message });
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
function headers(extra: Record<string, string> = {}): Record<string, string> {
|
|
146
|
+
const h: Record<string, string> = { "content-type": "application/json", ...extra };
|
|
147
|
+
if (o.apiKey !== "") h.authorization = `Bearer ${o.apiKey}`;
|
|
148
|
+
return h;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function composeSignal(caller: AbortSignal | undefined): AbortSignal | null {
|
|
152
|
+
const timeout = o.timeoutMs > 0 ? AbortSignal.timeout(o.timeoutMs) : null;
|
|
153
|
+
if (caller && timeout) return AbortSignal.any([caller, timeout]);
|
|
154
|
+
return caller ?? timeout;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function httpError(res: Response): Promise<UpstreamError> {
|
|
158
|
+
let body: unknown = null;
|
|
159
|
+
try {
|
|
160
|
+
body = await res.json();
|
|
161
|
+
} catch {
|
|
162
|
+
// Status alone drives classification.
|
|
163
|
+
}
|
|
164
|
+
const err = classifyOllamaStatus(res.status, body);
|
|
165
|
+
trip(err);
|
|
166
|
+
return err;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Re-prefixes the served model so state/ledger keys match the catalog slug. */
|
|
170
|
+
function prefixServed(chunk: UpstreamChunk): UpstreamChunk {
|
|
171
|
+
let events: StreamEvent[] | null = null;
|
|
172
|
+
for (let i = 0; i < chunk.events.length; i++) {
|
|
173
|
+
const ev = chunk.events[i];
|
|
174
|
+
if (ev !== undefined && ev.type === "start" && !ev.servedSlug.startsWith(OLLAMA_SLUG_PREFIX)) {
|
|
175
|
+
events ??= [...chunk.events];
|
|
176
|
+
events[i] = { ...ev, servedSlug: `${OLLAMA_SLUG_PREFIX}${ev.servedSlug}` };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const raw = typeof chunk.raw.model === "string" && !chunk.raw.model.startsWith(OLLAMA_SLUG_PREFIX) ? { ...chunk.raw, model: `${OLLAMA_SLUG_PREFIX}${chunk.raw.model}` } : chunk.raw;
|
|
180
|
+
return events === null && raw === chunk.raw ? chunk : { raw, events: events ?? chunk.events };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
available: () => Date.now() >= cooldownUntil,
|
|
185
|
+
cooldownUntilMs: () => (Date.now() >= cooldownUntil ? null : cooldownUntil),
|
|
186
|
+
lastTrip: () => lastTrip,
|
|
187
|
+
|
|
188
|
+
async dispatch(opts: DispatchOptions): Promise<Dispatch> {
|
|
189
|
+
const body = toOllamaBody({ ...opts.body, stream: true });
|
|
190
|
+
let res: Response;
|
|
191
|
+
try {
|
|
192
|
+
res = await fetchImpl(`${baseUrl}/chat/completions`, {
|
|
193
|
+
method: "POST",
|
|
194
|
+
headers: headers(),
|
|
195
|
+
body: JSON.stringify(body),
|
|
196
|
+
signal: composeSignal(opts.signal),
|
|
197
|
+
});
|
|
198
|
+
} catch (err) {
|
|
199
|
+
throw transportError(err);
|
|
200
|
+
}
|
|
201
|
+
if (!res.ok) throw await httpError(res);
|
|
202
|
+
if (!res.body) throw new UpstreamError("upstream_error", res.status, "response had no body", true);
|
|
203
|
+
|
|
204
|
+
const parsed = parseSse(res.body, (msg, fields) => log.warn(msg, fields));
|
|
205
|
+
let resolveId!: (id: string | null) => void;
|
|
206
|
+
const idPromise = new Promise<string | null>((resolve) => {
|
|
207
|
+
resolveId = resolve;
|
|
208
|
+
});
|
|
209
|
+
let idResolved = false;
|
|
210
|
+
const resolveOnce = (id: string | null): void => {
|
|
211
|
+
if (!idResolved) {
|
|
212
|
+
idResolved = true;
|
|
213
|
+
resolveId(id);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
const chunks = (async function* (): AsyncGenerator<UpstreamChunk> {
|
|
217
|
+
try {
|
|
218
|
+
for await (const chunk of parsed) {
|
|
219
|
+
const errPayload = chunk.raw.error;
|
|
220
|
+
if (errPayload !== undefined && errPayload !== null) {
|
|
221
|
+
const rec = asRec(errPayload) ?? {};
|
|
222
|
+
const message = typeof rec.message === "string" ? rec.message : "Ollama stream error";
|
|
223
|
+
throw new UpstreamError("upstream_error", 0, message, true, errPayload);
|
|
224
|
+
}
|
|
225
|
+
if (!idResolved && typeof chunk.raw.id === "string") resolveOnce(chunk.raw.id);
|
|
226
|
+
yield prefixServed(chunk);
|
|
227
|
+
}
|
|
228
|
+
} catch (err) {
|
|
229
|
+
throw transportError(err);
|
|
230
|
+
} finally {
|
|
231
|
+
resolveOnce(null);
|
|
232
|
+
}
|
|
233
|
+
})();
|
|
234
|
+
return { chunks, generationId: () => idPromise };
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
async complete(body: Record<string, unknown>, signal: AbortSignal): Promise<{ text: string; costUsd: number | null }> {
|
|
238
|
+
let res: Response;
|
|
239
|
+
try {
|
|
240
|
+
res = await fetchImpl(`${baseUrl}/chat/completions`, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: headers(),
|
|
243
|
+
body: JSON.stringify(toOllamaBody({ ...body, stream: false })),
|
|
244
|
+
signal: composeSignal(signal),
|
|
245
|
+
});
|
|
246
|
+
} catch (err) {
|
|
247
|
+
throw transportError(err);
|
|
248
|
+
}
|
|
249
|
+
if (!res.ok) throw await httpError(res);
|
|
250
|
+
const json = asRec(await res.json());
|
|
251
|
+
const choices = json?.choices;
|
|
252
|
+
const choice0 = Array.isArray(choices) && choices.length > 0 ? asRec(choices[0]) : null;
|
|
253
|
+
const message = choice0 ? asRec(choice0.message) : null;
|
|
254
|
+
const content = message?.content;
|
|
255
|
+
return { text: typeof content === "string" ? content : "", costUsd: null };
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
async fetchModels(signal?: AbortSignal): Promise<unknown[]> {
|
|
259
|
+
let res: Response;
|
|
260
|
+
try {
|
|
261
|
+
res = await fetchImpl(`${baseUrl}/models`, { headers: headers(), signal: composeSignal(signal) });
|
|
262
|
+
} catch (err) {
|
|
263
|
+
throw transportError(err);
|
|
264
|
+
}
|
|
265
|
+
if (!res.ok) throw await httpError(res);
|
|
266
|
+
const data = asRec(await res.json())?.data;
|
|
267
|
+
if (!Array.isArray(data)) throw new UpstreamError("upstream_error", res.status, "models payload had no data array", true);
|
|
268
|
+
return data;
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
fetchModelsForUser(signal?: AbortSignal): Promise<unknown[]> {
|
|
272
|
+
return this.fetchModels(signal);
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
@@ -23,6 +23,21 @@ import {
|
|
|
23
23
|
const CONTEXT_LENGTH_RE =
|
|
24
24
|
/context[ _-]?length|context[ _-]?window|maximum context|too many tokens|reduce (?:the |your )?(?:length|prompt)|prompt is too long|token limit/i;
|
|
25
25
|
|
|
26
|
+
// A 400 that names a MODEL limitation rather than a malformed request: the
|
|
27
|
+
// same body is fine on a sibling. Seen live: "This model only supports single
|
|
28
|
+
// tool-calls at once!" from a model handed history with parallel tool calls —
|
|
29
|
+
// the catalog has no reliable flag for that (`parallel_tool_calls` is listed by
|
|
30
|
+
// 8 of 347 models, including none of the ones that handle it), so it can only
|
|
31
|
+
// be learned reactively. Retryable ⇒ same-tier failover with the slug
|
|
32
|
+
// excluded; still an attributable error, so trust demotes a repeat offender.
|
|
33
|
+
const MODEL_CAPABILITY_RE =
|
|
34
|
+
/only supports single tool[ -]?calls?|parallel tool[ -]?calls?|does not support (?:tools?|function|tool[ -]?calls?|images?|vision|system)|(?:tool|function)[ -]?call(?:s|ing)? (?:is|are) not supported|unsupported (?:tool|function|parameter|modality|image)/i;
|
|
35
|
+
|
|
36
|
+
/** Exported for tests: the status/body → UpstreamError mapping. */
|
|
37
|
+
export function classifyUpstreamStatus(status: number, body: unknown): UpstreamError {
|
|
38
|
+
return classifyStatus(status, body);
|
|
39
|
+
}
|
|
40
|
+
|
|
26
41
|
function asRec(v: unknown): Record<string, unknown> | null {
|
|
27
42
|
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
|
28
43
|
}
|
|
@@ -46,7 +61,10 @@ function classifyStatus(status: number, body: unknown): UpstreamError {
|
|
|
46
61
|
if (status === 403) return fail("moderation", true);
|
|
47
62
|
if (status === 429) return fail("rate_limit", true);
|
|
48
63
|
if (status === 400) {
|
|
49
|
-
|
|
64
|
+
if (CONTEXT_LENGTH_RE.test(message)) return fail("context_length", false);
|
|
65
|
+
// The model, not the request, is what cannot cope: let failover pick a sibling.
|
|
66
|
+
if (MODEL_CAPABILITY_RE.test(message)) return fail("invalid_request", true);
|
|
67
|
+
return fail("invalid_request", false);
|
|
50
68
|
}
|
|
51
69
|
// Provider routing may recover a missing model on the next attempt.
|
|
52
70
|
if (status === 404) return fail("model_unavailable", true);
|
package/src/upstream/types.ts
CHANGED
|
@@ -11,6 +11,8 @@ import type { UpstreamChunk, WireError } from "../wire/types.ts";
|
|
|
11
11
|
export type UpstreamErrorKind =
|
|
12
12
|
| "auth"
|
|
13
13
|
| "rate_limit"
|
|
14
|
+
/** Plan credits or a usage allowance exhausted (Ollama Cloud 402). Account-level, retryable elsewhere. */
|
|
15
|
+
| "quota"
|
|
14
16
|
| "context_length"
|
|
15
17
|
| "model_unavailable"
|
|
16
18
|
| "invalid_request"
|
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 = 16;
|
|
22
22
|
|
|
23
23
|
const MIGRATIONS = `
|
|
24
24
|
CREATE TABLE IF NOT EXISTS catalog_cache (
|
|
@@ -76,6 +76,8 @@ CREATE INDEX IF NOT EXISTS idx_ledger_slug ON ledger (slug);
|
|
|
76
76
|
-- slug ever had; measured on a real ledger, the latency statement went from
|
|
77
77
|
-- 5-10ms and RISING with history to a flat 0.04-0.08ms.
|
|
78
78
|
CREATE INDEX IF NOT EXISTS idx_ledger_slug_created ON ledger (slug, created_at_ms DESC);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS idx_ledger_harness_created ON ledger (harness_id, created_at_ms DESC);
|
|
80
|
+
CREATE INDEX IF NOT EXISTS idx_ledger_slug_harness_created ON ledger (slug, harness_id, created_at_ms DESC);
|
|
79
81
|
|
|
80
82
|
CREATE TABLE IF NOT EXISTS token_calibration (
|
|
81
83
|
tokenizer TEXT PRIMARY KEY,
|
|
@@ -224,6 +226,23 @@ const MIGRATE_V13 = `
|
|
|
224
226
|
ALTER TABLE conversations ADD COLUMN compaction_plan TEXT;
|
|
225
227
|
`;
|
|
226
228
|
|
|
229
|
+
// v16: conversations remember the compacted prompt size the current plan was
|
|
230
|
+
// made at, so re-planning can be rationed to real growth (see
|
|
231
|
+
// CompactionConfig.replanGrowthRatio). 0 = unknown; the next plan sets it.
|
|
232
|
+
//
|
|
233
|
+
// The same migration discards every token_calibration row. Until v16 the
|
|
234
|
+
// calibration paired the PRE-compaction, pre-context-block byte count with the
|
|
235
|
+
// POST-compaction billed token count, so every learned ratio absorbed
|
|
236
|
+
// compaction (measured: actual/compacted-estimate = 1.5), and one provider
|
|
237
|
+
// that reports ~8x prompt tokens had dragged the qwen3 family to 1.6
|
|
238
|
+
// bytes/token. Rows are running sums with thousands of samples, so they would
|
|
239
|
+
// never converge on correctly paired data; relearning from zero costs ~20
|
|
240
|
+
// samples per family at the built-in defaults.
|
|
241
|
+
const MIGRATE_V16 = `
|
|
242
|
+
ALTER TABLE conversations ADD COLUMN compaction_plan_tokens INTEGER NOT NULL DEFAULT 0;
|
|
243
|
+
DELETE FROM token_calibration;
|
|
244
|
+
`;
|
|
245
|
+
|
|
227
246
|
// v9: benchmark_cache holds the external benchmark feeds (Artificial Analysis,
|
|
228
247
|
// BenchLM) that backfill quality scores OpenRouter leaves unpublished. It is a
|
|
229
248
|
// whole new table, created idempotently by the MIGRATIONS block above, so there
|
|
@@ -240,6 +259,10 @@ ALTER TABLE conversations ADD COLUMN compaction_plan TEXT;
|
|
|
240
259
|
// is set. Created idempotently by the MIGRATIONS block above, so the version bump
|
|
241
260
|
// alone records it; no ALTER guard needed.
|
|
242
261
|
|
|
262
|
+
// v15: idx_ledger_harness_created and idx_ledger_slug_harness_created serve
|
|
263
|
+
// harness-scoped daily spend, trust, and latency windows. Created idempotently
|
|
264
|
+
// by MIGRATIONS; no ALTER guard needed.
|
|
265
|
+
|
|
243
266
|
export function openDb(path: string): Database {
|
|
244
267
|
// ":memory:" has no parent directory to create.
|
|
245
268
|
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
|
|
@@ -264,6 +287,7 @@ export function openDb(path: string): Database {
|
|
|
264
287
|
const convCols = db.query("PRAGMA table_info(conversations)").all() as { name: string }[];
|
|
265
288
|
if (!convCols.some((c) => c.name === "context_version")) db.exec(MIGRATE_V11);
|
|
266
289
|
if (!convCols.some((c) => c.name === "compaction_plan")) db.exec(MIGRATE_V13);
|
|
290
|
+
if (!convCols.some((c) => c.name === "compaction_plan_tokens")) db.exec(MIGRATE_V16);
|
|
267
291
|
db.exec(`PRAGMA user_version = ${USER_VERSION}`);
|
|
268
292
|
}
|
|
269
293
|
return db;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { estimateUnreportedCache } from "../src/cost/cache-estimate.ts";
|
|
4
|
+
import { EMPTY_USAGE, type UsageCounts } from "../src/cost/types.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Ollama Cloud caches prompt prefixes and bills them at its cached rate but
|
|
8
|
+
* reports no count (measured 2026-09-07: 12 identical 162k-token requests
|
|
9
|
+
* moved the plan meter $0.06 against $0.29 at the full rate). The estimate
|
|
10
|
+
* follows the router's warm-cache rule: same model as the previous turn
|
|
11
|
+
* within the TTL ⇒ the previous prompt is the cached prefix.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const base = (over: Partial<UsageCounts> = {}): UsageCounts => ({ ...EMPTY_USAGE, promptTokens: 120_000, completionTokens: 300, ...over });
|
|
15
|
+
const ctx = { previousSlug: "ollama/glm", previousPromptTokens: 100_000, previousAtMs: 1_000_000, servedSlug: "ollama/glm", nowMs: 1_060_000, cacheWarmTtlMs: 300_000 };
|
|
16
|
+
|
|
17
|
+
describe("estimateUnreportedCache", () => {
|
|
18
|
+
test("same model within the TTL: the previous prompt is the cached prefix, flagged as estimated", () => {
|
|
19
|
+
const out = estimateUnreportedCache(base(), ctx);
|
|
20
|
+
expect(out.cachedTokens).toBe(100_000);
|
|
21
|
+
expect(out.cachedEstimated).toBe(true);
|
|
22
|
+
expect(out.promptTokens).toBe(120_000);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("a shorter prompt than the previous one caps the cached count at the prompt", () => {
|
|
26
|
+
expect(estimateUnreportedCache(base({ promptTokens: 40_000 }), ctx).cachedTokens).toBe(40_000);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("first turn, model switch, or idle past the TTL count as cold", () => {
|
|
30
|
+
expect(estimateUnreportedCache(base(), { ...ctx, previousSlug: null }).cachedTokens).toBe(0);
|
|
31
|
+
expect(estimateUnreportedCache(base(), { ...ctx, previousSlug: "ollama/other" }).cachedTokens).toBe(0);
|
|
32
|
+
expect(estimateUnreportedCache(base(), { ...ctx, nowMs: ctx.previousAtMs + 300_001 }).cachedTokens).toBe(0);
|
|
33
|
+
expect(estimateUnreportedCache(base(), { ...ctx, previousPromptTokens: 0 }).cachedTokens).toBe(0);
|
|
34
|
+
expect(estimateUnreportedCache(base(), { ...ctx, cacheWarmTtlMs: 0 }).cachedTokens).toBe(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("provider-reported cache counts are never overwritten", () => {
|
|
38
|
+
const reported = base({ cachedTokens: 5_000 });
|
|
39
|
+
expect(estimateUnreportedCache(reported, ctx)).toBe(reported);
|
|
40
|
+
const written = base({ cacheWriteTokens: 5_000 });
|
|
41
|
+
expect(estimateUnreportedCache(written, ctx)).toBe(written);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("no prompt tokens: nothing to estimate", () => {
|
|
45
|
+
const empty = base({ promptTokens: 0 });
|
|
46
|
+
expect(estimateUnreportedCache(empty, ctx)).toBe(empty);
|
|
47
|
+
});
|
|
48
|
+
});
|
package/test/catalog.test.ts
CHANGED
|
@@ -316,4 +316,48 @@ describe("createCatalog key-scoped availability", () => {
|
|
|
316
316
|
expect(hydrated?.keyScoped).toBe(true);
|
|
317
317
|
db.close();
|
|
318
318
|
});
|
|
319
|
+
|
|
320
|
+
test("a refresh that keeps under half the models is recorded as a shrink and surfaced", async () => {
|
|
321
|
+
const { createCatalog } = await import("../src/catalog/openrouter-catalog.ts");
|
|
322
|
+
const { openDb } = await import("../src/util/sqlite.ts");
|
|
323
|
+
const { DEFAULT_CONFIG } = await import("../src/config/defaults.ts");
|
|
324
|
+
|
|
325
|
+
// Every usable record, then a 4-model slice of it, then the full set again.
|
|
326
|
+
const usable = RAW.filter((r) => normalizeCatalogModel(r) !== null);
|
|
327
|
+
expect(usable.length).toBeGreaterThanOrEqual(20);
|
|
328
|
+
const responses = [usable, usable.slice(0, 4), usable];
|
|
329
|
+
let calls = 0;
|
|
330
|
+
const upstream: any = {
|
|
331
|
+
dispatch: () => Promise.reject(new Error("unused")),
|
|
332
|
+
complete: () => Promise.reject(new Error("unused")),
|
|
333
|
+
fetchModels: async () => usable,
|
|
334
|
+
fetchModelsForUser: async () => responses[Math.min(calls++, responses.length - 1)],
|
|
335
|
+
};
|
|
336
|
+
const cfg = {
|
|
337
|
+
...DEFAULT_CONFIG,
|
|
338
|
+
benchmarks: { ...DEFAULT_CONFIG.benchmarks, enabled: false },
|
|
339
|
+
openrouter: { ...DEFAULT_CONFIG.openrouter, apiKey: "sk-or-test" },
|
|
340
|
+
logLevel: "silent" as const,
|
|
341
|
+
};
|
|
342
|
+
const db = openDb(":memory:");
|
|
343
|
+
const catalog = createCatalog(cfg, upstream, db);
|
|
344
|
+
|
|
345
|
+
const first = await catalog.get();
|
|
346
|
+
expect(catalog.lastShrink?.()).toBeNull();
|
|
347
|
+
|
|
348
|
+
// The shrink is ADOPTED (the key decides what may be dispatched) but flagged.
|
|
349
|
+
const second = await catalog.refresh();
|
|
350
|
+
expect(second.models.length).toBe(4);
|
|
351
|
+
const shrink = catalog.lastShrink?.();
|
|
352
|
+
expect(shrink).not.toBeNull();
|
|
353
|
+
expect(shrink!.fromModels).toBe(first.models.length);
|
|
354
|
+
expect(shrink!.toModels).toBe(4);
|
|
355
|
+
|
|
356
|
+
// Recovery clears it.
|
|
357
|
+
const third = await catalog.refresh();
|
|
358
|
+
expect(third.models.length).toBe(first.models.length);
|
|
359
|
+
expect(catalog.lastShrink?.()).toBeNull();
|
|
360
|
+
db.close();
|
|
361
|
+
});
|
|
362
|
+
|
|
319
363
|
});
|
package/test/classify.test.ts
CHANGED
|
@@ -263,15 +263,51 @@ describe("scoreHeuristic", () => {
|
|
|
263
263
|
expect(scoreHeuristic(contFeatures(400), BASE).tier).toBe("moderate");
|
|
264
264
|
});
|
|
265
265
|
|
|
266
|
-
test("a circular tool call on a
|
|
267
|
-
//
|
|
268
|
-
|
|
266
|
+
test("a circular tool call on a FRESH turn escalates to hard", () => {
|
|
267
|
+
// Off a continuation the stuck signal keeps full weight: the user is
|
|
268
|
+
// watching a live loop and a pricier model may actually break it.
|
|
269
|
+
const deepCircular = scoreHeuristic(
|
|
270
|
+
{ ...contFeatures(90, { circularToolCall: true }), isToolResultContinuation: false },
|
|
271
|
+
BASE,
|
|
272
|
+
);
|
|
269
273
|
expect(deepCircular.tier).toBe("hard");
|
|
270
274
|
});
|
|
271
275
|
|
|
272
|
-
test("a
|
|
276
|
+
test("a circular tool call on a mechanical continuation is damped, not hard", () => {
|
|
277
|
+
// Measured: hard escalations on circular calls never shortened the loop
|
|
278
|
+
// (chain means identical, 5.74 turns, hard vs moderate). 22 of 27 such
|
|
279
|
+
// hard turns were mechanical continuations paying up to 6x for nothing.
|
|
280
|
+
const retry = scoreHeuristic(contFeatures(90, { circularToolCall: true }), BASE);
|
|
281
|
+
const plain = scoreHeuristic(contFeatures(90), BASE);
|
|
282
|
+
expect(tierIdx(retry.tier)).toBeLessThanOrEqual(tierIdx("moderate"));
|
|
283
|
+
// Exactly the damped weight, not merely "less than the full one".
|
|
284
|
+
expect(retry.score - plain.score).toBeCloseTo(BASE.classifier.mechanicalRetryFactor * 0.24, 5);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("a failing tool result on a deep loop is at least simple", () => {
|
|
288
|
+
// Was 'at least moderate' before the mechanical-retry damp: the flat
|
|
289
|
+
// +0.26 pushed deep mechanical retry loops into hard. A damped retry
|
|
290
|
+
// still clears trivial.
|
|
273
291
|
const deepAndFailing = scoreHeuristic(contFeatures(20, { lastToolFailed: true }), BASE);
|
|
274
|
-
expect(tierIdx(deepAndFailing.tier)).toBeGreaterThanOrEqual(tierIdx("
|
|
292
|
+
expect(tierIdx(deepAndFailing.tier)).toBeGreaterThanOrEqual(tierIdx("simple"));
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("a failed-tool retry on a mechanical continuation is damped, not hard", () => {
|
|
296
|
+
// A retry after a failed tool call is the most mechanical turn there is;
|
|
297
|
+
// the flat +0.26 let automated retry loops buy the hard tier ($7.02 of one
|
|
298
|
+
// measured day vs $0.19 for the same rows as moderate picks). The
|
|
299
|
+
// continuation keeps only mechanicalRetryFactor of the weight.
|
|
300
|
+
const retry = scoreHeuristic(contFeatures(20, { lastToolFailed: true }), BASE);
|
|
301
|
+
const quiet = scoreHeuristic(contFeatures(20), BASE);
|
|
302
|
+
expect(tierIdx(retry.tier)).toBeLessThan(tierIdx("hard"));
|
|
303
|
+
expect(retry.score - quiet.score).toBeCloseTo(
|
|
304
|
+
BASE.classifier.mechanicalRetryFactor * 0.26,
|
|
305
|
+
5,
|
|
306
|
+
);
|
|
307
|
+
// A failure the USER sees (not a tool-result continuation) keeps the full
|
|
308
|
+
// weight: that genuinely changes what the turn needs.
|
|
309
|
+
const userSeen = scoreHeuristic(contFeatures(2, { isToolResultContinuation: false, lastToolFailed: true }), BASE);
|
|
310
|
+
expect(userSeen.score - scoreHeuristic(contFeatures(2, { isToolResultContinuation: false }), BASE).score).toBeCloseTo(0.26, 5);
|
|
275
311
|
});
|
|
276
312
|
});
|
|
277
313
|
|
package/test/compaction.test.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { writeRouterConfig } from "../src/cli/config-cmd.ts";
|
|
|
8
8
|
import {
|
|
9
9
|
applyAnswers,
|
|
10
10
|
CLEAR_TOKEN,
|
|
11
|
+
displayValue,
|
|
11
12
|
formatValue,
|
|
12
13
|
getPath,
|
|
13
14
|
mergeConfigPartial,
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
type FieldSpec,
|
|
21
22
|
type WizardIo,
|
|
22
23
|
} from "../src/cli/config-wizard.ts";
|
|
24
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
23
25
|
import { loadConfig } from "../src/config/load.ts";
|
|
24
26
|
|
|
25
27
|
const cfg = loadConfig({});
|
|
@@ -113,6 +115,78 @@ describe("validateField", () => {
|
|
|
113
115
|
expect(validateField(opt, CLEAR_TOKEN)).toEqual({ ok: true, value: null });
|
|
114
116
|
expect(validateField(num, CLEAR_TOKEN).ok).toBe(false);
|
|
115
117
|
});
|
|
118
|
+
|
|
119
|
+
test("a restricted string array rejects unknown items", () => {
|
|
120
|
+
const tiers: FieldSpec = { path: "escalation.probeTiers", label: "t", kind: "stringArray", options: ["trivial", "simple"] };
|
|
121
|
+
expect(validateField(tiers, "simple, trivial")).toEqual({ ok: true, value: ["simple", "trivial"] });
|
|
122
|
+
const bad = validateField(tiers, "simple, hard");
|
|
123
|
+
expect(bad.ok).toBe(false);
|
|
124
|
+
if (!bad.ok) expect(bad.error).toContain("hard");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("number arrays parse, bound-check and reject non-numbers", () => {
|
|
128
|
+
const arms: FieldSpec = { path: "exploration.holdTurns.values", label: "a", kind: "numberArray", min: 1 };
|
|
129
|
+
expect(validateField(arms, "2, 3,4")).toEqual({ ok: true, value: [2, 3, 4] });
|
|
130
|
+
expect(validateField(arms, "2, x").ok).toBe(false);
|
|
131
|
+
expect(validateField(arms, "0, 2").ok).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("secrets display as set/unset", () => {
|
|
135
|
+
const key: FieldSpec = { path: "openrouter.apiKey", label: "k", kind: "string", optional: true, secret: true };
|
|
136
|
+
expect(displayValue(key, "sk-abc")).toBe("set");
|
|
137
|
+
expect(displayValue(key, "")).toBe("unset");
|
|
138
|
+
expect(displayValue(key, undefined)).toBe("unset");
|
|
139
|
+
expect(displayValue({ ...key, secret: false }, "sk-abc")).toBe("sk-abc");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("WIZARD_SECTIONS coverage", () => {
|
|
144
|
+
/** Leaves that are edited as whole records/arrays rather than fields. */
|
|
145
|
+
const RECORD_PATHS = new Set(["ollama.prices", "ollama.twins", "profiles"]);
|
|
146
|
+
|
|
147
|
+
function leaves(obj: unknown, prefix = ""): string[] {
|
|
148
|
+
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) return [prefix];
|
|
149
|
+
const out: string[] = [];
|
|
150
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
151
|
+
const path = prefix === "" ? k : `${prefix}.${k}`;
|
|
152
|
+
if (RECORD_PATHS.has(path)) continue;
|
|
153
|
+
out.push(...leaves(v, path));
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
test("every config leaf has a wizard field (so /router can edit all of it)", () => {
|
|
159
|
+
const fields = new Set(WIZARD_SECTIONS.flatMap((s) => s.fields.map((f) => f.path)));
|
|
160
|
+
const missing = leaves(DEFAULT_CONFIG).filter((p) => !fields.has(p));
|
|
161
|
+
expect(missing).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("every wizard field points at a real config path or a known optional", () => {
|
|
165
|
+
// Optional keys absent from DEFAULT_CONFIG still have to be spelled right;
|
|
166
|
+
// they are listed here so a typo in a new field path fails loudly.
|
|
167
|
+
const KNOWN_OPTIONAL = new Set([
|
|
168
|
+
"server.apiKey", "server.harnessId", "openrouter.referer",
|
|
169
|
+
"budget.perTurnUsd", "budget.perConversationUsd", "budget.perDayUsd", "filters.maxExpectedWaitMs",
|
|
170
|
+
...["trivial", "simple", "moderate", "hard"].flatMap((t) => [
|
|
171
|
+
`tiers.${t}.maxInputPerMtok`, `tiers.${t}.maxOutputPerMtok`, `tiers.${t}.qualityNormalization`, `tiers.${t}.capabilityFloorUsd`,
|
|
172
|
+
]),
|
|
173
|
+
...["coding", "vision", "documentation", "data", "chat"].flatMap((t) => [`tasks.${t}.minQuality`, `tasks.${t}.requireImage`, `tasks.${t}.prefer`]),
|
|
174
|
+
...["trivial", "simple", "moderate", "hard"].map((t) => `exploration.rates.${t}`),
|
|
175
|
+
]);
|
|
176
|
+
const known = new Set(leaves(DEFAULT_CONFIG));
|
|
177
|
+
const unknown = WIZARD_SECTIONS.flatMap((s) => s.fields.map((f) => f.path)).filter((p) => !known.has(p) && !KNOWN_OPTIONAL.has(p));
|
|
178
|
+
expect(unknown).toEqual([]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("field paths are unique across sections", () => {
|
|
182
|
+
const all = WIZARD_SECTIONS.flatMap((s) => s.fields.map((f) => f.path));
|
|
183
|
+
expect(new Set(all).size).toBe(all.length);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("secrets are exactly the credential keys", () => {
|
|
187
|
+
const secrets = WIZARD_SECTIONS.flatMap((s) => s.fields.filter((f) => f.secret === true).map((f) => f.path)).sort();
|
|
188
|
+
expect(secrets).toEqual(["benchmarks.artificialAnalysisApiKey", "context.token", "ollama.apiKey", "openrouter.apiKey", "server.apiKey"]);
|
|
189
|
+
});
|
|
116
190
|
});
|
|
117
191
|
|
|
118
192
|
describe("applyAnswers", () => {
|
|
@@ -198,13 +272,14 @@ describe("runWizard", () => {
|
|
|
198
272
|
});
|
|
199
273
|
|
|
200
274
|
test("blank answers keep current values; only edits are written", async () => {
|
|
201
|
-
// Server section: host, port, apiKey, harnessId.
|
|
275
|
+
// Server section: host, port, apiKey, harnessId, maxConcurrentTurns.
|
|
202
276
|
const { partial, changed } = await drive([
|
|
203
277
|
SECTION.get("Server") ?? "",
|
|
204
278
|
"", // keep host
|
|
205
279
|
"9000", // change port
|
|
206
280
|
"", // keep apiKey
|
|
207
281
|
"", // keep harnessId
|
|
282
|
+
"", // keep maxConcurrentTurns
|
|
208
283
|
"s",
|
|
209
284
|
]);
|
|
210
285
|
expect(partial).toEqual({ server: { port: 9000 } });
|
|
@@ -248,6 +323,7 @@ describe("runWizard", () => {
|
|
|
248
323
|
"n", // injectBreakpoints
|
|
249
324
|
"", // maxBreakpoints
|
|
250
325
|
"", // minPromptTokens
|
|
326
|
+
"", // milestoneTokens
|
|
251
327
|
"s",
|
|
252
328
|
]);
|
|
253
329
|
expect(partial).toEqual({ logLevel: "warn", cache: { injectBreakpoints: false } });
|