@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,39 @@
1
+ /**
2
+ * ACP-compatible front door for the unified runner.
3
+ *
4
+ * Implements the minimal Agent Client Protocol (ACP) local-agent lifecycle over
5
+ * newline-delimited JSON-RPC on stdio: `initialize`, `authenticate`,
6
+ * `session/new`, and `session/prompt`. A `session/prompt` runs the unified
7
+ * harness ensemble through an injected runner and streams the synthesized final
8
+ * answer back as `session/update` notifications before returning the prompt
9
+ * turn's stop reason.
10
+ *
11
+ * The runner is injected so this package stays free of a dependency on
12
+ * `@fusionkit/ensemble` (which depends on this package). The input/output streams
13
+ * are injectable for deterministic testing.
14
+ */
15
+ import type { Readable, Writable } from "node:stream";
16
+ export declare const ACP_PROTOCOL_VERSION = 1;
17
+ export type AcpRunnerInput = {
18
+ prompt: string;
19
+ sessionId: string;
20
+ requestId: string;
21
+ };
22
+ export type AcpRunnerResult = {
23
+ finalOutput: string;
24
+ runId: string;
25
+ status: "succeeded" | "failed" | "skipped";
26
+ evidence: string[];
27
+ };
28
+ export type AcpRunner = (input: AcpRunnerInput) => Promise<AcpRunnerResult>;
29
+ export type AcpAgentOptions = {
30
+ runner: AcpRunner;
31
+ input?: Readable;
32
+ output?: Writable;
33
+ protocolVersion?: number;
34
+ };
35
+ /**
36
+ * Run the ACP agent loop until the input stream ends. Resolves when input
37
+ * closes. Each line is one JSON-RPC message.
38
+ */
39
+ export declare function runAcpAgent(options: AcpAgentOptions): Promise<void>;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * ACP-compatible front door for the unified runner.
3
+ *
4
+ * Implements the minimal Agent Client Protocol (ACP) local-agent lifecycle over
5
+ * newline-delimited JSON-RPC on stdio: `initialize`, `authenticate`,
6
+ * `session/new`, and `session/prompt`. A `session/prompt` runs the unified
7
+ * harness ensemble through an injected runner and streams the synthesized final
8
+ * answer back as `session/update` notifications before returning the prompt
9
+ * turn's stop reason.
10
+ *
11
+ * The runner is injected so this package stays free of a dependency on
12
+ * `@fusionkit/ensemble` (which depends on this package). The input/output streams
13
+ * are injectable for deterministic testing.
14
+ */
15
+ import { createInterface } from "node:readline";
16
+ export const ACP_PROTOCOL_VERSION = 1;
17
+ function textFromPromptParam(params) {
18
+ if (typeof params !== "object" || params === null)
19
+ return "";
20
+ const prompt = params.prompt;
21
+ if (typeof prompt === "string")
22
+ return prompt;
23
+ if (Array.isArray(prompt)) {
24
+ return prompt
25
+ .map((block) => {
26
+ const candidate = block;
27
+ return typeof candidate.text === "string" ? candidate.text : "";
28
+ })
29
+ .join("");
30
+ }
31
+ return "";
32
+ }
33
+ function sessionIdFromParams(params, fallback) {
34
+ if (typeof params === "object" && params !== null) {
35
+ const sessionId = params.sessionId;
36
+ if (typeof sessionId === "string" && sessionId.length > 0)
37
+ return sessionId;
38
+ }
39
+ return fallback;
40
+ }
41
+ /**
42
+ * Run the ACP agent loop until the input stream ends. Resolves when input
43
+ * closes. Each line is one JSON-RPC message.
44
+ */
45
+ export async function runAcpAgent(options) {
46
+ const input = options.input ?? process.stdin;
47
+ const output = options.output ?? process.stdout;
48
+ const protocolVersion = options.protocolVersion ?? ACP_PROTOCOL_VERSION;
49
+ let sessionCounter = 0;
50
+ let requestCounter = 0;
51
+ const send = (message) => {
52
+ output.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);
53
+ };
54
+ const respond = (id, result) => {
55
+ send({ id, result });
56
+ };
57
+ const respondError = (id, code, message) => {
58
+ send({ id, error: { code, message } });
59
+ };
60
+ const handlePrompt = async (id, params) => {
61
+ const sessionId = sessionIdFromParams(params, `sess_${sessionCounter}`);
62
+ const prompt = textFromPromptParam(params);
63
+ requestCounter += 1;
64
+ const result = await options.runner({
65
+ prompt,
66
+ sessionId,
67
+ requestId: `acp_${requestCounter}`
68
+ });
69
+ send({
70
+ method: "session/update",
71
+ params: {
72
+ sessionId,
73
+ update: {
74
+ sessionUpdate: "agent_message_chunk",
75
+ content: { type: "text", text: result.finalOutput }
76
+ }
77
+ }
78
+ });
79
+ respond(id, {
80
+ stopReason: result.status === "succeeded" ? "end_turn" : "refusal",
81
+ _meta: { runId: result.runId, status: result.status, evidence: result.evidence }
82
+ });
83
+ };
84
+ const dispatch = async (message) => {
85
+ const { id, method } = message;
86
+ if (method === undefined || id === undefined)
87
+ return;
88
+ switch (method) {
89
+ case "initialize":
90
+ respond(id, {
91
+ protocolVersion,
92
+ agentCapabilities: {
93
+ loadSession: false,
94
+ promptCapabilities: { image: false, audio: false, embeddedContext: true }
95
+ },
96
+ authMethods: []
97
+ });
98
+ return;
99
+ case "authenticate":
100
+ respond(id, {});
101
+ return;
102
+ case "session/new":
103
+ sessionCounter += 1;
104
+ respond(id, { sessionId: `sess_${sessionCounter}` });
105
+ return;
106
+ case "session/load":
107
+ respond(id, {});
108
+ return;
109
+ case "session/cancel":
110
+ respond(id, {});
111
+ return;
112
+ case "session/prompt":
113
+ await handlePrompt(id, message.params);
114
+ return;
115
+ default:
116
+ respondError(id, -32601, `method not found: ${method}`);
117
+ return;
118
+ }
119
+ };
120
+ const rl = createInterface({ input });
121
+ const pending = [];
122
+ rl.on("line", (line) => {
123
+ const trimmed = line.trim();
124
+ if (trimmed.length === 0)
125
+ return;
126
+ let message;
127
+ try {
128
+ message = JSON.parse(trimmed);
129
+ }
130
+ catch {
131
+ return;
132
+ }
133
+ pending.push(dispatch(message).catch((error) => {
134
+ if (message.id !== undefined) {
135
+ respondError(message.id, -32603, error instanceof Error ? error.message : String(error));
136
+ }
137
+ }));
138
+ });
139
+ await new Promise((resolve) => {
140
+ rl.on("close", () => resolve());
141
+ });
142
+ await Promise.all(pending);
143
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * ACP Registry integration. Resolves curated ACP-compatible agents (for
3
+ * example the registry-backed `Codex CLI` and `Claude Agent` adapters) from the
4
+ * ACP Registry so they can drive the generic ACP front door. The fetcher and
5
+ * install directory are injectable for deterministic testing.
6
+ *
7
+ * Registry source: https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json
8
+ */
9
+ export declare const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
10
+ export type AcpRegistryAgent = {
11
+ id: string;
12
+ name?: string;
13
+ version?: string;
14
+ description?: string;
15
+ distribution?: Record<string, unknown>;
16
+ };
17
+ export type AcpRegistry = {
18
+ agents: AcpRegistryAgent[];
19
+ };
20
+ export type AcpRegistryFetcher = (url: string) => Promise<unknown>;
21
+ export type InstalledAcpAdapter = {
22
+ id: string;
23
+ name: string;
24
+ version: string;
25
+ distribution: Record<string, unknown>;
26
+ installedAt: string;
27
+ metadataPath: string;
28
+ };
29
+ export declare function fetchAcpRegistry(fetcher?: AcpRegistryFetcher, url?: string): Promise<AcpRegistry>;
30
+ export type InstallAcpAdaptersOptions = {
31
+ agentIds: string[];
32
+ installDir: string;
33
+ fetcher?: AcpRegistryFetcher;
34
+ url?: string;
35
+ };
36
+ export declare function installAcpAdapters(options: InstallAcpAdaptersOptions): Promise<InstalledAcpAdapter[]>;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * ACP Registry integration. Resolves curated ACP-compatible agents (for
3
+ * example the registry-backed `Codex CLI` and `Claude Agent` adapters) from the
4
+ * ACP Registry so they can drive the generic ACP front door. The fetcher and
5
+ * install directory are injectable for deterministic testing.
6
+ *
7
+ * Registry source: https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json
8
+ */
9
+ import { mkdirSync, writeFileSync } from "node:fs";
10
+ import { join, resolve } from "node:path";
11
+ export const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
12
+ function normalizeRegistry(raw) {
13
+ if (typeof raw !== "object" || raw === null) {
14
+ throw new Error("ACP registry payload must be an object");
15
+ }
16
+ const agentsValue = raw.agents;
17
+ if (!Array.isArray(agentsValue)) {
18
+ throw new Error("ACP registry payload is missing an agents array");
19
+ }
20
+ const agents = [];
21
+ for (const entry of agentsValue) {
22
+ if (typeof entry !== "object" || entry === null)
23
+ continue;
24
+ const id = entry.id;
25
+ if (typeof id !== "string" || id.length === 0)
26
+ continue;
27
+ const agent = { id };
28
+ const name = entry.name;
29
+ if (typeof name === "string")
30
+ agent.name = name;
31
+ const version = entry.version;
32
+ if (typeof version === "string")
33
+ agent.version = version;
34
+ const description = entry.description;
35
+ if (typeof description === "string")
36
+ agent.description = description;
37
+ const distribution = entry.distribution;
38
+ if (typeof distribution === "object" && distribution !== null) {
39
+ agent.distribution = distribution;
40
+ }
41
+ agents.push(agent);
42
+ }
43
+ return { agents };
44
+ }
45
+ const defaultFetcher = async (url) => {
46
+ const response = await fetch(url);
47
+ if (!response.ok) {
48
+ throw new Error(`ACP registry fetch failed: ${response.status}`);
49
+ }
50
+ return (await response.json());
51
+ };
52
+ export async function fetchAcpRegistry(fetcher = defaultFetcher, url = ACP_REGISTRY_URL) {
53
+ return normalizeRegistry(await fetcher(url));
54
+ }
55
+ export async function installAcpAdapters(options) {
56
+ if (options.agentIds.length === 0) {
57
+ throw new Error("at least one ACP agent id is required");
58
+ }
59
+ const registry = await fetchAcpRegistry(options.fetcher ?? defaultFetcher, options.url ?? ACP_REGISTRY_URL);
60
+ const byId = new Map(registry.agents.map((agent) => [agent.id, agent]));
61
+ const dir = resolve(options.installDir);
62
+ mkdirSync(dir, { recursive: true });
63
+ const installed = [];
64
+ for (const agentId of options.agentIds) {
65
+ const agent = byId.get(agentId);
66
+ if (agent === undefined) {
67
+ throw new Error(`ACP registry has no agent with id "${agentId}"`);
68
+ }
69
+ if (agent.distribution === undefined) {
70
+ throw new Error(`ACP agent "${agentId}" has no distribution metadata`);
71
+ }
72
+ const metadataPath = join(dir, `${agentId}.json`);
73
+ const record = {
74
+ id: agent.id,
75
+ name: agent.name ?? agent.id,
76
+ version: agent.version ?? "unknown",
77
+ distribution: agent.distribution,
78
+ installedAt: new Date().toISOString(),
79
+ metadataPath
80
+ };
81
+ writeFileSync(metadataPath, JSON.stringify(record, null, 2) + "\n");
82
+ installed.push(record);
83
+ }
84
+ return installed;
85
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Anthropic Messages adapter. Claude Code speaks the Anthropic Messages API to
3
+ * whatever `ANTHROPIC_BASE_URL` points at, so to back it with a local model we
4
+ * translate `/v1/messages` (and `/v1/messages/count_tokens`, and the
5
+ * `/v1/models` discovery probe) to and from the gateway's OpenAI Chat
6
+ * Completions core. The pure translation functions are exported for testing;
7
+ * the request handler wires them to a `Backend` and returns a `Response` the
8
+ * server pipes straight to the client (JSON or SSE).
9
+ */
10
+ import type { Backend } from "../backend.js";
11
+ type AnthropicTextBlock = {
12
+ type: "text";
13
+ text: string;
14
+ };
15
+ type AnthropicImageBlock = {
16
+ type: "image";
17
+ source: {
18
+ type: "base64";
19
+ media_type: string;
20
+ data: string;
21
+ };
22
+ };
23
+ type AnthropicToolUseBlock = {
24
+ type: "tool_use";
25
+ id: string;
26
+ name: string;
27
+ input: unknown;
28
+ };
29
+ type AnthropicToolResultBlock = {
30
+ type: "tool_result";
31
+ tool_use_id: string;
32
+ content?: string | AnthropicContentBlock[];
33
+ is_error?: boolean;
34
+ };
35
+ type AnthropicContentBlock = AnthropicTextBlock | AnthropicImageBlock | AnthropicToolUseBlock | AnthropicToolResultBlock | {
36
+ type: string;
37
+ [key: string]: unknown;
38
+ };
39
+ type AnthropicMessage = {
40
+ role: "user" | "assistant";
41
+ content: string | AnthropicContentBlock[];
42
+ };
43
+ export type AnthropicRequest = {
44
+ model?: string;
45
+ system?: string | AnthropicTextBlock[];
46
+ messages: AnthropicMessage[];
47
+ max_tokens?: number;
48
+ temperature?: number;
49
+ top_p?: number;
50
+ stop_sequences?: string[];
51
+ stream?: boolean;
52
+ tools?: Array<{
53
+ name: string;
54
+ description?: string;
55
+ input_schema?: unknown;
56
+ }>;
57
+ tool_choice?: {
58
+ type: "auto" | "any" | "tool";
59
+ name?: string;
60
+ };
61
+ };
62
+ type OpenAiToolCall = {
63
+ id?: string;
64
+ index?: number;
65
+ function?: {
66
+ name?: string;
67
+ arguments?: string;
68
+ };
69
+ };
70
+ type OpenAiDelta = {
71
+ content?: string | null;
72
+ tool_calls?: OpenAiToolCall[];
73
+ };
74
+ type OpenAiChoice = {
75
+ delta?: OpenAiDelta;
76
+ message?: {
77
+ content?: string | null;
78
+ tool_calls?: OpenAiToolCall[];
79
+ };
80
+ finish_reason?: string | null;
81
+ };
82
+ type OpenAiUsage = {
83
+ prompt_tokens?: number;
84
+ completion_tokens?: number;
85
+ };
86
+ type OpenAiResponse = {
87
+ id?: string;
88
+ choices?: OpenAiChoice[];
89
+ usage?: OpenAiUsage;
90
+ };
91
+ /**
92
+ * Translate an Anthropic Messages request to an OpenAI Chat Completions body.
93
+ * The upstream model is always the backend's own model (Claude Code sends a
94
+ * `claude-*` id the local server would not recognise); the requested id is
95
+ * only echoed back in the response.
96
+ */
97
+ export declare function anthropicToChat(body: AnthropicRequest, backendModel: string | undefined): Record<string, unknown>;
98
+ export declare function mapStopReason(finishReason: string | null | undefined): string;
99
+ export declare function chatToAnthropicMessage(openai: OpenAiResponse, model: string): Record<string, unknown>;
100
+ export declare function openAiSseToAnthropic(upstream: ReadableStream<Uint8Array>, model: string): ReadableStream<Uint8Array>;
101
+ export declare function countTokensEstimate(body: AnthropicRequest): number;
102
+ export declare function handleAnthropicMessages(backend: Backend, body: AnthropicRequest, modelCallId?: string, signal?: AbortSignal): Promise<Response>;
103
+ export declare function handleCountTokens(body: AnthropicRequest): Response;
104
+ /**
105
+ * Anthropic-shaped `/v1/models` discovery response. Claude Code only adds
106
+ * models whose id begins with `claude` or `anthropic`, so the local model is
107
+ * surfaced under a `claude`-prefixed id with the real model id as its
108
+ * display name.
109
+ */
110
+ export declare function anthropicModelsResponse(backendModel: string | undefined): Response;
111
+ export {};