@stage-labs/metro 0.1.0-beta.308 → 0.1.0-beta.309
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/node_modules/@metro-labs/daemon/src/agent-user/view.ts +3 -9
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +11 -4
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +7 -6
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +1 -6
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +53 -29
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-store.ts +55 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +40 -62
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/delivered.ts +82 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +47 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +18 -19
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +36 -64
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +99 -213
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +80 -283
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-import.ts +125 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-lists.ts +0 -17
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-v2.ts +79 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter.ts +0 -79
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/org-models.ts +231 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +4 -44
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/text.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +37 -57
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +0 -13
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +3 -4
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-auth.ts +0 -219
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-device.ts +0 -119
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-auth.ts +0 -160
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-client.ts +0 -46
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-setup.ts +0 -150
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-stream.ts +0 -139
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-translate.ts +0 -236
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +0 -208
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +0 -157
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +0 -82
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter-login.ts +0 -192
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter-signin.ts +0 -64
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/pkce.ts +0 -36
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { log } from '@metro-labs/core/log';
|
|
2
2
|
import { lastServed } from './served.js';
|
|
3
3
|
import { PER_MINUTE, usageOf, type UsageWindow } from './usage.js';
|
|
4
|
-
import { bareModel, connectionOf, isSmallModel, notReady,
|
|
4
|
+
import { bareModel, connectionOf, isSmallModel, notReady, primaryRoute, routeLabel, type Connection, type Fallback, type ModelSetup, type Route } from './model-config.js';
|
|
5
5
|
|
|
6
6
|
export const USAGE_LIMIT = 0.95;
|
|
7
7
|
const COOLDOWN_MS = 60_000;
|
|
@@ -36,7 +36,6 @@ function scopeOf(label: string): string | null {
|
|
|
36
36
|
function applies(window: UsageWindow, route: Route, now: number): boolean {
|
|
37
37
|
if (window.used === null || window.label === PER_MINUTE) return false;
|
|
38
38
|
if (window.resetAt !== null && Date.parse(window.resetAt) <= now) return false;
|
|
39
|
-
if (route.connection.provider === 'gemini') return window.label === route.model;
|
|
40
39
|
const scope = scopeOf(window.label);
|
|
41
40
|
return scope === null || route.model.toLowerCase().split(/[^a-z0-9]+/).includes(scope);
|
|
42
41
|
}
|
|
@@ -59,16 +58,16 @@ function fallbackModel(requested: string, conn: Connection, model: string): stri
|
|
|
59
58
|
return conn.provider === 'anthropic' && isSmallModel(bare) ? bare : model;
|
|
60
59
|
}
|
|
61
60
|
|
|
62
|
-
function fallbackRoute(
|
|
63
|
-
const conn = connectionOf(
|
|
64
|
-
if (conn === null || notReady(
|
|
61
|
+
function fallbackRoute(setup: ModelSetup, fallback: Fallback, requested: string): Route | null {
|
|
62
|
+
const conn = connectionOf(setup, fallback.connection);
|
|
63
|
+
if (conn === null || notReady({ connection: conn, model: fallback.model }) !== null) return null;
|
|
65
64
|
return { connection: conn, model: fallbackModel(requested, conn, fallback.model) };
|
|
66
65
|
}
|
|
67
66
|
|
|
68
67
|
const unique = (routes: Route[]): Route[] => routes.filter((route, at) => routes.findIndex((other) => keyOf(other) === keyOf(route)) === at);
|
|
69
68
|
|
|
70
|
-
export const chainOf = (primary: Route,
|
|
71
|
-
unique([primary, ...
|
|
69
|
+
export const chainOf = (primary: Route, setup: ModelSetup, requested: string): Route[] =>
|
|
70
|
+
unique([primary, ...setup.fallbacks.flatMap((f) => fallbackRoute(setup, f, requested) ?? [])]);
|
|
72
71
|
|
|
73
72
|
export function routesToTry(chain: Route[], now = Date.now()): Route[] {
|
|
74
73
|
const open = chain.filter((route) => holdOf(route, now) === null);
|
|
@@ -120,28 +119,28 @@ export interface ChainRow {
|
|
|
120
119
|
active: boolean;
|
|
121
120
|
}
|
|
122
121
|
|
|
123
|
-
function primaryOf(
|
|
124
|
-
const
|
|
125
|
-
if (
|
|
122
|
+
function primaryOf(setup: ModelSetup): Route | null {
|
|
123
|
+
const primary = primaryRoute(setup);
|
|
124
|
+
if (primary === null) return null;
|
|
126
125
|
const served = lastServed();
|
|
127
|
-
const model =
|
|
128
|
-
return { connection:
|
|
126
|
+
const model = primary.model !== '' ? primary.model : served?.connection === primary.connection.id ? served.model : '';
|
|
127
|
+
return { connection: primary.connection, model };
|
|
129
128
|
}
|
|
130
129
|
|
|
131
|
-
function rowOf(
|
|
132
|
-
const reason = notReady(
|
|
130
|
+
function rowOf(route: Route, now: number): Omit<ChainRow, 'active'> {
|
|
131
|
+
const reason = notReady(route);
|
|
133
132
|
const used = windowsFor(route, now).reduce<number | null>((most, w) => Math.max(most ?? 0, w.used ?? 0), null);
|
|
134
133
|
return { connection: route.connection.id, model: route.model, used, hold: reason === null ? holdOf(route, now) : { reason, until: null } };
|
|
135
134
|
}
|
|
136
135
|
|
|
137
|
-
export function chainStatus(
|
|
138
|
-
const primary = primaryOf(
|
|
136
|
+
export function chainStatus(setup: ModelSetup, now = Date.now()): ChainRow[] {
|
|
137
|
+
const primary = primaryOf(setup);
|
|
139
138
|
if (primary === null) return [];
|
|
140
|
-
const listed =
|
|
141
|
-
const conn = connectionOf(
|
|
139
|
+
const listed = setup.fallbacks.flatMap((f) => {
|
|
140
|
+
const conn = connectionOf(setup, f.connection);
|
|
142
141
|
return conn === null ? [] : [{ connection: conn, model: f.model }];
|
|
143
142
|
});
|
|
144
|
-
const rows = unique([primary, ...listed]).map((route) => rowOf(
|
|
143
|
+
const rows = unique([primary, ...listed]).map((route) => rowOf(route, now));
|
|
145
144
|
const active = Math.max(0, rows.findIndex((row) => row.hold === null));
|
|
146
145
|
return rows.map((row, at) => ({ ...row, active: at === active }));
|
|
147
146
|
}
|
|
@@ -3,7 +3,6 @@ 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';
|
|
5
5
|
import {
|
|
6
|
-
assertBedrockReady,
|
|
7
6
|
bedrockBase,
|
|
8
7
|
bedrockCount,
|
|
9
8
|
bedrockMessages,
|
|
@@ -37,21 +36,17 @@ import {
|
|
|
37
36
|
withEffort,
|
|
38
37
|
withThinkingFor,
|
|
39
38
|
} from './effort.js';
|
|
40
|
-
import { notReady,
|
|
41
|
-
import { codexMessages,
|
|
42
|
-
import { geminiMessages, sharedGeminiState } from './gemini.js';
|
|
39
|
+
import { notReady, resolveRoute, routeLabel, sessionConnection, type Connection, type ModelSetup, type Route } from './model-config.js';
|
|
40
|
+
import { codexMessages, type CodexDeps } from './codex.js';
|
|
43
41
|
import { countTokens } from './subscription.js';
|
|
44
|
-
import type { GeminiDeps } from './gemini.js';
|
|
45
|
-
import type { CodexDeps } from './codex.js';
|
|
46
|
-
import type { GeminiTokens } from './gemini-auth.js';
|
|
47
42
|
import { OPENROUTER_BASE } from './openrouter.js';
|
|
48
43
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
49
44
|
import { forgetServed, noteServed } from './served.js';
|
|
50
45
|
import { forgetUsage, noteUsageHeaders, UsageScanner } from './usage.js';
|
|
51
46
|
import { fitToolSearch } from './tool-search.js';
|
|
52
47
|
import { ANTHROPIC_API, refreshLoginModels } from './provider-models.js';
|
|
53
|
-
import type { CodexTokens } from './codex-auth.js';
|
|
54
48
|
import { forgetClaudeTokens, withClaudeLogin, type ClaudeLoginDeps } from './claude-logins.js';
|
|
49
|
+
import { orgModels } from './org-models.js';
|
|
55
50
|
|
|
56
51
|
const GATEWAY_PREFIX = '/gateway';
|
|
57
52
|
const MESSAGES = '/v1/messages';
|
|
@@ -60,13 +55,12 @@ const MODELS = '/v1/models';
|
|
|
60
55
|
const HELLO = '/api/hello';
|
|
61
56
|
|
|
62
57
|
export interface GatewayDeps {
|
|
63
|
-
|
|
58
|
+
setup: () => ModelSetup;
|
|
64
59
|
identify?: (key: string) => boolean;
|
|
65
60
|
anthropicBase?: string;
|
|
66
61
|
bedrockBase?: string;
|
|
67
62
|
openrouterBase?: string;
|
|
68
63
|
codex?: Partial<CodexDeps>;
|
|
69
|
-
gemini?: Partial<GeminiDeps>;
|
|
70
64
|
claudeLogins?: ClaudeLoginDeps;
|
|
71
65
|
}
|
|
72
66
|
|
|
@@ -79,18 +73,10 @@ export function resetGatewayState(): void {
|
|
|
79
73
|
learned.fields.clear();
|
|
80
74
|
learned.dropBetas = false;
|
|
81
75
|
forgetLearnedThinking();
|
|
82
|
-
sharedCodexState.clear();
|
|
83
|
-
sharedGeminiState.clear();
|
|
84
76
|
forgetClaudeTokens();
|
|
85
77
|
}
|
|
86
78
|
|
|
87
|
-
const
|
|
88
|
-
writeModelConfig(setCodexAuth(readModelConfig(), id, tokens));
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
const saveGeminiTokens = (id: string, tokens: GeminiTokens): void => {
|
|
92
|
-
writeModelConfig(setGeminiAuth(readModelConfig(), id, tokens));
|
|
93
|
-
};
|
|
79
|
+
const refreshCodex = (conn: Connection): Promise<Connection | null> => orgModels()?.refresh(conn) ?? Promise.resolve(null);
|
|
94
80
|
|
|
95
81
|
const keyOf = (req: IncomingMessage): string => {
|
|
96
82
|
const raw = req.headers['x-metro-key'];
|
|
@@ -99,27 +85,23 @@ const keyOf = (req: IncomingMessage): string => {
|
|
|
99
85
|
|
|
100
86
|
const defaultIdentify = (key: string): boolean => key !== '' && agentIdForKey(key) !== undefined;
|
|
101
87
|
|
|
102
|
-
function
|
|
103
|
-
|
|
104
|
-
.
|
|
105
|
-
|
|
106
|
-
|
|
88
|
+
function routesOf(setup: ModelSetup): Route[] {
|
|
89
|
+
return [...(setup.primary === null ? [] : [setup.primary]), ...setup.fallbacks].flatMap((ref) => {
|
|
90
|
+
const conn = setup.connections.find((c) => c.id === ref.connection);
|
|
91
|
+
const model = ref.model === '' ? (conn?.model ?? '') : ref.model;
|
|
92
|
+
return conn === undefined || model === '' ? [] : [{ connection: conn, model }];
|
|
93
|
+
});
|
|
107
94
|
}
|
|
108
95
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
zdr: false,
|
|
117
|
-
codex: null,
|
|
118
|
-
gemini: null,
|
|
119
|
-
claude: null,
|
|
120
|
-
};
|
|
96
|
+
function modelsBody(setup: ModelSetup): Record<string, unknown> {
|
|
97
|
+
const listed = routesOf(setup)
|
|
98
|
+
.filter((r) => r.connection.provider !== 'anthropic')
|
|
99
|
+
.map((r) => ({ id: routeLabel(r), display_name: `${r.connection.label} · ${r.model}`, description: 'Through metro' }));
|
|
100
|
+
const data = listed.filter((m, at) => listed.findIndex((o) => o.id === m.id) === at);
|
|
101
|
+
return { data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null };
|
|
102
|
+
}
|
|
121
103
|
|
|
122
|
-
const passthrough = (body: Record<string, unknown>): Route => ({ connection:
|
|
104
|
+
const passthrough = (body: Record<string, unknown>): Route => ({ connection: sessionConnection(), model: requestedModel(body) });
|
|
123
105
|
|
|
124
106
|
function requestedModel(body: Record<string, unknown>): string {
|
|
125
107
|
const model = body.model;
|
|
@@ -175,7 +157,7 @@ function listLoginModels(headers: IncomingHttpHeaders, anthropicBase: string): v
|
|
|
175
157
|
function sendAnthropic(req: IncomingMessage, conn: Connection, anthropicBase: string, attempt: (base: Record<string, string>) => Promise<Response>, deps: GatewayDeps): Promise<Response> {
|
|
176
158
|
if (conn.apiKey !== '') return attempt(anthropicHeaders(req, conn.apiKey));
|
|
177
159
|
if (conn.claude === null) return attempt(forwardedHeaders(req));
|
|
178
|
-
return withClaudeLogin(conn.claude
|
|
160
|
+
return withClaudeLogin(conn.claude, deps.claudeLogins ?? {}, (token) => {
|
|
179
161
|
listLoginModels({ authorization: `Bearer ${token}` }, anthropicBase);
|
|
180
162
|
return attempt(loginHeaders(req, token));
|
|
181
163
|
});
|
|
@@ -184,7 +166,7 @@ function sendAnthropic(req: IncomingMessage, conn: Connection, anthropicBase: st
|
|
|
184
166
|
async function refusedLogin(conn: Connection, upstream: Response): Promise<void> {
|
|
185
167
|
if (conn.apiKey !== '' || conn.claude === null || upstream.status !== 401) return;
|
|
186
168
|
await upstream.body?.cancel();
|
|
187
|
-
throw new GatewayError(403, 'permission_error', `Anthropic refused the Claude login
|
|
169
|
+
throw new GatewayError(403, 'permission_error', `Anthropic refused the Claude login ${conn.label}: sign in again on the Model page`);
|
|
188
170
|
}
|
|
189
171
|
|
|
190
172
|
async function toAnthropic(
|
|
@@ -204,7 +186,7 @@ async function toAnthropic(
|
|
|
204
186
|
throw new GatewayError(
|
|
205
187
|
403,
|
|
206
188
|
'permission_error',
|
|
207
|
-
|
|
189
|
+
"Claude Code on this machine has no Anthropic login of its own; route this agent to a model on the organization's Models page, or sign in a Claude login on the Model page",
|
|
208
190
|
);
|
|
209
191
|
const watch = watchUpstream(res);
|
|
210
192
|
if (passthrough) listLoginModels(req.headers, anthropicBase);
|
|
@@ -223,17 +205,8 @@ async function toAnthropic(
|
|
|
223
205
|
await pipeResponse(upstream, res, watch, passthrough ? { scanner } : { ownCredential: true, scanner });
|
|
224
206
|
}
|
|
225
207
|
|
|
226
|
-
async function toOpenRouter(
|
|
227
|
-
req: IncomingMessage,
|
|
228
|
-
res: ServerResponse,
|
|
229
|
-
body: Record<string, unknown>,
|
|
230
|
-
route: Route,
|
|
231
|
-
cfg: ModelConfig,
|
|
232
|
-
deps: GatewayDeps,
|
|
233
|
-
): Promise<void> {
|
|
208
|
+
async function toOpenRouter(req: IncomingMessage, res: ServerResponse, body: Record<string, unknown>, route: Route, deps: GatewayDeps): Promise<void> {
|
|
234
209
|
const conn = route.connection;
|
|
235
|
-
const reason = notReady(cfg, conn);
|
|
236
|
-
if (reason !== null) throw new GatewayError(400, 'invalid_request_error', reason);
|
|
237
210
|
const headers: Record<string, string> = {
|
|
238
211
|
'content-type': 'application/json',
|
|
239
212
|
accept: req.headers.accept ?? 'application/json',
|
|
@@ -270,11 +243,9 @@ function noteRefusal(connection: string, model: string, upstream: Response): voi
|
|
|
270
243
|
if (!upstream.ok) log.warn({ connection, model, status: upstream.status }, 'gateway: the provider refused the request');
|
|
271
244
|
}
|
|
272
245
|
|
|
273
|
-
async function
|
|
274
|
-
const conn = route.connection;
|
|
246
|
+
async function toCodex(req: IncomingMessage, res: ServerResponse, path: string, body: Record<string, unknown>, route: Route, deps: GatewayDeps): Promise<void> {
|
|
275
247
|
if (path === COUNT) countTokens(res, body);
|
|
276
|
-
else
|
|
277
|
-
else await codexMessages(req, res, body, route.model, conn, { save: saveCodexTokens, ...deps.codex }, sharedCodexState, watchUpstream(res));
|
|
248
|
+
else await codexMessages(req, res, body, route.model, route.connection, { refresh: refreshCodex, ...deps.codex }, watchUpstream(res));
|
|
278
249
|
}
|
|
279
250
|
|
|
280
251
|
function shapedFor(req: IncomingMessage, sent: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -290,16 +261,17 @@ interface Request {
|
|
|
290
261
|
parsed: Record<string, unknown>;
|
|
291
262
|
sent: Record<string, unknown>;
|
|
292
263
|
body: Record<string, unknown>;
|
|
293
|
-
|
|
264
|
+
setup: ModelSetup;
|
|
294
265
|
}
|
|
295
266
|
|
|
296
267
|
async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request, route: Route, deps: GatewayDeps): Promise<void> {
|
|
297
268
|
const { path, body } = ask;
|
|
298
269
|
const conn = route.connection;
|
|
299
270
|
log.info({ route: routeLabel(route), connection: conn.label, path }, 'gateway: routing');
|
|
271
|
+
const reason = notReady(route);
|
|
272
|
+
if (reason !== null) throw new GatewayError(400, 'invalid_request_error', reason);
|
|
300
273
|
if (path === MESSAGES) noteServed({ connection: conn.id, provider: conn.provider, model: route.model, at: new Date().toISOString() });
|
|
301
274
|
if (conn.provider === 'bedrock') {
|
|
302
|
-
assertBedrockReady(conn);
|
|
303
275
|
const up = { settings: conn, base: deps.bedrockBase ?? bedrockBase(conn.region), learned, watch: watchUpstream(res) };
|
|
304
276
|
if (path === COUNT) await bedrockCount(req, res, body, route.model, up);
|
|
305
277
|
else await bedrockMessages(req, res, body, route.model, up);
|
|
@@ -307,25 +279,25 @@ async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request,
|
|
|
307
279
|
}
|
|
308
280
|
if (conn.provider === 'openrouter') {
|
|
309
281
|
if (path === COUNT) throw new GatewayError(404, 'not_found_error', 'OpenRouter does not count tokens');
|
|
310
|
-
await toOpenRouter(req, res, body, route,
|
|
282
|
+
await toOpenRouter(req, res, body, route, deps);
|
|
311
283
|
return;
|
|
312
284
|
}
|
|
313
|
-
if (conn.provider === 'codex'
|
|
314
|
-
await
|
|
285
|
+
if (conn.provider === 'codex') {
|
|
286
|
+
await toCodex(req, res, path, body, route, deps);
|
|
315
287
|
return;
|
|
316
288
|
}
|
|
317
289
|
await toAnthropic(req, res, bytesOf(ask.raw, ask.parsed, ask.sent), ask.sent, body, route, deps);
|
|
318
290
|
}
|
|
319
291
|
|
|
320
292
|
async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
|
|
321
|
-
const
|
|
293
|
+
const setup = deps.setup();
|
|
322
294
|
const raw = await readBody(req);
|
|
323
295
|
const parsed = parseJson(raw);
|
|
324
296
|
const sent = fitToolSearch(req, parsed);
|
|
325
297
|
const requested = requestedModel(sent);
|
|
326
|
-
const primary = resolveRoute(requested,
|
|
327
|
-
const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent),
|
|
328
|
-
const routes = routesToTry(chainOf(primary,
|
|
298
|
+
const primary = resolveRoute(requested, setup) ?? passthrough(sent);
|
|
299
|
+
const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent), setup };
|
|
300
|
+
const routes = routesToTry(chainOf(primary, setup, requested));
|
|
329
301
|
for (const [at, route] of routes.entries()) {
|
|
330
302
|
const next = routes[at + 1];
|
|
331
303
|
if (path === MESSAGES) noteChoice(primary, route);
|
|
@@ -357,7 +329,7 @@ function failed(res: ServerResponse, err: unknown): void {
|
|
|
357
329
|
function answer(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): void {
|
|
358
330
|
if (req.method === 'GET' && path === MODELS) {
|
|
359
331
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
360
|
-
res.end(JSON.stringify(modelsBody(deps.
|
|
332
|
+
res.end(JSON.stringify(modelsBody(deps.setup())));
|
|
361
333
|
return;
|
|
362
334
|
}
|
|
363
335
|
if (req.method === 'POST' && (path === MESSAGES || path === COUNT)) {
|
|
@@ -1,217 +1,105 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
3
|
-
import { apiFailure, apiSession, cors,
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
readModelSnapshot,
|
|
16
|
-
removeConnection,
|
|
17
|
-
routedConnection,
|
|
18
|
-
setFallbacks,
|
|
19
|
-
setRoute,
|
|
20
|
-
updateConnection,
|
|
21
|
-
writeModelConfig,
|
|
22
|
-
type ModelConfig,
|
|
23
|
-
} from './model-config.js';
|
|
24
|
-
import { asApiError, BODY_MAX, claudeLoginDeps, connectionFor, settingsBody, type ModelApiDeps, type Route, type Store } from './model-store.js';
|
|
25
|
-
import { dropUnusedClaudeLogins } from './claude-logins.js';
|
|
26
|
-
import { CODEX_ROUTES, codexDeviceRoute, GEMINI_ROUTES } from './model-signin.js';
|
|
27
|
-
import { refreshUsage } from './usage-refresh.js';
|
|
28
|
-
import { handleOpenRouterSignIn } from './openrouter-signin.js';
|
|
3
|
+
import { apiFailure, apiSession, cors, requireAdmin, sendJson, type ApiSession } from '@metro-labs/http/api-http';
|
|
4
|
+
import { agentsDir } from '../agents/files.js';
|
|
5
|
+
import { removeClaudeLogin } from './claude-logins.js';
|
|
6
|
+
import { readClaudeLogins, type ClaudeLogin } from './claude-store.js';
|
|
7
|
+
import { effectiveSetup } from './effective.js';
|
|
8
|
+
import { chainStatus } from './fallback.js';
|
|
9
|
+
import { notReady, primaryRoute, type Connection, type ModelSetup } from './model-config.js';
|
|
10
|
+
import { orgModels, type SyncStatus } from './org-models.js';
|
|
11
|
+
import { claudeModels } from './provider-models.js';
|
|
12
|
+
import { lastServed } from './served.js';
|
|
13
|
+
import { refreshUsage, type UsageDeps } from './usage-refresh.js';
|
|
14
|
+
import { usageSeen } from './usage.js';
|
|
29
15
|
|
|
30
16
|
const PATH = '/api/model';
|
|
31
17
|
const SNAPSHOT = '/api/model/snapshot';
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
function
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return body;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async function settingsWithUsage(deps: ModelApiDeps, store: Store): Promise<Record<string, unknown>> {
|
|
121
|
-
await refreshUsage(deps, store);
|
|
122
|
-
return settingsBody(store.read());
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const OPENROUTER_ROUTES: Record<string, Route> = {
|
|
126
|
-
models: { method: 'GET', run: async (_req, deps) => ({ models: await openrouterModels(deps.openrouterBase, deps.fetchImpl).catch(asApiError) }) },
|
|
127
|
-
zdr: { method: 'GET', run: async (_req, deps) => ({ models: await openrouterZdrModels(deps.openrouterBase, deps.fetchImpl).catch(asApiError) }) },
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
const ANTHROPIC_ROUTES: Record<string, Route> = {
|
|
131
|
-
models: {
|
|
132
|
-
method: 'GET',
|
|
133
|
-
run: async (req, deps, store) => ({ models: await anthropicModels(connectionFor(store.read(), req, 'anthropic'), deps.anthropicBase, deps.fetchImpl).catch(asApiError) }),
|
|
134
|
-
},
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
const BEDROCK_ROUTES: Record<string, Route> = {
|
|
138
|
-
models: {
|
|
139
|
-
method: 'GET',
|
|
140
|
-
run: async (req, deps, store) => ({ models: await bedrockModels(connectionFor(store.read(), req, 'bedrock'), deps.bedrockControlBase, deps.fetchImpl).catch(asApiError) }),
|
|
141
|
-
},
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
const named = (table: Record<string, Route>, name: string, method: string | undefined): Route | number => {
|
|
145
|
-
const route = table[name];
|
|
146
|
-
if (route === undefined) return 404;
|
|
147
|
-
return route.method === method ? route : 405;
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
async function restore(req: IncomingMessage, store: Store, deps: ModelApiDeps): Promise<unknown> {
|
|
151
|
-
const body = await readJsonBody(req, RESTORE_MAX);
|
|
152
|
-
if (!isRecord(body)) throw new ApiError('body must be a JSON object', 400);
|
|
153
|
-
const before = store.read();
|
|
154
|
-
const next = parseModelConfig(body);
|
|
155
|
-
const answer = kept(store, next, deps, 'model-api: model setup restored from a file', {});
|
|
156
|
-
dropUnusedClaudeLogins(before, next, claudeLoginDeps(deps));
|
|
157
|
-
return answer;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function bundleRoute(path: string, method: string | undefined): Route | number {
|
|
161
|
-
if (path === BUNDLE) return method === 'GET' ? { method: 'GET', run: (_req, _deps, store) => Promise.resolve(store.read()) } : 405;
|
|
162
|
-
return method === 'POST' ? { method: 'POST', run: (req, deps, store) => restore(req, store, deps) } : 405;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function settingsRoute(path: string, method: string | undefined): Route | number {
|
|
166
|
-
if (path === FALLBACKS) return method === 'PUT' ? { method: 'POST', run: chooseFallbacks } : 405;
|
|
167
|
-
if (method === 'GET') return { method: 'GET', run: (_req, deps, store) => settingsWithUsage(deps, store) };
|
|
168
|
-
if (method === 'PUT') return { method: 'POST', run: chooseRoute };
|
|
169
|
-
return 405;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function connectionsRoute(path: string, method: string | undefined): Route | number {
|
|
173
|
-
if (path === CONNECTIONS) return method === 'POST' ? { method: 'POST', run: create } : 405;
|
|
174
|
-
const id = path.slice(CONNECTIONS.length + 1);
|
|
175
|
-
if (id === '' || id.includes('/')) return 404;
|
|
176
|
-
if (method === 'PUT') return { method: 'POST', run: (req, deps, store) => change(req, deps, store, id) };
|
|
177
|
-
if (method === 'DELETE') return { method: 'GET', run: (_req, deps, store) => Promise.resolve(drop(deps, store, id)) };
|
|
178
|
-
return 405;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function codexRoute(rest: string, method: string | undefined): Route | number {
|
|
182
|
-
if (rest in CODEX_ROUTES) return named(CODEX_ROUTES, rest, method);
|
|
183
|
-
const id = rest.startsWith(DEVICE_PREFIX) ? rest.slice(DEVICE_PREFIX.length) : '';
|
|
184
|
-
if (!DEVICE_ID_RE.test(id)) return 404;
|
|
185
|
-
return codexDeviceRoute(id, method);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const TABLES: [string, Record<string, Route>][] = [
|
|
189
|
-
[OPENROUTER, OPENROUTER_ROUTES],
|
|
190
|
-
[ANTHROPIC, ANTHROPIC_ROUTES],
|
|
191
|
-
[BEDROCK, BEDROCK_ROUTES],
|
|
192
|
-
[GEMINI, GEMINI_ROUTES],
|
|
193
|
-
];
|
|
194
|
-
const EXACT = new Set([PATH, SNAPSHOT, FALLBACKS, BUNDLE, RESTORE, CONNECTIONS]);
|
|
195
|
-
const PREFIXES = [`${CONNECTIONS}/`, CODEX, ...TABLES.map(([prefix]) => prefix)];
|
|
196
|
-
|
|
197
|
-
const mine = (path: string): boolean => EXACT.has(path) || PREFIXES.some((prefix) => path.startsWith(prefix));
|
|
198
|
-
|
|
199
|
-
function routeFor(path: string, method: string | undefined): Route | number {
|
|
200
|
-
if (path === SNAPSHOT) return method === 'GET' ? { method: 'GET', run: (_req, _deps, store) => Promise.resolve().then(() => settingsBody(store.read())).catch(asApiError) } : 405;
|
|
201
|
-
if (path === PATH || path === FALLBACKS) return settingsRoute(path, method);
|
|
202
|
-
if (path === BUNDLE || path === RESTORE) return bundleRoute(path, method);
|
|
203
|
-
if (path === CONNECTIONS || path.startsWith(`${CONNECTIONS}/`)) return connectionsRoute(path, method);
|
|
204
|
-
const table = TABLES.find(([prefix]) => path.startsWith(prefix));
|
|
205
|
-
if (table !== undefined) return named(table[1], path.slice(table[0].length), method);
|
|
206
|
-
return codexRoute(path.slice(CODEX.length), method);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const runnerIsUp = (deps: ModelApiDeps): boolean => (deps.runnerAlive ?? (() => runnerAlive()))();
|
|
210
|
-
|
|
211
|
-
const storeFor = (path: string, deps: ModelApiDeps): Store => ({
|
|
212
|
-
read: deps.read ?? (path === SNAPSHOT ? readModelSnapshot : readModelConfig),
|
|
213
|
-
write: deps.write ?? writeModelConfig,
|
|
214
|
-
});
|
|
18
|
+
const SYNC = '/api/model/sync';
|
|
19
|
+
const CLAUDE_MODELS = '/api/model/claude-models';
|
|
20
|
+
const CLAUDE_LOGIN = '/api/model/claude/';
|
|
21
|
+
const LOGIN_RE = /^[A-Za-z0-9_-]{11}$/;
|
|
22
|
+
|
|
23
|
+
export interface ModelApiDeps extends UsageDeps {
|
|
24
|
+
dir?: () => string;
|
|
25
|
+
setup?: () => ModelSetup;
|
|
26
|
+
sync?: () => Promise<void>;
|
|
27
|
+
syncStatus?: () => SyncStatus;
|
|
28
|
+
anthropicBase?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const NO_SYNC: SyncStatus = { enrolled: false, at: null, error: null };
|
|
32
|
+
|
|
33
|
+
function publicConnection(c: Connection): Record<string, unknown> {
|
|
34
|
+
return {
|
|
35
|
+
id: c.id,
|
|
36
|
+
provider: c.provider,
|
|
37
|
+
label: c.label,
|
|
38
|
+
model: c.model,
|
|
39
|
+
hasKey: c.apiKey !== '',
|
|
40
|
+
region: c.region,
|
|
41
|
+
zdr: c.zdr,
|
|
42
|
+
signedIn: c.codex !== null || (c.claude !== null && !c.signedOut),
|
|
43
|
+
signedOut: c.signedOut,
|
|
44
|
+
account: c.account.email,
|
|
45
|
+
plan: c.account.plan,
|
|
46
|
+
method: c.account.method,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const loginsOf = (deps: ModelApiDeps): ClaudeLogin[] => readClaudeLogins(deps.dir?.() ?? agentsDir());
|
|
51
|
+
const setupOf = (deps: ModelApiDeps): ModelSetup => (deps.setup ?? (() => effectiveSetup(deps.dir?.())))();
|
|
52
|
+
|
|
53
|
+
export function settingsBody(deps: ModelApiDeps): Record<string, unknown> {
|
|
54
|
+
const setup = setupOf(deps);
|
|
55
|
+
const primary = primaryRoute(setup);
|
|
56
|
+
const reason = primary === null ? null : notReady(primary);
|
|
57
|
+
return {
|
|
58
|
+
source: setup.source,
|
|
59
|
+
sync: (deps.syncStatus ?? (() => orgModels()?.syncStatus() ?? NO_SYNC))(),
|
|
60
|
+
route: primary?.connection.id ?? '',
|
|
61
|
+
model: primary?.model ?? '',
|
|
62
|
+
ready: reason === null,
|
|
63
|
+
reason,
|
|
64
|
+
connections: setup.connections.map(publicConnection),
|
|
65
|
+
fallbacks: setup.fallbacks,
|
|
66
|
+
chain: chainStatus(setup),
|
|
67
|
+
claude: loginsOf(deps),
|
|
68
|
+
lastServed: lastServed(),
|
|
69
|
+
usage: usageSeen(),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function withUsage(deps: ModelApiDeps): Promise<Record<string, unknown>> {
|
|
74
|
+
await refreshUsage(setupOf(deps), loginsOf(deps), deps);
|
|
75
|
+
return settingsBody(deps);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function synced(deps: ModelApiDeps): Promise<Record<string, unknown>> {
|
|
79
|
+
await (deps.sync ?? (() => orgModels()?.requested() ?? Promise.resolve()))();
|
|
80
|
+
return settingsBody(deps);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function dropLogin(session: ApiSession, login: string, deps: ModelApiDeps): Record<string, unknown> {
|
|
84
|
+
requireAdmin(session);
|
|
85
|
+
removeClaudeLogin(login, { ...deps.claudeLogins, dir: deps.dir?.() ?? deps.claudeLogins?.dir });
|
|
86
|
+
return settingsBody(deps);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type Run = (session: ApiSession) => Promise<unknown>;
|
|
90
|
+
|
|
91
|
+
function routeFor(path: string, method: string | undefined, deps: ModelApiDeps): Run | number {
|
|
92
|
+
const only = (wanted: string, run: Run): Run | number => (method === wanted ? run : 405);
|
|
93
|
+
if (path === PATH) return only('GET', () => withUsage(deps));
|
|
94
|
+
if (path === SNAPSHOT) return only('GET', () => Promise.resolve(settingsBody(deps)));
|
|
95
|
+
if (path === SYNC) return only('POST', () => synced(deps));
|
|
96
|
+
if (path === CLAUDE_MODELS) return only('GET', () => Promise.resolve({ models: claudeModels(deps.anthropicBase) }));
|
|
97
|
+
const login = path.slice(CLAUDE_LOGIN.length);
|
|
98
|
+
if (!LOGIN_RE.test(login)) return 404;
|
|
99
|
+
return only('DELETE', (session) => Promise.resolve(dropLogin(session, login, deps)));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const mine = (path: string): boolean => path === PATH || path.startsWith(`${PATH}/`);
|
|
215
103
|
|
|
216
104
|
export function handleModelRequest(req: IncomingMessage, res: ServerResponse, deps: ModelApiDeps): boolean {
|
|
217
105
|
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
@@ -220,9 +108,7 @@ export function handleModelRequest(req: IncomingMessage, res: ServerResponse, de
|
|
|
220
108
|
res.writeHead(204, cors(req)).end();
|
|
221
109
|
return true;
|
|
222
110
|
}
|
|
223
|
-
const
|
|
224
|
-
if (handleOpenRouterSignIn(req, res, deps, store)) return true;
|
|
225
|
-
const route = routeFor(path, req.method);
|
|
111
|
+
const route = path === PATH || path.startsWith(CLAUDE_LOGIN) || [SNAPSHOT, SYNC, CLAUDE_MODELS].includes(path) ? routeFor(path, req.method, deps) : 404;
|
|
226
112
|
if (typeof route === 'number') {
|
|
227
113
|
sendJson(req, res, route, { error: route === 404 ? 'not found' : 'method not allowed' });
|
|
228
114
|
return true;
|
|
@@ -230,7 +116,7 @@ export function handleModelRequest(req: IncomingMessage, res: ServerResponse, de
|
|
|
230
116
|
apiSession(req)
|
|
231
117
|
.then(async (session) => {
|
|
232
118
|
if (!session) throw new ApiError('unauthorized', 401);
|
|
233
|
-
sendJson(req, res, 200, await route
|
|
119
|
+
sendJson(req, res, 200, await route(session));
|
|
234
120
|
})
|
|
235
121
|
.catch((err: unknown) => {
|
|
236
122
|
apiFailure(req, res, err, 'model-api');
|