@stage-labs/metro 0.1.0-beta.164 → 0.1.0-beta.166
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/orchestrator.md +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/files.ts +8 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +28 -13
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +19 -19
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/effort.ts +70 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +9 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +83 -51
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-translate.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +35 -18
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +66 -238
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +202 -141
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +195 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +70 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/served.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +25 -23
- package/runtime/runtime.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
+
import { newId } from '@metro-labs/core/ids';
|
|
2
3
|
import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
|
|
3
4
|
import { agentsDir } from '../agents/files.js';
|
|
4
5
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
@@ -8,45 +9,26 @@ import { tokensFromDisk as geminiTokensFromDisk, type GeminiTokens } from './gem
|
|
|
8
9
|
export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex', 'gemini'] as const;
|
|
9
10
|
export type Provider = (typeof PROVIDERS)[number];
|
|
10
11
|
|
|
11
|
-
export interface
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
export interface BedrockSettings {
|
|
17
|
-
region: string;
|
|
18
|
-
apiKey: string;
|
|
12
|
+
export interface Connection {
|
|
13
|
+
id: string;
|
|
14
|
+
provider: Provider;
|
|
15
|
+
label: string;
|
|
19
16
|
model: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface OpenRouterSettings {
|
|
23
17
|
apiKey: string;
|
|
24
|
-
|
|
18
|
+
region: string;
|
|
25
19
|
zdr: boolean;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
export interface CodexSettings {
|
|
29
|
-
model: string;
|
|
30
|
-
auth: CodexTokens | null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface GeminiSettings {
|
|
34
|
-
model: string;
|
|
35
|
-
auth: GeminiTokens | null;
|
|
20
|
+
codex: CodexTokens | null;
|
|
21
|
+
gemini: GeminiTokens | null;
|
|
36
22
|
}
|
|
37
23
|
|
|
38
24
|
export interface ModelConfig {
|
|
39
|
-
version:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
bedrock: BedrockSettings;
|
|
43
|
-
openrouter: OpenRouterSettings;
|
|
44
|
-
codex: CodexSettings;
|
|
45
|
-
gemini: GeminiSettings;
|
|
25
|
+
version: 2;
|
|
26
|
+
route: string;
|
|
27
|
+
connections: Connection[];
|
|
46
28
|
}
|
|
47
29
|
|
|
48
30
|
export interface Route {
|
|
49
|
-
|
|
31
|
+
connection: Connection;
|
|
50
32
|
model: string;
|
|
51
33
|
}
|
|
52
34
|
|
|
@@ -54,26 +36,39 @@ export class ModelConfigError extends Error {}
|
|
|
54
36
|
|
|
55
37
|
export const MODEL_FILE = 'model.json';
|
|
56
38
|
const MAX_FIELD = 512;
|
|
39
|
+
const MAX_LABEL = 60;
|
|
40
|
+
const MAX_CONNECTIONS = 20;
|
|
57
41
|
const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex|gemini):(.+)$/;
|
|
58
42
|
const SMALL_RE = /haiku/i;
|
|
59
43
|
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
gemini: { model: '', auth: null },
|
|
68
|
-
});
|
|
44
|
+
export const LABELS: Record<Provider, string> = {
|
|
45
|
+
anthropic: 'Anthropic',
|
|
46
|
+
bedrock: 'Amazon Bedrock',
|
|
47
|
+
openrouter: 'OpenRouter',
|
|
48
|
+
codex: 'Codex (ChatGPT)',
|
|
49
|
+
gemini: 'Gemini (Google)',
|
|
50
|
+
};
|
|
69
51
|
|
|
70
|
-
const isProvider = (value: unknown): value is Provider =>
|
|
71
|
-
typeof value === 'string' && (PROVIDERS as readonly string[]).includes(value);
|
|
52
|
+
const isProvider = (value: unknown): value is Provider => typeof value === 'string' && (PROVIDERS as readonly string[]).includes(value);
|
|
72
53
|
|
|
73
54
|
const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
|
74
55
|
const maybe = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
|
|
75
56
|
|
|
76
|
-
|
|
57
|
+
export const empty = (): ModelConfig => ({ version: 2, route: '', connections: [] });
|
|
58
|
+
|
|
59
|
+
export const newConnection = (provider: Provider, label: string): Connection => ({
|
|
60
|
+
id: newId(),
|
|
61
|
+
provider,
|
|
62
|
+
label,
|
|
63
|
+
model: '',
|
|
64
|
+
apiKey: '',
|
|
65
|
+
region: '',
|
|
66
|
+
zdr: false,
|
|
67
|
+
codex: null,
|
|
68
|
+
gemini: null,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function codexFromDisk(raw: unknown): CodexTokens | null {
|
|
77
72
|
if (!isRecord(raw)) return null;
|
|
78
73
|
const accessToken = text(raw.accessToken);
|
|
79
74
|
const refreshToken = text(raw.refreshToken);
|
|
@@ -90,23 +85,49 @@ function tokensFromDisk(raw: unknown): CodexTokens | null {
|
|
|
90
85
|
};
|
|
91
86
|
}
|
|
92
87
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
const bedrock = isRecord(raw.bedrock) ? raw.bedrock : {};
|
|
98
|
-
const openrouter = isRecord(raw.openrouter) ? raw.openrouter : {};
|
|
99
|
-
const codex = isRecord(raw.codex) ? raw.codex : {};
|
|
100
|
-
const gemini = isRecord(raw.gemini) ? raw.gemini : {};
|
|
88
|
+
function connectionFromDisk(raw: unknown): Connection | null {
|
|
89
|
+
if (!isRecord(raw) || !isProvider(raw.provider)) return null;
|
|
90
|
+
const id = text(raw.id);
|
|
91
|
+
const provider = raw.provider;
|
|
101
92
|
return {
|
|
102
|
-
|
|
103
|
-
provider
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
93
|
+
id: id === '' ? newId() : id,
|
|
94
|
+
provider,
|
|
95
|
+
label: text(raw.label) === '' ? LABELS[provider] : text(raw.label).slice(0, MAX_LABEL),
|
|
96
|
+
model: text(raw.model),
|
|
97
|
+
apiKey: text(raw.apiKey),
|
|
98
|
+
region: text(raw.region),
|
|
99
|
+
zdr: raw.zdr === true,
|
|
100
|
+
codex: codexFromDisk(raw.codex),
|
|
101
|
+
gemini: geminiTokensFromDisk(raw.gemini),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fromVersionOne(raw: Record<string, unknown>): ModelConfig {
|
|
106
|
+
const block = (name: Provider): Record<string, unknown> => (isRecord(raw[name]) ? raw[name] : {});
|
|
107
|
+
const made: Connection[] = [];
|
|
108
|
+
const keep = (provider: Provider, fill: (c: Connection) => Connection, has: boolean): void => {
|
|
109
|
+
if (has) made.push(fill(newConnection(provider, LABELS[provider])));
|
|
109
110
|
};
|
|
111
|
+
const anthropic = block('anthropic');
|
|
112
|
+
const bedrock = block('bedrock');
|
|
113
|
+
const openrouter = block('openrouter');
|
|
114
|
+
const codex = block('codex');
|
|
115
|
+
const gemini = block('gemini');
|
|
116
|
+
keep('anthropic', (c) => ({ ...c, apiKey: text(anthropic.apiKey), model: text(anthropic.model) }), text(anthropic.apiKey) !== '' || text(anthropic.model) !== '');
|
|
117
|
+
keep('bedrock', (c) => ({ ...c, apiKey: text(bedrock.apiKey), region: text(bedrock.region), model: text(bedrock.model) }), text(bedrock.apiKey) !== '');
|
|
118
|
+
keep('openrouter', (c) => ({ ...c, apiKey: text(openrouter.apiKey), model: text(openrouter.model), zdr: openrouter.zdr === true }), text(openrouter.apiKey) !== '');
|
|
119
|
+
keep('codex', (c) => ({ ...c, model: text(codex.model), codex: codexFromDisk(codex.auth) }), codexFromDisk(codex.auth) !== null);
|
|
120
|
+
keep('gemini', (c) => ({ ...c, model: text(gemini.model), gemini: geminiTokensFromDisk(gemini.auth) }), geminiTokensFromDisk(gemini.auth) !== null);
|
|
121
|
+
const wanted = isProvider(raw.provider) ? raw.provider : 'anthropic';
|
|
122
|
+
return { version: 2, route: made.find((c) => c.provider === wanted)?.id ?? '', connections: made };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function parseModelConfig(raw: unknown): ModelConfig {
|
|
126
|
+
if (!isRecord(raw)) return empty();
|
|
127
|
+
if (!Array.isArray(raw.connections)) return fromVersionOne(raw);
|
|
128
|
+
const connections = raw.connections.flatMap((c: unknown) => connectionFromDisk(c) ?? []).slice(0, MAX_CONNECTIONS);
|
|
129
|
+
const route = text(raw.route);
|
|
130
|
+
return { version: 2, route: connections.some((c) => c.id === route) ? route : (connections[0]?.id ?? ''), connections };
|
|
110
131
|
}
|
|
111
132
|
|
|
112
133
|
export function readModelConfig(dir = agentsDir()): ModelConfig {
|
|
@@ -117,116 +138,156 @@ export function writeModelConfig(cfg: ModelConfig, dir = agentsDir()): void {
|
|
|
117
138
|
writeSecure(join(dir, MODEL_FILE), JSON.stringify(cfg, null, 2));
|
|
118
139
|
}
|
|
119
140
|
|
|
120
|
-
|
|
121
|
-
if (!(key in patch)) return current;
|
|
122
|
-
const value = patch[key];
|
|
123
|
-
if (typeof value !== 'boolean') throw new ModelConfigError(`${what} must be true or false`);
|
|
124
|
-
return value;
|
|
125
|
-
}
|
|
141
|
+
export const connectionOf = (cfg: ModelConfig, id: string): Connection | null => cfg.connections.find((c) => c.id === id) ?? null;
|
|
126
142
|
|
|
127
|
-
|
|
128
|
-
if (!(key in patch)) return current;
|
|
129
|
-
const value = patch[key];
|
|
130
|
-
if (typeof value !== 'string') throw new ModelConfigError(`${what} must be a string`);
|
|
131
|
-
if (value.length > MAX_FIELD) throw new ModelConfigError(`${what} is too long`);
|
|
132
|
-
return value.trim();
|
|
133
|
-
}
|
|
143
|
+
export const routedConnection = (cfg: ModelConfig): Connection | null => connectionOf(cfg, cfg.route);
|
|
134
144
|
|
|
135
|
-
export function
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const anthropic = isRecord(patch.anthropic) ? patch.anthropic : {};
|
|
140
|
-
const bedrock = isRecord(patch.bedrock) ? patch.bedrock : {};
|
|
141
|
-
const openrouter = isRecord(patch.openrouter) ? patch.openrouter : {};
|
|
142
|
-
const codex = isRecord(patch.codex) ? patch.codex : {};
|
|
143
|
-
const gemini = isRecord(patch.gemini) ? patch.gemini : {};
|
|
144
|
-
return {
|
|
145
|
-
version: 1,
|
|
146
|
-
provider,
|
|
147
|
-
anthropic: {
|
|
148
|
-
apiKey: field(anthropic, 'apiKey', cfg.anthropic.apiKey, 'Anthropic API key'),
|
|
149
|
-
model: field(anthropic, 'model', cfg.anthropic.model, 'Anthropic model'),
|
|
150
|
-
},
|
|
151
|
-
bedrock: {
|
|
152
|
-
region: field(bedrock, 'region', cfg.bedrock.region, 'Bedrock region'),
|
|
153
|
-
apiKey: field(bedrock, 'apiKey', cfg.bedrock.apiKey, 'Bedrock API key'),
|
|
154
|
-
model: field(bedrock, 'model', cfg.bedrock.model, 'Bedrock model'),
|
|
155
|
-
},
|
|
156
|
-
openrouter: {
|
|
157
|
-
apiKey: field(openrouter, 'apiKey', cfg.openrouter.apiKey, 'OpenRouter API key'),
|
|
158
|
-
model: field(openrouter, 'model', cfg.openrouter.model, 'OpenRouter model'),
|
|
159
|
-
zdr: flag(openrouter, 'zdr', cfg.openrouter.zdr, 'OpenRouter zero data retention'),
|
|
160
|
-
},
|
|
161
|
-
codex: { model: field(codex, 'model', cfg.codex.model, 'Codex model'), auth: cfg.codex.auth },
|
|
162
|
-
gemini: { model: field(gemini, 'model', cfg.gemini.model, 'Gemini model'), auth: cfg.gemini.auth },
|
|
163
|
-
};
|
|
145
|
+
export function requireConnection(cfg: ModelConfig, id: string): Connection {
|
|
146
|
+
const found = connectionOf(cfg, id);
|
|
147
|
+
if (found === null) throw new ModelConfigError('no such connection');
|
|
148
|
+
return found;
|
|
164
149
|
}
|
|
165
150
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
export const setGeminiAuth = (cfg: ModelConfig, auth: GeminiTokens | null): ModelConfig => ({ ...cfg, gemini: { ...cfg.gemini, auth } });
|
|
151
|
+
const PASSTHROUGH = 'Nothing is connected: the request carries the Claude Code login of the session that sent it.';
|
|
169
152
|
|
|
170
|
-
const CHECKS: Record<Provider, [(
|
|
153
|
+
const CHECKS: Record<Provider, [(c: Connection) => boolean, string][]> = {
|
|
171
154
|
anthropic: [],
|
|
172
155
|
bedrock: [
|
|
173
|
-
[(
|
|
174
|
-
[(
|
|
156
|
+
[(c) => c.apiKey === '', 'Bedrock needs an API key: add it on the Model page.'],
|
|
157
|
+
[(c) => c.region === '', 'Bedrock needs a region: add it on the Model page.'],
|
|
175
158
|
],
|
|
176
159
|
openrouter: [
|
|
177
|
-
[(
|
|
178
|
-
[(
|
|
160
|
+
[(c) => c.apiKey === '', 'OpenRouter needs an API key: add it on the Model page.'],
|
|
161
|
+
[(c) => c.model === '', 'OpenRouter needs a model id: choose one on the Model page.'],
|
|
179
162
|
],
|
|
180
163
|
codex: [
|
|
181
|
-
[(
|
|
182
|
-
[(
|
|
164
|
+
[(c) => c.codex === null, 'Codex is not connected: sign in with ChatGPT on the Model page.'],
|
|
165
|
+
[(c) => c.model === '', 'Codex needs a model id: choose one on the Model page.'],
|
|
183
166
|
],
|
|
184
167
|
gemini: [
|
|
185
|
-
[(
|
|
186
|
-
[(
|
|
168
|
+
[(c) => c.gemini === null, 'Gemini is not connected: sign in with Google on the Model page.'],
|
|
169
|
+
[(c) => c.model === '', 'Gemini needs a model id: choose one on the Model page.'],
|
|
187
170
|
],
|
|
188
171
|
};
|
|
189
172
|
|
|
190
|
-
export function notReady(cfg: ModelConfig,
|
|
191
|
-
|
|
173
|
+
export function notReady(cfg: ModelConfig, conn = routedConnection(cfg)): string | null {
|
|
174
|
+
if (conn === null) return cfg.connections.length === 0 ? null : PASSTHROUGH;
|
|
175
|
+
return CHECKS[conn.provider].find(([missing]) => missing(conn))?.[1] ?? null;
|
|
192
176
|
}
|
|
193
177
|
|
|
194
|
-
export
|
|
195
|
-
|
|
196
|
-
|
|
178
|
+
export const isSmallModel = (requested: string): boolean => SMALL_RE.test(requested);
|
|
179
|
+
|
|
180
|
+
const DEFAULTS: Record<Provider, (requested: string, c: Connection) => string> = {
|
|
181
|
+
openrouter: (requested, c) => (requested.includes('/') ? requested : c.model),
|
|
182
|
+
bedrock: (requested, c) => (c.model === '' ? requested : c.model),
|
|
183
|
+
codex: (requested, c) => (requested.startsWith('gpt-') ? requested : c.model),
|
|
184
|
+
gemini: (requested, c) => (requested.startsWith('gemini-') ? requested : c.model),
|
|
185
|
+
anthropic: (requested, c) => (c.model === '' || isSmallModel(requested) ? requested : c.model),
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
function forProvider(cfg: ModelConfig, provider: Provider): Connection | null {
|
|
189
|
+
const routed = routedConnection(cfg);
|
|
190
|
+
if (routed?.provider === provider) return routed;
|
|
191
|
+
return cfg.connections.find((c) => c.provider === provider) ?? null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function resolveRoute(requested: string, cfg: ModelConfig): Route | null {
|
|
195
|
+
const explicit = PREFIX_RE.exec(requested);
|
|
196
|
+
if (explicit !== null && isProvider(explicit[1])) {
|
|
197
|
+
const conn = forProvider(cfg, explicit[1]);
|
|
198
|
+
return conn === null ? null : { connection: conn, model: (explicit[2] ?? '').trim() };
|
|
199
|
+
}
|
|
200
|
+
const routed = routedConnection(cfg);
|
|
201
|
+
return routed === null ? null : { connection: routed, model: DEFAULTS[routed.provider](requested, routed) };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export const routeLabel = (route: Route): string =>
|
|
205
|
+
route.connection.provider === 'anthropic' ? route.model : `${route.connection.provider}:${route.model}`;
|
|
206
|
+
|
|
207
|
+
export function publicConnection(c: Connection): Record<string, unknown> {
|
|
197
208
|
return {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
209
|
+
id: c.id,
|
|
210
|
+
provider: c.provider,
|
|
211
|
+
label: c.label,
|
|
212
|
+
model: c.model,
|
|
213
|
+
hasKey: c.apiKey !== '',
|
|
214
|
+
region: c.region,
|
|
215
|
+
zdr: c.zdr,
|
|
216
|
+
signedIn: c.codex !== null || c.gemini !== null,
|
|
217
|
+
account: c.codex?.email ?? c.gemini?.email ?? null,
|
|
218
|
+
plan: c.codex?.plan ?? c.gemini?.tier ?? null,
|
|
206
219
|
};
|
|
207
220
|
}
|
|
208
221
|
|
|
209
|
-
export
|
|
222
|
+
export function publicModelConfig(cfg: ModelConfig): Record<string, unknown> {
|
|
223
|
+
const reason = notReady(cfg);
|
|
224
|
+
return { route: cfg.route, ready: reason === null, reason, connections: cfg.connections.map(publicConnection) };
|
|
225
|
+
}
|
|
210
226
|
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
};
|
|
227
|
+
const withConnection = (cfg: ModelConfig, id: string, change: (c: Connection) => Connection): ModelConfig => ({
|
|
228
|
+
...cfg,
|
|
229
|
+
connections: cfg.connections.map((c) => (c.id === id ? change(c) : c)),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
export const setCodexAuth = (cfg: ModelConfig, id: string, auth: CodexTokens | null): ModelConfig =>
|
|
233
|
+
withConnection(cfg, id, (c) => ({ ...c, codex: auth }));
|
|
234
|
+
|
|
235
|
+
export const setGeminiAuth = (cfg: ModelConfig, id: string, auth: GeminiTokens | null): ModelConfig =>
|
|
236
|
+
withConnection(cfg, id, (c) => ({ ...c, gemini: auth }));
|
|
237
|
+
|
|
238
|
+
function field(patch: Record<string, unknown>, key: string, current: string, what: string, max = MAX_FIELD): string {
|
|
239
|
+
if (!(key in patch)) return current;
|
|
240
|
+
const value = patch[key];
|
|
241
|
+
if (typeof value !== 'string') throw new ModelConfigError(`${what} must be a string`);
|
|
242
|
+
if (value.length > max) throw new ModelConfigError(`${what} is too long`);
|
|
243
|
+
return value.trim();
|
|
244
|
+
}
|
|
218
245
|
|
|
219
|
-
function
|
|
220
|
-
|
|
246
|
+
function flag(patch: Record<string, unknown>, key: string, current: boolean, what: string): boolean {
|
|
247
|
+
if (!(key in patch)) return current;
|
|
248
|
+
const value = patch[key];
|
|
249
|
+
if (typeof value !== 'boolean') throw new ModelConfigError(`${what} must be true or false`);
|
|
250
|
+
return value;
|
|
221
251
|
}
|
|
222
252
|
|
|
223
|
-
export function
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
253
|
+
export function labelFor(cfg: ModelConfig, provider: Provider, given: string): string {
|
|
254
|
+
if (given !== '') return given.slice(0, MAX_LABEL);
|
|
255
|
+
const taken = cfg.connections.filter((c) => c.provider === provider).length;
|
|
256
|
+
return taken === 0 ? LABELS[provider] : `${LABELS[provider]} ${String(taken + 1)}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function addConnection(cfg: ModelConfig, patch: unknown): ModelConfig {
|
|
260
|
+
if (!isRecord(patch) || !isProvider(patch.provider)) throw new ModelConfigError(`provider must be one of ${PROVIDERS.join(', ')}`);
|
|
261
|
+
if (cfg.connections.length >= MAX_CONNECTIONS) throw new ModelConfigError(`a box keeps at most ${String(MAX_CONNECTIONS)} connections`);
|
|
262
|
+
const made = newConnection(patch.provider, labelFor(cfg, patch.provider, field(patch, 'label', '', 'label', MAX_LABEL)));
|
|
263
|
+
const filled = applyToConnection(made, patch);
|
|
264
|
+
return { ...cfg, route: cfg.route === '' ? filled.id : cfg.route, connections: [...cfg.connections, filled] };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function applyToConnection(c: Connection, patch: Record<string, unknown>): Connection {
|
|
268
|
+
return {
|
|
269
|
+
...c,
|
|
270
|
+
label: field(patch, 'label', c.label, 'label', MAX_LABEL),
|
|
271
|
+
model: field(patch, 'model', c.model, 'model'),
|
|
272
|
+
apiKey: field(patch, 'apiKey', c.apiKey, 'API key'),
|
|
273
|
+
region: field(patch, 'region', c.region, 'region'),
|
|
274
|
+
zdr: flag(patch, 'zdr', c.zdr, 'zero data retention'),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function updateConnection(cfg: ModelConfig, id: string, patch: unknown): ModelConfig {
|
|
279
|
+
if (!isRecord(patch)) throw new ModelConfigError('body must be a JSON object');
|
|
280
|
+
requireConnection(cfg, id);
|
|
281
|
+
return withConnection(cfg, id, (c) => applyToConnection(c, patch));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function removeConnection(cfg: ModelConfig, id: string): ModelConfig {
|
|
285
|
+
requireConnection(cfg, id);
|
|
286
|
+
const connections = cfg.connections.filter((c) => c.id !== id);
|
|
287
|
+
return { ...cfg, route: cfg.route === id ? (connections[0]?.id ?? '') : cfg.route, connections };
|
|
228
288
|
}
|
|
229
289
|
|
|
230
|
-
export function
|
|
231
|
-
|
|
290
|
+
export function setRoute(cfg: ModelConfig, id: string): ModelConfig {
|
|
291
|
+
requireConnection(cfg, id);
|
|
292
|
+
return { ...cfg, route: id };
|
|
232
293
|
}
|
|
@@ -0,0 +1,195 @@
|
|
|
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 { errMsg, log } from '@metro-labs/core/log';
|
|
6
|
+
import { beginLogin, finishLogin, readCodexCliAuth, type CodexTokens } from './codex-auth.js';
|
|
7
|
+
import { beginDeviceLogin, pollDeviceLogin } from './codex-device.js';
|
|
8
|
+
import { codexModels, currentTokens, freshCodexState } 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, freshGeminiState, listGeminiModels, type GeminiDeps } from './gemini.js';
|
|
12
|
+
import { openrouterCredits } from './openrouter.js';
|
|
13
|
+
import { lastServed } from './served.js';
|
|
14
|
+
import { geminiUsage, noteUsage, openrouterUsage, usageOf } from './usage.js';
|
|
15
|
+
import {
|
|
16
|
+
addConnection,
|
|
17
|
+
connectionOf,
|
|
18
|
+
setCodexAuth,
|
|
19
|
+
setGeminiAuth,
|
|
20
|
+
type Connection,
|
|
21
|
+
type ModelConfig,
|
|
22
|
+
type Provider,
|
|
23
|
+
} from './model-config.js';
|
|
24
|
+
import { asApiError, askedConnection, BODY_MAX, connectionFor, CREDITS_TTL_MS, settingsBody, type ModelApiDeps, type Route, type Store } from './model-store.js';
|
|
25
|
+
|
|
26
|
+
const codexState = freshCodexState();
|
|
27
|
+
const geminiState = freshGeminiState();
|
|
28
|
+
|
|
29
|
+
function connectionToFill(store: Store, req: IncomingMessage, provider: Provider): { cfg: ModelConfig; id: string } {
|
|
30
|
+
const asked = askedConnection(req);
|
|
31
|
+
const current = store.read();
|
|
32
|
+
if (asked !== '') {
|
|
33
|
+
if (connectionOf(current, asked)?.provider !== provider) throw new ApiError('no such connection', 404);
|
|
34
|
+
return { cfg: current, id: asked };
|
|
35
|
+
}
|
|
36
|
+
const cfg = addConnection(current, { provider });
|
|
37
|
+
return { cfg, id: cfg.connections.at(-1)?.id ?? '' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const saved = (store: Store, cfg: ModelConfig, note: string, fields: Record<string, unknown>): unknown => {
|
|
41
|
+
store.write(cfg);
|
|
42
|
+
log.info(fields, note);
|
|
43
|
+
return settingsBody(cfg);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function keepCodex(store: Store, req: IncomingMessage, tokens: CodexTokens | null, note: string): unknown {
|
|
47
|
+
const { cfg, id } = connectionToFill(store, req, 'codex');
|
|
48
|
+
const next = setCodexAuth(cfg, id, tokens);
|
|
49
|
+
return saved(store, next, note, { connection: id, plan: tokens?.plan ?? null });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function pollDevice(id: string, req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
|
|
53
|
+
const result = await pollDeviceLogin(id, deps.fetchImpl).catch(asApiError);
|
|
54
|
+
if (result.status !== 'done') return result;
|
|
55
|
+
return { status: 'done', settings: keepCodex(store, req, result.tokens, 'model-api: Codex connected by device code') };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const codexDepsFor = (deps: ModelApiDeps, store: Store, id: string): { issuer?: string; fetchImpl?: typeof fetch; base?: string; save: (connId: string, t: CodexTokens) => void } => ({
|
|
59
|
+
issuer: deps.issuer,
|
|
60
|
+
fetchImpl: deps.fetchImpl,
|
|
61
|
+
base: deps.codexBase,
|
|
62
|
+
save: (connId, t) => {
|
|
63
|
+
store.write(setCodexAuth(store.read(), connId === '' ? id : connId, t));
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const CODEX_ROUTES: Record<string, Route> = {
|
|
68
|
+
device: {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
run: async (_req, deps) => {
|
|
71
|
+
const login = await beginDeviceLogin(deps.issuer, deps.fetchImpl).catch(asApiError);
|
|
72
|
+
return { id: login.id, user_code: login.userCode, verify_url: login.verifyUrl, interval: login.interval };
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
login: { method: 'POST', run: (_req, deps) => Promise.resolve({ url: beginLogin(deps.issuer).url }) },
|
|
76
|
+
callback: {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
run: async (req, deps, store) => {
|
|
79
|
+
const body = await readJsonBody(req, BODY_MAX);
|
|
80
|
+
const raw = isRecord(body) && typeof body.url === 'string' ? body.url : '';
|
|
81
|
+
const tokens = await finishLogin(raw, deps.issuer, deps.fetchImpl).catch(asApiError);
|
|
82
|
+
return keepCodex(store, req, tokens, 'model-api: Codex connected');
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
import: {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
run: (_req, deps, store) => {
|
|
88
|
+
let tokens;
|
|
89
|
+
try {
|
|
90
|
+
tokens = readCodexCliAuth(deps.codexHome);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
asApiError(err);
|
|
93
|
+
}
|
|
94
|
+
return Promise.resolve(keepCodex(store, _req, tokens, 'model-api: Codex CLI login imported'));
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
models: {
|
|
98
|
+
method: 'GET',
|
|
99
|
+
run: async (req, deps, store) => {
|
|
100
|
+
const conn = connectionFor(store.read(), req, 'codex');
|
|
101
|
+
if (conn.codex === null) throw new ApiError('this connection is not signed in yet', 400);
|
|
102
|
+
const codexDeps = codexDepsFor(deps, store, conn.id);
|
|
103
|
+
const auth = await currentTokens(conn, codexDeps, codexState).catch(asApiError);
|
|
104
|
+
return { models: await codexModels(auth, codexDeps).catch(asApiError) };
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const geminiDeps = (deps: ModelApiDeps, store: Store): GeminiDeps => ({
|
|
110
|
+
base: deps.geminiBase,
|
|
111
|
+
tokenBase: deps.geminiTokenBase,
|
|
112
|
+
fetchImpl: deps.fetchImpl,
|
|
113
|
+
save: (id, t) => {
|
|
114
|
+
store.write(setGeminiAuth(store.read(), id, t));
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
async function geminiModelsOf(conn: Connection, deps: ModelApiDeps, store: Store): Promise<Awaited<ReturnType<typeof listGeminiModels>>> {
|
|
119
|
+
if (conn.gemini === null) throw new ApiError('this connection is not signed in yet', 400);
|
|
120
|
+
const tokens = await currentGeminiTokens(conn, geminiDeps(deps, store), geminiState);
|
|
121
|
+
return listGeminiModels(tokens, geminiDeps(deps, store));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function projectOf(body: unknown): string | null {
|
|
125
|
+
try {
|
|
126
|
+
return parseGeminiProject(isRecord(body) ? body.project : null);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return asApiError(err);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function connectGemini(req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
|
|
133
|
+
const body = await readJsonBody(req, BODY_MAX);
|
|
134
|
+
const code = isRecord(body) && typeof body.code === 'string' ? body.code : '';
|
|
135
|
+
const state = isRecord(body) && typeof body.state === 'string' ? body.state : '';
|
|
136
|
+
const project = projectOf(body);
|
|
137
|
+
const tokens = await exchangeGeminiCode(code, state, deps.geminiTokenBase, deps.fetchImpl).catch(asApiError);
|
|
138
|
+
const email = await userEmail(tokens, deps.geminiUserBase, deps.fetchImpl);
|
|
139
|
+
const onboarded = await onboard(tokens, project, deps.geminiBase, deps.fetchImpl).catch(asApiError);
|
|
140
|
+
const { cfg, id } = connectionToFill(store, req, 'gemini');
|
|
141
|
+
const full: GeminiTokens = { ...tokens, email, project: onboarded.project, tier: onboarded.tier };
|
|
142
|
+
return saved(store, setGeminiAuth(cfg, id, full), 'model-api: Gemini connected', { connection: id, tier: onboarded.tier });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const GEMINI_ROUTES: Record<string, Route> = {
|
|
146
|
+
login: { method: 'POST', run: (_req, deps) => Promise.resolve(beginGeminiLogin(deps.geminiAuthBase)) },
|
|
147
|
+
code: { method: 'POST', run: connectGemini },
|
|
148
|
+
models: {
|
|
149
|
+
method: 'GET',
|
|
150
|
+
run: async (req, deps, store) => ({ models: (await geminiModelsOf(connectionFor(store.read(), req, 'gemini'), deps, store).catch(asApiError)).map((m) => m.id) }),
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export function codexDeviceRoute(id: string, method: string | undefined): Route | number {
|
|
155
|
+
return method === 'GET' ? { method: 'GET', run: (req, deps, store) => pollDevice(id, req, deps, store) } : 405;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function refreshOne(conn: Connection, deps: ModelApiDeps, now: number): Promise<void> {
|
|
159
|
+
if (conn.apiKey === '') return;
|
|
160
|
+
const seen = usageOf(conn.id);
|
|
161
|
+
if (seen !== undefined && now - Date.parse(seen.at) < CREDITS_TTL_MS) return;
|
|
162
|
+
try {
|
|
163
|
+
const credits = await openrouterCredits(conn.apiKey, deps.openrouterBase, deps.fetchImpl);
|
|
164
|
+
noteUsage(conn.id, openrouterUsage(credits.total, credits.spent, new Date(now)));
|
|
165
|
+
} catch (err) {
|
|
166
|
+
log.warn({ err: errMsg(err) }, 'model-api: could not read the OpenRouter credits');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const modelsInUse = (conn: Connection): Set<string> => {
|
|
171
|
+
const served = lastServed();
|
|
172
|
+
return new Set([conn.model, served?.connection === conn.id ? served.model : ''].filter((m) => m !== ''));
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
async function refreshQuota(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
|
|
176
|
+
if (conn.gemini === null) return;
|
|
177
|
+
const inUse = modelsInUse(conn);
|
|
178
|
+
const seen = usageOf(conn.id);
|
|
179
|
+
const covers = seen !== undefined && [...inUse].every((id) => seen.windows.some((w) => w.label === id));
|
|
180
|
+
if (seen !== undefined && covers && now - Date.parse(seen.at) < CREDITS_TTL_MS) return;
|
|
181
|
+
try {
|
|
182
|
+
const rows = (await geminiModelsOf(conn, deps, store)).filter((m) => inUse.has(m.id));
|
|
183
|
+
const usage = geminiUsage(rows, new Date(now));
|
|
184
|
+
if (usage !== null) noteUsage(conn.id, usage);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
log.warn({ err: errMsg(err) }, 'model-api: could not read the Gemini quota');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function refreshUsage(deps: ModelApiDeps, store: Store, now = Date.now()): Promise<void> {
|
|
191
|
+
for (const conn of store.read().connections) {
|
|
192
|
+
if (conn.provider === 'openrouter') await refreshOne(conn, deps, now);
|
|
193
|
+
if (conn.provider === 'gemini') await refreshQuota(conn, deps, store, now);
|
|
194
|
+
}
|
|
195
|
+
}
|