@stage-labs/metro 0.1.0-beta.118 → 0.1.0-beta.119
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/node_modules/@metro-labs/daemon/src/gateway/codex.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +29 -4
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter.ts +23 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +140 -0
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.119",
|
|
4
4
|
"description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.119",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, the standing rules loaded at each session start, and a refresh skill for when the daemon could not keep the server list current. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -8,6 +8,7 @@ import { assembleMessage, CodexEventTranslator, parseEvent, SseParser } from './
|
|
|
8
8
|
import { ToolNames, toResponsesRequest } from './codex-translate.js';
|
|
9
9
|
import { GatewayError, idleMessage, providerStatus, sendError, upstreamMessage, type Watch } from './forward.js';
|
|
10
10
|
import type { ModelConfig } from './model-config.js';
|
|
11
|
+
import { noteUsageHeaders } from './usage.js';
|
|
11
12
|
|
|
12
13
|
export const CODEX_BASE = 'https://chatgpt.com/backend-api/codex';
|
|
13
14
|
const CODEX_VERSION = '0.153.4';
|
|
@@ -186,6 +187,7 @@ export async function codexMessages(
|
|
|
186
187
|
): Promise<void> {
|
|
187
188
|
const call: Call = { body, model, sessionId: sessionIdOf(req), watch, deps, names: new ToolNames() };
|
|
188
189
|
const upstream = await reach(call, cfg, state);
|
|
190
|
+
noteUsageHeaders('codex', upstream.headers);
|
|
189
191
|
if (!upstream.ok) {
|
|
190
192
|
const text = await upstream.text();
|
|
191
193
|
sendError(res, providerStatus(upstream.status), errorKind(upstream.status), upstreamMessage(text, `Codex answered ${String(upstream.status)}`));
|
|
@@ -15,6 +15,7 @@ import { codexCount, codexMessages, freshCodexState, type CodexDeps } from './co
|
|
|
15
15
|
import { OPENROUTER_BASE } from './openrouter.js';
|
|
16
16
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
17
17
|
import { forgetServed, noteServed } from './served.js';
|
|
18
|
+
import { forgetUsage, noteUsageHeaders } from './usage.js';
|
|
18
19
|
import type { CodexTokens } from './codex-auth.js';
|
|
19
20
|
|
|
20
21
|
export const GATEWAY_PREFIX = '/gateway';
|
|
@@ -38,6 +39,7 @@ const codexState = freshCodexState();
|
|
|
38
39
|
|
|
39
40
|
export function resetGatewayState(): void {
|
|
40
41
|
forgetServed();
|
|
42
|
+
forgetUsage();
|
|
41
43
|
learned.fields.clear();
|
|
42
44
|
learned.dropBetas = false;
|
|
43
45
|
Object.assign(codexState, freshCodexState());
|
|
@@ -102,6 +104,7 @@ async function toAnthropic(
|
|
|
102
104
|
signal: watch.signal,
|
|
103
105
|
redirect: 'manual',
|
|
104
106
|
});
|
|
107
|
+
noteUsageHeaders('anthropic', upstream.headers);
|
|
105
108
|
await pipeResponse(upstream, res, watch, key === '' ? {} : { ownCredential: true });
|
|
106
109
|
}
|
|
107
110
|
|
|
@@ -2,14 +2,15 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
2
2
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
3
3
|
import { apiFailure, apiSession, cors, readJsonBody, sendJson } from '@metro-labs/http/api-http';
|
|
4
4
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
-
import { log } from '@metro-labs/core/log';
|
|
5
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
6
6
|
import { beginLogin, CodexAuthError, finishLogin, readCodexCliAuth } from './codex-auth.js';
|
|
7
7
|
import { beginDeviceLogin, pollDeviceLogin } from './codex-device.js';
|
|
8
8
|
import { codexModels, currentTokens, freshCodexState } from './codex.js';
|
|
9
|
-
import { openrouterModels, openrouterZdrModels } from './openrouter.js';
|
|
9
|
+
import { openrouterCredits, openrouterModels, openrouterZdrModels } from './openrouter.js';
|
|
10
10
|
import { anthropicModels, bedrockModels } from './provider-models.js';
|
|
11
11
|
import { syncAvailableModelsQuietly, type SetupDeps } from '../claude/setup.js';
|
|
12
12
|
import { lastServed } from './served.js';
|
|
13
|
+
import { noteUsage, openrouterUsage, usageOf, usageSeen } from './usage.js';
|
|
13
14
|
import type { CodexTokens } from './codex-auth.js';
|
|
14
15
|
import { GatewayError } from './forward.js';
|
|
15
16
|
import {
|
|
@@ -57,7 +58,31 @@ interface Route {
|
|
|
57
58
|
run: Handler;
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
const settingsBody = (cfg: ModelConfig): Record<string, unknown> => ({
|
|
61
|
+
const settingsBody = (cfg: ModelConfig): Record<string, unknown> => ({
|
|
62
|
+
...publicModelConfig(cfg),
|
|
63
|
+
lastServed: lastServed(),
|
|
64
|
+
usage: usageSeen(),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const CREDITS_TTL_MS = 5 * 60_000;
|
|
68
|
+
|
|
69
|
+
async function refreshCredits(cfg: ModelConfig, deps: ModelApiDeps, now = Date.now()): Promise<void> {
|
|
70
|
+
const key = cfg.openrouter.apiKey;
|
|
71
|
+
if (key === '') return;
|
|
72
|
+
const seen = usageOf('openrouter');
|
|
73
|
+
if (seen !== undefined && now - Date.parse(seen.at) < CREDITS_TTL_MS) return;
|
|
74
|
+
try {
|
|
75
|
+
const credits = await openrouterCredits(key, deps.openrouterBase, deps.fetchImpl);
|
|
76
|
+
noteUsage('openrouter', openrouterUsage(credits.total, credits.spent, new Date(now)));
|
|
77
|
+
} catch (err) {
|
|
78
|
+
log.warn({ err: errMsg(err) }, 'model-api: could not read the OpenRouter credits');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function settingsWithUsage(cfg: ModelConfig, deps: ModelApiDeps): Promise<Record<string, unknown>> {
|
|
83
|
+
await refreshCredits(cfg, deps);
|
|
84
|
+
return settingsBody(cfg);
|
|
85
|
+
}
|
|
61
86
|
|
|
62
87
|
function asApiError(err: unknown): never {
|
|
63
88
|
if (err instanceof ModelConfigError || err instanceof CodexAuthError) throw new ApiError(err.message, 400);
|
|
@@ -175,7 +200,7 @@ const named = (table: Record<string, Route>, name: string, method: string | unde
|
|
|
175
200
|
};
|
|
176
201
|
|
|
177
202
|
function settingsRoute(method: string | undefined): Route | number {
|
|
178
|
-
if (method === 'GET') return { method: 'GET', run: (_req,
|
|
203
|
+
if (method === 'GET') return { method: 'GET', run: (_req, deps, store) => settingsWithUsage(store.read(), deps) };
|
|
179
204
|
if (method === 'PUT') return { method: 'POST', run: (req, deps, store) => update(req, store, deps) };
|
|
180
205
|
return 405;
|
|
181
206
|
}
|
|
@@ -55,6 +55,29 @@ export async function openrouterModels(base = OPENROUTER_BASE, fetchImpl: typeof
|
|
|
55
55
|
.slice(0, MODELS_MAX);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export interface OpenRouterCredits {
|
|
59
|
+
total: number;
|
|
60
|
+
spent: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function openrouterCredits(
|
|
64
|
+
apiKey: string,
|
|
65
|
+
base = OPENROUTER_BASE,
|
|
66
|
+
fetchImpl: typeof fetch = fetch,
|
|
67
|
+
): Promise<OpenRouterCredits> {
|
|
68
|
+
const res = await fetchImpl(`${base}/v1/credits`, {
|
|
69
|
+
headers: { accept: 'application/json', authorization: `Bearer ${apiKey}` },
|
|
70
|
+
redirect: 'manual',
|
|
71
|
+
});
|
|
72
|
+
if (!res.ok) throw new GatewayError(res.status, 'api_error', `OpenRouter would not report the credits (${String(res.status)})`);
|
|
73
|
+
const body: unknown = await res.json();
|
|
74
|
+
const data = isRecord(body) && isRecord(body.data) ? body.data : {};
|
|
75
|
+
const total = Number(data.total_credits);
|
|
76
|
+
const spent = Number(data.total_usage);
|
|
77
|
+
if (!Number.isFinite(total) || !Number.isFinite(spent)) throw new GatewayError(502, 'api_error', 'OpenRouter answered with no credit figures');
|
|
78
|
+
return { total, spent };
|
|
79
|
+
}
|
|
80
|
+
|
|
58
81
|
export async function openrouterZdrModels(base = OPENROUTER_BASE, fetchImpl: typeof fetch = fetch): Promise<string[]> {
|
|
59
82
|
const res = await fetchImpl(`${base}/v1/endpoints/zdr`, { headers: { accept: 'application/json' }, redirect: 'manual' });
|
|
60
83
|
if (!res.ok) throw new GatewayError(res.status, 'api_error', `OpenRouter would not list its zero data retention endpoints (${String(res.status)})`);
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
export type UsageProvider = 'anthropic' | 'codex' | 'openrouter';
|
|
2
|
+
|
|
3
|
+
export interface UsageWindow {
|
|
4
|
+
label: string;
|
|
5
|
+
used: number | null;
|
|
6
|
+
resetAt: string | null;
|
|
7
|
+
detail: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ProviderUsage {
|
|
11
|
+
windows: UsageWindow[];
|
|
12
|
+
note: string | null;
|
|
13
|
+
at: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const latest = new Map<UsageProvider, ProviderUsage>();
|
|
17
|
+
|
|
18
|
+
export const noteUsage = (provider: UsageProvider, usage: ProviderUsage): void => {
|
|
19
|
+
latest.set(provider, usage);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const usageSeen = (): Partial<Record<UsageProvider, ProviderUsage>> => Object.fromEntries(latest);
|
|
23
|
+
|
|
24
|
+
export const usageOf = (provider: UsageProvider): ProviderUsage | undefined => latest.get(provider);
|
|
25
|
+
|
|
26
|
+
export const forgetUsage = (): void => {
|
|
27
|
+
latest.clear();
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const clamp = (value: number): number => Math.min(1, Math.max(0, value));
|
|
31
|
+
|
|
32
|
+
function fraction(raw: string | null): number | null {
|
|
33
|
+
if (raw === null) return null;
|
|
34
|
+
const value = Number(raw);
|
|
35
|
+
return Number.isFinite(value) ? clamp(value) : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const MS_THRESHOLD = 1e12;
|
|
39
|
+
|
|
40
|
+
function whenFrom(raw: string | null, now: Date, relative = false): string | null {
|
|
41
|
+
if (raw === null || raw.trim() === '') return null;
|
|
42
|
+
const numeric = Number(raw);
|
|
43
|
+
if (Number.isFinite(numeric)) {
|
|
44
|
+
const absolute = numeric > MS_THRESHOLD ? numeric : numeric * 1000;
|
|
45
|
+
const ms = relative ? now.getTime() + numeric * 1000 : absolute;
|
|
46
|
+
return new Date(ms).toISOString();
|
|
47
|
+
}
|
|
48
|
+
const parsed = Date.parse(raw);
|
|
49
|
+
return Number.isNaN(parsed) ? null : new Date(parsed).toISOString();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ANTHROPIC_WINDOWS: [string, string][] = [
|
|
53
|
+
['5h', '5-hour window'],
|
|
54
|
+
['7d', 'Weekly'],
|
|
55
|
+
['7d_sonnet', 'Weekly, Sonnet'],
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
function anthropicUnified(headers: Headers, now: Date): { windows: UsageWindow[]; note: string | null } {
|
|
59
|
+
const windows: UsageWindow[] = [];
|
|
60
|
+
let note: string | null = null;
|
|
61
|
+
for (const [key, label] of ANTHROPIC_WINDOWS) {
|
|
62
|
+
const used = fraction(headers.get(`anthropic-ratelimit-unified-${key}-utilization`));
|
|
63
|
+
if (used === null) continue;
|
|
64
|
+
const status = headers.get(`anthropic-ratelimit-unified-${key}-status`);
|
|
65
|
+
const blocked = status !== null && status !== 'allowed' ? status.replaceAll('_', ' ') : null;
|
|
66
|
+
if (blocked !== null && note === null) note = `${label}: ${blocked}`;
|
|
67
|
+
windows.push({ label, used, resetAt: whenFrom(headers.get(`anthropic-ratelimit-unified-${key}-reset`), now), detail: blocked });
|
|
68
|
+
}
|
|
69
|
+
return { windows, note };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function anthropicKeyed(headers: Headers, now: Date): UsageWindow[] {
|
|
73
|
+
const limit = Number(headers.get('anthropic-ratelimit-tokens-limit'));
|
|
74
|
+
const remaining = Number(headers.get('anthropic-ratelimit-tokens-remaining'));
|
|
75
|
+
if (!Number.isFinite(limit) || !Number.isFinite(remaining) || limit <= 0) return [];
|
|
76
|
+
return [
|
|
77
|
+
{
|
|
78
|
+
label: 'Tokens per minute',
|
|
79
|
+
used: clamp(1 - remaining / limit),
|
|
80
|
+
resetAt: whenFrom(headers.get('anthropic-ratelimit-tokens-reset'), now),
|
|
81
|
+
detail: `${remaining.toLocaleString('en-US')} of ${limit.toLocaleString('en-US')} left`,
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function anthropicUsage(headers: Headers, now = new Date()): ProviderUsage | null {
|
|
87
|
+
const unified = anthropicUnified(headers, now);
|
|
88
|
+
if (unified.windows.length > 0) return { ...unified, at: now.toISOString() };
|
|
89
|
+
const keyed = anthropicKeyed(headers, now);
|
|
90
|
+
return keyed.length === 0 ? null : { windows: keyed, note: null, at: now.toISOString() };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const MINUTES_PER_HOUR = 60;
|
|
94
|
+
const MINUTES_PER_DAY = 24 * MINUTES_PER_HOUR;
|
|
95
|
+
const MINUTES_PER_WEEK = 7 * MINUTES_PER_DAY;
|
|
96
|
+
|
|
97
|
+
export function windowLabel(minutes: number | null): string {
|
|
98
|
+
if (minutes === null || !Number.isFinite(minutes) || minutes <= 0) return 'Usage window';
|
|
99
|
+
if (minutes === MINUTES_PER_WEEK) return 'Weekly';
|
|
100
|
+
if (minutes % MINUTES_PER_DAY === 0) return `${String(minutes / MINUTES_PER_DAY)}-day window`;
|
|
101
|
+
if (minutes % MINUTES_PER_HOUR === 0) return `${String(minutes / MINUTES_PER_HOUR)}-hour window`;
|
|
102
|
+
return `${String(minutes)}-minute window`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function codexWindow(headers: Headers, kind: 'primary' | 'secondary', now: Date): UsageWindow | null {
|
|
106
|
+
const raw = headers.get(`x-codex-${kind}-used-percent`);
|
|
107
|
+
if (raw === null) return null;
|
|
108
|
+
const percent = Number(raw);
|
|
109
|
+
if (!Number.isFinite(percent)) return null;
|
|
110
|
+
const used = clamp(percent / 100);
|
|
111
|
+
const minutes = Number(headers.get(`x-codex-${kind}-window-minutes`) ?? 'none');
|
|
112
|
+
const absolute = whenFrom(headers.get(`x-codex-${kind}-reset-at`), now);
|
|
113
|
+
const resetAt = absolute ?? whenFrom(headers.get(`x-codex-${kind}-reset-after-seconds`), now, true);
|
|
114
|
+
return { label: windowLabel(Number.isFinite(minutes) ? minutes : null), used, resetAt, detail: null };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function codexUsage(headers: Headers, now = new Date()): ProviderUsage | null {
|
|
118
|
+
const windows = [codexWindow(headers, 'primary', now), codexWindow(headers, 'secondary', now)].filter(
|
|
119
|
+
(w): w is UsageWindow => w !== null,
|
|
120
|
+
);
|
|
121
|
+
if (windows.length === 0) return null;
|
|
122
|
+
const reached = headers.get('x-codex-rate-limit-reached-type');
|
|
123
|
+
return { windows, note: reached === null || reached === '' ? null : reached.replaceAll('_', ' '), at: now.toISOString() };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function noteUsageHeaders(provider: 'anthropic' | 'codex', headers: Headers, now = new Date()): void {
|
|
127
|
+
const usage = provider === 'anthropic' ? anthropicUsage(headers, now) : codexUsage(headers, now);
|
|
128
|
+
if (usage !== null) noteUsage(provider, usage);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const dollars = (value: number): string => `$${value.toFixed(2)}`;
|
|
132
|
+
|
|
133
|
+
export function openrouterUsage(total: number, spent: number, now = new Date()): ProviderUsage {
|
|
134
|
+
const used = total > 0 ? clamp(spent / total) : null;
|
|
135
|
+
return {
|
|
136
|
+
windows: [{ label: 'Credits', used, resetAt: null, detail: `${dollars(spent)} of ${dollars(total)} used` }],
|
|
137
|
+
note: null,
|
|
138
|
+
at: now.toISOString(),
|
|
139
|
+
};
|
|
140
|
+
}
|
package/runtime/runtime.json
CHANGED