@stage-labs/metro 0.1.0-beta.164 → 0.1.0-beta.166
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/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/orchestrator.md +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/files.ts +8 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +28 -13
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +19 -19
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/effort.ts +70 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +9 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +83 -51
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-translate.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +35 -18
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +66 -238
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +202 -141
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +195 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +70 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/served.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +25 -23
- package/runtime/runtime.json +1 -1
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.166",
|
|
4
4
|
"description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.166",
|
|
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",
|
|
@@ -11,6 +11,12 @@ You are the main thread of a Claude Code session that is connected to chat throu
|
|
|
11
11
|
|
|
12
12
|
**Never wait on the terminal.** `AskUserQuestion`, plan mode and anything that blocks for a person at the keyboard are denied everywhere, main thread and subagents alike. When something is ambiguous, do what does not depend on it, choose the most reasonable reading, say so in your reply, and if a real decision is needed ask over chat with `send` and carry on. Tool-approval prompts reach the chat too; the person answers `yes <id>` or `no <id>` there.
|
|
13
13
|
|
|
14
|
+
**Finish what was asked.** You work on your own. Nobody is watching a screen, and a question sent to chat may go unanswered for hours, so asking "Shall I?" about work already requested simply stops it. Anything reversible that follows from the request, do. Stop only for a destructive action or a genuine change of scope. Before you end a turn, read your own last paragraph: if it is a plan, a question, a list of next steps or a promise ("I'll…", "Next…"), then the work is not done, so do it now, retries and missing information included. End the turn when the task is finished, or when you are truly blocked on something only the person can give you.
|
|
15
|
+
|
|
16
|
+
The exception is a person describing a problem, asking a question or thinking out loud. There the answer IS the deliverable: reply, and change nothing until they ask.
|
|
17
|
+
|
|
18
|
+
**The whole request is the deliverable.** Do not quietly make it smaller, larger or different. Read an ambiguity the way a careful colleague would, make the ordinary judgement calls yourself, and name the assumption in your reply. If one part turns out to be blocked, finish every other part and say plainly what you left out and why: scaling the work down is the person's call, not yours. Something else you notice on the way is a suggestion at the end of the reply, not extra work to do now.
|
|
19
|
+
|
|
14
20
|
**Report like a relay.** A worker's report is the only evidence you have, so pass on what it verified and say plainly what it did not. Short messages on chat: lead with the outcome, one idea per sentence, no tool names, no file paths unless the person needs them. Send a file out with `create_upload` (the curl line it hands back runs in a worker) and then `send` with the upload id.
|
|
15
21
|
|
|
16
22
|
**Keep going.** Long tasks run as background agents and loops; check in over chat when something lands rather than going quiet. The session is restarted by the daemon if it exits, so an interrupted task is resumed from chat, not from memory.
|
|
@@ -49,6 +49,7 @@ import { pipeline } from 'node:stream/promises';
|
|
|
49
49
|
import {
|
|
50
50
|
claudeDir,
|
|
51
51
|
deleteClaudeSession,
|
|
52
|
+
deleteMemoryFile,
|
|
52
53
|
listClaudeProjects,
|
|
53
54
|
listClaudeSessions,
|
|
54
55
|
listMemory,
|
|
@@ -259,6 +260,7 @@ function removed(rest: string[], query: URLSearchParams, dir: string): unknown {
|
|
|
259
260
|
const [head = '', item = ''] = rest;
|
|
260
261
|
if (rest.length !== 2) throw new ApiError('method not allowed', 405);
|
|
261
262
|
if (head === 'skills') return { deleted: deleteClaudeSkill(decodeURIComponent(item), dir) };
|
|
263
|
+
if (head === 'memory') return { deleted: deleteMemoryFile(projectOf(query), decodeURIComponent(item), dir) };
|
|
262
264
|
if (head !== 'sessions') throw new ApiError('method not allowed', 405);
|
|
263
265
|
deleteClaudeSession(projectOf(query), item, dir);
|
|
264
266
|
return { deleted: item };
|
|
@@ -351,6 +351,14 @@ export function writeMemoryFile(project: string, name: string, text: string, dir
|
|
|
351
351
|
return { name: file, bytes: stat.size, modifiedAt: stat.mtime.toISOString() };
|
|
352
352
|
}
|
|
353
353
|
|
|
354
|
+
export function deleteMemoryFile(project: string, name: string, dir = claudeDir()): string {
|
|
355
|
+
const file = safeName(name, MEMORY_RE, 'memory file name');
|
|
356
|
+
const path = join(memoryDir(project, dir), file);
|
|
357
|
+
if (!existsSync(path)) throw new ApiError('no such memory file', 404);
|
|
358
|
+
rmSync(path);
|
|
359
|
+
return file;
|
|
360
|
+
}
|
|
361
|
+
|
|
354
362
|
export function readMemoryFile(project: string, name: string, dir = claudeDir()): string {
|
|
355
363
|
const path = join(memoryDir(project, dir), safeName(name, MEMORY_RE, 'memory file name'));
|
|
356
364
|
if (!existsSync(path)) throw new ApiError('no such memory file', 404);
|
|
@@ -7,7 +7,7 @@ import { isRecord } from '@metro-labs/core/is-record';
|
|
|
7
7
|
import { readJson, writeJson } from '@metro-labs/core/secure-fs';
|
|
8
8
|
import { METRO_VERSION } from '@metro-labs/core/version';
|
|
9
9
|
import { agentsDir, listAgentFiles } from '../agents/files.js';
|
|
10
|
-
import { notReady, readModelConfig } from '../gateway/model-config.js';
|
|
10
|
+
import { notReady, readModelConfig, routedConnection } from '../gateway/model-config.js';
|
|
11
11
|
import { claudeDir, listClaudeProjects } from './files.js';
|
|
12
12
|
import { claudeAccount, claudeInstalled } from './login.js';
|
|
13
13
|
import { trustFolder } from './onboarding.js';
|
|
@@ -105,7 +105,8 @@ function credentialReady(deps: SessionDeps): string | null {
|
|
|
105
105
|
if (signedIn()) return null;
|
|
106
106
|
try {
|
|
107
107
|
const cfg = readModelConfig(deps.agents ?? agentsDir());
|
|
108
|
-
|
|
108
|
+
const conn = routedConnection(cfg);
|
|
109
|
+
if (conn !== null && conn.provider !== 'anthropic' && notReady(cfg) === null) return null;
|
|
109
110
|
} catch (err) {
|
|
110
111
|
return `the Model page is not readable (${errMsg(err)})`;
|
|
111
112
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
3
|
import { dirname, join } from 'node:path';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
@@ -7,7 +8,7 @@ import { readJson, writeJson } from '@metro-labs/core/secure-fs';
|
|
|
7
8
|
import { agentsDir } from '../agents/files.js';
|
|
8
9
|
import { claudeDir } from './files.js';
|
|
9
10
|
import { stagedMarketplaceDir } from './plugin-install.js';
|
|
10
|
-
import { readModelConfig, type ModelConfig } from '../gateway/model-config.js';
|
|
11
|
+
import { readModelConfig, routedConnection, type ModelConfig } from '../gateway/model-config.js';
|
|
11
12
|
|
|
12
13
|
export const PRIVACY_ENV: Record<string, string> = {
|
|
13
14
|
DISABLE_TELEMETRY: '1',
|
|
@@ -30,7 +31,9 @@ effort: xhigh
|
|
|
30
31
|
|
|
31
32
|
You are a worker agent. The main thread that dispatched you is an orchestrator with no file, shell or network access; it can only delegate and relay messages. That means you are where the actual work happens, and the quality of your output is the quality of the result.
|
|
32
33
|
|
|
33
|
-
Work to completion. Do not hand back a partial answer with a suggestion that someone else finish it; there is no one else. If part of the task is genuinely blocked, complete every other part in full and say plainly what you left undone and why.
|
|
34
|
+
Work to completion. Do not hand back a partial answer with a suggestion that someone else finish it; there is no one else. If part of the task is genuinely blocked, complete every other part in full and say plainly what you left undone and why. Before you finish, read your own last paragraph: if it is a plan, a question or a promise about work you have not done, do that work now.
|
|
35
|
+
|
|
36
|
+
Keep the change to what the task asks for. A pre-existing bug, a performance problem or behaviour the task does not mention is a follow-up to report at the end, not something to fix now, unless the requested behaviour cannot work without it. Where the task is ambiguous, build the reading its wording and the surrounding code support best, and state that assumption. Commit tests only where the task asks for them or the repository already keeps tests for that kind of change, sized like the neighbouring test files; scratch checks you used to verify yourself need not be kept. This is about extras only: every behaviour the task does ask for, implement completely.
|
|
34
37
|
|
|
35
38
|
Never call AskUserQuestion or ExitPlanMode. They are blocked by policy, because nobody is watching the terminal around the clock and a blocking prompt stalls indefinitely. When you hit an ambiguity: do everything that does not depend on it, then choose the most reasonable interpretation, state the assumption explicitly in your report, and continue.
|
|
36
39
|
|
|
@@ -44,7 +47,7 @@ export interface SetupDeps {
|
|
|
44
47
|
env?: NodeJS.ProcessEnv;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
export type Placed = 'written' | 'present' | 'missing';
|
|
50
|
+
export type Placed = 'written' | 'present' | 'updated' | 'missing';
|
|
48
51
|
export type SettingsOutcome = 'written' | 'unchanged' | 'unreadable';
|
|
49
52
|
|
|
50
53
|
export interface SetupReport {
|
|
@@ -127,11 +130,21 @@ export function guidancePath(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
127
130
|
return fileURLToPath(new URL(`../../../../plugin/${GUIDANCE}`, import.meta.url));
|
|
128
131
|
}
|
|
129
132
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
+
const PRIOR_WORKER: ReadonlySet<string> = new Set(['08a0cd8710df285d6512245246bb8b658b7cfc7f3e89490514ee5376779eb2ef']);
|
|
134
|
+
const PRIOR_SKILL: ReadonlySet<string> = new Set(['0b3d122ed95beff09d579cf912cd4238e1db524c41fce4b314de57d6ff5908ad']);
|
|
135
|
+
|
|
136
|
+
const digest = (text: string): string => createHash('sha256').update(text).digest('hex');
|
|
137
|
+
|
|
138
|
+
export function placeFile(path: string, text: string, prior: ReadonlySet<string> = new Set()): Placed {
|
|
139
|
+
if (!existsSync(path)) {
|
|
140
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
141
|
+
writeFileSync(path, text, { mode: 0o644 });
|
|
142
|
+
return 'written';
|
|
143
|
+
}
|
|
144
|
+
const current = readFileSync(path, 'utf8');
|
|
145
|
+
if (current === text || !prior.has(digest(current))) return 'present';
|
|
133
146
|
writeFileSync(path, text, { mode: 0o644 });
|
|
134
|
-
return '
|
|
147
|
+
return 'updated';
|
|
135
148
|
}
|
|
136
149
|
|
|
137
150
|
function readSettings(path: string): Record<string, unknown> | null {
|
|
@@ -172,9 +185,11 @@ const workerPath = (dir: string): string => join(dir, 'agents', 'worker.md');
|
|
|
172
185
|
|
|
173
186
|
function placeSkill(dir: string, guidance: string): Placed {
|
|
174
187
|
if (!existsSync(guidance)) return 'missing';
|
|
175
|
-
return
|
|
188
|
+
return placeFile(skillPath(dir), readFileSync(guidance, 'utf8'), PRIOR_SKILL);
|
|
176
189
|
}
|
|
177
190
|
|
|
191
|
+
const written = (placed: Placed): boolean => placed === 'written' || placed === 'updated';
|
|
192
|
+
|
|
178
193
|
export function ensureClaudeSetup(deps: SetupDeps = {}): SetupReport {
|
|
179
194
|
const dir = deps.dir ?? claudeDir();
|
|
180
195
|
const agents = deps.agents ?? agentsDir();
|
|
@@ -182,12 +197,12 @@ export function ensureClaudeSetup(deps: SetupDeps = {}): SetupReport {
|
|
|
182
197
|
const report: SetupReport = {
|
|
183
198
|
privacy,
|
|
184
199
|
guard: 'plugin',
|
|
185
|
-
worker:
|
|
200
|
+
worker: placeFile(workerPath(dir), WORKER_AGENT, PRIOR_WORKER),
|
|
186
201
|
skill: placeSkill(dir, deps.guidance ?? guidancePath(deps.env)),
|
|
187
202
|
settings: applyPrivacy(dir, privacy),
|
|
188
203
|
};
|
|
189
204
|
syncAvailableModelsQuietly({ ...deps, dir, agents });
|
|
190
|
-
if (report.worker
|
|
205
|
+
if (written(report.worker) || written(report.skill) || report.settings === 'written')
|
|
191
206
|
log.info(report, 'claude-setup: applied the Claude Code setup for a metro box');
|
|
192
207
|
if (report.settings === 'unreadable') log.warn({ path: join(dir, 'settings.json') }, 'claude-setup: settings.json is not valid JSON, so the privacy settings were not written');
|
|
193
208
|
return report;
|
|
@@ -222,9 +237,9 @@ const MODELS_KEY = 'availableModels';
|
|
|
222
237
|
const ENFORCE_KEY = 'enforceAvailableModels';
|
|
223
238
|
|
|
224
239
|
export function routeOf(cfg: ModelConfig): string | null {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return
|
|
240
|
+
const conn = routedConnection(cfg);
|
|
241
|
+
if (conn === null || conn.provider === 'anthropic' || conn.model === '') return null;
|
|
242
|
+
return `${conn.provider}:${conn.model}`;
|
|
228
243
|
}
|
|
229
244
|
|
|
230
245
|
function withAvailableModels(settings: Record<string, unknown>, route: string | null, metroWrote: boolean): Record<string, unknown> {
|
|
@@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
2
2
|
import { log } from '@metro-labs/core/log';
|
|
3
3
|
import { EventStreamDecoder, type EventStreamMessage } from './eventstream.js';
|
|
4
4
|
import { errorFrame, GatewayError, idleMessage, providerStatus, sendError, upstreamMessage, type Watch } from './forward.js';
|
|
5
|
-
import type {
|
|
5
|
+
import type { Connection } from './model-config.js';
|
|
6
6
|
import { UsageScanner } from './usage.js';
|
|
7
7
|
|
|
8
8
|
const ANTHROPIC_VERSION = 'bedrock-2023-05-31';
|
|
@@ -18,7 +18,7 @@ export interface Adaptations {
|
|
|
18
18
|
export const freshAdaptations = (): Adaptations => ({ fields: new Set(), dropBetas: false });
|
|
19
19
|
|
|
20
20
|
export interface BedrockUpstream {
|
|
21
|
-
settings:
|
|
21
|
+
settings: Connection;
|
|
22
22
|
base: string;
|
|
23
23
|
learned: Adaptations;
|
|
24
24
|
watch: Watch;
|
|
@@ -253,7 +253,7 @@ export async function bedrockCount(
|
|
|
253
253
|
res.end(JSON.stringify({ input_tokens: counted ?? estimateTokens(rewritten.body) }));
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
export function assertBedrockReady(settings:
|
|
256
|
+
export function assertBedrockReady(settings: Connection): void {
|
|
257
257
|
if (settings.apiKey === '' || settings.region === '')
|
|
258
258
|
throw new GatewayError(400, 'invalid_request_error', 'Bedrock needs an API key and a region: add them on the Model page.');
|
|
259
259
|
}
|
|
@@ -7,7 +7,7 @@ import { refreshTokens, tokensStale, type CodexTokens } from './codex-auth.js';
|
|
|
7
7
|
import { assembleMessage, CodexEventTranslator, parseEvent, SseParser } from './codex-stream.js';
|
|
8
8
|
import { ToolNames, toResponsesRequest } from './codex-translate.js';
|
|
9
9
|
import { GatewayError, idleMessage, providerStatus, sendError, upstreamMessage, type Watch } from './forward.js';
|
|
10
|
-
import type {
|
|
10
|
+
import type { Connection } from './model-config.js';
|
|
11
11
|
import { noteUsageHeaders, UsageScanner } from './usage.js';
|
|
12
12
|
|
|
13
13
|
export const CODEX_BASE = 'https://chatgpt.com/backend-api/codex';
|
|
@@ -20,7 +20,7 @@ export interface CodexDeps {
|
|
|
20
20
|
base?: string;
|
|
21
21
|
issuer?: string;
|
|
22
22
|
fetchImpl?: typeof fetch;
|
|
23
|
-
save: (tokens: CodexTokens) => void;
|
|
23
|
+
save: (id: string, tokens: CodexTokens) => void;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export interface CodexState {
|
|
@@ -57,13 +57,13 @@ function headersFor(tokens: CodexTokens, sessionId: string): Record<string, stri
|
|
|
57
57
|
|
|
58
58
|
const newerThan = (a: CodexTokens, b: CodexTokens): boolean => Date.parse(a.savedAt) > Date.parse(b.savedAt);
|
|
59
59
|
|
|
60
|
-
function refreshed(tokens: CodexTokens, deps: CodexDeps, state: CodexState): Promise<CodexTokens> {
|
|
60
|
+
function refreshed(id: string, tokens: CodexTokens, deps: CodexDeps, state: CodexState): Promise<CodexTokens> {
|
|
61
61
|
const latest = state.latest;
|
|
62
62
|
if (latest !== null && newerThan(latest, tokens) && !tokensStale(latest)) return Promise.resolve(latest);
|
|
63
63
|
if (state.refreshing !== null) return state.refreshing;
|
|
64
64
|
const run = refreshTokens(tokens, deps.issuer, deps.fetchImpl)
|
|
65
65
|
.then((fresh) => {
|
|
66
|
-
deps.save(fresh);
|
|
66
|
+
deps.save(id, fresh);
|
|
67
67
|
state.latest = fresh;
|
|
68
68
|
return fresh;
|
|
69
69
|
})
|
|
@@ -77,10 +77,10 @@ function refreshed(tokens: CodexTokens, deps: CodexDeps, state: CodexState): Pro
|
|
|
77
77
|
return run;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
export function currentTokens(
|
|
81
|
-
const tokens =
|
|
80
|
+
export function currentTokens(conn: Connection, deps: CodexDeps, state: CodexState): Promise<CodexTokens> {
|
|
81
|
+
const tokens = conn.codex;
|
|
82
82
|
if (tokens === null) throw new GatewayError(400, 'invalid_request_error', 'Codex is not connected: sign in on the Model page.');
|
|
83
|
-
return tokensStale(tokens) ? refreshed(tokens, deps, state) : Promise.resolve(tokens);
|
|
83
|
+
return tokensStale(tokens) ? refreshed(conn.id, tokens, deps, state) : Promise.resolve(tokens);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
function errorKind(status: number): string {
|
|
@@ -116,18 +116,18 @@ function send(call: Call, tokens: CodexTokens): Promise<Response> {
|
|
|
116
116
|
});
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
async function reach(call: Call,
|
|
120
|
-
let tokens = await currentTokens(
|
|
119
|
+
async function reach(call: Call, conn: Connection, state: CodexState): Promise<Response> {
|
|
120
|
+
let tokens = await currentTokens(conn, call.deps, state);
|
|
121
121
|
let upstream = await send(call, tokens);
|
|
122
122
|
if (upstream.status === 401) {
|
|
123
123
|
await upstream.body?.cancel();
|
|
124
|
-
tokens = await refreshed(tokens, call.deps, state);
|
|
124
|
+
tokens = await refreshed(conn.id, tokens, call.deps, state);
|
|
125
125
|
upstream = await send(call, tokens);
|
|
126
126
|
}
|
|
127
127
|
return upstream;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
async function relayStream(upstream: Response, res: ServerResponse, model: string, names: ToolNames, watch: Watch): Promise<void> {
|
|
130
|
+
async function relayStream(upstream: Response, res: ServerResponse, model: string, names: ToolNames, watch: Watch, key: string): Promise<void> {
|
|
131
131
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
|
|
132
132
|
const translator = new CodexEventTranslator(model, (name) => names.restore(name));
|
|
133
133
|
const body = upstream.body;
|
|
@@ -136,7 +136,7 @@ async function relayStream(upstream: Response, res: ServerResponse, model: strin
|
|
|
136
136
|
return;
|
|
137
137
|
}
|
|
138
138
|
const ping = setInterval(() => res.write('event: ping\ndata: {"type":"ping"}\n\n'), PING_MS);
|
|
139
|
-
const scanner = new UsageScanner(
|
|
139
|
+
const scanner = new UsageScanner(key);
|
|
140
140
|
const emit = (frames: string): void => {
|
|
141
141
|
scanner.feed(frames);
|
|
142
142
|
res.write(frames);
|
|
@@ -166,7 +166,7 @@ async function relayStream(upstream: Response, res: ServerResponse, model: strin
|
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
async function relayWhole(upstream: Response, res: ServerResponse, model: string, names: ToolNames): Promise<void> {
|
|
169
|
+
async function relayWhole(upstream: Response, res: ServerResponse, model: string, names: ToolNames, key: string): Promise<void> {
|
|
170
170
|
const translator = new CodexEventTranslator(model, (name) => names.restore(name));
|
|
171
171
|
let frames = '';
|
|
172
172
|
const parser = new SseParser();
|
|
@@ -175,7 +175,7 @@ async function relayWhole(upstream: Response, res: ServerResponse, model: string
|
|
|
175
175
|
if (parsed !== null) frames += translator.push(parsed.event, parsed.data);
|
|
176
176
|
}
|
|
177
177
|
frames += translator.close();
|
|
178
|
-
const scanner = new UsageScanner(
|
|
178
|
+
const scanner = new UsageScanner(key);
|
|
179
179
|
scanner.feed(frames);
|
|
180
180
|
scanner.done();
|
|
181
181
|
const message = assembleMessage(frames);
|
|
@@ -189,21 +189,21 @@ export async function codexMessages(
|
|
|
189
189
|
res: ServerResponse,
|
|
190
190
|
body: Record<string, unknown>,
|
|
191
191
|
model: string,
|
|
192
|
-
|
|
192
|
+
conn: Connection,
|
|
193
193
|
deps: CodexDeps,
|
|
194
194
|
state: CodexState,
|
|
195
195
|
watch: Watch,
|
|
196
196
|
): Promise<void> {
|
|
197
197
|
const call: Call = { body, model, sessionId: sessionIdOf(req), watch, deps, names: new ToolNames() };
|
|
198
|
-
const upstream = await reach(call,
|
|
199
|
-
noteUsageHeaders('codex', upstream.headers);
|
|
198
|
+
const upstream = await reach(call, conn, state);
|
|
199
|
+
noteUsageHeaders('codex', conn.id, upstream.headers);
|
|
200
200
|
if (!upstream.ok) {
|
|
201
201
|
const text = await upstream.text();
|
|
202
202
|
sendError(res, providerStatus(upstream.status), errorKind(upstream.status), upstreamMessage(text, `Codex answered ${String(upstream.status)}`));
|
|
203
203
|
return;
|
|
204
204
|
}
|
|
205
|
-
if (body.stream === true) await relayStream(upstream, res, model, call.names, watch);
|
|
206
|
-
else await relayWhole(upstream, res, model, call.names);
|
|
205
|
+
if (body.stream === true) await relayStream(upstream, res, model, call.names, watch, conn.id);
|
|
206
|
+
else await relayWhole(upstream, res, model, call.names, conn.id);
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
export function codexCount(res: ServerResponse, body: Record<string, unknown>): void {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
|
|
4
|
+
export const MAIN_EFFORT = 'low';
|
|
5
|
+
export const SUBAGENT_EFFORT = 'max';
|
|
6
|
+
export const BINDING_BETA = 'thinking-binding-controls-2026-08-01';
|
|
7
|
+
|
|
8
|
+
const SUBAGENT_HEADER = 'x-claude-code-agent-id';
|
|
9
|
+
const SUBAGENT_MARK = 'cc_is_subagent=true';
|
|
10
|
+
const ABOVE_HIGH = new Set(['xhigh', 'max']);
|
|
11
|
+
|
|
12
|
+
export type RequestKind = 'main' | 'subagent' | 'other';
|
|
13
|
+
|
|
14
|
+
type Body = Record<string, unknown>;
|
|
15
|
+
|
|
16
|
+
const record = (value: unknown): Body => (isRecord(value) ? value : {});
|
|
17
|
+
|
|
18
|
+
const effortOf = (body: Body): string => {
|
|
19
|
+
const effort = record(body.output_config).effort;
|
|
20
|
+
return typeof effort === 'string' ? effort : '';
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function header(req: IncomingMessage, name: string): string {
|
|
24
|
+
const raw = req.headers[name];
|
|
25
|
+
return (Array.isArray(raw) ? raw[0] : raw)?.trim() ?? '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function systemMarksSubagent(body: Body): boolean {
|
|
29
|
+
const system = body.system;
|
|
30
|
+
if (!Array.isArray(system)) return false;
|
|
31
|
+
return system.some((block) => isRecord(block) && typeof block.text === 'string' && block.text.includes(SUBAGENT_MARK));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const chore = (body: Body): boolean => record(body.output_config).format !== undefined || record(body.thinking).type === 'disabled';
|
|
35
|
+
|
|
36
|
+
export function requestKind(req: IncomingMessage, body: Body): RequestKind {
|
|
37
|
+
if (header(req, SUBAGENT_HEADER) !== '' || systemMarksSubagent(body)) return 'subagent';
|
|
38
|
+
return chore(body) ? 'other' : 'main';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function plannedEffort(req: IncomingMessage, body: Body): string | null {
|
|
42
|
+
const current = effortOf(body);
|
|
43
|
+
if (current === '') return null;
|
|
44
|
+
const kind = requestKind(req, body);
|
|
45
|
+
if (kind === 'other') return null;
|
|
46
|
+
const wanted = kind === 'subagent' ? SUBAGENT_EFFORT : MAIN_EFFORT;
|
|
47
|
+
return wanted === current ? null : wanted;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const withEffort = (body: Body, effort: string): Body => ({ ...body, output_config: { ...record(body.output_config), effort } });
|
|
51
|
+
|
|
52
|
+
export function withoutEffort(body: Body): Body {
|
|
53
|
+
const config = Object.fromEntries(Object.entries(record(body.output_config)).filter(([key]) => key !== 'effort'));
|
|
54
|
+
if (Object.keys(config).length > 0) return { ...body, output_config: config };
|
|
55
|
+
return Object.fromEntries(Object.entries(body).filter(([key]) => key !== 'output_config'));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function effortToApply(body: Body): string | null {
|
|
59
|
+
if (chore(body)) return null;
|
|
60
|
+
const effort = effortOf(body);
|
|
61
|
+
return effort === '' ? null : effort;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const cappedEffort = (effort: string): string => (ABOVE_HIGH.has(effort) ? 'high' : effort);
|
|
65
|
+
|
|
66
|
+
export function withBlockBinding(body: Body): Body {
|
|
67
|
+
const thinking = body.thinking;
|
|
68
|
+
if (!isRecord(thinking) || thinking.block_binding !== undefined) return body;
|
|
69
|
+
return { ...body, thinking: { ...thinking, block_binding: { prefix_mismatch_behavior: 'drop_block' } } };
|
|
70
|
+
}
|
|
@@ -127,6 +127,15 @@ export function anthropicHeaders(req: IncomingMessage, apiKey: string): Record<s
|
|
|
127
127
|
return headers;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
export function addBeta(headers: Record<string, string>, beta: string): Record<string, string> {
|
|
131
|
+
const listed = (headers['anthropic-beta'] ?? '')
|
|
132
|
+
.split(',')
|
|
133
|
+
.map((one) => one.trim())
|
|
134
|
+
.filter((one) => one !== '');
|
|
135
|
+
if (listed.includes(beta)) return headers;
|
|
136
|
+
return { ...headers, 'anthropic-beta': [...listed, beta].join(',') };
|
|
137
|
+
}
|
|
138
|
+
|
|
130
139
|
export interface PipeOptions {
|
|
131
140
|
keepalive?: boolean;
|
|
132
141
|
ownCredential?: boolean;
|