@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,19 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
import { ID_RE, newId } from '@metro-labs/core/ids';
|
|
3
|
-
import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
|
|
4
|
-
import { agentsDir } from '../agents/files.js';
|
|
5
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
|
-
import { CODEX_SIGN_IN, NEW_CLIENT, type CodexCredential, type CodexMethod, type CodexTokens } from './codex-auth.js';
|
|
7
|
-
import { tokensFromDisk as geminiTokensFromDisk, type GeminiTokens } from './gemini-auth.js';
|
|
8
|
-
|
|
9
|
-
export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex', 'gemini'] as const;
|
|
1
|
+
export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex'] as const;
|
|
10
2
|
export type Provider = (typeof PROVIDERS)[number];
|
|
3
|
+
export type CodexMethod = 'chatgpt' | 'code';
|
|
4
|
+
export type RouteSource = 'agent' | 'organization';
|
|
11
5
|
|
|
12
|
-
export interface
|
|
13
|
-
|
|
6
|
+
export interface CodexAccess {
|
|
7
|
+
method: CodexMethod;
|
|
8
|
+
accessToken: string;
|
|
9
|
+
expiresAt: number;
|
|
10
|
+
accountId: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Account {
|
|
14
14
|
email: string | null;
|
|
15
15
|
plan: string | null;
|
|
16
|
-
|
|
16
|
+
method: CodexMethod | null;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export interface Connection {
|
|
@@ -24,9 +24,11 @@ export interface Connection {
|
|
|
24
24
|
apiKey: string;
|
|
25
25
|
region: string;
|
|
26
26
|
zdr: boolean;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
claude:
|
|
27
|
+
revision: number;
|
|
28
|
+
codex: CodexAccess | null;
|
|
29
|
+
claude: string | null;
|
|
30
|
+
account: Account;
|
|
31
|
+
signedOut: boolean;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export interface Fallback {
|
|
@@ -34,11 +36,11 @@ export interface Fallback {
|
|
|
34
36
|
model: string;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
export interface
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
export interface ModelSetup {
|
|
40
|
+
primary: Fallback | null;
|
|
41
|
+
fallbacks: Fallback[];
|
|
40
42
|
connections: Connection[];
|
|
41
|
-
|
|
43
|
+
source: RouteSource | null;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export interface Route {
|
|
@@ -46,317 +48,112 @@ export interface Route {
|
|
|
46
48
|
model: string;
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
export
|
|
51
|
+
export const LOCAL_PREFIX = 'local:';
|
|
52
|
+
export const SESSION_LOGIN = `${LOCAL_PREFIX}session`;
|
|
53
|
+
export const CODEX_SIGN_IN = "Codex is not signed in: sign in again on the organization's Models page.";
|
|
50
54
|
|
|
51
|
-
const
|
|
52
|
-
const MAX_FIELD = 512;
|
|
53
|
-
const MAX_LABEL = 60;
|
|
54
|
-
const MAX_CONNECTIONS = 20;
|
|
55
|
-
const MAX_FALLBACKS = 8;
|
|
56
|
-
const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex|gemini):(.+)$/;
|
|
55
|
+
const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex):(.+)$/;
|
|
57
56
|
const SMALL_RE = /haiku/i;
|
|
57
|
+
const NO_ACCOUNT: Account = { email: null, plan: null, method: null };
|
|
58
58
|
|
|
59
|
-
const
|
|
60
|
-
anthropic: 'Anthropic',
|
|
61
|
-
bedrock: 'Amazon Bedrock',
|
|
62
|
-
openrouter: 'OpenRouter',
|
|
63
|
-
codex: 'Codex (ChatGPT)',
|
|
64
|
-
gemini: 'Gemini (Google)',
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
const isProvider = (value: unknown): value is Provider => typeof value === 'string' && (PROVIDERS as readonly string[]).includes(value);
|
|
59
|
+
export const isProvider = (value: unknown): value is Provider => typeof value === 'string' && (PROVIDERS as readonly string[]).includes(value);
|
|
68
60
|
|
|
69
|
-
const
|
|
70
|
-
const maybe = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
|
|
61
|
+
export const emptySetup = (): ModelSetup => ({ primary: null, fallbacks: [], connections: [], source: null });
|
|
71
62
|
|
|
72
|
-
export const
|
|
63
|
+
export const loginRef = (login: string): string => `${LOCAL_PREFIX}${login}`;
|
|
73
64
|
|
|
74
|
-
const
|
|
75
|
-
id
|
|
76
|
-
provider,
|
|
65
|
+
const localConnection = (id: string, label: string, claude: string | null, account: Account, missing: boolean): Connection => ({
|
|
66
|
+
id,
|
|
67
|
+
provider: 'anthropic',
|
|
77
68
|
label,
|
|
78
69
|
model: '',
|
|
79
70
|
apiKey: '',
|
|
80
71
|
region: '',
|
|
81
72
|
zdr: false,
|
|
73
|
+
revision: 0,
|
|
82
74
|
codex: null,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
function claudeFromDisk(raw: unknown): ClaudeLogin | null {
|
|
88
|
-
if (!isRecord(raw) || !ID_RE.test(text(raw.id))) return null;
|
|
89
|
-
return { id: text(raw.id), email: maybe(raw.email), plan: maybe(raw.plan), savedAt: text(raw.savedAt) };
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function credentialFromDisk(raw: Record<string, unknown>): CodexCredential | null {
|
|
93
|
-
const accessToken = text(raw.accessToken);
|
|
94
|
-
const refreshToken = text(raw.refreshToken);
|
|
95
|
-
if (accessToken === '' || refreshToken === '') return null;
|
|
96
|
-
return {
|
|
97
|
-
email: maybe(raw.email),
|
|
98
|
-
accessToken,
|
|
99
|
-
refreshToken,
|
|
100
|
-
expiresAt: typeof raw.expiresAt === 'number' && Number.isFinite(raw.expiresAt) ? raw.expiresAt : 0,
|
|
101
|
-
savedAt: text(raw.savedAt) === '' ? new Date(0).toISOString() : text(raw.savedAt),
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function codexFromDisk(raw: unknown): CodexTokens | null {
|
|
106
|
-
if (!isRecord(raw)) return null;
|
|
107
|
-
const held = credentialFromDisk(raw);
|
|
108
|
-
if (held === null) return null;
|
|
109
|
-
const accountId = text(raw.accountId);
|
|
110
|
-
if (accountId !== '') return { method: 'code', accountId, plan: maybe(raw.plan), ...held };
|
|
111
|
-
const clientId = text(raw.clientId);
|
|
112
|
-
return clientId === '' || clientId === NEW_CLIENT ? null : { method: 'chatgpt', clientId, subject: text(raw.subject), ...held };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function connectionFromDisk(raw: unknown): Connection | null {
|
|
116
|
-
if (!isRecord(raw) || !isProvider(raw.provider)) return null;
|
|
117
|
-
const id = text(raw.id);
|
|
118
|
-
const provider = raw.provider;
|
|
119
|
-
return {
|
|
120
|
-
id: id === '' ? newId() : id,
|
|
121
|
-
provider,
|
|
122
|
-
label: text(raw.label) === '' ? LABELS[provider] : text(raw.label).slice(0, MAX_LABEL),
|
|
123
|
-
model: text(raw.model),
|
|
124
|
-
apiKey: text(raw.apiKey),
|
|
125
|
-
region: text(raw.region),
|
|
126
|
-
zdr: raw.zdr === true,
|
|
127
|
-
codex: codexFromDisk(raw.codex),
|
|
128
|
-
gemini: geminiTokensFromDisk(raw.gemini),
|
|
129
|
-
claude: provider === 'anthropic' ? claudeFromDisk(raw.claude) : null,
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function fallbacksOf(raw: unknown, connections: Connection[]): Fallback[] {
|
|
134
|
-
if (!Array.isArray(raw)) return [];
|
|
135
|
-
const known = raw.flatMap((f: unknown) => {
|
|
136
|
-
if (!isRecord(f)) return [];
|
|
137
|
-
const entry = { connection: text(f.connection), model: text(f.model).slice(0, MAX_FIELD) };
|
|
138
|
-
return entry.model !== '' && connections.some((c) => c.id === entry.connection) ? [entry] : [];
|
|
139
|
-
});
|
|
140
|
-
return known.filter((f, at) => known.findIndex((o) => o.connection === f.connection && o.model === f.model) === at).slice(0, MAX_FALLBACKS);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const withFallbacks = (cfg: ModelConfig, fallbacks: Fallback[]): ModelConfig => ({
|
|
144
|
-
version: 2,
|
|
145
|
-
route: cfg.route,
|
|
146
|
-
connections: cfg.connections,
|
|
147
|
-
...(fallbacks.length === 0 ? {} : { fallbacks }),
|
|
75
|
+
claude,
|
|
76
|
+
account,
|
|
77
|
+
signedOut: missing,
|
|
148
78
|
});
|
|
149
79
|
|
|
150
|
-
export
|
|
151
|
-
if (!isRecord(raw) || !Array.isArray(raw.connections)) return empty();
|
|
152
|
-
const connections = raw.connections.flatMap((c: unknown) => connectionFromDisk(c) ?? []).slice(0, MAX_CONNECTIONS);
|
|
153
|
-
const route = text(raw.route);
|
|
154
|
-
const cfg: ModelConfig = { version: 2, route: connections.some((c) => c.id === route) ? route : (connections[0]?.id ?? ''), connections };
|
|
155
|
-
return withFallbacks(cfg, fallbacksOf(raw.fallbacks, connections));
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const needsIds = (raw: unknown): boolean =>
|
|
159
|
-
isRecord(raw) && Array.isArray(raw.connections) && raw.connections.some((c: unknown) => isRecord(c) && text(c.id) === '');
|
|
160
|
-
|
|
161
|
-
const readStoredModel = (dir: string): unknown =>
|
|
162
|
-
readJson<unknown>(join(dir, MODEL_FILE), null, { warn: 'model-config: model.json is unreadable, so every request goes to Anthropic until it is fixed' });
|
|
163
|
-
|
|
164
|
-
export function readModelConfig(dir = agentsDir()): ModelConfig {
|
|
165
|
-
const raw = readStoredModel(dir);
|
|
166
|
-
const cfg = parseModelConfig(raw);
|
|
167
|
-
if (needsIds(raw)) writeModelConfig(cfg, dir);
|
|
168
|
-
return cfg;
|
|
169
|
-
}
|
|
80
|
+
export const sessionConnection = (): Connection => localConnection(SESSION_LOGIN, 'Claude Code login', null, NO_ACCOUNT, false);
|
|
170
81
|
|
|
171
|
-
export
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
82
|
+
export interface LocalLogin {
|
|
83
|
+
id: string;
|
|
84
|
+
email: string | null;
|
|
85
|
+
plan: string | null;
|
|
175
86
|
}
|
|
176
87
|
|
|
177
|
-
export function
|
|
178
|
-
|
|
88
|
+
export function loginConnection(login: string, known: LocalLogin | undefined): Connection {
|
|
89
|
+
const account = known === undefined ? NO_ACCOUNT : { email: known.email, plan: known.plan, method: null };
|
|
90
|
+
return localConnection(loginRef(login), known?.email ?? 'Claude login', login, account, known === undefined);
|
|
179
91
|
}
|
|
180
92
|
|
|
181
|
-
export const connectionOf = (
|
|
182
|
-
|
|
183
|
-
export const routedConnection = (cfg: ModelConfig): Connection | null => connectionOf(cfg, cfg.route);
|
|
93
|
+
export const connectionOf = (setup: ModelSetup, id: string): Connection | null => setup.connections.find((c) => c.id === id) ?? null;
|
|
184
94
|
|
|
185
|
-
function
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
return
|
|
95
|
+
export function primaryRoute(setup: ModelSetup): Route | null {
|
|
96
|
+
const primary = setup.primary;
|
|
97
|
+
const conn = primary === null ? null : connectionOf(setup, primary.connection);
|
|
98
|
+
if (primary === null || conn === null) return null;
|
|
99
|
+
return { connection: conn, model: primary.model === '' ? conn.model : primary.model };
|
|
189
100
|
}
|
|
190
101
|
|
|
191
|
-
const
|
|
102
|
+
const MISSING_LOGIN = 'This Claude login is not on this box: sign in again on the Model page.';
|
|
192
103
|
|
|
193
|
-
const CHECKS: Record<Provider, [(
|
|
194
|
-
anthropic: [
|
|
104
|
+
const CHECKS: Record<Provider, [(r: Route) => boolean, string][]> = {
|
|
105
|
+
anthropic: [
|
|
106
|
+
[(r) => r.connection.claude !== null && r.connection.signedOut, MISSING_LOGIN],
|
|
107
|
+
[(r) => !r.connection.id.startsWith(LOCAL_PREFIX) && r.connection.apiKey === '', "Anthropic has no API key: add it on the organization's Models page."],
|
|
108
|
+
],
|
|
195
109
|
bedrock: [
|
|
196
|
-
[(
|
|
197
|
-
[(
|
|
110
|
+
[(r) => r.connection.apiKey === '', "Bedrock has no API key: add it on the organization's Models page."],
|
|
111
|
+
[(r) => r.connection.region === '', "Bedrock needs a region: add it on the organization's Models page."],
|
|
198
112
|
],
|
|
199
113
|
openrouter: [
|
|
200
|
-
[(
|
|
201
|
-
[(
|
|
114
|
+
[(r) => r.connection.apiKey === '', "OpenRouter has no API key: add it on the organization's Models page."],
|
|
115
|
+
[(r) => r.model === '', "OpenRouter needs a model id: choose one on the organization's Models page."],
|
|
202
116
|
],
|
|
203
117
|
codex: [
|
|
204
|
-
[(
|
|
205
|
-
[(
|
|
206
|
-
],
|
|
207
|
-
gemini: [
|
|
208
|
-
[(c) => c.gemini === null, 'Gemini is not connected: sign in with Google on the Model page.'],
|
|
209
|
-
[(c) => c.model === '', 'Gemini needs a model id: choose one on the Model page.'],
|
|
118
|
+
[(r) => r.connection.codex === null, CODEX_SIGN_IN],
|
|
119
|
+
[(r) => r.model === '', "Codex needs a model id: choose one on the organization's Models page."],
|
|
210
120
|
],
|
|
211
121
|
};
|
|
212
122
|
|
|
213
|
-
export
|
|
214
|
-
if (conn === null) return cfg.connections.length === 0 ? null : PASSTHROUGH;
|
|
215
|
-
return CHECKS[conn.provider].find(([missing]) => missing(conn))?.[1] ?? null;
|
|
216
|
-
}
|
|
123
|
+
export const notReady = (route: Route): string | null => CHECKS[route.connection.provider].find(([missing]) => missing(route))?.[1] ?? null;
|
|
217
124
|
|
|
218
125
|
export const isSmallModel = (requested: string): boolean => SMALL_RE.test(requested);
|
|
219
126
|
|
|
220
|
-
const DEFAULTS: Record<Provider, (requested: string,
|
|
221
|
-
openrouter: (requested,
|
|
222
|
-
bedrock: (requested,
|
|
223
|
-
codex: (requested,
|
|
224
|
-
|
|
225
|
-
anthropic: (requested, c) => (c.model === '' || isSmallModel(requested) ? requested : c.model),
|
|
127
|
+
const DEFAULTS: Record<Provider, (requested: string, model: string) => string> = {
|
|
128
|
+
openrouter: (requested, model) => (requested.includes('/') ? requested : model),
|
|
129
|
+
bedrock: (requested, model) => (model === '' ? requested : model),
|
|
130
|
+
codex: (requested, model) => (requested.startsWith('gpt-') ? requested : model),
|
|
131
|
+
anthropic: (requested, model) => (model === '' || isSmallModel(requested) ? requested : model),
|
|
226
132
|
};
|
|
227
133
|
|
|
228
|
-
function forProvider(
|
|
229
|
-
const routed =
|
|
134
|
+
function forProvider(setup: ModelSetup, provider: Provider): Connection | null {
|
|
135
|
+
const routed = primaryRoute(setup)?.connection ?? null;
|
|
230
136
|
if (routed?.provider === provider) return routed;
|
|
231
|
-
return
|
|
137
|
+
return setup.connections.find((c) => c.provider === provider) ?? null;
|
|
232
138
|
}
|
|
233
139
|
|
|
234
140
|
export const bareModel = (requested: string): string => PREFIX_RE.exec(requested)?.[2]?.trim() ?? requested;
|
|
235
141
|
|
|
236
|
-
export function resolveRoute(requested: string,
|
|
142
|
+
export function resolveRoute(requested: string, setup: ModelSetup): Route | null {
|
|
237
143
|
const explicit = PREFIX_RE.exec(requested);
|
|
238
144
|
if (explicit !== null && isProvider(explicit[1])) {
|
|
239
|
-
const conn = forProvider(
|
|
240
|
-
|
|
145
|
+
const conn = forProvider(setup, explicit[1]);
|
|
146
|
+
if (conn !== null) return { connection: conn, model: (explicit[2] ?? '').trim() };
|
|
241
147
|
}
|
|
242
|
-
const
|
|
243
|
-
|
|
148
|
+
const primary = primaryRoute(setup);
|
|
149
|
+
if (primary === null) return null;
|
|
150
|
+
return { connection: primary.connection, model: DEFAULTS[primary.connection.provider](bareModel(requested), primary.model) };
|
|
244
151
|
}
|
|
245
152
|
|
|
246
153
|
export const routeLabel = (route: Route): string =>
|
|
247
154
|
route.connection.provider === 'anthropic' ? route.model : `${route.connection.provider}:${route.model}`;
|
|
248
155
|
|
|
249
|
-
export
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (c.claude !== null) return { account: c.claude.email, plan: c.claude.plan };
|
|
253
|
-
if (c.codex !== null) return { account: c.codex.email, plan: c.codex.method === 'code' ? c.codex.plan : null, method: c.codex.method };
|
|
254
|
-
return { account: c.gemini?.email ?? null, plan: c.gemini?.tier ?? null };
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function publicConnection(c: Connection): Record<string, unknown> {
|
|
258
|
-
return {
|
|
259
|
-
id: c.id,
|
|
260
|
-
provider: c.provider,
|
|
261
|
-
label: c.label,
|
|
262
|
-
model: c.model,
|
|
263
|
-
hasKey: c.apiKey !== '',
|
|
264
|
-
region: c.region,
|
|
265
|
-
zdr: c.zdr,
|
|
266
|
-
signedIn: c.codex !== null || c.gemini !== null || c.claude !== null,
|
|
267
|
-
...signedInAs(c),
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
export function publicModelConfig(cfg: ModelConfig): Record<string, unknown> {
|
|
272
|
-
const reason = notReady(cfg);
|
|
273
|
-
return { route: cfg.route, ready: reason === null, reason, connections: cfg.connections.map(publicConnection) };
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
const withConnection = (cfg: ModelConfig, id: string, change: (c: Connection) => Connection): ModelConfig => ({
|
|
277
|
-
...cfg,
|
|
278
|
-
connections: cfg.connections.map((c) => (c.id === id ? change(c) : c)),
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
export const setCodexAuth = (cfg: ModelConfig, id: string, auth: CodexTokens | null): ModelConfig =>
|
|
282
|
-
withConnection(cfg, id, (c) => ({ ...c, codex: auth }));
|
|
283
|
-
|
|
284
|
-
export const setGeminiAuth = (cfg: ModelConfig, id: string, auth: GeminiTokens | null): ModelConfig =>
|
|
285
|
-
withConnection(cfg, id, (c) => ({ ...c, gemini: auth }));
|
|
286
|
-
|
|
287
|
-
export const setClaudeLogin = (cfg: ModelConfig, id: string, login: ClaudeLogin | null): ModelConfig =>
|
|
288
|
-
withConnection(cfg, id, (c) => ({ ...c, claude: login }));
|
|
289
|
-
|
|
290
|
-
function field(patch: Record<string, unknown>, key: string, current: string, what: string, max = MAX_FIELD): string {
|
|
291
|
-
if (!(key in patch)) return current;
|
|
292
|
-
const value = patch[key];
|
|
293
|
-
if (typeof value !== 'string') throw new ModelConfigError(`${what} must be a string`);
|
|
294
|
-
if (value.length > max) throw new ModelConfigError(`${what} is too long`);
|
|
295
|
-
return value.trim();
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
function flag(patch: Record<string, unknown>, key: string, current: boolean, what: string): boolean {
|
|
299
|
-
if (!(key in patch)) return current;
|
|
300
|
-
const value = patch[key];
|
|
301
|
-
if (typeof value !== 'boolean') throw new ModelConfigError(`${what} must be true or false`);
|
|
302
|
-
return value;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function labelFor(cfg: ModelConfig, provider: Provider, given: string): string {
|
|
306
|
-
if (given !== '') return given.slice(0, MAX_LABEL);
|
|
307
|
-
const taken = cfg.connections.filter((c) => c.provider === provider).length;
|
|
308
|
-
return taken === 0 ? LABELS[provider] : `${LABELS[provider]} ${String(taken + 1)}`;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
export function addConnection(cfg: ModelConfig, patch: unknown): ModelConfig {
|
|
312
|
-
if (!isRecord(patch) || !isProvider(patch.provider)) throw new ModelConfigError(`provider must be one of ${PROVIDERS.join(', ')}`);
|
|
313
|
-
if (cfg.connections.length >= MAX_CONNECTIONS) throw new ModelConfigError(`a box keeps at most ${String(MAX_CONNECTIONS)} connections`);
|
|
314
|
-
const made = newConnection(patch.provider, labelFor(cfg, patch.provider, field(patch, 'label', '', 'label', MAX_LABEL)));
|
|
315
|
-
const filled = applyToConnection(made, patch);
|
|
316
|
-
return { ...cfg, route: cfg.route === '' ? filled.id : cfg.route, connections: [...cfg.connections, filled] };
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function applyToConnection(c: Connection, patch: Record<string, unknown>): Connection {
|
|
320
|
-
return {
|
|
321
|
-
...c,
|
|
322
|
-
label: field(patch, 'label', c.label, 'label', MAX_LABEL),
|
|
323
|
-
model: field(patch, 'model', c.model, 'model'),
|
|
324
|
-
apiKey: field(patch, 'apiKey', c.apiKey, 'API key'),
|
|
325
|
-
region: field(patch, 'region', c.region, 'region'),
|
|
326
|
-
zdr: flag(patch, 'zdr', c.zdr, 'zero data retention'),
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
export function updateConnection(cfg: ModelConfig, id: string, patch: unknown): ModelConfig {
|
|
331
|
-
if (!isRecord(patch)) throw new ModelConfigError('body must be a JSON object');
|
|
332
|
-
requireConnection(cfg, id);
|
|
333
|
-
return withConnection(cfg, id, (c) => applyToConnection(c, patch));
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
export function removeConnection(cfg: ModelConfig, id: string): ModelConfig {
|
|
337
|
-
requireConnection(cfg, id);
|
|
338
|
-
const connections = cfg.connections.filter((c) => c.id !== id);
|
|
339
|
-
const next = { ...cfg, route: cfg.route === id ? (connections[0]?.id ?? '') : cfg.route, connections };
|
|
340
|
-
return withFallbacks(next, (cfg.fallbacks ?? []).filter((f) => f.connection !== id));
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function fallbackEntry(cfg: ModelConfig, raw: unknown): Fallback {
|
|
344
|
-
if (!isRecord(raw)) throw new ModelConfigError('each fallback must be an object with a connection and a model');
|
|
345
|
-
const connection = field(raw, 'connection', '', 'connection');
|
|
346
|
-
const model = field(raw, 'model', '', 'model');
|
|
347
|
-
requireConnection(cfg, connection);
|
|
348
|
-
if (model === '') throw new ModelConfigError('each fallback needs a model');
|
|
349
|
-
return { connection, model };
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
export function setFallbacks(cfg: ModelConfig, raw: unknown): ModelConfig {
|
|
353
|
-
if (!Array.isArray(raw)) throw new ModelConfigError('fallbacks must be a list');
|
|
354
|
-
if (raw.length > MAX_FALLBACKS) throw new ModelConfigError(`a box keeps at most ${String(MAX_FALLBACKS)} fallbacks`);
|
|
355
|
-
const entries = raw.map((f: unknown) => fallbackEntry(cfg, f));
|
|
356
|
-
return withFallbacks(cfg, fallbacksOf(entries, cfg.connections));
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
export function setRoute(cfg: ModelConfig, id: string): ModelConfig {
|
|
360
|
-
requireConnection(cfg, id);
|
|
361
|
-
return { ...cfg, route: id };
|
|
156
|
+
export function runnerModel(setup: ModelSetup): string | null {
|
|
157
|
+
const primary = primaryRoute(setup);
|
|
158
|
+
return primary === null || primary.model === '' ? null : routeLabel(primary);
|
|
362
159
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import { writeSecure } from '@metro-labs/core/secure-fs';
|
|
4
|
+
import type { AgentKey } from '../connectors/agent-key.js';
|
|
5
|
+
import { agentCall, type MetroApi } from '../connectors/metro-api.js';
|
|
6
|
+
import { claudeLoginOf, claudeLoginsIn, isV2, modelPath, readModelFile, v3Text } from './claude-store.js';
|
|
7
|
+
import { isProvider, type Fallback } from './model-config.js';
|
|
8
|
+
|
|
9
|
+
const IMPORT_PATH = '/api/agents/models/import';
|
|
10
|
+
let lastRefusal = '';
|
|
11
|
+
|
|
12
|
+
const CODEX_FIELDS = ['clientId', 'subject', 'accountId', 'plan', 'email', 'accessToken', 'refreshToken', 'expiresAt', 'savedAt'] as const;
|
|
13
|
+
|
|
14
|
+
const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
|
15
|
+
|
|
16
|
+
type Row = Record<string, unknown>;
|
|
17
|
+
|
|
18
|
+
interface Plan {
|
|
19
|
+
connections: Row[];
|
|
20
|
+
sessionOnly: Set<string>;
|
|
21
|
+
credentialed: number;
|
|
22
|
+
gemini: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function codexOf(raw: unknown): Row | null {
|
|
26
|
+
if (!isRecord(raw) || text(raw.accessToken) === '' || text(raw.refreshToken) === '') return null;
|
|
27
|
+
const method = text(raw.accountId) !== '' ? 'code' : text(raw.clientId) !== '' ? 'chatgpt' : '';
|
|
28
|
+
if (method === '') return null;
|
|
29
|
+
return { method, ...Object.fromEntries(CODEX_FIELDS.flatMap((field) => (raw[field] === undefined ? [] : [[field, raw[field]]]))) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function credentialOf(c: Row): Row | null {
|
|
33
|
+
if (c.provider === 'codex') {
|
|
34
|
+
const codex = codexOf(c.codex);
|
|
35
|
+
return codex === null ? null : { codex };
|
|
36
|
+
}
|
|
37
|
+
return text(c.apiKey) === '' ? null : { apiKey: text(c.apiKey) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function addRow(plan: Plan, c: Row): void {
|
|
41
|
+
const base = { id: text(c.id), provider: c.provider, label: text(c.label), model: text(c.model), region: text(c.region), zdr: c.zdr === true };
|
|
42
|
+
const credential = credentialOf(c);
|
|
43
|
+
const login = c.provider === 'anthropic' && credential === null ? claudeLoginOf(c.claude) : null;
|
|
44
|
+
if (credential !== null) {
|
|
45
|
+
plan.connections.push({ ...base, ...credential });
|
|
46
|
+
plan.credentialed += 1;
|
|
47
|
+
} else if (login !== null) plan.connections.push({ ...base, claude: { id: login.id } });
|
|
48
|
+
else if (c.provider === 'anthropic') {
|
|
49
|
+
plan.connections.push(base);
|
|
50
|
+
plan.sessionOnly.add(base.id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function planOf(rows: Row[]): Plan {
|
|
55
|
+
const plan: Plan = { connections: [], sessionOnly: new Set(), credentialed: 0, gemini: 0 };
|
|
56
|
+
for (const c of rows) {
|
|
57
|
+
if (c.provider === 'gemini') plan.gemini += 1;
|
|
58
|
+
else if (text(c.id) !== '' && isProvider(c.provider)) addRow(plan, c);
|
|
59
|
+
}
|
|
60
|
+
return plan;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function fallbacksOf(raw: unknown, sent: Set<string>): Fallback[] {
|
|
64
|
+
if (!Array.isArray(raw)) return [];
|
|
65
|
+
return raw.flatMap((f: unknown) => {
|
|
66
|
+
const fallback = isRecord(f) ? { connection: text(f.connection), model: text(f.model) } : null;
|
|
67
|
+
return fallback === null || !sent.has(fallback.connection) || fallback.model === '' ? [] : [fallback];
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function routeOf(raw: Row, rows: Row[], plan: Plan, fallbacks: Fallback[]): Fallback | '' {
|
|
72
|
+
const id = text(raw.route);
|
|
73
|
+
const routed = rows.find((c) => text(c.id) === id);
|
|
74
|
+
if (routed === undefined || !plan.connections.some((c) => c.id === id)) return '';
|
|
75
|
+
const route = { connection: id, model: text(routed.model) };
|
|
76
|
+
return plan.sessionOnly.has(id) && route.model === '' && fallbacks.length === 0 ? '' : route;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function importBody(raw: Row & { connections: unknown[] }): { body: Row | null; sent: number; gemini: number } {
|
|
80
|
+
const rows = raw.connections.filter(isRecord);
|
|
81
|
+
const plan = planOf(rows);
|
|
82
|
+
const fallbacks = fallbacksOf(raw.fallbacks, new Set(plan.connections.map((c) => text(c.id))));
|
|
83
|
+
const route = routeOf(raw, rows, plan, fallbacks);
|
|
84
|
+
const empty = plan.credentialed === 0 && route === '' && fallbacks.length === 0;
|
|
85
|
+
return { body: empty ? null : { connections: plan.connections, route, fallbacks }, sent: plan.credentialed, gemini: plan.gemini };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface Moved {
|
|
89
|
+
sent: number;
|
|
90
|
+
gemini: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function noteSkipped(raw: unknown): void {
|
|
94
|
+
const skipped = Array.isArray(raw) ? raw.flatMap((s: unknown) => (isRecord(s) ? [{ id: text(s.id), reason: text(s.reason) }] : [])) : [];
|
|
95
|
+
if (skipped.length > 0) log.warn({ skipped }, 'org-models: api.metro.box could not import some connections of model.json, so they are not moved');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function importModelFile(api: MetroApi, key: AgentKey, dir: string): Promise<Moved | null> {
|
|
99
|
+
const raw = readModelFile(dir);
|
|
100
|
+
if (!isV2(raw)) return null;
|
|
101
|
+
const { body, sent, gemini } = importBody(raw);
|
|
102
|
+
try {
|
|
103
|
+
if (body !== null) {
|
|
104
|
+
const answer = await agentCall(api, key, IMPORT_PATH, body);
|
|
105
|
+
if (!isRecord(answer.ids)) throw new Error('api.metro.box answered the import without ids');
|
|
106
|
+
noteSkipped(answer.skipped);
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (errMsg(err) !== lastRefusal) log.warn({ err: errMsg(err) }, 'org-models: the model setup of this box could not move to its organization yet; it is tried again');
|
|
110
|
+
lastRefusal = errMsg(err);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
lastRefusal = '';
|
|
114
|
+
return { sent, gemini };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function settleModelFile(dir: string, moved: Moved): boolean {
|
|
118
|
+
const raw = readModelFile(dir);
|
|
119
|
+
if (!isV2(raw)) return false;
|
|
120
|
+
const logins = claudeLoginsIn(raw);
|
|
121
|
+
writeSecure(modelPath(dir), v3Text(logins));
|
|
122
|
+
if (moved.gemini > 0) log.info({ dropped: moved.gemini }, 'org-models: Gemini connections are no longer supported, so they were dropped');
|
|
123
|
+
log.info({ moved: moved.sent, claudeLogins: logins.length }, 'org-models: the model setup of this box moved to its organization; model.json keeps only its Claude logins');
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
1
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
2
|
|
|
4
3
|
export const LIST_TTL_MS = 60 * 60_000;
|
|
5
4
|
export const RETRY_MS = 5 * 60_000;
|
|
6
5
|
|
|
7
6
|
export interface ListCache<T> {
|
|
8
|
-
get: (key: string, load: () => Promise<T[]>) => Promise<T[]>;
|
|
9
7
|
peek: (key: string) => T[] | null;
|
|
10
8
|
refresh: (key: string, load: () => Promise<T[]>) => Promise<void>;
|
|
11
9
|
}
|
|
@@ -24,19 +22,6 @@ export function listCache<T>(name: string, now: () => number = Date.now): ListCa
|
|
|
24
22
|
return hit !== undefined && now() - hit.at < LIST_TTL_MS;
|
|
25
23
|
};
|
|
26
24
|
return {
|
|
27
|
-
async get(key, load) {
|
|
28
|
-
const hit = kept.get(key);
|
|
29
|
-
if (hit !== undefined && fresh(key)) return hit.list;
|
|
30
|
-
try {
|
|
31
|
-
const list = await load();
|
|
32
|
-
kept.set(key, { at: now(), list });
|
|
33
|
-
return list;
|
|
34
|
-
} catch (err) {
|
|
35
|
-
if (hit === undefined) throw err;
|
|
36
|
-
log.warn({ list: name, err: errMsg(err) }, 'model-lists: the provider would not list its models, so the last list it gave stands');
|
|
37
|
-
return hit.list;
|
|
38
|
-
}
|
|
39
|
-
},
|
|
40
25
|
peek: (key) => kept.get(key)?.list ?? null,
|
|
41
26
|
async refresh(key, load) {
|
|
42
27
|
if (fresh(key) || now() - (tried.get(key) ?? Number.NEGATIVE_INFINITY) < RETRY_MS) return;
|
|
@@ -53,5 +38,3 @@ export function listCache<T>(name: string, now: () => number = Date.now): ListCa
|
|
|
53
38
|
export function forgetModelLists(): void {
|
|
54
39
|
for (const clear of clears) clear();
|
|
55
40
|
}
|
|
56
|
-
|
|
57
|
-
export const fingerprint = (...parts: string[]): string => createHash('sha256').update(parts.join('\n')).digest('hex').slice(0, 16);
|