@fusionkit/model-gateway 0.1.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.
Files changed (47) hide show
  1. package/dist/acp-agent.d.ts +39 -0
  2. package/dist/acp-agent.js +143 -0
  3. package/dist/acp-registry.d.ts +36 -0
  4. package/dist/acp-registry.js +85 -0
  5. package/dist/adapters/anthropic.d.ts +111 -0
  6. package/dist/adapters/anthropic.js +446 -0
  7. package/dist/adapters/chat.d.ts +14 -0
  8. package/dist/adapters/chat.js +34 -0
  9. package/dist/adapters/responses.d.ts +94 -0
  10. package/dist/adapters/responses.js +438 -0
  11. package/dist/backend.d.ts +52 -0
  12. package/dist/backend.js +57 -0
  13. package/dist/config.d.ts +22 -0
  14. package/dist/config.js +47 -0
  15. package/dist/front-door-acceptance.d.ts +41 -0
  16. package/dist/front-door-acceptance.js +219 -0
  17. package/dist/fusion-backend.d.ts +96 -0
  18. package/dist/fusion-backend.js +521 -0
  19. package/dist/fusion-gateway.d.ts +69 -0
  20. package/dist/fusion-gateway.js +355 -0
  21. package/dist/index.d.ts +40 -0
  22. package/dist/index.js +28 -0
  23. package/dist/mlx-backend.d.ts +42 -0
  24. package/dist/mlx-backend.js +71 -0
  25. package/dist/provenance.d.ts +29 -0
  26. package/dist/provenance.js +182 -0
  27. package/dist/server.d.ts +27 -0
  28. package/dist/server.js +234 -0
  29. package/dist/test/acp-agent.test.d.ts +1 -0
  30. package/dist/test/acp-agent.test.js +66 -0
  31. package/dist/test/acp-registry.test.d.ts +1 -0
  32. package/dist/test/acp-registry.test.js +70 -0
  33. package/dist/test/anthropic.test.d.ts +1 -0
  34. package/dist/test/anthropic.test.js +251 -0
  35. package/dist/test/chat.test.d.ts +1 -0
  36. package/dist/test/chat.test.js +270 -0
  37. package/dist/test/front-door-acceptance.test.d.ts +1 -0
  38. package/dist/test/front-door-acceptance.test.js +94 -0
  39. package/dist/test/fusion-backend-trace.test.d.ts +1 -0
  40. package/dist/test/fusion-backend-trace.test.js +107 -0
  41. package/dist/test/fusion-backend.test.d.ts +1 -0
  42. package/dist/test/fusion-backend.test.js +193 -0
  43. package/dist/test/fusion-gateway.test.d.ts +1 -0
  44. package/dist/test/fusion-gateway.test.js +107 -0
  45. package/dist/test/responses.test.d.ts +1 -0
  46. package/dist/test/responses.test.js +157 -0
  47. package/package.json +31 -0
@@ -0,0 +1,521 @@
1
+ /**
2
+ * The fusion front-door backend.
3
+ *
4
+ * This is the clean abstraction behind "the judge streams a trajectory the
5
+ * user's harness executes". It implements the gateway {@link Backend} contract
6
+ * (an OpenAI Chat Completions surface) so it slots into the existing
7
+ * `startGateway` server and reuses every dialect adapter (chat / responses /
8
+ * anthropic) — including their full tool-call, tool-result, and streaming
9
+ * support — for free.
10
+ *
11
+ * Per front-door turn it:
12
+ * 1. derives a stable session key from the conversation prefix,
13
+ * 2. runs the panel **once** per session (injected `runPanels`, so this
14
+ * package keeps no dependency on `@fusionkit/ensemble`) to produce the
15
+ * candidate trajectories,
16
+ * 3. forwards the live conversation + the harness tools + the candidate
17
+ * trajectories to FusionKit's `trajectory:step`, whose response (an OpenAI
18
+ * chat completion, optionally streamed, that may carry `tool_calls`) is
19
+ * returned verbatim for the server to translate into the caller's dialect.
20
+ *
21
+ * There is no apply/verify/repair here: iteration is the user's harness's job.
22
+ *
23
+ * Failures are surfaced, never swallowed: a panel run that throws or yields no
24
+ * usable candidate, or a `trajectory:step` that errors, produces an explicit
25
+ * error (a non-2xx response when nothing has streamed yet, or a terminal error
26
+ * event with `finish_reason: "error"` once the SSE has started) and the failed
27
+ * session is evicted so the next turn retries instead of replaying the failure.
28
+ */
29
+ import { createHash } from "node:crypto";
30
+ import { emitTrace, getTraceEmitter, judgeFinalPayload, judgeRequestPayload, judgeThinkingPayload, newSpanId, newTraceId, TRACE_ID_HEADER } from "@fusionkit/protocol";
31
+ const DEFAULT_SESSION_TTL_MS = 60 * 60 * 1000;
32
+ const DEFAULT_PANEL_TIMEOUT_MS = 15 * 60 * 1000;
33
+ const DEFAULT_STEP_TIMEOUT_MS = 10 * 60 * 1000;
34
+ function textOfContent(content) {
35
+ if (typeof content === "string")
36
+ return content;
37
+ if (Array.isArray(content)) {
38
+ return content
39
+ .map((part) => {
40
+ if (typeof part === "string")
41
+ return part;
42
+ if (part !== null && typeof part === "object" && typeof part.text === "string") {
43
+ return part.text;
44
+ }
45
+ return "";
46
+ })
47
+ .join("");
48
+ }
49
+ return "";
50
+ }
51
+ function errorText(error) {
52
+ return error instanceof Error ? error.message : String(error);
53
+ }
54
+ /** A candidate set is usable when at least one trajectory did not fail. */
55
+ function hasUsableCandidates(candidates) {
56
+ return candidates.some((candidate) => candidate.status !== "failed");
57
+ }
58
+ /** Combine an optional client-abort signal with a wall-clock timeout. */
59
+ function withDeadline(signal, timeoutMs) {
60
+ const timeout = AbortSignal.timeout(timeoutMs);
61
+ return signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
62
+ }
63
+ /** Reject if `promise` does not settle within `timeoutMs` (the work detaches). */
64
+ async function withTimeout(promise, timeoutMs, label) {
65
+ let timer;
66
+ const timeout = new Promise((_, reject) => {
67
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
68
+ });
69
+ try {
70
+ return await Promise.race([promise, timeout]);
71
+ }
72
+ finally {
73
+ if (timer !== undefined)
74
+ clearTimeout(timer);
75
+ }
76
+ }
77
+ function jsonError(status, message) {
78
+ return new Response(JSON.stringify({ error: { message, type: "fusion_error" } }), {
79
+ status,
80
+ headers: { "content-type": "application/json" }
81
+ });
82
+ }
83
+ /** Best-effort reassembly of an OpenAI chat SSE stream into content, usage,
84
+ * tool-call deltas, and finish reason (used to tell terminal from intermediate). */
85
+ function assembleSseContent(buffer) {
86
+ let content = "";
87
+ let usage;
88
+ let finishReason;
89
+ const toolCalls = [];
90
+ for (const line of buffer.split("\n")) {
91
+ const trimmed = line.trim();
92
+ if (!trimmed.startsWith("data:"))
93
+ continue;
94
+ const data = trimmed.slice(5).trim();
95
+ if (data.length === 0 || data === "[DONE]")
96
+ continue;
97
+ try {
98
+ const json = JSON.parse(data);
99
+ const choice = json.choices?.[0];
100
+ const delta = choice?.delta?.content;
101
+ if (typeof delta === "string")
102
+ content += delta;
103
+ if (Array.isArray(choice?.delta?.tool_calls))
104
+ toolCalls.push(...choice.delta.tool_calls);
105
+ if (typeof choice?.finish_reason === "string")
106
+ finishReason = choice.finish_reason;
107
+ if (json.usage !== undefined && json.usage !== null)
108
+ usage = json.usage;
109
+ }
110
+ catch {
111
+ // ignore partial/non-JSON lines
112
+ }
113
+ }
114
+ return {
115
+ content,
116
+ toolCalls,
117
+ ...(usage !== undefined ? { usage } : {}),
118
+ ...(finishReason !== undefined ? { finishReason } : {})
119
+ };
120
+ }
121
+ /** A judge step is terminal (the real answer) only when it requests no tool calls. */
122
+ function isTerminalJudgeStep(toolCalls, finishReason) {
123
+ const calls = Array.isArray(toolCalls) ? toolCalls : [];
124
+ return calls.length === 0 && finishReason !== "tool_calls";
125
+ }
126
+ /** A terminal SSE chunk that marks the turn as failed (not a normal stop). */
127
+ function errorEvent(message) {
128
+ return (`data: ${JSON.stringify({
129
+ choices: [{ index: 0, delta: { content: message }, finish_reason: "error" }]
130
+ })}\n\n` + "data: [DONE]\n\n");
131
+ }
132
+ export class FusionBackend {
133
+ defaultModel;
134
+ #stepUrl;
135
+ #runPanels;
136
+ #judgeModel;
137
+ #ttlMs;
138
+ #panelTimeoutMs;
139
+ #stepTimeoutMs;
140
+ #mintTraceId;
141
+ #sessions = new Map();
142
+ constructor(options) {
143
+ this.#stepUrl = options.stepUrl;
144
+ this.#runPanels = options.runPanels;
145
+ this.defaultModel = options.defaultModel;
146
+ this.#judgeModel = options.judgeModel;
147
+ this.#ttlMs = options.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
148
+ this.#panelTimeoutMs = options.panelTimeoutMs ?? DEFAULT_PANEL_TIMEOUT_MS;
149
+ this.#stepTimeoutMs = options.stepTimeoutMs ?? DEFAULT_STEP_TIMEOUT_MS;
150
+ this.#mintTraceId = options.mintTraceId ?? newTraceId;
151
+ }
152
+ async chat(body, signal, options = {}) {
153
+ const chat = (body ?? {});
154
+ const messages = Array.isArray(chat.messages) ? chat.messages : [];
155
+ const sessionKey = this.#sessionKey(messages);
156
+ const session = this.#ensureSession(sessionKey);
157
+ const streaming = chat.stream === true;
158
+ const buildStepBody = (candidates) => {
159
+ const stepBody = {
160
+ model: chat.model ?? this.defaultModel ?? "fusion-panel",
161
+ messages,
162
+ trajectories: candidates,
163
+ stream: streaming
164
+ };
165
+ if (chat.tools !== undefined)
166
+ stepBody.tools = chat.tools;
167
+ if (chat.tool_choice !== undefined)
168
+ stepBody.tool_choice = chat.tool_choice;
169
+ if (this.#judgeModel !== undefined)
170
+ stepBody.judge_model = this.#judgeModel;
171
+ return JSON.stringify(stepBody);
172
+ };
173
+ const headers = {
174
+ "content-type": "application/json",
175
+ [TRACE_ID_HEADER]: session.traceId
176
+ };
177
+ if (options.modelCallId)
178
+ headers["x-velum-model-call-id"] = options.modelCallId;
179
+ // The judge step is a child span of the session: emit the full prompt sent to
180
+ // the judge (the live conversation + candidate trajectories + tools) and the
181
+ // judge's final output, so the companion app can show exactly what the judge
182
+ // saw and produced.
183
+ const judgeSpan = newSpanId();
184
+ const traceEnabled = getTraceEmitter().isEnabled();
185
+ const sessionTraceId = session.traceId;
186
+ const sessionSpan = session.sessionSpan;
187
+ const judgeModel = this.#judgeModel;
188
+ // The user-turn index: a follow-up user message is a new turn, while the
189
+ // harness's internal tool-loop continuations (which append assistant/tool
190
+ // messages, not user ones) keep the same count and thus the same turn. The
191
+ // panel runs once per turn, so each new user request is fused over fresh
192
+ // candidates; the tool loop within a turn reuses them.
193
+ const turn = messages.filter((message) => message.role === "user").length;
194
+ const turnCandidates = this.#ensureTurnCandidates(session, sessionKey, turn, messages);
195
+ const emitJudgeRequest = (candidates) => {
196
+ if (!traceEnabled)
197
+ return;
198
+ emitTrace({
199
+ component: "judge",
200
+ event_type: "judge.request",
201
+ traceId: sessionTraceId,
202
+ spanId: judgeSpan,
203
+ parentSpanId: sessionSpan,
204
+ payload: judgeRequestPayload({
205
+ ...(judgeModel !== undefined ? { judgeModel } : {}),
206
+ messages,
207
+ trajectories: candidates,
208
+ ...(chat.tools !== undefined ? { tools: chat.tools } : {}),
209
+ ...(chat.tool_choice !== undefined ? { toolChoice: chat.tool_choice } : {}),
210
+ trajectoryIds: candidates.map((candidate) => candidate.trajectory_id),
211
+ turn
212
+ })
213
+ });
214
+ };
215
+ const emitJudgeFinal = (input) => {
216
+ if (!traceEnabled)
217
+ return;
218
+ emitTrace({
219
+ component: "judge",
220
+ event_type: "judge.final",
221
+ traceId: sessionTraceId,
222
+ spanId: judgeSpan,
223
+ parentSpanId: sessionSpan,
224
+ payload: judgeFinalPayload({ ...input, turn })
225
+ });
226
+ };
227
+ // An intermediate tool-calling turn is NOT the final answer: the harness will
228
+ // execute the tool calls and call back. Emit it as `judge.thinking` so the
229
+ // companion app shows it as in-progress instead of marking the session done.
230
+ const emitJudgeStep = (input) => {
231
+ if (!traceEnabled)
232
+ return;
233
+ const toolCallCount = input.toolCalls?.length ?? 0;
234
+ const rawAnalysis = input.content !== undefined && input.content.length > 0
235
+ ? input.content
236
+ : `judge requested ${toolCallCount} tool call(s)`;
237
+ emitTrace({
238
+ component: "judge",
239
+ event_type: "judge.thinking",
240
+ traceId: sessionTraceId,
241
+ spanId: judgeSpan,
242
+ parentSpanId: sessionSpan,
243
+ payload: judgeThinkingPayload({
244
+ rawAnalysis,
245
+ ...(input.toolCalls !== undefined ? { toolCalls: input.toolCalls } : {}),
246
+ ...(input.usage !== undefined ? { usage: input.usage } : {}),
247
+ turn
248
+ })
249
+ });
250
+ };
251
+ // Resolve the panel candidates (bounded), failing loudly so a panel crash or
252
+ // an empty/all-failed candidate set never silently fuses into a blank answer.
253
+ const resolveCandidates = async () => {
254
+ const candidates = await withTimeout(turnCandidates, this.#panelTimeoutMs, "fusion panel");
255
+ if (!hasUsableCandidates(candidates)) {
256
+ throw new Error(candidates.length === 0
257
+ ? "fusion panel produced no candidates"
258
+ : "fusion panel produced no usable candidates (every model failed)");
259
+ }
260
+ return candidates;
261
+ };
262
+ // Non-streaming: the panel phase can block before the single JSON reply.
263
+ if (!streaming) {
264
+ let candidates;
265
+ try {
266
+ candidates = await resolveCandidates();
267
+ }
268
+ catch (error) {
269
+ this.#evictTurn(session, turn);
270
+ console.error(`fusion: panel phase failed: ${errorText(error)}`);
271
+ return jsonError(502, errorText(error));
272
+ }
273
+ emitJudgeRequest(candidates);
274
+ const response = await fetch(this.#stepUrl, {
275
+ method: "POST",
276
+ headers,
277
+ body: buildStepBody(candidates),
278
+ signal: withDeadline(signal, this.#stepTimeoutMs)
279
+ });
280
+ if (traceEnabled) {
281
+ // Capture the judge's output without consuming the piped response.
282
+ const clone = response.clone();
283
+ void (async () => {
284
+ try {
285
+ if (!clone.ok) {
286
+ emitJudgeFinal({ httpStatus: clone.status, error: (await clone.text()).slice(0, 2000) });
287
+ return;
288
+ }
289
+ const judged = (await clone.json());
290
+ const choice = judged.choices?.[0];
291
+ const message = choice?.message;
292
+ const content = typeof message?.content === "string" ? message.content : undefined;
293
+ const toolCalls = Array.isArray(message?.tool_calls) ? message.tool_calls : [];
294
+ if (isTerminalJudgeStep(toolCalls, choice?.finish_reason)) {
295
+ emitJudgeFinal({
296
+ httpStatus: clone.status,
297
+ ...(content !== undefined ? { content } : {}),
298
+ ...(judged.usage !== undefined ? { usage: judged.usage } : {})
299
+ });
300
+ }
301
+ else {
302
+ emitJudgeStep({
303
+ ...(content !== undefined ? { content } : {}),
304
+ toolCalls,
305
+ ...(judged.usage !== undefined ? { usage: judged.usage } : {})
306
+ });
307
+ }
308
+ }
309
+ catch {
310
+ // best-effort judge.final
311
+ }
312
+ })();
313
+ }
314
+ return response;
315
+ }
316
+ // Streaming: return immediately with a live SSE stream so the caller's HTTP
317
+ // client sees the response start right away. The (potentially slow) panel
318
+ // phase runs inside the stream behind keepalive comments, then the judge
319
+ // step's SSE is piped through. This avoids first-byte timeouts in real CLIs
320
+ // (e.g. codex) while the panel solves the task once. Because the 200 + SSE
321
+ // headers are already sent, failures surface as a terminal error event.
322
+ const stepUrl = this.#stepUrl;
323
+ const stepSignal = withDeadline(signal, this.#stepTimeoutMs);
324
+ const evictOnFailure = () => this.#evictTurn(session, turn);
325
+ const encoder = new TextEncoder();
326
+ const decoder = new TextDecoder();
327
+ const readable = new ReadableStream({
328
+ async start(controller) {
329
+ let alive = true;
330
+ let sseBuffer = "";
331
+ const keepalive = setInterval(() => {
332
+ if (alive) {
333
+ try {
334
+ controller.enqueue(encoder.encode(": keepalive\n\n"));
335
+ }
336
+ catch {
337
+ alive = false;
338
+ }
339
+ }
340
+ }, 3000);
341
+ const fail = (message) => {
342
+ console.error(`fusion: ${message}`);
343
+ evictOnFailure();
344
+ controller.enqueue(encoder.encode(errorEvent(`fusion error: ${message}`)));
345
+ };
346
+ try {
347
+ let candidates;
348
+ try {
349
+ candidates = await resolveCandidates();
350
+ }
351
+ catch (error) {
352
+ fail(errorText(error));
353
+ return;
354
+ }
355
+ emitJudgeRequest(candidates);
356
+ if (process.env.FUSION_DEBUG) {
357
+ const toolNames = Array.isArray(chat.tools)
358
+ ? chat.tools.map((t) => {
359
+ const tool = t;
360
+ return tool.function?.name ?? tool.name ?? tool.type ?? "?";
361
+ })
362
+ : [];
363
+ console.error(`[fusion-debug] step: messages=${messages.length} roles=${messages.map((m) => m.role).join(",")} ` +
364
+ `candidates=${candidates.length} tools=[${toolNames.join(", ")}]`);
365
+ }
366
+ const upstream = await fetch(stepUrl, {
367
+ method: "POST",
368
+ headers,
369
+ body: buildStepBody(candidates),
370
+ signal: stepSignal
371
+ });
372
+ if (!upstream.ok || upstream.body === null) {
373
+ const detail = upstream.body === null ? "no stream" : (await upstream.text()).slice(0, 800);
374
+ emitJudgeFinal({ httpStatus: upstream.status, error: detail });
375
+ fail(`trajectory:step ${upstream.status}: ${detail}`);
376
+ return;
377
+ }
378
+ const reader = upstream.body.getReader();
379
+ for (;;) {
380
+ const { done, value } = await reader.read();
381
+ if (done)
382
+ break;
383
+ if (value !== undefined) {
384
+ controller.enqueue(value);
385
+ if (traceEnabled)
386
+ sseBuffer += decoder.decode(value, { stream: true });
387
+ }
388
+ }
389
+ if (traceEnabled) {
390
+ const assembled = assembleSseContent(sseBuffer);
391
+ if (isTerminalJudgeStep(assembled.toolCalls, assembled.finishReason)) {
392
+ emitJudgeFinal({
393
+ httpStatus: upstream.status,
394
+ ...(assembled.content.length > 0 ? { content: assembled.content } : {}),
395
+ ...(assembled.usage !== undefined ? { usage: assembled.usage } : {})
396
+ });
397
+ }
398
+ else {
399
+ emitJudgeStep({
400
+ ...(assembled.content.length > 0 ? { content: assembled.content } : {}),
401
+ toolCalls: assembled.toolCalls,
402
+ ...(assembled.usage !== undefined ? { usage: assembled.usage } : {})
403
+ });
404
+ }
405
+ }
406
+ }
407
+ catch (error) {
408
+ emitJudgeFinal({ error: errorText(error) });
409
+ fail(errorText(error));
410
+ }
411
+ finally {
412
+ alive = false;
413
+ clearInterval(keepalive);
414
+ try {
415
+ controller.close();
416
+ }
417
+ catch {
418
+ // already closed
419
+ }
420
+ }
421
+ }
422
+ });
423
+ return new Response(readable, {
424
+ status: 200,
425
+ headers: { "content-type": "text/event-stream", "cache-control": "no-cache" }
426
+ });
427
+ }
428
+ models() {
429
+ const model = this.defaultModel ?? "fusion-panel";
430
+ return Promise.resolve(new Response(JSON.stringify({ object: "list", data: [{ id: model, object: "model", owned_by: "fusion-gateway" }] }), { status: 200, headers: { "content-type": "application/json" } }));
431
+ }
432
+ embeddings() {
433
+ return Promise.resolve(new Response(JSON.stringify({ error: { message: "embeddings are not supported by the fusion gateway" } }), {
434
+ status: 501,
435
+ headers: { "content-type": "application/json" }
436
+ }));
437
+ }
438
+ /** A stable key for the conversation: system text + first user message. */
439
+ #sessionKey(messages) {
440
+ const system = messages
441
+ .filter((message) => message.role === "system")
442
+ .map((message) => textOfContent(message.content))
443
+ .join("\n");
444
+ const firstUser = messages.find((message) => message.role === "user");
445
+ const seed = JSON.stringify([system, firstUser ? textOfContent(firstUser.content) : ""]);
446
+ return createHash("sha256").update(seed).digest("hex").slice(0, 16);
447
+ }
448
+ #task(messages) {
449
+ // The panel task is the *current* request: the most recent user message.
450
+ // Real CLIs (codex/claude/cursor) put their large agent harness prompt in
451
+ // the system message and may prepend an <environment_context> user message,
452
+ // so take the latest user turn (the active instruction) and fall back to
453
+ // system text only if there is no user content at all. Using the latest
454
+ // user message means a follow-up turn's panel solves the follow-up request.
455
+ const userText = messages
456
+ .filter((message) => message.role === "user")
457
+ .map((message) => textOfContent(message.content).trim())
458
+ .filter((text) => text.length > 0);
459
+ const latest = userText.at(-1);
460
+ if (latest !== undefined && latest.length > 0)
461
+ return latest;
462
+ return messages
463
+ .filter((message) => message.role === "system")
464
+ .map((message) => textOfContent(message.content))
465
+ .join("\n\n")
466
+ .trim();
467
+ }
468
+ /** Drop a turn's cached candidates so the next call for that turn re-runs the panel. */
469
+ #evictTurn(session, turn) {
470
+ session.turns.delete(turn);
471
+ }
472
+ /** Remove expired sessions so a long-lived gateway does not grow unbounded. */
473
+ #sweepExpired(now) {
474
+ for (const [key, session] of this.#sessions) {
475
+ if (now - session.createdAt >= this.#ttlMs)
476
+ this.#sessions.delete(key);
477
+ }
478
+ }
479
+ /** Establish (or reuse) the per-conversation session identity. No panel runs here. */
480
+ #ensureSession(sessionKey) {
481
+ const now = Date.now();
482
+ this.#sweepExpired(now);
483
+ const existing = this.#sessions.get(sessionKey);
484
+ if (existing !== undefined && now - existing.createdAt < this.#ttlMs)
485
+ return existing;
486
+ const session = {
487
+ traceId: this.#mintTraceId(),
488
+ sessionSpan: newSpanId(),
489
+ turns: new Map(),
490
+ createdAt: now
491
+ };
492
+ this.#sessions.set(sessionKey, session);
493
+ return session;
494
+ }
495
+ /**
496
+ * Run the panel once per user turn and cache its candidates on the session.
497
+ * Internal tool-loop continuations keep the same `turn` and reuse the result;
498
+ * a follow-up user message is a new `turn` and triggers a fresh panel run.
499
+ * A failed turn is evicted so a retry re-runs it (failures are never cached).
500
+ */
501
+ #ensureTurnCandidates(session, sessionKey, turn, messages) {
502
+ const existing = session.turns.get(turn);
503
+ if (existing !== undefined)
504
+ return existing;
505
+ const candidates = this.#runPanels({
506
+ task: this.#task(messages),
507
+ messages,
508
+ traceId: session.traceId,
509
+ sessionSpanId: session.sessionSpan,
510
+ sessionKey,
511
+ turn
512
+ });
513
+ session.turns.set(turn, candidates);
514
+ candidates.catch((error) => {
515
+ console.error(`fusion: panel run failed for session ${sessionKey} turn ${turn}: ${errorText(error)}`);
516
+ if (session.turns.get(turn) === candidates)
517
+ session.turns.delete(turn);
518
+ });
519
+ return candidates;
520
+ }
521
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Fusion Harness Gateway — the provider-facing front door that lets a coding
3
+ * tool (Codex, Claude Code, Cursor via Cursorkit) be the entrypoint. A prompt
4
+ * sent from the tool hits this gateway, which translates the request into a
5
+ * dialect-agnostic prompt, runs the unified HandoffKit/FusionKit harness
6
+ * ensemble through an injected runner, then translates the synthesized final
7
+ * answer back into the tool's native wire format.
8
+ *
9
+ * The runner is injected (not imported) so this package stays free of a
10
+ * dependency on `@fusionkit/ensemble`, which already depends on this package.
11
+ */
12
+ import type { AnthropicRequest } from "./adapters/anthropic.js";
13
+ import type { ResponsesRequest } from "./adapters/responses.js";
14
+ export type FrontDoorDialect = "openai-responses" | "anthropic-messages" | "openai-chat";
15
+ export type FrontDoorRunnerInput = {
16
+ dialect: FrontDoorDialect;
17
+ prompt: string;
18
+ requestedModel: string | undefined;
19
+ requestId: string;
20
+ /** Correlates this front-door request with all downstream trace events. */
21
+ traceId: string;
22
+ };
23
+ export type FrontDoorRunnerResult = {
24
+ finalOutput: string;
25
+ runId: string;
26
+ status: "succeeded" | "failed" | "skipped";
27
+ evidence: string[];
28
+ reportPath?: string;
29
+ };
30
+ export type FrontDoorRunner = (input: FrontDoorRunnerInput) => Promise<FrontDoorRunnerResult>;
31
+ export type FusionGatewayOptions = {
32
+ /** Runs the unified harness ensemble for a single front-door prompt. */
33
+ runner: FrontDoorRunner;
34
+ /** Bind host; defaults to loopback. */
35
+ host?: string;
36
+ /** Bind port; defaults to an ephemeral free port. */
37
+ port?: number;
38
+ /** When set, require this bearer token (or matching `x-api-key`). */
39
+ authToken?: string;
40
+ /** Model id echoed back in responses and `/v1/models`. */
41
+ defaultModel?: string;
42
+ };
43
+ export type FusionGateway = {
44
+ /** Base URL clients should target (without the `/v1` suffix). */
45
+ url(): string;
46
+ port(): number;
47
+ close(): Promise<void>;
48
+ };
49
+ export declare const FUSION_RUN_ID_HEADER = "x-fusion-run-id";
50
+ export declare const FUSION_STATUS_HEADER = "x-fusion-status";
51
+ export declare const FUSION_EVIDENCE_HEADER = "x-fusion-evidence";
52
+ export declare const FUSION_REPORT_HEADER = "x-fusion-report";
53
+ export declare function promptFromResponses(body: ResponsesRequest): string;
54
+ export declare function promptFromAnthropic(body: AnthropicRequest): string;
55
+ type ChatMessage = {
56
+ role?: string;
57
+ content?: unknown;
58
+ };
59
+ export type ChatRequest = {
60
+ model?: string;
61
+ messages?: ChatMessage[];
62
+ stream?: boolean;
63
+ };
64
+ export declare function promptFromChat(body: ChatRequest): string;
65
+ export declare function formatResponses(finalOutput: string, model: string): Record<string, unknown>;
66
+ export declare function formatAnthropic(finalOutput: string, model: string): Record<string, unknown>;
67
+ export declare function formatChat(finalOutput: string, model: string): Record<string, unknown>;
68
+ export declare function startFusionGateway(options: FusionGatewayOptions): Promise<FusionGateway>;
69
+ export {};