@stage-labs/metro 0.1.0-beta.29 → 0.1.0-beta.30

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.
@@ -1,5 +1,6 @@
1
1
  import { createServer, } from 'node:http';
2
2
  import { EventStreamDecoder } from './bedrock-eventstream.js';
3
+ import { callBedrock, freshAdaptations, invoke, invokeUrl, upstreamMessage, } from './bedrock-upstream.js';
3
4
  export class BedrockConfigError extends Error {
4
5
  }
5
6
  export class ProxyError extends Error {
@@ -108,45 +109,6 @@ async function readBody(req) {
108
109
  throw new ProxyError(400, 'invalid_request_error', 'body must be JSON');
109
110
  }
110
111
  }
111
- function invokeUrl(up, modelId, action) {
112
- return `${up.base}/model/${encodeURIComponent(modelId)}/${action}`;
113
- }
114
- function callBedrock(up, url, body, accept, signal) {
115
- return fetch(url, {
116
- method: 'POST',
117
- headers: {
118
- authorization: `Bearer ${up.cfg.bearerToken}`,
119
- 'content-type': 'application/json',
120
- accept,
121
- },
122
- body: JSON.stringify(body),
123
- signal,
124
- });
125
- }
126
- async function invoke(up, req, signal) {
127
- const action = req.stream ? 'invoke-with-response-stream' : 'invoke';
128
- const accept = req.stream ? 'application/vnd.amazon.eventstream' : 'application/json';
129
- const url = invokeUrl(up, req.modelId, action);
130
- const first = await callBedrock(up, url, req.body, accept, signal);
131
- if (first.status !== 400 || req.betas.length === 0)
132
- return first;
133
- await first.text();
134
- process.stderr.write(`metro bedrock: retrying without anthropic_beta [${req.betas.join(', ')}]\n`);
135
- const stripped = Object.fromEntries(Object.entries(req.body).filter(([key]) => key !== 'anthropic_beta'));
136
- return callBedrock(up, url, stripped, accept, signal);
137
- }
138
- function upstreamMessage(text) {
139
- try {
140
- const parsed = JSON.parse(text);
141
- const message = parsed.message ?? parsed.Message;
142
- if (typeof message === 'string' && message !== '')
143
- return message;
144
- }
145
- catch {
146
- return text === '' ? 'Bedrock returned no body' : text;
147
- }
148
- return text === '' ? 'Bedrock returned no body' : text;
149
- }
150
112
  async function relayError(upstream, res) {
151
113
  const text = await upstream.text();
152
114
  const kind = errorKind(upstream.status, upstream.headers.get('x-amzn-errortype'));
@@ -248,11 +210,11 @@ function presentedToken(req) {
248
210
  const key = req.headers['x-api-key'];
249
211
  return Array.isArray(key) ? (key[0] ?? '') : (key ?? '');
250
212
  }
251
- async function route(req, res, cfg, opts) {
213
+ async function route(req, res, cfg, opts, learned) {
252
214
  if (presentedToken(req) !== opts.token)
253
215
  throw new ProxyError(401, 'authentication_error', 'unauthorized');
254
216
  const path = (req.url ?? '').split('?')[0] ?? '';
255
- const up = { cfg, base: upstreamBase(cfg, opts.upstream) };
217
+ const up = { cfg, base: upstreamBase(cfg, opts.upstream), learned };
256
218
  if (req.method === 'POST' && path === MESSAGES_PATH)
257
219
  return handleMessages(req, res, up);
258
220
  if (req.method === 'POST' && path === COUNT_PATH)
@@ -272,8 +234,9 @@ function failed(res, err) {
272
234
  sendError(res, 502, 'api_error', `metro bedrock proxy: ${message}`);
273
235
  }
274
236
  export function startBedrockProxy(cfg, opts) {
237
+ const learned = freshAdaptations();
275
238
  const server = createServer((req, res) => {
276
- route(req, res, cfg, opts).catch((err) => {
239
+ route(req, res, cfg, opts, learned).catch((err) => {
277
240
  failed(res, err);
278
241
  });
279
242
  });
@@ -285,6 +248,7 @@ export function startBedrockProxy(cfg, opts) {
285
248
  resolve({
286
249
  port,
287
250
  server,
251
+ learned,
288
252
  close: () => new Promise((done) => {
289
253
  server.close(() => {
290
254
  done();
@@ -0,0 +1,72 @@
1
+ export const freshAdaptations = () => ({
2
+ fields: new Set(),
3
+ dropBetas: false,
4
+ });
5
+ const EXTRA_INPUT_RE = /^([A-Za-z0-9_]+)(?:\.[^:]*)?: Extra inputs are not permitted/;
6
+ const MAX_REPAIRS = 4;
7
+ export function invokeUrl(up, modelId, action) {
8
+ return `${up.base}/model/${encodeURIComponent(modelId)}/${action}`;
9
+ }
10
+ export function callBedrock(up, url, body, accept, signal) {
11
+ return fetch(url, {
12
+ method: 'POST',
13
+ headers: {
14
+ authorization: `Bearer ${up.cfg.bearerToken}`,
15
+ 'content-type': 'application/json',
16
+ accept,
17
+ },
18
+ body: JSON.stringify(body),
19
+ signal,
20
+ });
21
+ }
22
+ export function upstreamMessage(text) {
23
+ const fallback = text === '' ? 'Bedrock returned no body' : text;
24
+ try {
25
+ const parsed = JSON.parse(text);
26
+ const message = parsed.message ?? parsed.Message;
27
+ return typeof message === 'string' && message !== '' ? message : fallback;
28
+ }
29
+ catch {
30
+ return fallback;
31
+ }
32
+ }
33
+ const without = (body, keys) => Object.fromEntries(Object.entries(body).filter(([key]) => !keys.has(key)));
34
+ function applyLearned(req, learned) {
35
+ const dropped = new Set(learned.fields);
36
+ if (learned.dropBetas)
37
+ dropped.add('anthropic_beta');
38
+ return {
39
+ body: without(req.body, dropped),
40
+ betas: learned.dropBetas ? [] : req.betas,
41
+ };
42
+ }
43
+ function repair(attempt, learned, message) {
44
+ const named = EXTRA_INPUT_RE.exec(message)?.[1];
45
+ if (named !== undefined && named in attempt.body) {
46
+ learned.fields.add(named);
47
+ process.stderr.write(`metro bedrock: Bedrock refused "${named}" (extra input); dropping it from every request\n`);
48
+ return { body: without(attempt.body, new Set([named])), betas: attempt.betas };
49
+ }
50
+ if (attempt.betas.length > 0) {
51
+ learned.dropBetas = true;
52
+ process.stderr.write(`metro bedrock: retrying without anthropic_beta [${attempt.betas.join(', ')}]\n`);
53
+ return { body: without(attempt.body, new Set(['anthropic_beta'])), betas: [] };
54
+ }
55
+ return null;
56
+ }
57
+ export async function invoke(up, req, signal) {
58
+ const action = req.stream ? 'invoke-with-response-stream' : 'invoke';
59
+ const accept = req.stream ? 'application/vnd.amazon.eventstream' : 'application/json';
60
+ const url = invokeUrl(up, req.modelId, action);
61
+ let attempt = applyLearned(req, up.learned);
62
+ for (let repairs = 0;; repairs += 1) {
63
+ const res = await callBedrock(up, url, attempt.body, accept, signal);
64
+ if (res.status !== 400 || repairs >= MAX_REPAIRS)
65
+ return res;
66
+ const text = await res.text();
67
+ const next = repair(attempt, up.learned, upstreamMessage(text));
68
+ if (next === null)
69
+ return new Response(text, { status: 400, headers: res.headers });
70
+ attempt = next;
71
+ }
72
+ }
package/dist/bedrock.js CHANGED
@@ -56,10 +56,15 @@ export function settingsConflicts(files) {
56
56
  }
57
57
  return out;
58
58
  }
59
- export function claudeEnv(base, port, token) {
59
+ export function firstPartyModelId(bedrockId) {
60
+ return bedrockId.replace(/^(?:[a-z-]+\.)?anthropic\./, '').replace(/-v\d+:\d+$/, '');
61
+ }
62
+ export function claudeEnv(base, port, token, pinned = null) {
60
63
  const kept = Object.entries(base).filter(([key]) => !SCRUBBED.includes(key));
64
+ const model = (base.ANTHROPIC_MODEL ?? '').trim();
61
65
  return {
62
66
  ...Object.fromEntries(kept),
67
+ ...(pinned !== null && model === '' && { ANTHROPIC_MODEL: firstPartyModelId(pinned) }),
63
68
  ANTHROPIC_BASE_URL: `http://127.0.0.1:${String(port)}`,
64
69
  ANTHROPIC_AUTH_TOKEN: token,
65
70
  };
@@ -91,7 +96,7 @@ export async function bedrock(argv) {
91
96
  const pinned = cfg.model === null ? '' : ` (${cfg.model})`;
92
97
  process.stderr.write(`metro bedrock: Claude Code → http://127.0.0.1:${String(proxy.port)} → Bedrock ${cfg.region}${pinned}\n`);
93
98
  try {
94
- return await runClaude(claudeArgs(argv), claudeEnv(process.env, proxy.port, token));
99
+ return await runClaude(claudeArgs(argv), claudeEnv(process.env, proxy.port, token, cfg.model));
95
100
  }
96
101
  finally {
97
102
  await proxy.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.29",
3
+ "version": "0.1.0-beta.30",
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,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.29"
2
+ "version": "0.1.0-beta.30"
3
3
  }