@stage-labs/metro 0.1.0-beta.73 → 0.1.0-beta.75
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/mcp/src/daemon/model-api.ts +6 -3
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/forward.ts +13 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/gateway.ts +7 -3
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/model-config.ts +19 -1
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/openrouter.ts +13 -4
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/served.ts +17 -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.75",
|
|
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": {
|
|
@@ -7,6 +7,7 @@ import { beginLogin, CodexAuthError, finishLogin, readCodexCliAuth } from '../ga
|
|
|
7
7
|
import { beginDeviceLogin, pollDeviceLogin } from '../gateway/codex-device.js';
|
|
8
8
|
import { codexModels, currentTokens, freshCodexState } from '../gateway/codex.js';
|
|
9
9
|
import { openrouterModels } from '../gateway/openrouter.js';
|
|
10
|
+
import { lastServed } from '../gateway/served.js';
|
|
10
11
|
import type { CodexTokens } from '../gateway/codex-auth.js';
|
|
11
12
|
import { GatewayError } from '../gateway/forward.js';
|
|
12
13
|
import {
|
|
@@ -49,6 +50,8 @@ interface Route {
|
|
|
49
50
|
run: Handler;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
const settingsBody = (cfg: ModelConfig): Record<string, unknown> => ({ ...publicModelConfig(cfg), lastServed: lastServed() });
|
|
54
|
+
|
|
52
55
|
function asApiError(err: unknown): never {
|
|
53
56
|
if (err instanceof ModelConfigError || err instanceof CodexAuthError) throw new ApiError(err.message, 400);
|
|
54
57
|
if (err instanceof GatewayError) throw new ApiError(err.message, 502);
|
|
@@ -65,13 +68,13 @@ async function update(req: IncomingMessage, store: Store): Promise<unknown> {
|
|
|
65
68
|
}
|
|
66
69
|
store.write(next);
|
|
67
70
|
log.info({ provider: next.provider }, 'model-api: route updated');
|
|
68
|
-
return
|
|
71
|
+
return settingsBody(next);
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
function saveCodex(store: Store, cfg: ModelConfig, note: string): unknown {
|
|
72
75
|
store.write(cfg);
|
|
73
76
|
log.info({ signedIn: cfg.codex.auth !== null, plan: cfg.codex.auth?.plan ?? null }, note);
|
|
74
|
-
return
|
|
77
|
+
return settingsBody(cfg);
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
const modelApiState = freshCodexState();
|
|
@@ -146,7 +149,7 @@ const named = (table: Record<string, Route>, name: string, method: string | unde
|
|
|
146
149
|
};
|
|
147
150
|
|
|
148
151
|
function settingsRoute(method: string | undefined): Route | number {
|
|
149
|
-
if (method === 'GET') return { method: 'GET', run: (_req, _deps, store) => Promise.resolve(
|
|
152
|
+
if (method === 'GET') return { method: 'GET', run: (_req, _deps, store) => Promise.resolve(settingsBody(store.read())) };
|
|
150
153
|
if (method === 'PUT') return { method: 'POST', run: (req, _deps, store) => update(req, store) };
|
|
151
154
|
return 405;
|
|
152
155
|
}
|
|
@@ -113,6 +113,19 @@ export function forwardedHeaders(req: IncomingMessage): Record<string, string> {
|
|
|
113
113
|
return out;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
export function anthropicHeaders(req: IncomingMessage, apiKey: string): Record<string, string> {
|
|
117
|
+
const headers = forwardedHeaders(req);
|
|
118
|
+
delete headers.authorization;
|
|
119
|
+
const betas = (headers['anthropic-beta'] ?? '')
|
|
120
|
+
.split(',')
|
|
121
|
+
.map((beta) => beta.trim())
|
|
122
|
+
.filter((beta) => beta !== '' && !beta.includes('oauth'));
|
|
123
|
+
if (betas.length > 0) headers['anthropic-beta'] = betas.join(',');
|
|
124
|
+
else delete headers['anthropic-beta'];
|
|
125
|
+
headers['x-api-key'] = apiKey;
|
|
126
|
+
return headers;
|
|
127
|
+
}
|
|
128
|
+
|
|
116
129
|
export interface PipeOptions {
|
|
117
130
|
keepalive?: boolean;
|
|
118
131
|
ownCredential?: boolean;
|
|
@@ -9,10 +9,11 @@ import {
|
|
|
9
9
|
freshAdaptations,
|
|
10
10
|
type Adaptations,
|
|
11
11
|
} from './bedrock.js';
|
|
12
|
-
import { forwardedHeaders, GatewayError, parseJson, pipeResponse, readBody, sendError, watchUpstream } from './forward.js';
|
|
12
|
+
import { anthropicHeaders, forwardedHeaders, GatewayError, parseJson, pipeResponse, readBody, sendError, watchUpstream } from './forward.js';
|
|
13
13
|
import { notReady, readModelConfig, resolveRoute, routeLabel, setCodexAuth, writeModelConfig, type ModelConfig, type Route } from './model-config.js';
|
|
14
14
|
import { codexCount, codexMessages, freshCodexState, type CodexDeps } from './codex.js';
|
|
15
15
|
import { OPENROUTER_BASE } from './openrouter.js';
|
|
16
|
+
import { forgetServed, noteServed } from './served.js';
|
|
16
17
|
import type { CodexTokens } from './codex-auth.js';
|
|
17
18
|
|
|
18
19
|
export const GATEWAY_PREFIX = '/gateway';
|
|
@@ -35,6 +36,7 @@ const learned: Adaptations = freshAdaptations();
|
|
|
35
36
|
const codexState = freshCodexState();
|
|
36
37
|
|
|
37
38
|
export function resetGatewayState(): void {
|
|
39
|
+
forgetServed();
|
|
38
40
|
learned.fields.clear();
|
|
39
41
|
learned.dropBetas = false;
|
|
40
42
|
Object.assign(codexState, freshCodexState());
|
|
@@ -80,14 +82,15 @@ async function toAnthropic(
|
|
|
80
82
|
const payload = explicit ? Buffer.from(JSON.stringify({ ...body, model: route.model })) : raw;
|
|
81
83
|
const url = `${deps.anthropicBase ?? ANTHROPIC_BASE}${(req.url ?? '').slice(GATEWAY_PREFIX.length)}`;
|
|
82
84
|
const watch = watchUpstream(res);
|
|
85
|
+
const key = deps.config().anthropic.apiKey;
|
|
83
86
|
const upstream = await fetch(url, {
|
|
84
87
|
method: 'POST',
|
|
85
|
-
headers: forwardedHeaders(req),
|
|
88
|
+
headers: key === '' ? forwardedHeaders(req) : anthropicHeaders(req, key),
|
|
86
89
|
body: new Uint8Array(payload),
|
|
87
90
|
signal: watch.signal,
|
|
88
91
|
redirect: 'manual',
|
|
89
92
|
});
|
|
90
|
-
await pipeResponse(upstream, res, watch);
|
|
93
|
+
await pipeResponse(upstream, res, watch, key === '' ? {} : { ownCredential: true });
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
async function toOpenRouter(
|
|
@@ -127,6 +130,7 @@ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string,
|
|
|
127
130
|
const body = parseJson(raw);
|
|
128
131
|
const route = resolveRoute(requestedModel(body), cfg);
|
|
129
132
|
log.info({ route: routeLabel(route), path }, 'gateway: routing');
|
|
133
|
+
if (path === MESSAGES) noteServed({ provider: route.provider, model: route.model, at: new Date().toISOString() });
|
|
130
134
|
if (route.provider === 'bedrock') {
|
|
131
135
|
assertBedrockReady(cfg.bedrock);
|
|
132
136
|
const up = { settings: cfg.bedrock, base: deps.bedrockBase ?? bedrockBase(cfg.bedrock.region), learned, watch: watchUpstream(res) };
|
|
@@ -7,6 +7,11 @@ import type { CodexTokens } from './codex-auth.js';
|
|
|
7
7
|
export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex'] as const;
|
|
8
8
|
export type Provider = (typeof PROVIDERS)[number];
|
|
9
9
|
|
|
10
|
+
export interface AnthropicSettings {
|
|
11
|
+
apiKey: string;
|
|
12
|
+
model: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
10
15
|
export interface BedrockSettings {
|
|
11
16
|
region: string;
|
|
12
17
|
apiKey: string;
|
|
@@ -26,6 +31,7 @@ export interface CodexSettings {
|
|
|
26
31
|
export interface ModelConfig {
|
|
27
32
|
version: 1;
|
|
28
33
|
provider: Provider;
|
|
34
|
+
anthropic: AnthropicSettings;
|
|
29
35
|
bedrock: BedrockSettings;
|
|
30
36
|
openrouter: OpenRouterSettings;
|
|
31
37
|
codex: CodexSettings;
|
|
@@ -41,10 +47,12 @@ export class ModelConfigError extends Error {}
|
|
|
41
47
|
export const MODEL_FILE = 'model.json';
|
|
42
48
|
const MAX_FIELD = 512;
|
|
43
49
|
const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex):(.+)$/;
|
|
50
|
+
const SMALL_RE = /haiku/i;
|
|
44
51
|
|
|
45
52
|
const empty = (): ModelConfig => ({
|
|
46
53
|
version: 1,
|
|
47
54
|
provider: 'anthropic',
|
|
55
|
+
anthropic: { apiKey: '', model: '' },
|
|
48
56
|
bedrock: { region: '', apiKey: '', model: '' },
|
|
49
57
|
openrouter: { apiKey: '', model: '' },
|
|
50
58
|
codex: { model: '', auth: null },
|
|
@@ -76,12 +84,14 @@ function tokensFromDisk(raw: unknown): CodexTokens | null {
|
|
|
76
84
|
function fromDisk(raw: unknown): ModelConfig {
|
|
77
85
|
const base = empty();
|
|
78
86
|
if (!isRecord(raw)) return base;
|
|
87
|
+
const anthropic = isRecord(raw.anthropic) ? raw.anthropic : {};
|
|
79
88
|
const bedrock = isRecord(raw.bedrock) ? raw.bedrock : {};
|
|
80
89
|
const openrouter = isRecord(raw.openrouter) ? raw.openrouter : {};
|
|
81
90
|
const codex = isRecord(raw.codex) ? raw.codex : {};
|
|
82
91
|
return {
|
|
83
92
|
version: 1,
|
|
84
93
|
provider: isProvider(raw.provider) ? raw.provider : 'anthropic',
|
|
94
|
+
anthropic: { apiKey: text(anthropic.apiKey), model: text(anthropic.model) },
|
|
85
95
|
bedrock: { region: text(bedrock.region), apiKey: text(bedrock.apiKey), model: text(bedrock.model) },
|
|
86
96
|
openrouter: { apiKey: text(openrouter.apiKey), model: text(openrouter.model) },
|
|
87
97
|
codex: { model: text(codex.model), auth: tokensFromDisk(codex.auth) },
|
|
@@ -108,12 +118,17 @@ export function applyModelUpdate(cfg: ModelConfig, patch: unknown): ModelConfig
|
|
|
108
118
|
if (!isRecord(patch)) throw new ModelConfigError('body must be a JSON object');
|
|
109
119
|
const provider = 'provider' in patch ? patch.provider : cfg.provider;
|
|
110
120
|
if (!isProvider(provider)) throw new ModelConfigError(`provider must be one of ${PROVIDERS.join(', ')}`);
|
|
121
|
+
const anthropic = isRecord(patch.anthropic) ? patch.anthropic : {};
|
|
111
122
|
const bedrock = isRecord(patch.bedrock) ? patch.bedrock : {};
|
|
112
123
|
const openrouter = isRecord(patch.openrouter) ? patch.openrouter : {};
|
|
113
124
|
const codex = isRecord(patch.codex) ? patch.codex : {};
|
|
114
125
|
return {
|
|
115
126
|
version: 1,
|
|
116
127
|
provider,
|
|
128
|
+
anthropic: {
|
|
129
|
+
apiKey: field(anthropic, 'apiKey', cfg.anthropic.apiKey, 'Anthropic API key'),
|
|
130
|
+
model: field(anthropic, 'model', cfg.anthropic.model, 'Anthropic model'),
|
|
131
|
+
},
|
|
117
132
|
bedrock: {
|
|
118
133
|
region: field(bedrock, 'region', cfg.bedrock.region, 'Bedrock region'),
|
|
119
134
|
apiKey: field(bedrock, 'apiKey', cfg.bedrock.apiKey, 'Bedrock API key'),
|
|
@@ -155,17 +170,20 @@ export function publicModelConfig(cfg: ModelConfig): Record<string, unknown> {
|
|
|
155
170
|
provider: cfg.provider,
|
|
156
171
|
ready: notReady(cfg) === null,
|
|
157
172
|
reason: notReady(cfg),
|
|
173
|
+
anthropic: { model: cfg.anthropic.model, hasKey: cfg.anthropic.apiKey !== '' },
|
|
158
174
|
bedrock: { region: cfg.bedrock.region, model: cfg.bedrock.model, hasKey: cfg.bedrock.apiKey !== '' },
|
|
159
175
|
openrouter: { model: cfg.openrouter.model, hasKey: cfg.openrouter.apiKey !== '' },
|
|
160
176
|
codex: { model: cfg.codex.model, signedIn: auth !== null, account: auth?.email ?? null, plan: auth?.plan ?? null },
|
|
161
177
|
};
|
|
162
178
|
}
|
|
163
179
|
|
|
180
|
+
export const isSmallModel = (requested: string): boolean => SMALL_RE.test(requested);
|
|
181
|
+
|
|
164
182
|
function defaultModelFor(provider: Provider, requested: string, cfg: ModelConfig): string {
|
|
165
183
|
if (provider === 'openrouter') return requested.includes('/') ? requested : cfg.openrouter.model;
|
|
166
184
|
if (provider === 'bedrock') return cfg.bedrock.model === '' ? requested : cfg.bedrock.model;
|
|
167
185
|
if (provider === 'codex') return requested.startsWith('gpt-') ? requested : cfg.codex.model;
|
|
168
|
-
return requested;
|
|
186
|
+
return cfg.anthropic.model === '' || isSmallModel(requested) ? requested : cfg.anthropic.model;
|
|
169
187
|
}
|
|
170
188
|
|
|
171
189
|
export function resolveRoute(requested: string, cfg: ModelConfig): Route {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRecord } from '../daemon/is-record.js';
|
|
1
2
|
import { GatewayError } from './forward.js';
|
|
2
3
|
|
|
3
4
|
export const OPENROUTER_BASE = 'https://openrouter.ai/api';
|
|
@@ -6,15 +7,23 @@ const MODELS_MAX = 2000;
|
|
|
6
7
|
export interface OpenRouterModel {
|
|
7
8
|
id: string;
|
|
8
9
|
name: string;
|
|
10
|
+
prompt: number | null;
|
|
11
|
+
completion: number | null;
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
const str = (value: unknown): string => (typeof value === 'string' ? value : '');
|
|
12
15
|
|
|
16
|
+
function price(raw: unknown): number | null {
|
|
17
|
+
const value = typeof raw === 'string' ? Number(raw) : typeof raw === 'number' ? raw : Number.NaN;
|
|
18
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
function modelOf(entry: unknown): OpenRouterModel | null {
|
|
14
|
-
if (
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
22
|
+
if (!isRecord(entry)) return null;
|
|
23
|
+
const id = str(entry.id);
|
|
24
|
+
if (id === '') return null;
|
|
25
|
+
const pricing = isRecord(entry.pricing) ? entry.pricing : {};
|
|
26
|
+
return { id, name: str(entry.name) || id, prompt: price(pricing.prompt), completion: price(pricing.completion) };
|
|
18
27
|
}
|
|
19
28
|
|
|
20
29
|
export async function openrouterModels(base = OPENROUTER_BASE, fetchImpl: typeof fetch = fetch): Promise<OpenRouterModel[]> {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface Served {
|
|
2
|
+
provider: string;
|
|
3
|
+
model: string;
|
|
4
|
+
at: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
let last: Served | null = null;
|
|
8
|
+
|
|
9
|
+
export const noteServed = (served: Served): void => {
|
|
10
|
+
last = served;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const lastServed = (): Served | null => last;
|
|
14
|
+
|
|
15
|
+
export const forgetServed = (): void => {
|
|
16
|
+
last = null;
|
|
17
|
+
};
|
package/runtime/runtime.json
CHANGED