@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
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
|
+
import { claudeLoginOf } from './claude-store.js';
|
|
3
|
+
import { isProvider, LOCAL_PREFIX, loginConnection, sessionConnection, type Account, type CodexAccess, type Connection, type Fallback, type ModelSetup, type Provider } from './model-config.js';
|
|
4
|
+
|
|
5
|
+
const MAX_CONNECTIONS = 20;
|
|
6
|
+
const MAX_FALLBACKS = 8;
|
|
7
|
+
const NEW_CLIENT = 'dynamic_agent_client';
|
|
8
|
+
const NO_ACCOUNT: Account = { email: null, plan: null, method: null };
|
|
9
|
+
|
|
10
|
+
type Row = Record<string, unknown>;
|
|
11
|
+
|
|
12
|
+
const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
|
13
|
+
const maybe = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
|
|
14
|
+
|
|
15
|
+
const expiryOf = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
|
|
16
|
+
|
|
17
|
+
function codexOf(raw: unknown): CodexAccess | null {
|
|
18
|
+
if (!isRecord(raw) || text(raw.accessToken) === '' || text(raw.refreshToken) === '') return null;
|
|
19
|
+
const accountId = maybe(text(raw.accountId));
|
|
20
|
+
const clientId = text(raw.clientId);
|
|
21
|
+
if (accountId === null && (clientId === '' || clientId === NEW_CLIENT)) return null;
|
|
22
|
+
return { method: accountId === null ? 'chatgpt' : 'code', accessToken: text(raw.accessToken), expiresAt: expiryOf(raw.expiresAt), accountId };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function accountOf(raw: unknown, codex: CodexAccess | null): Account {
|
|
26
|
+
if (codex === null || !isRecord(raw)) return NO_ACCOUNT;
|
|
27
|
+
return { email: maybe(raw.email), plan: codex.method === 'code' ? maybe(raw.plan) : null, method: codex.method };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ownConnection(row: Row, id: string, provider: Provider): Connection {
|
|
31
|
+
const codex = provider === 'codex' ? codexOf(row.codex) : null;
|
|
32
|
+
const apiKey = provider === 'codex' ? '' : text(row.apiKey);
|
|
33
|
+
const label = text(row.label);
|
|
34
|
+
return {
|
|
35
|
+
id,
|
|
36
|
+
provider,
|
|
37
|
+
label: label === '' ? provider : label,
|
|
38
|
+
model: text(row.model),
|
|
39
|
+
apiKey,
|
|
40
|
+
region: text(row.region),
|
|
41
|
+
zdr: row.zdr === true,
|
|
42
|
+
revision: 0,
|
|
43
|
+
codex,
|
|
44
|
+
claude: null,
|
|
45
|
+
account: accountOf(row.codex, codex),
|
|
46
|
+
signedOut: codex === null && apiKey === '',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function connectionOf(row: Row): Connection | null {
|
|
51
|
+
const id = text(row.id);
|
|
52
|
+
if (id === '' || id.startsWith(LOCAL_PREFIX) || !isProvider(row.provider)) return null;
|
|
53
|
+
if (row.provider !== 'anthropic' || text(row.apiKey) !== '') return ownConnection(row, id, row.provider);
|
|
54
|
+
const login = claudeLoginOf(row.claude);
|
|
55
|
+
return login === null ? sessionConnection() : loginConnection(login.id, login);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fallbacksOf(raw: unknown, linked: Map<string, Connection>): Fallback[] {
|
|
59
|
+
if (!Array.isArray(raw)) return [];
|
|
60
|
+
const known = raw.flatMap((f: unknown) => {
|
|
61
|
+
const conn = isRecord(f) ? linked.get(text(f.connection)) : undefined;
|
|
62
|
+
const model = isRecord(f) ? text(f.model) : '';
|
|
63
|
+
return conn === undefined || model === '' ? [] : [{ connection: conn.id, model }];
|
|
64
|
+
});
|
|
65
|
+
return known.filter((f, at) => known.findIndex((o) => o.connection === f.connection && o.model === f.model) === at).slice(0, MAX_FALLBACKS);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function v2Setup(raw: Row & { connections: unknown[] }): ModelSetup {
|
|
69
|
+
const rows = raw.connections.filter((c): c is Row => isRecord(c) && (isProvider(c.provider) || c.provider === 'gemini')).slice(0, MAX_CONNECTIONS);
|
|
70
|
+
const linked = new Map(rows.flatMap((row) => {
|
|
71
|
+
const conn = connectionOf(row);
|
|
72
|
+
return conn === null ? [] : [[text(row.id), conn] as const];
|
|
73
|
+
}));
|
|
74
|
+
const routed = rows.find((row) => text(row.id) === text(raw.route)) ?? rows[0];
|
|
75
|
+
const head = routed === undefined ? undefined : linked.get(text(routed.id));
|
|
76
|
+
const primary = routed === undefined || head === undefined ? null : { connection: head.id, model: text(routed.model) };
|
|
77
|
+
const connections = [...linked.values()].filter((c, at, all) => all.findIndex((o) => o.id === c.id) === at);
|
|
78
|
+
return { primary, fallbacks: fallbacksOf(raw.fallbacks, linked), connections, source: null };
|
|
79
|
+
}
|
|
@@ -1,76 +1,10 @@
|
|
|
1
1
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
2
|
import { GatewayError } from './forward.js';
|
|
3
|
-
import { listCache } from './model-lists.js';
|
|
4
|
-
import { stringOf } from '@metro-labs/http/api-http';
|
|
5
3
|
import type { KeySpend } from './usage.js';
|
|
6
4
|
|
|
7
5
|
export const OPENROUTER_BASE = 'https://openrouter.ai/api';
|
|
8
|
-
const MODELS_MAX = 2000;
|
|
9
6
|
const KEY_TIMEOUT_MS = 10_000;
|
|
10
7
|
|
|
11
|
-
export interface OpenRouterModel {
|
|
12
|
-
id: string;
|
|
13
|
-
name: string;
|
|
14
|
-
prompt: number | null;
|
|
15
|
-
completion: number | null;
|
|
16
|
-
created: number | null;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const modelLists = listCache<OpenRouterModel>('openrouter');
|
|
20
|
-
const zdrLists = listCache<string>('openrouter-zdr');
|
|
21
|
-
|
|
22
|
-
function price(raw: unknown): number | null {
|
|
23
|
-
const value = typeof raw === 'string' ? Number(raw) : typeof raw === 'number' ? raw : Number.NaN;
|
|
24
|
-
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function released(raw: unknown): number | null {
|
|
28
|
-
return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : null;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const strings = (value: unknown): string[] | null => (Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : null);
|
|
32
|
-
|
|
33
|
-
function chatCapable(entry: Record<string, unknown>): boolean {
|
|
34
|
-
const output = strings(isRecord(entry.architecture) ? entry.architecture.output_modalities : undefined);
|
|
35
|
-
const params = strings(entry.supported_parameters);
|
|
36
|
-
return (output === null || output.includes('text')) && (params === null || params.includes('tools'));
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function modelOf(entry: unknown): OpenRouterModel | null {
|
|
40
|
-
if (!isRecord(entry) || !chatCapable(entry)) return null;
|
|
41
|
-
const id = stringOf(entry.id);
|
|
42
|
-
if (id === '') return null;
|
|
43
|
-
const pricing = isRecord(entry.pricing) ? entry.pricing : {};
|
|
44
|
-
return {
|
|
45
|
-
id,
|
|
46
|
-
name: stringOf(entry.name) || id,
|
|
47
|
-
prompt: price(pricing.prompt),
|
|
48
|
-
completion: price(pricing.completion),
|
|
49
|
-
created: released(entry.created),
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function newestFirst(a: OpenRouterModel, b: OpenRouterModel): number {
|
|
54
|
-
const when = (b.created ?? 0) - (a.created ?? 0);
|
|
55
|
-
return when !== 0 ? when : a.id.localeCompare(b.id);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
async function listModels(base: string, fetchImpl: typeof fetch): Promise<OpenRouterModel[]> {
|
|
59
|
-
const res = await fetchImpl(`${base}/v1/models`, { headers: { accept: 'application/json' }, redirect: 'manual' });
|
|
60
|
-
if (!res.ok) throw new GatewayError(res.status, 'api_error', `OpenRouter would not list its models (${String(res.status)})`);
|
|
61
|
-
const body: unknown = await res.json();
|
|
62
|
-
const data = typeof body === 'object' && body !== null ? (body as { data?: unknown }).data : undefined;
|
|
63
|
-
if (!Array.isArray(data)) throw new GatewayError(502, 'api_error', 'OpenRouter answered with no model list');
|
|
64
|
-
return data
|
|
65
|
-
.map(modelOf)
|
|
66
|
-
.filter((model): model is OpenRouterModel => model !== null)
|
|
67
|
-
.sort(newestFirst)
|
|
68
|
-
.slice(0, MODELS_MAX);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export const openrouterModels = (base = OPENROUTER_BASE, fetchImpl: typeof fetch = fetch): Promise<OpenRouterModel[]> =>
|
|
72
|
-
modelLists.get(base, () => listModels(base, fetchImpl));
|
|
73
|
-
|
|
74
8
|
const nullableAmount = (raw: unknown): number | null => (typeof raw === 'number' && Number.isFinite(raw) ? raw : null);
|
|
75
9
|
|
|
76
10
|
export async function openrouterKey(apiKey: string, base = OPENROUTER_BASE, fetchImpl: typeof fetch = fetch): Promise<KeySpend> {
|
|
@@ -89,16 +23,3 @@ export async function openrouterKey(apiKey: string, base = OPENROUTER_BASE, fetc
|
|
|
89
23
|
if (spent === null) throw new GatewayError(502, 'api_error', 'OpenRouter answered with no spend for the key');
|
|
90
24
|
return { limit: nullableAmount(data.limit), remaining: nullableAmount(data.limit_remaining), spent };
|
|
91
25
|
}
|
|
92
|
-
|
|
93
|
-
async function listZdr(base: string, fetchImpl: typeof fetch): Promise<string[]> {
|
|
94
|
-
const res = await fetchImpl(`${base}/v1/endpoints/zdr`, { headers: { accept: 'application/json' }, redirect: 'manual' });
|
|
95
|
-
if (!res.ok) throw new GatewayError(res.status, 'api_error', `OpenRouter would not list its zero data retention endpoints (${String(res.status)})`);
|
|
96
|
-
const body: unknown = await res.json();
|
|
97
|
-
const data = typeof body === 'object' && body !== null ? (body as { data?: unknown }).data : undefined;
|
|
98
|
-
if (!Array.isArray(data)) throw new GatewayError(502, 'api_error', 'OpenRouter answered with no endpoint list');
|
|
99
|
-
const ids = new Set<string>();
|
|
100
|
-
for (const entry of data) if (isRecord(entry) && stringOf(entry.model_id) !== '') ids.add(stringOf(entry.model_id));
|
|
101
|
-
return [...ids].sort();
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export const openrouterZdrModels = (base = OPENROUTER_BASE, fetchImpl: typeof fetch = fetch): Promise<string[]> => zdrLists.get(base, () => listZdr(base, fetchImpl));
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
2
|
+
import type { AgentKey } from '../connectors/agent-key.js';
|
|
3
|
+
import { agentCall, MetroApiRefusal, type MetroApi } from '../connectors/metro-api.js';
|
|
4
|
+
import { deliveredOf, refreshedConnection, type Delivered } from './delivered.js';
|
|
5
|
+
import { importModelFile, settleModelFile, type Moved } from './model-import.js';
|
|
6
|
+
import type { Connection } from './model-config.js';
|
|
7
|
+
|
|
8
|
+
const MODELS_PATH = '/api/agents/models';
|
|
9
|
+
const REFRESH_PATH = '/api/agents/models/refresh';
|
|
10
|
+
const SYNC_MS = 60_000;
|
|
11
|
+
const PAGE_SYNC_MS = 5_000;
|
|
12
|
+
const AGENT_KEY_MISSING = 'agent_key_missing';
|
|
13
|
+
|
|
14
|
+
export interface OrgModelsDeps {
|
|
15
|
+
owner: () => string | null;
|
|
16
|
+
key: () => AgentKey | null;
|
|
17
|
+
api: MetroApi;
|
|
18
|
+
dir: () => string;
|
|
19
|
+
pageSyncMs?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SyncStatus {
|
|
23
|
+
enrolled: boolean;
|
|
24
|
+
at: string | null;
|
|
25
|
+
error: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const identityRefused = (err: unknown): boolean => err instanceof MetroApiRefusal && err.upstreamStatus === 401 && err.code === AGENT_KEY_MISSING;
|
|
29
|
+
|
|
30
|
+
const unrenewed = new Set<string>();
|
|
31
|
+
|
|
32
|
+
function noteUnrenewed(conn: Connection): void {
|
|
33
|
+
if (unrenewed.has(conn.id)) return;
|
|
34
|
+
unrenewed.add(conn.id);
|
|
35
|
+
log.warn({ connection: conn.id }, 'org-models: a Codex sign-in still read from model.json is not renewed on this box; api.metro.box renews it once the box moved its model setup to the organization');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class OrgModels {
|
|
39
|
+
private kept: Delivered | null = null;
|
|
40
|
+
private status: SyncStatus = { enrolled: false, at: null, error: null };
|
|
41
|
+
private running: Promise<void> | null = null;
|
|
42
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
43
|
+
private trailing: ReturnType<typeof setTimeout> | null = null;
|
|
44
|
+
private pageAt = Number.NEGATIVE_INFINITY;
|
|
45
|
+
private readonly refreshing = new Map<string, Promise<Connection | null>>();
|
|
46
|
+
private readonly listeners = new Set<() => void>();
|
|
47
|
+
|
|
48
|
+
constructor(private readonly deps: OrgModelsDeps) {}
|
|
49
|
+
|
|
50
|
+
current(): Delivered | null {
|
|
51
|
+
return this.kept;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
syncStatus(): SyncStatus {
|
|
55
|
+
return { ...this.status };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
onChange(listener: () => void): () => void {
|
|
59
|
+
this.listeners.add(listener);
|
|
60
|
+
return () => {
|
|
61
|
+
this.listeners.delete(listener);
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
sync(): Promise<void> {
|
|
66
|
+
this.running ??= this.run().finally(() => {
|
|
67
|
+
this.running = null;
|
|
68
|
+
});
|
|
69
|
+
return this.running;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
requested(): Promise<void> {
|
|
73
|
+
const now = this.deps.api.now();
|
|
74
|
+
const wait = this.pageAt + (this.deps.pageSyncMs ?? PAGE_SYNC_MS) - now;
|
|
75
|
+
if (wait <= 0) {
|
|
76
|
+
this.pageAt = now;
|
|
77
|
+
return this.sync();
|
|
78
|
+
}
|
|
79
|
+
this.trailing ??= this.later(wait);
|
|
80
|
+
return this.running ?? Promise.resolve();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
start(everyMs = SYNC_MS): void {
|
|
84
|
+
if (this.timer !== null) return;
|
|
85
|
+
const tick = (): void => {
|
|
86
|
+
this.sync().catch((err: unknown) => {
|
|
87
|
+
log.warn({ err: errMsg(err) }, 'org-models: the model setup could not be read');
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
tick();
|
|
91
|
+
this.timer = setInterval(tick, everyMs);
|
|
92
|
+
this.timer.unref();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
stop(): void {
|
|
96
|
+
if (this.timer !== null) clearInterval(this.timer);
|
|
97
|
+
if (this.trailing !== null) clearTimeout(this.trailing);
|
|
98
|
+
this.timer = null;
|
|
99
|
+
this.trailing = null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
refresh(conn: Connection): Promise<Connection | null> {
|
|
103
|
+
if (conn.revision < 1) {
|
|
104
|
+
noteUnrenewed(conn);
|
|
105
|
+
return Promise.resolve(null);
|
|
106
|
+
}
|
|
107
|
+
const newer = this.kept?.connections.find((c) => c.id === conn.id && c.revision > conn.revision);
|
|
108
|
+
if (newer !== undefined) return Promise.resolve(newer);
|
|
109
|
+
const slot = `${conn.id}\n${String(conn.revision)}`;
|
|
110
|
+
const found = this.refreshing.get(slot);
|
|
111
|
+
if (found !== undefined) return found;
|
|
112
|
+
const run = this.askRefresh(conn).finally(() => {
|
|
113
|
+
this.refreshing.delete(slot);
|
|
114
|
+
});
|
|
115
|
+
this.refreshing.set(slot, run);
|
|
116
|
+
return run;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private later(wait: number): ReturnType<typeof setTimeout> {
|
|
120
|
+
const timer = setTimeout(() => {
|
|
121
|
+
this.trailing = null;
|
|
122
|
+
this.requested().catch((err: unknown) => {
|
|
123
|
+
log.warn({ err: errMsg(err) }, 'org-models: the model setup could not be read');
|
|
124
|
+
});
|
|
125
|
+
}, wait);
|
|
126
|
+
timer.unref();
|
|
127
|
+
return timer;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private enrolledKey(): AgentKey | null {
|
|
131
|
+
try {
|
|
132
|
+
const key = this.deps.key();
|
|
133
|
+
const owner = this.deps.owner();
|
|
134
|
+
return key !== null && owner !== null && key.enrollment?.organization === owner ? key : null;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
log.warn({ err: errMsg(err) }, 'org-models: the agent key could not be read');
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private keep(next: Delivered | null): void {
|
|
142
|
+
const changed = JSON.stringify(next) !== JSON.stringify(this.kept);
|
|
143
|
+
this.kept = next;
|
|
144
|
+
if (changed) this.notify();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private notify(): void {
|
|
148
|
+
for (const listener of [...this.listeners])
|
|
149
|
+
try {
|
|
150
|
+
listener();
|
|
151
|
+
} catch (err) {
|
|
152
|
+
log.warn({ err: errMsg(err) }, 'org-models: a model change listener failed');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private failed(error: string, clear: boolean): void {
|
|
157
|
+
if (this.status.error !== error) log.warn({ err: error, kept: !clear && this.kept !== null }, clear ? 'org-models: api.metro.box no longer knows this agent key, so the model setup is cleared' : 'org-models: api.metro.box did not deliver the model setup, so the last one stands');
|
|
158
|
+
this.status = { ...this.status, error };
|
|
159
|
+
if (clear) this.keep(null);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async run(): Promise<void> {
|
|
163
|
+
const key = this.enrolledKey();
|
|
164
|
+
if (key === null) {
|
|
165
|
+
this.status = { enrolled: false, at: null, error: null };
|
|
166
|
+
this.keep(null);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
this.status = { ...this.status, enrolled: true };
|
|
170
|
+
const moved = await importModelFile(this.deps.api, key, this.deps.dir()).catch((err: unknown) => {
|
|
171
|
+
log.warn({ err: errMsg(err) }, 'org-models: model.json could not be read for the move to the organization');
|
|
172
|
+
return null;
|
|
173
|
+
});
|
|
174
|
+
try {
|
|
175
|
+
const delivered = deliveredOf(await agentCall(this.deps.api, key, MODELS_PATH, {}));
|
|
176
|
+
if (delivered === null) {
|
|
177
|
+
this.failed('api.metro.box sent a model setup Metro cannot read', false);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (this.status.error !== null) log.info('org-models: the model setup is read from api.metro.box again');
|
|
181
|
+
this.status = { enrolled: true, at: new Date(this.deps.api.now()).toISOString(), error: null };
|
|
182
|
+
this.keep(delivered);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
this.failed(errMsg(err), identityRefused(err));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (moved !== null) this.settle(moved);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private settle(moved: Moved): void {
|
|
191
|
+
try {
|
|
192
|
+
if (settleModelFile(this.deps.dir(), moved)) this.notify();
|
|
193
|
+
} catch (err) {
|
|
194
|
+
log.warn({ err: errMsg(err) }, 'org-models: model.json could not be rewritten after the move to the organization');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async askRefresh(conn: Connection): Promise<Connection | null> {
|
|
199
|
+
const key = this.enrolledKey();
|
|
200
|
+
if (key === null) return null;
|
|
201
|
+
try {
|
|
202
|
+
const fresh = refreshedConnection(await agentCall(this.deps.api, key, REFRESH_PATH, { connection: conn.id, revision: conn.revision }));
|
|
203
|
+
if (fresh?.id !== conn.id) throw new Error('api.metro.box answered the refresh with another connection');
|
|
204
|
+
const kept = this.kept;
|
|
205
|
+
if (kept !== null) this.keep({ ...kept, connections: kept.connections.map((c) => (c.id === fresh.id ? fresh : c)) });
|
|
206
|
+
log.info({ connection: conn.id, revision: fresh.revision }, 'org-models: api.metro.box renewed the sign-in of a connection');
|
|
207
|
+
return fresh;
|
|
208
|
+
} catch (err) {
|
|
209
|
+
log.warn({ connection: conn.id, err: errMsg(err) }, 'org-models: api.metro.box could not renew the sign-in of a connection');
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let shared: OrgModels | null = null;
|
|
216
|
+
|
|
217
|
+
export const orgModels = (): OrgModels | null => shared;
|
|
218
|
+
|
|
219
|
+
export const deliveredSetup = (): Delivered | null => shared?.current() ?? null;
|
|
220
|
+
|
|
221
|
+
export function startOrgModels(deps: OrgModelsDeps, everyMs = SYNC_MS): OrgModels {
|
|
222
|
+
shared?.stop();
|
|
223
|
+
shared = new OrgModels(deps);
|
|
224
|
+
shared.start(everyMs);
|
|
225
|
+
log.info('org-models: the model setup comes from the organization on api.metro.box once the agent is enrolled');
|
|
226
|
+
return shared;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function stopOrgModels(): void {
|
|
230
|
+
shared?.stop();
|
|
231
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { IncomingHttpHeaders } from 'node:http';
|
|
2
2
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
3
|
import { GatewayError, OAUTH_BETA } from './forward.js';
|
|
4
|
-
import
|
|
5
|
-
import { fingerprint, listCache } from './model-lists.js';
|
|
4
|
+
import { listCache } from './model-lists.js';
|
|
6
5
|
import { stringOf } from '@metro-labs/http/api-http';
|
|
7
6
|
|
|
8
7
|
export const ANTHROPIC_API = 'https://api.anthropic.com';
|
|
@@ -30,7 +29,6 @@ const KNOWN_CLAUDE: ProviderModel[] = [
|
|
|
30
29
|
];
|
|
31
30
|
|
|
32
31
|
const claudeLists = listCache<ProviderModel>('anthropic');
|
|
33
|
-
const bedrockLists = listCache<ProviderModel>('bedrock');
|
|
34
32
|
|
|
35
33
|
const newestFirst = (list: Dated[]): ProviderModel[] =>
|
|
36
34
|
list.sort((a, b) => b.created.localeCompare(a.created) || a.id.localeCompare(b.id)).map(({ id, name }) => ({ id, name }));
|
|
@@ -73,45 +71,7 @@ export function refreshLoginModels(headers: IncomingHttpHeaders, base = ANTHROPI
|
|
|
73
71
|
return claudeLists.refresh(loginKey(base), () => listClaude(auth, 'the Claude Code login', base, fetchImpl));
|
|
74
72
|
}
|
|
75
73
|
|
|
76
|
-
export
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return live.length > 0 ? live : KNOWN_CLAUDE;
|
|
80
|
-
}
|
|
81
|
-
return claudeLists.get(`key:${base}:${fingerprint(settings.apiKey)}`, () => listClaude({ 'x-api-key': settings.apiKey }, 'the stored key', base, fetchImpl));
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export const bedrockControlBase = (region: string): string => `https://bedrock.${region}.amazonaws.com`;
|
|
85
|
-
|
|
86
|
-
function bedrockEntry(entry: unknown): Dated[] {
|
|
87
|
-
if (!isRecord(entry)) return [];
|
|
88
|
-
const id = stringOf(entry.inferenceProfileId);
|
|
89
|
-
const status = stringOf(entry.status);
|
|
90
|
-
if (id === '' || !id.includes('anthropic') || (status !== '' && status !== 'ACTIVE')) return [];
|
|
91
|
-
return [{ id, name: stringOf(entry.inferenceProfileName) || id, created: stringOf(entry.createdAt) }];
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async function listBedrock(settings: Connection, base: string, fetchImpl: typeof fetch): Promise<ProviderModel[]> {
|
|
95
|
-
const res = await fetchImpl(`${base}/inference-profiles?maxResults=${String(LIST_MAX)}`, {
|
|
96
|
-
headers: { authorization: `Bearer ${settings.apiKey}`, accept: 'application/json' },
|
|
97
|
-
redirect: 'manual',
|
|
98
|
-
signal: AbortSignal.timeout(LIST_TIMEOUT_MS),
|
|
99
|
-
});
|
|
100
|
-
if (!res.ok)
|
|
101
|
-
throw new GatewayError(
|
|
102
|
-
res.status,
|
|
103
|
-
'api_error',
|
|
104
|
-
`Bedrock would not list its inference profiles (${String(res.status)}); the stored key may cover the runtime only, so type the model id instead`,
|
|
105
|
-
);
|
|
106
|
-
const body: unknown = await res.json();
|
|
107
|
-
const list = isRecord(body) && Array.isArray(body.inferenceProfileSummaries) ? body.inferenceProfileSummaries : null;
|
|
108
|
-
if (list === null) throw new GatewayError(502, 'api_error', 'Bedrock answered with no inference profile list');
|
|
109
|
-
return newestFirst(list.flatMap(bedrockEntry));
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export async function bedrockModels(settings: Connection, base?: string, fetchImpl: typeof fetch = fetch): Promise<ProviderModel[]> {
|
|
113
|
-
if (settings.region === '') throw new GatewayError(400, 'invalid_request_error', 'Bedrock needs a region before its models can be listed');
|
|
114
|
-
if (settings.apiKey === '') throw new GatewayError(400, 'invalid_request_error', 'Bedrock needs an API key before its models can be listed');
|
|
115
|
-
const from = base ?? bedrockControlBase(settings.region);
|
|
116
|
-
return bedrockLists.get(`${from}:${fingerprint(settings.apiKey)}`, () => listBedrock(settings, from, fetchImpl));
|
|
74
|
+
export function claudeModels(base = ANTHROPIC_API): ProviderModel[] {
|
|
75
|
+
const live = claudeLists.peek(loginKey(base)) ?? [];
|
|
76
|
+
return live.length > 0 ? live : KNOWN_CLAUDE;
|
|
117
77
|
}
|
|
@@ -4,8 +4,6 @@ import { stringOf } from '@metro-labs/http/api-http';
|
|
|
4
4
|
const IMAGE_NOTE = '[an image was attached here; this model cannot see it]';
|
|
5
5
|
export const TOOL_ERROR = '[tool error]';
|
|
6
6
|
|
|
7
|
-
export const nonEmpty = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
|
|
8
|
-
|
|
9
7
|
export interface InlineImage {
|
|
10
8
|
mediaType: string;
|
|
11
9
|
data: string;
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
2
2
|
import { readClaudeUsage } from '../claude/usage-probe.js';
|
|
3
3
|
import { openrouterKey } from './openrouter.js';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { claudeLoginDeps, CREDITS_TTL_MS, type ModelApiDeps, type Store } from './model-store.js';
|
|
9
|
-
import { probeClaudeLogin } from './claude-logins.js';
|
|
4
|
+
import { claudeLoginUsage, keepProbeAnswer, mayProbe, mergeUsage, noteUsage, openrouterUsage, probeAnswer, usageOf } from './usage.js';
|
|
5
|
+
import { loginRef, primaryRoute, SESSION_LOGIN, type Connection, type ModelSetup } from './model-config.js';
|
|
6
|
+
import { probeClaudeLogin, type ClaudeLoginDeps } from './claude-logins.js';
|
|
7
|
+
import type { ClaudeLogin } from './claude-store.js';
|
|
10
8
|
|
|
9
|
+
export const CREDITS_TTL_MS = 5 * 60_000;
|
|
11
10
|
const WAIT_MS = 4_000;
|
|
12
11
|
const LOGIN_PROBE = 'claude-login';
|
|
13
12
|
|
|
13
|
+
export interface UsageDeps {
|
|
14
|
+
fetchImpl?: typeof fetch;
|
|
15
|
+
openrouterBase?: string;
|
|
16
|
+
claudeUsage?: (env?: Record<string, string>) => Promise<unknown>;
|
|
17
|
+
claudeLogins?: ClaudeLoginDeps;
|
|
18
|
+
usageWaitMs?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
const fresh = (id: string, now: number): boolean => {
|
|
15
22
|
const seen = usageOf(id);
|
|
16
23
|
return seen !== undefined && now - Date.parse(seen.at) < CREDITS_TTL_MS;
|
|
@@ -26,66 +33,35 @@ async function probe(what: string, run: () => Promise<void>): Promise<void> {
|
|
|
26
33
|
}
|
|
27
34
|
}
|
|
28
35
|
|
|
29
|
-
async function refreshKey(conn: Connection, deps:
|
|
30
|
-
if (conn.apiKey === '' || !due(conn.id, now)) return;
|
|
36
|
+
async function refreshKey(conn: Connection, deps: UsageDeps, now: number): Promise<void> {
|
|
37
|
+
if (conn.provider !== 'openrouter' || conn.apiKey === '' || !due(conn.id, now)) return;
|
|
31
38
|
await probe('OpenRouter key spend', async () => {
|
|
32
39
|
noteUsage(conn.id, openrouterUsage(await openrouterKey(conn.apiKey, deps.openrouterBase, deps.fetchImpl), new Date(now)));
|
|
33
40
|
});
|
|
34
41
|
}
|
|
35
42
|
|
|
36
|
-
const
|
|
37
|
-
const served = lastServed();
|
|
38
|
-
const fallbacks = (cfg.fallbacks ?? []).filter((f) => f.connection === conn.id).map((f) => f.model);
|
|
39
|
-
return new Set([conn.model, served?.connection === conn.id ? served.model : '', ...fallbacks].filter((m) => m !== ''));
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
async function refreshQuota(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
|
|
43
|
-
if (conn.gemini === null) return;
|
|
44
|
-
const inUse = modelsInUse(conn, store.read());
|
|
45
|
-
const seen = usageOf(conn.id);
|
|
46
|
-
const covers = seen !== undefined && [...inUse].every((id) => seen.windows.some((w) => w.label === id));
|
|
47
|
-
if (seen !== undefined && covers && now - Date.parse(seen.at) < CREDITS_TTL_MS) return;
|
|
48
|
-
await probe('Gemini quota', async () => {
|
|
49
|
-
const rows = (await geminiModelsOf(conn, deps, store)).filter((m) => inUse.has(m.id));
|
|
50
|
-
const usage = geminiUsage(rows, new Date(now));
|
|
51
|
-
if (usage !== null) noteUsage(conn.id, usage);
|
|
52
|
-
});
|
|
53
|
-
}
|
|
43
|
+
const hasFallbacks = (setup: ModelSetup): boolean => setup.fallbacks.length > 0;
|
|
54
44
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
];
|
|
59
|
-
|
|
60
|
-
const hasFallbacks = (cfg: ModelConfig): boolean => (cfg.fallbacks ?? []).length > 0;
|
|
61
|
-
|
|
62
|
-
async function refreshOwnLogin(conn: Connection, cfg: ModelConfig, deps: ModelApiDeps, now: number): Promise<void> {
|
|
63
|
-
const login = conn.apiKey === '' ? conn.claude : null;
|
|
64
|
-
if (login === null || (fresh(conn.id, now) && !hasFallbacks(cfg)) || !mayProbe(conn.id, now, CREDITS_TTL_MS)) return;
|
|
45
|
+
async function refreshOwnLogin(login: string, setup: ModelSetup, deps: UsageDeps, now: number): Promise<void> {
|
|
46
|
+
const key = loginRef(login);
|
|
47
|
+
if ((fresh(key, now) && !hasFallbacks(setup)) || !mayProbe(key, now, CREDITS_TTL_MS)) return;
|
|
65
48
|
await probe('usage of a Claude login', async () => {
|
|
66
|
-
const usage = claudeLoginUsage(await probeClaudeLogin(login
|
|
67
|
-
if (usage !== null) mergeUsage(
|
|
49
|
+
const usage = claudeLoginUsage(await probeClaudeLogin(login, deps.claudeLogins), new Date(now));
|
|
50
|
+
if (usage !== null) mergeUsage(key, usage);
|
|
68
51
|
});
|
|
69
52
|
}
|
|
70
53
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
54
|
+
const ridesSession = (setup: ModelSetup): boolean => primaryRoute(setup) === null || setup.connections.some((c) => c.id === SESSION_LOGIN);
|
|
55
|
+
|
|
56
|
+
async function refreshSession(setup: ModelSetup, deps: UsageDeps, now: number): Promise<void> {
|
|
57
|
+
if (!ridesSession(setup) || (fresh(SESSION_LOGIN, now) && !hasFallbacks(setup))) return;
|
|
74
58
|
if (mayProbe(LOGIN_PROBE, now, CREDITS_TTL_MS))
|
|
75
59
|
await probe('usage of the Claude Code login', async () => {
|
|
76
60
|
const usage = claudeLoginUsage(await (deps.claudeUsage ?? (() => readClaudeUsage()))(), new Date(now));
|
|
77
61
|
if (usage !== null) keepProbeAnswer(LOGIN_PROBE, usage);
|
|
78
62
|
});
|
|
79
63
|
const usage = probeAnswer(LOGIN_PROBE);
|
|
80
|
-
if (usage
|
|
81
|
-
for (const id of ids) mergeUsage(id, usage);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function refreshOne(conn: Connection, cfg: ModelConfig, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
|
|
85
|
-
if (conn.provider === 'openrouter') return refreshKey(conn, deps, now);
|
|
86
|
-
if (conn.provider === 'gemini') return refreshQuota(conn, deps, store, now);
|
|
87
|
-
if (conn.provider === 'anthropic') return refreshOwnLogin(conn, cfg, deps, now);
|
|
88
|
-
return Promise.resolve();
|
|
64
|
+
if (usage !== undefined && now - Date.parse(usage.at) < CREDITS_TTL_MS) mergeUsage(SESSION_LOGIN, usage);
|
|
89
65
|
}
|
|
90
66
|
|
|
91
67
|
async function within(ms: number, work: Promise<unknown>): Promise<void> {
|
|
@@ -97,17 +73,21 @@ async function within(ms: number, work: Promise<unknown>): Promise<void> {
|
|
|
97
73
|
clearTimeout(timer);
|
|
98
74
|
}
|
|
99
75
|
|
|
100
|
-
export async function refreshUsage(
|
|
101
|
-
const
|
|
102
|
-
const work = Promise.all([
|
|
76
|
+
export async function refreshUsage(setup: ModelSetup, logins: ClaudeLogin[], deps: UsageDeps = {}, now = Date.now()): Promise<void> {
|
|
77
|
+
const own = new Set([...logins.map((l) => l.id), ...setup.connections.flatMap((c) => (c.claude === null || c.signedOut ? [] : [c.claude]))]);
|
|
78
|
+
const work = Promise.all([
|
|
79
|
+
...setup.connections.map((conn) => refreshKey(conn, deps, now)),
|
|
80
|
+
...[...own].map((login) => refreshOwnLogin(login, setup, deps, now)),
|
|
81
|
+
refreshSession(setup, deps, now),
|
|
82
|
+
]);
|
|
103
83
|
await within(deps.usageWaitMs ?? WAIT_MS, work);
|
|
104
84
|
}
|
|
105
85
|
|
|
106
|
-
export function watchFallbackUsage(deps:
|
|
107
|
-
const store: Store = { read: deps.read ?? readModelConfig, write: deps.write ?? writeModelConfig };
|
|
86
|
+
export function watchFallbackUsage(setup: () => ModelSetup, logins: () => ClaudeLogin[], deps: UsageDeps = {}, every = CREDITS_TTL_MS): void {
|
|
108
87
|
const run = (): void => {
|
|
109
|
-
|
|
110
|
-
|
|
88
|
+
const current = setup();
|
|
89
|
+
if (!hasFallbacks(current)) return;
|
|
90
|
+
refreshUsage(current, logins(), deps).catch((err: unknown) => {
|
|
111
91
|
log.warn({ err: errMsg(err) }, 'model-api: could not refresh the usage of the fallback models');
|
|
112
92
|
});
|
|
113
93
|
};
|
|
@@ -315,19 +315,6 @@ export function noteUsageHeaders(kind: 'anthropic' | 'codex', key: Key, headers:
|
|
|
315
315
|
|
|
316
316
|
const dollars = (value: number): string => `$${value.toFixed(2)}`;
|
|
317
317
|
|
|
318
|
-
export interface QuotaRow {
|
|
319
|
-
id: string;
|
|
320
|
-
remaining: number | null;
|
|
321
|
-
resetAt: string | null;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
export function geminiUsage(rows: QuotaRow[], now = new Date()): Reported | null {
|
|
325
|
-
const windows: UsageWindow[] = rows
|
|
326
|
-
.filter((row) => row.remaining !== null)
|
|
327
|
-
.map((row) => ({ label: row.id, used: clamp(1 - (row.remaining ?? 0)), resetAt: row.resetAt, detail: null }));
|
|
328
|
-
return windows.length === 0 ? null : { windows, note: null, at: now.toISOString() };
|
|
329
|
-
}
|
|
330
|
-
|
|
331
318
|
export interface KeySpend {
|
|
332
319
|
limit: number | null;
|
|
333
320
|
remaining: number | null;
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
localOwner,
|
|
24
24
|
readLocalAgentFile,
|
|
25
25
|
} from '../agents/file-admin.js';
|
|
26
|
-
import {
|
|
26
|
+
import { effectiveSetup } from '../gateway/effective.js';
|
|
27
27
|
import type { StationName } from '@metro-labs/core/station-names';
|
|
28
28
|
|
|
29
29
|
export interface LocalModeDeps {
|
|
@@ -36,7 +36,6 @@ export interface LocalModeDeps {
|
|
|
36
36
|
toolGroups?: AgentApiDeps['toolGroups'];
|
|
37
37
|
prepareAccount: AccountApiDeps['prepareAccount'];
|
|
38
38
|
liveEvents?: (on: boolean) => void;
|
|
39
|
-
switchModel?: (model: string | null) => boolean;
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
function attachSessions(deps: LocalModeDeps): AttachSessions {
|
|
@@ -142,8 +141,8 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
142
141
|
controlApi: { restart: deps.restart, stop: deps.stop },
|
|
143
142
|
ownerApi: { setOwner: setLocalOwner },
|
|
144
143
|
machineApi: {},
|
|
145
|
-
modelApi: {
|
|
146
|
-
gateway: {
|
|
144
|
+
modelApi: {},
|
|
145
|
+
gateway: { setup: () => effectiveSetup() },
|
|
147
146
|
terminalApi: {},
|
|
148
147
|
mode: localModeInfo,
|
|
149
148
|
};
|