@stage-labs/metro 0.1.0-beta.170 → 0.1.0-beta.172
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/cli.js +7 -10
- package/dist/route.js +16 -8
- package/dist/serve.js +1 -1
- package/dist/service.js +1 -1
- package/package.json +1 -1
- package/runtime/marketplace/.claude-plugin/marketplace.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +2 -3
- package/runtime/node_modules/@metro-labs/core/src/stations/account-store.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +14 -7
- package/runtime/node_modules/@metro-labs/daemon/src/channels/pending.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +41 -3
- package/runtime/node_modules/@metro-labs/daemon/src/claude/settings.ts +11 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +30 -6
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +12 -8
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +4 -4
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +18 -7
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +6 -8
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-stream.ts +5 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +18 -7
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +4 -6
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-inline.ts +29 -1
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-resolve.ts +4 -9
- package/runtime/node_modules/@metro-labs/discord-bot/src/actions.ts +1 -144
- package/runtime/node_modules/@metro-labs/discord-bot/src/format.ts +14 -5
- package/runtime/node_modules/@metro-labs/telegram/src/format.ts +19 -0
- package/runtime/node_modules/@metro-labs/telegram/src/inbound.ts +2 -1
- package/runtime/node_modules/@metro-labs/telegram-bot/src/actions.ts +1 -45
- package/runtime/node_modules/@metro-labs/telegram-bot/src/format.ts +13 -5
- package/runtime/node_modules/@metro-labs/telegram-bot/src/media-actions.ts +1 -73
- package/runtime/node_modules/@metro-labs/xmtp/package.json +0 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/actions-conv.ts +2 -173
- package/runtime/node_modules/@metro-labs/xmtp/src/actions-meta.ts +2 -41
- package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +1 -129
- package/runtime/node_modules/@metro-labs/xmtp/src/emit-core.ts +13 -5
- package/runtime/node_modules/@metro-labs/xmtp/src/emit.ts +1 -10
- package/runtime/node_modules/@metro-labs/xmtp/src/index.ts +3 -4
- package/runtime/node_modules/@metro-labs/xmtp/src/labels.ts +1 -19
- package/runtime/runtime.json +1 -1
- package/runtime/stations.json +0 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/actions-push.ts +0 -87
- package/runtime/node_modules/@metro-labs/xmtp/src/push.ts +0 -349
package/dist/cli.js
CHANGED
|
@@ -9,15 +9,15 @@ import { service, serviceStopHint } from './service.js';
|
|
|
9
9
|
import { currentVersion } from './version.js';
|
|
10
10
|
const USAGE = `metro — run your agent on this machine
|
|
11
11
|
|
|
12
|
-
metro serve [--port <n>] [--owner <
|
|
12
|
+
metro serve [--port <n>] [--owner <organization id>]
|
|
13
13
|
run the daemon: the agent, its channels and its connectors live in
|
|
14
14
|
~/.metro/agents here, and it is published through Tailscale Funnel at a
|
|
15
15
|
permanent https://<machine>.<tailnet>.ts.net (Tailscale installed and
|
|
16
16
|
signed in on this machine); the page at metro.box manages it through the
|
|
17
|
-
link it prints; --owner names the
|
|
18
|
-
the first start only; Stop on the page parks the daemon and keeps the
|
|
17
|
+
link it prints; --owner names the organization whose members may sign
|
|
18
|
+
in, needed on the first start only; Stop on the page parks the daemon and keeps the
|
|
19
19
|
address, so Start works from the page too
|
|
20
|
-
metro service install [--port <n>] [--owner <
|
|
20
|
+
metro service install [--port <n>] [--owner <organization id>]
|
|
21
21
|
run metro serve as a service (systemd on Linux, launchd on macOS): it
|
|
22
22
|
starts at boot and after a crash, so the page can stop, start and
|
|
23
23
|
restart it with no shell; metro service uninstall and status as well
|
|
@@ -26,18 +26,15 @@ const USAGE = `metro — run your agent on this machine
|
|
|
26
26
|
follow this machine's inbound events, one JSON line each
|
|
27
27
|
metro whoami print the agent this machine runs
|
|
28
28
|
metro claude [args...]
|
|
29
|
-
open Claude Code with the metro channel
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
open Claude Code with the metro channel and the metro MCP server, its
|
|
30
|
+
inference routed through the daemon's model gateway (the Model page);
|
|
31
|
+
every argument is passed through the same
|
|
32
32
|
metro update update to the newest published version (--check only reports)
|
|
33
33
|
metro version print this CLI's version
|
|
34
34
|
|
|
35
35
|
METRO_WEBHOOK_PORT the daemon's port (default 8420)
|
|
36
36
|
METRO_AGENTS_DIR where the agents live (default ~/.metro/agents)
|
|
37
37
|
METRO_AGENT_KEY the key metro tail presents, instead of the agent file's
|
|
38
|
-
METRO_BEDROCK_MODEL
|
|
39
|
-
send every request to this Bedrock model id (default: derive from the
|
|
40
|
-
model Claude Code asks for, e.g. eu.anthropic.claude-sonnet-4-6)
|
|
41
38
|
METRO_RUNTIME_DIR run the daemon from this directory instead of the bundled one
|
|
42
39
|
`;
|
|
43
40
|
async function stopDaemon() {
|
package/dist/route.js
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { agentsDir } from './local.js';
|
|
4
|
-
const PROVIDERS = ['bedrock', 'openrouter', 'codex', 'gemini'];
|
|
4
|
+
const PROVIDERS = new Set(['bedrock', 'openrouter', 'codex', 'gemini']);
|
|
5
|
+
const text = (value) => (typeof value === 'string' ? value : '');
|
|
6
|
+
function routeIn(cfg) {
|
|
7
|
+
if (typeof cfg !== 'object' || cfg === null)
|
|
8
|
+
return null;
|
|
9
|
+
const { route, connections } = cfg;
|
|
10
|
+
if (!Array.isArray(connections))
|
|
11
|
+
return null;
|
|
12
|
+
const conn = connections.find((c) => typeof c === 'object' && c !== null && c.id === route);
|
|
13
|
+
if (typeof conn !== 'object' || conn === null)
|
|
14
|
+
return null;
|
|
15
|
+
const provider = text(conn.provider);
|
|
16
|
+
const model = text(conn.model);
|
|
17
|
+
return PROVIDERS.has(provider) && model !== '' ? `${provider}:${model}` : null;
|
|
18
|
+
}
|
|
5
19
|
export function currentRoute(dir = agentsDir()) {
|
|
6
20
|
const path = join(dir, 'model.json');
|
|
7
21
|
if (!existsSync(path))
|
|
8
22
|
return null;
|
|
9
23
|
try {
|
|
10
|
-
|
|
11
|
-
const provider = PROVIDERS.find((p) => p === cfg.provider);
|
|
12
|
-
if (provider === undefined)
|
|
13
|
-
return null;
|
|
14
|
-
const block = cfg[provider];
|
|
15
|
-
const model = typeof block === 'object' && block !== null ? block.model : undefined;
|
|
16
|
-
return typeof model === 'string' && model !== '' ? `${provider}:${model}` : null;
|
|
24
|
+
return routeIn(JSON.parse(readFileSync(path, 'utf8')));
|
|
17
25
|
}
|
|
18
26
|
catch {
|
|
19
27
|
return null;
|
package/dist/serve.js
CHANGED
|
@@ -12,7 +12,7 @@ const SCRUBBED = new Set(['METRO_RUN_TOKEN', 'METRO_AGENT', 'DATABASE_URL']);
|
|
|
12
12
|
const PORT_FLAG = /^--port=(.*)$/;
|
|
13
13
|
const OWNER_FLAG = /^--owner=(.*)$/;
|
|
14
14
|
const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
|
|
15
|
-
const USAGE = 'usage: metro serve [--port <n>] [--owner <
|
|
15
|
+
const USAGE = 'usage: metro serve [--port <n>] [--owner <organization id>]';
|
|
16
16
|
const TUNNEL_FLAG = /^--(no-)?tunnel(=.*)?$/;
|
|
17
17
|
const TAILSCALE_APP = '/Applications/Tailscale.app/Contents/MacOS/Tailscale';
|
|
18
18
|
function portOf(raw) {
|
package/dist/service.js
CHANGED
|
@@ -5,7 +5,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
5
5
|
import { serveLockedBy } from './control.js';
|
|
6
6
|
import { findBun } from './runtime.js';
|
|
7
7
|
import { findTailscale, parseServeArgs, requireOwner } from './serve.js';
|
|
8
|
-
const USAGE = 'usage: metro service install [--port <n>] [--owner <
|
|
8
|
+
const USAGE = 'usage: metro service install [--port <n>] [--owner <organization id>] | uninstall | status';
|
|
9
9
|
const SERVICE = 'metro';
|
|
10
10
|
const LABEL = 'box.metro.serve';
|
|
11
11
|
const CARRIED = /^(METRO_.*|XDG_CACHE_HOME|PATH|HOME)$/;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.172",
|
|
4
4
|
"description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"name": "metro",
|
|
8
8
|
"source": "./plugin",
|
|
9
|
-
"description": "
|
|
9
|
+
"description": "Every connector on this box as its own MCP server through metro's local relay, the orchestrator guard, and the standing rules loaded at session start."
|
|
10
10
|
}
|
|
11
11
|
]
|
|
12
12
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.172",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -13,6 +13,5 @@
|
|
|
13
13
|
"mcp",
|
|
14
14
|
"connectors",
|
|
15
15
|
"relay"
|
|
16
|
-
]
|
|
17
|
-
"skills": "./skills/"
|
|
16
|
+
]
|
|
18
17
|
}
|
|
@@ -26,7 +26,7 @@ export function makeAccountStore<T extends { id: string }>(
|
|
|
26
26
|
function loadAccounts(): T[] {
|
|
27
27
|
if (!existsSync(opts.file))
|
|
28
28
|
return die(
|
|
29
|
-
`no accounts file ${opts.file} — the daemon writes it from
|
|
29
|
+
`no accounts file ${opts.file} — the daemon writes it from ~/.metro/agents/agent.json at boot; attach an account to this station from the page first`,
|
|
30
30
|
);
|
|
31
31
|
chmodIfExists(opts.file);
|
|
32
32
|
let raw: T[];
|
|
@@ -44,7 +44,7 @@ export class InboundRelay {
|
|
|
44
44
|
private readonly pendingAttachments = new Map<string, PendingMsg>();
|
|
45
45
|
private readonly seenEvents = new Map<string, number>();
|
|
46
46
|
private readonly allowedLines = new Set<string>();
|
|
47
|
-
private readonly pendingPermissions = new
|
|
47
|
+
private readonly pendingPermissions = new Map<string, string>();
|
|
48
48
|
private readonly sentIds = new Set<string>();
|
|
49
49
|
private lastLine: string | undefined;
|
|
50
50
|
|
|
@@ -56,9 +56,13 @@ export class InboundRelay {
|
|
|
56
56
|
return this.lastLine;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
registerPermission(requestId: string): void {
|
|
60
|
-
this.pendingPermissions.
|
|
61
|
-
|
|
59
|
+
registerPermission(requestId: string, line: string): void {
|
|
60
|
+
this.pendingPermissions.set(requestId, line);
|
|
61
|
+
while (this.pendingPermissions.size > PENDING_PERMISSIONS_MAX) {
|
|
62
|
+
const oldest = this.pendingPermissions.keys().next();
|
|
63
|
+
if (oldest.done) break;
|
|
64
|
+
this.pendingPermissions.delete(oldest.value);
|
|
65
|
+
}
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
noteSent(messageId: string): void {
|
|
@@ -104,6 +108,7 @@ export class InboundRelay {
|
|
|
104
108
|
from: ctx.from,
|
|
105
109
|
station: ctx.station,
|
|
106
110
|
...senderMeta(ctx),
|
|
111
|
+
...ctx.reply,
|
|
107
112
|
kind: note.kind,
|
|
108
113
|
mime: p.mime ?? '',
|
|
109
114
|
name: note.name,
|
|
@@ -149,6 +154,7 @@ export class InboundRelay {
|
|
|
149
154
|
line_name: e.lineName,
|
|
150
155
|
from_name: e.fromName,
|
|
151
156
|
...displayNameMeta(e.fromDisplayName),
|
|
157
|
+
...e.reply,
|
|
152
158
|
},
|
|
153
159
|
});
|
|
154
160
|
}
|
|
@@ -206,6 +212,7 @@ export class InboundRelay {
|
|
|
206
212
|
lineName: str(ev.lineName),
|
|
207
213
|
fromName: str(ev.fromName),
|
|
208
214
|
fromDisplayName: str(ev.fromDisplayName),
|
|
215
|
+
reply: replyMeta(ev, this.sentIds),
|
|
209
216
|
attachments: atts.map((a) => ({ kind: a.kind, name: a.name })),
|
|
210
217
|
saved: new Set<number>(),
|
|
211
218
|
timer: setTimeout(() => {
|
|
@@ -253,12 +260,12 @@ export class InboundRelay {
|
|
|
253
260
|
});
|
|
254
261
|
}
|
|
255
262
|
|
|
256
|
-
private async handlePermissionReply(text: string): Promise<boolean> {
|
|
263
|
+
private async handlePermissionReply(text: string, line: string): Promise<boolean> {
|
|
257
264
|
const m = PERMISSION_REPLY_RE.exec(text);
|
|
258
265
|
if (m?.[1] === undefined || m[2] === undefined || !this.pendingPermissions.size)
|
|
259
266
|
return false;
|
|
260
267
|
const id = m[2].toLowerCase();
|
|
261
|
-
if (
|
|
268
|
+
if (this.pendingPermissions.get(id) !== line) return false;
|
|
262
269
|
this.pendingPermissions.delete(id);
|
|
263
270
|
await this.notify('notifications/claude/channel/permission', {
|
|
264
271
|
request_id: id,
|
|
@@ -315,7 +322,7 @@ export class InboundRelay {
|
|
|
315
322
|
this.deps.log('drop: empty message', base.station, base.line, str(ev.messageId));
|
|
316
323
|
return;
|
|
317
324
|
}
|
|
318
|
-
if (base.evType === 'msg' && (await this.handlePermissionReply(base.text)))
|
|
325
|
+
if (base.evType === 'msg' && (await this.handlePermissionReply(base.text, base.line)))
|
|
319
326
|
return;
|
|
320
327
|
await this.notify('notifications/claude/channel', {
|
|
321
328
|
content:
|
|
@@ -15,6 +15,7 @@ export interface PendingMsg {
|
|
|
15
15
|
lineName: string;
|
|
16
16
|
fromName: string;
|
|
17
17
|
fromDisplayName: string;
|
|
18
|
+
reply: Record<string, string>;
|
|
18
19
|
attachments: PendingAtt[];
|
|
19
20
|
saved: Set<number>;
|
|
20
21
|
timer: ReturnType<typeof setTimeout>;
|
|
@@ -30,6 +31,7 @@ export interface MediaCtx {
|
|
|
30
31
|
lineName?: string;
|
|
31
32
|
fromName?: string;
|
|
32
33
|
fromDisplayName?: string;
|
|
34
|
+
reply?: Record<string, string>;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export function capSet(set: Set<string>, max: number): void {
|
|
@@ -69,6 +71,7 @@ export function takeMediaCtx(buf: PendingMsg): MediaCtx {
|
|
|
69
71
|
lineName: buf.lineName,
|
|
70
72
|
fromName: buf.fromName,
|
|
71
73
|
fromDisplayName: buf.fromDisplayName,
|
|
74
|
+
reply: buf.reply,
|
|
72
75
|
};
|
|
73
76
|
buf.text = '';
|
|
74
77
|
return ctx;
|
|
@@ -70,12 +70,49 @@ function sweep(now: number): void {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
const CHECK_MS = 30_000;
|
|
74
|
+
|
|
75
|
+
interface Checked<T> {
|
|
76
|
+
at: number;
|
|
77
|
+
value: T;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const checks: { installed?: Checked<boolean>; account?: Checked<ClaudeAccount> } = {};
|
|
81
|
+
|
|
82
|
+
function remembered<T>(slot: Checked<T> | undefined, read: () => T, keep: (c: Checked<T>) => void): T {
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
if (slot !== undefined && now - slot.at < CHECK_MS) return slot.value;
|
|
85
|
+
const value = read();
|
|
86
|
+
keep({ at: now, value });
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function forgetClaudeChecks(): void {
|
|
91
|
+
delete checks.installed;
|
|
92
|
+
delete checks.account;
|
|
93
|
+
}
|
|
94
|
+
|
|
73
95
|
export function claudeInstalled(): boolean {
|
|
74
|
-
|
|
75
|
-
|
|
96
|
+
return remembered(checks.installed, () => {
|
|
97
|
+
const run = spawnSync('claude', ['--version'], { stdio: 'ignore' });
|
|
98
|
+
return run.error === undefined && run.status === 0;
|
|
99
|
+
}, (c) => {
|
|
100
|
+
checks.installed = c;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface ClaudeAccount {
|
|
105
|
+
signedIn: boolean;
|
|
106
|
+
account: string | null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function claudeAccount(): ClaudeAccount {
|
|
110
|
+
return remembered(checks.account, readAccount, (c) => {
|
|
111
|
+
checks.account = c;
|
|
112
|
+
});
|
|
76
113
|
}
|
|
77
114
|
|
|
78
|
-
|
|
115
|
+
function readAccount(): ClaudeAccount {
|
|
79
116
|
const run = spawnSync('claude', STATUS_COMMAND, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
80
117
|
if (run.error !== undefined || typeof run.stdout !== 'string') return { signedIn: false, account: null };
|
|
81
118
|
try {
|
|
@@ -126,6 +163,7 @@ export function startClaudeLogin(deps: LoginDeps = {}, now = Date.now()): LoginV
|
|
|
126
163
|
session.state = code === 0 ? 'done' : 'failed';
|
|
127
164
|
if (code !== 0) session.error = `the login ended with status ${String(code)}`;
|
|
128
165
|
terminal.close();
|
|
166
|
+
forgetClaudeChecks();
|
|
129
167
|
const onboarding = code === 0 ? markOnboardingDone() : 'skipped';
|
|
130
168
|
log.info({ state: session.state, onboarding }, 'claude-login: the official login finished');
|
|
131
169
|
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
4
|
import { claudeDir, listClaudeProjects } from './files.js';
|
|
@@ -68,8 +68,17 @@ function projectEntries(dir: string): ClaudeSettingsFile[] {
|
|
|
68
68
|
return out;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
const sameFile = (a: string, b: string): boolean => {
|
|
72
|
+
try {
|
|
73
|
+
return realpathSync(a) === realpathSync(b);
|
|
74
|
+
} catch {
|
|
75
|
+
return a === b;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
71
79
|
export function listClaudeSettings(dir = claudeDir()): ClaudeSettingsFile[] {
|
|
72
|
-
|
|
80
|
+
const user = entryOf(USER_ID, 'user', 'This machine', join(dir, 'settings.json'));
|
|
81
|
+
return [user, ...projectEntries(dir).filter((p) => !sameFile(p.path, user.path))];
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
function assertSettingsJson(text: string): void {
|
|
@@ -146,6 +146,18 @@ function startKeepalive(res: ServerResponse): Keepalive {
|
|
|
146
146
|
};
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
function drainedOrClosed(res: ServerResponse): Promise<void> {
|
|
150
|
+
return new Promise((resolve) => {
|
|
151
|
+
const done = (): void => {
|
|
152
|
+
res.off('drain', done);
|
|
153
|
+
res.off('close', done);
|
|
154
|
+
resolve();
|
|
155
|
+
};
|
|
156
|
+
res.once('drain', done);
|
|
157
|
+
res.once('close', done);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
149
161
|
async function pumpBody(
|
|
150
162
|
res: ServerResponse,
|
|
151
163
|
body: ReadableStream<Uint8Array>,
|
|
@@ -156,11 +168,7 @@ async function pumpBody(
|
|
|
156
168
|
const { done, value } = await reader.read();
|
|
157
169
|
if (done) return;
|
|
158
170
|
touch();
|
|
159
|
-
|
|
160
|
-
if (!ok)
|
|
161
|
-
await new Promise((resolve) => {
|
|
162
|
-
res.once('drain', resolve);
|
|
163
|
-
});
|
|
171
|
+
if (!res.write(value)) await drainedOrClosed(res);
|
|
164
172
|
}
|
|
165
173
|
}
|
|
166
174
|
|
|
@@ -229,13 +237,29 @@ async function relayExchange(
|
|
|
229
237
|
res.once('finish', () => {
|
|
230
238
|
res.socket?.removeListener('close', bail);
|
|
231
239
|
});
|
|
240
|
+
try {
|
|
241
|
+
await relayOnce(req, res, connectorId, deps, control.signal);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
if (!control.signal.aborted) throw err;
|
|
244
|
+
log.debug({ connector: connectorId }, 'relay: the client left before the answer ended');
|
|
245
|
+
if (!res.writableEnded) res.end();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function relayOnce(
|
|
250
|
+
req: IncomingMessage,
|
|
251
|
+
res: ServerResponse,
|
|
252
|
+
connectorId: string,
|
|
253
|
+
deps: RelayApiDeps,
|
|
254
|
+
signal: AbortSignal,
|
|
255
|
+
): Promise<void> {
|
|
232
256
|
const body = req.method === 'POST' ? await readCapped(req) : null;
|
|
233
257
|
const out = await exchange(
|
|
234
258
|
req,
|
|
235
259
|
connectorId,
|
|
236
260
|
deps,
|
|
237
261
|
body,
|
|
238
|
-
|
|
262
|
+
signal,
|
|
239
263
|
);
|
|
240
264
|
if (out.kind === 'missing') {
|
|
241
265
|
answer(res, 404, { error: 'no such connector' });
|
|
@@ -216,19 +216,25 @@ export function localMarkSignedOut(id: string, dir = agentsDir()): void {
|
|
|
216
216
|
replace(dir, signedOut(row));
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
async function freshAuth(
|
|
219
|
+
async function freshAuth(row: LocalConnectorRow, dir: string): Promise<ConnectorAuth> {
|
|
220
|
+
const auth = row.config.auth;
|
|
220
221
|
if (auth.kind !== 'oauth' || !oauthExpired(auth)) return auth;
|
|
221
|
-
return
|
|
222
|
+
return refreshOnce(row, auth, dir);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function updateRow(dir: string, id: string, config: Partial<LocalConnectorRow['config']>): void {
|
|
226
|
+
const now = readLocalConnectors(dir).find((r) => r.id === id);
|
|
227
|
+
if (now !== undefined) replace(dir, { ...now, config: { ...now.config, ...config } });
|
|
222
228
|
}
|
|
223
229
|
|
|
224
230
|
export async function localVerifyConnector(subject: string, id: string, dir = agentsDir()): Promise<ConnectorCheck> {
|
|
225
231
|
const row = rowOrThrow(subject, id, dir);
|
|
226
232
|
try {
|
|
227
233
|
const url = parseConnectorUrl(row.url);
|
|
228
|
-
const auth = await freshAuth(row
|
|
234
|
+
const auth = await freshAuth(row, dir);
|
|
229
235
|
const verified = stamp(await verifyRemoteMcp(url, auth));
|
|
230
236
|
const oauth = row.config.oauth || (await oauthCapable(url, auth));
|
|
231
|
-
|
|
237
|
+
updateRow(dir, row.id, { auth, verified, oauth });
|
|
232
238
|
return { id: row.id, name: row.name, ok: true, verified };
|
|
233
239
|
} catch (err) {
|
|
234
240
|
if (!(err instanceof ConnectorVerifyError)) throw err;
|
|
@@ -239,9 +245,7 @@ export async function localVerifyConnector(subject: string, id: string, dir = ag
|
|
|
239
245
|
export async function localConnectorTools(subject: string, id: string, dir = agentsDir()): Promise<RemoteTool[]> {
|
|
240
246
|
const row = rowOrThrow(subject, id, dir);
|
|
241
247
|
const url = parseConnectorUrl(row.url);
|
|
242
|
-
|
|
243
|
-
if (auth !== row.config.auth) replace(dir, { ...row, config: { ...row.config, auth } });
|
|
244
|
-
return listRemoteTools(url, auth);
|
|
248
|
+
return listRemoteTools(url, await freshAuth(row, dir));
|
|
245
249
|
}
|
|
246
250
|
|
|
247
251
|
export async function localRenameConnector(subject: string, id: string, raw: string, dir = agentsDir()): Promise<Connector> {
|
|
@@ -264,7 +268,7 @@ function refreshOnce(row: LocalConnectorRow, auth: OAuthAuth, dir: string): Prom
|
|
|
264
268
|
if (running !== undefined) return running;
|
|
265
269
|
const job = refreshOAuth(auth, parseConnectorUrl(row.url).toString())
|
|
266
270
|
.then((fresh) => {
|
|
267
|
-
|
|
271
|
+
updateRow(dir, row.id, { auth: fresh });
|
|
268
272
|
return fresh;
|
|
269
273
|
})
|
|
270
274
|
.finally(() => {
|
|
@@ -172,7 +172,7 @@ function writeEvent(res: ServerResponse, message: EventStreamMessage, scanner: U
|
|
|
172
172
|
res.write(`event: error\ndata: ${JSON.stringify(body)}\n\n`);
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
async function relayStream(upstream: Response, res: ServerResponse, watch: Watch): Promise<void> {
|
|
175
|
+
async function relayStream(upstream: Response, res: ServerResponse, watch: Watch, key: string): Promise<void> {
|
|
176
176
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
|
|
177
177
|
const body = upstream.body;
|
|
178
178
|
if (body === null) {
|
|
@@ -180,7 +180,7 @@ async function relayStream(upstream: Response, res: ServerResponse, watch: Watch
|
|
|
180
180
|
return;
|
|
181
181
|
}
|
|
182
182
|
const ping = setInterval(() => res.write('event: ping\ndata: {"type":"ping"}\n\n'), PING_MS);
|
|
183
|
-
const scanner = new UsageScanner(
|
|
183
|
+
const scanner = new UsageScanner(key);
|
|
184
184
|
try {
|
|
185
185
|
const decoder = new EventStreamDecoder();
|
|
186
186
|
const reader = body.getReader();
|
|
@@ -220,11 +220,11 @@ export async function bedrockMessages(
|
|
|
220
220
|
return;
|
|
221
221
|
}
|
|
222
222
|
if (rewritten.stream) {
|
|
223
|
-
await relayStream(upstream, res, up.watch);
|
|
223
|
+
await relayStream(upstream, res, up.watch, up.settings.id);
|
|
224
224
|
return;
|
|
225
225
|
}
|
|
226
226
|
const text = await upstream.text();
|
|
227
|
-
const scanner = new UsageScanner(
|
|
227
|
+
const scanner = new UsageScanner(up.settings.id);
|
|
228
228
|
scanner.feed(text);
|
|
229
229
|
scanner.done();
|
|
230
230
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
@@ -23,12 +23,22 @@ export interface CodexDeps {
|
|
|
23
23
|
save: (id: string, tokens: CodexTokens) => void;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
interface Slot {
|
|
27
27
|
refreshing: Promise<CodexTokens> | null;
|
|
28
28
|
latest: CodexTokens | null;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export
|
|
31
|
+
export type CodexState = Map<string, Slot>;
|
|
32
|
+
|
|
33
|
+
export const sharedCodexState: CodexState = new Map();
|
|
34
|
+
|
|
35
|
+
function slotFor(state: CodexState, id: string): Slot {
|
|
36
|
+
const found = state.get(id);
|
|
37
|
+
if (found !== undefined) return found;
|
|
38
|
+
const made: Slot = { refreshing: null, latest: null };
|
|
39
|
+
state.set(id, made);
|
|
40
|
+
return made;
|
|
41
|
+
}
|
|
32
42
|
|
|
33
43
|
const OS_NAMES: Record<string, string> = { darwin: 'Mac OS', linux: 'Linux', win32: 'Windows' };
|
|
34
44
|
|
|
@@ -58,22 +68,23 @@ function headersFor(tokens: CodexTokens, sessionId: string): Record<string, stri
|
|
|
58
68
|
const newerThan = (a: CodexTokens, b: CodexTokens): boolean => Date.parse(a.savedAt) > Date.parse(b.savedAt);
|
|
59
69
|
|
|
60
70
|
function refreshed(id: string, tokens: CodexTokens, deps: CodexDeps, state: CodexState): Promise<CodexTokens> {
|
|
61
|
-
const
|
|
71
|
+
const slot = slotFor(state, id);
|
|
72
|
+
const latest = slot.latest;
|
|
62
73
|
if (latest !== null && newerThan(latest, tokens) && !tokensStale(latest)) return Promise.resolve(latest);
|
|
63
|
-
if (
|
|
74
|
+
if (slot.refreshing !== null) return slot.refreshing;
|
|
64
75
|
const run = refreshTokens(tokens, deps.issuer, deps.fetchImpl)
|
|
65
76
|
.then((fresh) => {
|
|
66
77
|
deps.save(id, fresh);
|
|
67
|
-
|
|
78
|
+
slot.latest = fresh;
|
|
68
79
|
return fresh;
|
|
69
80
|
})
|
|
70
81
|
.catch((err: unknown) => {
|
|
71
82
|
throw new GatewayError(403, 'permission_error', `Codex sign-in expired (${errMsg(err)}): connect again on the Model page`);
|
|
72
83
|
})
|
|
73
84
|
.finally(() => {
|
|
74
|
-
|
|
85
|
+
slot.refreshing = null;
|
|
75
86
|
});
|
|
76
|
-
|
|
87
|
+
slot.refreshing = run;
|
|
77
88
|
return run;
|
|
78
89
|
}
|
|
79
90
|
|
|
@@ -12,8 +12,8 @@ import {
|
|
|
12
12
|
import { addBeta, anthropicHeaders, forwardedHeaders, GatewayError, parseJson, pipeResponse, readBody, sendError, watchUpstream } from './forward.js';
|
|
13
13
|
import { BINDING_BETA, cappedEffort, effortToApply, plannedEffort, withBlockBinding, withEffort, withThinkingFor } from './effort.js';
|
|
14
14
|
import { notReady, readModelConfig, resolveRoute, routeLabel, setCodexAuth, setGeminiAuth, writeModelConfig, type Connection, type ModelConfig, type Route } from './model-config.js';
|
|
15
|
-
import { codexCount, codexMessages,
|
|
16
|
-
import {
|
|
15
|
+
import { codexCount, codexMessages, sharedCodexState } from './codex.js';
|
|
16
|
+
import { geminiCount, geminiMessages, sharedGeminiState } from './gemini.js';
|
|
17
17
|
import type { GeminiDeps } from './gemini.js';
|
|
18
18
|
import type { CodexDeps } from './codex.js';
|
|
19
19
|
import type { GeminiTokens } from './gemini-auth.js';
|
|
@@ -41,16 +41,14 @@ export interface GatewayDeps {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
const learned: Adaptations = freshAdaptations();
|
|
44
|
-
const codexState = freshCodexState();
|
|
45
|
-
const geminiState = freshGeminiState();
|
|
46
44
|
|
|
47
45
|
export function resetGatewayState(): void {
|
|
48
46
|
forgetServed();
|
|
49
47
|
forgetUsage();
|
|
50
48
|
learned.fields.clear();
|
|
51
49
|
learned.dropBetas = false;
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
sharedCodexState.clear();
|
|
51
|
+
sharedGeminiState.clear();
|
|
54
52
|
}
|
|
55
53
|
|
|
56
54
|
const saveCodexTokens = (id: string, tokens: CodexTokens): void => {
|
|
@@ -197,11 +195,11 @@ async function toSubscription(req: IncomingMessage, res: ServerResponse, path: s
|
|
|
197
195
|
const conn = route.connection;
|
|
198
196
|
if (conn.provider === 'gemini') {
|
|
199
197
|
if (path === COUNT) geminiCount(res, body);
|
|
200
|
-
else await geminiMessages(req, res, body, route.model, conn, { save: saveGeminiTokens, ...deps.gemini },
|
|
198
|
+
else await geminiMessages(req, res, body, route.model, conn, { save: saveGeminiTokens, ...deps.gemini }, sharedGeminiState, watchUpstream(res));
|
|
201
199
|
return;
|
|
202
200
|
}
|
|
203
201
|
if (path === COUNT) codexCount(res, body);
|
|
204
|
-
else await codexMessages(req, res, body, route.model, conn, { save: saveCodexTokens, ...deps.codex },
|
|
202
|
+
else await codexMessages(req, res, body, route.model, conn, { save: saveCodexTokens, ...deps.codex }, sharedCodexState, watchUpstream(res));
|
|
205
203
|
}
|
|
206
204
|
|
|
207
205
|
function shapedFor(req: IncomingMessage, sent: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -39,6 +39,7 @@ export class GeminiStreamTranslator {
|
|
|
39
39
|
private toolCalls = 0;
|
|
40
40
|
private usage: Usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 };
|
|
41
41
|
private stop = 'end_turn';
|
|
42
|
+
private finishedBy: string | null = null;
|
|
42
43
|
private refusal: string | null = null;
|
|
43
44
|
private readonly model: string;
|
|
44
45
|
private readonly restore: (name: string) => string;
|
|
@@ -115,6 +116,7 @@ export class GeminiStreamTranslator {
|
|
|
115
116
|
const parts = Array.isArray(content.parts) ? content.parts.filter(isRecord) : [];
|
|
116
117
|
const out = parts.map((part) => this.part(part)).join('');
|
|
117
118
|
const finish = str(candidate.finishReason);
|
|
119
|
+
if (finish !== '') this.finishedBy = finish;
|
|
118
120
|
const stop = STOP_OF[finish];
|
|
119
121
|
if (stop !== undefined) this.stop = stop;
|
|
120
122
|
else if (REFUSALS.has(finish)) this.refusal = `Gemini stopped the answer (${finish})`;
|
|
@@ -137,8 +139,9 @@ export class GeminiStreamTranslator {
|
|
|
137
139
|
if (this.done) return '';
|
|
138
140
|
this.done = true;
|
|
139
141
|
let out = this.start() + this.closeOpen();
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
+
const unfinished = this.finishedBy === null && this.refusal === null ? 'Gemini ended the stream before completing the response' : null;
|
|
143
|
+
const failure = error ?? this.refusal ?? unfinished;
|
|
144
|
+
if (failure !== null)
|
|
142
145
|
return out + frame('error', { type: 'error', error: { type: 'api_error', message: failure } });
|
|
143
146
|
const stop = this.toolCalls > 0 ? 'tool_use' : this.stop;
|
|
144
147
|
out += frame('message_delta', { type: 'message_delta', delta: { stop_reason: stop, stop_sequence: null }, usage: this.usage });
|
|
@@ -22,32 +22,43 @@ export interface GeminiDeps {
|
|
|
22
22
|
save: (id: string, tokens: GeminiTokens) => void;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
interface Slot {
|
|
26
26
|
refreshing: Promise<GeminiTokens> | null;
|
|
27
27
|
latest: GeminiTokens | null;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
export
|
|
30
|
+
export type GeminiState = Map<string, Slot>;
|
|
31
|
+
|
|
32
|
+
export const sharedGeminiState: GeminiState = new Map();
|
|
33
|
+
|
|
34
|
+
function slotFor(state: GeminiState, id: string): Slot {
|
|
35
|
+
const found = state.get(id);
|
|
36
|
+
if (found !== undefined) return found;
|
|
37
|
+
const made: Slot = { refreshing: null, latest: null };
|
|
38
|
+
state.set(id, made);
|
|
39
|
+
return made;
|
|
40
|
+
}
|
|
31
41
|
|
|
32
42
|
const newerThan = (a: GeminiTokens, b: GeminiTokens): boolean => Date.parse(a.savedAt) > Date.parse(b.savedAt);
|
|
33
43
|
|
|
34
44
|
function refreshed(id: string, tokens: GeminiTokens, deps: GeminiDeps, state: GeminiState): Promise<GeminiTokens> {
|
|
35
|
-
const
|
|
45
|
+
const slot = slotFor(state, id);
|
|
46
|
+
const latest = slot.latest;
|
|
36
47
|
if (latest !== null && newerThan(latest, tokens) && !tokensStale(latest)) return Promise.resolve(latest);
|
|
37
|
-
if (
|
|
48
|
+
if (slot.refreshing !== null) return slot.refreshing;
|
|
38
49
|
const run = refreshTokens(tokens, deps.tokenBase, deps.fetchImpl)
|
|
39
50
|
.then((fresh) => {
|
|
40
51
|
deps.save(id, fresh);
|
|
41
|
-
|
|
52
|
+
slot.latest = fresh;
|
|
42
53
|
return fresh;
|
|
43
54
|
})
|
|
44
55
|
.catch((err: unknown) => {
|
|
45
56
|
throw new GatewayError(403, 'permission_error', `Google sign-in expired (${errMsg(err)}): connect again on the Model page`);
|
|
46
57
|
})
|
|
47
58
|
.finally(() => {
|
|
48
|
-
|
|
59
|
+
slot.refreshing = null;
|
|
49
60
|
});
|
|
50
|
-
|
|
61
|
+
slot.refreshing = run;
|
|
51
62
|
return run;
|
|
52
63
|
}
|
|
53
64
|
|