auto-model-router 0.2.31 → 0.3.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 +211 -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 +115 -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 +189 -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 +43 -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 +127 -2
- package/src/cost/ledger.ts +73 -4
- package/src/cost/report.ts +340 -0
- package/src/cost/types.ts +33 -1
- package/src/index.ts +5 -8
- package/src/router/candidates.ts +90 -11
- 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 +11 -0
- package/src/server/http.ts +47 -6
- package/src/server/providers.ts +54 -0
- package/src/server/turn.ts +122 -34
- package/src/tokens/estimate.ts +16 -0
- package/src/upstream/multi.ts +26 -0
- package/src/upstream/ollama-usage.ts +157 -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/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 +506 -0
- package/test/omp-credentials.test.ts +43 -1
- package/test/report-hub.test.ts +341 -0
- package/test/report-logic.test.ts +92 -0
- package/test/report.test.ts +217 -0
- package/test/select.test.ts +176 -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 +124 -7
- package/tools/build-site.ts +1 -0
package/src/server/turn.ts
CHANGED
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
import type { CatalogSource } from "../catalog/types.ts";
|
|
12
12
|
import type { ContextBridge } from "../context/types.ts";
|
|
13
13
|
import type { RouterConfig } from "../config/types.ts";
|
|
14
|
+
import { computeCost } from "../cost/forecast.ts";
|
|
14
15
|
import { EMPTY_USAGE, type Ledger, type UsageCounts } from "../cost/types.ts";
|
|
15
16
|
import { createProbe, type Probe } from "../router/escalate.ts";
|
|
16
17
|
import { resolveHoldTurns } from "../router/explore.ts";
|
|
18
|
+
import { adjustPendingEstimate } from "../tokens/estimate.ts";
|
|
17
19
|
import {
|
|
18
20
|
TIER_ORDER,
|
|
19
21
|
type ConversationStore,
|
|
@@ -36,6 +38,23 @@ import type { NormRequest, ResponseSink, TurnSummary, UpstreamChunk } from "../w
|
|
|
36
38
|
*/
|
|
37
39
|
const MAX_SAME_TIER_FAILOVERS = 2;
|
|
38
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Probe signals that indict the PROVIDER rather than the tier: an empty
|
|
43
|
+
* stream, a refusal, or an error finish says nothing about whether the work
|
|
44
|
+
* needed a stronger model. Measured on 7 days of live traffic, 49 of 71
|
|
45
|
+
* escalations re-dispatched the very slug that had just failed, one tier up,
|
|
46
|
+
* paying the tier premium for a provider hiccup. These signals get the same
|
|
47
|
+
* same-tier failover an HTTP 5xx gets, and only then step up a tier. Structural
|
|
48
|
+
* signals (malformed arguments, a repeated call) still escalate directly — a
|
|
49
|
+
* stronger model is the remedy there.
|
|
50
|
+
*/
|
|
51
|
+
const PROVIDER_SIGNALS: ReadonlySet<string> = new Set(["empty_completion", "refusal", "upstream_error"]);
|
|
52
|
+
|
|
53
|
+
/** A client hang-up: the request signal fired, or the transport reported the abort. */
|
|
54
|
+
function isClientAbort(err: unknown, signal: AbortSignal): boolean {
|
|
55
|
+
return signal.aborted || (err instanceof UpstreamError && err.kind === "aborted");
|
|
56
|
+
}
|
|
57
|
+
|
|
39
58
|
export interface TurnDeps {
|
|
40
59
|
config: RouterConfig;
|
|
41
60
|
router: Router;
|
|
@@ -170,6 +189,14 @@ export async function runTurn(
|
|
|
170
189
|
...(decision.compactionPlan.length > 0 ? { compactionPlan: decision.compactionPlan } : {}),
|
|
171
190
|
});
|
|
172
191
|
|
|
192
|
+
// Calibrate the token estimate against the bytes that actually go out —
|
|
193
|
+
// after compaction shrank the prompt and the context block was appended
|
|
194
|
+
// — not the raw request the estimate was taken from.
|
|
195
|
+
adjustPendingEstimate(
|
|
196
|
+
req.conversationKey,
|
|
197
|
+
req.promptBytes - decision.compactionSavedBytes + (contextBlock === undefined ? 0 : Buffer.byteLength(contextBlock)),
|
|
198
|
+
);
|
|
199
|
+
|
|
173
200
|
// Our own abort composes with the client's: escalation teardown and
|
|
174
201
|
// client disconnect both kill the upstream connection.
|
|
175
202
|
const attemptAbort = new AbortController();
|
|
@@ -196,6 +223,7 @@ export async function runTurn(
|
|
|
196
223
|
if (generationId === null && dispatch !== null) {
|
|
197
224
|
generationId = await dispatch.generationId().catch(() => null);
|
|
198
225
|
}
|
|
226
|
+
const priceModel = deps.catalog.find(servedSlug ?? decision.slug);
|
|
199
227
|
ledger.record({
|
|
200
228
|
id: crypto.randomUUID(),
|
|
201
229
|
createdAtMs: Date.now(),
|
|
@@ -229,6 +257,9 @@ export async function runTurn(
|
|
|
229
257
|
upstreamGenerationId: generationId,
|
|
230
258
|
error: fields.error,
|
|
231
259
|
promptTokensSaved: decision.promptTokensSaved,
|
|
260
|
+
// The ledger prices OpenRouter slugs from its own cached payload; any
|
|
261
|
+
// other provider's model exists only in the live catalog.
|
|
262
|
+
...(priceModel === undefined ? {} : { priceModel }),
|
|
232
263
|
});
|
|
233
264
|
// Book the money HERE, beside the ledger row, so the two can never
|
|
234
265
|
// disagree. Every dispatch that reaches this point was billed —
|
|
@@ -239,6 +270,28 @@ export async function runTurn(
|
|
|
239
270
|
conversations.accrue(req.conversationKey, { spentUsd: reportedUsd ?? decision.forecast.expectedUsd });
|
|
240
271
|
};
|
|
241
272
|
|
|
273
|
+
// Same-tier failover: re-route with every failed slug excluded and accept
|
|
274
|
+
// the result only if it stays in this tier on a different model. A
|
|
275
|
+
// 404/429/5xx, an empty stream, or a refusal indicts the slug, not the
|
|
276
|
+
// tier, so a sibling is tried before the tier is abandoned. Returns null
|
|
277
|
+
// when the bound is hit or the router widened tiers on its own; the
|
|
278
|
+
// caller then falls through to tier escalation.
|
|
279
|
+
const trySameTierFailover = async (why: string): Promise<Decision | null> => {
|
|
280
|
+
if (sameTierFailovers >= MAX_SAME_TIER_FAILOVERS) return null;
|
|
281
|
+
let failover: Decision | null = null;
|
|
282
|
+
try {
|
|
283
|
+
failover = await router.route(req, { attempt: attempt + 1, excludeSlugs: failedSlugs });
|
|
284
|
+
} catch {
|
|
285
|
+
// A routing failure here must not kill the turn; tier escalation
|
|
286
|
+
// may still find a model.
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
if (failover === null || failover.tier !== decision.tier || failedSlugs.includes(failover.slug)) return null;
|
|
290
|
+
sameTierFailovers++;
|
|
291
|
+
failover.reasons = [...failover.reasons, `failover: ${decision.slug} ${why}; retrying ${failover.slug} in ${failover.tier}`];
|
|
292
|
+
return failover;
|
|
293
|
+
};
|
|
294
|
+
|
|
242
295
|
// "retry" re-enters the attempt loop; "done" means the turn is settled
|
|
243
296
|
// (client told, or client gone) and runTurn must return.
|
|
244
297
|
const onUpstreamError = async (err: unknown): Promise<"retry" | "done"> => {
|
|
@@ -261,30 +314,15 @@ export async function runTurn(
|
|
|
261
314
|
}
|
|
262
315
|
if (uerr.retryable && attempt + 1 < maxAttempts) {
|
|
263
316
|
failedSlugs.push(decision.slug);
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
failover = null;
|
|
274
|
-
}
|
|
275
|
-
if (failover !== null && failover.tier === decision.tier && !failedSlugs.includes(failover.slug)) {
|
|
276
|
-
sameTierFailovers++;
|
|
277
|
-
failover.reasons = [
|
|
278
|
-
...failover.reasons,
|
|
279
|
-
`failover: ${decision.slug} returned ${uerr.kind}; retrying ${failover.slug} in ${failover.tier}`,
|
|
280
|
-
];
|
|
281
|
-
pendingDecision = failover;
|
|
282
|
-
await writeEntry({ wasted: true, escalationSignal: null, error: `${uerr.kind}: ${uerr.message}` });
|
|
283
|
-
return "retry";
|
|
284
|
-
}
|
|
285
|
-
// No different candidate at this tier — the router widened on
|
|
286
|
-
// its own or only the failed slug qualifies. Fall through to
|
|
287
|
-
// tier escalation.
|
|
317
|
+
// Before jumping a tier, try a DIFFERENT model in the same tier:
|
|
318
|
+
// a 404/429/5xx indicts the slug, not the tier. Null means no other
|
|
319
|
+
// candidate at this tier — the router widened on its own or only
|
|
320
|
+
// the failed slug qualifies — so fall through to tier escalation.
|
|
321
|
+
const failover = await trySameTierFailover(`returned ${uerr.kind}`);
|
|
322
|
+
if (failover !== null) {
|
|
323
|
+
pendingDecision = failover;
|
|
324
|
+
await writeEntry({ wasted: true, escalationSignal: null, error: `${uerr.kind}: ${uerr.message}` });
|
|
325
|
+
return "retry";
|
|
288
326
|
}
|
|
289
327
|
const topTier = TIER_ORDER[TIER_ORDER.length - 1];
|
|
290
328
|
if (decision.tier !== topTier) {
|
|
@@ -307,6 +345,11 @@ export async function runTurn(
|
|
|
307
345
|
let escalateVerdict: Extract<ProbeVerdict, { action: "escalate" }> | null = null;
|
|
308
346
|
let streamError: unknown = null;
|
|
309
347
|
let sinkDied = false;
|
|
348
|
+
// The upstream generation ran to its end — either the stream closed
|
|
349
|
+
// normally, or the client hung up after the finish event had already
|
|
350
|
+
// arrived. Both are settled generations; only the second used to be
|
|
351
|
+
// recorded as an error.
|
|
352
|
+
let streamEnded = false;
|
|
310
353
|
|
|
311
354
|
try {
|
|
312
355
|
dispatch = await upstream.dispatch({ body, sessionId: decision.sessionId, signal: attemptSignal });
|
|
@@ -374,21 +417,22 @@ export async function runTurn(
|
|
|
374
417
|
attemptAbort.abort();
|
|
375
418
|
break;
|
|
376
419
|
}
|
|
377
|
-
if (
|
|
378
|
-
const verdict = probe.verdictOnEnd();
|
|
379
|
-
if (verdict.action === "commit") {
|
|
380
|
-
committed = true;
|
|
381
|
-
for (const heldChunk of probe.held()) await emit(heldChunk);
|
|
382
|
-
} else {
|
|
383
|
-
escalateVerdict = verdict;
|
|
384
|
-
attemptAbort.abort();
|
|
385
|
-
}
|
|
386
|
-
}
|
|
420
|
+
if (escalateVerdict === null) streamEnded = true;
|
|
387
421
|
} catch (err) {
|
|
388
422
|
if (escalateVerdict !== null || attemptAbort.signal.aborted) {
|
|
389
423
|
// Teardown noise from our own abort; the escalate path owns the outcome.
|
|
390
424
|
} else if (err instanceof SinkError) {
|
|
391
425
|
sinkDied = true;
|
|
426
|
+
} else if (finishReason !== null && isClientAbort(err, signal)) {
|
|
427
|
+
// The client closed the connection AFTER the finish event: the
|
|
428
|
+
// generation is complete and billed, the client already has its
|
|
429
|
+
// answer, only the trailing `[DONE]` went unread. Measured live:
|
|
430
|
+
// 1,842 of 2,068 "request aborted" rows carried a finish reason
|
|
431
|
+
// and full usage, and every one skipped the state save below —
|
|
432
|
+
// stale hysteresis, cache-warmth, and compaction state on 12% of
|
|
433
|
+
// turns. A settled generation is settled however the socket
|
|
434
|
+
// closed.
|
|
435
|
+
streamEnded = true;
|
|
392
436
|
} else {
|
|
393
437
|
streamError = err;
|
|
394
438
|
}
|
|
@@ -396,6 +440,29 @@ export async function runTurn(
|
|
|
396
440
|
// Never leak the upstream connection, whatever happened above.
|
|
397
441
|
attemptAbort.abort();
|
|
398
442
|
}
|
|
443
|
+
if (streamEnded && !committed && escalateVerdict === null) {
|
|
444
|
+
const verdict = probe.verdictOnEnd();
|
|
445
|
+
if (verdict.action === "commit") {
|
|
446
|
+
committed = true;
|
|
447
|
+
try {
|
|
448
|
+
for (const heldChunk of probe.held()) await emit(heldChunk);
|
|
449
|
+
} catch (err) {
|
|
450
|
+
if (err instanceof SinkError) sinkDied = true;
|
|
451
|
+
else streamError = err;
|
|
452
|
+
}
|
|
453
|
+
} else {
|
|
454
|
+
escalateVerdict = verdict;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// A provider that reports no cost (Ollama) still reports usage: price the
|
|
460
|
+
// actual tokens at the served model's catalog rate rather than leaving the
|
|
461
|
+
// pre-dispatch forecast (with its assumed 1,024 completion tokens) to stand
|
|
462
|
+
// in for a turn that produced 26.
|
|
463
|
+
if (reportedUsd === null && usage.promptTokens > 0) {
|
|
464
|
+
const served = deps.catalog.find(servedSlug ?? decision.slug);
|
|
465
|
+
if (served !== undefined) reportedUsd = computeCost(served, usage).total;
|
|
399
466
|
}
|
|
400
467
|
|
|
401
468
|
if (sinkDied) {
|
|
@@ -408,6 +475,26 @@ export async function runTurn(
|
|
|
408
475
|
}
|
|
409
476
|
|
|
410
477
|
if (escalateVerdict !== null) {
|
|
478
|
+
// The model that produced the rejected output must not serve the
|
|
479
|
+
// retry, at this tier or the next: without this, 49 of 71 measured
|
|
480
|
+
// escalations re-dispatched the same slug one tier up.
|
|
481
|
+
failedSlugs.push(decision.slug);
|
|
482
|
+
if (PROVIDER_SIGNALS.has(escalateVerdict.signal) && attempt + 1 < maxAttempts) {
|
|
483
|
+
const failover = await trySameTierFailover(`${escalateVerdict.signal} (${escalateVerdict.reason})`);
|
|
484
|
+
if (failover !== null) {
|
|
485
|
+
pendingDecision = failover;
|
|
486
|
+
await writeEntry({ wasted: true, escalationSignal: escalateVerdict.signal, error: null });
|
|
487
|
+
log.info("same-tier failover", {
|
|
488
|
+
signal: escalateVerdict.signal,
|
|
489
|
+
reason: escalateVerdict.reason,
|
|
490
|
+
from: decision.slug,
|
|
491
|
+
to: failover.slug,
|
|
492
|
+
tier: decision.tier,
|
|
493
|
+
attempt,
|
|
494
|
+
});
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
411
498
|
const hasRunway = attempt + 1 < maxAttempts && decision.probe.escalateTo !== null;
|
|
412
499
|
if (hasRunway) {
|
|
413
500
|
escalations++;
|
|
@@ -461,6 +548,7 @@ export async function runTurn(
|
|
|
461
548
|
// it verbatim (after byte-length validation), keeping shrunk tool results
|
|
462
549
|
// shrunk so the prompt cache survives and the savings compound.
|
|
463
550
|
state.compactionPlan = decision.compactionPlan.length > 0 ? decision.compactionPlan : null;
|
|
551
|
+
state.compactionPlanTokens = decision.compactionPlanTokens;
|
|
464
552
|
if (usage.cachedTokens > 0 || usage.cacheWriteTokens > 0) {
|
|
465
553
|
// Non-zero cache traffic is direct evidence the upstream cache exists.
|
|
466
554
|
state.cacheWarmSlug = servedSlug ?? decision.slug;
|
package/src/tokens/estimate.ts
CHANGED
|
@@ -66,6 +66,22 @@ export function estimatePromptTokens(req: NormRequest, tokenizer: string, ledger
|
|
|
66
66
|
return tokens;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Replaces the byte count the pending estimate will be calibrated against.
|
|
71
|
+
*
|
|
72
|
+
* The estimate is taken from the RAW request, but what the upstream bills is
|
|
73
|
+
* the prompt after compaction shrank it and the context block was appended.
|
|
74
|
+
* Pairing raw bytes with billed tokens taught every family a ratio that had
|
|
75
|
+
* compaction baked in (measured: the compacted estimate ran 33% under the
|
|
76
|
+
* billed count). The turn orchestrator calls this with the dispatched size
|
|
77
|
+
* once it knows it, so the ratio describes the tokenizer and nothing else.
|
|
78
|
+
*/
|
|
79
|
+
export function adjustPendingEstimate(conversationKey: string, dispatchedBytes: number): void {
|
|
80
|
+
const pending = pendingEstimates.get(conversationKey);
|
|
81
|
+
if (pending === undefined || dispatchedBytes <= 0) return;
|
|
82
|
+
pending.bytes = dispatchedBytes;
|
|
83
|
+
}
|
|
84
|
+
|
|
69
85
|
/** Internal: called by cost/ledger.ts when recording a turn. */
|
|
70
86
|
export function consumePendingEstimate(conversationKey: string): { tokenizer: string; bytes: number } | null {
|
|
71
87
|
const pending = pendingEstimates.get(conversationKey) ?? null;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One `UpstreamClient` over several providers, keyed by catalog slug prefix.
|
|
3
|
+
*
|
|
4
|
+
* `ollama/…` slugs go to the Ollama client; everything else is OpenRouter.
|
|
5
|
+
* Catalog fetches and the adjudicator's `complete` stay on OpenRouter, whose
|
|
6
|
+
* catalog is the router's baseline; Ollama's own listing is read by its
|
|
7
|
+
* catalog source, not through this seam.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isOllamaSlug } from "../catalog/ollama-catalog.ts";
|
|
11
|
+
import type { Dispatch, DispatchOptions, UpstreamClient } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
export function createMultiUpstream(openrouter: UpstreamClient, ollama: UpstreamClient): UpstreamClient {
|
|
14
|
+
return {
|
|
15
|
+
dispatch(opts: DispatchOptions): Promise<Dispatch> {
|
|
16
|
+
const model = opts.body.model;
|
|
17
|
+
return typeof model === "string" && isOllamaSlug(model) ? ollama.dispatch(opts) : openrouter.dispatch(opts);
|
|
18
|
+
},
|
|
19
|
+
complete(body, signal) {
|
|
20
|
+
const model = body.model;
|
|
21
|
+
return typeof model === "string" && isOllamaSlug(model) ? ollama.complete(body, signal) : openrouter.complete(body, signal);
|
|
22
|
+
},
|
|
23
|
+
fetchModels: (signal) => openrouter.fetchModels(signal),
|
|
24
|
+
fetchModelsForUser: (signal) => openrouter.fetchModelsForUser(signal),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ollama Cloud plan usage, read from `GET https://ollama.com/api/usage`.
|
|
3
|
+
*
|
|
4
|
+
* The endpoint is what ollama.com's own dashboard reads. It is not in the
|
|
5
|
+
* public docs (two open feature requests ask for exactly this), so everything
|
|
6
|
+
* here is defensive: unknown shape ⇒ null, never a throw, and the raw value is
|
|
7
|
+
* surfaced on /health so an operator can check it against the dashboard.
|
|
8
|
+
*
|
|
9
|
+
* Observed shape (2026-09-06, a Pro account):
|
|
10
|
+
*
|
|
11
|
+
* { activity: { cost: "0.00000", period: {type: "last_4_weeks", …}, models: [] },
|
|
12
|
+
* limits: { monthly: { usage: 0, models: [{ name, request_count }] } } }
|
|
13
|
+
*
|
|
14
|
+
* `limits.monthly.usage` is the share of the plan's included monthly credits
|
|
15
|
+
* consumed. It is reported relative to the plan, which is the point: the
|
|
16
|
+
* router never needs to know whether the account is Pro ($60) or Max ($300)
|
|
17
|
+
* to know how close it is to the edge. `request_count` moves immediately;
|
|
18
|
+
* `usage` and `activity.cost` are aggregated with a lag and round to whole
|
|
19
|
+
* units, so a few cents of test traffic reads as 0.
|
|
20
|
+
*
|
|
21
|
+
* The scale of `usage` is inferred, not documented: a value above 1 is a
|
|
22
|
+
* percentage; 0 is 0; a non-integer at or below 1 is a fraction. The one
|
|
23
|
+
* ambiguous reading, exactly 1, is taken as 1% (dashboards show whole
|
|
24
|
+
* percents) rather than 100%, which errs toward keeping the bias on.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { Logger } from "../util/log.ts";
|
|
28
|
+
|
|
29
|
+
export interface OllamaUsage {
|
|
30
|
+
/** Share of the plan's included monthly credits used, 0-1. Null when the payload lacks it. */
|
|
31
|
+
monthlyUsedFraction: number | null;
|
|
32
|
+
/** `limits.monthly.usage` exactly as reported, for the dashboard cross-check. */
|
|
33
|
+
monthlyUsageRaw: number | null;
|
|
34
|
+
/** `activity.cost` (rolling 4 weeks, USD) as reported, or null. */
|
|
35
|
+
activityCostUsd: number | null;
|
|
36
|
+
/** Requests this billing month, summed over models. */
|
|
37
|
+
requestsThisMonth: number;
|
|
38
|
+
fetchedAtMs: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function asRec(v: unknown): Record<string, unknown> | null {
|
|
42
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** See the module comment for the scale inference. */
|
|
46
|
+
export function usageFraction(raw: number): number {
|
|
47
|
+
if (!Number.isFinite(raw) || raw <= 0) return 0;
|
|
48
|
+
if (raw > 1) return Math.min(1, raw / 100);
|
|
49
|
+
if (raw === 1) return 0.01;
|
|
50
|
+
return raw;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function parseOllamaUsage(json: unknown, nowMs = Date.now()): OllamaUsage | null {
|
|
54
|
+
const root = asRec(json);
|
|
55
|
+
if (root === null) return null;
|
|
56
|
+
const limits = asRec(root.limits);
|
|
57
|
+
const monthly = limits === null ? null : asRec(limits.monthly);
|
|
58
|
+
const usageRaw = monthly !== null && typeof monthly.usage === "number" && Number.isFinite(monthly.usage) ? monthly.usage : null;
|
|
59
|
+
let requests = 0;
|
|
60
|
+
if (monthly !== null && Array.isArray(monthly.models)) {
|
|
61
|
+
for (const m of monthly.models) {
|
|
62
|
+
const rec = asRec(m);
|
|
63
|
+
if (rec !== null && typeof rec.request_count === "number") requests += rec.request_count;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const activity = asRec(root.activity);
|
|
67
|
+
const costRaw = activity?.cost;
|
|
68
|
+
const cost = typeof costRaw === "number" ? costRaw : typeof costRaw === "string" ? Number(costRaw) : NaN;
|
|
69
|
+
if (usageRaw === null && limits === null && activity === null) return null;
|
|
70
|
+
return {
|
|
71
|
+
monthlyUsedFraction: usageRaw === null ? null : usageFraction(usageRaw),
|
|
72
|
+
monthlyUsageRaw: usageRaw,
|
|
73
|
+
activityCostUsd: Number.isFinite(cost) ? cost : null,
|
|
74
|
+
requestsThisMonth: requests,
|
|
75
|
+
fetchedAtMs: nowMs,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
80
|
+
|
|
81
|
+
export interface OllamaUsageSource {
|
|
82
|
+
/** Latest usage, refreshed when older than the poll interval; last good value on failure. */
|
|
83
|
+
get(): Promise<OllamaUsage | null>;
|
|
84
|
+
/** Last fetched value without touching the network. */
|
|
85
|
+
peek(): OllamaUsage | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Inert source for setups with no key (the daemon path without `/login ollama-cloud`). */
|
|
89
|
+
export const NO_USAGE: OllamaUsageSource = { get: async () => null, peek: () => null };
|
|
90
|
+
|
|
91
|
+
export function createOllamaUsageSource(
|
|
92
|
+
opts: { apiKey: string; pollMs: number; timeoutMs: number; log: Logger; fetchImpl?: FetchLike; root?: string },
|
|
93
|
+
): OllamaUsageSource {
|
|
94
|
+
if (opts.apiKey === "" || opts.pollMs <= 0) return NO_USAGE;
|
|
95
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
96
|
+
const root = (opts.root ?? "https://ollama.com").replace(/\/+$/, "");
|
|
97
|
+
let current: OllamaUsage | null = null;
|
|
98
|
+
let checkedAtMs = 0;
|
|
99
|
+
let inflight: Promise<OllamaUsage | null> | null = null;
|
|
100
|
+
let warned = false;
|
|
101
|
+
|
|
102
|
+
async function refresh(): Promise<OllamaUsage | null> {
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetchImpl(`${root}/api/usage`, {
|
|
105
|
+
headers: { authorization: `Bearer ${opts.apiKey}` },
|
|
106
|
+
signal: AbortSignal.timeout(opts.timeoutMs),
|
|
107
|
+
});
|
|
108
|
+
if (res.ok) {
|
|
109
|
+
const parsed = parseOllamaUsage(await res.json());
|
|
110
|
+
if (parsed !== null) {
|
|
111
|
+
current = parsed;
|
|
112
|
+
warned = false;
|
|
113
|
+
} else if (!warned) {
|
|
114
|
+
warned = true;
|
|
115
|
+
opts.log.warn("ollama usage payload had no recognisable fields; credit-aware bias stays on its last reading");
|
|
116
|
+
}
|
|
117
|
+
} else if (!warned) {
|
|
118
|
+
warned = true;
|
|
119
|
+
opts.log.warn("ollama usage endpoint unavailable; credit-aware bias stays on its last reading", { status: res.status });
|
|
120
|
+
}
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (!warned) {
|
|
123
|
+
warned = true;
|
|
124
|
+
opts.log.warn("ollama usage fetch failed; credit-aware bias stays on its last reading", {
|
|
125
|
+
error: err instanceof Error ? err.message : String(err),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
checkedAtMs = Date.now();
|
|
130
|
+
return current;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
async get() {
|
|
135
|
+
if (Date.now() - checkedAtMs < opts.pollMs) return current;
|
|
136
|
+
inflight ??= refresh().finally(() => {
|
|
137
|
+
inflight = null;
|
|
138
|
+
});
|
|
139
|
+
return inflight;
|
|
140
|
+
},
|
|
141
|
+
peek: () => current,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The cost multiplier to apply to Ollama candidates right now: `costBias`
|
|
147
|
+
* while the plan's included credits are below `biasUntilUsage`, list price
|
|
148
|
+
* (1) once they are exhausted. Unknown usage keeps the bias — the plan is far
|
|
149
|
+
* more often under its allowance than over it, and a 402 still trips the
|
|
150
|
+
* breaker if that guess is wrong.
|
|
151
|
+
*/
|
|
152
|
+
export function effectiveOllamaBias(costBias: number, biasUntilUsage: number, usage: OllamaUsage | null): number {
|
|
153
|
+
if (costBias >= 1) return costBias;
|
|
154
|
+
const used = usage?.monthlyUsedFraction ?? null;
|
|
155
|
+
if (used === null) return costBias;
|
|
156
|
+
return used >= biasUntilUsage ? 1 : costBias;
|
|
157
|
+
}
|