faberun 0.26.3 → 0.27.0
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/README.md +2 -1
- package/package.json +1 -1
- package/src/cli/setup.mjs +1 -1
- package/src/cli.mjs +1 -1
- package/src/contract/index.mjs +1 -1
- package/src/contract/runtime.mjs +1 -1
- package/src/engine/live-preflight.mjs +1 -1
- package/src/engine/runtime-discovery.mjs +46 -4
- package/src/harnesses/catalogue.mjs +25 -2
- package/src/harnesses/claude/index.mjs +79 -1
- package/src/harnesses/dsh/index.mjs +4 -116
- package/src/harnesses/dsh/runner.mjs +1 -17
- package/src/harnesses/fx/home.mjs +57 -0
- package/src/harnesses/fx/index.mjs +124 -0
- package/src/harnesses/fx/native/build.zig +20 -0
- package/src/harnesses/fx/native/build.zig.zon +7 -0
- package/src/harnesses/fx/native/home.zig +85 -0
- package/src/harnesses/fx/native/main.zig +503 -0
- package/src/harnesses/fx/native/permissions.zig +51 -0
- package/src/harnesses/fx/native/relay.zig +253 -0
- package/src/harnesses/fx/native/usage.zig +112 -0
- package/src/harnesses/fx/permissions.mjs +35 -0
- package/src/harnesses/fx/runner.mjs +350 -0
- package/src/harnesses/fx/usage-proxy.mjs +176 -0
- package/src/harnesses/index.mjs +8 -4
- package/src/harnesses/runner-io.mjs +25 -0
- package/src/harnesses/runner-transcript.mjs +127 -0
- package/src/harnesses/session-metrics.mjs +38 -13
- package/src/host/preflight.mjs +2 -1
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Faberun's fx client: one prompt on stdin, one JSONL transcript on stdout,
|
|
3
|
+
* no interactive surface. The adapter runs this file under
|
|
4
|
+
* `process.execPath`, so spawning it never depends on a shebang, an
|
|
5
|
+
* executable bit, or `node` being on the provider's PATH.
|
|
6
|
+
*
|
|
7
|
+
* Why ACP instead of `fx ask --json`: `ask` prints its JSON once, at exit, so
|
|
8
|
+
* the controller's stall detector would see a silent worker for the whole
|
|
9
|
+
* turn, and its only permission choices are "everything" or "let the model
|
|
10
|
+
* review itself". `fx acp` streams every tool call as it happens and, in
|
|
11
|
+
* `ask` mode, sends each shell command and file mutation to this client as
|
|
12
|
+
* `session/request_permission`, so the file-effect boundary the contract
|
|
13
|
+
* names is decided here, per path. Measured 2026-09-24 against
|
|
14
|
+
* api.deepseek.com: seven permission requests for one parseDuration turn,
|
|
15
|
+
* every file mutation carrying its `path` in `rawInput`.
|
|
16
|
+
*
|
|
17
|
+
* Usage and provider failures come from the loopback relay in
|
|
18
|
+
* `usage-proxy.mjs`, never from fx: fx's usage omits the cache split and its
|
|
19
|
+
* provider errors arrive as prose.
|
|
20
|
+
*
|
|
21
|
+
* Transcript, the only thing this process writes to stdout:
|
|
22
|
+
* {"type":"fx.started","sessionId":string}
|
|
23
|
+
* {"type":"fx.tool","name":string,"target":string|null} one per tool call
|
|
24
|
+
* {"type":"fx.request","status":number,"usage":Usage|null} one per provider request
|
|
25
|
+
* {"type":"fx.message","text":string} one per non-empty assistant message
|
|
26
|
+
* {"type":"fx.completed","sessionId":string,"result":string,"usage":Usage|null}
|
|
27
|
+
* {"type":"fx.failed","sessionId":string|null,"kind":string,"error":{...},"usage":Usage|null}
|
|
28
|
+
* where Usage is `{inputTokens, outputTokens, cacheReadInputTokens}` with
|
|
29
|
+
* `inputTokens` excluding the cached prefix, matching `canonicalUsage`.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { spawn } from "node:child_process";
|
|
33
|
+
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
34
|
+
import { homedir, tmpdir } from "node:os";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { spawnInvocation } from "../../host/platform.mjs";
|
|
37
|
+
import { emit, readPrompt } from "../runner-io.mjs";
|
|
38
|
+
import { prepareFxHome } from "./home.mjs";
|
|
39
|
+
import { permissionVerdict } from "./permissions.mjs";
|
|
40
|
+
import { startUsageProxy } from "./usage-proxy.mjs";
|
|
41
|
+
|
|
42
|
+
/** The provider name the generated settings give the relay connection. */
|
|
43
|
+
const PROVIDER = "faberun";
|
|
44
|
+
/**
|
|
45
|
+
* fx's built-in ChatGPT-subscription provider, and the Responses endpoint it
|
|
46
|
+
* posts to; the relay stands in for the host.
|
|
47
|
+
*/
|
|
48
|
+
const CODEX_PROVIDER = "codex";
|
|
49
|
+
const CODEX_UPSTREAM = "https://chatgpt.com/backend-api/codex";
|
|
50
|
+
/**
|
|
51
|
+
* Which of fx's provider paths the worker takes: `openai-compatible` is a
|
|
52
|
+
* custom Chat Completions connection (DeepSeek, Z.ai) authenticated by a key
|
|
53
|
+
* in the environment; `codex` is fx's ChatGPT login, which fx-faberun reads
|
|
54
|
+
* from the operator's real profile through `FX_AUTH_HOME`.
|
|
55
|
+
*/
|
|
56
|
+
const PROVIDER_KINDS = new Set(["openai-compatible", "codex"]);
|
|
57
|
+
/**
|
|
58
|
+
* Measured: DeepSeek's `max_tokens` ceiling on deepseek-flash; fx needs a
|
|
59
|
+
* declared value. It is the default, not a law: a runtime may declare
|
|
60
|
+
* `max_output_tokens` when its provider allows more. Measured 2026-09-27 on
|
|
61
|
+
* frb-linux2: a `faberun plan` draft node cannot fit in 8,192 output tokens
|
|
62
|
+
* and died with `OutputTruncated` with no way to raise the ceiling.
|
|
63
|
+
*/
|
|
64
|
+
const MAX_OUTPUT_TOKENS = 8192;
|
|
65
|
+
|
|
66
|
+
/** @typedef {{fx: string, model: string, sandbox: string, provider: string, baseUrl: string|null, keyEnv: string, contextWindow: number, maxOutputTokens: number}} RunnerOptions */
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {string[]} argv
|
|
70
|
+
* @returns {RunnerOptions}
|
|
71
|
+
*/
|
|
72
|
+
function parseArgs(argv) {
|
|
73
|
+
/** @type {RunnerOptions} */
|
|
74
|
+
const options = {
|
|
75
|
+
fx: "fx",
|
|
76
|
+
model: "",
|
|
77
|
+
sandbox: "workspace-write",
|
|
78
|
+
provider: "openai-compatible",
|
|
79
|
+
baseUrl: null,
|
|
80
|
+
keyEnv: "DEEPSEEK_API_KEY",
|
|
81
|
+
contextWindow: 1_000_000,
|
|
82
|
+
maxOutputTokens: MAX_OUTPUT_TOKENS,
|
|
83
|
+
};
|
|
84
|
+
for (let index = 0; index < argv.length; index += 2) {
|
|
85
|
+
const flag = argv[index];
|
|
86
|
+
const value = argv[index + 1];
|
|
87
|
+
if (typeof value !== "string") throw new Error(`${flag} needs a value`);
|
|
88
|
+
if (flag === "--fx") options.fx = value;
|
|
89
|
+
else if (flag === "--model") options.model = value;
|
|
90
|
+
else if (flag === "--sandbox") options.sandbox = value;
|
|
91
|
+
else if (flag === "--provider") {
|
|
92
|
+
if (!PROVIDER_KINDS.has(value)) throw new Error(`unknown provider: ${value}`);
|
|
93
|
+
options.provider = value;
|
|
94
|
+
} else if (flag === "--base-url") options.baseUrl = value;
|
|
95
|
+
else if (flag === "--key-env") options.keyEnv = value;
|
|
96
|
+
else if (flag === "--context-window") options.contextWindow = Number(value);
|
|
97
|
+
else if (flag === "--max-output-tokens") options.maxOutputTokens = Number(value);
|
|
98
|
+
else throw new Error(`unknown argument: ${flag}`);
|
|
99
|
+
}
|
|
100
|
+
if (!options.model) throw new Error("--model is required");
|
|
101
|
+
return options;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {Record<string, any>} toolCall
|
|
106
|
+
* @returns {string|null}
|
|
107
|
+
*/
|
|
108
|
+
function toolTarget(toolCall) {
|
|
109
|
+
const input = toolCall.rawInput && typeof toolCall.rawInput === "object" ? toolCall.rawInput : {};
|
|
110
|
+
const value = input.path ?? input.command ?? input.pattern ?? input.url ?? null;
|
|
111
|
+
return typeof value === "string" ? value.slice(0, 200) : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** @param {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number}} totals @param {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number}} usage */
|
|
115
|
+
function addUsage(totals, usage) {
|
|
116
|
+
totals.inputTokens += usage.inputTokens;
|
|
117
|
+
totals.outputTokens += usage.outputTokens;
|
|
118
|
+
totals.cacheReadInputTokens += usage.cacheReadInputTokens;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The failure a provider status means. 402 is DeepSeek's "Insufficient
|
|
123
|
+
* Balance" and 429 its rate limit; both name the exhaustion the declared
|
|
124
|
+
* failover edge exists for.
|
|
125
|
+
*
|
|
126
|
+
* @param {{status: number, error: string|null, retryAfterMs: number|null}} request
|
|
127
|
+
* @returns {{kind: string, error: {code: string, message: string, retryAfterMs?: number}}}
|
|
128
|
+
*/
|
|
129
|
+
function providerFailure(request) {
|
|
130
|
+
const code = request.status === 429 ? "RATE_LIMIT" : request.status === 402 ? "QUOTA" : `http_${request.status}`;
|
|
131
|
+
const message = `${request.status} ${request.error ?? ""}`.trim().slice(0, 512);
|
|
132
|
+
return { kind: "error", error: { code, message, ...(request.retryAfterMs ? { retryAfterMs: request.retryAfterMs } : {}) } };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const options = parseArgs(process.argv.slice(2));
|
|
136
|
+
const prompt = await readPrompt();
|
|
137
|
+
const workspace = realpathSync(process.cwd());
|
|
138
|
+
const home = mkdtempSync(join(tmpdir(), "faberun-fx-home-"));
|
|
139
|
+
const usage = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 };
|
|
140
|
+
let sawUsage = false;
|
|
141
|
+
/** @type {{status: number, error: string|null, retryAfterMs: number|null}|null} */
|
|
142
|
+
let lastFailure = null;
|
|
143
|
+
/** @type {string[]} */
|
|
144
|
+
const texts = [];
|
|
145
|
+
let segment = "";
|
|
146
|
+
let sessionId = /** @type {string|null} */ (null);
|
|
147
|
+
let settled = false;
|
|
148
|
+
|
|
149
|
+
const codex = options.provider === "codex";
|
|
150
|
+
const proxy = await startUsageProxy({
|
|
151
|
+
upstream: options.baseUrl ?? (codex ? CODEX_UPSTREAM : "https://api.deepseek.com"),
|
|
152
|
+
onRequest(request) {
|
|
153
|
+
if (request.usage) {
|
|
154
|
+
addUsage(usage, request.usage);
|
|
155
|
+
sawUsage = true;
|
|
156
|
+
}
|
|
157
|
+
lastFailure = request.status >= 400 ? request : null;
|
|
158
|
+
emit({ type: "fx.request", status: request.status, usage: request.usage });
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
prepareFxHome(homedir(), home, codex ? {
|
|
163
|
+
provider: CODEX_PROVIDER,
|
|
164
|
+
models: { [CODEX_PROVIDER]: options.model },
|
|
165
|
+
permission_mode: "ask",
|
|
166
|
+
} : {
|
|
167
|
+
provider: PROVIDER,
|
|
168
|
+
models: { [PROVIDER]: options.model },
|
|
169
|
+
permission_mode: "ask",
|
|
170
|
+
providers: {
|
|
171
|
+
[PROVIDER]: {
|
|
172
|
+
protocol: "openai-chat-completions",
|
|
173
|
+
base_url: proxy.url,
|
|
174
|
+
auth: { type: "bearer", env: options.keyEnv },
|
|
175
|
+
model_metadata: {
|
|
176
|
+
[options.model]: { context_window: options.contextWindow, max_output_tokens: options.maxOutputTokens, supports_tool_use: true },
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// FX_SOUND: fx plays a sound on every send and response, which a detached
|
|
183
|
+
// campaign of parallel workers turns into noise on the operator's machine.
|
|
184
|
+
const env = {
|
|
185
|
+
...process.env,
|
|
186
|
+
HOME: home,
|
|
187
|
+
FX_PROVIDER: codex ? CODEX_PROVIDER : PROVIDER,
|
|
188
|
+
FX_PERMISSION_MODE: "ask",
|
|
189
|
+
FX_AUTO_UPGRADE: "0",
|
|
190
|
+
FX_SOUND: "0",
|
|
191
|
+
// The ChatGPT session stays in the operator's profile: a copy would diverge
|
|
192
|
+
// on the first token refresh (fx-faberun only). fx accepts a loopback
|
|
193
|
+
// override of the Responses endpoint, which is how the relay meters it.
|
|
194
|
+
...(codex ? { FX_AUTH_HOME: homedir(), FX_E2E_OPENAI_CODEX_RESPONSES_URL: `${proxy.url}/responses` } : {}),
|
|
195
|
+
};
|
|
196
|
+
const invocation = spawnInvocation(options.fx, ["acp"]);
|
|
197
|
+
const child = spawn(invocation.command, invocation.args, { cwd: workspace, env, stdio: ["pipe", "pipe", "pipe"], ...invocation.options });
|
|
198
|
+
|
|
199
|
+
function flushSegment() {
|
|
200
|
+
if (!segment.trim()) return;
|
|
201
|
+
texts.push(segment);
|
|
202
|
+
emit({ type: "fx.message", text: segment });
|
|
203
|
+
segment = "";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** @param {Record<string, unknown>} event @param {number} exitCode */
|
|
207
|
+
async function settle(event, exitCode) {
|
|
208
|
+
if (settled) return;
|
|
209
|
+
settled = true;
|
|
210
|
+
emit({ ...event, usage: sawUsage ? usage : null });
|
|
211
|
+
child.kill("SIGTERM");
|
|
212
|
+
await proxy.close();
|
|
213
|
+
rmSync(home, { recursive: true, force: true });
|
|
214
|
+
process.exit(exitCode);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** @param {string} kind @param {{code: string, message: string, retryAfterMs?: number}} error */
|
|
218
|
+
function fail(kind, error) {
|
|
219
|
+
flushSegment();
|
|
220
|
+
// A provider status beats fx's prose about it: it is the fact failover keys on.
|
|
221
|
+
const failure = lastFailure ? providerFailure(lastFailure) : { kind, error };
|
|
222
|
+
return settle({ type: "fx.failed", sessionId, ...failure }, 1);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let nextId = 0;
|
|
226
|
+
/** @type {Map<number, (message: Record<string, any>) => void>} */
|
|
227
|
+
const pending = new Map();
|
|
228
|
+
/** @param {string} method @param {Record<string, unknown>} params @returns {Promise<Record<string, any>>} */
|
|
229
|
+
function request(method, params) {
|
|
230
|
+
const id = ++nextId;
|
|
231
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
|
|
232
|
+
return new Promise((resolveReply) => pending.set(id, resolveReply));
|
|
233
|
+
}
|
|
234
|
+
/** @param {number|string} id @param {Record<string, unknown>} body */
|
|
235
|
+
function reply(id, body) {
|
|
236
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, ...body })}\n`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** @param {Record<string, any>} message */
|
|
240
|
+
function onServerRequest(message) {
|
|
241
|
+
if (message.method !== "session/request_permission") {
|
|
242
|
+
reply(message.id, { error: { code: -32601, message: `faberun does not serve ${message.method}` } });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const toolCall = message.params?.toolCall ?? {};
|
|
246
|
+
const verdict = permissionVerdict(options.sandbox, workspace, toolCall);
|
|
247
|
+
const wanted = verdict.allow ? "allow_once" : "reject_once";
|
|
248
|
+
const option = (message.params?.options ?? []).find((/** @type {{kind: string}} */ candidate) => candidate.kind === wanted);
|
|
249
|
+
if (!verdict.allow) process.stderr.write(`faberun denied ${toolCall.title ?? "a tool call"}: ${verdict.reason}\n`);
|
|
250
|
+
reply(message.id, { result: { outcome: option ? { outcome: "selected", optionId: option.optionId } : { outcome: "cancelled" } } });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** @param {Record<string, any>} update */
|
|
254
|
+
function onUpdate(update) {
|
|
255
|
+
if (update.sessionUpdate === "agent_message_chunk" && typeof update.content?.text === "string") {
|
|
256
|
+
segment += update.content.text;
|
|
257
|
+
} else if (update.sessionUpdate === "tool_call") {
|
|
258
|
+
// A tool call ends the message before it, the boundary a judge's
|
|
259
|
+
// verdicts are counted on.
|
|
260
|
+
flushSegment();
|
|
261
|
+
emit({ type: "fx.tool", name: typeof update.kind === "string" ? update.kind : "tool", target: toolTarget(update) });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let buffered = "";
|
|
266
|
+
let stderrTail = "";
|
|
267
|
+
child.stdout.setEncoding("utf8");
|
|
268
|
+
child.stdout.on("data", (chunk) => {
|
|
269
|
+
buffered += chunk;
|
|
270
|
+
const lines = buffered.split("\n");
|
|
271
|
+
buffered = lines.pop() ?? "";
|
|
272
|
+
for (const line of lines) {
|
|
273
|
+
if (!line.trim()) continue;
|
|
274
|
+
let message;
|
|
275
|
+
try {
|
|
276
|
+
message = JSON.parse(line);
|
|
277
|
+
} catch {
|
|
278
|
+
// stdout carries the JSON-RPC transport only; a non-JSON line is a
|
|
279
|
+
// harness defect, not provider prose, and must not be silently dropped.
|
|
280
|
+
fail("invalid_protocol", { code: "invalid_protocol", message: `fx wrote a non-JSON line: ${line.slice(0, 200)}` });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (message.method && message.id !== undefined) onServerRequest(message);
|
|
284
|
+
else if (message.method === "session/update") onUpdate(message.params?.update ?? {});
|
|
285
|
+
else if (message.id !== undefined && pending.has(message.id)) {
|
|
286
|
+
const resolveReply = pending.get(message.id);
|
|
287
|
+
pending.delete(message.id);
|
|
288
|
+
resolveReply?.(message);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
child.stderr.setEncoding("utf8");
|
|
293
|
+
child.stderr.on("data", (chunk) => {
|
|
294
|
+
stderrTail = `${stderrTail}${chunk}`.slice(-4096);
|
|
295
|
+
process.stderr.write(chunk);
|
|
296
|
+
});
|
|
297
|
+
child.on("error", (error) => {
|
|
298
|
+
fail("harness_exit", { code: "harness_exit", message: `cannot start ${options.fx}: ${error.message}` });
|
|
299
|
+
});
|
|
300
|
+
child.on("close", (code) => {
|
|
301
|
+
const detail = stderrTail.trim() ? `: ${stderrTail.trim().slice(-512)}` : "";
|
|
302
|
+
fail("harness_exit", { code: "harness_exit", message: `fx exited with code ${code ?? 1} before the turn ended${detail}` });
|
|
303
|
+
});
|
|
304
|
+
for (const signal of ["SIGTERM", "SIGINT"]) {
|
|
305
|
+
process.on(signal, () => {
|
|
306
|
+
child.kill(/** @type {NodeJS.Signals} */ (signal));
|
|
307
|
+
rmSync(home, { recursive: true, force: true });
|
|
308
|
+
process.exit(1);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
process.on("exit", () => {
|
|
312
|
+
if (!settled) child.kill("SIGKILL");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const initialized = await request("initialize", {
|
|
316
|
+
protocolVersion: 1,
|
|
317
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
|
|
318
|
+
});
|
|
319
|
+
if (initialized.error) {
|
|
320
|
+
await fail("initialize_failed", { code: "initialize_failed", message: JSON.stringify(initialized.error).slice(0, 512) });
|
|
321
|
+
}
|
|
322
|
+
const session = await request("session/new", { cwd: workspace, mcpServers: [] });
|
|
323
|
+
if (session.error || typeof session.result?.sessionId !== "string") {
|
|
324
|
+
await fail("session_failed", { code: "session_failed", message: JSON.stringify(session.error ?? session.result).slice(0, 512) });
|
|
325
|
+
}
|
|
326
|
+
sessionId = session.result.sessionId;
|
|
327
|
+
emit({ type: "fx.started", sessionId });
|
|
328
|
+
const turn = await request("session/prompt", { sessionId, prompt: [{ type: "text", text: prompt }] });
|
|
329
|
+
flushSegment();
|
|
330
|
+
if (turn.error) {
|
|
331
|
+
await fail("prompt_failed", { code: "prompt_failed", message: JSON.stringify(turn.error).slice(0, 512) });
|
|
332
|
+
}
|
|
333
|
+
const stopReason = turn.result?.stopReason;
|
|
334
|
+
if (stopReason === "end_turn") {
|
|
335
|
+
if (!sawUsage) {
|
|
336
|
+
// No request reached the relay with usage; fx's own totals are the
|
|
337
|
+
// fallback, without the cache split it cannot report.
|
|
338
|
+
const reported = turn.result?.usage;
|
|
339
|
+
if (reported && typeof reported === "object") {
|
|
340
|
+
usage.inputTokens = Number(reported.inputTokens) || 0;
|
|
341
|
+
usage.outputTokens = Number(reported.outputTokens) || 0;
|
|
342
|
+
sawUsage = true;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
await settle({ type: "fx.completed", sessionId, result: texts.at(-1) ?? "" }, 0);
|
|
346
|
+
}
|
|
347
|
+
await fail(stopReason === "cancelled" ? "aborted" : String(stopReason ?? "unknown"), {
|
|
348
|
+
code: String(stopReason ?? "unknown"),
|
|
349
|
+
message: `the turn ended: ${stopReason}`,
|
|
350
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A loopback relay between one fx worker and its OpenAI-compatible provider.
|
|
3
|
+
* fx 0.0.11 keeps only prompt and completion totals from a Chat Completions
|
|
4
|
+
* response, so the cache counters the provider returns never reach its JSON
|
|
5
|
+
* or its ACP usage. The relay forwards every byte unchanged and reads the
|
|
6
|
+
* `usage` object on the way back: measured 2026-09-24 over 24 fx requests to
|
|
7
|
+
* api.deepseek.com, 268,288 of 283,372 input tokens (94.7%) were cache hits
|
|
8
|
+
* that fx reported as ordinary input. It also sees the HTTP status fx turns
|
|
9
|
+
* into prose, which is the only reliable quota and rate-limit signal.
|
|
10
|
+
*
|
|
11
|
+
* One relay per runner, bound to 127.0.0.1 on an ephemeral port, so parallel
|
|
12
|
+
* workers never share a meter.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createServer } from "node:http";
|
|
16
|
+
|
|
17
|
+
/** Bytes of an error body kept for classification; the rest is the provider's prose. */
|
|
18
|
+
const ERROR_BODY_LIMIT = 2048;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number}} RequestUsage
|
|
22
|
+
* @typedef {{status: number, usage: RequestUsage|null, error: string|null, retryAfterMs: number|null}} RelayedRequest
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {{upstream: string, onRequest: (request: RelayedRequest) => void}} options
|
|
27
|
+
* @returns {Promise<{url: string, close: () => Promise<void>}>}
|
|
28
|
+
*/
|
|
29
|
+
export async function startUsageProxy({ upstream, onRequest }) {
|
|
30
|
+
const base = upstream.replace(/\/+$/u, "");
|
|
31
|
+
const server = createServer((req, res) => {
|
|
32
|
+
relay(base, req, res).then(onRequest, (error) => {
|
|
33
|
+
if (!res.headersSent) res.writeHead(502, { "content-type": "text/plain" });
|
|
34
|
+
res.end("faberun usage proxy: upstream unreachable");
|
|
35
|
+
onRequest({ status: 502, usage: null, error: error instanceof Error ? error.message : String(error), retryAfterMs: null });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
await new Promise((resolve, reject) => {
|
|
39
|
+
server.once("error", reject);
|
|
40
|
+
server.listen(0, "127.0.0.1", () => resolve(undefined));
|
|
41
|
+
});
|
|
42
|
+
const address = /** @type {import("node:net").AddressInfo} */ (server.address());
|
|
43
|
+
return {
|
|
44
|
+
url: `http://127.0.0.1:${address.port}`,
|
|
45
|
+
close: () => new Promise((resolve) => {
|
|
46
|
+
server.closeAllConnections();
|
|
47
|
+
server.close(() => resolve(undefined));
|
|
48
|
+
}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string} base
|
|
54
|
+
* @param {import("node:http").IncomingMessage} req
|
|
55
|
+
* @param {import("node:http").ServerResponse} res
|
|
56
|
+
* @returns {Promise<RelayedRequest>}
|
|
57
|
+
*/
|
|
58
|
+
async function relay(base, req, res) {
|
|
59
|
+
const chunks = [];
|
|
60
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
61
|
+
const headers = new Headers();
|
|
62
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
63
|
+
if (value === undefined || ["host", "connection", "content-length", "accept-encoding"].includes(name)) continue;
|
|
64
|
+
headers.set(name, Array.isArray(value) ? value.join(", ") : value);
|
|
65
|
+
}
|
|
66
|
+
const response = await fetch(`${base}${req.url ?? ""}`, {
|
|
67
|
+
method: req.method,
|
|
68
|
+
headers,
|
|
69
|
+
body: chunks.length ? Buffer.concat(chunks) : undefined,
|
|
70
|
+
redirect: "manual",
|
|
71
|
+
});
|
|
72
|
+
/** @type {Record<string, string>} */
|
|
73
|
+
const outgoing = {};
|
|
74
|
+
for (const name of ["content-type", "retry-after", "x-request-id"]) {
|
|
75
|
+
const value = response.headers.get(name);
|
|
76
|
+
if (value) outgoing[name] = value;
|
|
77
|
+
}
|
|
78
|
+
res.writeHead(response.status, outgoing);
|
|
79
|
+
const decoder = new TextDecoder();
|
|
80
|
+
let text = "";
|
|
81
|
+
let pending = "";
|
|
82
|
+
/** @type {unknown} */
|
|
83
|
+
let usage = null;
|
|
84
|
+
const contentType = response.headers.get("content-type");
|
|
85
|
+
// Measured 2026-09-26: the Codex endpoint streams SSE with no content-type
|
|
86
|
+
// at all, so a missing header is decided by the first bytes.
|
|
87
|
+
let streaming = (contentType ?? "").includes("text/event-stream");
|
|
88
|
+
let sniffed = contentType !== null;
|
|
89
|
+
if (response.body) {
|
|
90
|
+
for await (const chunk of response.body) {
|
|
91
|
+
res.write(chunk);
|
|
92
|
+
const decoded = decoder.decode(chunk, { stream: true });
|
|
93
|
+
if (!sniffed) {
|
|
94
|
+
sniffed = true;
|
|
95
|
+
streaming = /^\s*(data:|event:|:)/u.test(decoded);
|
|
96
|
+
}
|
|
97
|
+
if (!streaming) {
|
|
98
|
+
text += decoded;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
pending += decoded;
|
|
102
|
+
const lines = pending.split("\n");
|
|
103
|
+
pending = lines.pop() ?? "";
|
|
104
|
+
for (const line of lines) usage = streamedUsage(line) ?? usage;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
usage = streamedUsage(pending) ?? usage;
|
|
108
|
+
res.end();
|
|
109
|
+
if (!streaming && response.ok) usage = parsedUsage(text);
|
|
110
|
+
return {
|
|
111
|
+
status: response.status,
|
|
112
|
+
usage: canonicalRequestUsage(usage),
|
|
113
|
+
error: response.ok ? null : text.slice(0, ERROR_BODY_LIMIT) || response.statusText,
|
|
114
|
+
retryAfterMs: retryAfter(response.headers.get("retry-after")),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** @param {string} line @returns {unknown} */
|
|
119
|
+
function streamedUsage(line) {
|
|
120
|
+
const trimmed = line.trim();
|
|
121
|
+
if (!trimmed.startsWith("data:")) return null;
|
|
122
|
+
const payload = trimmed.slice(5).trim();
|
|
123
|
+
if (payload === "[DONE]") return null;
|
|
124
|
+
return parsedUsage(payload);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** @param {string} text @returns {unknown} */
|
|
128
|
+
function parsedUsage(text) {
|
|
129
|
+
try {
|
|
130
|
+
const value = JSON.parse(text);
|
|
131
|
+
if (!value || typeof value !== "object") return null;
|
|
132
|
+
// A Responses stream reports usage once, on `response.completed`, inside
|
|
133
|
+
// the response object.
|
|
134
|
+
return value.usage ?? value.response?.usage ?? null;
|
|
135
|
+
} catch {
|
|
136
|
+
// A partial or non-JSON frame carries no usage; the next frame might.
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Reduce the provider's usage object to the ledger's shape, where
|
|
143
|
+
* `inputTokens` excludes the cached prefix. DeepSeek reports the split
|
|
144
|
+
* directly (`prompt_cache_hit_tokens`, `prompt_cache_miss_tokens`); OpenAI,
|
|
145
|
+
* vLLM and Z.ai report `prompt_tokens_details.cached_tokens` inside
|
|
146
|
+
* `prompt_tokens`; the Responses API (fx's Codex provider) reports
|
|
147
|
+
* `input_tokens_details.cached_tokens` inside `input_tokens`.
|
|
148
|
+
*
|
|
149
|
+
* @param {unknown} usage
|
|
150
|
+
* @returns {RequestUsage|null}
|
|
151
|
+
*/
|
|
152
|
+
export function canonicalRequestUsage(usage) {
|
|
153
|
+
if (!usage || typeof usage !== "object") return null;
|
|
154
|
+
const record = /** @type {Record<string, any>} */ (usage);
|
|
155
|
+
const number = (/** @type {unknown} */ value) => (typeof value === "number" && Number.isFinite(value) ? value : null);
|
|
156
|
+
const input = number(record.input_tokens);
|
|
157
|
+
if (input !== null) {
|
|
158
|
+
const cached = number(record.input_tokens_details?.cached_tokens) ?? 0;
|
|
159
|
+
return { inputTokens: Math.max(0, input - cached), outputTokens: number(record.output_tokens) ?? 0, cacheReadInputTokens: cached };
|
|
160
|
+
}
|
|
161
|
+
const prompt = number(record.prompt_tokens);
|
|
162
|
+
const output = number(record.completion_tokens) ?? 0;
|
|
163
|
+
const hit = number(record.prompt_cache_hit_tokens) ?? number(record.prompt_tokens_details?.cached_tokens) ?? 0;
|
|
164
|
+
const miss = number(record.prompt_cache_miss_tokens);
|
|
165
|
+
if (prompt === null && miss === null) return null;
|
|
166
|
+
return { inputTokens: miss ?? Math.max(0, (prompt ?? 0) - hit), outputTokens: output, cacheReadInputTokens: hit };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** @param {string|null} header @returns {number|null} */
|
|
170
|
+
function retryAfter(header) {
|
|
171
|
+
if (!header) return null;
|
|
172
|
+
const seconds = Number(header);
|
|
173
|
+
if (Number.isFinite(seconds) && seconds > 0) return seconds * 1000;
|
|
174
|
+
const at = Date.parse(header);
|
|
175
|
+
return Number.isFinite(at) && at > Date.now() ? at - Date.now() : null;
|
|
176
|
+
}
|
package/src/harnesses/index.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { claudeHarness } from "./claude/index.mjs";
|
|
|
3
3
|
import { codexHarness } from "./codex/index.mjs";
|
|
4
4
|
import { agyHarness } from "./agy/index.mjs";
|
|
5
5
|
import { dshHarness } from "./dsh/index.mjs";
|
|
6
|
+
import { fxHarness } from "./fx/index.mjs";
|
|
6
7
|
import { zcodeHarness } from "./zcode/index.mjs";
|
|
7
8
|
import { execJsonlHarness } from "./exec-jsonl/index.mjs";
|
|
8
9
|
import { replayHarness } from "./replay/index.mjs";
|
|
@@ -20,6 +21,7 @@ const HARNESSES = new Map([
|
|
|
20
21
|
["codex", codexHarness],
|
|
21
22
|
["agy", agyHarness],
|
|
22
23
|
["dsh", dshHarness],
|
|
24
|
+
["fx", fxHarness],
|
|
23
25
|
["zcode", zcodeHarness],
|
|
24
26
|
["exec-jsonl", execJsonlHarness],
|
|
25
27
|
["replay", replayHarness],
|
|
@@ -65,7 +67,7 @@ const CAPABILITY_NAMES = new Set([
|
|
|
65
67
|
|
|
66
68
|
/**
|
|
67
69
|
* One declared runtime. `harness` names a registered adapter (`claude`,
|
|
68
|
-
* `codex`, `agy`, `dsh`, `zcode`, `exec-jsonl`, or `replay`) and `model` names
|
|
70
|
+
* `codex`, `agy`, `dsh`, `fx`, `zcode`, `exec-jsonl`, or `replay`) and `model` names
|
|
69
71
|
* what that harness asks; the two are independent. replay requires
|
|
70
72
|
* `config["replay.recording"]` for commands, and dsh requires
|
|
71
73
|
* `config.provider` for the provider route every attempt runs on.
|
|
@@ -106,7 +108,7 @@ export const READ_BYTE_LIMIT = 32 * 1024;
|
|
|
106
108
|
* One provider adapter: capabilities plus executable, version, command, and
|
|
107
109
|
* result-normalization behavior.
|
|
108
110
|
*
|
|
109
|
-
* @typedef {{capabilities: HarnessCapabilities, permissionExecution: PermissionExecutionPolicy, executable: (runtime: HarnessRuntime) => string, versionArgs: (runtime: HarnessRuntime) => string[], parseVersion: (stdout: string, stderr?: string) => string|null, command: (runtime: HarnessRuntime, prompt: string, options: CommandOptions) => HarnessCommand, normalize: (stdout: string, exitCode: number|null, signal: string|null, options?: NormalizeOptions) => ProviderEnvelope}} HarnessAdapter
|
|
111
|
+
* @typedef {{capabilities: HarnessCapabilities, permissionExecution: PermissionExecutionPolicy, executable: (runtime: HarnessRuntime) => string, versionArgs: (runtime: HarnessRuntime) => string[], parseVersion: (stdout: string, stderr?: string) => string|null, command: (runtime: HarnessRuntime, prompt: string, options: CommandOptions) => HarnessCommand, normalize: (stdout: string, exitCode: number|null, signal: string|null, options?: NormalizeOptions) => ProviderEnvelope, reportsCost?: (runtime: {harness: string, config?: Record<string, unknown>}) => boolean}} HarnessAdapter
|
|
110
112
|
*/
|
|
111
113
|
|
|
112
114
|
/**
|
|
@@ -245,7 +247,7 @@ export function providerCommand(runtime, prompt, options = {}) {
|
|
|
245
247
|
}
|
|
246
248
|
|
|
247
249
|
/**
|
|
248
|
-
* @param {string|{harness: string}} runtimeOrHarness
|
|
250
|
+
* @param {string|{harness: string, model?: string, config?: Record<string, unknown>}} runtimeOrHarness
|
|
249
251
|
* @param {string} stdout
|
|
250
252
|
* @param {number|null} exitCode
|
|
251
253
|
* @param {string|null} signal
|
|
@@ -255,7 +257,9 @@ export function providerCommand(runtime, prompt, options = {}) {
|
|
|
255
257
|
export function normalizeProviderResult(runtimeOrHarness, stdout, exitCode, signal, options = {}) {
|
|
256
258
|
const runtime = typeof runtimeOrHarness === "string" ? { harness: runtimeOrHarness } : runtimeOrHarness;
|
|
257
259
|
const harness = getHarness(runtime.harness);
|
|
258
|
-
|
|
260
|
+
const envelope = harness.normalize(stdout, exitCode, signal, options);
|
|
261
|
+
// A cost the harness reports in another vendor's rates is not a bill.
|
|
262
|
+
return harness.reportsCost?.(runtime) === false ? { ...envelope, costUsd: null } : envelope;
|
|
259
263
|
}
|
|
260
264
|
|
|
261
265
|
/**
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The process half every Faberun harness runner shares: one prompt read from
|
|
3
|
+
* stdin, one JSONL transcript written to fd 1. Kept apart from
|
|
4
|
+
* `runner-transcript.mjs` because that module runs inside the controller and
|
|
5
|
+
* this one inside the spawned runner process.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { writeSync } from "node:fs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whole-line writes to fd 1: `process.exit` cannot lose an unfinished write.
|
|
12
|
+
*
|
|
13
|
+
* @param {Record<string, unknown>} event
|
|
14
|
+
*/
|
|
15
|
+
export function emit(event) {
|
|
16
|
+
const line = Buffer.from(`${JSON.stringify(event)}\n`, "utf8");
|
|
17
|
+
for (let written = 0; written < line.length; ) written += writeSync(1, line, written);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @returns {Promise<string>} */
|
|
21
|
+
export async function readPrompt() {
|
|
22
|
+
const chunks = [];
|
|
23
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
24
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
25
|
+
}
|