auto-model-router 0.34.0 → 0.35.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/omp-extension/router-toast.ts +14 -4
- package/omp-extension/toast-logic.ts +20 -2
- package/package.json +1 -1
- package/src/catalog/composite.ts +6 -1
- package/src/cli/config-wizard.ts +2 -0
- package/src/config/defaults.ts +5 -0
- package/src/config/schema.ts +2 -0
- package/src/config/types.ts +9 -0
- package/src/server/providers.ts +12 -0
- package/src/upstream/anthropic.ts +2 -1
- package/src/upstream/compat.ts +4 -2
- package/src/upstream/openrouter-usage.ts +120 -0
- package/src/upstream/openrouter.ts +6 -1
- package/test/failover.test.ts +42 -1
- package/test/openrouter-usage.test.ts +59 -0
- package/test/toast-logic.test.ts +55 -0
- package/test/turn.test.ts +1 -1
- package/test/upstreams.test.ts +26 -0
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.35.0",
|
|
11
11
|
"pluginRoot": "."
|
|
12
12
|
},
|
|
13
13
|
"plugins": [
|
|
14
14
|
{
|
|
15
15
|
"name": "auto-model-router",
|
|
16
16
|
"description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.35.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
|
|
28
28
|
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
29
29
|
|
|
30
|
-
import { routerAuthHeaders, routerBaseUrl } from "./router-url.ts";
|
|
30
|
+
import { routerAuthHeaders, routerBaseUrl, routerHome } from "./router-url.ts";
|
|
31
|
+
import { readRemoteRouter } from "./remote-logic.ts";
|
|
31
32
|
import { newestId, selectToasts, type ToastDecision } from "./toast-logic.ts";
|
|
32
33
|
|
|
33
34
|
/** Raw router config.yml, or null when there is none to read. */
|
|
@@ -35,8 +36,12 @@ import { newestId, selectToasts, type ToastDecision } from "./toast-logic.ts";
|
|
|
35
36
|
/** Absolute path of the shared embed port file (main session writes it). */
|
|
36
37
|
|
|
37
38
|
// This harness's id, matching the X-Omp-Harness header the router records.
|
|
38
|
-
//
|
|
39
|
-
|
|
39
|
+
// In remote (team) mode the team rewrites that header to the member id from
|
|
40
|
+
// the key, so remote.json's userId is the same fact — and the only way to
|
|
41
|
+
// keep OTHER members' task toasts out, because task turns carry their own
|
|
42
|
+
// session id and cannot be scoped by session. Empty ⇒ toast every harness
|
|
43
|
+
// (single-harness default).
|
|
44
|
+
const HARNESS_ID = process.env.OMP_HARNESS_ID ?? readRemoteRouter(routerHome())?.userId ?? "";
|
|
40
45
|
const POLL_MS = 2_000;
|
|
41
46
|
// The toast explains the choice by default: model, tier, cost, why it was picked
|
|
42
47
|
// and what it was handed. `AUTO_MODEL_ROUTER_TOAST=compact` restores the one-liner.
|
|
@@ -47,6 +52,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
47
52
|
|
|
48
53
|
// The newest ledger entry already toasted. Ledger is `created_at_ms DESC`.
|
|
49
54
|
let lastSeenId: string | null = null;
|
|
55
|
+
// Task session → the model the task last toasted. Task turns belong to
|
|
56
|
+
// their own session ids, so this — not the session filter — is what keeps
|
|
57
|
+
// a task's forty same-model dispatches down to one toast, and surfaces the
|
|
58
|
+
// mid-task model change when an escalation switches the model.
|
|
59
|
+
const taskModels = new Map<string, string | null>();
|
|
50
60
|
|
|
51
61
|
pi.on("session_start", (_event, ctx) => {
|
|
52
62
|
// Headless/print/subagent sessions have no UI to toast into; skip the
|
|
@@ -94,7 +104,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
94
104
|
const entries = body.entries;
|
|
95
105
|
if (!Array.isArray(entries) || entries.length === 0) return;
|
|
96
106
|
|
|
97
|
-
for (const t of selectToasts(entries, lastSeenId, HARNESS_ID, sessionId, VERBOSE)) {
|
|
107
|
+
for (const t of selectToasts(entries, lastSeenId, HARNESS_ID, sessionId, VERBOSE, taskModels)) {
|
|
98
108
|
ctx.ui.notify(t.text, "info");
|
|
99
109
|
}
|
|
100
110
|
lastSeenId = newestId(entries) ?? lastSeenId;
|
|
@@ -91,7 +91,7 @@ export interface ToastDecision {
|
|
|
91
91
|
/** The router's own decision trail, already written for people. */
|
|
92
92
|
reasons?: string[];
|
|
93
93
|
/** Classifier inputs; only a few are worth surfacing. */
|
|
94
|
-
features?: { promptTokens?: number; toolCount?: number; turnDepth?: number; isToolResultContinuation?: boolean } | null;
|
|
94
|
+
features?: { promptTokens?: number; toolCount?: number; turnDepth?: number; isToolResultContinuation?: boolean; isSubagent?: boolean } | null;
|
|
95
95
|
/** Attempt index within the turn; >0 means this served after an escalation. */
|
|
96
96
|
attempt?: number;
|
|
97
97
|
/** Prompt tokens compaction removed before dispatch. */
|
|
@@ -172,6 +172,7 @@ const tokens = (n: number): string => (n >= 1000 ? `${Math.round(n / 100) / 10}k
|
|
|
172
172
|
export function factsOf(d: ToastDecision): string[] {
|
|
173
173
|
const out: string[] = [];
|
|
174
174
|
const f = d.features ?? undefined;
|
|
175
|
+
if (f?.isSubagent === true) out.push("task");
|
|
175
176
|
if (f?.promptTokens !== undefined && f.promptTokens > 0) out.push(`${tokens(f.promptTokens)} prompt`);
|
|
176
177
|
if (d.promptTokensSaved !== undefined && d.promptTokensSaved > 0) out.push(`${tokens(d.promptTokensSaved)} compacted`);
|
|
177
178
|
if (f?.toolCount !== undefined && f.toolCount > 0) out.push(`${f.toolCount} tools`);
|
|
@@ -215,6 +216,14 @@ export function toToastText(d: ToastDecision, verbose = true): string {
|
|
|
215
216
|
*
|
|
216
217
|
* When `harnessId` is non-empty, only entries from that harness are toasted,
|
|
217
218
|
* so multiple harnesses sharing one router don't spam each other's toasts.
|
|
219
|
+
*
|
|
220
|
+
* `ompSessionId` scopes the toast to this interactive session's own turns.
|
|
221
|
+
* Subagent (task) turns carry their OWN session id — the subagent process's —
|
|
222
|
+
* so they never match it and would otherwise be invisible. They are admitted
|
|
223
|
+
* whenever the harness matches, deduplicated per task session on the served
|
|
224
|
+
* model through `subModels` (the caller holds the map across ticks): the
|
|
225
|
+
* first dispatch of a task toasts, a mid-task model change (an escalation)
|
|
226
|
+
* toasts again, and the forty same-model dispatches after it toast nothing.
|
|
218
227
|
*/
|
|
219
228
|
export function selectToasts(
|
|
220
229
|
entries: ToastDecision[],
|
|
@@ -222,6 +231,7 @@ export function selectToasts(
|
|
|
222
231
|
harnessId = "",
|
|
223
232
|
ompSessionId = "",
|
|
224
233
|
verbose = true,
|
|
234
|
+
subModels?: Map<string, string | null>,
|
|
225
235
|
): ToastMessage[] {
|
|
226
236
|
if (lastSeenId === null) return [];
|
|
227
237
|
// `entries` is newest-first. Entries strictly newer than lastSeenId are the
|
|
@@ -235,7 +245,15 @@ export function selectToasts(
|
|
|
235
245
|
if (d === undefined) continue;
|
|
236
246
|
if (d.wasted) continue;
|
|
237
247
|
if (harnessId !== "" && d.harnessId !== harnessId) continue;
|
|
238
|
-
|
|
248
|
+
const isTask = d.features?.isSubagent === true;
|
|
249
|
+
if (!isTask && ompSessionId !== "" && d.ompSessionId !== ompSessionId) continue;
|
|
250
|
+
if (isTask && subModels !== undefined) {
|
|
251
|
+
const session = d.ompSessionId ?? "";
|
|
252
|
+
const model = d.servedSlug ?? d.slug;
|
|
253
|
+
// Same task, same model as last toasted: not a change, not news.
|
|
254
|
+
if (subModels.get(session) === model) continue;
|
|
255
|
+
subModels.set(session, model);
|
|
256
|
+
}
|
|
239
257
|
out.push({ model: d.servedSlug ?? d.slug, tier: d.tier, costUsd: d.reportedUsd, text: toToastText(d, verbose) });
|
|
240
258
|
}
|
|
241
259
|
return out;
|
package/package.json
CHANGED
package/src/catalog/composite.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import type { OllamaAvailability } from "../upstream/ollama.ts";
|
|
23
23
|
import { effectiveOllamaBias, NO_USAGE, type OllamaUsageSource } from "../upstream/ollama-usage.ts";
|
|
24
|
+
import { openRouterServing, type OpenRouterCredits } from "../upstream/openrouter-usage.ts";
|
|
24
25
|
import { mergeSnapshots, type OllamaCatalogSource } from "./ollama-catalog.ts";
|
|
25
26
|
import type { CatalogModel, CatalogShrink, CatalogSnapshot, CatalogSource } from "./types.ts";
|
|
26
27
|
|
|
@@ -34,6 +35,10 @@ export interface CompositeBias {
|
|
|
34
35
|
live?: () => { costBias: number; biasUntilUsage: number };
|
|
35
36
|
/** False when OpenRouter cannot dispatch (no key): its models are listed for metadata only, never served. Default true. */
|
|
36
37
|
serveOpenRouter?: () => boolean;
|
|
38
|
+
/** Last known OpenRouter credit balance (USD), or null when never fetched. Read every combine, no network. */
|
|
39
|
+
openRouterCredits?: () => OpenRouterCredits | null;
|
|
40
|
+
/** Balance at or below which OpenRouter stops serving. 0 disables the gate. */
|
|
41
|
+
minCreditsUsd?: number;
|
|
37
42
|
/** Named upstreams' models, built from the OpenRouter models (twins) and filtered by each upstream's breaker. */
|
|
38
43
|
named?: {
|
|
39
44
|
models(openrouter: readonly CatalogModel[]): readonly CatalogModel[];
|
|
@@ -69,7 +74,7 @@ export function createCompositeCatalog(
|
|
|
69
74
|
|
|
70
75
|
function combine(base: CatalogSnapshot, models: readonly CatalogModel[]): CatalogSnapshot {
|
|
71
76
|
const available = availability.available();
|
|
72
|
-
const serveBase = bias.serveOpenRouter?.() ?? true;
|
|
77
|
+
const serveBase = (bias.serveOpenRouter?.() ?? true) && openRouterServing(bias.openRouterCredits?.() ?? null, bias.minCreditsUsd ?? 0);
|
|
73
78
|
const providerBias = currentBias();
|
|
74
79
|
// Named upstreams: every enabled entry's models, minus those of an upstream in cooldown.
|
|
75
80
|
const namedAll = bias.named?.models(base.models) ?? NO_NAMED;
|
package/src/cli/config-wizard.ts
CHANGED
|
@@ -128,6 +128,8 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
|
|
|
128
128
|
{ path: "openrouter.timeoutMs", label: "Request timeout", kind: "number", min: 1, hint: "ms" },
|
|
129
129
|
{ path: "openrouter.catalogTtlMs", label: "Catalog TTL", kind: "number", min: 1, hint: "ms" },
|
|
130
130
|
{ path: "openrouter.catalogRefreshMs", label: "Catalog refresh", kind: "number", min: 0, hint: "ms, 0=off" },
|
|
131
|
+
{ path: "openrouter.minCreditsUsd", label: "Credit floor USD", kind: "number", min: 0, hint: "0=serve regardless of balance" },
|
|
132
|
+
{ path: "openrouter.usagePollMs", label: "Credits poll", kind: "number", min: 0, hint: "ms, 0=off" },
|
|
131
133
|
],
|
|
132
134
|
},
|
|
133
135
|
{
|
package/src/config/defaults.ts
CHANGED
|
@@ -32,6 +32,11 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
32
32
|
// Refetch the key-scoped catalog every 5 minutes in the background so
|
|
33
33
|
// guardrail changes are picked up without waiting for traffic + TTL.
|
|
34
34
|
catalogRefreshMs: 5 * 60 * 1000,
|
|
35
|
+
// Stop serving OpenRouter at/below this balance (USD); top up to rejoin.
|
|
36
|
+
// 0 disables the gate. Mirrors ollama.blockBelowUsage's intent.
|
|
37
|
+
minCreditsUsd: 5,
|
|
38
|
+
// Balance moves slowly; 10 minutes matches the Ollama usage poll.
|
|
39
|
+
usagePollMs: 10 * 60 * 1000,
|
|
35
40
|
},
|
|
36
41
|
ollama: {
|
|
37
42
|
// Off: a second upstream changes what every turn can route to.
|
package/src/config/schema.ts
CHANGED
|
@@ -33,6 +33,8 @@ const openrouter = z.strictObject({
|
|
|
33
33
|
timeoutMs: z.number().positive().optional(),
|
|
34
34
|
catalogTtlMs: z.number().positive().optional(),
|
|
35
35
|
catalogRefreshMs: z.number().nonnegative().optional(),
|
|
36
|
+
minCreditsUsd: z.number().nonnegative().optional(),
|
|
37
|
+
usagePollMs: z.number().nonnegative().optional(),
|
|
36
38
|
});
|
|
37
39
|
|
|
38
40
|
const ollamaRate = z.strictObject({
|
package/src/config/types.ts
CHANGED
|
@@ -70,6 +70,15 @@ export interface OpenRouterConfig {
|
|
|
70
70
|
catalogTtlMs: number;
|
|
71
71
|
/** Background catalog refresh cadence, ms. 0 disables the periodic refresh. */
|
|
72
72
|
catalogRefreshMs: number;
|
|
73
|
+
/**
|
|
74
|
+
* Balance (USD, `total_credits − total_usage`) at or below which OpenRouter
|
|
75
|
+
* stops serving: its models drop from the catalog until the account is
|
|
76
|
+
* topped up. 0 disables the gate — the 402 breaker still catches the real
|
|
77
|
+
* thing. Default 5.
|
|
78
|
+
*/
|
|
79
|
+
minCreditsUsd: number;
|
|
80
|
+
/** Credits poll interval, ms. 0 disables the poll (and the gate never fires). */
|
|
81
|
+
usagePollMs: number;
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
/**
|
package/src/server/providers.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { createOllamaClient, type OllamaClient } from "../upstream/ollama.ts";
|
|
|
19
19
|
import { setKnownUpstreamIds } from "../cost/report.ts";
|
|
20
20
|
import { createOllamaUsageSource, type OllamaUsageSource } from "../upstream/ollama-usage.ts";
|
|
21
21
|
import { createOpenRouterClient } from "../upstream/openrouter.ts";
|
|
22
|
+
import { createOpenRouterUsageSource } from "../upstream/openrouter-usage.ts";
|
|
22
23
|
import type { UpstreamClient } from "../upstream/types.ts";
|
|
23
24
|
import { createLogger, type Logger } from "../util/log.ts";
|
|
24
25
|
|
|
@@ -75,6 +76,15 @@ export function createProviders(
|
|
|
75
76
|
// estimate can be scaled to what ollama.com actually bills.
|
|
76
77
|
calibration: { db: sqlDb, ledgerUsd: ollamaLedgerUsd, planCreditsOverrideUsd: cfg.ollama.planCreditsUsd },
|
|
77
78
|
});
|
|
79
|
+
// OpenRouter's credit balance on the same slow cadence: at/below
|
|
80
|
+
// `openrouter.minCreditsUsd` its models drop from the catalog until the
|
|
81
|
+
// account is topped up (a 402 trip only blocks for the rate-limit minute).
|
|
82
|
+
const openrouterUsage = createOpenRouterUsageSource({
|
|
83
|
+
apiKey: () => cfg.openrouter.apiKey,
|
|
84
|
+
pollMs: cfg.openrouter.usagePollMs,
|
|
85
|
+
timeoutMs: 15_000,
|
|
86
|
+
log,
|
|
87
|
+
});
|
|
78
88
|
// Named upstreams (OpenAI, Azure, Anthropic, vLLM…): a client per id, built when
|
|
79
89
|
// first needed and kept — its breaker state must survive config reloads — while
|
|
80
90
|
// the entry it reads is looked up live, so a changed key or URL applies at once.
|
|
@@ -105,6 +115,8 @@ export function createProviders(
|
|
|
105
115
|
usage: ollamaUsage,
|
|
106
116
|
live: () => ({ costBias: cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage }),
|
|
107
117
|
serveOpenRouter: () => cfg.openrouter.apiKey !== "",
|
|
118
|
+
openRouterCredits: () => openrouterUsage.peek(),
|
|
119
|
+
minCreditsUsd: cfg.openrouter.minCreditsUsd,
|
|
108
120
|
named: { models: (base) => staticCatalog.get(base), serving: namedServingOne, bias: (id) => cfg.upstreams.find((u) => u.id === id)?.costBias ?? 1 },
|
|
109
121
|
}),
|
|
110
122
|
ollama,
|
|
@@ -340,6 +340,7 @@ export function classifyAnthropicStatus(id: string, status: number, body: unknow
|
|
|
340
340
|
const message = typeof msg === "string" && msg !== "" ? msg : `${id} HTTP ${status}`;
|
|
341
341
|
const fail = (kind: UpstreamErrorKind, retryable: boolean): UpstreamError => new UpstreamError(kind, status, message, retryable, body);
|
|
342
342
|
if (status === 401 || status === 403) return fail("auth", false);
|
|
343
|
+
if (status === 402) return fail("quota", true);
|
|
343
344
|
if (status === 404) return fail("model_unavailable", true);
|
|
344
345
|
if (status === 413) return fail("context_length", false);
|
|
345
346
|
if (status === 429) return fail("rate_limit", true);
|
|
@@ -431,7 +432,7 @@ export function createAnthropicClient(cfg: RouterConfig, id: string, fetchImpl:
|
|
|
431
432
|
/* status alone */
|
|
432
433
|
}
|
|
433
434
|
const err = classifyAnthropicStatus(id, res.status, body);
|
|
434
|
-
if (err.kind === "rate_limit" || res.status === 529) breaker.trip(err);
|
|
435
|
+
if (err.kind === "rate_limit" || err.kind === "quota" || res.status === 529) breaker.trip(err);
|
|
435
436
|
return err;
|
|
436
437
|
}
|
|
437
438
|
|
package/src/upstream/compat.ts
CHANGED
|
@@ -94,8 +94,10 @@ export function classifyCompatStatus(id: string, status: number, body: unknown):
|
|
|
94
94
|
if (status === 401) return fail("auth", false);
|
|
95
95
|
if (status === 402) return fail("quota", true);
|
|
96
96
|
if (status === 403) return /credit|quota|plan|limit|billing/i.test(message) ? fail("quota", true) : fail("moderation", true);
|
|
97
|
-
// OpenAI reports an exhausted balance as a 429 with insufficient_quota
|
|
98
|
-
|
|
97
|
+
// OpenAI reports an exhausted balance as a 429 with insufficient_quota; Kimi
|
|
98
|
+
// ("exceed your available credits given your current in-flight requests") says
|
|
99
|
+
// credits. Both are the account, not the moment.
|
|
100
|
+
if (status === 429) return /insufficient_quota|exceeded your current quota|available credits|in-flight requests/i.test(`${code} ${message}`) ? fail("quota", true) : fail("rate_limit", true);
|
|
99
101
|
if (status === 404) return fail("model_unavailable", true);
|
|
100
102
|
if (status === 400 || status === 413 || status === 422) {
|
|
101
103
|
if (/context|too many tokens|token limit|maximum context|too long/i.test(message)) return fail("context_length", false);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouter credit balance, polled on a slow interval and read without
|
|
3
|
+
* network on every routing decision (`GET /api/v1/credits`: management-key
|
|
4
|
+
* scope, `data.total_credits - data.total_usage` = the balance that gates a
|
|
5
|
+
* dispatch — OpenRouter reserves credits per in-flight request, so the
|
|
6
|
+
* UNRESERVED balance is what a 402 means).
|
|
7
|
+
*
|
|
8
|
+
* Mirrors the Ollama usage source: a key that is empty now may be set from
|
|
9
|
+
* the dashboard later, so the reader stays live and idles until it is not.
|
|
10
|
+
* A failed or unparseable poll keeps the last reading — the balance moves
|
|
11
|
+
* slowly, and hiding a provider on a poll glitch would route around it for
|
|
12
|
+
* nothing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Logger } from "../util/log.ts";
|
|
16
|
+
|
|
17
|
+
/** Minimal fetch surface, injectable for tests. */
|
|
18
|
+
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
19
|
+
|
|
20
|
+
export interface OpenRouterCredits {
|
|
21
|
+
/** Balance in USD: total_credits − total_usage. Null when the payload lacks it. */
|
|
22
|
+
remainingUsd: number | null;
|
|
23
|
+
/** Lifetime credits bought and used, for the dashboard. */
|
|
24
|
+
totalCreditsUsd: number | null;
|
|
25
|
+
totalUsageUsd: number | null;
|
|
26
|
+
fetchedAtMs: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OpenRouterUsageSource {
|
|
30
|
+
/** Latest reading, refreshed when older than the poll interval; last good value on failure. */
|
|
31
|
+
get(): Promise<OpenRouterCredits | null>;
|
|
32
|
+
/** Last fetched value without touching the network. */
|
|
33
|
+
peek(): OpenRouterCredits | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const NO_OPENROUTER_USAGE: OpenRouterUsageSource = { get: async () => null, peek: () => null };
|
|
37
|
+
|
|
38
|
+
function parseCredits(json: unknown): OpenRouterCredits | null {
|
|
39
|
+
if (typeof json !== "object" || json === null || !("data" in json)) return null;
|
|
40
|
+
const data: unknown = json.data;
|
|
41
|
+
if (typeof data !== "object" || data === null) return null;
|
|
42
|
+
const d = data as Record<string, unknown>; // narrowed above; cast names the wire shape once
|
|
43
|
+
const total = typeof d.total_credits === "number" ? d.total_credits : null;
|
|
44
|
+
const used = typeof d.total_usage === "number" ? d.total_usage : null;
|
|
45
|
+
if (total === null || used === null) return null;
|
|
46
|
+
return { remainingUsd: total - used, totalCreditsUsd: total, totalUsageUsd: used, fetchedAtMs: Date.now() };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createOpenRouterUsageSource(opts: {
|
|
50
|
+
apiKey: () => string;
|
|
51
|
+
pollMs: number;
|
|
52
|
+
timeoutMs: number;
|
|
53
|
+
log: Logger;
|
|
54
|
+
fetchImpl?: FetchLike;
|
|
55
|
+
root?: string;
|
|
56
|
+
}): OpenRouterUsageSource {
|
|
57
|
+
if (opts.pollMs <= 0) return NO_OPENROUTER_USAGE;
|
|
58
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
59
|
+
const root = (opts.root ?? "https://openrouter.ai/api/v1").replace(/\/+$/, "");
|
|
60
|
+
let current: OpenRouterCredits | null = null;
|
|
61
|
+
let checkedAtMs = 0;
|
|
62
|
+
let inflight: Promise<OpenRouterCredits | null> | null = null;
|
|
63
|
+
let warned = false;
|
|
64
|
+
|
|
65
|
+
async function refresh(): Promise<OpenRouterCredits | null> {
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetchImpl(`${root}/credits`, {
|
|
68
|
+
headers: { authorization: `Bearer ${opts.apiKey()}` },
|
|
69
|
+
signal: AbortSignal.timeout(opts.timeoutMs),
|
|
70
|
+
});
|
|
71
|
+
if (res.ok) {
|
|
72
|
+
const parsed = parseCredits(await res.json());
|
|
73
|
+
if (parsed !== null) {
|
|
74
|
+
current = parsed;
|
|
75
|
+
warned = false;
|
|
76
|
+
} else if (!warned) {
|
|
77
|
+
warned = true;
|
|
78
|
+
opts.log.warn("openrouter credits payload had no recognisable fields; credit gate keeps its last reading");
|
|
79
|
+
}
|
|
80
|
+
} else if (!warned) {
|
|
81
|
+
warned = true;
|
|
82
|
+
opts.log.warn("openrouter credits endpoint unavailable; credit gate keeps its last reading", { status: res.status });
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (!warned) {
|
|
86
|
+
warned = true;
|
|
87
|
+
opts.log.warn("openrouter credits fetch failed; credit gate keeps its last reading", {
|
|
88
|
+
error: err instanceof Error ? err.message : String(err),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
checkedAtMs = Date.now();
|
|
93
|
+
return current;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
async get() {
|
|
98
|
+
if (opts.apiKey() === "") return null;
|
|
99
|
+
if (Date.now() - checkedAtMs < opts.pollMs) return current;
|
|
100
|
+
inflight ??= refresh().finally(() => {
|
|
101
|
+
inflight = null;
|
|
102
|
+
});
|
|
103
|
+
return inflight;
|
|
104
|
+
},
|
|
105
|
+
peek: () => current,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* True while OpenRouter may serve: the balance is strictly above the floor, or
|
|
111
|
+
* unknown (a poll that has not landed yet, no key for the endpoint, a payload
|
|
112
|
+
* change). Hiding on unknown would take a provider down for a dashboard's
|
|
113
|
+
* missing field — fail open instead; the 402 breaker still catches the real
|
|
114
|
+
* thing.
|
|
115
|
+
*/
|
|
116
|
+
export function openRouterServing(credits: OpenRouterCredits | null, minCreditsUsd: number): boolean {
|
|
117
|
+
if (minCreditsUsd <= 0) return true;
|
|
118
|
+
if (credits === null || credits.remainingUsd === null) return true;
|
|
119
|
+
return credits.remainingUsd > minCreditsUsd;
|
|
120
|
+
}
|
|
@@ -55,7 +55,12 @@ function classifyStatus(status: number, body: unknown): UpstreamError {
|
|
|
55
55
|
// retrying is pointless.
|
|
56
56
|
if (status === 401) return fail("auth", false);
|
|
57
57
|
// 402 = out of credits; retrying changes nothing, only topping up does.
|
|
58
|
-
|
|
58
|
+
// EXCEPT the concurrency shape: OpenRouter reserves credits for in-flight
|
|
59
|
+
// requests, so a burst can exhaust the UNRESERVED balance on an account
|
|
60
|
+
// with plenty left ("would exceed your available credits given your
|
|
61
|
+
// current in-flight requests"). That clears when the streams settle —
|
|
62
|
+
// a moment, not an account state — so fail over like a rate limit.
|
|
63
|
+
if (status === 402) return /in-flight requests/i.test(message) ? fail("rate_limit", true) : fail("auth", false);
|
|
59
64
|
// 403 = provider content-moderation or per-model policy gate (prompt-injection
|
|
60
65
|
// block, age/data-policy confirmation). This indicts the model/provider, NOT
|
|
61
66
|
// the key: siblings routinely serve the same content. Retryable so the turn
|
package/test/failover.test.ts
CHANGED
|
@@ -31,7 +31,7 @@ import type {
|
|
|
31
31
|
function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
32
32
|
return {
|
|
33
33
|
server: { host: "127.0.0.1", port: 8787, maxConcurrentTurns: 24, subagentProfile: "auto-sub" },
|
|
34
|
-
openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0 },
|
|
34
|
+
openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0, minCreditsUsd: 0, usagePollMs: 0 },
|
|
35
35
|
ollama: { enabled: false, baseUrl: "http://127.0.0.1:11434/v1", apiKey: "", timeoutMs: 30_000, catalogTtlMs: 300_000, includeLocal: false, prices: {}, twins: {}, costBias: 1, biasUntilUsage: 0.9, usagePollMs: 0, quotaCooldownMs: 0, rateLimitCooldownMs: 0, planCreditsUsd: 0 },
|
|
36
36
|
upstreams: [],
|
|
37
37
|
benchmarks: { enabled: false, artificialAnalysisApiKey: "", benchlm: true, refreshMs: 86_400_000, timeoutMs: 30_000, useLocalScores: false },
|
|
@@ -380,6 +380,33 @@ describe("same-tier failover", () => {
|
|
|
380
380
|
expect(finishes[0]!.attempts).toBe(2);
|
|
381
381
|
});
|
|
382
382
|
|
|
383
|
+
test("a Kimi credit 429 fails over to another provider's model in the SAME turn", async () => {
|
|
384
|
+
// Moonshot's balance wording misclassified as rate_limit used to let the
|
|
385
|
+
// raw 429 (Retry-After 30 min) reach the client. Quota is retryable
|
|
386
|
+
// elsewhere: the retry re-routes with the failed slug excluded.
|
|
387
|
+
const { router, calls } = mkRouter([mkDecision("moderate", "kimi/kimi-k3"), mkDecision("moderate", "openrouter/grok-4")]);
|
|
388
|
+
const { upstream, calls: dispatches } = mkUpstream([
|
|
389
|
+
{ kind: "fail", error: new UpstreamError("quota", 429, "This request would exceed your available credits given your current in-flight requests", true) },
|
|
390
|
+
{ kind: "chunks", chunks: okChunks("openrouter/grok-4") },
|
|
391
|
+
]);
|
|
392
|
+
const { ledger, entries } = mkLedger();
|
|
393
|
+
const { store } = mkConversations();
|
|
394
|
+
const { sink, errors, finishes } = mkSink();
|
|
395
|
+
|
|
396
|
+
await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
|
|
397
|
+
|
|
398
|
+
expect(errors).toHaveLength(0);
|
|
399
|
+
expect(finishes).toHaveLength(1);
|
|
400
|
+
expect(calls).toHaveLength(2);
|
|
401
|
+
expect(calls[1]).toEqual({ attempt: 1, excludeSlugs: ["kimi/kimi-k3"] });
|
|
402
|
+
expect(dispatches.map((d) => d.body.model)).toEqual(["kimi/kimi-k3", "openrouter/grok-4"]);
|
|
403
|
+
expect(entries[0]!.error).toContain("available credits");
|
|
404
|
+
expect(entries[0]!.wasted).toBe(true);
|
|
405
|
+
expect(entries[1]!.wasted).toBe(false);
|
|
406
|
+
expect(finishes[0]!.servedSlug).toBe("openrouter/grok-4");
|
|
407
|
+
expect(finishes[0]!.escalated).toBe(false);
|
|
408
|
+
});
|
|
409
|
+
|
|
383
410
|
test("a 403 moderation block fails over to a different model in the same tier", async () => {
|
|
384
411
|
const { router, calls } = mkRouter([mkDecision("trivial", "a/model"), mkDecision("trivial", "b/model")]);
|
|
385
412
|
const { upstream, calls: dispatches } = mkUpstream([
|
|
@@ -664,6 +691,7 @@ describe("same-tier failover", () => {
|
|
|
664
691
|
|
|
665
692
|
});
|
|
666
693
|
|
|
694
|
+
|
|
667
695
|
describe("400 classification (review 2026-09-05 follow-up)", () => {
|
|
668
696
|
test("a 400 naming a model capability limit is retryable, so failover picks a sibling", async () => {
|
|
669
697
|
const e = classifyUpstreamStatus(400, { error: { message: "This model only supports single tool-calls at once!" } });
|
|
@@ -677,6 +705,19 @@ describe("400 classification (review 2026-09-05 follow-up)", () => {
|
|
|
677
705
|
expect(e.retryable).toBe(false);
|
|
678
706
|
});
|
|
679
707
|
|
|
708
|
+
test("an OpenRouter 402 naming in-flight requests is a concurrency throttle, retryable like a rate limit", async () => {
|
|
709
|
+
// OpenRouter reserves credits per in-flight request: a burst can exhaust
|
|
710
|
+
// the UNRESERVED balance on an account with plenty left. It clears when
|
|
711
|
+
// the streams settle — fail over, never surface to the client.
|
|
712
|
+
const e = classifyUpstreamStatus(402, { error: { message: "This request would exceed your available credits given your current in-flight requests. Retry after in-flight requests settle, or add credits." } });
|
|
713
|
+
expect(e.kind).toBe("rate_limit");
|
|
714
|
+
expect(e.retryable).toBe(true);
|
|
715
|
+
// A plain 402 (balance actually gone) stays final.
|
|
716
|
+
const broke = classifyUpstreamStatus(402, { error: { message: "Insufficient credits" } });
|
|
717
|
+
expect(broke.kind).toBe("auth");
|
|
718
|
+
expect(broke.retryable).toBe(false);
|
|
719
|
+
});
|
|
720
|
+
|
|
680
721
|
test("a 400 for context overflow is still context_length", async () => {
|
|
681
722
|
const e = classifyUpstreamStatus(400, { error: { message: "This endpoint's maximum context length is 131072 tokens" } });
|
|
682
723
|
expect(e.kind).toBe("context_length");
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createOpenRouterUsageSource, NO_OPENROUTER_USAGE, openRouterServing, type OpenRouterCredits } from "../src/upstream/openrouter-usage.ts";
|
|
3
|
+
import { createLogger } from "../src/util/log.ts";
|
|
4
|
+
|
|
5
|
+
const log = createLogger("error");
|
|
6
|
+
|
|
7
|
+
function credits(remaining: number | null): OpenRouterCredits | null {
|
|
8
|
+
return remaining === null ? null : { remainingUsd: remaining, totalCreditsUsd: 100, totalUsageUsd: 100 - remaining, fetchedAtMs: 1 };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("openRouterServing", () => {
|
|
12
|
+
test("serves above the floor and stops at/below it", () => {
|
|
13
|
+
expect(openRouterServing(credits(5.01), 5)).toBe(true);
|
|
14
|
+
expect(openRouterServing(credits(5), 5)).toBe(false);
|
|
15
|
+
expect(openRouterServing(credits(0), 5)).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("fails open on unknown balance and when the gate is off", () => {
|
|
19
|
+
expect(openRouterServing(null, 5)).toBe(true);
|
|
20
|
+
expect(openRouterServing(credits(0), 0)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("createOpenRouterUsageSource", () => {
|
|
25
|
+
test("returns null without a key and parses the credits payload", async () => {
|
|
26
|
+
const unkeyed = createOpenRouterUsageSource({ apiKey: () => "", pollMs: 1000, timeoutMs: 100, log, root: "https://x/v1" });
|
|
27
|
+
expect(await unkeyed.get()).toBe(null);
|
|
28
|
+
expect(NO_OPENROUTER_USAGE.peek()).toBe(null);
|
|
29
|
+
|
|
30
|
+
let calls = 0;
|
|
31
|
+
const src = createOpenRouterUsageSource({
|
|
32
|
+
apiKey: () => "k",
|
|
33
|
+
pollMs: 1000,
|
|
34
|
+
timeoutMs: 100,
|
|
35
|
+
log,
|
|
36
|
+
root: "https://x/v1",
|
|
37
|
+
fetchImpl: async () => {
|
|
38
|
+
calls++;
|
|
39
|
+
return new Response(JSON.stringify({ data: { total_credits: 20, total_usage: 13.5 } }), { status: 200 });
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const v = await src.get();
|
|
43
|
+
expect(calls).toBe(1);
|
|
44
|
+
expect(v?.remainingUsd).toBe(6.5);
|
|
45
|
+
expect(src.peek()?.totalUsageUsd).toBe(13.5);
|
|
46
|
+
|
|
47
|
+
// A failed poll keeps the last good reading instead of hiding the provider.
|
|
48
|
+
const failing = createOpenRouterUsageSource({
|
|
49
|
+
apiKey: () => "k",
|
|
50
|
+
pollMs: 0,
|
|
51
|
+
timeoutMs: 100,
|
|
52
|
+
log,
|
|
53
|
+
root: "https://x/v1",
|
|
54
|
+
fetchImpl: async () => new Response("nope", { status: 500 }),
|
|
55
|
+
});
|
|
56
|
+
await failing.get();
|
|
57
|
+
expect(failing.peek()).toBe(null); // never had a reading; gate stays open on unknown
|
|
58
|
+
});
|
|
59
|
+
});
|
package/test/toast-logic.test.ts
CHANGED
|
@@ -176,6 +176,61 @@ describe("selectToasts", () => {
|
|
|
176
176
|
expect(toasts).toHaveLength(1);
|
|
177
177
|
expect(toasts[0]?.model).toBe("keep");
|
|
178
178
|
});
|
|
179
|
+
|
|
180
|
+
test("task (subagent) turns toast even though they carry their own session id", async () => {
|
|
181
|
+
// A task's dispatches carry the subagent's own session id and the
|
|
182
|
+
// isSubagent flag, so the session filter must not drop them.
|
|
183
|
+
const entries = [
|
|
184
|
+
dec({ id: "d2", slug: "x/task-model", ompSessionId: "task-1", features: { isSubagent: true } }),
|
|
185
|
+
dec({ id: "d1", slug: "prior", ompSessionId: "sess-a" }),
|
|
186
|
+
];
|
|
187
|
+
const subModels = new Map<string, string | null>();
|
|
188
|
+
const toasts = selectToasts(entries, "d1", "", "sess-a", true, subModels);
|
|
189
|
+
expect(toasts).toHaveLength(1);
|
|
190
|
+
expect(toasts[0]?.model).toBe("x/task-model");
|
|
191
|
+
expect(toasts[0]!.text).toContain("task");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("a task toasts once per model: the first dispatch and every change after", async () => {
|
|
195
|
+
const entries = [
|
|
196
|
+
dec({ id: "d5", slug: "x/m2", ompSessionId: "task-1", features: { isSubagent: true } }),
|
|
197
|
+
dec({ id: "d4", slug: "x/m1", ompSessionId: "task-1", features: { isSubagent: true } }),
|
|
198
|
+
dec({ id: "d3", slug: "x/m1", ompSessionId: "task-1", features: { isSubagent: true } }),
|
|
199
|
+
dec({ id: "d2", slug: "x/m1", ompSessionId: "task-1", features: { isSubagent: true } }),
|
|
200
|
+
];
|
|
201
|
+
const subModels = new Map<string, string | null>();
|
|
202
|
+
const first = selectToasts(entries, "d1", "", "sess-a", true, subModels);
|
|
203
|
+
// oldest→newest: m1 (first dispatch toasts), m1 (skip), m1 (skip), m2 (change, toasts)
|
|
204
|
+
expect(first.map((t) => t.model)).toEqual(["x/m1", "x/m2"]);
|
|
205
|
+
// The next tick remembers the task's last model: the same model again is not news.
|
|
206
|
+
const again = selectToasts([dec({ id: "d6", slug: "x/m2", ompSessionId: "task-1", features: { isSubagent: true } })], "d5", "", "sess-a", true, subModels);
|
|
207
|
+
expect(again).toHaveLength(0);
|
|
208
|
+
// A second task with its own session id toasts its own first dispatch.
|
|
209
|
+
const other = selectToasts([dec({ id: "d7", slug: "x/m3", ompSessionId: "task-2", features: { isSubagent: true } })], "d6", "", "sess-a", true, subModels);
|
|
210
|
+
expect(other).toHaveLength(1);
|
|
211
|
+
expect(other[0]?.model).toBe("x/m3");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("task toasts respect the harness filter: another member's tasks stay out", async () => {
|
|
215
|
+
const entries = [
|
|
216
|
+
dec({ id: "d2", slug: "theirs", ompSessionId: "task-9", harnessId: "member-b", features: { isSubagent: true } }),
|
|
217
|
+
dec({ id: "d1", slug: "mine", ompSessionId: "task-8", harnessId: "member-a", features: { isSubagent: true } }),
|
|
218
|
+
dec({ id: "d0", slug: "prior", ompSessionId: "sess-a", harnessId: "member-a" }),
|
|
219
|
+
];
|
|
220
|
+
const subModels = new Map<string, string | null>();
|
|
221
|
+
const toasts = selectToasts(entries, "d0", "member-a", "sess-a", true, subModels);
|
|
222
|
+
expect(toasts).toHaveLength(1);
|
|
223
|
+
expect(toasts[0]?.model).toBe("mine");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("a main-session turn still toasts every dispatch, unchanged", async () => {
|
|
227
|
+
const entries = [
|
|
228
|
+
dec({ id: "d3", slug: "x/m1", ompSessionId: "sess-a" }),
|
|
229
|
+
dec({ id: "d2", slug: "x/m1", ompSessionId: "sess-a" }),
|
|
230
|
+
dec({ id: "d1", slug: "prior", ompSessionId: "sess-a" }),
|
|
231
|
+
];
|
|
232
|
+
expect(selectToasts(entries, "d1", "", "sess-a", true)).toHaveLength(2);
|
|
233
|
+
});
|
|
179
234
|
});
|
|
180
235
|
|
|
181
236
|
describe("toToastText", () => {
|
package/test/turn.test.ts
CHANGED
|
@@ -34,7 +34,7 @@ import type {
|
|
|
34
34
|
function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
35
35
|
return {
|
|
36
36
|
server: { host: "127.0.0.1", port: 8787, maxConcurrentTurns: 24, subagentProfile: "auto-sub" },
|
|
37
|
-
openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0 },
|
|
37
|
+
openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0, minCreditsUsd: 0, usagePollMs: 0 },
|
|
38
38
|
ollama: { enabled: false, baseUrl: "http://127.0.0.1:11434/v1", apiKey: "", timeoutMs: 30_000, catalogTtlMs: 300_000, includeLocal: false, prices: {}, twins: {}, costBias: 1, biasUntilUsage: 0.9, usagePollMs: 0, quotaCooldownMs: 0, rateLimitCooldownMs: 0, planCreditsUsd: 0 },
|
|
39
39
|
upstreams: [],
|
|
40
40
|
benchmarks: { enabled: false, artificialAnalysisApiKey: "", benchlm: true, refreshMs: 86_400_000, timeoutMs: 30_000, useLocalScores: false },
|
package/test/upstreams.test.ts
CHANGED
|
@@ -193,6 +193,8 @@ describe("the OpenAI-compatible client", () => {
|
|
|
193
193
|
test("statuses: OpenAI's insufficient_quota 429 is the account, a plain 429 the moment; 400 context is final", async () => {
|
|
194
194
|
expect(classifyCompatStatus("x", 429, { error: { code: "insufficient_quota", message: "You exceeded your current quota" } })).toMatchObject({ kind: "quota", retryable: true });
|
|
195
195
|
expect(classifyCompatStatus("x", 429, { error: { message: "Rate limit reached" } })).toMatchObject({ kind: "rate_limit", retryable: true });
|
|
196
|
+
// Kimi's balance wording: 429 but the account, so the 15-minute quota cooldown hides the whole upstream.
|
|
197
|
+
expect(classifyCompatStatus("kimi", 429, { error: { message: "This request would exceed your available credits given your current in-flight requests" } })).toMatchObject({ kind: "quota", retryable: true });
|
|
196
198
|
expect(classifyCompatStatus("x", 400, { error: { message: "This model's maximum context length is 8192 tokens" } })).toMatchObject({ kind: "context_length", retryable: false });
|
|
197
199
|
expect(classifyCompatStatus("x", 401, {})).toMatchObject({ kind: "auth", retryable: false });
|
|
198
200
|
expect(classifyCompatStatus("x", 503, {})).toMatchObject({ kind: "upstream_error", retryable: true });
|
|
@@ -238,6 +240,18 @@ describe("the OpenAI-compatible client", () => {
|
|
|
238
240
|
expect(caught).toMatchObject({ kind: "quota", retryable: true });
|
|
239
241
|
expect(broke.available()).toBe(false);
|
|
240
242
|
expect(broke.lastTrip()?.kind).toBe("quota");
|
|
243
|
+
const kimiBroke = createCompatClient(cfg, "openai-direct", async () =>
|
|
244
|
+
Response.json({ error: { message: "This request would exceed your available credits given your current in-flight requests" } }, { status: 429 }),
|
|
245
|
+
);
|
|
246
|
+
caught = null;
|
|
247
|
+
try {
|
|
248
|
+
await kimiBroke.dispatch({ body: { model: "openai-direct/gpt-4o", messages: [] }, sessionId: "s", signal: new AbortController().signal });
|
|
249
|
+
} catch (err) {
|
|
250
|
+
caught = err;
|
|
251
|
+
}
|
|
252
|
+
expect(caught).toMatchObject({ kind: "quota", retryable: true });
|
|
253
|
+
expect(kimiBroke.available()).toBe(false);
|
|
254
|
+
expect(kimiBroke.lastTrip()?.kind).toBe("quota");
|
|
241
255
|
// The live key applies to the next call without a new client.
|
|
242
256
|
applyConfigPatch(cfg, { upstreams: [{ id: "openai-direct", kind: "openai", baseUrl: "https://api.openai.com/v1", apiKey: "sk-new", models: [{ id: "gpt-4o", input: 2.5, output: 10 }] }] } as never);
|
|
243
257
|
await client.dispatch({ body: { model: "openai-direct/gpt-4o", messages: [], stream: true }, sessionId: "s", signal: new AbortController().signal });
|
|
@@ -363,8 +377,20 @@ describe("the Anthropic client", () => {
|
|
|
363
377
|
}
|
|
364
378
|
expect(caught).toMatchObject({ kind: "upstream_error", retryable: true });
|
|
365
379
|
expect(overloaded.available()).toBe(false);
|
|
380
|
+
const billedOut = createAnthropicClient(cfg, "anthropic-direct", async () => Response.json({ error: { message: "Your credit balance is too low" } }, { status: 402 }));
|
|
381
|
+
caught = null;
|
|
382
|
+
try {
|
|
383
|
+
await billedOut.dispatch({ body: { model: "anthropic-direct/claude-sonnet-4", messages: [] }, sessionId: "s", signal: new AbortController().signal });
|
|
384
|
+
} catch (err) {
|
|
385
|
+
caught = err;
|
|
386
|
+
}
|
|
387
|
+
expect(caught).toMatchObject({ kind: "quota", retryable: true });
|
|
388
|
+
expect(billedOut.available()).toBe(false);
|
|
389
|
+
expect(billedOut.lastTrip()?.kind).toBe("quota");
|
|
366
390
|
expect(classifyAnthropicStatus("a", 400, { error: { message: "prompt is too long: 250000 tokens" } })).toMatchObject({ kind: "context_length", retryable: false });
|
|
367
391
|
expect(classifyAnthropicStatus("a", 401, {})).toMatchObject({ kind: "auth", retryable: false });
|
|
392
|
+
// The credit card said no (402): the account, not the moment — quota, and the breaker hides the upstream.
|
|
393
|
+
expect(classifyAnthropicStatus("a", 402, { error: { message: "Your credit balance is too low" } })).toMatchObject({ kind: "quota", retryable: true });
|
|
368
394
|
});
|
|
369
395
|
|
|
370
396
|
test("a subscription upstream: OAuth bearer instead of x-api-key, and the Claude Code identity leads the system blocks", async () => {
|