@stage-labs/metro 0.1.0-beta.315 → 0.1.0-beta.317
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/core/package.json +2 -0
- package/runtime/node_modules/@metro-labs/core/src/effort.ts +21 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/account-status.ts +26 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/account-routes.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +23 -2
- package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +17 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/delivered.ts +16 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/effort.ts +4 -4
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +10 -8
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +21 -8
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +11 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +22 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +30 -3
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +9 -3
- package/runtime/node_modules/@metro-labs/daemon/src/routes/pair-again.ts +23 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-interactive.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-session.ts +9 -2
- package/runtime/node_modules/@metro-labs/daemon/src/stations/registry.ts +6 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-oauth.ts +13 -4
- package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +3 -2
- package/runtime/node_modules/@metro-labs/whatsapp/src/client-types.ts +2 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +35 -40
- package/runtime/node_modules/@metro-labs/whatsapp/src/connection.ts +139 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/login.ts +36 -3
- package/runtime/node_modules/@metro-labs/whatsapp/src/messages.ts +24 -3
- package/runtime/node_modules/@metro-labs/whatsapp/src/names.ts +46 -15
- package/runtime/node_modules/@metro-labs/whatsapp/src/profile.ts +158 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +2 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +2 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/effort.ts +21 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-status.ts +26 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/types.ts +2 -0
- package/runtime/sdk-runner/runner.json +1 -1
package/package.json
CHANGED
|
@@ -19,12 +19,14 @@
|
|
|
19
19
|
"./trains/protocol": "./src/protocol.ts",
|
|
20
20
|
"./ids": "./src/ids.ts",
|
|
21
21
|
"./claude-token": "./src/claude-token.ts",
|
|
22
|
+
"./effort": "./src/effort.ts",
|
|
22
23
|
"./tickets": "./src/tickets.ts",
|
|
23
24
|
"./station-names": "./src/station-names.ts",
|
|
24
25
|
"./str": "./src/str.ts",
|
|
25
26
|
"./lines": "./src/lines.ts",
|
|
26
27
|
"./endpoints": "./src/endpoints.ts",
|
|
27
28
|
"./stations/types": "./src/stations/types.ts",
|
|
29
|
+
"./stations/account-status": "./src/stations/account-status.ts",
|
|
28
30
|
"./stations/channel-directory": "./src/stations/channel-directory.ts",
|
|
29
31
|
"./stations/channel-pages": "./src/stations/channel-pages.ts",
|
|
30
32
|
"./stations/account-files": "./src/stations/account-files.ts",
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { isRecord } from './is-record.js';
|
|
2
|
+
|
|
3
|
+
export const EFFORT_LEVELS = ['low', 'medium', 'high', 'max', 'asked'] as const;
|
|
4
|
+
|
|
5
|
+
export type EffortLevel = (typeof EFFORT_LEVELS)[number];
|
|
6
|
+
|
|
7
|
+
export interface RouteEffort {
|
|
8
|
+
main?: EffortLevel;
|
|
9
|
+
helpers?: EffortLevel;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const EFFORT_DEFAULTS: Required<RouteEffort> = { main: 'low', helpers: 'max' };
|
|
13
|
+
|
|
14
|
+
export const isEffortLevel = (value: unknown): value is EffortLevel => typeof value === 'string' && (EFFORT_LEVELS as readonly string[]).includes(value);
|
|
15
|
+
|
|
16
|
+
export function readEffort(raw: unknown): RouteEffort {
|
|
17
|
+
if (!isRecord(raw)) return {};
|
|
18
|
+
return { ...(isEffortLevel(raw.main) ? { main: raw.main } : {}), ...(isEffortLevel(raw.helpers) ? { helpers: raw.helpers } : {}) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const effortSet = (effort: RouteEffort): boolean => effort.main !== undefined || effort.helpers !== undefined;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { isRecord } from '../is-record.js';
|
|
2
|
+
|
|
3
|
+
export const ACCOUNT_STATES = ['connected', 'connecting', 'logged_out', 'disconnected'] as const;
|
|
4
|
+
|
|
5
|
+
export type AccountState = (typeof ACCOUNT_STATES)[number];
|
|
6
|
+
|
|
7
|
+
export interface AccountStatus {
|
|
8
|
+
state: AccountState;
|
|
9
|
+
reason?: string;
|
|
10
|
+
since: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const REASON_MAX = 300;
|
|
14
|
+
|
|
15
|
+
export function accountStatus(state: AccountState, reason?: string, since = Date.now()): AccountStatus {
|
|
16
|
+
const text = reason?.trim().slice(0, REASON_MAX) ?? '';
|
|
17
|
+
return text === '' ? { state, since } : { state, reason: text, since };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function accountStatusOf(raw: unknown): AccountStatus | null {
|
|
21
|
+
if (!isRecord(raw)) return null;
|
|
22
|
+
const state = ACCOUNT_STATES.find((s) => s === raw.state);
|
|
23
|
+
const since = raw.since;
|
|
24
|
+
if (state === undefined || typeof since !== 'number' || !Number.isFinite(since)) return null;
|
|
25
|
+
return accountStatus(state, typeof raw.reason === 'string' ? raw.reason : undefined, since);
|
|
26
|
+
}
|
|
@@ -114,6 +114,8 @@ export interface Station {
|
|
|
114
114
|
readFilters?: ReadonlySet<ReadFilter>;
|
|
115
115
|
approvals?: boolean;
|
|
116
116
|
sendsFrames?: boolean;
|
|
117
|
+
reportsStatus?: boolean;
|
|
118
|
+
pairAgain?: { identity: string };
|
|
117
119
|
attachmentMode: AttachmentMode;
|
|
118
120
|
forget?: (accountId: string) => void;
|
|
119
121
|
forgetExcept?: (keptAccountIds: readonly string[]) => void;
|
|
@@ -13,6 +13,7 @@ export type AccountRoute =
|
|
|
13
13
|
| { kind: 'enabled'; station: StationName; accountId: string }
|
|
14
14
|
| { kind: 'policy'; station: StationName; accountId: string }
|
|
15
15
|
| { kind: 'profiles'; station: StationName; accountId: string }
|
|
16
|
+
| { kind: 'repair'; station: StationName; accountId: string }
|
|
16
17
|
| { kind: 'name'; station: StationName; accountId: string };
|
|
17
18
|
|
|
18
19
|
const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
|
|
@@ -26,10 +27,11 @@ const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
|
|
|
26
27
|
enabled: ['PUT'],
|
|
27
28
|
policy: ['PUT'],
|
|
28
29
|
profiles: ['GET'],
|
|
30
|
+
repair: ['POST'],
|
|
29
31
|
name: ['GET', 'POST'],
|
|
30
32
|
};
|
|
31
33
|
|
|
32
|
-
const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled', 'name', 'policy', 'profiles'] as const;
|
|
34
|
+
const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled', 'name', 'policy', 'profiles', 'repair'] as const;
|
|
33
35
|
|
|
34
36
|
type SubRoute = (typeof SUB_ROUTES)[number];
|
|
35
37
|
|
|
@@ -37,6 +37,7 @@ interface AttachSessionApi {
|
|
|
37
37
|
owner: AttachOwner,
|
|
38
38
|
station: InteractiveStation,
|
|
39
39
|
input: Record<string, unknown>,
|
|
40
|
+
repair?: string,
|
|
40
41
|
) => Promise<AttachView>;
|
|
41
42
|
view: (owner: AttachOwner, attachId: string) => AttachView;
|
|
42
43
|
submit: (
|
|
@@ -315,16 +316,36 @@ async function handleStep(
|
|
|
315
316
|
sendJson(req, res, 200, view);
|
|
316
317
|
}
|
|
317
318
|
|
|
319
|
+
async function handleRepair(
|
|
320
|
+
req: IncomingMessage,
|
|
321
|
+
res: ServerResponse,
|
|
322
|
+
deps: AccountApiDeps,
|
|
323
|
+
owner: AttachOwner,
|
|
324
|
+
target: { station: StationName; accountId: string },
|
|
325
|
+
): Promise<void> {
|
|
326
|
+
const { station, accountId } = target;
|
|
327
|
+
if (!isInteractiveStation(station) || stationByName(station)?.pairAgain === undefined)
|
|
328
|
+
throw new ApiError(`${station} cannot pair again`, 400);
|
|
329
|
+
const view = await deps.attachSessions.start(owner, station, asInput(await readJsonBody(req)), accountId);
|
|
330
|
+
sendJson(req, res, 201, view);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
type AttachRoute = Extract<AccountRoute, { kind: 'start' | 'session' | 'step' | 'repair' }>;
|
|
334
|
+
|
|
335
|
+
const isAttachRoute = (route: AccountRoute): route is AttachRoute =>
|
|
336
|
+
route.kind === 'start' || route.kind === 'session' || route.kind === 'step' || route.kind === 'repair';
|
|
337
|
+
|
|
318
338
|
async function dispatchAttach(
|
|
319
339
|
req: IncomingMessage,
|
|
320
340
|
res: ServerResponse,
|
|
321
341
|
deps: AccountApiDeps,
|
|
322
342
|
agentId: string,
|
|
323
|
-
route:
|
|
343
|
+
route: AttachRoute,
|
|
324
344
|
session?: ApiSession,
|
|
325
345
|
): Promise<void> {
|
|
326
346
|
const owner: AttachOwner = { agentId, organization: session?.subject, userId: session?.userId, sessionId: session?.sessionId, authorization: req.headers.authorization };
|
|
327
347
|
if (route.kind === 'start') return handleStart(req, res, deps, agentId, owner);
|
|
348
|
+
if (route.kind === 'repair') return handleRepair(req, res, deps, owner, route);
|
|
328
349
|
if (route.kind === 'session') return handleSession(req, res, deps, owner, route.attachId);
|
|
329
350
|
return handleStep(req, res, deps, owner, route.attachId);
|
|
330
351
|
}
|
|
@@ -337,7 +358,7 @@ async function dispatchRoute(
|
|
|
337
358
|
route: AccountRoute,
|
|
338
359
|
session?: ApiSession,
|
|
339
360
|
): Promise<void> {
|
|
340
|
-
if (route
|
|
361
|
+
if (isAttachRoute(route)) return dispatchAttach(req, res, deps, agentId, route, session);
|
|
341
362
|
if (route.kind === 'allowlist') return handleAllowlist(req, res, deps, agentId, route);
|
|
342
363
|
if (route.kind === 'enabled') return handleEnabled(req, res, deps, agentId, route);
|
|
343
364
|
if (route.kind === 'policy') return handlePolicy(req, res, deps, agentId, route);
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type AccountRoute,
|
|
14
14
|
} from './account-routes.js';
|
|
15
15
|
import type { AgentSummary } from './admin.js';
|
|
16
|
+
import { pairAgainStations, statusStations } from '../stations/registry.js';
|
|
16
17
|
|
|
17
18
|
const PREFIX = '/api/agents';
|
|
18
19
|
|
|
@@ -70,6 +71,8 @@ async function handleList(
|
|
|
70
71
|
capabilities: deps.capabilities(),
|
|
71
72
|
features: deps.features ?? [],
|
|
72
73
|
attachable: deps.attachable ?? ATTACHABLE,
|
|
74
|
+
reportsStatus: statusStations(),
|
|
75
|
+
pairAgain: pairAgainStations(),
|
|
73
76
|
...(deps.toolGroups === undefined ? {} : { tools: deps.toolGroups() }),
|
|
74
77
|
};
|
|
75
78
|
if (!wantsAccounts(req)) {
|
|
@@ -210,6 +210,23 @@ export function localRenewGmail(agentId: string, accountId: string, previous: st
|
|
|
210
210
|
return { agentId, station: 'gmail', accountId };
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
const sameIdentity = (a: unknown, b: unknown): boolean =>
|
|
214
|
+
typeof a === 'string' && typeof b === 'string' && a.replace(/[\s()+-]/g, '').toLowerCase() === b.replace(/[\s()+-]/g, '').toLowerCase();
|
|
215
|
+
|
|
216
|
+
const shownIdentity = (value: unknown): string => (typeof value === 'string' && value !== '' ? value : 'unknown');
|
|
217
|
+
|
|
218
|
+
export function localPairAgain(agentId: string, station: StationName, accountId: string, identity: string, config: Record<string, unknown>, dir = agentsDir()): AccountRef {
|
|
219
|
+
const stored = agentOrThrow(agentId, dir);
|
|
220
|
+
const account = stored.file.stations.find((s) => s.station === station && s.id === accountId);
|
|
221
|
+
if (account === undefined) throw new AgentAdminError('This channel is gone. Nothing was changed.', 404);
|
|
222
|
+
const before = account.config[identity];
|
|
223
|
+
const after = config[identity];
|
|
224
|
+
if (!sameIdentity(before, after)) throw new AgentAdminError(`This channel is ${shownIdentity(before)}, not ${shownIdentity(after)}. Nothing was changed.`, 400);
|
|
225
|
+
account.config = { ...account.config, ...config };
|
|
226
|
+
save(stored);
|
|
227
|
+
return { agentId, station, accountId };
|
|
228
|
+
}
|
|
229
|
+
|
|
213
230
|
export function readLocalAgentFile(agentId: string, dir = agentsDir()): AgentFile {
|
|
214
231
|
const found = storedAgents(dir).find((s) => s.file.id === agentId);
|
|
215
232
|
if (found === undefined) throw missing();
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { readEffort, type RouteEffort } from '@metro-labs/core/effort';
|
|
1
2
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
|
-
import { isProvider, type Account, type CodexAccess, type Connection, type Fallback, type Provider, type RouteSource } from './model-config.js';
|
|
3
|
+
import { isProvider, type Account, type Chain, type CodexAccess, type Connection, type Fallback, type Provider, type RouteSource } from './model-config.js';
|
|
3
4
|
|
|
4
5
|
export interface DeliveredRoute {
|
|
5
6
|
primary: Fallback | null;
|
|
6
7
|
fallbacks: Fallback[];
|
|
8
|
+
helpers: Chain | null;
|
|
9
|
+
effort: RouteEffort;
|
|
7
10
|
}
|
|
8
11
|
|
|
9
12
|
export interface Delivered {
|
|
@@ -24,10 +27,20 @@ function refOf(raw: unknown): Fallback | null {
|
|
|
24
27
|
return ref.connection === '' ? null : ref;
|
|
25
28
|
}
|
|
26
29
|
|
|
30
|
+
function fallbacksOf(raw: Record<string, unknown>): Fallback[] {
|
|
31
|
+
const fallbacks = Array.isArray(raw.fallbacks) ? raw.fallbacks.flatMap((f: unknown) => refOf(f) ?? []) : [];
|
|
32
|
+
return fallbacks.filter((f) => f.model !== '').slice(0, MAX_FALLBACKS);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function helpersOf(raw: unknown): Chain | null {
|
|
36
|
+
if (!isRecord(raw)) return null;
|
|
37
|
+
const primary = refOf(raw.primary);
|
|
38
|
+
return primary === null ? null : { primary, fallbacks: fallbacksOf(raw) };
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
function routeOf(raw: unknown): DeliveredRoute | null {
|
|
28
42
|
if (!isRecord(raw)) return null;
|
|
29
|
-
|
|
30
|
-
return { primary: refOf(raw.primary), fallbacks: fallbacks.filter((f) => f.model !== '').slice(0, MAX_FALLBACKS) };
|
|
43
|
+
return { primary: refOf(raw.primary), fallbacks: fallbacksOf(raw), helpers: helpersOf(raw.helpers), effort: readEffort(raw.effort) };
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
function accountOf(raw: unknown): Account {
|
|
@@ -6,7 +6,7 @@ import { deliveredSetup, type OrgModels } from './org-models.js';
|
|
|
6
6
|
export function setupOf(delivered: Delivered | null): ModelSetup {
|
|
7
7
|
const route = delivered?.route ?? null;
|
|
8
8
|
if (delivered === null || route === null) return { ...emptySetup(), source: delivered?.source ?? null };
|
|
9
|
-
return { primary: route.primary, fallbacks: route.fallbacks, connections: delivered.connections, source: delivered.source };
|
|
9
|
+
return { primary: route.primary, fallbacks: route.fallbacks, connections: delivered.connections, source: delivered.source, helpers: route.helpers, effort: route.effort };
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export const effectiveSetup = (delivered: Delivered | null = deliveredSetup()): ModelSetup => setupOf(delivered);
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
import { EFFORT_DEFAULTS, type RouteEffort } from '@metro-labs/core/effort';
|
|
2
3
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
4
|
|
|
4
|
-
const MAIN_EFFORT = 'low';
|
|
5
|
-
const SUBAGENT_EFFORT = 'max';
|
|
6
5
|
export const BINDING_BETA = 'thinking-binding-controls-2026-08-01';
|
|
7
6
|
|
|
8
7
|
const SUBAGENT_HEADER = 'x-claude-code-agent-id';
|
|
@@ -42,12 +41,13 @@ const messagesOf = (body: Body): unknown[] => (Array.isArray(body.messages) ? bo
|
|
|
42
41
|
|
|
43
42
|
const askedEfforts = (body: Body): string[] => [body, ...messagesOf(body).map(record)].map(effortOf).filter((effort) => effort !== '');
|
|
44
43
|
|
|
45
|
-
export function plannedEffort(req: IncomingMessage, body: Body): string | null {
|
|
44
|
+
export function plannedEffort(req: IncomingMessage, body: Body, setting: RouteEffort = {}): string | null {
|
|
46
45
|
const asked = askedEfforts(body);
|
|
47
46
|
if (asked.length === 0) return null;
|
|
48
47
|
const kind = requestKind(req, body);
|
|
49
48
|
if (kind === 'other') return null;
|
|
50
|
-
const wanted = kind === 'subagent' ?
|
|
49
|
+
const wanted = kind === 'subagent' ? (setting.helpers ?? EFFORT_DEFAULTS.helpers) : (setting.main ?? EFFORT_DEFAULTS.main);
|
|
50
|
+
if (wanted === 'asked') return null;
|
|
51
51
|
return asked.every((effort) => effort === wanted) ? null : wanted;
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -18,14 +18,16 @@ interface Cooling {
|
|
|
18
18
|
reason: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export type Lane = 'main' | 'helpers';
|
|
22
|
+
|
|
21
23
|
const cooling = new Map<string, Cooling>();
|
|
22
|
-
|
|
24
|
+
const lastChoice = new Map<Lane, string>();
|
|
23
25
|
|
|
24
26
|
const keyOf = (route: Route): string => `${route.connection.id}\n${route.model}`;
|
|
25
27
|
|
|
26
28
|
export function forgetFallbackState(): void {
|
|
27
29
|
cooling.clear();
|
|
28
|
-
lastChoice
|
|
30
|
+
lastChoice.clear();
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
function scopeOf(label: string): string | null {
|
|
@@ -96,18 +98,18 @@ export function noteRefused(route: Route, status: number, headers: Headers, next
|
|
|
96
98
|
log.warn({ from: routeLabel(route), to: next === undefined ? null : routeLabel(next), status }, why);
|
|
97
99
|
}
|
|
98
100
|
|
|
99
|
-
export function noteChoice(primary: Route, chosen: Route, now = Date.now()): void {
|
|
101
|
+
export function noteChoice(primary: Route, chosen: Route, lane: Lane, now = Date.now()): void {
|
|
100
102
|
const key = keyOf(chosen);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
lastChoice
|
|
103
|
+
const last = lastChoice.get(lane);
|
|
104
|
+
if (key === last) return;
|
|
105
|
+
lastChoice.set(lane, key);
|
|
104
106
|
if (key === keyOf(primary)) {
|
|
105
|
-
if (
|
|
107
|
+
if (last !== undefined) log.info({ lane, model: routeLabel(chosen), connection: chosen.connection.label }, 'gateway: back on the first model of the fallback list');
|
|
106
108
|
return;
|
|
107
109
|
}
|
|
108
110
|
const hold = holdOf(primary, now);
|
|
109
111
|
log.info(
|
|
110
|
-
{ from: routeLabel(primary), to: routeLabel(chosen), connection: chosen.connection.label, reason: hold?.reason ?? null, until: hold?.until ?? null },
|
|
112
|
+
{ lane, from: routeLabel(primary), to: routeLabel(chosen), connection: chosen.connection.label, reason: hold?.reason ?? null, until: hold?.until ?? null },
|
|
111
113
|
'gateway: the first model is over its limit, so requests go to a fallback model',
|
|
112
114
|
);
|
|
113
115
|
}
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
type Watch,
|
|
27
27
|
type Refusal,
|
|
28
28
|
} from './forward.js';
|
|
29
|
-
import { chainOf, forgetFallbackState, noteChoice, noteRefused, routesToTry } from './fallback.js';
|
|
29
|
+
import { chainOf, forgetFallbackState, noteChoice, noteRefused, routesToTry, type Lane } from './fallback.js';
|
|
30
30
|
import {
|
|
31
31
|
BINDING_BETA,
|
|
32
32
|
cappedEffort,
|
|
@@ -34,12 +34,13 @@ import {
|
|
|
34
34
|
forgetLearnedThinking,
|
|
35
35
|
learnAlwaysThinks,
|
|
36
36
|
plannedEffort,
|
|
37
|
+
requestKind,
|
|
37
38
|
thinkingOff,
|
|
38
39
|
withBlockBinding,
|
|
39
40
|
withEffort,
|
|
40
41
|
withThinkingFor,
|
|
41
42
|
} from './effort.js';
|
|
42
|
-
import { notReady, resolveRoute, routeLabel, type Connection, type ModelSetup, type Route } from './model-config.js';
|
|
43
|
+
import { helperSetup, notReady, pinnedRoute, resolveRoute, routeLabel, type Connection, type ModelSetup, type Route } from './model-config.js';
|
|
43
44
|
import { codexMessages, type CodexDeps } from './codex.js';
|
|
44
45
|
import { countTokens } from './subscription.js';
|
|
45
46
|
import { OPENROUTER_BASE } from './openrouter.js';
|
|
@@ -241,11 +242,23 @@ async function toCodex(req: IncomingMessage, res: ServerResponse, path: string,
|
|
|
241
242
|
else await codexMessages(req, res, body, route.model, route.connection, { refresh: refreshCodex, ...deps.codex }, watchUpstream(res));
|
|
242
243
|
}
|
|
243
244
|
|
|
244
|
-
function shapedFor(req: IncomingMessage, sent: Record<string, unknown
|
|
245
|
-
const effort = plannedEffort(req, sent);
|
|
245
|
+
function shapedFor(req: IncomingMessage, sent: Record<string, unknown>, setup: ModelSetup): Record<string, unknown> {
|
|
246
|
+
const effort = plannedEffort(req, sent, setup.effort);
|
|
246
247
|
return effort === null ? sent : withEffort(sent, effort);
|
|
247
248
|
}
|
|
248
249
|
|
|
250
|
+
interface Chosen {
|
|
251
|
+
lane: Lane;
|
|
252
|
+
setup: ModelSetup;
|
|
253
|
+
primary: Route;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function chosenChain(req: IncomingMessage, sent: Record<string, unknown>, setup: ModelSetup, requested: string): Chosen {
|
|
257
|
+
const helpers = requestKind(req, sent) === 'subagent' ? helperSetup(setup) : null;
|
|
258
|
+
if (helpers === null) return { lane: 'main', setup, primary: resolveRoute(requested, setup) ?? noRoute() };
|
|
259
|
+
return { lane: 'helpers', setup: helpers, primary: pinnedRoute(requested, helpers) ?? noRoute() };
|
|
260
|
+
}
|
|
261
|
+
|
|
249
262
|
const bytesOf = (raw: Buffer, parsed: Record<string, unknown>, sent: Record<string, unknown>): Buffer => (sent === parsed ? raw : Buffer.from(JSON.stringify(sent)));
|
|
250
263
|
|
|
251
264
|
interface Request {
|
|
@@ -299,12 +312,12 @@ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string,
|
|
|
299
312
|
const parsed = parseJson(raw);
|
|
300
313
|
const sent = fitToolSearch(req, parsed);
|
|
301
314
|
const requested = requestedModel(sent);
|
|
302
|
-
const primary =
|
|
303
|
-
const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent), setup };
|
|
304
|
-
const routes = routesToTry(chainOf(primary,
|
|
315
|
+
const { lane, setup: chain, primary } = chosenChain(req, sent, setup, requested);
|
|
316
|
+
const ask: Request = { path, raw, parsed, sent, body: shapedFor(req, sent, setup), setup };
|
|
317
|
+
const routes = routesToTry(chainOf(primary, chain, requested));
|
|
305
318
|
for (const [at, route] of routes.entries()) {
|
|
306
319
|
const next = routes[at + 1];
|
|
307
|
-
if (path === MESSAGES) noteChoice(primary, route);
|
|
320
|
+
if (path === MESSAGES) noteChoice(primary, route, lane);
|
|
308
321
|
if (next === undefined) {
|
|
309
322
|
await attempt(req, res, ask, route, deps);
|
|
310
323
|
return;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { EFFORT_DEFAULTS } from '@metro-labs/core/effort';
|
|
2
3
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
3
4
|
import { apiFailure, apiSession, cors, sendJson } from '@metro-labs/http/api-http';
|
|
4
5
|
import { effectiveSetup } from './effective.js';
|
|
5
6
|
import { chainStatus } from './fallback.js';
|
|
6
|
-
import { notReady, primaryRoute, type Connection, type ModelSetup } from './model-config.js';
|
|
7
|
+
import { helperSetup, notReady, primaryRoute, type Connection, type ModelSetup } from './model-config.js';
|
|
7
8
|
import { orgModels, type SyncStatus } from './org-models.js';
|
|
8
9
|
import { lastServed } from './served.js';
|
|
9
10
|
import { refreshUsage, type UsageDeps } from './usage-refresh.js';
|
|
@@ -41,6 +42,13 @@ function publicConnection(c: Connection): Record<string, unknown> {
|
|
|
41
42
|
|
|
42
43
|
const setupOf = (deps: ModelApiDeps): ModelSetup => (deps.setup ?? (() => effectiveSetup()))();
|
|
43
44
|
|
|
45
|
+
function helpersBody(setup: ModelSetup): Record<string, unknown> | null {
|
|
46
|
+
const helpers = helperSetup(setup);
|
|
47
|
+
if (helpers === null) return null;
|
|
48
|
+
const primary = primaryRoute(helpers);
|
|
49
|
+
return { route: primary?.connection.id ?? '', model: primary?.model ?? '', fallbacks: helpers.fallbacks, chain: chainStatus(helpers) };
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
export function settingsBody(deps: ModelApiDeps): Record<string, unknown> {
|
|
45
53
|
const setup = setupOf(deps);
|
|
46
54
|
const primary = primaryRoute(setup);
|
|
@@ -55,6 +63,8 @@ export function settingsBody(deps: ModelApiDeps): Record<string, unknown> {
|
|
|
55
63
|
connections: setup.connections.map(publicConnection),
|
|
56
64
|
fallbacks: setup.fallbacks,
|
|
57
65
|
chain: chainStatus(setup),
|
|
66
|
+
helpers: helpersBody(setup),
|
|
67
|
+
effort: { ...EFFORT_DEFAULTS, ...setup.effort },
|
|
58
68
|
lastServed: lastServed(),
|
|
59
69
|
usage: usageSeen(),
|
|
60
70
|
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { RouteEffort } from '@metro-labs/core/effort';
|
|
2
|
+
|
|
1
3
|
export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex', 'routeway'] as const;
|
|
2
4
|
export type Provider = (typeof PROVIDERS)[number];
|
|
3
5
|
export type CodexMethod = 'chatgpt' | 'code';
|
|
@@ -36,11 +38,18 @@ export interface Fallback {
|
|
|
36
38
|
model: string;
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
export interface Chain {
|
|
42
|
+
primary: Fallback;
|
|
43
|
+
fallbacks: Fallback[];
|
|
44
|
+
}
|
|
45
|
+
|
|
39
46
|
export interface ModelSetup {
|
|
40
47
|
primary: Fallback | null;
|
|
41
48
|
fallbacks: Fallback[];
|
|
42
49
|
connections: Connection[];
|
|
43
50
|
source: RouteSource | null;
|
|
51
|
+
helpers?: Chain | null;
|
|
52
|
+
effort?: RouteEffort;
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
export interface Route {
|
|
@@ -119,6 +128,19 @@ export function resolveRoute(requested: string, setup: ModelSetup): Route | null
|
|
|
119
128
|
return { connection: primary.connection, model: DEFAULTS[primary.connection.provider](bareModel(requested), primary.model) };
|
|
120
129
|
}
|
|
121
130
|
|
|
131
|
+
export function pinnedRoute(requested: string, setup: ModelSetup): Route | null {
|
|
132
|
+
const primary = primaryRoute(setup);
|
|
133
|
+
if (primary === null) return null;
|
|
134
|
+
const bare = bareModel(requested);
|
|
135
|
+
const passes = primary.connection.provider === 'anthropic' && (primary.model === '' || isSmallModel(bare));
|
|
136
|
+
return passes ? { connection: primary.connection, model: bare } : primary;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function helperSetup(setup: ModelSetup): ModelSetup | null {
|
|
140
|
+
const helpers = setup.helpers ?? null;
|
|
141
|
+
return helpers === null ? null : { ...setup, primary: helpers.primary, fallbacks: helpers.fallbacks, helpers: null };
|
|
142
|
+
}
|
|
143
|
+
|
|
122
144
|
export const routeLabel = (route: Route): string =>
|
|
123
145
|
route.connection.provider === 'anthropic' ? route.model : `${route.connection.provider}:${route.model}`;
|
|
124
146
|
|
|
@@ -42,7 +42,7 @@ export async function refreshUsage(setup: ModelSetup, deps: UsageDeps = {}, now
|
|
|
42
42
|
export function watchFallbackUsage(setup: () => ModelSetup, deps: UsageDeps = {}, every = CREDITS_TTL_MS): void {
|
|
43
43
|
const run = (): void => {
|
|
44
44
|
const current = setup();
|
|
45
|
-
if (current.fallbacks.length === 0) return;
|
|
45
|
+
if (current.fallbacks.length === 0 && (current.helpers ?? null) === null) return;
|
|
46
46
|
refreshUsage(current, deps).catch((err: unknown) => {
|
|
47
47
|
log.warn({ err: errMsg(err) }, 'model-api: could not refresh the usage of the fallback models');
|
|
48
48
|
});
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from '../stations/registry.js';
|
|
7
7
|
import { listEndpoints } from '../stations/webhook-endpoints.js';
|
|
8
8
|
import { hookUrl, threemaCallbackUrl } from '../stations/attach.js';
|
|
9
|
+
import { accountStatusOf } from '@metro-labs/core/stations/account-status';
|
|
9
10
|
import { accountEnabled, agentIdForAccount, allowlistForAccount, approversForAccount, knownAccounts, type KnownAccount } from '../agents/map.js';
|
|
10
11
|
|
|
11
12
|
const accountId = (acc: unknown): string | undefined => {
|
|
@@ -85,8 +86,33 @@ function withCallbackUrl(acc: unknown): unknown {
|
|
|
85
86
|
return { ...rest, callback: threemaCallbackUrl(callbackId, callbackToken) };
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
function withStatus(reports: boolean, acc: unknown): unknown {
|
|
90
|
+
const rec = asRecord(acc);
|
|
91
|
+
if (rec === undefined || !('status' in rec)) return acc;
|
|
92
|
+
const { status, ...rest } = rec;
|
|
93
|
+
const live = reports ? accountStatusOf(status) : null;
|
|
94
|
+
return live === null ? rest : { ...rest, status: live };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function decorate(station: string, rows: unknown[]): unknown[] {
|
|
98
|
+
const reports = stationByName(station)?.reportsStatus === true;
|
|
99
|
+
const live = rows.map((row) => withStatus(reports, row));
|
|
100
|
+
return station === 'threema' ? live.map(withCallbackUrl) : live;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const STATUS_WAIT_MS = 5_000;
|
|
104
|
+
|
|
105
|
+
function answerIn<T>(call: Promise<T>, ms: number): Promise<T> {
|
|
106
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
107
|
+
const late = new Promise<never>((_resolve, reject) => {
|
|
108
|
+
timer = setTimeout(() => {
|
|
109
|
+
reject(new Error(`no answer within ${String(ms)} ms`));
|
|
110
|
+
}, ms);
|
|
111
|
+
});
|
|
112
|
+
return Promise.race([call, late]).finally(() => {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
90
116
|
|
|
91
117
|
export interface ScopedAccounts {
|
|
92
118
|
accounts: Record<string, unknown[]>;
|
|
@@ -99,7 +125,8 @@ async function liveAccounts(
|
|
|
99
125
|
if (!hasTrain(station))
|
|
100
126
|
return { rows: inCoreAccounts(station), reachable: true };
|
|
101
127
|
try {
|
|
102
|
-
const
|
|
128
|
+
const call = forwardTrainCall(station, 'accounts', {});
|
|
129
|
+
const resp = await (stationByName(station)?.reportsStatus === true ? answerIn(call, STATUS_WAIT_MS) : call);
|
|
103
130
|
const list = (resp.result as { accounts?: unknown[] } | undefined)?.accounts;
|
|
104
131
|
return { rows: decorate(station, Array.isArray(list) ? list : []), reachable: true };
|
|
105
132
|
} catch {
|
|
@@ -4,6 +4,7 @@ import { AttachSessions } from '../stations/attach-session.js';
|
|
|
4
4
|
import { startInteractiveAttach } from '../stations/attach-interactive.js';
|
|
5
5
|
import { assertAttachOwner, finishGmailUpgrade, startGmailAttach } from './gmail-attach.js';
|
|
6
6
|
import { detachWithGmailRevocation } from './gmail-detach.js';
|
|
7
|
+
import { finishPairAgain, pairAgainInput } from './pair-again.js';
|
|
7
8
|
import { recentSenders } from '../agents/senders.js';
|
|
8
9
|
import { forwardTrainCall } from '../stations/train-call.js';
|
|
9
10
|
import type { AgentApiDeps } from '../agents/api.js';
|
|
@@ -44,11 +45,16 @@ function attachSessions(deps: LocalModeDeps): AttachSessions {
|
|
|
44
45
|
assertAttachOwner(owner);
|
|
45
46
|
return Promise.resolve();
|
|
46
47
|
},
|
|
47
|
-
start: (station, input, hooks, owner
|
|
48
|
-
|
|
48
|
+
start: (station, input, hooks, owner, repair) => {
|
|
49
|
+
if (repair !== undefined) return startInteractiveAttach(station, pairAgainInput(owner, station, repair, input), hooks);
|
|
50
|
+
return station === 'gmail' ? startGmailAttach(station, input, hooks, owner) : startInteractiveAttach(station, input, hooks);
|
|
51
|
+
},
|
|
52
|
+
complete: async (owner, station, config, active, repair) => {
|
|
49
53
|
assertAttachOwner(owner);
|
|
50
54
|
if (!active()) throw new ApiError('This sign-in was cancelled or expired.', 409);
|
|
51
|
-
const ref =
|
|
55
|
+
const ref = repair !== undefined
|
|
56
|
+
? finishPairAgain(owner, station, repair, config)
|
|
57
|
+
: (station === 'gmail' ? finishGmailUpgrade(owner, config) : null) ?? await localAttachAccount(owner.agentId, station, config);
|
|
52
58
|
const activated = await deps.syncStations(station).then(
|
|
53
59
|
() => true,
|
|
54
60
|
(err: unknown) => {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
2
|
+
import type { StationName } from '@metro-labs/core/station-names';
|
|
3
|
+
import { localPairAgain, readLocalAgentFile } from '../agents/file-admin.js';
|
|
4
|
+
import type { AccountRef } from '../agents/account-attach.js';
|
|
5
|
+
import { stationByName } from '../stations/registry.js';
|
|
6
|
+
import type { AttachOwner } from '../stations/attach-session.js';
|
|
7
|
+
|
|
8
|
+
function identityOf(station: string): string {
|
|
9
|
+
const identity = stationByName(station)?.pairAgain?.identity;
|
|
10
|
+
if (identity === undefined) throw new ApiError(`${station} cannot pair again`, 400);
|
|
11
|
+
return identity;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function pairAgainInput(owner: AttachOwner, station: string, accountId: string, input: Record<string, unknown>): Record<string, unknown> {
|
|
15
|
+
const identity = identityOf(station);
|
|
16
|
+
const account = readLocalAgentFile(owner.agentId).stations.find((s) => s.station === station && s.id === accountId);
|
|
17
|
+
if (account === undefined) throw new ApiError('No such channel on this agent.', 404);
|
|
18
|
+
return { ...input, expect: account.config[identity] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function finishPairAgain(owner: AttachOwner, station: StationName, accountId: string, config: Record<string, unknown>): AccountRef {
|
|
22
|
+
return localPairAgain(owner.agentId, station, accountId, identityOf(station), config);
|
|
23
|
+
}
|