@stage-labs/metro 0.1.0-beta.251 → 0.1.0-beta.253
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 +2 -2
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/package.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +9 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +52 -18
- package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +5 -4
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +168 -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 +48 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +97 -22
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +34 -21
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +68 -5
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +8 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +1 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +37 -14
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +28 -3
- package/runtime/node_modules/@metro-labs/discord-bot/package.json +1 -1
- package/runtime/node_modules/@metro-labs/gmail/package.json +1 -1
- package/runtime/node_modules/@metro-labs/http/package.json +1 -1
- package/runtime/node_modules/@metro-labs/outlook/package.json +1 -1
- package/runtime/node_modules/@metro-labs/telegram/package.json +1 -1
- package/runtime/node_modules/@metro-labs/telegram-bot/package.json +1 -1
- package/runtime/node_modules/@metro-labs/threema/package.json +1 -1
- package/runtime/node_modules/@metro-labs/webhook/package.json +1 -1
- package/runtime/node_modules/@metro-labs/whatsapp/package.json +1 -1
- package/runtime/node_modules/@metro-labs/xmtp/package.json +1 -1
- package/runtime/runtime.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
1
|
+
import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
3
|
import { cameThroughProxy } from '@metro-labs/http/api-http';
|
|
4
4
|
import { agentIdForKey } from '../agents/keys.js';
|
|
@@ -10,7 +10,21 @@ import {
|
|
|
10
10
|
freshAdaptations,
|
|
11
11
|
type Adaptations,
|
|
12
12
|
} from './bedrock.js';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
addBeta,
|
|
15
|
+
anthropicHeaders,
|
|
16
|
+
forwardedHeaders,
|
|
17
|
+
GatewayError,
|
|
18
|
+
holdLimitRefusal,
|
|
19
|
+
loginHeaders,
|
|
20
|
+
parseJson,
|
|
21
|
+
pipeResponse,
|
|
22
|
+
readBody,
|
|
23
|
+
sendError,
|
|
24
|
+
upstreamMessage,
|
|
25
|
+
watchUpstream,
|
|
26
|
+
} from './forward.js';
|
|
27
|
+
import { chainOf, forgetFallbackState, noteChoice, noteRefused, routesToTry } from './fallback.js';
|
|
14
28
|
import {
|
|
15
29
|
BINDING_BETA,
|
|
16
30
|
cappedEffort,
|
|
@@ -37,6 +51,7 @@ import { forgetUsage, noteUsageHeaders, UsageScanner } from './usage.js';
|
|
|
37
51
|
import { fitToolSearch } from './tool-search.js';
|
|
38
52
|
import { ANTHROPIC_API, refreshLoginModels } from './provider-models.js';
|
|
39
53
|
import type { CodexTokens } from './codex-auth.js';
|
|
54
|
+
import { forgetClaudeTokens, withClaudeLogin, type ClaudeLoginDeps } from './claude-logins.js';
|
|
40
55
|
|
|
41
56
|
const GATEWAY_PREFIX = '/gateway';
|
|
42
57
|
const MESSAGES = '/v1/messages';
|
|
@@ -52,18 +67,21 @@ export interface GatewayDeps {
|
|
|
52
67
|
openrouterBase?: string;
|
|
53
68
|
codex?: Partial<CodexDeps>;
|
|
54
69
|
gemini?: Partial<GeminiDeps>;
|
|
70
|
+
claudeLogins?: ClaudeLoginDeps;
|
|
55
71
|
}
|
|
56
72
|
|
|
57
73
|
const learned: Adaptations = freshAdaptations();
|
|
58
74
|
|
|
59
75
|
export function resetGatewayState(): void {
|
|
60
76
|
forgetServed();
|
|
77
|
+
forgetFallbackState();
|
|
61
78
|
forgetUsage();
|
|
62
79
|
learned.fields.clear();
|
|
63
80
|
learned.dropBetas = false;
|
|
64
81
|
forgetLearnedThinking();
|
|
65
82
|
sharedCodexState.clear();
|
|
66
83
|
sharedGeminiState.clear();
|
|
84
|
+
forgetClaudeTokens();
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
const saveCodexTokens = (id: string, tokens: CodexTokens): void => {
|
|
@@ -88,7 +106,18 @@ function modelsBody(cfg: ModelConfig): Record<string, unknown> {
|
|
|
88
106
|
return { data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null };
|
|
89
107
|
}
|
|
90
108
|
|
|
91
|
-
const PASSTHROUGH: Connection = {
|
|
109
|
+
const PASSTHROUGH: Connection = {
|
|
110
|
+
id: PASSTHROUGH_ID,
|
|
111
|
+
provider: 'anthropic',
|
|
112
|
+
label: 'Claude Code login',
|
|
113
|
+
model: '',
|
|
114
|
+
apiKey: '',
|
|
115
|
+
region: '',
|
|
116
|
+
zdr: false,
|
|
117
|
+
codex: null,
|
|
118
|
+
gemini: null,
|
|
119
|
+
claude: null,
|
|
120
|
+
};
|
|
92
121
|
|
|
93
122
|
const passthrough = (body: Record<string, unknown>): Route => ({ connection: PASSTHROUGH, model: requestedModel(body) });
|
|
94
123
|
|
|
@@ -137,12 +166,27 @@ async function refusedThinkingOff(upstream: Response, shaped: Record<string, unk
|
|
|
137
166
|
return true;
|
|
138
167
|
}
|
|
139
168
|
|
|
140
|
-
function listLoginModels(
|
|
141
|
-
refreshLoginModels(
|
|
169
|
+
function listLoginModels(headers: IncomingHttpHeaders, anthropicBase: string): void {
|
|
170
|
+
refreshLoginModels(headers, anthropicBase).catch((err: unknown) => {
|
|
142
171
|
log.warn({ err: errMsg(err) }, 'gateway: could not list the models of the Claude Code login');
|
|
143
172
|
});
|
|
144
173
|
}
|
|
145
174
|
|
|
175
|
+
function sendAnthropic(req: IncomingMessage, conn: Connection, anthropicBase: string, attempt: (base: Record<string, string>) => Promise<Response>, deps: GatewayDeps): Promise<Response> {
|
|
176
|
+
if (conn.apiKey !== '') return attempt(anthropicHeaders(req, conn.apiKey));
|
|
177
|
+
if (conn.claude === null) return attempt(forwardedHeaders(req));
|
|
178
|
+
return withClaudeLogin(conn.claude.id, deps.claudeLogins ?? {}, (token) => {
|
|
179
|
+
listLoginModels({ authorization: `Bearer ${token}` }, anthropicBase);
|
|
180
|
+
return attempt(loginHeaders(req, token));
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function refusedLogin(conn: Connection, upstream: Response): Promise<void> {
|
|
185
|
+
if (conn.apiKey !== '' || conn.claude === null || upstream.status !== 401) return;
|
|
186
|
+
await upstream.body?.cancel();
|
|
187
|
+
throw new GatewayError(403, 'permission_error', `Anthropic refused the Claude login of ${conn.label}: sign in again on the Model page`);
|
|
188
|
+
}
|
|
189
|
+
|
|
146
190
|
async function toAnthropic(
|
|
147
191
|
req: IncomingMessage,
|
|
148
192
|
res: ServerResponse,
|
|
@@ -155,25 +199,28 @@ async function toAnthropic(
|
|
|
155
199
|
const conn = route.connection;
|
|
156
200
|
const anthropicBase = deps.anthropicBase ?? ANTHROPIC_API;
|
|
157
201
|
const url = `${anthropicBase}${(req.url ?? '').slice(GATEWAY_PREFIX.length)}`;
|
|
158
|
-
const
|
|
159
|
-
if (
|
|
202
|
+
const passthrough = conn.apiKey === '' && conn.claude === null;
|
|
203
|
+
if (passthrough && standsInFor(req))
|
|
160
204
|
throw new GatewayError(
|
|
161
205
|
403,
|
|
162
206
|
'permission_error',
|
|
163
207
|
'Claude Code on this machine has no Anthropic login of its own; choose Bedrock, OpenRouter, Codex or Gemini on the Model page, or sign in on the Claude tab',
|
|
164
208
|
);
|
|
165
209
|
const watch = watchUpstream(res);
|
|
166
|
-
if (
|
|
167
|
-
const base = key === '' ? forwardedHeaders(req) : anthropicHeaders(req, key);
|
|
210
|
+
if (passthrough) listLoginModels(req.headers, anthropicBase);
|
|
168
211
|
const send = (payload: Buffer, headers: Record<string, string>): Promise<Response> =>
|
|
169
212
|
fetch(url, { method: 'POST', headers, body: new Uint8Array(payload), signal: watch.signal, redirect: 'manual' });
|
|
170
|
-
const attempt = (): Promise<Response> =>
|
|
171
|
-
|
|
172
|
-
|
|
213
|
+
const attempt = async (base: Record<string, string>): Promise<Response> => {
|
|
214
|
+
const shape = (): Promise<Response> => asAnthropicWants(send, base, anthropicPayloads(raw, sent, shaped, route.model), conn.label, route.model);
|
|
215
|
+
const first = await shape();
|
|
216
|
+
return (await refusedThinkingOff(first, shaped, route.model)) ? shape() : first;
|
|
217
|
+
};
|
|
218
|
+
const upstream = await sendAnthropic(req, conn, anthropicBase, attempt, deps);
|
|
219
|
+
await refusedLogin(conn, upstream);
|
|
173
220
|
noteRefusal(conn.label, route.model, upstream);
|
|
174
221
|
noteUsageHeaders('anthropic', conn.id, upstream.headers);
|
|
175
222
|
const scanner = new UsageScanner(conn.id);
|
|
176
|
-
await pipeResponse(upstream, res, watch,
|
|
223
|
+
await pipeResponse(upstream, res, watch, passthrough ? { scanner } : { ownCredential: true, scanner });
|
|
177
224
|
}
|
|
178
225
|
|
|
179
226
|
async function toOpenRouter(
|
|
@@ -237,13 +284,17 @@ function shapedFor(req: IncomingMessage, sent: Record<string, unknown>): Record<
|
|
|
237
284
|
|
|
238
285
|
const bytesOf = (raw: Buffer, parsed: Record<string, unknown>, sent: Record<string, unknown>): Buffer => (sent === parsed ? raw : Buffer.from(JSON.stringify(sent)));
|
|
239
286
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
287
|
+
interface Request {
|
|
288
|
+
path: string;
|
|
289
|
+
raw: Buffer;
|
|
290
|
+
parsed: Record<string, unknown>;
|
|
291
|
+
sent: Record<string, unknown>;
|
|
292
|
+
body: Record<string, unknown>;
|
|
293
|
+
cfg: ModelConfig;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request, route: Route, deps: GatewayDeps): Promise<void> {
|
|
297
|
+
const { path, body } = ask;
|
|
247
298
|
const conn = route.connection;
|
|
248
299
|
log.info({ route: routeLabel(route), connection: conn.label, path }, 'gateway: routing');
|
|
249
300
|
if (path === MESSAGES) noteServed({ connection: conn.id, provider: conn.provider, model: route.model, at: new Date().toISOString() });
|
|
@@ -256,14 +307,38 @@ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string,
|
|
|
256
307
|
}
|
|
257
308
|
if (conn.provider === 'openrouter') {
|
|
258
309
|
if (path === COUNT) throw new GatewayError(404, 'not_found_error', 'OpenRouter does not count tokens');
|
|
259
|
-
await toOpenRouter(req, res, body, route, cfg, deps);
|
|
310
|
+
await toOpenRouter(req, res, body, route, ask.cfg, deps);
|
|
260
311
|
return;
|
|
261
312
|
}
|
|
262
313
|
if (conn.provider === 'codex' || conn.provider === 'gemini') {
|
|
263
314
|
await toSubscription(req, res, path, body, route, deps);
|
|
264
315
|
return;
|
|
265
316
|
}
|
|
266
|
-
await toAnthropic(req, res, bytesOf(raw, parsed, sent), sent, body, route, deps);
|
|
317
|
+
await toAnthropic(req, res, bytesOf(ask.raw, ask.parsed, ask.sent), ask.sent, body, route, deps);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
|
|
321
|
+
const cfg = deps.config();
|
|
322
|
+
const raw = await readBody(req);
|
|
323
|
+
const parsed = parseJson(raw);
|
|
324
|
+
const sent = fitToolSearch(req, parsed);
|
|
325
|
+
const requested = requestedModel(sent);
|
|
326
|
+
const primary = resolveRoute(requested, cfg) ?? passthrough(sent);
|
|
327
|
+
const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent), cfg };
|
|
328
|
+
const routes = routesToTry(chainOf(primary, cfg, requested));
|
|
329
|
+
for (const [at, route] of routes.entries()) {
|
|
330
|
+
const next = routes[at + 1];
|
|
331
|
+
if (path === MESSAGES) noteChoice(primary, route);
|
|
332
|
+
if (next === undefined) {
|
|
333
|
+
await attempt(req, res, ask, route, deps);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const held = holdLimitRefusal(res);
|
|
337
|
+
await attempt(req, held.res, ask, route, deps);
|
|
338
|
+
const refusal = held.refusal();
|
|
339
|
+
if (refusal === null) return;
|
|
340
|
+
noteRefused(route, refusal.status, refusal.headers, next);
|
|
341
|
+
}
|
|
267
342
|
}
|
|
268
343
|
|
|
269
344
|
function failed(res: ServerResponse, err: unknown): void {
|
|
@@ -14,17 +14,20 @@ import {
|
|
|
14
14
|
parseModelConfig,
|
|
15
15
|
readModelConfig,
|
|
16
16
|
removeConnection,
|
|
17
|
+
setFallbacks,
|
|
17
18
|
setRoute,
|
|
18
19
|
updateConnection,
|
|
19
20
|
writeModelConfig,
|
|
20
21
|
type ModelConfig,
|
|
21
22
|
} from './model-config.js';
|
|
22
|
-
import { asApiError, BODY_MAX, connectionFor, settingsBody, type ModelApiDeps, type Route, type Store } from './model-store.js';
|
|
23
|
+
import { asApiError, BODY_MAX, claudeLoginDeps, connectionFor, settingsBody, type ModelApiDeps, type Route, type Store } from './model-store.js';
|
|
24
|
+
import { dropUnusedClaudeLogins } from './claude-logins.js';
|
|
23
25
|
import { CODEX_ROUTES, codexDeviceRoute, GEMINI_ROUTES } from './model-signin.js';
|
|
24
26
|
import { refreshUsage } from './usage-refresh.js';
|
|
25
27
|
|
|
26
28
|
const PATH = '/api/model';
|
|
27
29
|
const CONNECTIONS = '/api/model/connections';
|
|
30
|
+
const FALLBACKS = '/api/model/fallbacks';
|
|
28
31
|
const CODEX = '/api/model/codex/';
|
|
29
32
|
const GEMINI = '/api/model/gemini/';
|
|
30
33
|
const OPENROUTER = '/api/model/openrouter/';
|
|
@@ -69,6 +72,11 @@ async function chooseRoute(req: IncomingMessage, deps: ModelApiDeps, store: Stor
|
|
|
69
72
|
return kept(store, next, deps, 'model-api: route changed', { route: next.route });
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
async function chooseFallbacks(req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
|
|
76
|
+
const next = await withBody(req, (body) => setFallbacks(store.read(), body.fallbacks));
|
|
77
|
+
return kept(store, next, deps, 'model-api: fallback models changed', { fallbacks: (next.fallbacks ?? []).map((f) => `${f.connection}:${f.model}`) });
|
|
78
|
+
}
|
|
79
|
+
|
|
72
80
|
async function create(req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
|
|
73
81
|
const next = await withBody(req, (body) => addConnection(store.read(), body));
|
|
74
82
|
return kept(store, next, deps, 'model-api: connection added', { connection: next.connections.at(-1)?.id ?? '' });
|
|
@@ -82,14 +90,17 @@ async function change(req: IncomingMessage, deps: ModelApiDeps, store: Store, id
|
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
function drop(deps: ModelApiDeps, store: Store, id: string): unknown {
|
|
93
|
+
const before = store.read();
|
|
85
94
|
let next: ModelConfig;
|
|
86
95
|
try {
|
|
87
|
-
next = removeConnection(
|
|
96
|
+
next = removeConnection(before, id);
|
|
88
97
|
} catch (err) {
|
|
89
98
|
asApiError(err);
|
|
90
99
|
}
|
|
91
100
|
forgetOne(id);
|
|
92
|
-
|
|
101
|
+
const body = kept(store, next, deps, 'model-api: connection removed', { connection: id });
|
|
102
|
+
dropUnusedClaudeLogins(before, next, claudeLoginDeps(deps));
|
|
103
|
+
return body;
|
|
93
104
|
}
|
|
94
105
|
|
|
95
106
|
async function settingsWithUsage(deps: ModelApiDeps, store: Store): Promise<Record<string, unknown>> {
|
|
@@ -125,7 +136,11 @@ const named = (table: Record<string, Route>, name: string, method: string | unde
|
|
|
125
136
|
async function restore(req: IncomingMessage, store: Store, deps: ModelApiDeps): Promise<unknown> {
|
|
126
137
|
const body = await readJsonBody(req, RESTORE_MAX);
|
|
127
138
|
if (!isRecord(body)) throw new ApiError('body must be a JSON object', 400);
|
|
128
|
-
|
|
139
|
+
const before = store.read();
|
|
140
|
+
const next = parseModelConfig(body);
|
|
141
|
+
const answer = kept(store, next, deps, 'model-api: model setup restored from a file', {});
|
|
142
|
+
dropUnusedClaudeLogins(before, next, claudeLoginDeps(deps));
|
|
143
|
+
return answer;
|
|
129
144
|
}
|
|
130
145
|
|
|
131
146
|
function bundleRoute(path: string, method: string | undefined): Route | number {
|
|
@@ -133,7 +148,8 @@ function bundleRoute(path: string, method: string | undefined): Route | number {
|
|
|
133
148
|
return method === 'POST' ? { method: 'POST', run: (req, deps, store) => restore(req, store, deps) } : 405;
|
|
134
149
|
}
|
|
135
150
|
|
|
136
|
-
function settingsRoute(method: string | undefined): Route | number {
|
|
151
|
+
function settingsRoute(path: string, method: string | undefined): Route | number {
|
|
152
|
+
if (path === FALLBACKS) return method === 'PUT' ? { method: 'POST', run: chooseFallbacks } : 405;
|
|
137
153
|
if (method === 'GET') return { method: 'GET', run: (_req, deps, store) => settingsWithUsage(deps, store) };
|
|
138
154
|
if (method === 'PUT') return { method: 'POST', run: chooseRoute };
|
|
139
155
|
return 405;
|
|
@@ -155,26 +171,23 @@ function codexRoute(rest: string, method: string | undefined): Route | number {
|
|
|
155
171
|
return codexDeviceRoute(id, method);
|
|
156
172
|
}
|
|
157
173
|
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
path.startsWith(BEDROCK);
|
|
174
|
+
const TABLES: [string, Record<string, Route>][] = [
|
|
175
|
+
[OPENROUTER, OPENROUTER_ROUTES],
|
|
176
|
+
[ANTHROPIC, ANTHROPIC_ROUTES],
|
|
177
|
+
[BEDROCK, BEDROCK_ROUTES],
|
|
178
|
+
[GEMINI, GEMINI_ROUTES],
|
|
179
|
+
];
|
|
180
|
+
const EXACT = new Set([PATH, FALLBACKS, BUNDLE, RESTORE, CONNECTIONS]);
|
|
181
|
+
const PREFIXES = [`${CONNECTIONS}/`, CODEX, ...TABLES.map(([prefix]) => prefix)];
|
|
182
|
+
|
|
183
|
+
const mine = (path: string): boolean => EXACT.has(path) || PREFIXES.some((prefix) => path.startsWith(prefix));
|
|
169
184
|
|
|
170
185
|
function routeFor(path: string, method: string | undefined): Route | number {
|
|
171
|
-
if (path === PATH) return settingsRoute(method);
|
|
186
|
+
if (path === PATH || path === FALLBACKS) return settingsRoute(path, method);
|
|
172
187
|
if (path === BUNDLE || path === RESTORE) return bundleRoute(path, method);
|
|
173
188
|
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);
|
|
189
|
+
const table = TABLES.find(([prefix]) => path.startsWith(prefix));
|
|
190
|
+
if (table !== undefined) return named(table[1], path.slice(table[0].length), method);
|
|
178
191
|
return codexRoute(path.slice(CODEX.length), method);
|
|
179
192
|
}
|
|
180
193
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { newId } from '@metro-labs/core/ids';
|
|
2
|
+
import { ID_RE, newId } from '@metro-labs/core/ids';
|
|
3
3
|
import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
|
|
4
4
|
import { agentsDir } from '../agents/files.js';
|
|
5
5
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
@@ -9,6 +9,13 @@ import { tokensFromDisk as geminiTokensFromDisk, type GeminiTokens } from './gem
|
|
|
9
9
|
export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex', 'gemini'] as const;
|
|
10
10
|
export type Provider = (typeof PROVIDERS)[number];
|
|
11
11
|
|
|
12
|
+
export interface ClaudeLogin {
|
|
13
|
+
id: string;
|
|
14
|
+
email: string | null;
|
|
15
|
+
plan: string | null;
|
|
16
|
+
savedAt: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
12
19
|
export interface Connection {
|
|
13
20
|
id: string;
|
|
14
21
|
provider: Provider;
|
|
@@ -19,12 +26,19 @@ export interface Connection {
|
|
|
19
26
|
zdr: boolean;
|
|
20
27
|
codex: CodexTokens | null;
|
|
21
28
|
gemini: GeminiTokens | null;
|
|
29
|
+
claude: ClaudeLogin | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Fallback {
|
|
33
|
+
connection: string;
|
|
34
|
+
model: string;
|
|
22
35
|
}
|
|
23
36
|
|
|
24
37
|
export interface ModelConfig {
|
|
25
38
|
version: 2;
|
|
26
39
|
route: string;
|
|
27
40
|
connections: Connection[];
|
|
41
|
+
fallbacks?: Fallback[];
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
export interface Route {
|
|
@@ -38,6 +52,7 @@ const MODEL_FILE = 'model.json';
|
|
|
38
52
|
const MAX_FIELD = 512;
|
|
39
53
|
const MAX_LABEL = 60;
|
|
40
54
|
const MAX_CONNECTIONS = 20;
|
|
55
|
+
const MAX_FALLBACKS = 8;
|
|
41
56
|
const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex|gemini):(.+)$/;
|
|
42
57
|
const SMALL_RE = /haiku/i;
|
|
43
58
|
|
|
@@ -66,8 +81,14 @@ const newConnection = (provider: Provider, label: string): Connection => ({
|
|
|
66
81
|
zdr: false,
|
|
67
82
|
codex: null,
|
|
68
83
|
gemini: null,
|
|
84
|
+
claude: null,
|
|
69
85
|
});
|
|
70
86
|
|
|
87
|
+
function claudeFromDisk(raw: unknown): ClaudeLogin | null {
|
|
88
|
+
if (!isRecord(raw) || !ID_RE.test(text(raw.id))) return null;
|
|
89
|
+
return { id: text(raw.id), email: maybe(raw.email), plan: maybe(raw.plan), savedAt: text(raw.savedAt) };
|
|
90
|
+
}
|
|
91
|
+
|
|
71
92
|
function codexFromDisk(raw: unknown): CodexTokens | null {
|
|
72
93
|
if (!isRecord(raw)) return null;
|
|
73
94
|
const accessToken = text(raw.accessToken);
|
|
@@ -99,14 +120,33 @@ function connectionFromDisk(raw: unknown): Connection | null {
|
|
|
99
120
|
zdr: raw.zdr === true,
|
|
100
121
|
codex: codexFromDisk(raw.codex),
|
|
101
122
|
gemini: geminiTokensFromDisk(raw.gemini),
|
|
123
|
+
claude: provider === 'anthropic' ? claudeFromDisk(raw.claude) : null,
|
|
102
124
|
};
|
|
103
125
|
}
|
|
104
126
|
|
|
127
|
+
function fallbacksOf(raw: unknown, connections: Connection[]): Fallback[] {
|
|
128
|
+
if (!Array.isArray(raw)) return [];
|
|
129
|
+
const known = raw.flatMap((f: unknown) => {
|
|
130
|
+
if (!isRecord(f)) return [];
|
|
131
|
+
const entry = { connection: text(f.connection), model: text(f.model).slice(0, MAX_FIELD) };
|
|
132
|
+
return entry.model !== '' && connections.some((c) => c.id === entry.connection) ? [entry] : [];
|
|
133
|
+
});
|
|
134
|
+
return known.filter((f, at) => known.findIndex((o) => o.connection === f.connection && o.model === f.model) === at).slice(0, MAX_FALLBACKS);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const withFallbacks = (cfg: ModelConfig, fallbacks: Fallback[]): ModelConfig => ({
|
|
138
|
+
version: 2,
|
|
139
|
+
route: cfg.route,
|
|
140
|
+
connections: cfg.connections,
|
|
141
|
+
...(fallbacks.length === 0 ? {} : { fallbacks }),
|
|
142
|
+
});
|
|
143
|
+
|
|
105
144
|
export function parseModelConfig(raw: unknown): ModelConfig {
|
|
106
145
|
if (!isRecord(raw) || !Array.isArray(raw.connections)) return empty();
|
|
107
146
|
const connections = raw.connections.flatMap((c: unknown) => connectionFromDisk(c) ?? []).slice(0, MAX_CONNECTIONS);
|
|
108
147
|
const route = text(raw.route);
|
|
109
|
-
|
|
148
|
+
const cfg: ModelConfig = { version: 2, route: connections.some((c) => c.id === route) ? route : (connections[0]?.id ?? ''), connections };
|
|
149
|
+
return withFallbacks(cfg, fallbacksOf(raw.fallbacks, connections));
|
|
110
150
|
}
|
|
111
151
|
|
|
112
152
|
const needsIds = (raw: unknown): boolean =>
|
|
@@ -160,7 +200,7 @@ export function notReady(cfg: ModelConfig, conn = routedConnection(cfg)): string
|
|
|
160
200
|
return CHECKS[conn.provider].find(([missing]) => missing(conn))?.[1] ?? null;
|
|
161
201
|
}
|
|
162
202
|
|
|
163
|
-
const isSmallModel = (requested: string): boolean => SMALL_RE.test(requested);
|
|
203
|
+
export const isSmallModel = (requested: string): boolean => SMALL_RE.test(requested);
|
|
164
204
|
|
|
165
205
|
const DEFAULTS: Record<Provider, (requested: string, c: Connection) => string> = {
|
|
166
206
|
openrouter: (requested, c) => (requested.includes('/') ? requested : c.model),
|
|
@@ -176,6 +216,8 @@ function forProvider(cfg: ModelConfig, provider: Provider): Connection | null {
|
|
|
176
216
|
return cfg.connections.find((c) => c.provider === provider) ?? null;
|
|
177
217
|
}
|
|
178
218
|
|
|
219
|
+
export const bareModel = (requested: string): string => PREFIX_RE.exec(requested)?.[2]?.trim() ?? requested;
|
|
220
|
+
|
|
179
221
|
export function resolveRoute(requested: string, cfg: ModelConfig): Route | null {
|
|
180
222
|
const explicit = PREFIX_RE.exec(requested);
|
|
181
223
|
if (explicit !== null && isProvider(explicit[1])) {
|
|
@@ -192,6 +234,7 @@ export const routeLabel = (route: Route): string =>
|
|
|
192
234
|
export const PASSTHROUGH_ID = 'passthrough';
|
|
193
235
|
|
|
194
236
|
function signedInAs(c: Connection): { account: string | null; plan: string | null } {
|
|
237
|
+
if (c.claude !== null) return { account: c.claude.email, plan: c.claude.plan };
|
|
195
238
|
if (c.codex === null) return { account: c.gemini?.email ?? null, plan: c.gemini?.tier ?? null };
|
|
196
239
|
const claims = claimsOf(c.codex.idToken);
|
|
197
240
|
return { account: c.codex.email ?? claims.email, plan: c.codex.plan ?? claims.plan };
|
|
@@ -206,7 +249,7 @@ function publicConnection(c: Connection): Record<string, unknown> {
|
|
|
206
249
|
hasKey: c.apiKey !== '',
|
|
207
250
|
region: c.region,
|
|
208
251
|
zdr: c.zdr,
|
|
209
|
-
signedIn: c.codex !== null || c.gemini !== null,
|
|
252
|
+
signedIn: c.codex !== null || c.gemini !== null || c.claude !== null,
|
|
210
253
|
...signedInAs(c),
|
|
211
254
|
};
|
|
212
255
|
}
|
|
@@ -227,6 +270,9 @@ export const setCodexAuth = (cfg: ModelConfig, id: string, auth: CodexTokens | n
|
|
|
227
270
|
export const setGeminiAuth = (cfg: ModelConfig, id: string, auth: GeminiTokens | null): ModelConfig =>
|
|
228
271
|
withConnection(cfg, id, (c) => ({ ...c, gemini: auth }));
|
|
229
272
|
|
|
273
|
+
export const setClaudeLogin = (cfg: ModelConfig, id: string, login: ClaudeLogin | null): ModelConfig =>
|
|
274
|
+
withConnection(cfg, id, (c) => ({ ...c, claude: login }));
|
|
275
|
+
|
|
230
276
|
function field(patch: Record<string, unknown>, key: string, current: string, what: string, max = MAX_FIELD): string {
|
|
231
277
|
if (!(key in patch)) return current;
|
|
232
278
|
const value = patch[key];
|
|
@@ -276,7 +322,24 @@ export function updateConnection(cfg: ModelConfig, id: string, patch: unknown):
|
|
|
276
322
|
export function removeConnection(cfg: ModelConfig, id: string): ModelConfig {
|
|
277
323
|
requireConnection(cfg, id);
|
|
278
324
|
const connections = cfg.connections.filter((c) => c.id !== id);
|
|
279
|
-
|
|
325
|
+
const next = { ...cfg, route: cfg.route === id ? (connections[0]?.id ?? '') : cfg.route, connections };
|
|
326
|
+
return withFallbacks(next, (cfg.fallbacks ?? []).filter((f) => f.connection !== id));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function fallbackEntry(cfg: ModelConfig, raw: unknown): Fallback {
|
|
330
|
+
if (!isRecord(raw)) throw new ModelConfigError('each fallback must be an object with a connection and a model');
|
|
331
|
+
const connection = field(raw, 'connection', '', 'connection');
|
|
332
|
+
const model = field(raw, 'model', '', 'model');
|
|
333
|
+
requireConnection(cfg, connection);
|
|
334
|
+
if (model === '') throw new ModelConfigError('each fallback needs a model');
|
|
335
|
+
return { connection, model };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function setFallbacks(cfg: ModelConfig, raw: unknown): ModelConfig {
|
|
339
|
+
if (!Array.isArray(raw)) throw new ModelConfigError('fallbacks must be a list');
|
|
340
|
+
if (raw.length > MAX_FALLBACKS) throw new ModelConfigError(`a box keeps at most ${String(MAX_FALLBACKS)} fallbacks`);
|
|
341
|
+
const entries = raw.map((f: unknown) => fallbackEntry(cfg, f));
|
|
342
|
+
return withFallbacks(cfg, fallbacksOf(entries, cfg.connections));
|
|
280
343
|
}
|
|
281
344
|
|
|
282
345
|
export function setRoute(cfg: ModelConfig, id: string): ModelConfig {
|
|
@@ -42,6 +42,8 @@ const saved = (store: Store, cfg: ModelConfig, note: string, fields: { connectio
|
|
|
42
42
|
|
|
43
43
|
function keepCodex(store: Store, req: IncomingMessage, tokens: CodexTokens | null, note: string): unknown {
|
|
44
44
|
const { cfg, id } = connectionToFill(store, req, 'codex');
|
|
45
|
+
const twin = cfg.connections.find((c) => c.id !== id && tokens !== null && c.codex?.refreshToken === tokens.refreshToken);
|
|
46
|
+
if (twin !== undefined) throw new ApiError(`this ChatGPT login is already connected as ${twin.label}; sign in with ChatGPT to add another one`, 409);
|
|
45
47
|
const next = setCodexAuth(cfg, id, tokens);
|
|
46
48
|
return saved(store, next, note, { connection: id, plan: tokens?.plan ?? null });
|
|
47
49
|
}
|
|
@@ -5,8 +5,10 @@ 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';
|
|
11
|
+
import type { ClaudeLoginDeps } from './claude-logins.js';
|
|
10
12
|
|
|
11
13
|
export interface ModelApiDeps {
|
|
12
14
|
read?: () => ModelConfig;
|
|
@@ -24,10 +26,13 @@ export interface ModelApiDeps {
|
|
|
24
26
|
bedrockControlBase?: string;
|
|
25
27
|
setup?: SetupDeps;
|
|
26
28
|
restartSession?: () => boolean;
|
|
27
|
-
claudeUsage?: () => Promise<unknown>;
|
|
29
|
+
claudeUsage?: (env?: Record<string, string>) => Promise<unknown>;
|
|
30
|
+
claudeLoginsRoot?: string;
|
|
28
31
|
usageWaitMs?: number;
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
export const claudeLoginDeps = (deps: ModelApiDeps): ClaudeLoginDeps => ({ root: deps.claudeLoginsRoot, probe: deps.claudeUsage });
|
|
35
|
+
|
|
31
36
|
export interface Store {
|
|
32
37
|
read: () => ModelConfig;
|
|
33
38
|
write: (cfg: ModelConfig) => void;
|
|
@@ -45,6 +50,8 @@ export const CREDITS_TTL_MS = 5 * 60_000;
|
|
|
45
50
|
|
|
46
51
|
export const settingsBody = (cfg: ModelConfig): Record<string, unknown> => ({
|
|
47
52
|
...publicModelConfig(cfg),
|
|
53
|
+
fallbacks: cfg.fallbacks ?? [],
|
|
54
|
+
chain: chainStatus(cfg),
|
|
48
55
|
lastServed: lastServed(),
|
|
49
56
|
usage: usageSeen(),
|
|
50
57
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IncomingHttpHeaders } from 'node:http';
|
|
2
2
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
-
import { GatewayError } from './forward.js';
|
|
3
|
+
import { GatewayError, OAUTH_BETA } from './forward.js';
|
|
4
4
|
import type { Connection } from './model-config.js';
|
|
5
5
|
import { fingerprint, listCache } from './model-lists.js';
|
|
6
6
|
import { stringOf } from '@metro-labs/http/api-http';
|
|
@@ -9,7 +9,6 @@ export const ANTHROPIC_API = 'https://api.anthropic.com';
|
|
|
9
9
|
const ANTHROPIC_VERSION = '2023-06-01';
|
|
10
10
|
const LIST_MAX = 1000;
|
|
11
11
|
const LIST_TIMEOUT_MS = 10_000;
|
|
12
|
-
const OAUTH_BETA = 'oauth-2025-04-20';
|
|
13
12
|
const BEARER = 'Bearer ';
|
|
14
13
|
|
|
15
14
|
export interface ProviderModel {
|
|
@@ -3,10 +3,11 @@ 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
|
-
import { CREDITS_TTL_MS, type ModelApiDeps, type Store } from './model-store.js';
|
|
9
|
+
import { claudeLoginDeps, CREDITS_TTL_MS, type ModelApiDeps, type Store } from './model-store.js';
|
|
10
|
+
import { probeClaudeLogin } from './claude-logins.js';
|
|
10
11
|
|
|
11
12
|
const WAIT_MS = 4_000;
|
|
12
13
|
const LOGIN_PROBE = 'claude-login';
|
|
@@ -41,14 +42,15 @@ async function refreshCodex(conn: Connection, deps: ModelApiDeps, store: Store,
|
|
|
41
42
|
});
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
const modelsInUse = (conn: Connection): Set<string> => {
|
|
45
|
+
const modelsInUse = (conn: Connection, cfg: ModelConfig): Set<string> => {
|
|
45
46
|
const served = lastServed();
|
|
46
|
-
|
|
47
|
+
const fallbacks = (cfg.fallbacks ?? []).filter((f) => f.connection === conn.id).map((f) => f.model);
|
|
48
|
+
return new Set([conn.model, served?.connection === conn.id ? served.model : '', ...fallbacks].filter((m) => m !== ''));
|
|
47
49
|
};
|
|
48
50
|
|
|
49
51
|
async function refreshQuota(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
|
|
50
52
|
if (conn.gemini === null) return;
|
|
51
|
-
const inUse = modelsInUse(conn);
|
|
53
|
+
const inUse = modelsInUse(conn, store.read());
|
|
52
54
|
const seen = usageOf(conn.id);
|
|
53
55
|
const covers = seen !== undefined && [...inUse].every((id) => seen.windows.some((w) => w.label === id));
|
|
54
56
|
if (seen !== undefined && covers && now - Date.parse(seen.at) < CREDITS_TTL_MS) return;
|
|
@@ -61,28 +63,38 @@ async function refreshQuota(conn: Connection, deps: ModelApiDeps, store: Store,
|
|
|
61
63
|
|
|
62
64
|
const loginIds = (cfg: ModelConfig): string[] => [
|
|
63
65
|
...(routedConnection(cfg) === null ? [PASSTHROUGH_ID] : []),
|
|
64
|
-
...cfg.connections.filter((c) => c.provider === 'anthropic' && c.apiKey === '').map((c) => c.id),
|
|
66
|
+
...cfg.connections.filter((c) => c.provider === 'anthropic' && c.apiKey === '' && c.claude === null).map((c) => c.id),
|
|
65
67
|
];
|
|
66
68
|
|
|
67
|
-
const
|
|
69
|
+
const hasFallbacks = (cfg: ModelConfig): boolean => (cfg.fallbacks ?? []).length > 0;
|
|
70
|
+
|
|
71
|
+
async function refreshOwnLogin(conn: Connection, cfg: ModelConfig, deps: ModelApiDeps, now: number): Promise<void> {
|
|
72
|
+
const login = conn.apiKey === '' ? conn.claude : null;
|
|
73
|
+
if (login === null || (fresh(conn.id, now) && !hasFallbacks(cfg)) || !mayProbe(conn.id, now, CREDITS_TTL_MS)) return;
|
|
74
|
+
await probe('usage of a Claude login', async () => {
|
|
75
|
+
const usage = claudeLoginUsage(await probeClaudeLogin(login.id, claudeLoginDeps(deps)), new Date(now));
|
|
76
|
+
if (usage !== null) mergeUsage(conn.id, usage);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
68
79
|
|
|
69
80
|
async function refreshLogin(cfg: ModelConfig, deps: ModelApiDeps, now: number): Promise<void> {
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
81
|
+
const ids = loginIds(cfg);
|
|
82
|
+
if (!ids.some((id) => !fresh(id, now)) && !(hasFallbacks(cfg) && ids.length > 0)) return;
|
|
72
83
|
if (mayProbe(LOGIN_PROBE, now, CREDITS_TTL_MS))
|
|
73
84
|
await probe('usage of the Claude Code login', async () => {
|
|
74
|
-
const usage = claudeLoginUsage(await (deps.claudeUsage ?? readClaudeUsage)(), new Date(now));
|
|
85
|
+
const usage = claudeLoginUsage(await (deps.claudeUsage ?? (() => readClaudeUsage()))(), new Date(now));
|
|
75
86
|
if (usage !== null) keepProbeAnswer(LOGIN_PROBE, usage);
|
|
76
87
|
});
|
|
77
88
|
const usage = probeAnswer(LOGIN_PROBE);
|
|
78
89
|
if (usage === undefined || now - Date.parse(usage.at) >= CREDITS_TTL_MS) return;
|
|
79
|
-
for (const id of
|
|
90
|
+
for (const id of ids) mergeUsage(id, usage);
|
|
80
91
|
}
|
|
81
92
|
|
|
82
|
-
function refreshOne(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
|
|
93
|
+
function refreshOne(conn: Connection, cfg: ModelConfig, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
|
|
83
94
|
if (conn.provider === 'openrouter') return refreshKey(conn, deps, now);
|
|
84
95
|
if (conn.provider === 'codex') return refreshCodex(conn, deps, store, now);
|
|
85
96
|
if (conn.provider === 'gemini') return refreshQuota(conn, deps, store, now);
|
|
97
|
+
if (conn.provider === 'anthropic') return refreshOwnLogin(conn, cfg, deps, now);
|
|
86
98
|
return Promise.resolve();
|
|
87
99
|
}
|
|
88
100
|
|
|
@@ -97,6 +109,17 @@ async function within(ms: number, work: Promise<unknown>): Promise<void> {
|
|
|
97
109
|
|
|
98
110
|
export async function refreshUsage(deps: ModelApiDeps, store: Store, now = Date.now()): Promise<void> {
|
|
99
111
|
const cfg = store.read();
|
|
100
|
-
const work = Promise.all([...cfg.connections.map((conn) => refreshOne(conn, deps, store, now)), refreshLogin(cfg, deps, now)]);
|
|
112
|
+
const work = Promise.all([...cfg.connections.map((conn) => refreshOne(conn, cfg, deps, store, now)), refreshLogin(cfg, deps, now)]);
|
|
101
113
|
await within(deps.usageWaitMs ?? WAIT_MS, work);
|
|
102
114
|
}
|
|
115
|
+
|
|
116
|
+
export function watchFallbackUsage(deps: ModelApiDeps = {}, every = CREDITS_TTL_MS): void {
|
|
117
|
+
const store: Store = { read: deps.read ?? readModelConfig, write: deps.write ?? writeModelConfig };
|
|
118
|
+
const run = (): void => {
|
|
119
|
+
if (!hasFallbacks(store.read())) return;
|
|
120
|
+
refreshUsage(deps, store).catch((err: unknown) => {
|
|
121
|
+
log.warn({ err: errMsg(err) }, 'model-api: could not refresh the usage of the fallback models');
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
setInterval(run, every).unref();
|
|
125
|
+
}
|