@stage-labs/metro 0.1.0-beta.321 → 0.1.0-beta.322
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/claude-env.js +2 -2
- package/dist/cli.js +1 -1
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +12 -12
- package/runtime/node_modules/@metro-labs/daemon/package.json +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/box-gateway.ts +32 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +2 -8
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +6 -6
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/org-models.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/relay.ts +108 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +1 -2
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +1 -2
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +14 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +10 -1
- package/runtime/node_modules/@metro-labs/http/src/mcp-server-tools.ts +21 -7
- package/runtime/node_modules/@metro-labs/inference/package.json +42 -0
- package/runtime/node_modules/@metro-labs/inference/src/attempts.ts +120 -0
- package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/codex.ts +1 -1
- package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/delivered.ts +7 -1
- package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/effort.ts +2 -2
- package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/failures.ts +11 -1
- package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/fallback.ts +5 -4
- package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/forward.ts +1 -1
- package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/gateway.ts +47 -37
- package/runtime/node_modules/@metro-labs/inference/src/measure.ts +42 -0
- package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/model-config.ts +9 -9
- package/runtime/node_modules/@metro-labs/inference/src/named.ts +36 -0
- package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/routeway.ts +2 -1
- package/runtime/node_modules/@metro-labs/inference/src/served.ts +18 -0
- package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/usage.ts +34 -9
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +12 -12
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/sdk-runner/src/app.ts +1 -1
- package/runtime/sdk-runner/src/runner.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/served.ts +0 -18
- /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/bedrock.ts +0 -0
- /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/cache-ttl.ts +0 -0
- /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/codex-stream.ts +0 -0
- /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/codex-translate.ts +0 -0
- /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/codex-version.ts +0 -0
- /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/eventstream.ts +0 -0
- /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/frames.ts +0 -0
- /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/openrouter.ts +0 -0
- /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/subscription.ts +0 -0
- /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/text.ts +0 -0
- /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/tool-search.ts +0 -0
- /package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/usage-refresh.ts +0 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import type { OutgoingHttpHeader, OutgoingHttpHeaders, ServerResponse } from 'node:http';
|
|
3
|
+
import type { RequestKind } from './effort.js';
|
|
4
|
+
import type { Route } from './model-config.js';
|
|
5
|
+
|
|
6
|
+
export type AttemptOutcome = 'ok' | 'rate_limited' | 'refused' | 'provider_error' | 'cancelled';
|
|
7
|
+
|
|
8
|
+
export type AttemptKind = 'main' | 'subagent' | 'compaction' | 'chore';
|
|
9
|
+
|
|
10
|
+
export interface AttemptTokens {
|
|
11
|
+
input: number;
|
|
12
|
+
output: number;
|
|
13
|
+
cacheRead: number;
|
|
14
|
+
cacheWrite5m: number;
|
|
15
|
+
cacheWrite1h: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AttemptCall {
|
|
19
|
+
connection: string;
|
|
20
|
+
provider: string;
|
|
21
|
+
model: string;
|
|
22
|
+
lane: 'main' | 'workers';
|
|
23
|
+
kind: AttemptKind;
|
|
24
|
+
attempt: number;
|
|
25
|
+
status: number;
|
|
26
|
+
outcome: AttemptOutcome;
|
|
27
|
+
tokens: AttemptTokens;
|
|
28
|
+
providerCost: number | null;
|
|
29
|
+
ms: number;
|
|
30
|
+
firstTokenMs: number | null;
|
|
31
|
+
at: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type OnCall = (call: AttemptCall) => void;
|
|
35
|
+
|
|
36
|
+
const KINDS: Record<RequestKind, AttemptKind> = { main: 'main', subagent: 'subagent', compact: 'compaction', other: 'chore' };
|
|
37
|
+
|
|
38
|
+
const NO_TOKENS: AttemptTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite5m: 0, cacheWrite1h: 0 };
|
|
39
|
+
|
|
40
|
+
export class Attempt {
|
|
41
|
+
private readonly started = Date.now();
|
|
42
|
+
private firstAt: number | null = null;
|
|
43
|
+
private status: number | null = null;
|
|
44
|
+
private failedMidStream = false;
|
|
45
|
+
private tokens: AttemptTokens = NO_TOKENS;
|
|
46
|
+
private cost: number | null = null;
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
private readonly route: Route,
|
|
50
|
+
private readonly lane: 'main' | 'helpers',
|
|
51
|
+
private readonly kind: RequestKind,
|
|
52
|
+
private readonly index: number,
|
|
53
|
+
) {}
|
|
54
|
+
|
|
55
|
+
firstByte(): void {
|
|
56
|
+
this.firstAt ??= Date.now();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
answered(status: number): void {
|
|
60
|
+
this.status ??= status;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
failed(): void {
|
|
64
|
+
this.failedMidStream = true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
counted(tokens: AttemptTokens, cost: number | null): void {
|
|
68
|
+
this.tokens = tokens;
|
|
69
|
+
this.cost = cost;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
call(fallbackStatus: number, cancelled: boolean): AttemptCall {
|
|
73
|
+
const status = this.status ?? fallbackStatus;
|
|
74
|
+
const ended = Date.now();
|
|
75
|
+
return {
|
|
76
|
+
connection: this.route.connection.id,
|
|
77
|
+
provider: this.route.connection.provider,
|
|
78
|
+
model: this.route.model,
|
|
79
|
+
lane: this.lane === 'helpers' ? 'workers' : 'main',
|
|
80
|
+
kind: KINDS[this.kind],
|
|
81
|
+
attempt: this.index,
|
|
82
|
+
status,
|
|
83
|
+
outcome: cancelled ? 'cancelled' : outcomeOf(status, this.failedMidStream),
|
|
84
|
+
tokens: this.tokens,
|
|
85
|
+
providerCost: this.cost,
|
|
86
|
+
ms: ended - this.started,
|
|
87
|
+
firstTokenMs: this.firstAt === null ? null : this.firstAt - this.started,
|
|
88
|
+
at: ended,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function outcomeOf(status: number, failedMidStream: boolean): AttemptOutcome {
|
|
94
|
+
if (status === 402 || status === 429) return 'rate_limited';
|
|
95
|
+
if (status >= 500) return 'provider_error';
|
|
96
|
+
if (status >= 400) return 'refused';
|
|
97
|
+
return failedMidStream ? 'provider_error' : 'ok';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const current = new AsyncLocalStorage<Attempt>();
|
|
101
|
+
|
|
102
|
+
export const currentAttempt = (): Attempt | undefined => current.getStore();
|
|
103
|
+
|
|
104
|
+
export const inAttempt = <T>(attempt: Attempt, run: () => Promise<T>): Promise<T> => current.run(attempt, run);
|
|
105
|
+
|
|
106
|
+
export function tracked(res: ServerResponse, attempt: Attempt): ServerResponse {
|
|
107
|
+
const proxy: ServerResponse = new Proxy(res, {
|
|
108
|
+
get(target, prop) {
|
|
109
|
+
if (prop === 'writeHead')
|
|
110
|
+
return (status: number, headers?: OutgoingHttpHeaders | OutgoingHttpHeader[]): ServerResponse => {
|
|
111
|
+
attempt.answered(status);
|
|
112
|
+
target.writeHead(status, headers);
|
|
113
|
+
return proxy;
|
|
114
|
+
};
|
|
115
|
+
const value: unknown = Reflect.get(target, prop, target);
|
|
116
|
+
return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(target) : value;
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
return proxy;
|
|
120
|
+
}
|
|
@@ -18,7 +18,7 @@ import { noteUsageHeaders } from './usage.js';
|
|
|
18
18
|
const CODEX_BACKEND = 'https://chatgpt.com/backend-api/codex';
|
|
19
19
|
const OPENAI_API = 'https://api.openai.com/v1';
|
|
20
20
|
const INVALID = [400, 404, 422];
|
|
21
|
-
const RENEW_FAILED = "Codex refused its sign-in and api.metro.box could not renew it: sign in again on the organization's
|
|
21
|
+
const RENEW_FAILED = "Codex refused its sign-in and api.metro.box could not renew it: sign in again on the organization's Gateway page.";
|
|
22
22
|
|
|
23
23
|
export interface CodexDeps {
|
|
24
24
|
base?: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readEffort, type RouteEffort } from '@metro-labs/core/effort';
|
|
2
2
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
-
import { isProvider, type Account, type Chain, type CodexAccess, type Connection, type Fallback, type Provider, type RouteSource } from './model-config.js';
|
|
3
|
+
import { emptySetup, isProvider, type ModelSetup, type Account, type Chain, type CodexAccess, type Connection, type Fallback, type Provider, type RouteSource } from './model-config.js';
|
|
4
4
|
|
|
5
5
|
export interface DeliveredRoute {
|
|
6
6
|
primary: Fallback | null;
|
|
@@ -100,3 +100,9 @@ export function deliveredOf(raw: Record<string, unknown>): Delivered | null {
|
|
|
100
100
|
export function refreshedConnection(raw: Record<string, unknown>): Connection | null {
|
|
101
101
|
return deliveredConnection(isRecord(raw.connection) ? raw.connection : raw);
|
|
102
102
|
}
|
|
103
|
+
|
|
104
|
+
export function setupOf(delivered: Delivered | null): ModelSetup {
|
|
105
|
+
const route = delivered?.route ?? null;
|
|
106
|
+
if (delivered === null || route === null) return { ...emptySetup(), source: delivered?.source ?? null };
|
|
107
|
+
return { primary: route.primary, fallbacks: route.fallbacks, connections: delivered.connections, source: delivered.source, helpers: route.helpers, effort: route.effort };
|
|
108
|
+
}
|
|
@@ -70,10 +70,10 @@ function wantedEffort(kind: RequestKind, setting: RouteEffort): string | null {
|
|
|
70
70
|
return wanted === 'asked' ? null : wanted;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
export function plannedEffort(req: IncomingMessage, body: Body, setting: RouteEffort = {}): string | null {
|
|
73
|
+
export function plannedEffort(req: IncomingMessage, body: Body, setting: RouteEffort = {}, kind: RequestKind = requestKind(req, body)): string | null {
|
|
74
74
|
const asked = askedEfforts(body);
|
|
75
75
|
if (asked.length === 0) return null;
|
|
76
|
-
const wanted = wantedEffort(
|
|
76
|
+
const wanted = wantedEffort(kind, setting);
|
|
77
77
|
if (wanted === null) return null;
|
|
78
78
|
return asked.every((effort) => effort === wanted) ? null : wanted;
|
|
79
79
|
}
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
2
|
import { log } from '@metro-labs/core/log';
|
|
3
3
|
import { stringOf } from '@metro-labs/http/api-http';
|
|
4
|
+
import { currentAttempt } from './attempts.js';
|
|
4
5
|
import { parseEvent, SseParser } from './frames.js';
|
|
5
6
|
import type { Route } from './model-config.js';
|
|
6
7
|
|
|
7
8
|
const MESSAGE_MAX = 300;
|
|
9
|
+
|
|
10
|
+
let keepProviderText = true;
|
|
11
|
+
|
|
12
|
+
export const quietProviderText = (): void => {
|
|
13
|
+
keepProviderText = false;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const providerText = (text: string): string | undefined => (keepProviderText ? text.slice(0, MESSAGE_MAX) : undefined);
|
|
8
17
|
const ERROR_DATA = /^\{\s*"type"\s*:\s*"error"/;
|
|
9
18
|
|
|
10
19
|
export interface FailureSite {
|
|
@@ -21,7 +30,8 @@ export interface Failure {
|
|
|
21
30
|
export const siteOf = (route: Route): FailureSite => ({ connection: route.connection.label, model: route.model });
|
|
22
31
|
|
|
23
32
|
export function noteFailure(site: FailureSite, failure: Failure, what: string): void {
|
|
24
|
-
|
|
33
|
+
currentAttempt()?.failed();
|
|
34
|
+
log.warn({ connection: site.connection, model: site.model, ...failure, message: providerText(failure.message) }, what);
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
export class StreamErrors {
|
|
@@ -21,7 +21,7 @@ interface Cooling {
|
|
|
21
21
|
export type Lane = 'main' | 'helpers';
|
|
22
22
|
|
|
23
23
|
const cooling = new Map<string, Cooling>();
|
|
24
|
-
const lastChoice = new Map<
|
|
24
|
+
const lastChoice = new Map<string, string>();
|
|
25
25
|
|
|
26
26
|
const keyOf = (route: Route): string => `${route.connection.id}\n${route.model}`;
|
|
27
27
|
|
|
@@ -98,11 +98,12 @@ export function noteRefused(route: Route, status: number, headers: Headers, next
|
|
|
98
98
|
log.warn({ from: routeLabel(route), to: next === undefined ? null : routeLabel(next), status }, why);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
export function noteChoice(primary: Route, chosen: Route, lane: Lane, now = Date.now()): void {
|
|
101
|
+
export function noteChoice(primary: Route, chosen: Route, lane: Lane, now = Date.now(), scope = ''): void {
|
|
102
102
|
const key = keyOf(chosen);
|
|
103
|
-
const
|
|
103
|
+
const at = `${scope}\n${lane}`;
|
|
104
|
+
const last = lastChoice.get(at);
|
|
104
105
|
if (key === last) return;
|
|
105
|
-
lastChoice.set(
|
|
106
|
+
lastChoice.set(at, key);
|
|
106
107
|
if (key === keyOf(primary)) {
|
|
107
108
|
if (last !== undefined) log.info({ lane, model: routeLabel(chosen), connection: chosen.connection.label }, 'gateway: back on the first model of the fallback list');
|
|
108
109
|
return;
|
|
@@ -34,7 +34,7 @@ const RESPONSE_DROP = new Set(['content-length', 'content-encoding', 'transfer-e
|
|
|
34
34
|
|
|
35
35
|
export class SubscriptionRefused extends GatewayError {
|
|
36
36
|
constructor(label: string) {
|
|
37
|
-
super(403, 'permission_error', `Anthropic refused the Claude subscription ${label}: sign in again on the
|
|
37
|
+
super(403, 'permission_error', `Anthropic refused the Claude subscription ${label}: sign in again on the Gateway page`);
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
3
|
import { cameThroughProxy } from '@metro-labs/http/api-http';
|
|
4
|
-
import { agentIdForKey } from '../agents/keys.js';
|
|
5
4
|
import {
|
|
6
5
|
bedrockBase,
|
|
7
6
|
bedrockCount,
|
|
@@ -36,11 +35,16 @@ import {
|
|
|
36
35
|
helperRequest,
|
|
37
36
|
learnAlwaysThinks,
|
|
38
37
|
plannedEffort,
|
|
38
|
+
requestKind,
|
|
39
39
|
thinkingOff,
|
|
40
40
|
withBlockBinding,
|
|
41
41
|
withEffort,
|
|
42
42
|
withThinkingFor,
|
|
43
|
+
type RequestKind,
|
|
43
44
|
} from './effort.js';
|
|
45
|
+
import { currentAttempt, type OnCall } from './attempts.js';
|
|
46
|
+
import { measured } from './measure.js';
|
|
47
|
+
import { NAMED_MODELS, namedChoice, WORKERS, type Chosen } from './named.js';
|
|
44
48
|
import { helperSetup, notReady, pinnedRoute, resolveRoute, routeLabel, type Connection, type ModelSetup, type Route } from './model-config.js';
|
|
45
49
|
import { codexMessages, type CodexDeps } from './codex.js';
|
|
46
50
|
import { countTokens } from './subscription.js';
|
|
@@ -50,8 +54,7 @@ import { isRecord } from '@metro-labs/core/is-record';
|
|
|
50
54
|
import { forgetServed, noteServed } from './served.js';
|
|
51
55
|
import { forgetUsage, noteUsageHeaders, UsageScanner } from './usage.js';
|
|
52
56
|
import { fitToolSearch } from './tool-search.js';
|
|
53
|
-
import { siteOf } from './failures.js';
|
|
54
|
-
import { orgModels } from './org-models.js';
|
|
57
|
+
import { providerText, siteOf } from './failures.js';
|
|
55
58
|
|
|
56
59
|
const GATEWAY_PREFIX = '/gateway';
|
|
57
60
|
const MESSAGES = '/v1/messages';
|
|
@@ -59,20 +62,28 @@ const COUNT = '/v1/messages/count_tokens';
|
|
|
59
62
|
const MODELS = '/v1/models';
|
|
60
63
|
const HELLO = '/api/hello';
|
|
61
64
|
const ANTHROPIC_API = 'https://api.anthropic.com';
|
|
62
|
-
const NO_ROUTE = "metro gateway: no model is set up for this agent yet: enroll it with its organization and choose a model on the organization's
|
|
65
|
+
const NO_ROUTE = "metro gateway: no model is set up for this agent yet: enroll it with its organization and choose a model on the organization's Gateway page";
|
|
63
66
|
|
|
64
|
-
export interface
|
|
67
|
+
export interface ModelDeps {
|
|
65
68
|
setup: () => ModelSetup;
|
|
66
|
-
identify?: (key: string) => boolean;
|
|
67
69
|
anthropicBase?: string;
|
|
68
70
|
bedrockBase?: string;
|
|
69
71
|
openrouterBase?: string;
|
|
70
72
|
routewayBase?: string;
|
|
71
73
|
codex?: Partial<CodexDeps>;
|
|
74
|
+
onCall?: OnCall;
|
|
75
|
+
scope?: string;
|
|
76
|
+
named?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface GatewayDeps extends ModelDeps {
|
|
80
|
+
identify: (key: string) => boolean;
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
const learned: Adaptations = freshAdaptations();
|
|
75
84
|
|
|
85
|
+
const noRefresh = (): Promise<Connection | null> => Promise.resolve(null);
|
|
86
|
+
|
|
76
87
|
export function resetGatewayState(): void {
|
|
77
88
|
forgetServed();
|
|
78
89
|
forgetFallbackState();
|
|
@@ -81,15 +92,11 @@ export function resetGatewayState(): void {
|
|
|
81
92
|
forgetLearnedThinking();
|
|
82
93
|
}
|
|
83
94
|
|
|
84
|
-
const refreshCodex = (conn: Connection): Promise<Connection | null> => orgModels()?.refresh(conn) ?? Promise.resolve(null);
|
|
85
|
-
|
|
86
95
|
const keyOf = (req: IncomingMessage): string => {
|
|
87
96
|
const raw = req.headers['x-metro-key'];
|
|
88
97
|
return (Array.isArray(raw) ? raw[0] : raw)?.trim() ?? '';
|
|
89
98
|
};
|
|
90
99
|
|
|
91
|
-
const defaultIdentify = (key: string): boolean => key !== '' && agentIdForKey(key) !== undefined;
|
|
92
|
-
|
|
93
100
|
function routesOf(setup: ModelSetup): Route[] {
|
|
94
101
|
return [...(setup.primary === null ? [] : [setup.primary]), ...setup.fallbacks].flatMap((ref) => {
|
|
95
102
|
const conn = setup.connections.find((c) => c.id === ref.connection);
|
|
@@ -145,7 +152,7 @@ async function asAnthropicWants(send: Send, base: Record<string, string>, payloa
|
|
|
145
152
|
const { metro, asSent, bound } = payloads;
|
|
146
153
|
const upstream = await send(metro, bound ? addBeta(base, BINDING_BETA) : base);
|
|
147
154
|
if (upstream.status !== 400 || asSent === null) return upstream;
|
|
148
|
-
const reason = upstreamMessage(await upstream.text().catch(() => ''), '')
|
|
155
|
+
const reason = providerText(upstreamMessage(await upstream.text().catch(() => ''), ''));
|
|
149
156
|
log.warn({ connection, model, reason }, 'gateway: the provider refused the request metro shaped, so it was sent again as Claude Code wrote it');
|
|
150
157
|
return send(asSent, base);
|
|
151
158
|
}
|
|
@@ -174,9 +181,12 @@ async function shapedAttempt(send: Send, base: Record<string, string>, body: Sha
|
|
|
174
181
|
const sender = (url: string, watch: Watch): Send => (payload, headers) =>
|
|
175
182
|
fetch(url, { method: 'POST', headers, body: new Uint8Array(payload), signal: watch.signal, redirect: 'manual' });
|
|
176
183
|
|
|
177
|
-
const upstreamPath = (req: IncomingMessage): string =>
|
|
184
|
+
const upstreamPath = (req: IncomingMessage): string => {
|
|
185
|
+
const url = req.url ?? '';
|
|
186
|
+
return url.startsWith(`${GATEWAY_PREFIX}/`) ? url.slice(GATEWAY_PREFIX.length) : url;
|
|
187
|
+
};
|
|
178
188
|
|
|
179
|
-
async function toAnthropic(req: IncomingMessage, res: ServerResponse, body: Shaped, route: Route, deps:
|
|
189
|
+
async function toAnthropic(req: IncomingMessage, res: ServerResponse, body: Shaped, route: Route, deps: ModelDeps): Promise<void> {
|
|
180
190
|
const conn = route.connection;
|
|
181
191
|
const watch = watchUpstream(res);
|
|
182
192
|
const send = sender(`${deps.anthropicBase ?? ANTHROPIC_API}${upstreamPath(req)}`, watch);
|
|
@@ -187,7 +197,7 @@ async function toAnthropic(req: IncomingMessage, res: ServerResponse, body: Shap
|
|
|
187
197
|
await pipeResponse(upstream, res, watch, { ownCredential: true, scanner: new UsageScanner(conn.id), site: siteOf(route) });
|
|
188
198
|
}
|
|
189
199
|
|
|
190
|
-
async function toRouteway(req: IncomingMessage, res: ServerResponse, body: Shaped, route: Route, deps:
|
|
200
|
+
async function toRouteway(req: IncomingMessage, res: ServerResponse, body: Shaped, route: Route, deps: ModelDeps): Promise<void> {
|
|
191
201
|
const conn = route.connection;
|
|
192
202
|
const watch = watchUpstream(res);
|
|
193
203
|
const send = sender(`${deps.routewayBase ?? ROUTEWAY_BASE}${upstreamPath(req)}`, watch);
|
|
@@ -200,7 +210,7 @@ async function toRouteway(req: IncomingMessage, res: ServerResponse, body: Shape
|
|
|
200
210
|
await pipeResponse(upstream, res, watch, { keepalive: true, ownCredential: true, scanner: new UsageScanner(conn.id), site: siteOf(route) });
|
|
201
211
|
}
|
|
202
212
|
|
|
203
|
-
async function toOpenRouter(req: IncomingMessage, res: ServerResponse, body: Record<string, unknown>, route: Route, deps:
|
|
213
|
+
async function toOpenRouter(req: IncomingMessage, res: ServerResponse, body: Record<string, unknown>, route: Route, deps: ModelDeps): Promise<void> {
|
|
204
214
|
const conn = route.connection;
|
|
205
215
|
const headers: Record<string, string> = {
|
|
206
216
|
'content-type': 'application/json',
|
|
@@ -238,22 +248,16 @@ function noteRefusal(connection: string, model: string, upstream: Response): voi
|
|
|
238
248
|
if (!upstream.ok) log.warn({ connection, model, status: upstream.status }, 'gateway: the provider refused the request');
|
|
239
249
|
}
|
|
240
250
|
|
|
241
|
-
async function toCodex(req: IncomingMessage, res: ServerResponse, path: string, body: Record<string, unknown>, route: Route, deps:
|
|
251
|
+
async function toCodex(req: IncomingMessage, res: ServerResponse, path: string, body: Record<string, unknown>, route: Route, deps: ModelDeps): Promise<void> {
|
|
242
252
|
if (path === COUNT) countTokens(res, body);
|
|
243
|
-
else await codexMessages(req, res, body, route.model, route.connection, { refresh:
|
|
253
|
+
else await codexMessages(req, res, body, route.model, route.connection, { refresh: noRefresh, ...deps.codex }, watchUpstream(res));
|
|
244
254
|
}
|
|
245
255
|
|
|
246
|
-
function shapedFor(req: IncomingMessage, sent: Record<string, unknown>, setup: ModelSetup): Record<string, unknown> {
|
|
247
|
-
const effort = plannedEffort(req, sent, setup.effort);
|
|
256
|
+
function shapedFor(req: IncomingMessage, sent: Record<string, unknown>, setup: ModelSetup, kind: RequestKind): Record<string, unknown> {
|
|
257
|
+
const effort = plannedEffort(req, sent, setup.effort, kind);
|
|
248
258
|
return effort === null ? sent : withEffort(sent, effort);
|
|
249
259
|
}
|
|
250
260
|
|
|
251
|
-
interface Chosen {
|
|
252
|
-
lane: Lane;
|
|
253
|
-
setup: ModelSetup;
|
|
254
|
-
primary: Route;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
261
|
function chosenChain(req: IncomingMessage, sent: Record<string, unknown>, setup: ModelSetup, requested: string): Chosen {
|
|
258
262
|
const helpers = helperRequest(req, sent) ? helperSetup(setup) : null;
|
|
259
263
|
if (helpers === null) return { lane: 'main', setup, primary: resolveRoute(requested, setup) ?? noRoute() };
|
|
@@ -264,6 +268,8 @@ const bytesOf = (raw: Buffer, parsed: Record<string, unknown>, sent: Record<stri
|
|
|
264
268
|
|
|
265
269
|
interface Request {
|
|
266
270
|
path: string;
|
|
271
|
+
lane: Lane;
|
|
272
|
+
kind: RequestKind;
|
|
267
273
|
raw: Buffer;
|
|
268
274
|
parsed: Record<string, unknown>;
|
|
269
275
|
sent: Record<string, unknown>;
|
|
@@ -271,13 +277,13 @@ interface Request {
|
|
|
271
277
|
setup: ModelSetup;
|
|
272
278
|
}
|
|
273
279
|
|
|
274
|
-
async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request, route: Route, deps:
|
|
280
|
+
async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request, route: Route, deps: ModelDeps): Promise<void> {
|
|
275
281
|
const { path, body } = ask;
|
|
276
282
|
const conn = route.connection;
|
|
277
283
|
log.info({ route: routeLabel(route), connection: conn.label, path }, 'gateway: routing');
|
|
278
284
|
const reason = notReady(route);
|
|
279
285
|
if (reason !== null) throw new GatewayError(400, 'invalid_request_error', reason);
|
|
280
|
-
if (path === MESSAGES) noteServed({ connection: conn.id, provider: conn.provider, model: route.model, at: new Date().toISOString() });
|
|
286
|
+
if (path === MESSAGES) noteServed({ connection: conn.id, provider: conn.provider, model: route.model, at: new Date().toISOString() }, deps.scope);
|
|
281
287
|
if (conn.provider === 'bedrock') {
|
|
282
288
|
const up = { settings: conn, base: deps.bedrockBase ?? bedrockBase(conn.region), learned, watch: watchUpstream(res) };
|
|
283
289
|
if (path === COUNT) await bedrockCount(req, res, body, route.model, up);
|
|
@@ -297,33 +303,37 @@ async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request,
|
|
|
297
303
|
await (conn.provider === 'routeway' ? toRouteway : toAnthropic)(req, res, shaped, route, deps);
|
|
298
304
|
}
|
|
299
305
|
|
|
300
|
-
async function heldAttempt(req: IncomingMessage, held: Held, ask: Request, route: Route, deps:
|
|
306
|
+
async function heldAttempt(req: IncomingMessage, held: Held, ask: Request, route: Route, deps: ModelDeps): Promise<Refusal | null> {
|
|
301
307
|
try {
|
|
302
308
|
await attempt(req, held.res, ask, route, deps);
|
|
303
309
|
} catch (err) {
|
|
304
310
|
if (!(err instanceof SubscriptionRefused)) throw err;
|
|
311
|
+
currentAttempt()?.answered(401);
|
|
305
312
|
return { status: 401, headers: new Headers() };
|
|
306
313
|
}
|
|
307
314
|
return held.refusal();
|
|
308
315
|
}
|
|
309
316
|
|
|
310
|
-
async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps:
|
|
317
|
+
async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: ModelDeps): Promise<void> {
|
|
311
318
|
const setup = deps.setup();
|
|
312
319
|
const raw = await readBody(req);
|
|
313
320
|
const parsed = parseJson(raw);
|
|
314
321
|
const sent = fitToolSearch(req, parsed);
|
|
315
322
|
const requested = requestedModel(sent);
|
|
316
|
-
const
|
|
317
|
-
const
|
|
323
|
+
const named = deps.named === true ? namedChoice(requested, setup) : null;
|
|
324
|
+
const { lane, setup: chain, primary } = named ?? chosenChain(req, sent, setup, requested);
|
|
325
|
+
const kind: RequestKind = named === null ? requestKind(req, sent) : requested === WORKERS ? 'subagent' : 'main';
|
|
326
|
+
const ask: Request = { path, lane, kind, raw, parsed, sent, body: shapedFor(req, sent, setup, kind), setup };
|
|
318
327
|
const routes = routesToTry(chainOf(primary, chain, requested));
|
|
319
328
|
for (const [at, route] of routes.entries()) {
|
|
320
329
|
const next = routes[at + 1];
|
|
321
|
-
if (path === MESSAGES) noteChoice(primary, route, lane);
|
|
330
|
+
if (path === MESSAGES) noteChoice(primary, route, lane, Date.now(), deps.scope);
|
|
322
331
|
if (next === undefined) {
|
|
323
|
-
await attempt(req,
|
|
332
|
+
await measured(res, ask, route, at, deps.onCall, (out) => attempt(req, out, ask, route, deps));
|
|
324
333
|
return;
|
|
325
334
|
}
|
|
326
|
-
const
|
|
335
|
+
const held = holdLimitRefusal(res);
|
|
336
|
+
const refusal = await measured(held.res, ask, route, at, deps.onCall, (out) => heldAttempt(req, { res: out, refusal: held.refusal }, ask, route, deps));
|
|
327
337
|
if (refusal === null) return;
|
|
328
338
|
noteRefused(route, refusal.status, refusal.headers, next);
|
|
329
339
|
}
|
|
@@ -342,10 +352,10 @@ function failed(res: ServerResponse, err: unknown): void {
|
|
|
342
352
|
sendError(res, 502, 'api_error', `metro gateway: ${errMsg(err)}`);
|
|
343
353
|
}
|
|
344
354
|
|
|
345
|
-
function
|
|
355
|
+
export function answerModels(req: IncomingMessage, res: ServerResponse, path: string, deps: ModelDeps): void {
|
|
346
356
|
if (req.method === 'GET' && path === MODELS) {
|
|
347
357
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
348
|
-
res.end(JSON.stringify(modelsBody(deps.setup())));
|
|
358
|
+
res.end(JSON.stringify(deps.named === true ? NAMED_MODELS : modelsBody(deps.setup())));
|
|
349
359
|
return;
|
|
350
360
|
}
|
|
351
361
|
if (req.method === 'POST' && (path === MESSAGES || path === COUNT)) {
|
|
@@ -366,7 +376,7 @@ export function handleGatewayRequest(req: IncomingMessage, res: ServerResponse,
|
|
|
366
376
|
return true;
|
|
367
377
|
}
|
|
368
378
|
if (cameThroughProxy(req)) sendError(res, 401, 'authentication_error', 'metro gateway: the agent key works only on this machine, not through its public address');
|
|
369
|
-
else if ((deps.identify
|
|
379
|
+
else if (keyOf(req) !== '' && deps.identify(keyOf(req))) answerModels(req, res, path, deps);
|
|
370
380
|
else sendError(res, 401, 'authentication_error', 'metro gateway: no agent key (x-metro-key); start the agent with metro agent');
|
|
371
381
|
return true;
|
|
372
382
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ServerResponse } from 'node:http';
|
|
2
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import { Attempt, inAttempt, tracked, type AttemptCall, type OnCall } from './attempts.js';
|
|
4
|
+
import type { RequestKind } from './effort.js';
|
|
5
|
+
import { GatewayError, SubscriptionRefused } from './forward.js';
|
|
6
|
+
import type { Route } from './model-config.js';
|
|
7
|
+
|
|
8
|
+
const MESSAGES = '/v1/messages';
|
|
9
|
+
|
|
10
|
+
export interface Asked {
|
|
11
|
+
path: string;
|
|
12
|
+
lane: 'main' | 'helpers';
|
|
13
|
+
kind: RequestKind;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function statusOf(failure: unknown, res: ServerResponse): number {
|
|
17
|
+
if (failure === null) return res.statusCode;
|
|
18
|
+
if (failure instanceof GatewayError) return failure.status;
|
|
19
|
+
return failure instanceof SubscriptionRefused ? 401 : 502;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function report(onCall: OnCall, call: AttemptCall): void {
|
|
23
|
+
try {
|
|
24
|
+
onCall(call);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
log.warn({ err: errMsg(err) }, 'gateway: recording a call failed');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function measured<T>(res: ServerResponse, ask: Asked, route: Route, at: number, onCall: OnCall | undefined, run: (out: ServerResponse) => Promise<T>): Promise<T> {
|
|
31
|
+
if (ask.path !== MESSAGES || onCall === undefined) return run(res);
|
|
32
|
+
const counted = new Attempt(route, ask.lane, ask.kind, at);
|
|
33
|
+
let failure: unknown = null;
|
|
34
|
+
try {
|
|
35
|
+
return await inAttempt(counted, () => run(tracked(res, counted)));
|
|
36
|
+
} catch (err) {
|
|
37
|
+
failure = err;
|
|
38
|
+
throw err;
|
|
39
|
+
} finally {
|
|
40
|
+
report(onCall, counted.call(statusOf(failure, res), res.destroyed && !res.writableFinished));
|
|
41
|
+
}
|
|
42
|
+
}
|
package/runtime/node_modules/@metro-labs/{daemon/src/gateway → inference/src}/model-config.ts
RENAMED
|
@@ -57,7 +57,7 @@ export interface Route {
|
|
|
57
57
|
model: string;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
export const CODEX_SIGN_IN = "Codex is not signed in: sign in again on the organization's
|
|
60
|
+
export const CODEX_SIGN_IN = "Codex is not signed in: sign in again on the organization's Gateway page.";
|
|
61
61
|
|
|
62
62
|
const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex|routeway):(.+)$/;
|
|
63
63
|
const SMALL_RE = /haiku/i;
|
|
@@ -78,22 +78,22 @@ export function primaryRoute(setup: ModelSetup): Route | null {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
const CHECKS: Record<Provider, [(r: Route) => boolean, string][]> = {
|
|
81
|
-
anthropic: [[(r) => r.connection.apiKey === '' && r.connection.claudeToken === '', "Anthropic has no API key or Claude subscription: add one on the organization's
|
|
81
|
+
anthropic: [[(r) => r.connection.apiKey === '' && r.connection.claudeToken === '', "Anthropic has no API key or Claude subscription: add one on the organization's Gateway page."]],
|
|
82
82
|
bedrock: [
|
|
83
|
-
[(r) => r.connection.apiKey === '', "Bedrock has no API key: add it on the organization's
|
|
84
|
-
[(r) => r.connection.region === '', "Bedrock needs a region: add it on the organization's
|
|
83
|
+
[(r) => r.connection.apiKey === '', "Bedrock has no API key: add it on the organization's Gateway page."],
|
|
84
|
+
[(r) => r.connection.region === '', "Bedrock needs a region: add it on the organization's Gateway page."],
|
|
85
85
|
],
|
|
86
86
|
openrouter: [
|
|
87
|
-
[(r) => r.connection.apiKey === '', "OpenRouter has no API key: add it on the organization's
|
|
88
|
-
[(r) => r.model === '', "OpenRouter needs a model id: choose one on the organization's
|
|
87
|
+
[(r) => r.connection.apiKey === '', "OpenRouter has no API key: add it on the organization's Gateway page."],
|
|
88
|
+
[(r) => r.model === '', "OpenRouter needs a model id: choose one on the organization's Gateway page."],
|
|
89
89
|
],
|
|
90
90
|
codex: [
|
|
91
91
|
[(r) => r.connection.codex === null, CODEX_SIGN_IN],
|
|
92
|
-
[(r) => r.model === '', "Codex needs a model id: choose one on the organization's
|
|
92
|
+
[(r) => r.model === '', "Codex needs a model id: choose one on the organization's Gateway page."],
|
|
93
93
|
],
|
|
94
94
|
routeway: [
|
|
95
|
-
[(r) => r.connection.apiKey === '', "Routeway has no API key: add it on the organization's
|
|
96
|
-
[(r) => r.model === '', "Routeway needs a model id: choose one on the organization's
|
|
95
|
+
[(r) => r.connection.apiKey === '', "Routeway has no API key: add it on the organization's Gateway page."],
|
|
96
|
+
[(r) => r.model === '', "Routeway needs a model id: choose one on the organization's Gateway page."],
|
|
97
97
|
],
|
|
98
98
|
};
|
|
99
99
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Lane } from './fallback.js';
|
|
2
|
+
import { GatewayError } from './forward.js';
|
|
3
|
+
import { helperSetup, primaryRoute, type ModelSetup, type Route } from './model-config.js';
|
|
4
|
+
|
|
5
|
+
export interface Chosen {
|
|
6
|
+
lane: Lane;
|
|
7
|
+
setup: ModelSetup;
|
|
8
|
+
primary: Route;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const NO_ROUTE = "metro gateway: no model is set up for this agent yet: choose one on the organization's Gateway page";
|
|
12
|
+
|
|
13
|
+
const noRoute = (message: string): never => {
|
|
14
|
+
throw new GatewayError(503, 'api_error', message);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const MAIN = 'metro';
|
|
18
|
+
export const WORKERS = 'metro-workers';
|
|
19
|
+
const NAMES = `Use the model "${MAIN}" (this agent's main models) or "${WORKERS}" (its worker models).`;
|
|
20
|
+
|
|
21
|
+
export const NAMED_MODELS = {
|
|
22
|
+
data: [
|
|
23
|
+
{ id: MAIN, display_name: 'Main models', description: "This agent's main model order on Metro" },
|
|
24
|
+
{ id: WORKERS, display_name: 'Worker models', description: "This agent's worker model order on Metro" },
|
|
25
|
+
],
|
|
26
|
+
has_more: false,
|
|
27
|
+
first_id: MAIN,
|
|
28
|
+
last_id: WORKERS,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function namedChoice(requested: string, setup: ModelSetup): Chosen {
|
|
32
|
+
if (requested !== MAIN && requested !== WORKERS) throw new GatewayError(400, 'invalid_request_error', NAMES);
|
|
33
|
+
const helpers = requested === WORKERS ? helperSetup(setup) : null;
|
|
34
|
+
const chain = helpers ?? setup;
|
|
35
|
+
return { lane: helpers === null ? 'main' : 'helpers', setup: chain, primary: primaryRoute(chain) ?? noRoute(NO_ROUTE) };
|
|
36
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { IncomingMessage } from 'node:http';
|
|
2
2
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
3
|
import { log } from '@metro-labs/core/log';
|
|
4
|
+
import { providerText } from './failures.js';
|
|
4
5
|
import { withoutCacheTtl, withoutTtlBeta } from './cache-ttl.js';
|
|
5
6
|
import { anthropicHeaders, GatewayError, type Watch } from './forward.js';
|
|
6
7
|
|
|
@@ -84,5 +85,5 @@ export async function noteRoutewayRefusal(upstream: Response): Promise<void> {
|
|
|
84
85
|
const error = isRecord(body) && isRecord(body.error) ? body.error : null;
|
|
85
86
|
if (error === null) return;
|
|
86
87
|
const text = (value: unknown): string | null => (typeof value === 'string' ? value.slice(0, 300) : null);
|
|
87
|
-
log.warn({ status: upstream.status, reason: text(error.message), param: text(error.param) }, 'gateway: Routeway refused the request');
|
|
88
|
+
log.warn({ status: upstream.status, reason: providerText(text(error.message) ?? ''), param: providerText(text(error.param) ?? '') }, 'gateway: Routeway refused the request');
|
|
88
89
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface Served {
|
|
2
|
+
connection: string;
|
|
3
|
+
provider: string;
|
|
4
|
+
model: string;
|
|
5
|
+
at: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const last = new Map<string, Served>();
|
|
9
|
+
|
|
10
|
+
export const noteServed = (served: Served, scope = ''): void => {
|
|
11
|
+
last.set(scope, served);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const lastServed = (scope = ''): Served | null => last.get(scope) ?? null;
|
|
15
|
+
|
|
16
|
+
export const forgetServed = (): void => {
|
|
17
|
+
last.clear();
|
|
18
|
+
};
|