@stage-labs/metro 0.1.0-beta.119 → 0.1.0-beta.120
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/bedrock.ts +9 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +12 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +7 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +4 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +98 -9
- 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.120",
|
|
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.120",
|
|
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",
|
|
@@ -3,6 +3,7 @@ import { log } from '@metro-labs/core/log';
|
|
|
3
3
|
import { EventStreamDecoder, type EventStreamMessage } from './eventstream.js';
|
|
4
4
|
import { errorFrame, GatewayError, idleMessage, providerStatus, sendError, upstreamMessage, type Watch } from './forward.js';
|
|
5
5
|
import type { BedrockSettings } from './model-config.js';
|
|
6
|
+
import { UsageScanner } from './usage.js';
|
|
6
7
|
|
|
7
8
|
const ANTHROPIC_VERSION = 'bedrock-2023-05-31';
|
|
8
9
|
const EXTRA_INPUT_RE = /^([A-Za-z0-9_]+)(?:\.[^:]*)?: Extra inputs are not permitted/;
|
|
@@ -154,11 +155,12 @@ function eventTypeOf(event: string): string {
|
|
|
154
155
|
}
|
|
155
156
|
}
|
|
156
157
|
|
|
157
|
-
function writeEvent(res: ServerResponse, message: EventStreamMessage): void {
|
|
158
|
+
function writeEvent(res: ServerResponse, message: EventStreamMessage, scanner: UsageScanner): void {
|
|
158
159
|
if (message.headers[':message-type'] === 'event') {
|
|
159
160
|
const parsed = JSON.parse(message.payload.toString('utf8')) as { bytes?: unknown };
|
|
160
161
|
if (typeof parsed.bytes !== 'string') return;
|
|
161
162
|
const event = Buffer.from(parsed.bytes, 'base64').toString('utf8');
|
|
163
|
+
scanner.feed(`${event}\n`);
|
|
162
164
|
res.write(`event: ${eventTypeOf(event)}\ndata: ${event}\n\n`);
|
|
163
165
|
return;
|
|
164
166
|
}
|
|
@@ -178,6 +180,7 @@ async function relayStream(upstream: Response, res: ServerResponse, watch: Watch
|
|
|
178
180
|
return;
|
|
179
181
|
}
|
|
180
182
|
const ping = setInterval(() => res.write('event: ping\ndata: {"type":"ping"}\n\n'), PING_MS);
|
|
183
|
+
const scanner = new UsageScanner('bedrock');
|
|
181
184
|
try {
|
|
182
185
|
const decoder = new EventStreamDecoder();
|
|
183
186
|
const reader = body.getReader();
|
|
@@ -185,7 +188,7 @@ async function relayStream(upstream: Response, res: ServerResponse, watch: Watch
|
|
|
185
188
|
const { done, value } = await reader.read();
|
|
186
189
|
if (done) break;
|
|
187
190
|
watch.touch();
|
|
188
|
-
for (const message of decoder.push(Buffer.from(value))) writeEvent(res, message);
|
|
191
|
+
for (const message of decoder.push(Buffer.from(value))) writeEvent(res, message, scanner);
|
|
189
192
|
}
|
|
190
193
|
} catch (err) {
|
|
191
194
|
if (!watch.idle()) throw err;
|
|
@@ -194,6 +197,7 @@ async function relayStream(upstream: Response, res: ServerResponse, watch: Watch
|
|
|
194
197
|
clearInterval(ping);
|
|
195
198
|
watch.stop();
|
|
196
199
|
res.end();
|
|
200
|
+
scanner.done();
|
|
197
201
|
}
|
|
198
202
|
}
|
|
199
203
|
|
|
@@ -220,6 +224,9 @@ export async function bedrockMessages(
|
|
|
220
224
|
return;
|
|
221
225
|
}
|
|
222
226
|
const text = await upstream.text();
|
|
227
|
+
const scanner = new UsageScanner('bedrock');
|
|
228
|
+
scanner.feed(text);
|
|
229
|
+
scanner.done();
|
|
223
230
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
224
231
|
res.end(text);
|
|
225
232
|
}
|
|
@@ -8,7 +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
|
+
import { noteUsageHeaders, UsageScanner } from './usage.js';
|
|
12
12
|
|
|
13
13
|
export const CODEX_BASE = 'https://chatgpt.com/backend-api/codex';
|
|
14
14
|
const CODEX_VERSION = '0.153.4';
|
|
@@ -136,6 +136,11 @@ async function relayStream(upstream: Response, res: ServerResponse, model: strin
|
|
|
136
136
|
return;
|
|
137
137
|
}
|
|
138
138
|
const ping = setInterval(() => res.write('event: ping\ndata: {"type":"ping"}\n\n'), PING_MS);
|
|
139
|
+
const scanner = new UsageScanner('codex');
|
|
140
|
+
const emit = (frames: string): void => {
|
|
141
|
+
scanner.feed(frames);
|
|
142
|
+
res.write(frames);
|
|
143
|
+
};
|
|
139
144
|
try {
|
|
140
145
|
const parser = new SseParser();
|
|
141
146
|
const decoder = new TextDecoder();
|
|
@@ -146,10 +151,10 @@ async function relayStream(upstream: Response, res: ServerResponse, model: strin
|
|
|
146
151
|
watch.touch();
|
|
147
152
|
for (const raw of parser.push(decoder.decode(value, { stream: true }))) {
|
|
148
153
|
const parsed = parseEvent(raw);
|
|
149
|
-
if (parsed !== null)
|
|
154
|
+
if (parsed !== null) emit(translator.push(parsed.event, parsed.data));
|
|
150
155
|
}
|
|
151
156
|
}
|
|
152
|
-
|
|
157
|
+
emit(translator.close());
|
|
153
158
|
} catch (err) {
|
|
154
159
|
if (!watch.idle()) throw err;
|
|
155
160
|
res.write(translator.finished ? '' : translator.close(idleMessage(watch.ms)));
|
|
@@ -157,6 +162,7 @@ async function relayStream(upstream: Response, res: ServerResponse, model: strin
|
|
|
157
162
|
clearInterval(ping);
|
|
158
163
|
watch.stop();
|
|
159
164
|
res.end();
|
|
165
|
+
scanner.done();
|
|
160
166
|
}
|
|
161
167
|
}
|
|
162
168
|
|
|
@@ -169,6 +175,9 @@ async function relayWhole(upstream: Response, res: ServerResponse, model: string
|
|
|
169
175
|
if (parsed !== null) frames += translator.push(parsed.event, parsed.data);
|
|
170
176
|
}
|
|
171
177
|
frames += translator.close();
|
|
178
|
+
const scanner = new UsageScanner('codex');
|
|
179
|
+
scanner.feed(frames);
|
|
180
|
+
scanner.done();
|
|
172
181
|
const message = assembleMessage(frames);
|
|
173
182
|
const kind = isRecord(message.error) ? String(message.error.type) : '';
|
|
174
183
|
res.writeHead(message.type === 'error' ? (STATUS_OF[kind] ?? 502) : 200, { 'content-type': 'application/json' });
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { UsageScanner } from './usage.js';
|
|
2
3
|
|
|
3
4
|
export class GatewayError extends Error {
|
|
4
5
|
constructor(
|
|
@@ -129,6 +130,7 @@ export function anthropicHeaders(req: IncomingMessage, apiKey: string): Record<s
|
|
|
129
130
|
export interface PipeOptions {
|
|
130
131
|
keepalive?: boolean;
|
|
131
132
|
ownCredential?: boolean;
|
|
133
|
+
scanner?: UsageScanner;
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
function openResponse(upstream: Response, res: ServerResponse, opts: PipeOptions): boolean {
|
|
@@ -140,12 +142,14 @@ function openResponse(upstream: Response, res: ServerResponse, opts: PipeOptions
|
|
|
140
142
|
return (upstream.headers.get('content-type') ?? '').startsWith('text/event-stream');
|
|
141
143
|
}
|
|
142
144
|
|
|
143
|
-
async function pump(body: ReadableStream<Uint8Array>, res: ServerResponse, watch: Watch): Promise<void> {
|
|
145
|
+
async function pump(body: ReadableStream<Uint8Array>, res: ServerResponse, watch: Watch, scanner?: UsageScanner): Promise<void> {
|
|
144
146
|
const reader = body.getReader();
|
|
147
|
+
const decoder = scanner === undefined ? null : new TextDecoder();
|
|
145
148
|
for (;;) {
|
|
146
149
|
const { done, value } = await reader.read();
|
|
147
150
|
if (done) return;
|
|
148
151
|
watch.touch();
|
|
152
|
+
if (decoder !== null) scanner?.feed(decoder.decode(value, { stream: true }));
|
|
149
153
|
res.write(value);
|
|
150
154
|
}
|
|
151
155
|
}
|
|
@@ -159,7 +163,7 @@ export async function pipeResponse(upstream: Response, res: ServerResponse, watc
|
|
|
159
163
|
}
|
|
160
164
|
const ping = opts.keepalive === true && streaming ? setInterval(() => res.write('event: ping\ndata: {"type":"ping"}\n\n'), PING_MS) : null;
|
|
161
165
|
try {
|
|
162
|
-
await pump(body, res, watch);
|
|
166
|
+
await pump(body, res, watch, opts.scanner);
|
|
163
167
|
} catch (err) {
|
|
164
168
|
if (!watch.idle()) throw err;
|
|
165
169
|
if (streaming) res.write(errorFrame('api_error', idleMessage(watch.ms)));
|
|
@@ -167,6 +171,7 @@ export async function pipeResponse(upstream: Response, res: ServerResponse, watc
|
|
|
167
171
|
if (ping !== null) clearInterval(ping);
|
|
168
172
|
watch.stop();
|
|
169
173
|
res.end();
|
|
174
|
+
opts.scanner?.done();
|
|
170
175
|
}
|
|
171
176
|
}
|
|
172
177
|
|
|
@@ -15,7 +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
|
+
import { forgetUsage, noteUsageHeaders, UsageScanner } from './usage.js';
|
|
19
19
|
import type { CodexTokens } from './codex-auth.js';
|
|
20
20
|
|
|
21
21
|
export const GATEWAY_PREFIX = '/gateway';
|
|
@@ -105,7 +105,8 @@ async function toAnthropic(
|
|
|
105
105
|
redirect: 'manual',
|
|
106
106
|
});
|
|
107
107
|
noteUsageHeaders('anthropic', upstream.headers);
|
|
108
|
-
|
|
108
|
+
const scanner = new UsageScanner('anthropic');
|
|
109
|
+
await pipeResponse(upstream, res, watch, key === '' ? { scanner } : { ownCredential: true, scanner });
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
async function toOpenRouter(
|
|
@@ -136,7 +137,7 @@ async function toOpenRouter(
|
|
|
136
137
|
signal: watch.signal,
|
|
137
138
|
redirect: 'manual',
|
|
138
139
|
});
|
|
139
|
-
await pipeResponse(upstream, res, watch, { keepalive: true, ownCredential: true });
|
|
140
|
+
await pipeResponse(upstream, res, watch, { keepalive: true, ownCredential: true, scanner: new UsageScanner('openrouter') });
|
|
140
141
|
}
|
|
141
142
|
|
|
142
143
|
export function openrouterBody(body: Record<string, unknown>, model: string, zdr: boolean): Record<string, unknown> {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type UsageProvider = 'anthropic' | 'codex' | 'openrouter';
|
|
1
|
+
export type UsageProvider = 'anthropic' | 'codex' | 'openrouter' | 'bedrock';
|
|
2
2
|
|
|
3
3
|
export interface UsageWindow {
|
|
4
4
|
label: string;
|
|
@@ -7,26 +7,108 @@ export interface UsageWindow {
|
|
|
7
7
|
detail: string | null;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export interface Tally {
|
|
11
|
+
requests: number;
|
|
12
|
+
input: number;
|
|
13
|
+
output: number;
|
|
14
|
+
cached: number;
|
|
15
|
+
since: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
export interface ProviderUsage {
|
|
11
19
|
windows: UsageWindow[];
|
|
12
20
|
note: string | null;
|
|
13
21
|
at: string;
|
|
22
|
+
tally: Tally | null;
|
|
14
23
|
}
|
|
15
24
|
|
|
16
|
-
|
|
25
|
+
type Reported = Omit<ProviderUsage, 'tally'>;
|
|
26
|
+
|
|
27
|
+
const latest = new Map<UsageProvider, Reported>();
|
|
28
|
+
const tallies = new Map<UsageProvider, Tally>();
|
|
17
29
|
|
|
18
|
-
export const noteUsage = (provider: UsageProvider, usage:
|
|
30
|
+
export const noteUsage = (provider: UsageProvider, usage: Reported): void => {
|
|
19
31
|
latest.set(provider, usage);
|
|
20
32
|
};
|
|
21
33
|
|
|
22
|
-
export
|
|
34
|
+
export function usageSeen(): Partial<Record<UsageProvider, ProviderUsage>> {
|
|
35
|
+
const out: Partial<Record<UsageProvider, ProviderUsage>> = {};
|
|
36
|
+
const providers = new Set<UsageProvider>([...latest.keys(), ...tallies.keys()]);
|
|
37
|
+
for (const provider of providers) {
|
|
38
|
+
const reported = latest.get(provider);
|
|
39
|
+
const tally = tallies.get(provider) ?? null;
|
|
40
|
+
out[provider] = reported === undefined
|
|
41
|
+
? { windows: [], note: null, at: tally?.since ?? new Date().toISOString(), tally }
|
|
42
|
+
: { ...reported, tally };
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
23
46
|
|
|
24
|
-
export const usageOf = (provider: UsageProvider):
|
|
47
|
+
export const usageOf = (provider: UsageProvider): Reported | undefined => latest.get(provider);
|
|
25
48
|
|
|
26
49
|
export const forgetUsage = (): void => {
|
|
27
50
|
latest.clear();
|
|
51
|
+
tallies.clear();
|
|
28
52
|
};
|
|
29
53
|
|
|
54
|
+
export interface Counted {
|
|
55
|
+
input: number;
|
|
56
|
+
output: number;
|
|
57
|
+
cached: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function tallyTokens(provider: UsageProvider, counted: Counted, now = new Date()): void {
|
|
61
|
+
const so = tallies.get(provider) ?? { requests: 0, input: 0, output: 0, cached: 0, since: now.toISOString() };
|
|
62
|
+
tallies.set(provider, {
|
|
63
|
+
requests: so.requests + 1,
|
|
64
|
+
input: so.input + counted.input,
|
|
65
|
+
output: so.output + counted.output,
|
|
66
|
+
cached: so.cached + counted.cached,
|
|
67
|
+
since: so.since,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const TOKEN_FIELD = /"(input_tokens|output_tokens|cache_read_input_tokens|cache_creation_input_tokens)"\s*:\s*(\d+)/g;
|
|
72
|
+
|
|
73
|
+
export class UsageScanner {
|
|
74
|
+
private carry = '';
|
|
75
|
+
private readonly max = { input: 0, output: 0, read: 0, creation: 0 };
|
|
76
|
+
private touched = false;
|
|
77
|
+
|
|
78
|
+
constructor(private readonly provider: UsageProvider) {}
|
|
79
|
+
|
|
80
|
+
private scan(text: string): void {
|
|
81
|
+
for (const hit of text.matchAll(TOKEN_FIELD)) {
|
|
82
|
+
const value = Number(hit[2]);
|
|
83
|
+
if (!Number.isFinite(value)) continue;
|
|
84
|
+
this.touched = true;
|
|
85
|
+
if (hit[1] === 'input_tokens') this.max.input = Math.max(this.max.input, value);
|
|
86
|
+
else if (hit[1] === 'output_tokens') this.max.output = Math.max(this.max.output, value);
|
|
87
|
+
else if (hit[1] === 'cache_read_input_tokens') this.max.read = Math.max(this.max.read, value);
|
|
88
|
+
else this.max.creation = Math.max(this.max.creation, value);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
feed(text: string): void {
|
|
93
|
+
const joined = this.carry + text;
|
|
94
|
+
const cut = joined.lastIndexOf('\n');
|
|
95
|
+
if (cut === -1) {
|
|
96
|
+
this.carry = joined;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
this.scan(joined.slice(0, cut));
|
|
100
|
+
this.carry = joined.slice(cut + 1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
done(now = new Date()): void {
|
|
104
|
+
if (this.carry !== '') this.scan(this.carry);
|
|
105
|
+
this.carry = '';
|
|
106
|
+
if (!this.touched) return;
|
|
107
|
+
tallyTokens(this.provider, { input: this.max.input, output: this.max.output, cached: this.max.read + this.max.creation }, now);
|
|
108
|
+
this.touched = false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
30
112
|
const clamp = (value: number): number => Math.min(1, Math.max(0, value));
|
|
31
113
|
|
|
32
114
|
function fraction(raw: string | null): number | null {
|
|
@@ -83,7 +165,7 @@ function anthropicKeyed(headers: Headers, now: Date): UsageWindow[] {
|
|
|
83
165
|
];
|
|
84
166
|
}
|
|
85
167
|
|
|
86
|
-
export function anthropicUsage(headers: Headers, now = new Date()):
|
|
168
|
+
export function anthropicUsage(headers: Headers, now = new Date()): Reported | null {
|
|
87
169
|
const unified = anthropicUnified(headers, now);
|
|
88
170
|
if (unified.windows.length > 0) return { ...unified, at: now.toISOString() };
|
|
89
171
|
const keyed = anthropicKeyed(headers, now);
|
|
@@ -114,8 +196,15 @@ function codexWindow(headers: Headers, kind: 'primary' | 'secondary', now: Date)
|
|
|
114
196
|
return { label: windowLabel(Number.isFinite(minutes) ? minutes : null), used, resetAt, detail: null };
|
|
115
197
|
}
|
|
116
198
|
|
|
117
|
-
|
|
118
|
-
|
|
199
|
+
function codexCredits(headers: Headers): UsageWindow | null {
|
|
200
|
+
if (headers.get('x-codex-credits-has-credits') !== 'true') return null;
|
|
201
|
+
const balance = Number(headers.get('x-codex-credits-balance') ?? 'none');
|
|
202
|
+
if (!Number.isFinite(balance)) return null;
|
|
203
|
+
return { label: 'Credits', used: null, resetAt: null, detail: `${balance.toLocaleString('en-US')} left` };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function codexUsage(headers: Headers, now = new Date()): Reported | null {
|
|
207
|
+
const windows = [codexWindow(headers, 'primary', now), codexWindow(headers, 'secondary', now), codexCredits(headers)].filter(
|
|
119
208
|
(w): w is UsageWindow => w !== null,
|
|
120
209
|
);
|
|
121
210
|
if (windows.length === 0) return null;
|
|
@@ -130,7 +219,7 @@ export function noteUsageHeaders(provider: 'anthropic' | 'codex', headers: Heade
|
|
|
130
219
|
|
|
131
220
|
const dollars = (value: number): string => `$${value.toFixed(2)}`;
|
|
132
221
|
|
|
133
|
-
export function openrouterUsage(total: number, spent: number, now = new Date()):
|
|
222
|
+
export function openrouterUsage(total: number, spent: number, now = new Date()): Reported {
|
|
134
223
|
const used = total > 0 ? clamp(spent / total) : null;
|
|
135
224
|
return {
|
|
136
225
|
windows: [{ label: 'Credits', used, resetAt: null, detail: `${dollars(spent)} of ${dollars(total)} used` }],
|
package/runtime/runtime.json
CHANGED