@stage-labs/metro 0.1.0-beta.70 → 0.1.0-beta.72
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/dist/bedrock.js +4 -73
- package/dist/claude-settings.js +40 -0
- package/dist/claude.js +101 -9
- package/dist/hold.js +20 -6
- package/dist/provider-flags.js +9 -0
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-api.ts +22 -4
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-settings.ts +117 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +9 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +3 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/model-api.ts +192 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +5 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/terminal-ws.ts +18 -9
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/bedrock.ts +252 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-auth.ts +231 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-device.ts +91 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-stream.ts +288 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-translate.ts +203 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex.ts +211 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/eventstream.ts +123 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/forward.ts +169 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/gateway.ts +193 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/model-config.ts +180 -0
- package/runtime/node_modules/@metro-labs/mcp/src/gateway/openrouter.ts +31 -0
- package/runtime/runtime.json +1 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { ApiError } from './api-error.js';
|
|
3
|
+
import { apiFailure, apiSession, cors, readJsonBody, sendJson } from './api-http.js';
|
|
4
|
+
import { isRecord } from './is-record.js';
|
|
5
|
+
import { log } from './log.js';
|
|
6
|
+
import { beginLogin, CodexAuthError, finishLogin, readCodexCliAuth } from '../gateway/codex-auth.js';
|
|
7
|
+
import { beginDeviceLogin, pollDeviceLogin } from '../gateway/codex-device.js';
|
|
8
|
+
import { codexModels, currentTokens, freshCodexState } from '../gateway/codex.js';
|
|
9
|
+
import { openrouterModels } from '../gateway/openrouter.js';
|
|
10
|
+
import type { CodexTokens } from '../gateway/codex-auth.js';
|
|
11
|
+
import { GatewayError } from '../gateway/forward.js';
|
|
12
|
+
import {
|
|
13
|
+
applyModelUpdate,
|
|
14
|
+
ModelConfigError,
|
|
15
|
+
publicModelConfig,
|
|
16
|
+
readModelConfig,
|
|
17
|
+
setCodexAuth,
|
|
18
|
+
writeModelConfig,
|
|
19
|
+
type ModelConfig,
|
|
20
|
+
} from '../gateway/model-config.js';
|
|
21
|
+
|
|
22
|
+
const PATH = '/api/model';
|
|
23
|
+
const CODEX = '/api/model/codex/';
|
|
24
|
+
const OPENROUTER = '/api/model/openrouter/';
|
|
25
|
+
const BODY_MAX = 16 * 1024;
|
|
26
|
+
const DEVICE_PREFIX = 'device/';
|
|
27
|
+
const DEVICE_ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
28
|
+
|
|
29
|
+
export interface ModelApiDeps {
|
|
30
|
+
authorize: (subject: string) => void;
|
|
31
|
+
read?: () => ModelConfig;
|
|
32
|
+
write?: (cfg: ModelConfig) => void;
|
|
33
|
+
issuer?: string;
|
|
34
|
+
fetchImpl?: typeof fetch;
|
|
35
|
+
codexHome?: string;
|
|
36
|
+
codexBase?: string;
|
|
37
|
+
openrouterBase?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Store {
|
|
41
|
+
read: () => ModelConfig;
|
|
42
|
+
write: (cfg: ModelConfig) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type Handler = (req: IncomingMessage, deps: ModelApiDeps, store: Store) => Promise<unknown>;
|
|
46
|
+
|
|
47
|
+
interface Route {
|
|
48
|
+
method: 'GET' | 'POST';
|
|
49
|
+
run: Handler;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function asApiError(err: unknown): never {
|
|
53
|
+
if (err instanceof ModelConfigError || err instanceof CodexAuthError) throw new ApiError(err.message, 400);
|
|
54
|
+
if (err instanceof GatewayError) throw new ApiError(err.message, 502);
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function update(req: IncomingMessage, store: Store): Promise<unknown> {
|
|
59
|
+
const patch = await readJsonBody(req, BODY_MAX);
|
|
60
|
+
let next: ModelConfig;
|
|
61
|
+
try {
|
|
62
|
+
next = applyModelUpdate(store.read(), patch);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
asApiError(err);
|
|
65
|
+
}
|
|
66
|
+
store.write(next);
|
|
67
|
+
log.info({ provider: next.provider }, 'model-api: route updated');
|
|
68
|
+
return publicModelConfig(next);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function saveCodex(store: Store, cfg: ModelConfig, note: string): unknown {
|
|
72
|
+
store.write(cfg);
|
|
73
|
+
log.info({ signedIn: cfg.codex.auth !== null, plan: cfg.codex.auth?.plan ?? null }, note);
|
|
74
|
+
return publicModelConfig(cfg);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const modelApiState = freshCodexState();
|
|
78
|
+
|
|
79
|
+
async function pollDevice(id: string, deps: ModelApiDeps, store: Store): Promise<unknown> {
|
|
80
|
+
const result = await pollDeviceLogin(id, deps.fetchImpl).catch(asApiError);
|
|
81
|
+
if (result.status !== 'done') return result;
|
|
82
|
+
return { status: 'done', settings: saveCodex(store, setCodexAuth(store.read(), result.tokens), 'model-api: Codex connected by device code') };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const CODEX_ROUTES: Record<string, Route> = {
|
|
86
|
+
device: {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
run: async (_req, deps) => {
|
|
89
|
+
const login = await beginDeviceLogin(deps.issuer, deps.fetchImpl).catch(asApiError);
|
|
90
|
+
return { id: login.id, user_code: login.userCode, verify_url: login.verifyUrl, interval: login.interval };
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
login: {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
run: (_req, deps) => Promise.resolve({ url: beginLogin(deps.issuer).url }),
|
|
96
|
+
},
|
|
97
|
+
callback: {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
run: async (req, deps, store) => {
|
|
100
|
+
const body = await readJsonBody(req, BODY_MAX);
|
|
101
|
+
const raw = isRecord(body) && typeof body.url === 'string' ? body.url : '';
|
|
102
|
+
const tokens = await finishLogin(raw, deps.issuer, deps.fetchImpl).catch(asApiError);
|
|
103
|
+
return saveCodex(store, setCodexAuth(store.read(), tokens), 'model-api: Codex connected');
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
logout: {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
run: (_req, _deps, store) => Promise.resolve(saveCodex(store, setCodexAuth(store.read(), null), 'model-api: Codex disconnected')),
|
|
109
|
+
},
|
|
110
|
+
import: {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
run: (_req, deps, store) => {
|
|
113
|
+
let tokens;
|
|
114
|
+
try {
|
|
115
|
+
tokens = readCodexCliAuth(deps.codexHome);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
asApiError(err);
|
|
118
|
+
}
|
|
119
|
+
return Promise.resolve(saveCodex(store, setCodexAuth(store.read(), tokens), 'model-api: Codex CLI login imported'));
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
models: {
|
|
123
|
+
method: 'GET',
|
|
124
|
+
run: async (_req, deps, store) => {
|
|
125
|
+
const cfg = store.read();
|
|
126
|
+
if (cfg.codex.auth === null) throw new ApiError('Codex is not connected: sign in with ChatGPT first', 400);
|
|
127
|
+
const codexDeps = { issuer: deps.issuer, fetchImpl: deps.fetchImpl, base: deps.codexBase, save: (t: CodexTokens) => { store.write(setCodexAuth(store.read(), t)); } };
|
|
128
|
+
const auth = await currentTokens(cfg, codexDeps, modelApiState).catch(asApiError);
|
|
129
|
+
const models = await codexModels(auth, codexDeps).catch(asApiError);
|
|
130
|
+
return { models };
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const OPENROUTER_ROUTES: Record<string, Route> = {
|
|
136
|
+
models: {
|
|
137
|
+
method: 'GET',
|
|
138
|
+
run: async (_req, deps) => ({ models: await openrouterModels(deps.openrouterBase, deps.fetchImpl).catch(asApiError) }),
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const named = (table: Record<string, Route>, name: string, method: string | undefined): Route | number => {
|
|
143
|
+
const route = table[name];
|
|
144
|
+
if (route === undefined) return 404;
|
|
145
|
+
return route.method === method ? route : 405;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
function settingsRoute(method: string | undefined): Route | number {
|
|
149
|
+
if (method === 'GET') return { method: 'GET', run: (_req, _deps, store) => Promise.resolve(publicModelConfig(store.read())) };
|
|
150
|
+
if (method === 'PUT') return { method: 'POST', run: (req, _deps, store) => update(req, store) };
|
|
151
|
+
return 405;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function codexRoute(rest: string, method: string | undefined): Route | number {
|
|
155
|
+
if (rest in CODEX_ROUTES) return named(CODEX_ROUTES, rest, method);
|
|
156
|
+
const id = rest.startsWith(DEVICE_PREFIX) ? rest.slice(DEVICE_PREFIX.length) : '';
|
|
157
|
+
if (!DEVICE_ID_RE.test(id)) return 404;
|
|
158
|
+
return method === 'GET' ? { method: 'GET', run: (_req, deps, store) => pollDevice(id, deps, store) } : 405;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const mine = (path: string): boolean => path === PATH || path.startsWith(CODEX) || path.startsWith(OPENROUTER);
|
|
162
|
+
|
|
163
|
+
function routeFor(path: string, method: string | undefined): Route | number {
|
|
164
|
+
if (path === PATH) return settingsRoute(method);
|
|
165
|
+
if (path.startsWith(OPENROUTER)) return named(OPENROUTER_ROUTES, path.slice(OPENROUTER.length), method);
|
|
166
|
+
return codexRoute(path.slice(CODEX.length), method);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function handleModelRequest(req: IncomingMessage, res: ServerResponse, deps: ModelApiDeps): boolean {
|
|
170
|
+
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
171
|
+
if (!mine(path)) return false;
|
|
172
|
+
if (req.method === 'OPTIONS') {
|
|
173
|
+
res.writeHead(204, cors(req)).end();
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
const route = routeFor(path, req.method);
|
|
177
|
+
if (typeof route === 'number') {
|
|
178
|
+
sendJson(req, res, route, { error: route === 404 ? 'not found' : 'method not allowed' });
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
const store: Store = { read: deps.read ?? readModelConfig, write: deps.write ?? writeModelConfig };
|
|
182
|
+
apiSession(req)
|
|
183
|
+
.then(async (session) => {
|
|
184
|
+
if (!session) throw new ApiError('unauthorized', 401);
|
|
185
|
+
deps.authorize(session.subject);
|
|
186
|
+
sendJson(req, res, 200, await route.run(req, deps, store));
|
|
187
|
+
})
|
|
188
|
+
.catch((err: unknown) => {
|
|
189
|
+
apiFailure(req, res, err, 'model-api');
|
|
190
|
+
});
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
@@ -16,6 +16,8 @@ import { handleBundleRequest, type BundleApiDeps } from './bundle-api.js';
|
|
|
16
16
|
import { handleUpdateRequest, type UpdateApiDeps } from './update-api.js';
|
|
17
17
|
import { handleControlRequest, type ControlApiDeps } from './control-api.js';
|
|
18
18
|
import { handleMachineRequest, type MachineApiDeps } from './machine-api.js';
|
|
19
|
+
import { handleModelRequest, type ModelApiDeps } from './model-api.js';
|
|
20
|
+
import type { GatewayDeps } from '../gateway/gateway.js';
|
|
19
21
|
import { handleTerminalRequest, type TerminalApiDeps } from './terminal-api.js';
|
|
20
22
|
|
|
21
23
|
export interface SessionApis {
|
|
@@ -26,6 +28,8 @@ export interface SessionApis {
|
|
|
26
28
|
updateApi?: UpdateApiDeps;
|
|
27
29
|
controlApi?: ControlApiDeps;
|
|
28
30
|
machineApi?: MachineApiDeps;
|
|
31
|
+
modelApi?: ModelApiDeps;
|
|
32
|
+
gateway?: GatewayDeps;
|
|
29
33
|
terminalApi?: TerminalApiDeps;
|
|
30
34
|
localCli?: LocalCliDeps;
|
|
31
35
|
claudeApi?: ClaudeApiDeps;
|
|
@@ -53,6 +57,7 @@ export function handleSessionApis(
|
|
|
53
57
|
...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
|
|
54
58
|
...when(apis.controlApi, (d) => handleControlRequest(req, res, d)),
|
|
55
59
|
...when(apis.machineApi, (d) => handleMachineRequest(req, res, d)),
|
|
60
|
+
...when(apis.modelApi, (d) => handleModelRequest(req, res, d)),
|
|
56
61
|
...when(apis.terminalApi, (d) => handleTerminalRequest(req, res, d)),
|
|
57
62
|
...when(apis.claudeApi, (d) => handleClaudeRequest(req, res, d)),
|
|
58
63
|
...when(apis.agentApi, (d) => handleAgentApiRequest(req, res, d)),
|
|
@@ -34,12 +34,22 @@ function sizeTmuxWindow(command: string[], session: string, cols: number, rows:
|
|
|
34
34
|
if (command[0] !== 'tmux') return;
|
|
35
35
|
const child = spawn('tmux', resizeWindowArgs(session, cols, rows), { stdio: 'ignore' });
|
|
36
36
|
child.on('error', (err) => {
|
|
37
|
-
log.
|
|
37
|
+
log.warn({ err: errMsg(err) }, 'terminal: resize-window could not run');
|
|
38
|
+
});
|
|
39
|
+
child.on('exit', (code) => {
|
|
40
|
+
if (code !== 0) log.warn({ code, session, cols, rows }, 'terminal: tmux refused the window resize');
|
|
38
41
|
});
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
const dimension = (raw: unknown
|
|
42
|
-
typeof raw === 'number' && Number.isInteger(raw) && raw > 1 && raw <= MAX_DIMENSION ? raw :
|
|
44
|
+
const dimension = (raw: unknown): number | null =>
|
|
45
|
+
typeof raw === 'number' && Number.isInteger(raw) && raw > 1 && raw <= MAX_DIMENSION ? raw : null;
|
|
46
|
+
|
|
47
|
+
export function sizeFrom(control: unknown): { cols: number; rows: number } | null {
|
|
48
|
+
if (!isRecord(control)) return null;
|
|
49
|
+
const cols = dimension(control.cols);
|
|
50
|
+
const rows = dimension(control.rows);
|
|
51
|
+
return cols === null || rows === null ? null : { cols, rows };
|
|
52
|
+
}
|
|
43
53
|
|
|
44
54
|
function runTerminal(ws: WebSocket, command: string[], subject: string, session: string): void {
|
|
45
55
|
const terminal = new Bun.Terminal({
|
|
@@ -61,12 +71,11 @@ function runTerminal(ws: WebSocket, command: string[], subject: string, session:
|
|
|
61
71
|
return;
|
|
62
72
|
}
|
|
63
73
|
try {
|
|
64
|
-
const
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
sizeTmuxWindow(command, session, cols, rows);
|
|
74
|
+
const size = sizeFrom(JSON.parse(bytes.toString('utf8')));
|
|
75
|
+
if (size !== null) {
|
|
76
|
+
terminal.resize(size.cols, size.rows);
|
|
77
|
+
proc.kill('SIGWINCH');
|
|
78
|
+
sizeTmuxWindow(command, session, size.cols, size.rows);
|
|
70
79
|
}
|
|
71
80
|
} catch (err) {
|
|
72
81
|
log.warn({ err: errMsg(err) }, 'terminal: bad control frame');
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { log } from '../daemon/log.js';
|
|
3
|
+
import { EventStreamDecoder, type EventStreamMessage } from './eventstream.js';
|
|
4
|
+
import { errorFrame, GatewayError, idleMessage, providerStatus, sendError, upstreamMessage, type Watch } from './forward.js';
|
|
5
|
+
import type { BedrockSettings } from './model-config.js';
|
|
6
|
+
|
|
7
|
+
const ANTHROPIC_VERSION = 'bedrock-2023-05-31';
|
|
8
|
+
const EXTRA_INPUT_RE = /^([A-Za-z0-9_]+)(?:\.[^:]*)?: Extra inputs are not permitted/;
|
|
9
|
+
const MAX_REPAIRS = 4;
|
|
10
|
+
const PING_MS = 25_000;
|
|
11
|
+
|
|
12
|
+
export interface Adaptations {
|
|
13
|
+
fields: Set<string>;
|
|
14
|
+
dropBetas: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const freshAdaptations = (): Adaptations => ({ fields: new Set(), dropBetas: false });
|
|
18
|
+
|
|
19
|
+
export interface BedrockUpstream {
|
|
20
|
+
settings: BedrockSettings;
|
|
21
|
+
base: string;
|
|
22
|
+
learned: Adaptations;
|
|
23
|
+
watch: Watch;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Rewritten {
|
|
27
|
+
modelId: string;
|
|
28
|
+
stream: boolean;
|
|
29
|
+
body: Record<string, unknown>;
|
|
30
|
+
betas: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Attempt {
|
|
34
|
+
body: Record<string, unknown>;
|
|
35
|
+
betas: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const PREFIXES: [RegExp, string][] = [
|
|
39
|
+
[/^us-gov-/, 'us-gov'],
|
|
40
|
+
[/^us-/, 'us'],
|
|
41
|
+
[/^eu-/, 'eu'],
|
|
42
|
+
[/^ap-/, 'apac'],
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export function regionPrefix(region: string): string {
|
|
46
|
+
for (const [pattern, prefix] of PREFIXES) if (pattern.test(region)) return prefix;
|
|
47
|
+
return 'global';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function bedrockModelId(requested: string, region: string): string {
|
|
51
|
+
if (requested.includes('anthropic.')) return requested;
|
|
52
|
+
return `${regionPrefix(region)}.anthropic.${requested}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const bedrockBase = (region: string): string => `https://bedrock-runtime.${region}.amazonaws.com`;
|
|
56
|
+
|
|
57
|
+
function splitBetas(header: string | string[] | undefined): string[] {
|
|
58
|
+
const raw = Array.isArray(header) ? header.join(',') : (header ?? '');
|
|
59
|
+
return raw
|
|
60
|
+
.split(',')
|
|
61
|
+
.map((b) => b.trim())
|
|
62
|
+
.filter((b) => b !== '');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function rewriteForBedrock(
|
|
66
|
+
body: Record<string, unknown>,
|
|
67
|
+
model: string,
|
|
68
|
+
betaHeader: string | string[] | undefined,
|
|
69
|
+
region: string,
|
|
70
|
+
): Rewritten {
|
|
71
|
+
const betas = splitBetas(betaHeader);
|
|
72
|
+
const out: Record<string, unknown> = {
|
|
73
|
+
...Object.fromEntries(Object.entries(body).filter(([key]) => key !== 'model' && key !== 'stream')),
|
|
74
|
+
anthropic_version: ANTHROPIC_VERSION,
|
|
75
|
+
};
|
|
76
|
+
if (betas.length > 0) out.anthropic_beta = betas;
|
|
77
|
+
return { modelId: bedrockModelId(model, region), stream: body.stream === true, body: out, betas };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function errorKind(status: number, errorType: string | null): string {
|
|
81
|
+
if (status === 429 || errorType === 'ThrottlingException') return 'rate_limit_error';
|
|
82
|
+
if (status === 400) return 'invalid_request_error';
|
|
83
|
+
if (status === 401 || status === 403) return 'permission_error';
|
|
84
|
+
if (status === 404) return 'not_found_error';
|
|
85
|
+
if (status === 503) return 'overloaded_error';
|
|
86
|
+
return 'api_error';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const invokeUrl = (up: BedrockUpstream, modelId: string, action: string): string =>
|
|
90
|
+
`${up.base}/model/${encodeURIComponent(modelId)}/${action}`;
|
|
91
|
+
|
|
92
|
+
function callBedrock(up: BedrockUpstream, url: string, body: unknown, accept: string): Promise<Response> {
|
|
93
|
+
return fetch(url, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: {
|
|
96
|
+
authorization: `Bearer ${up.settings.apiKey}`,
|
|
97
|
+
'content-type': 'application/json',
|
|
98
|
+
accept,
|
|
99
|
+
},
|
|
100
|
+
body: JSON.stringify(body),
|
|
101
|
+
signal: up.watch.signal,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const without = (body: Record<string, unknown>, keys: Set<string>): Record<string, unknown> =>
|
|
106
|
+
Object.fromEntries(Object.entries(body).filter(([key]) => !keys.has(key)));
|
|
107
|
+
|
|
108
|
+
function applyLearned(req: Rewritten, learned: Adaptations): Attempt {
|
|
109
|
+
const dropped = new Set(learned.fields);
|
|
110
|
+
if (learned.dropBetas) dropped.add('anthropic_beta');
|
|
111
|
+
return { body: without(req.body, dropped), betas: learned.dropBetas ? [] : req.betas };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function repair(attempt: Attempt, learned: Adaptations, message: string): Attempt | null {
|
|
115
|
+
const named = EXTRA_INPUT_RE.exec(message)?.[1];
|
|
116
|
+
if (named !== undefined && named in attempt.body) {
|
|
117
|
+
learned.fields.add(named);
|
|
118
|
+
log.warn({ field: named }, 'gateway: Bedrock refused an extra input; dropping it from every request');
|
|
119
|
+
return { body: without(attempt.body, new Set([named])), betas: attempt.betas };
|
|
120
|
+
}
|
|
121
|
+
if (attempt.betas.length > 0) {
|
|
122
|
+
learned.dropBetas = true;
|
|
123
|
+
log.warn({ betas: attempt.betas }, 'gateway: retrying without anthropic_beta');
|
|
124
|
+
return { body: without(attempt.body, new Set(['anthropic_beta'])), betas: [] };
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function withRepairs(up: BedrockUpstream, req: Rewritten, send: (attempt: Attempt) => Promise<Response>): Promise<Response> {
|
|
130
|
+
let attempt = applyLearned(req, up.learned);
|
|
131
|
+
for (let repairs = 0; ; repairs += 1) {
|
|
132
|
+
const res = await send(attempt);
|
|
133
|
+
if (res.status !== 400 || repairs >= MAX_REPAIRS) return res;
|
|
134
|
+
const text = await res.text();
|
|
135
|
+
const next = repair(attempt, up.learned, upstreamMessage(text, 'Bedrock returned no body'));
|
|
136
|
+
if (next === null) return new Response(text, { status: 400, headers: res.headers });
|
|
137
|
+
attempt = next;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function invoke(up: BedrockUpstream, req: Rewritten): Promise<Response> {
|
|
142
|
+
const action = req.stream ? 'invoke-with-response-stream' : 'invoke';
|
|
143
|
+
const accept = req.stream ? 'application/vnd.amazon.eventstream' : 'application/json';
|
|
144
|
+
const url = invokeUrl(up, req.modelId, action);
|
|
145
|
+
return withRepairs(up, req, (attempt) => callBedrock(up, url, attempt.body, accept));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function eventTypeOf(event: string): string {
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(event) as { type?: unknown };
|
|
151
|
+
return typeof parsed.type === 'string' ? parsed.type : 'message';
|
|
152
|
+
} catch {
|
|
153
|
+
return 'message';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function writeEvent(res: ServerResponse, message: EventStreamMessage): void {
|
|
158
|
+
if (message.headers[':message-type'] === 'event') {
|
|
159
|
+
const parsed = JSON.parse(message.payload.toString('utf8')) as { bytes?: unknown };
|
|
160
|
+
if (typeof parsed.bytes !== 'string') return;
|
|
161
|
+
const event = Buffer.from(parsed.bytes, 'base64').toString('utf8');
|
|
162
|
+
res.write(`event: ${eventTypeOf(event)}\ndata: ${event}\n\n`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const errorType = message.headers[':exception-type'] ?? message.headers[':error-code'] ?? null;
|
|
166
|
+
const body = {
|
|
167
|
+
type: 'error',
|
|
168
|
+
error: { type: errorKind(500, errorType), message: upstreamMessage(message.payload.toString('utf8'), 'Bedrock stream error') },
|
|
169
|
+
};
|
|
170
|
+
res.write(`event: error\ndata: ${JSON.stringify(body)}\n\n`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function relayStream(upstream: Response, res: ServerResponse, watch: Watch): Promise<void> {
|
|
174
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
|
|
175
|
+
const body = upstream.body;
|
|
176
|
+
if (body === null) {
|
|
177
|
+
res.end();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const ping = setInterval(() => res.write('event: ping\ndata: {"type":"ping"}\n\n'), PING_MS);
|
|
181
|
+
try {
|
|
182
|
+
const decoder = new EventStreamDecoder();
|
|
183
|
+
const reader = body.getReader();
|
|
184
|
+
for (;;) {
|
|
185
|
+
const { done, value } = await reader.read();
|
|
186
|
+
if (done) break;
|
|
187
|
+
watch.touch();
|
|
188
|
+
for (const message of decoder.push(Buffer.from(value))) writeEvent(res, message);
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
if (!watch.idle()) throw err;
|
|
192
|
+
res.write(errorFrame('api_error', idleMessage(watch.ms)));
|
|
193
|
+
} finally {
|
|
194
|
+
clearInterval(ping);
|
|
195
|
+
watch.stop();
|
|
196
|
+
res.end();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function relayFailure(upstream: Response, res: ServerResponse): Promise<void> {
|
|
201
|
+
const text = await upstream.text();
|
|
202
|
+
sendError(res, providerStatus(upstream.status), errorKind(upstream.status, upstream.headers.get('x-amzn-errortype')), upstreamMessage(text, 'Bedrock returned no body'));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function bedrockMessages(
|
|
206
|
+
req: IncomingMessage,
|
|
207
|
+
res: ServerResponse,
|
|
208
|
+
body: Record<string, unknown>,
|
|
209
|
+
model: string,
|
|
210
|
+
up: BedrockUpstream,
|
|
211
|
+
): Promise<void> {
|
|
212
|
+
const rewritten = rewriteForBedrock(body, model, req.headers['anthropic-beta'], up.settings.region);
|
|
213
|
+
const upstream = await invoke(up, rewritten);
|
|
214
|
+
if (!upstream.ok) {
|
|
215
|
+
await relayFailure(upstream, res);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (rewritten.stream) {
|
|
219
|
+
await relayStream(upstream, res, up.watch);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const text = await upstream.text();
|
|
223
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
224
|
+
res.end(text);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const estimateTokens = (body: Record<string, unknown>): number => Math.ceil(JSON.stringify(body).length / 4);
|
|
228
|
+
|
|
229
|
+
export async function bedrockCount(
|
|
230
|
+
req: IncomingMessage,
|
|
231
|
+
res: ServerResponse,
|
|
232
|
+
body: Record<string, unknown>,
|
|
233
|
+
model: string,
|
|
234
|
+
up: BedrockUpstream,
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
const rewritten = rewriteForBedrock(body, model, req.headers['anthropic-beta'], up.settings.region);
|
|
237
|
+
const url = invokeUrl(up, rewritten.modelId, 'count-tokens');
|
|
238
|
+
const upstream = await withRepairs(up, rewritten, (attempt) => callBedrock(up, url, { input: { invokeModel: { body: JSON.stringify(attempt.body) } } }, 'application/json'));
|
|
239
|
+
const text = await upstream.text();
|
|
240
|
+
let counted: number | null = null;
|
|
241
|
+
if (upstream.ok) {
|
|
242
|
+
const parsed = JSON.parse(text) as { inputTokens?: unknown };
|
|
243
|
+
if (typeof parsed.inputTokens === 'number') counted = parsed.inputTokens;
|
|
244
|
+
}
|
|
245
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
246
|
+
res.end(JSON.stringify({ input_tokens: counted ?? estimateTokens(rewritten.body) }));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function assertBedrockReady(settings: BedrockSettings): void {
|
|
250
|
+
if (settings.apiKey === '' || settings.region === '')
|
|
251
|
+
throw new GatewayError(400, 'invalid_request_error', 'Bedrock needs an API key and a region: add them on the Model page.');
|
|
252
|
+
}
|