@stage-labs/metro 0.1.0-beta.251 → 0.1.0-beta.252
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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +147 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +39 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +40 -10
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +22 -17
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +47 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +21 -9
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +28 -3
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.252",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -54,6 +54,7 @@ import type { StationName } from '@metro-labs/core/station-names';
|
|
|
54
54
|
import { startUploadReaper } from '../files/upload-store.js';
|
|
55
55
|
import { startAttachReaper } from '../files/attach-reaper.js';
|
|
56
56
|
import { startCloudWatchPublisher } from '../server/cloudwatch.js';
|
|
57
|
+
import { watchFallbackUsage } from '../gateway/usage-refresh.js';
|
|
57
58
|
import { isCallEvent, leaveCallsForShutdown, onCallEvent, sweepLeftoverCalls } from '../voice/calls.js';
|
|
58
59
|
|
|
59
60
|
installCrashGuard();
|
|
@@ -153,6 +154,7 @@ installBearerSessions(agentsDir(), localOwner);
|
|
|
153
154
|
startUploadReaper();
|
|
154
155
|
startAttachReaper();
|
|
155
156
|
startCloudWatchPublisher();
|
|
157
|
+
watchFallbackUsage();
|
|
156
158
|
announceLocalEndpoint();
|
|
157
159
|
tunnel?.start();
|
|
158
160
|
log.info(
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { log } from '@metro-labs/core/log';
|
|
2
|
+
import { lastServed } from './served.js';
|
|
3
|
+
import { PER_MINUTE, usageOf, type UsageWindow } from './usage.js';
|
|
4
|
+
import { bareModel, connectionOf, isSmallModel, notReady, routedConnection, routeLabel, type Connection, type Fallback, type ModelConfig, type Route } from './model-config.js';
|
|
5
|
+
|
|
6
|
+
export const USAGE_LIMIT = 0.95;
|
|
7
|
+
const COOLDOWN_MS = 60_000;
|
|
8
|
+
const SCOPED = 'Weekly, ';
|
|
9
|
+
const MS_PER_SECOND = 1000;
|
|
10
|
+
|
|
11
|
+
export interface Hold {
|
|
12
|
+
reason: string;
|
|
13
|
+
until: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Cooling {
|
|
17
|
+
until: number;
|
|
18
|
+
reason: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const cooling = new Map<string, Cooling>();
|
|
22
|
+
let lastChoice = '';
|
|
23
|
+
|
|
24
|
+
const keyOf = (route: Route): string => `${route.connection.id}\n${route.model}`;
|
|
25
|
+
|
|
26
|
+
export function forgetFallbackState(): void {
|
|
27
|
+
cooling.clear();
|
|
28
|
+
lastChoice = '';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function scopeOf(label: string): string | null {
|
|
32
|
+
if (!label.startsWith(SCOPED)) return null;
|
|
33
|
+
return (label.slice(SCOPED.length).trim().split(/\s+/)[0] ?? '').toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function applies(window: UsageWindow, route: Route, now: number): boolean {
|
|
37
|
+
if (window.used === null || window.label === PER_MINUTE) return false;
|
|
38
|
+
if (window.resetAt !== null && Date.parse(window.resetAt) <= now) return false;
|
|
39
|
+
if (route.connection.provider === 'gemini') return window.label === route.model;
|
|
40
|
+
const scope = scopeOf(window.label);
|
|
41
|
+
return scope === null || route.model.toLowerCase().split(/[^a-z0-9]+/).includes(scope);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function windowsFor(route: Route, now = Date.now()): UsageWindow[] {
|
|
45
|
+
return (usageOf(route.connection.id)?.windows ?? []).filter((w) => applies(w, route, now));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const percent = (used: number): string => `${String(Math.round(used * 100))}%`;
|
|
49
|
+
|
|
50
|
+
export function holdOf(route: Route, now = Date.now()): Hold | null {
|
|
51
|
+
const cool = cooling.get(keyOf(route));
|
|
52
|
+
if (cool !== undefined && cool.until > now) return { reason: cool.reason, until: new Date(cool.until).toISOString() };
|
|
53
|
+
const over = windowsFor(route, now).find((w) => (w.used ?? 0) > USAGE_LIMIT);
|
|
54
|
+
return over === undefined ? null : { reason: `${over.label} ${percent(over.used ?? 0)}`, until: over.resetAt };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fallbackModel(requested: string, conn: Connection, model: string): string {
|
|
58
|
+
const bare = bareModel(requested);
|
|
59
|
+
return conn.provider === 'anthropic' && isSmallModel(bare) ? bare : model;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function fallbackRoute(cfg: ModelConfig, fallback: Fallback, requested: string): Route | null {
|
|
63
|
+
const conn = connectionOf(cfg, fallback.connection);
|
|
64
|
+
if (conn === null || notReady(cfg, conn) !== null) return null;
|
|
65
|
+
return { connection: conn, model: fallbackModel(requested, conn, fallback.model) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const unique = (routes: Route[]): Route[] => routes.filter((route, at) => routes.findIndex((other) => keyOf(other) === keyOf(route)) === at);
|
|
69
|
+
|
|
70
|
+
export const chainOf = (primary: Route, cfg: ModelConfig, requested: string): Route[] =>
|
|
71
|
+
unique([primary, ...(cfg.fallbacks ?? []).flatMap((f) => fallbackRoute(cfg, f, requested) ?? [])]);
|
|
72
|
+
|
|
73
|
+
export function routesToTry(chain: Route[], now = Date.now()): Route[] {
|
|
74
|
+
const open = chain.filter((route) => holdOf(route, now) === null);
|
|
75
|
+
return open.length > 0 ? open : chain.slice(0, 1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function secondsHeader(headers: Headers, name: string): number | null {
|
|
79
|
+
const raw = headers.get(name);
|
|
80
|
+
if (raw === null || raw.trim() === '') return null;
|
|
81
|
+
const value = Number(raw);
|
|
82
|
+
return Number.isFinite(value) && value > 0 ? value : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function coolUntil(headers: Headers, now: number): number {
|
|
86
|
+
const after = secondsHeader(headers, 'retry-after');
|
|
87
|
+
if (after !== null) return now + after * MS_PER_SECOND;
|
|
88
|
+
const reset = secondsHeader(headers, 'anthropic-ratelimit-unified-reset');
|
|
89
|
+
if (reset !== null && reset * MS_PER_SECOND > now) return reset * MS_PER_SECOND;
|
|
90
|
+
return now + COOLDOWN_MS;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function noteRefused(route: Route, status: number, headers: Headers, next: Route | undefined, now = Date.now()): void {
|
|
94
|
+
const reason = `answered ${String(status)}`;
|
|
95
|
+
cooling.set(keyOf(route), { until: coolUntil(headers, now), reason });
|
|
96
|
+
log.warn({ from: routeLabel(route), to: next === undefined ? null : routeLabel(next), status }, 'gateway: the model hit its limit, so the request goes to the next fallback model');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function noteChoice(primary: Route, chosen: Route, now = Date.now()): void {
|
|
100
|
+
const key = keyOf(chosen);
|
|
101
|
+
if (key === lastChoice) return;
|
|
102
|
+
const first = lastChoice === '';
|
|
103
|
+
lastChoice = key;
|
|
104
|
+
if (key === keyOf(primary)) {
|
|
105
|
+
if (!first) log.info({ model: routeLabel(chosen), connection: chosen.connection.label }, 'gateway: back on the first model of the fallback list');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const hold = holdOf(primary, now);
|
|
109
|
+
log.info(
|
|
110
|
+
{ from: routeLabel(primary), to: routeLabel(chosen), connection: chosen.connection.label, reason: hold?.reason ?? null, until: hold?.until ?? null },
|
|
111
|
+
'gateway: the first model is over its limit, so requests go to a fallback model',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface ChainRow {
|
|
116
|
+
connection: string;
|
|
117
|
+
model: string;
|
|
118
|
+
used: number | null;
|
|
119
|
+
hold: Hold | null;
|
|
120
|
+
active: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function primaryOf(cfg: ModelConfig): Route | null {
|
|
124
|
+
const conn = routedConnection(cfg);
|
|
125
|
+
if (conn === null) return null;
|
|
126
|
+
const served = lastServed();
|
|
127
|
+
const model = conn.model !== '' ? conn.model : served?.connection === conn.id ? served.model : '';
|
|
128
|
+
return { connection: conn, model };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function rowOf(cfg: ModelConfig, route: Route, now: number): Omit<ChainRow, 'active'> {
|
|
132
|
+
const reason = notReady(cfg, route.connection);
|
|
133
|
+
const used = windowsFor(route, now).reduce<number | null>((most, w) => Math.max(most ?? 0, w.used ?? 0), null);
|
|
134
|
+
return { connection: route.connection.id, model: route.model, used, hold: reason === null ? holdOf(route, now) : { reason, until: null } };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function chainStatus(cfg: ModelConfig, now = Date.now()): ChainRow[] {
|
|
138
|
+
const primary = primaryOf(cfg);
|
|
139
|
+
if (primary === null) return [];
|
|
140
|
+
const listed = (cfg.fallbacks ?? []).flatMap((f) => {
|
|
141
|
+
const conn = connectionOf(cfg, f.connection);
|
|
142
|
+
return conn === null ? [] : [{ connection: conn, model: f.model }];
|
|
143
|
+
});
|
|
144
|
+
const rows = unique([primary, ...listed]).map((route) => rowOf(cfg, route, now));
|
|
145
|
+
const active = Math.max(0, rows.findIndex((row) => row.hold === null));
|
|
146
|
+
return rows.map((row, at) => ({ ...row, active: at === active }));
|
|
147
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
1
|
+
import type { IncomingMessage, OutgoingHttpHeader, OutgoingHttpHeaders, ServerResponse } from 'node:http';
|
|
2
2
|
import { UsageScanner } from './usage.js';
|
|
3
3
|
|
|
4
4
|
export class GatewayError extends Error {
|
|
@@ -235,3 +235,41 @@ export function upstreamMessage(text: string, fallback: string): string {
|
|
|
235
235
|
return text;
|
|
236
236
|
}
|
|
237
237
|
}
|
|
238
|
+
|
|
239
|
+
const LIMIT_STATUSES = new Set([402, 429]);
|
|
240
|
+
|
|
241
|
+
export interface Refusal {
|
|
242
|
+
status: number;
|
|
243
|
+
headers: Headers;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface Held {
|
|
247
|
+
res: ServerResponse;
|
|
248
|
+
refusal: () => Refusal | null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function headersOf(raw: OutgoingHttpHeaders | OutgoingHttpHeader[] | undefined): Headers {
|
|
252
|
+
const out = new Headers();
|
|
253
|
+
if (raw === undefined || Array.isArray(raw)) return out;
|
|
254
|
+
for (const [name, value] of Object.entries(raw)) if (value !== undefined) out.set(name, Array.isArray(value) ? value.join(', ') : String(value));
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function holdLimitRefusal(res: ServerResponse): Held {
|
|
259
|
+
let refusal: Refusal | null = null;
|
|
260
|
+
const swallow = (): boolean => true;
|
|
261
|
+
const held: ServerResponse = new Proxy(res, {
|
|
262
|
+
get(target, prop) {
|
|
263
|
+
if (prop === 'writeHead')
|
|
264
|
+
return (status: number, headers?: OutgoingHttpHeaders | OutgoingHttpHeader[]): ServerResponse => {
|
|
265
|
+
if (!LIMIT_STATUSES.has(status)) return target.writeHead(status, headers);
|
|
266
|
+
refusal = { status, headers: headersOf(headers) };
|
|
267
|
+
return held;
|
|
268
|
+
};
|
|
269
|
+
if (refusal !== null && (prop === 'write' || prop === 'end')) return swallow;
|
|
270
|
+
const value: unknown = Reflect.get(target, prop, target);
|
|
271
|
+
return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(target) : value;
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
return { res: held, refusal: () => refusal };
|
|
275
|
+
}
|
|
@@ -10,7 +10,8 @@ import {
|
|
|
10
10
|
freshAdaptations,
|
|
11
11
|
type Adaptations,
|
|
12
12
|
} from './bedrock.js';
|
|
13
|
-
import { addBeta, anthropicHeaders, forwardedHeaders, GatewayError, parseJson, pipeResponse, readBody, sendError, upstreamMessage, watchUpstream } from './forward.js';
|
|
13
|
+
import { addBeta, anthropicHeaders, forwardedHeaders, GatewayError, holdLimitRefusal, parseJson, pipeResponse, readBody, sendError, upstreamMessage, watchUpstream } from './forward.js';
|
|
14
|
+
import { chainOf, forgetFallbackState, noteChoice, noteRefused, routesToTry } from './fallback.js';
|
|
14
15
|
import {
|
|
15
16
|
BINDING_BETA,
|
|
16
17
|
cappedEffort,
|
|
@@ -58,6 +59,7 @@ const learned: Adaptations = freshAdaptations();
|
|
|
58
59
|
|
|
59
60
|
export function resetGatewayState(): void {
|
|
60
61
|
forgetServed();
|
|
62
|
+
forgetFallbackState();
|
|
61
63
|
forgetUsage();
|
|
62
64
|
learned.fields.clear();
|
|
63
65
|
learned.dropBetas = false;
|
|
@@ -237,13 +239,17 @@ function shapedFor(req: IncomingMessage, sent: Record<string, unknown>): Record<
|
|
|
237
239
|
|
|
238
240
|
const bytesOf = (raw: Buffer, parsed: Record<string, unknown>, sent: Record<string, unknown>): Buffer => (sent === parsed ? raw : Buffer.from(JSON.stringify(sent)));
|
|
239
241
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
242
|
+
interface Request {
|
|
243
|
+
path: string;
|
|
244
|
+
raw: Buffer;
|
|
245
|
+
parsed: Record<string, unknown>;
|
|
246
|
+
sent: Record<string, unknown>;
|
|
247
|
+
body: Record<string, unknown>;
|
|
248
|
+
cfg: ModelConfig;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request, route: Route, deps: GatewayDeps): Promise<void> {
|
|
252
|
+
const { path, body } = ask;
|
|
247
253
|
const conn = route.connection;
|
|
248
254
|
log.info({ route: routeLabel(route), connection: conn.label, path }, 'gateway: routing');
|
|
249
255
|
if (path === MESSAGES) noteServed({ connection: conn.id, provider: conn.provider, model: route.model, at: new Date().toISOString() });
|
|
@@ -256,14 +262,38 @@ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string,
|
|
|
256
262
|
}
|
|
257
263
|
if (conn.provider === 'openrouter') {
|
|
258
264
|
if (path === COUNT) throw new GatewayError(404, 'not_found_error', 'OpenRouter does not count tokens');
|
|
259
|
-
await toOpenRouter(req, res, body, route, cfg, deps);
|
|
265
|
+
await toOpenRouter(req, res, body, route, ask.cfg, deps);
|
|
260
266
|
return;
|
|
261
267
|
}
|
|
262
268
|
if (conn.provider === 'codex' || conn.provider === 'gemini') {
|
|
263
269
|
await toSubscription(req, res, path, body, route, deps);
|
|
264
270
|
return;
|
|
265
271
|
}
|
|
266
|
-
await toAnthropic(req, res, bytesOf(raw, parsed, sent), sent, body, route, deps);
|
|
272
|
+
await toAnthropic(req, res, bytesOf(ask.raw, ask.parsed, ask.sent), ask.sent, body, route, deps);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
|
|
276
|
+
const cfg = deps.config();
|
|
277
|
+
const raw = await readBody(req);
|
|
278
|
+
const parsed = parseJson(raw);
|
|
279
|
+
const sent = fitToolSearch(req, parsed);
|
|
280
|
+
const requested = requestedModel(sent);
|
|
281
|
+
const primary = resolveRoute(requested, cfg) ?? passthrough(sent);
|
|
282
|
+
const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent), cfg };
|
|
283
|
+
const routes = routesToTry(chainOf(primary, cfg, requested));
|
|
284
|
+
for (const [at, route] of routes.entries()) {
|
|
285
|
+
const next = routes[at + 1];
|
|
286
|
+
if (path === MESSAGES) noteChoice(primary, route);
|
|
287
|
+
if (next === undefined) {
|
|
288
|
+
await attempt(req, res, ask, route, deps);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const held = holdLimitRefusal(res);
|
|
292
|
+
await attempt(req, held.res, ask, route, deps);
|
|
293
|
+
const refusal = held.refusal();
|
|
294
|
+
if (refusal === null) return;
|
|
295
|
+
noteRefused(route, refusal.status, refusal.headers, next);
|
|
296
|
+
}
|
|
267
297
|
}
|
|
268
298
|
|
|
269
299
|
function failed(res: ServerResponse, err: unknown): void {
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
parseModelConfig,
|
|
15
15
|
readModelConfig,
|
|
16
16
|
removeConnection,
|
|
17
|
+
setFallbacks,
|
|
17
18
|
setRoute,
|
|
18
19
|
updateConnection,
|
|
19
20
|
writeModelConfig,
|
|
@@ -25,6 +26,7 @@ import { refreshUsage } from './usage-refresh.js';
|
|
|
25
26
|
|
|
26
27
|
const PATH = '/api/model';
|
|
27
28
|
const CONNECTIONS = '/api/model/connections';
|
|
29
|
+
const FALLBACKS = '/api/model/fallbacks';
|
|
28
30
|
const CODEX = '/api/model/codex/';
|
|
29
31
|
const GEMINI = '/api/model/gemini/';
|
|
30
32
|
const OPENROUTER = '/api/model/openrouter/';
|
|
@@ -69,6 +71,11 @@ async function chooseRoute(req: IncomingMessage, deps: ModelApiDeps, store: Stor
|
|
|
69
71
|
return kept(store, next, deps, 'model-api: route changed', { route: next.route });
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
async function chooseFallbacks(req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
|
|
75
|
+
const next = await withBody(req, (body) => setFallbacks(store.read(), body.fallbacks));
|
|
76
|
+
return kept(store, next, deps, 'model-api: fallback models changed', { fallbacks: (next.fallbacks ?? []).map((f) => `${f.connection}:${f.model}`) });
|
|
77
|
+
}
|
|
78
|
+
|
|
72
79
|
async function create(req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
|
|
73
80
|
const next = await withBody(req, (body) => addConnection(store.read(), body));
|
|
74
81
|
return kept(store, next, deps, 'model-api: connection added', { connection: next.connections.at(-1)?.id ?? '' });
|
|
@@ -133,7 +140,8 @@ function bundleRoute(path: string, method: string | undefined): Route | number {
|
|
|
133
140
|
return method === 'POST' ? { method: 'POST', run: (req, deps, store) => restore(req, store, deps) } : 405;
|
|
134
141
|
}
|
|
135
142
|
|
|
136
|
-
function settingsRoute(method: string | undefined): Route | number {
|
|
143
|
+
function settingsRoute(path: string, method: string | undefined): Route | number {
|
|
144
|
+
if (path === FALLBACKS) return method === 'PUT' ? { method: 'POST', run: chooseFallbacks } : 405;
|
|
137
145
|
if (method === 'GET') return { method: 'GET', run: (_req, deps, store) => settingsWithUsage(deps, store) };
|
|
138
146
|
if (method === 'PUT') return { method: 'POST', run: chooseRoute };
|
|
139
147
|
return 405;
|
|
@@ -155,26 +163,23 @@ function codexRoute(rest: string, method: string | undefined): Route | number {
|
|
|
155
163
|
return codexDeviceRoute(id, method);
|
|
156
164
|
}
|
|
157
165
|
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
path.startsWith(BEDROCK);
|
|
166
|
+
const TABLES: [string, Record<string, Route>][] = [
|
|
167
|
+
[OPENROUTER, OPENROUTER_ROUTES],
|
|
168
|
+
[ANTHROPIC, ANTHROPIC_ROUTES],
|
|
169
|
+
[BEDROCK, BEDROCK_ROUTES],
|
|
170
|
+
[GEMINI, GEMINI_ROUTES],
|
|
171
|
+
];
|
|
172
|
+
const EXACT = new Set([PATH, FALLBACKS, BUNDLE, RESTORE, CONNECTIONS]);
|
|
173
|
+
const PREFIXES = [`${CONNECTIONS}/`, CODEX, ...TABLES.map(([prefix]) => prefix)];
|
|
174
|
+
|
|
175
|
+
const mine = (path: string): boolean => EXACT.has(path) || PREFIXES.some((prefix) => path.startsWith(prefix));
|
|
169
176
|
|
|
170
177
|
function routeFor(path: string, method: string | undefined): Route | number {
|
|
171
|
-
if (path === PATH) return settingsRoute(method);
|
|
178
|
+
if (path === PATH || path === FALLBACKS) return settingsRoute(path, method);
|
|
172
179
|
if (path === BUNDLE || path === RESTORE) return bundleRoute(path, method);
|
|
173
180
|
if (path === CONNECTIONS || path.startsWith(`${CONNECTIONS}/`)) return connectionsRoute(path, method);
|
|
174
|
-
|
|
175
|
-
if (
|
|
176
|
-
if (path.startsWith(BEDROCK)) return named(BEDROCK_ROUTES, path.slice(BEDROCK.length), method);
|
|
177
|
-
if (path.startsWith(GEMINI)) return named(GEMINI_ROUTES, path.slice(GEMINI.length), method);
|
|
181
|
+
const table = TABLES.find(([prefix]) => path.startsWith(prefix));
|
|
182
|
+
if (table !== undefined) return named(table[1], path.slice(table[0].length), method);
|
|
178
183
|
return codexRoute(path.slice(CODEX.length), method);
|
|
179
184
|
}
|
|
180
185
|
|
|
@@ -21,10 +21,16 @@ export interface Connection {
|
|
|
21
21
|
gemini: GeminiTokens | null;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export interface Fallback {
|
|
25
|
+
connection: string;
|
|
26
|
+
model: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
export interface ModelConfig {
|
|
25
30
|
version: 2;
|
|
26
31
|
route: string;
|
|
27
32
|
connections: Connection[];
|
|
33
|
+
fallbacks?: Fallback[];
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
export interface Route {
|
|
@@ -38,6 +44,7 @@ const MODEL_FILE = 'model.json';
|
|
|
38
44
|
const MAX_FIELD = 512;
|
|
39
45
|
const MAX_LABEL = 60;
|
|
40
46
|
const MAX_CONNECTIONS = 20;
|
|
47
|
+
const MAX_FALLBACKS = 8;
|
|
41
48
|
const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex|gemini):(.+)$/;
|
|
42
49
|
const SMALL_RE = /haiku/i;
|
|
43
50
|
|
|
@@ -102,11 +109,29 @@ function connectionFromDisk(raw: unknown): Connection | null {
|
|
|
102
109
|
};
|
|
103
110
|
}
|
|
104
111
|
|
|
112
|
+
function fallbacksOf(raw: unknown, connections: Connection[]): Fallback[] {
|
|
113
|
+
if (!Array.isArray(raw)) return [];
|
|
114
|
+
const known = raw.flatMap((f: unknown) => {
|
|
115
|
+
if (!isRecord(f)) return [];
|
|
116
|
+
const entry = { connection: text(f.connection), model: text(f.model).slice(0, MAX_FIELD) };
|
|
117
|
+
return entry.model !== '' && connections.some((c) => c.id === entry.connection) ? [entry] : [];
|
|
118
|
+
});
|
|
119
|
+
return known.filter((f, at) => known.findIndex((o) => o.connection === f.connection && o.model === f.model) === at).slice(0, MAX_FALLBACKS);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const withFallbacks = (cfg: ModelConfig, fallbacks: Fallback[]): ModelConfig => ({
|
|
123
|
+
version: 2,
|
|
124
|
+
route: cfg.route,
|
|
125
|
+
connections: cfg.connections,
|
|
126
|
+
...(fallbacks.length === 0 ? {} : { fallbacks }),
|
|
127
|
+
});
|
|
128
|
+
|
|
105
129
|
export function parseModelConfig(raw: unknown): ModelConfig {
|
|
106
130
|
if (!isRecord(raw) || !Array.isArray(raw.connections)) return empty();
|
|
107
131
|
const connections = raw.connections.flatMap((c: unknown) => connectionFromDisk(c) ?? []).slice(0, MAX_CONNECTIONS);
|
|
108
132
|
const route = text(raw.route);
|
|
109
|
-
|
|
133
|
+
const cfg: ModelConfig = { version: 2, route: connections.some((c) => c.id === route) ? route : (connections[0]?.id ?? ''), connections };
|
|
134
|
+
return withFallbacks(cfg, fallbacksOf(raw.fallbacks, connections));
|
|
110
135
|
}
|
|
111
136
|
|
|
112
137
|
const needsIds = (raw: unknown): boolean =>
|
|
@@ -160,7 +185,7 @@ export function notReady(cfg: ModelConfig, conn = routedConnection(cfg)): string
|
|
|
160
185
|
return CHECKS[conn.provider].find(([missing]) => missing(conn))?.[1] ?? null;
|
|
161
186
|
}
|
|
162
187
|
|
|
163
|
-
const isSmallModel = (requested: string): boolean => SMALL_RE.test(requested);
|
|
188
|
+
export const isSmallModel = (requested: string): boolean => SMALL_RE.test(requested);
|
|
164
189
|
|
|
165
190
|
const DEFAULTS: Record<Provider, (requested: string, c: Connection) => string> = {
|
|
166
191
|
openrouter: (requested, c) => (requested.includes('/') ? requested : c.model),
|
|
@@ -176,6 +201,8 @@ function forProvider(cfg: ModelConfig, provider: Provider): Connection | null {
|
|
|
176
201
|
return cfg.connections.find((c) => c.provider === provider) ?? null;
|
|
177
202
|
}
|
|
178
203
|
|
|
204
|
+
export const bareModel = (requested: string): string => PREFIX_RE.exec(requested)?.[2]?.trim() ?? requested;
|
|
205
|
+
|
|
179
206
|
export function resolveRoute(requested: string, cfg: ModelConfig): Route | null {
|
|
180
207
|
const explicit = PREFIX_RE.exec(requested);
|
|
181
208
|
if (explicit !== null && isProvider(explicit[1])) {
|
|
@@ -276,7 +303,24 @@ export function updateConnection(cfg: ModelConfig, id: string, patch: unknown):
|
|
|
276
303
|
export function removeConnection(cfg: ModelConfig, id: string): ModelConfig {
|
|
277
304
|
requireConnection(cfg, id);
|
|
278
305
|
const connections = cfg.connections.filter((c) => c.id !== id);
|
|
279
|
-
|
|
306
|
+
const next = { ...cfg, route: cfg.route === id ? (connections[0]?.id ?? '') : cfg.route, connections };
|
|
307
|
+
return withFallbacks(next, (cfg.fallbacks ?? []).filter((f) => f.connection !== id));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function fallbackEntry(cfg: ModelConfig, raw: unknown): Fallback {
|
|
311
|
+
if (!isRecord(raw)) throw new ModelConfigError('each fallback must be an object with a connection and a model');
|
|
312
|
+
const connection = field(raw, 'connection', '', 'connection');
|
|
313
|
+
const model = field(raw, 'model', '', 'model');
|
|
314
|
+
requireConnection(cfg, connection);
|
|
315
|
+
if (model === '') throw new ModelConfigError('each fallback needs a model');
|
|
316
|
+
return { connection, model };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function setFallbacks(cfg: ModelConfig, raw: unknown): ModelConfig {
|
|
320
|
+
if (!Array.isArray(raw)) throw new ModelConfigError('fallbacks must be a list');
|
|
321
|
+
if (raw.length > MAX_FALLBACKS) throw new ModelConfigError(`a box keeps at most ${String(MAX_FALLBACKS)} fallbacks`);
|
|
322
|
+
const entries = raw.map((f: unknown) => fallbackEntry(cfg, f));
|
|
323
|
+
return withFallbacks(cfg, fallbacksOf(entries, cfg.connections));
|
|
280
324
|
}
|
|
281
325
|
|
|
282
326
|
export function setRoute(cfg: ModelConfig, id: string): ModelConfig {
|
|
@@ -5,6 +5,7 @@ import { GeminiAuthError } from './gemini-auth.js';
|
|
|
5
5
|
import { GatewayError } from './forward.js';
|
|
6
6
|
import { lastServed } from './served.js';
|
|
7
7
|
import { usageSeen } from './usage.js';
|
|
8
|
+
import { chainStatus } from './fallback.js';
|
|
8
9
|
import { ModelConfigError, connectionOf, publicModelConfig, routedConnection, type Connection, type ModelConfig, type Provider } from './model-config.js';
|
|
9
10
|
import type { SetupDeps } from '../claude/setup.js';
|
|
10
11
|
|
|
@@ -45,6 +46,8 @@ export const CREDITS_TTL_MS = 5 * 60_000;
|
|
|
45
46
|
|
|
46
47
|
export const settingsBody = (cfg: ModelConfig): Record<string, unknown> => ({
|
|
47
48
|
...publicModelConfig(cfg),
|
|
49
|
+
fallbacks: cfg.fallbacks ?? [],
|
|
50
|
+
chain: chainStatus(cfg),
|
|
48
51
|
lastServed: lastServed(),
|
|
49
52
|
usage: usageSeen(),
|
|
50
53
|
});
|
|
@@ -3,8 +3,8 @@ import { readClaudeUsage } from '../claude/usage-probe.js';
|
|
|
3
3
|
import { codexUsageNow, sharedCodexState } from './codex.js';
|
|
4
4
|
import { openrouterKey } from './openrouter.js';
|
|
5
5
|
import { lastServed } from './served.js';
|
|
6
|
-
import { claudeLoginUsage, geminiUsage, keepProbeAnswer, mayProbe, noteUsage, openrouterUsage, probeAnswer, usageOf
|
|
7
|
-
import { PASSTHROUGH_ID, routedConnection, type Connection, type ModelConfig } from './model-config.js';
|
|
6
|
+
import { claudeLoginUsage, geminiUsage, keepProbeAnswer, mayProbe, mergeUsage, noteUsage, openrouterUsage, probeAnswer, usageOf } from './usage.js';
|
|
7
|
+
import { PASSTHROUGH_ID, readModelConfig, routedConnection, writeModelConfig, type Connection, type ModelConfig } from './model-config.js';
|
|
8
8
|
import { codexDepsFor, geminiModelsOf } from './model-signin.js';
|
|
9
9
|
import { CREDITS_TTL_MS, type ModelApiDeps, type Store } from './model-store.js';
|
|
10
10
|
|
|
@@ -41,14 +41,15 @@ async function refreshCodex(conn: Connection, deps: ModelApiDeps, store: Store,
|
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
const modelsInUse = (conn: Connection): Set<string> => {
|
|
44
|
+
const modelsInUse = (conn: Connection, cfg: ModelConfig): Set<string> => {
|
|
45
45
|
const served = lastServed();
|
|
46
|
-
|
|
46
|
+
const fallbacks = (cfg.fallbacks ?? []).filter((f) => f.connection === conn.id).map((f) => f.model);
|
|
47
|
+
return new Set([conn.model, served?.connection === conn.id ? served.model : '', ...fallbacks].filter((m) => m !== ''));
|
|
47
48
|
};
|
|
48
49
|
|
|
49
50
|
async function refreshQuota(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
|
|
50
51
|
if (conn.gemini === null) return;
|
|
51
|
-
const inUse = modelsInUse(conn);
|
|
52
|
+
const inUse = modelsInUse(conn, store.read());
|
|
52
53
|
const seen = usageOf(conn.id);
|
|
53
54
|
const covers = seen !== undefined && [...inUse].every((id) => seen.windows.some((w) => w.label === id));
|
|
54
55
|
if (seen !== undefined && covers && now - Date.parse(seen.at) < CREDITS_TTL_MS) return;
|
|
@@ -64,11 +65,11 @@ const loginIds = (cfg: ModelConfig): string[] => [
|
|
|
64
65
|
...cfg.connections.filter((c) => c.provider === 'anthropic' && c.apiKey === '').map((c) => c.id),
|
|
65
66
|
];
|
|
66
67
|
|
|
67
|
-
const
|
|
68
|
+
const hasFallbacks = (cfg: ModelConfig): boolean => (cfg.fallbacks ?? []).length > 0;
|
|
68
69
|
|
|
69
70
|
async function refreshLogin(cfg: ModelConfig, deps: ModelApiDeps, now: number): Promise<void> {
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
71
|
+
const ids = loginIds(cfg);
|
|
72
|
+
if (!ids.some((id) => !fresh(id, now)) && !(hasFallbacks(cfg) && ids.length > 0)) return;
|
|
72
73
|
if (mayProbe(LOGIN_PROBE, now, CREDITS_TTL_MS))
|
|
73
74
|
await probe('usage of the Claude Code login', async () => {
|
|
74
75
|
const usage = claudeLoginUsage(await (deps.claudeUsage ?? readClaudeUsage)(), new Date(now));
|
|
@@ -76,7 +77,7 @@ async function refreshLogin(cfg: ModelConfig, deps: ModelApiDeps, now: number):
|
|
|
76
77
|
});
|
|
77
78
|
const usage = probeAnswer(LOGIN_PROBE);
|
|
78
79
|
if (usage === undefined || now - Date.parse(usage.at) >= CREDITS_TTL_MS) return;
|
|
79
|
-
for (const id of
|
|
80
|
+
for (const id of ids) mergeUsage(id, usage);
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
function refreshOne(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
|
|
@@ -100,3 +101,14 @@ export async function refreshUsage(deps: ModelApiDeps, store: Store, now = Date.
|
|
|
100
101
|
const work = Promise.all([...cfg.connections.map((conn) => refreshOne(conn, deps, store, now)), refreshLogin(cfg, deps, now)]);
|
|
101
102
|
await within(deps.usageWaitMs ?? WAIT_MS, work);
|
|
102
103
|
}
|
|
104
|
+
|
|
105
|
+
export function watchFallbackUsage(deps: ModelApiDeps = {}, every = CREDITS_TTL_MS): void {
|
|
106
|
+
const store: Store = { read: deps.read ?? readModelConfig, write: deps.write ?? writeModelConfig };
|
|
107
|
+
const run = (): void => {
|
|
108
|
+
if (!hasFallbacks(store.read())) return;
|
|
109
|
+
refreshUsage(deps, store).catch((err: unknown) => {
|
|
110
|
+
log.warn({ err: errMsg(err) }, 'model-api: could not refresh the usage of the fallback models');
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
setInterval(run, every).unref();
|
|
114
|
+
}
|
|
@@ -3,7 +3,7 @@ import { stringOf } from '@metro-labs/http/api-http';
|
|
|
3
3
|
|
|
4
4
|
type Key = string;
|
|
5
5
|
|
|
6
|
-
interface UsageWindow {
|
|
6
|
+
export interface UsageWindow {
|
|
7
7
|
label: string;
|
|
8
8
|
used: number | null;
|
|
9
9
|
resetAt: string | null;
|
|
@@ -28,14 +28,32 @@ export interface ProviderUsage {
|
|
|
28
28
|
export type Reported = Omit<ProviderUsage, 'tally'>;
|
|
29
29
|
|
|
30
30
|
const latest = new Map<Key, Reported>();
|
|
31
|
+
const stamps = new Map<Key, Map<string, number>>();
|
|
31
32
|
const tallies = new Map<Key, Tally>();
|
|
32
33
|
const probed = new Map<Key, number>();
|
|
33
34
|
const answers = new Map<Key, Reported>();
|
|
34
35
|
|
|
35
36
|
export const noteUsage = (key: Key, usage: Reported): void => {
|
|
36
37
|
latest.set(key, usage);
|
|
38
|
+
stamps.set(key, new Map(usage.windows.map((w) => [w.label, Date.parse(usage.at)])));
|
|
37
39
|
};
|
|
38
40
|
|
|
41
|
+
export function mergeUsage(key: Key, usage: Reported): void {
|
|
42
|
+
const seen = latest.get(key);
|
|
43
|
+
const marks = stamps.get(key);
|
|
44
|
+
if (seen === undefined || marks === undefined) {
|
|
45
|
+
noteUsage(key, usage);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const at = Date.parse(usage.at);
|
|
49
|
+
const newer = new Map(usage.windows.filter((w) => (marks.get(w.label) ?? Number.NEGATIVE_INFINITY) <= at).map((w) => [w.label, w]));
|
|
50
|
+
for (const label of newer.keys()) marks.set(label, at);
|
|
51
|
+
const kept = seen.windows.map((w) => newer.get(w.label) ?? w);
|
|
52
|
+
const added = [...newer.values()].filter((w) => !seen.windows.some((old) => old.label === w.label));
|
|
53
|
+
const later = Date.parse(seen.at) <= at;
|
|
54
|
+
latest.set(key, { windows: [...kept, ...added], note: later ? usage.note : seen.note, at: later ? usage.at : seen.at });
|
|
55
|
+
}
|
|
56
|
+
|
|
39
57
|
export function usageSeen(): Record<Key, ProviderUsage> {
|
|
40
58
|
const out: Record<Key, ProviderUsage> = {};
|
|
41
59
|
for (const key of new Set<Key>([...latest.keys(), ...tallies.keys()])) {
|
|
@@ -50,17 +68,20 @@ export const usageOf = (key: Key): Reported | undefined => latest.get(key);
|
|
|
50
68
|
|
|
51
69
|
export const forgetOne = (key: Key): void => {
|
|
52
70
|
latest.delete(key);
|
|
71
|
+
stamps.delete(key);
|
|
53
72
|
tallies.delete(key);
|
|
54
73
|
probed.delete(key);
|
|
55
74
|
};
|
|
56
75
|
|
|
57
76
|
export const forgetReported = (key: Key): void => {
|
|
58
77
|
latest.delete(key);
|
|
78
|
+
stamps.delete(key);
|
|
59
79
|
probed.delete(key);
|
|
60
80
|
};
|
|
61
81
|
|
|
62
82
|
export const forgetUsage = (): void => {
|
|
63
83
|
latest.clear();
|
|
84
|
+
stamps.clear();
|
|
64
85
|
tallies.clear();
|
|
65
86
|
probed.clear();
|
|
66
87
|
answers.clear();
|
|
@@ -178,13 +199,15 @@ function anthropicUnified(headers: Headers, now: Date): { windows: UsageWindow[]
|
|
|
178
199
|
return { windows, note };
|
|
179
200
|
}
|
|
180
201
|
|
|
202
|
+
export const PER_MINUTE = 'Tokens per minute';
|
|
203
|
+
|
|
181
204
|
function anthropicKeyed(headers: Headers, now: Date): UsageWindow[] {
|
|
182
205
|
const limit = Number(headers.get('anthropic-ratelimit-tokens-limit'));
|
|
183
206
|
const remaining = Number(headers.get('anthropic-ratelimit-tokens-remaining'));
|
|
184
207
|
if (!Number.isFinite(limit) || !Number.isFinite(remaining) || limit <= 0) return [];
|
|
185
208
|
return [
|
|
186
209
|
{
|
|
187
|
-
label:
|
|
210
|
+
label: PER_MINUTE,
|
|
188
211
|
used: clamp(1 - remaining / limit),
|
|
189
212
|
resetAt: whenFrom(headers.get('anthropic-ratelimit-tokens-reset'), now),
|
|
190
213
|
detail: `${remaining.toLocaleString('en-US')} of ${limit.toLocaleString('en-US')} left`,
|
|
@@ -315,7 +338,9 @@ export function claudeLoginUsage(limits: unknown, now = new Date()): Reported |
|
|
|
315
338
|
|
|
316
339
|
export function noteUsageHeaders(kind: 'anthropic' | 'codex', key: Key, headers: Headers, now = new Date()): void {
|
|
317
340
|
const usage = kind === 'anthropic' ? anthropicUsage(headers, now) : codexUsage(headers, now);
|
|
318
|
-
if (usage
|
|
341
|
+
if (usage === null) return;
|
|
342
|
+
if (kind === 'anthropic') mergeUsage(key, usage);
|
|
343
|
+
else noteUsage(key, usage);
|
|
319
344
|
}
|
|
320
345
|
|
|
321
346
|
const dollars = (value: number): string => `$${value.toFixed(2)}`;
|
package/runtime/runtime.json
CHANGED