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
package/src/server/turn.ts
CHANGED
|
@@ -11,9 +11,12 @@
|
|
|
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 { estimateUnreportedCache } from "../cost/cache-estimate.ts";
|
|
15
|
+
import { computeCost } from "../cost/forecast.ts";
|
|
14
16
|
import { EMPTY_USAGE, type Ledger, type UsageCounts } from "../cost/types.ts";
|
|
15
17
|
import { createProbe, type Probe } from "../router/escalate.ts";
|
|
16
18
|
import { resolveHoldTurns } from "../router/explore.ts";
|
|
19
|
+
import { adjustPendingEstimate } from "../tokens/estimate.ts";
|
|
17
20
|
import {
|
|
18
21
|
TIER_ORDER,
|
|
19
22
|
type ConversationStore,
|
|
@@ -36,6 +39,23 @@ import type { NormRequest, ResponseSink, TurnSummary, UpstreamChunk } from "../w
|
|
|
36
39
|
*/
|
|
37
40
|
const MAX_SAME_TIER_FAILOVERS = 2;
|
|
38
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Probe signals that indict the PROVIDER rather than the tier: an empty
|
|
44
|
+
* stream, a refusal, or an error finish says nothing about whether the work
|
|
45
|
+
* needed a stronger model. Measured on 7 days of live traffic, 49 of 71
|
|
46
|
+
* escalations re-dispatched the very slug that had just failed, one tier up,
|
|
47
|
+
* paying the tier premium for a provider hiccup. These signals get the same
|
|
48
|
+
* same-tier failover an HTTP 5xx gets, and only then step up a tier. Structural
|
|
49
|
+
* signals (malformed arguments, a repeated call) still escalate directly — a
|
|
50
|
+
* stronger model is the remedy there.
|
|
51
|
+
*/
|
|
52
|
+
const PROVIDER_SIGNALS: ReadonlySet<string> = new Set(["empty_completion", "refusal", "upstream_error"]);
|
|
53
|
+
|
|
54
|
+
/** A client hang-up: the request signal fired, or the transport reported the abort. */
|
|
55
|
+
function isClientAbort(err: unknown, signal: AbortSignal): boolean {
|
|
56
|
+
return signal.aborted || (err instanceof UpstreamError && err.kind === "aborted");
|
|
57
|
+
}
|
|
58
|
+
|
|
39
59
|
export interface TurnDeps {
|
|
40
60
|
config: RouterConfig;
|
|
41
61
|
router: Router;
|
|
@@ -170,6 +190,14 @@ export async function runTurn(
|
|
|
170
190
|
...(decision.compactionPlan.length > 0 ? { compactionPlan: decision.compactionPlan } : {}),
|
|
171
191
|
});
|
|
172
192
|
|
|
193
|
+
// Calibrate the token estimate against the bytes that actually go out —
|
|
194
|
+
// after compaction shrank the prompt and the context block was appended
|
|
195
|
+
// — not the raw request the estimate was taken from.
|
|
196
|
+
adjustPendingEstimate(
|
|
197
|
+
req.conversationKey,
|
|
198
|
+
req.promptBytes - decision.compactionSavedBytes + (contextBlock === undefined ? 0 : Buffer.byteLength(contextBlock)),
|
|
199
|
+
);
|
|
200
|
+
|
|
173
201
|
// Our own abort composes with the client's: escalation teardown and
|
|
174
202
|
// client disconnect both kill the upstream connection.
|
|
175
203
|
const attemptAbort = new AbortController();
|
|
@@ -196,6 +224,7 @@ export async function runTurn(
|
|
|
196
224
|
if (generationId === null && dispatch !== null) {
|
|
197
225
|
generationId = await dispatch.generationId().catch(() => null);
|
|
198
226
|
}
|
|
227
|
+
const priceModel = deps.catalog.find(servedSlug ?? decision.slug);
|
|
199
228
|
ledger.record({
|
|
200
229
|
id: crypto.randomUUID(),
|
|
201
230
|
createdAtMs: Date.now(),
|
|
@@ -229,6 +258,9 @@ export async function runTurn(
|
|
|
229
258
|
upstreamGenerationId: generationId,
|
|
230
259
|
error: fields.error,
|
|
231
260
|
promptTokensSaved: decision.promptTokensSaved,
|
|
261
|
+
// The ledger prices OpenRouter slugs from its own cached payload; any
|
|
262
|
+
// other provider's model exists only in the live catalog.
|
|
263
|
+
...(priceModel === undefined ? {} : { priceModel }),
|
|
232
264
|
});
|
|
233
265
|
// Book the money HERE, beside the ledger row, so the two can never
|
|
234
266
|
// disagree. Every dispatch that reaches this point was billed —
|
|
@@ -239,6 +271,28 @@ export async function runTurn(
|
|
|
239
271
|
conversations.accrue(req.conversationKey, { spentUsd: reportedUsd ?? decision.forecast.expectedUsd });
|
|
240
272
|
};
|
|
241
273
|
|
|
274
|
+
// Same-tier failover: re-route with every failed slug excluded and accept
|
|
275
|
+
// the result only if it stays in this tier on a different model. A
|
|
276
|
+
// 404/429/5xx, an empty stream, or a refusal indicts the slug, not the
|
|
277
|
+
// tier, so a sibling is tried before the tier is abandoned. Returns null
|
|
278
|
+
// when the bound is hit or the router widened tiers on its own; the
|
|
279
|
+
// caller then falls through to tier escalation.
|
|
280
|
+
const trySameTierFailover = async (why: string): Promise<Decision | null> => {
|
|
281
|
+
if (sameTierFailovers >= MAX_SAME_TIER_FAILOVERS) return null;
|
|
282
|
+
let failover: Decision | null = null;
|
|
283
|
+
try {
|
|
284
|
+
failover = await router.route(req, { attempt: attempt + 1, excludeSlugs: failedSlugs });
|
|
285
|
+
} catch {
|
|
286
|
+
// A routing failure here must not kill the turn; tier escalation
|
|
287
|
+
// may still find a model.
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
if (failover === null || failover.tier !== decision.tier || failedSlugs.includes(failover.slug)) return null;
|
|
291
|
+
sameTierFailovers++;
|
|
292
|
+
failover.reasons = [...failover.reasons, `failover: ${decision.slug} ${why}; retrying ${failover.slug} in ${failover.tier}`];
|
|
293
|
+
return failover;
|
|
294
|
+
};
|
|
295
|
+
|
|
242
296
|
// "retry" re-enters the attempt loop; "done" means the turn is settled
|
|
243
297
|
// (client told, or client gone) and runTurn must return.
|
|
244
298
|
const onUpstreamError = async (err: unknown): Promise<"retry" | "done"> => {
|
|
@@ -261,30 +315,15 @@ export async function runTurn(
|
|
|
261
315
|
}
|
|
262
316
|
if (uerr.retryable && attempt + 1 < maxAttempts) {
|
|
263
317
|
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.
|
|
318
|
+
// Before jumping a tier, try a DIFFERENT model in the same tier:
|
|
319
|
+
// a 404/429/5xx indicts the slug, not the tier. Null means no other
|
|
320
|
+
// candidate at this tier — the router widened on its own or only
|
|
321
|
+
// the failed slug qualifies — so fall through to tier escalation.
|
|
322
|
+
const failover = await trySameTierFailover(`returned ${uerr.kind}`);
|
|
323
|
+
if (failover !== null) {
|
|
324
|
+
pendingDecision = failover;
|
|
325
|
+
await writeEntry({ wasted: true, escalationSignal: null, error: `${uerr.kind}: ${uerr.message}` });
|
|
326
|
+
return "retry";
|
|
288
327
|
}
|
|
289
328
|
const topTier = TIER_ORDER[TIER_ORDER.length - 1];
|
|
290
329
|
if (decision.tier !== topTier) {
|
|
@@ -307,6 +346,11 @@ export async function runTurn(
|
|
|
307
346
|
let escalateVerdict: Extract<ProbeVerdict, { action: "escalate" }> | null = null;
|
|
308
347
|
let streamError: unknown = null;
|
|
309
348
|
let sinkDied = false;
|
|
349
|
+
// The upstream generation ran to its end — either the stream closed
|
|
350
|
+
// normally, or the client hung up after the finish event had already
|
|
351
|
+
// arrived. Both are settled generations; only the second used to be
|
|
352
|
+
// recorded as an error.
|
|
353
|
+
let streamEnded = false;
|
|
310
354
|
|
|
311
355
|
try {
|
|
312
356
|
dispatch = await upstream.dispatch({ body, sessionId: decision.sessionId, signal: attemptSignal });
|
|
@@ -374,21 +418,22 @@ export async function runTurn(
|
|
|
374
418
|
attemptAbort.abort();
|
|
375
419
|
break;
|
|
376
420
|
}
|
|
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
|
-
}
|
|
421
|
+
if (escalateVerdict === null) streamEnded = true;
|
|
387
422
|
} catch (err) {
|
|
388
423
|
if (escalateVerdict !== null || attemptAbort.signal.aborted) {
|
|
389
424
|
// Teardown noise from our own abort; the escalate path owns the outcome.
|
|
390
425
|
} else if (err instanceof SinkError) {
|
|
391
426
|
sinkDied = true;
|
|
427
|
+
} else if (finishReason !== null && isClientAbort(err, signal)) {
|
|
428
|
+
// The client closed the connection AFTER the finish event: the
|
|
429
|
+
// generation is complete and billed, the client already has its
|
|
430
|
+
// answer, only the trailing `[DONE]` went unread. Measured live:
|
|
431
|
+
// 1,842 of 2,068 "request aborted" rows carried a finish reason
|
|
432
|
+
// and full usage, and every one skipped the state save below —
|
|
433
|
+
// stale hysteresis, cache-warmth, and compaction state on 12% of
|
|
434
|
+
// turns. A settled generation is settled however the socket
|
|
435
|
+
// closed.
|
|
436
|
+
streamEnded = true;
|
|
392
437
|
} else {
|
|
393
438
|
streamError = err;
|
|
394
439
|
}
|
|
@@ -396,6 +441,44 @@ export async function runTurn(
|
|
|
396
441
|
// Never leak the upstream connection, whatever happened above.
|
|
397
442
|
attemptAbort.abort();
|
|
398
443
|
}
|
|
444
|
+
if (streamEnded && !committed && escalateVerdict === null) {
|
|
445
|
+
const verdict = probe.verdictOnEnd();
|
|
446
|
+
if (verdict.action === "commit") {
|
|
447
|
+
committed = true;
|
|
448
|
+
try {
|
|
449
|
+
for (const heldChunk of probe.held()) await emit(heldChunk);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
if (err instanceof SinkError) sinkDied = true;
|
|
452
|
+
else streamError = err;
|
|
453
|
+
}
|
|
454
|
+
} else {
|
|
455
|
+
escalateVerdict = verdict;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// A provider that reports no cost (Ollama) still reports usage: price the
|
|
461
|
+
// actual tokens at the served model's catalog rate rather than leaving the
|
|
462
|
+
// pre-dispatch forecast (with its assumed 1,024 completion tokens) to stand
|
|
463
|
+
// in for a turn that produced 26.
|
|
464
|
+
if (reportedUsd === null && usage.promptTokens > 0) {
|
|
465
|
+
const served = deps.catalog.find(servedSlug ?? decision.slug);
|
|
466
|
+
if (served !== undefined) {
|
|
467
|
+
// Ollama caches prompt prefixes and bills them at its cached rate
|
|
468
|
+
// without reporting a count; estimate it with the router's own
|
|
469
|
+
// warm-cache rule so the ledger stops booking every token fresh.
|
|
470
|
+
if (served.provider === "ollama") {
|
|
471
|
+
usage = estimateUnreportedCache(usage, {
|
|
472
|
+
previousSlug: state.currentSlug,
|
|
473
|
+
previousPromptTokens: state.lastPromptTokens,
|
|
474
|
+
previousAtMs: state.updatedAtMs,
|
|
475
|
+
servedSlug: served.slug,
|
|
476
|
+
nowMs: Date.now(),
|
|
477
|
+
cacheWarmTtlMs: config.hysteresis.cacheWarmTtlMs,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
reportedUsd = computeCost(served, usage).total;
|
|
481
|
+
}
|
|
399
482
|
}
|
|
400
483
|
|
|
401
484
|
if (sinkDied) {
|
|
@@ -408,6 +491,26 @@ export async function runTurn(
|
|
|
408
491
|
}
|
|
409
492
|
|
|
410
493
|
if (escalateVerdict !== null) {
|
|
494
|
+
// The model that produced the rejected output must not serve the
|
|
495
|
+
// retry, at this tier or the next: without this, 49 of 71 measured
|
|
496
|
+
// escalations re-dispatched the same slug one tier up.
|
|
497
|
+
failedSlugs.push(decision.slug);
|
|
498
|
+
if (PROVIDER_SIGNALS.has(escalateVerdict.signal) && attempt + 1 < maxAttempts) {
|
|
499
|
+
const failover = await trySameTierFailover(`${escalateVerdict.signal} (${escalateVerdict.reason})`);
|
|
500
|
+
if (failover !== null) {
|
|
501
|
+
pendingDecision = failover;
|
|
502
|
+
await writeEntry({ wasted: true, escalationSignal: escalateVerdict.signal, error: null });
|
|
503
|
+
log.info("same-tier failover", {
|
|
504
|
+
signal: escalateVerdict.signal,
|
|
505
|
+
reason: escalateVerdict.reason,
|
|
506
|
+
from: decision.slug,
|
|
507
|
+
to: failover.slug,
|
|
508
|
+
tier: decision.tier,
|
|
509
|
+
attempt,
|
|
510
|
+
});
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
411
514
|
const hasRunway = attempt + 1 < maxAttempts && decision.probe.escalateTo !== null;
|
|
412
515
|
if (hasRunway) {
|
|
413
516
|
escalations++;
|
|
@@ -461,6 +564,7 @@ export async function runTurn(
|
|
|
461
564
|
// it verbatim (after byte-length validation), keeping shrunk tool results
|
|
462
565
|
// shrunk so the prompt cache survives and the savings compound.
|
|
463
566
|
state.compactionPlan = decision.compactionPlan.length > 0 ? decision.compactionPlan : null;
|
|
567
|
+
state.compactionPlanTokens = decision.compactionPlanTokens;
|
|
464
568
|
if (usage.cachedTokens > 0 || usage.cacheWriteTokens > 0) {
|
|
465
569
|
// Non-zero cache traffic is direct evidence the upstream cache exists.
|
|
466
570
|
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,163 @@
|
|
|
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
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The dashboard's dollar reading: plan share × included credits, when both are known. */
|
|
160
|
+
export function ollamaMeter(usage: OllamaUsage | null, planCreditsUsd: number): { usedUsd: number; creditsUsd: number } | null {
|
|
161
|
+
if (usage === null || usage.monthlyUsedFraction === null || !(planCreditsUsd > 0)) return null;
|
|
162
|
+
return { usedUsd: Math.round(usage.monthlyUsedFraction * planCreditsUsd * 100) / 100, creditsUsd: planCreditsUsd };
|
|
163
|
+
}
|