@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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +3 -9
  3. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +11 -4
  4. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +7 -6
  5. package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +1 -6
  6. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +53 -29
  7. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-store.ts +55 -0
  8. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +40 -62
  9. package/runtime/node_modules/@metro-labs/daemon/src/gateway/delivered.ts +82 -0
  10. package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +47 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +18 -19
  12. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +36 -64
  13. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +99 -213
  14. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +80 -283
  15. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-import.ts +125 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-lists.ts +0 -17
  17. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-v2.ts +79 -0
  18. package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter.ts +0 -79
  19. package/runtime/node_modules/@metro-labs/daemon/src/gateway/org-models.ts +231 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +4 -44
  21. package/runtime/node_modules/@metro-labs/daemon/src/gateway/text.ts +0 -2
  22. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +37 -57
  23. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +0 -13
  24. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +3 -4
  25. package/runtime/runtime.json +1 -1
  26. package/runtime/sdk-runner/runner.json +1 -1
  27. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-auth.ts +0 -219
  28. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-device.ts +0 -119
  29. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-auth.ts +0 -160
  30. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-client.ts +0 -46
  31. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-setup.ts +0 -150
  32. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-stream.ts +0 -139
  33. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-translate.ts +0 -236
  34. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +0 -208
  35. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +0 -157
  36. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +0 -82
  37. package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter-login.ts +0 -192
  38. package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter-signin.ts +0 -64
  39. package/runtime/node_modules/@metro-labs/daemon/src/gateway/pkce.ts +0 -36
@@ -1,208 +0,0 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http';
2
- import { isRecord } from '@metro-labs/core/is-record';
3
- import { errMsg, log } from '@metro-labs/core/log';
4
- import { refreshTokens, tokensStale, type GeminiTokens } from './gemini-auth.js';
5
- import { API, clientHeaders, generateBases, machineSessionId, type Bases } from './gemini-client.js';
6
- import { GeminiStreamTranslator } from './gemini-stream.js';
7
- import { toGeminiRequest } from './gemini-translate.js';
8
- import { ToolNames } from './codex-translate.js';
9
- import { GatewayError, providerStatus, sendError, upstreamMessage, type Watch } from './forward.js';
10
- import type { Connection } from './model-config.js';
11
- import { answerWhole, currentOf, errorKind, reach, refreshed, relayTranslated, sessionHeader, type TokenSource, type TokenState } from './subscription.js';
12
- import { effortToApply, withoutEffort } from './effort.js';
13
-
14
- export interface GeminiDeps {
15
- base?: Bases;
16
- tokenBase?: string;
17
- fetchImpl?: typeof fetch;
18
- save: (id: string, tokens: GeminiTokens) => void;
19
- }
20
-
21
- export type GeminiState = TokenState<GeminiTokens>;
22
-
23
- export const sharedGeminiState: GeminiState = new Map();
24
-
25
- const sourceOf = (deps: GeminiDeps): TokenSource<GeminiTokens> => ({
26
- label: 'Google',
27
- stale: (tokens) => tokensStale(tokens),
28
- refresh: (tokens) => refreshTokens(tokens, deps.tokenBase, deps.fetchImpl),
29
- save: deps.save,
30
- });
31
-
32
- export const currentGeminiTokens = (conn: Connection, deps: GeminiDeps, state: GeminiState): Promise<GeminiTokens> =>
33
- currentOf(state, conn.id, conn.gemini, sourceOf(deps), 'Gemini is not connected: sign in with Google on the Model page.');
34
-
35
- interface Call {
36
- body: Record<string, unknown>;
37
- model: string;
38
- promptId: string;
39
- watch: Watch;
40
- deps: GeminiDeps;
41
- names: ToolNames;
42
- conn: Connection;
43
- }
44
-
45
- const headersFor = (token: string, stream: boolean): Record<string, string> => ({
46
- authorization: `Bearer ${token}`,
47
- 'content-type': 'application/json',
48
- ...clientHeaders(),
49
- 'x-machine-session-id': machineSessionId(),
50
- ...(stream ? { accept: 'text/event-stream' } : {}),
51
- });
52
-
53
- async function sendTo(base: string, call: Call, tokens: GeminiTokens, stream: boolean): Promise<Response> {
54
- const request = toGeminiRequest(call.body, call.model, tokens.project, call.promptId, call.names);
55
- const method = stream ? 'streamGenerateContent?alt=sse' : 'generateContent';
56
- return (call.deps.fetchImpl ?? fetch)(`${base}/${API}:${method}`, {
57
- method: 'POST',
58
- headers: headersFor(tokens.accessToken, stream),
59
- body: JSON.stringify(request),
60
- signal: call.watch.signal,
61
- redirect: 'manual',
62
- });
63
- }
64
-
65
- async function send(call: Call, tokens: GeminiTokens, stream: boolean): Promise<Response> {
66
- const bases = generateBases(call.deps.base);
67
- let last: Response | null = null;
68
- for (const base of bases) {
69
- if (last !== null) await last.body?.cancel();
70
- try {
71
- last = await sendTo(base, call, tokens, stream);
72
- } catch (err) {
73
- if (base === bases.at(-1) || call.watch.signal.aborted) throw err;
74
- continue;
75
- }
76
- if (last.status < 500 && last.status !== 429) return last;
77
- }
78
- if (last === null) throw new GatewayError(502, 'api_error', 'no Code Assist endpoint to call');
79
- return last;
80
- }
81
-
82
- const REFUSAL_LOG_MAX = 2000;
83
-
84
- const detailText = (detail: Record<string, unknown>): string[] => {
85
- const out: string[] = [];
86
- if (typeof detail.reason === 'string') out.push(detail.reason);
87
- if (isRecord(detail.metadata)) out.push(...Object.entries(detail.metadata).map(([k, v]) => `${k}=${String(v)}`));
88
- if (typeof detail.retryDelay === 'string') out.push(`retry after ${detail.retryDelay}`);
89
- if (Array.isArray(detail.violations))
90
- out.push(...detail.violations.filter(isRecord).map((v) => [v.subject, v.description].filter((x) => typeof x === 'string').join(': ')));
91
- return out.filter((x) => x !== '');
92
- };
93
-
94
- function refusalMessage(text: string, status: number): string {
95
- const message = upstreamMessage(text, `Gemini answered ${String(status)}`);
96
- let details: unknown = null;
97
- try {
98
- const parsed: unknown = JSON.parse(text);
99
- details = isRecord(parsed) && isRecord(parsed.error) ? parsed.error.details : null;
100
- } catch {
101
- return message;
102
- }
103
- const notes = Array.isArray(details) ? details.filter(isRecord).flatMap(detailText) : [];
104
- return notes.length === 0 ? message : `${message} (${notes.join('; ')})`;
105
- }
106
-
107
- const reachWith = (call: Call, state: GeminiState, stream: boolean): Promise<Response> =>
108
- reach(
109
- () => currentGeminiTokens(call.conn, call.deps, state),
110
- (tokens) => refreshed(state, call.conn.id, tokens, sourceOf(call.deps)),
111
- (tokens) => send(call, tokens, stream),
112
- );
113
-
114
- export interface GeminiModel {
115
- id: string;
116
- name: string;
117
- remaining: number | null;
118
- resetAt: string | null;
119
- }
120
-
121
- const modelOf = (id: string, raw: unknown): GeminiModel => {
122
- const entry = isRecord(raw) ? raw : {};
123
- const quota = isRecord(entry.quotaInfo) ? entry.quotaInfo : null;
124
- const fraction = quota !== null && typeof quota.remainingFraction === 'number' ? quota.remainingFraction : null;
125
- const resetAt = quota !== null && typeof quota.resetTime === 'string' ? quota.resetTime : null;
126
- return { id, name: typeof entry.displayName === 'string' ? entry.displayName : id, remaining: fraction ?? (resetAt === null ? null : 0), resetAt };
127
- };
128
-
129
- export async function listGeminiModels(tokens: GeminiTokens, deps: GeminiDeps): Promise<GeminiModel[]> {
130
- let last = 'no Code Assist endpoint to call';
131
- for (const base of generateBases(deps.base)) {
132
- try {
133
- const res = await (deps.fetchImpl ?? fetch)(`${base}/${API}:fetchAvailableModels`, {
134
- method: 'POST',
135
- headers: headersFor(tokens.accessToken, false),
136
- body: JSON.stringify({ project: tokens.project }),
137
- signal: AbortSignal.timeout(30_000),
138
- });
139
- const body: unknown = await res.json().catch(() => null);
140
- if (!res.ok) {
141
- last = upstreamMessage(JSON.stringify(body), `Google answered ${String(res.status)}`);
142
- continue;
143
- }
144
- const models = isRecord(body) && isRecord(body.models) ? body.models : {};
145
- return Object.entries(models)
146
- .filter(([id]) => id.startsWith('gemini'))
147
- .map(([id, raw]) => modelOf(id, raw));
148
- } catch (err) {
149
- last = errMsg(err);
150
- }
151
- }
152
- throw new GatewayError(502, 'api_error', `could not list the Gemini models: ${last}`);
153
- }
154
-
155
- const parseData = (raw: string): Record<string, unknown> | null => {
156
- try {
157
- const parsed: unknown = JSON.parse(raw);
158
- return isRecord(parsed) ? parsed : null;
159
- } catch {
160
- return null;
161
- }
162
- };
163
-
164
- const NAMES_THINKING = /thinking|thought/i;
165
-
166
- function refuse(res: ServerResponse, model: string, status: number, text: string): void {
167
- log.warn({ provider: 'gemini', model, status, body: text.slice(0, REFUSAL_LOG_MAX) }, 'gateway: the provider refused the request');
168
- sendError(res, providerStatus(status), errorKind(status), refusalMessage(text, status));
169
- }
170
-
171
- export async function geminiMessages(
172
- req: IncomingMessage,
173
- res: ServerResponse,
174
- body: Record<string, unknown>,
175
- model: string,
176
- conn: Connection,
177
- deps: GeminiDeps,
178
- state: GeminiState,
179
- watch: Watch,
180
- ): Promise<void> {
181
- const stream = body.stream === true;
182
- let call: Call = { body, model, promptId: sessionHeader(req) || 'metro', watch, deps, names: new ToolNames(), conn };
183
- let upstream = await reachWith(call, state, stream);
184
- if (upstream.status === 400 && effortToApply(body) !== null) {
185
- const text = await upstream.text();
186
- if (!NAMES_THINKING.test(text)) {
187
- refuse(res, model, 400, text);
188
- return;
189
- }
190
- log.warn({ provider: 'gemini', model }, 'gateway: Gemini refused the thinking level, so metro asked again without it');
191
- call = { ...call, body: withoutEffort(body) };
192
- upstream = await reachWith(call, state, stream);
193
- }
194
- if (!upstream.ok) {
195
- refuse(res, model, upstream.status, await upstream.text());
196
- return;
197
- }
198
- const translator = new GeminiStreamTranslator(model, (name) => call.names.restore(name));
199
- if (stream) {
200
- await relayTranslated(upstream, res, watch, conn.id, translator, (raw) => {
201
- const data = parseData(raw.data);
202
- return data === null ? '' : translator.push(data);
203
- });
204
- return;
205
- }
206
- const data = parseData(await upstream.text());
207
- answerWhole(res, (data === null ? '' : translator.push(data)) + translator.close(), conn.id);
208
- }
@@ -1,157 +0,0 @@
1
- import type { IncomingMessage } from 'node:http';
2
- import { ApiError } from '@metro-labs/http/api-error';
3
- import { readJsonBody } from '@metro-labs/http/api-http';
4
- import { isRecord } from '@metro-labs/core/is-record';
5
- import { log } from '@metro-labs/core/log';
6
- import { beginLogin, chatgptHostId, finishLogin, type ChatgptTokens, type CodexTokens } from './codex-auth.js';
7
- import { beginDeviceLogin, pollDeviceLogin } from './codex-device.js';
8
- import { codexModels, currentTokens, sharedCodexState, type CodexDeps } from './codex.js';
9
- import { beginLogin as beginGeminiLogin, exchangeCode as exchangeGeminiCode, userEmail, type GeminiTokens } from './gemini-auth.js';
10
- import { onboard, parseGeminiProject } from './gemini-setup.js';
11
- import { currentGeminiTokens, listGeminiModels, sharedGeminiState, type GeminiDeps } from './gemini.js';
12
- import { listCache } from './model-lists.js';
13
- import { forgetReported } from './usage.js';
14
- import {
15
- addConnection,
16
- connectionOf,
17
- setCodexAuth,
18
- setGeminiAuth,
19
- type Connection,
20
- type ModelConfig,
21
- type Provider,
22
- } from './model-config.js';
23
- import { asApiError, askedConnection, BODY_MAX, connectionFor, settingsBody, type ModelApiDeps, type Route, type Store } from './model-store.js';
24
-
25
- function connectionToFill(store: Store, req: IncomingMessage, provider: Provider): { cfg: ModelConfig; id: string } {
26
- const asked = askedConnection(req);
27
- const current = store.read();
28
- if (asked !== '') {
29
- if (connectionOf(current, asked)?.provider !== provider) throw new ApiError('no such connection', 404);
30
- return { cfg: current, id: asked };
31
- }
32
- const cfg = addConnection(current, { provider });
33
- return { cfg, id: cfg.connections.at(-1)?.id ?? '' };
34
- }
35
-
36
- const saved = (store: Store, cfg: ModelConfig, note: string, fields: { connection: string } & Record<string, unknown>): unknown => {
37
- store.write(cfg);
38
- forgetReported(fields.connection);
39
- log.info(fields, note);
40
- return settingsBody(cfg);
41
- };
42
-
43
- const codexDepsFor = (deps: ModelApiDeps, store: Store, id: string): CodexDeps => ({
44
- issuer: deps.issuer,
45
- fetchImpl: deps.fetchImpl,
46
- base: deps.codexBase,
47
- save: (connId, t) => {
48
- store.write(setCodexAuth(store.read(), connId === '' ? id : connId, t));
49
- },
50
- });
51
-
52
- function renewing(store: Store, req: IncomingMessage): ChatgptTokens | null {
53
- if (askedConnection(req) === '') return null;
54
- const tokens = connectionFor(store.read(), req, 'codex').codex;
55
- return tokens?.method === 'chatgpt' ? tokens : null;
56
- }
57
-
58
- function keepCodex(store: Store, req: IncomingMessage, tokens: CodexTokens, note: string): unknown {
59
- const { cfg, id } = connectionToFill(store, req, 'codex');
60
- return saved(store, setCodexAuth(cfg, id, tokens), note, { connection: id, method: tokens.method });
61
- }
62
-
63
- async function pollDevice(id: string, req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
64
- const result = await pollDeviceLogin(id, deps.fetchImpl).catch(asApiError);
65
- if (result.status !== 'done') return result;
66
- return { status: 'done', settings: keepCodex(store, req, result.tokens, 'model-api: Codex connected with a code') };
67
- }
68
-
69
- export const CODEX_ROUTES: Record<string, Route> = {
70
- login: {
71
- method: 'POST',
72
- run: (req, deps, store) => Promise.resolve({ url: beginLogin(renewing(store, req), chatgptHostId(deps.setup?.agents), deps.issuer) }),
73
- },
74
- callback: {
75
- method: 'POST',
76
- run: async (req, deps, store) => {
77
- const body = await readJsonBody(req, BODY_MAX);
78
- const raw = isRecord(body) && typeof body.url === 'string' ? body.url : '';
79
- const tokens = await finishLogin(raw, deps.issuer, deps.fetchImpl).catch(asApiError);
80
- return keepCodex(store, req, tokens, 'model-api: Codex connected with Sign in with ChatGPT');
81
- },
82
- },
83
- device: {
84
- method: 'POST',
85
- run: async (_req, deps) => {
86
- const login = await beginDeviceLogin(deps.issuer, deps.fetchImpl).catch(asApiError);
87
- return { id: login.id, user_code: login.userCode, verify_url: login.verifyUrl, interval: login.interval };
88
- },
89
- },
90
- models: {
91
- method: 'GET',
92
- run: async (req, deps, store) => {
93
- const conn = connectionFor(store.read(), req, 'codex');
94
- if (conn.codex === null) throw new ApiError('this connection is not signed in yet', 400);
95
- const codexDeps = codexDepsFor(deps, store, conn.id);
96
- const auth = await currentTokens(conn, codexDeps, sharedCodexState).catch(asApiError);
97
- return { models: await codexModels(auth, codexDeps).catch(asApiError) };
98
- },
99
- },
100
- };
101
-
102
- const geminiDeps = (deps: ModelApiDeps, store: Store): GeminiDeps => ({
103
- base: deps.geminiBase,
104
- tokenBase: deps.geminiTokenBase,
105
- fetchImpl: deps.fetchImpl,
106
- save: (id, t) => {
107
- store.write(setGeminiAuth(store.read(), id, t));
108
- },
109
- });
110
-
111
- export async function geminiModelsOf(conn: Connection, deps: ModelApiDeps, store: Store): Promise<Awaited<ReturnType<typeof listGeminiModels>>> {
112
- if (conn.gemini === null) throw new ApiError('this connection is not signed in yet', 400);
113
- const tokens = await currentGeminiTokens(conn, geminiDeps(deps, store), sharedGeminiState);
114
- return listGeminiModels(tokens, geminiDeps(deps, store));
115
- }
116
-
117
- function projectOf(body: unknown): string | null {
118
- try {
119
- return parseGeminiProject(isRecord(body) ? body.project : null);
120
- } catch (err) {
121
- return asApiError(err);
122
- }
123
- }
124
-
125
- async function connectGemini(req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
126
- const body = await readJsonBody(req, BODY_MAX);
127
- const code = isRecord(body) && typeof body.code === 'string' ? body.code : '';
128
- const state = isRecord(body) && typeof body.state === 'string' ? body.state : '';
129
- const project = projectOf(body);
130
- const tokens = await exchangeGeminiCode(code, state, deps.geminiTokenBase, deps.fetchImpl).catch(asApiError);
131
- const email = await userEmail(tokens, deps.geminiUserBase, deps.fetchImpl);
132
- const onboarded = await onboard(tokens, project, deps.geminiBase, deps.fetchImpl).catch(asApiError);
133
- const { cfg, id } = connectionToFill(store, req, 'gemini');
134
- const full: GeminiTokens = { ...tokens, email, project: onboarded.project, tier: onboarded.tier };
135
- return saved(store, setGeminiAuth(cfg, id, full), 'model-api: Gemini connected', { connection: id, tier: onboarded.tier });
136
- }
137
-
138
- const geminiLists = listCache<string>('gemini');
139
-
140
- async function geminiIds(req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<string[]> {
141
- const conn = connectionFor(store.read(), req, 'gemini');
142
- const key = `${deps.geminiBase ?? ''}:${conn.id}:${conn.gemini?.email ?? ''}:${conn.gemini?.project ?? ''}`;
143
- return geminiLists.get(key, async () => (await geminiModelsOf(conn, deps, store)).map((m) => m.id));
144
- }
145
-
146
- export const GEMINI_ROUTES: Record<string, Route> = {
147
- login: { method: 'POST', run: (_req, deps) => Promise.resolve(beginGeminiLogin(deps.geminiAuthBase)) },
148
- code: { method: 'POST', run: connectGemini },
149
- models: {
150
- method: 'GET',
151
- run: async (req, deps, store) => ({ models: await geminiIds(req, deps, store).catch(asApiError) }),
152
- },
153
- };
154
-
155
- export function codexDeviceRoute(id: string, method: string | undefined): Route | number {
156
- return method === 'GET' ? { method: 'GET', run: (req, deps, store) => pollDevice(id, req, deps, store) } : 405;
157
- }
@@ -1,82 +0,0 @@
1
- import type { IncomingMessage } from 'node:http';
2
- import { ApiError } from '@metro-labs/http/api-error';
3
- import { CodexAuthError } from './codex-auth.js';
4
- import { GeminiAuthError } from './gemini-auth.js';
5
- import { GatewayError } from './forward.js';
6
- import { lastServed } from './served.js';
7
- import { usageSeen } from './usage.js';
8
- import { chainStatus } from './fallback.js';
9
- import { ModelConfigError, connectionOf, publicModelConfig, routedConnection, type Connection, type ModelConfig, type Provider } from './model-config.js';
10
- import type { SetupDeps } from '../claude/setup.js';
11
- import type { ClaudeLoginDeps } from './claude-logins.js';
12
-
13
- export interface ModelApiDeps {
14
- read?: () => ModelConfig;
15
- write?: (cfg: ModelConfig) => void;
16
- issuer?: string;
17
- fetchImpl?: typeof fetch;
18
- codexBase?: string;
19
- geminiAuthBase?: string;
20
- geminiTokenBase?: string;
21
- geminiUserBase?: string;
22
- geminiBase?: string;
23
- openrouterBase?: string;
24
- owner?: () => string | null;
25
- openrouterPublicBase?: () => string | null;
26
- openrouterNow?: () => number;
27
- anthropicBase?: string;
28
- bedrockControlBase?: string;
29
- setup?: SetupDeps;
30
- runnerAlive?: () => boolean;
31
- switchModel?: (model: string | null) => boolean;
32
- claudeUsage?: (env?: Record<string, string>) => Promise<unknown>;
33
- claudeLoginsRoot?: string;
34
- usageWaitMs?: number;
35
- }
36
-
37
- export const claudeLoginDeps = (deps: ModelApiDeps): ClaudeLoginDeps => ({ root: deps.claudeLoginsRoot, probe: deps.claudeUsage });
38
-
39
- export interface Store {
40
- read: () => ModelConfig;
41
- write: (cfg: ModelConfig) => void;
42
- }
43
-
44
- type Handler = (req: IncomingMessage, deps: ModelApiDeps, store: Store) => Promise<unknown>;
45
-
46
- export interface Route {
47
- method: 'GET' | 'POST';
48
- run: Handler;
49
- }
50
-
51
- export const BODY_MAX = 16 * 1024;
52
- export const CREDITS_TTL_MS = 5 * 60_000;
53
-
54
- export const settingsBody = (cfg: ModelConfig): Record<string, unknown> => ({
55
- ...publicModelConfig(cfg),
56
- fallbacks: cfg.fallbacks ?? [],
57
- chain: chainStatus(cfg),
58
- lastServed: lastServed(),
59
- usage: usageSeen(),
60
- });
61
-
62
- export function asApiError(err: unknown): never {
63
- if (err instanceof ModelConfigError || err instanceof CodexAuthError || err instanceof GeminiAuthError) throw new ApiError(err.message, 400);
64
- if (err instanceof GatewayError) throw new ApiError(err.message, err.status >= 400 && err.status < 500 ? 400 : 502);
65
- throw err;
66
- }
67
-
68
- export const askedConnection = (req: IncomingMessage): string => new URL(req.url ?? '', 'http://metro').searchParams.get('connection') ?? '';
69
-
70
- export function connectionFor(cfg: ModelConfig, req: IncomingMessage, provider: Provider): Connection {
71
- const asked = askedConnection(req);
72
- if (asked !== '') {
73
- const found = connectionOf(cfg, asked);
74
- if (found?.provider !== provider) throw new ApiError('no such connection', 404);
75
- return found;
76
- }
77
- const routed = routedConnection(cfg);
78
- if (routed?.provider === provider) return routed;
79
- const first = cfg.connections.find((c) => c.provider === provider);
80
- if (first === undefined) throw new ApiError(`no ${provider} connection yet`, 400);
81
- return first;
82
- }
@@ -1,192 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
- import { pkcePair, newState } from '@metro-labs/core/stations/oauth';
3
- import { ticketStore } from '@metro-labs/core/tickets';
4
- import { isRecord } from '@metro-labs/core/is-record';
5
- import { log } from '@metro-labs/core/log';
6
- import { ApiError } from '@metro-labs/http/api-error';
7
- import { addConnection, connectionOf, updateConnection } from './model-config.js';
8
- import { asApiError, type Store } from './model-store.js';
9
- import { forgetReported } from './usage.js';
10
-
11
- export const OPENROUTER_CALLBACK = '/api/model/openrouter/callback';
12
- const TTL = 10 * 60_000;
13
- const MAX_ATTEMPTS = 100;
14
- const STALE = 'This OpenRouter sign-in expired or was cancelled. Start again.';
15
- const FAILED = 'OpenRouter sign-in did not finish. Try again or use an API key. Manage any unused key in OpenRouter.';
16
-
17
- type Result = { status: 'pending' } | { status: 'done'; connection: string } | { status: 'failed'; error: string };
18
-
19
- interface Attempt {
20
- owner: string;
21
- connection: string;
22
- keyHash: string;
23
- expiresAt: number;
24
- state: string;
25
- verifier: string;
26
- result: Result;
27
- abort: AbortController;
28
- }
29
-
30
- interface LoginDeps {
31
- store: Store;
32
- owner: () => string | null;
33
- publicBase: () => string | null;
34
- fetchImpl?: typeof fetch;
35
- now?: () => number;
36
- }
37
-
38
- const hash = (value: string): string => createHash('sha256').update(value).digest('hex');
39
-
40
- function callbackUrl(base: string | null): string {
41
- let url: URL;
42
- try {
43
- url = new URL(base ?? '');
44
- } catch {
45
- throw new ApiError('This box needs a public HTTPS address for OpenRouter sign-in. Use an API key instead.', 409);
46
- }
47
- if (url.protocol !== 'https:' || url.username !== '' || url.password !== '' || url.pathname !== '/' || url.search !== '' || url.hash !== '')
48
- throw new ApiError('This box needs a public HTTPS origin for OpenRouter sign-in. Use an API key instead.', 409);
49
- return `${url.origin}${OPENROUTER_CALLBACK}`;
50
- }
51
-
52
- async function exchange(code: string, verifier: string, signal: AbortSignal, fetchImpl: typeof fetch): Promise<string> {
53
- const res = await fetchImpl('https://openrouter.ai/api/v1/auth/keys', {
54
- method: 'POST',
55
- headers: { 'content-type': 'application/json', accept: 'application/json' },
56
- body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: 'S256' }),
57
- redirect: 'manual',
58
- signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]),
59
- });
60
- if (!res.ok) {
61
- await res.body?.cancel();
62
- throw new ApiError(FAILED, 502);
63
- }
64
- const body: unknown = await res.json();
65
- if (!isRecord(body) || typeof body.key !== 'string' || body.key.trim() === '' || body.key.length > 512)
66
- throw new ApiError(FAILED, 502);
67
- return body.key.trim();
68
- }
69
-
70
- function authorizationCode(params: URLSearchParams): string {
71
- const code = params.get('code') ?? '';
72
- if (params.has('error') || code === '' || code.length > 4096 || params.getAll('code').length !== 1 || params.getAll('state').length !== 1)
73
- throw new ApiError('OpenRouter did not approve this sign-in. Try again or use an API key.', 400);
74
- return code;
75
- }
76
-
77
- export class OpenRouterLogins {
78
- private readonly states = ticketStore<Attempt>(TTL, MAX_ATTEMPTS);
79
- private readonly attempts = new Map<string, Attempt>();
80
-
81
- constructor(private readonly deps: LoginDeps) {}
82
-
83
- private now(): number {
84
- return (this.deps.now ?? Date.now)();
85
- }
86
-
87
- private discard(attempt: Attempt): void {
88
- this.states.take(attempt.state, this.now());
89
- attempt.verifier = '';
90
- attempt.abort.abort();
91
- }
92
-
93
- private prune(): void {
94
- for (const [id, attempt] of this.attempts) {
95
- if (attempt.expiresAt > this.now()) continue;
96
- this.discard(attempt);
97
- this.attempts.delete(id);
98
- }
99
- }
100
-
101
- private checkOwner(owner: string): void {
102
- if (this.deps.owner() !== owner) throw new ApiError('This sign-in belongs to another organization.', 403);
103
- }
104
-
105
- private target(connection: string): string {
106
- const cfg = this.deps.store.read();
107
- if (connection === '') {
108
- try {
109
- addConnection(cfg, { provider: 'openrouter' });
110
- } catch (err) {
111
- asApiError(err);
112
- }
113
- return '';
114
- }
115
- const found = connectionOf(cfg, connection);
116
- if (found?.provider !== 'openrouter') throw new ApiError('This OpenRouter connection no longer exists.', 404);
117
- return hash(found.apiKey);
118
- }
119
-
120
- begin(owner: string, connection: string): { id: string; url: string; expiresAt: number } {
121
- this.checkOwner(owner);
122
- const callback = callbackUrl(this.deps.publicBase());
123
- const keyHash = this.target(connection);
124
- this.prune();
125
- if (this.attempts.size >= MAX_ATTEMPTS) throw new ApiError('Too many OpenRouter sign-ins. Try again later.', 429);
126
- const { verifier, challenge } = pkcePair();
127
- const attempt: Attempt = { owner, connection, keyHash, verifier, state: '', expiresAt: this.now() + TTL, result: { status: 'pending' }, abort: new AbortController() };
128
- attempt.state = this.states.mint(attempt, this.now()).ticket;
129
- const id = newState();
130
- this.attempts.set(id, attempt);
131
- const url = new URL('https://openrouter.ai/auth');
132
- url.search = new URLSearchParams({ callback_url: callback, code_challenge: challenge, code_challenge_method: 'S256', state: attempt.state, key_label: 'Metro' }).toString();
133
- return { id, url: url.href, expiresAt: attempt.expiresAt };
134
- }
135
-
136
- private find(owner: string, id: string): Attempt {
137
- this.checkOwner(owner);
138
- this.prune();
139
- const attempt = this.attempts.get(id);
140
- if (attempt?.owner !== owner) throw new ApiError(STALE, 410);
141
- return attempt;
142
- }
143
-
144
- status(owner: string, id: string): Result {
145
- return this.find(owner, id).result;
146
- }
147
-
148
- cancel(owner: string, id: string): Result {
149
- const attempt = this.find(owner, id);
150
- if (attempt.result.status === 'pending') {
151
- attempt.result = { status: 'failed', error: 'OpenRouter sign-in cancelled.' };
152
- this.discard(attempt);
153
- }
154
- return attempt.result;
155
- }
156
-
157
- private checkPending(attempt: Attempt): void {
158
- if (attempt.expiresAt <= this.now() || attempt.result.status !== 'pending') throw new ApiError(STALE, 410);
159
- this.checkOwner(attempt.owner);
160
- if (this.target(attempt.connection) !== attempt.keyHash)
161
- throw new ApiError('This connection changed during sign-in. Start again.', 409);
162
- }
163
-
164
- private save(attempt: Attempt, key: string): void {
165
- this.checkPending(attempt);
166
- const current = this.deps.store.read();
167
- const next = attempt.connection === '' ? addConnection(current, { provider: 'openrouter', apiKey: key }) : updateConnection(current, attempt.connection, { apiKey: key });
168
- const id = attempt.connection === '' ? (next.connections.at(-1)?.id ?? '') : attempt.connection;
169
- this.deps.store.write(next);
170
- forgetReported(id);
171
- attempt.result = { status: 'done', connection: id };
172
- log.info({ connection: id }, 'model-api: OpenRouter connected');
173
- }
174
-
175
- async complete(params: URLSearchParams): Promise<void> {
176
- const state = params.get('state') ?? '';
177
- const attempt = this.states.take(state, this.now());
178
- if (attempt === undefined) throw new ApiError(STALE, 410);
179
- const verifier = attempt.verifier;
180
- attempt.verifier = '';
181
- try {
182
- this.checkPending(attempt);
183
- const key = await exchange(authorizationCode(params), verifier, attempt.abort.signal, this.deps.fetchImpl ?? fetch);
184
- this.save(attempt, key);
185
- } catch (err) {
186
- if (attempt.result.status === 'pending') attempt.result = { status: 'failed', error: err instanceof ApiError ? err.message : FAILED };
187
- throw new ApiError(attempt.result.status === 'failed' ? attempt.result.error : FAILED, 400);
188
- } finally {
189
- this.discard(attempt);
190
- }
191
- }
192
- }