@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.
- package/dist/acp-agent.d.ts +39 -0
- package/dist/acp-agent.js +143 -0
- package/dist/acp-registry.d.ts +36 -0
- package/dist/acp-registry.js +85 -0
- package/dist/adapters/anthropic.d.ts +111 -0
- package/dist/adapters/anthropic.js +446 -0
- package/dist/adapters/chat.d.ts +14 -0
- package/dist/adapters/chat.js +34 -0
- package/dist/adapters/responses.d.ts +94 -0
- package/dist/adapters/responses.js +438 -0
- package/dist/backend.d.ts +52 -0
- package/dist/backend.js +57 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.js +47 -0
- package/dist/front-door-acceptance.d.ts +41 -0
- package/dist/front-door-acceptance.js +219 -0
- package/dist/fusion-backend.d.ts +96 -0
- package/dist/fusion-backend.js +521 -0
- package/dist/fusion-gateway.d.ts +69 -0
- package/dist/fusion-gateway.js +355 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +28 -0
- package/dist/mlx-backend.d.ts +42 -0
- package/dist/mlx-backend.js +71 -0
- package/dist/provenance.d.ts +29 -0
- package/dist/provenance.js +182 -0
- package/dist/server.d.ts +27 -0
- package/dist/server.js +234 -0
- package/dist/test/acp-agent.test.d.ts +1 -0
- package/dist/test/acp-agent.test.js +66 -0
- package/dist/test/acp-registry.test.d.ts +1 -0
- package/dist/test/acp-registry.test.js +70 -0
- package/dist/test/anthropic.test.d.ts +1 -0
- package/dist/test/anthropic.test.js +251 -0
- package/dist/test/chat.test.d.ts +1 -0
- package/dist/test/chat.test.js +270 -0
- package/dist/test/front-door-acceptance.test.d.ts +1 -0
- package/dist/test/front-door-acceptance.test.js +94 -0
- package/dist/test/fusion-backend-trace.test.d.ts +1 -0
- package/dist/test/fusion-backend-trace.test.js +107 -0
- package/dist/test/fusion-backend.test.d.ts +1 -0
- package/dist/test/fusion-backend.test.js +193 -0
- package/dist/test/fusion-gateway.test.d.ts +1 -0
- package/dist/test/fusion-gateway.test.js +107 -0
- package/dist/test/responses.test.d.ts +1 -0
- package/dist/test/responses.test.js +157 -0
- package/package.json +31 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { artifactHash, assertModelCallRecordV1, MODEL_FUSION_SCHEMA_BUNDLE_HASH, requestHash, responseHash } from "@fusionkit/protocol";
|
|
3
|
+
export const MODEL_CALL_ID_HEADER = "x-velum-model-call-id";
|
|
4
|
+
function asObject(value) {
|
|
5
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
function stringValue(value) {
|
|
11
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
12
|
+
}
|
|
13
|
+
function requestMessages(body) {
|
|
14
|
+
const obj = asObject(body);
|
|
15
|
+
const messages = obj?.messages;
|
|
16
|
+
if (Array.isArray(messages)) {
|
|
17
|
+
const projected = messages
|
|
18
|
+
.map((message) => {
|
|
19
|
+
const item = asObject(message);
|
|
20
|
+
if (item === undefined)
|
|
21
|
+
return undefined;
|
|
22
|
+
const role = item?.role;
|
|
23
|
+
if (role !== "system" &&
|
|
24
|
+
role !== "user" &&
|
|
25
|
+
role !== "assistant" &&
|
|
26
|
+
role !== "tool") {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
role,
|
|
31
|
+
content: requestHash(item.content ?? "")
|
|
32
|
+
};
|
|
33
|
+
})
|
|
34
|
+
.filter((message) => message !== undefined);
|
|
35
|
+
if (projected.length > 0)
|
|
36
|
+
return projected;
|
|
37
|
+
}
|
|
38
|
+
return [{ role: "user", content: requestHash(body) }];
|
|
39
|
+
}
|
|
40
|
+
function parseJson(buffer) {
|
|
41
|
+
if (buffer === undefined || buffer.length === 0)
|
|
42
|
+
return undefined;
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(buffer.toString("utf8"));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function responseText(buffer) {
|
|
51
|
+
return buffer?.toString("utf8") ?? "";
|
|
52
|
+
}
|
|
53
|
+
function usageFromObject(value) {
|
|
54
|
+
const obj = asObject(value);
|
|
55
|
+
const usage = asObject(obj?.usage);
|
|
56
|
+
if (usage === undefined)
|
|
57
|
+
return undefined;
|
|
58
|
+
const prompt = typeof usage.prompt_tokens === "number"
|
|
59
|
+
? usage.prompt_tokens
|
|
60
|
+
: typeof usage.input_tokens === "number"
|
|
61
|
+
? usage.input_tokens
|
|
62
|
+
: undefined;
|
|
63
|
+
const completion = typeof usage.completion_tokens === "number"
|
|
64
|
+
? usage.completion_tokens
|
|
65
|
+
: typeof usage.output_tokens === "number"
|
|
66
|
+
? usage.output_tokens
|
|
67
|
+
: undefined;
|
|
68
|
+
const total = typeof usage.total_tokens === "number"
|
|
69
|
+
? usage.total_tokens
|
|
70
|
+
: prompt !== undefined && completion !== undefined
|
|
71
|
+
? prompt + completion
|
|
72
|
+
: undefined;
|
|
73
|
+
if (prompt === undefined && completion === undefined && total === undefined) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
...(prompt !== undefined ? { prompt_tokens: prompt } : {}),
|
|
78
|
+
...(completion !== undefined ? { completion_tokens: completion } : {}),
|
|
79
|
+
...(total !== undefined ? { total_tokens: total } : {})
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function usageFromSse(text) {
|
|
83
|
+
let usage;
|
|
84
|
+
for (const line of text.split("\n")) {
|
|
85
|
+
const trimmed = line.trim();
|
|
86
|
+
if (!trimmed.startsWith("data:"))
|
|
87
|
+
continue;
|
|
88
|
+
const payload = trimmed.slice(5).trim();
|
|
89
|
+
if (payload === "[DONE]")
|
|
90
|
+
continue;
|
|
91
|
+
const parsed = parseJson(Buffer.from(payload));
|
|
92
|
+
const candidate = usageFromObject(parsed);
|
|
93
|
+
if (candidate !== undefined)
|
|
94
|
+
usage = candidate;
|
|
95
|
+
}
|
|
96
|
+
return usage;
|
|
97
|
+
}
|
|
98
|
+
function usageFromResponse(body) {
|
|
99
|
+
const parsed = parseJson(body);
|
|
100
|
+
return usageFromObject(parsed) ?? usageFromSse(responseText(body));
|
|
101
|
+
}
|
|
102
|
+
function observedModel(body) {
|
|
103
|
+
return stringValue(asObject(parseJson(body))?.model);
|
|
104
|
+
}
|
|
105
|
+
function providerRequestId(body) {
|
|
106
|
+
return stringValue(asObject(parseJson(body))?.id);
|
|
107
|
+
}
|
|
108
|
+
function errorKind(statusCode, error) {
|
|
109
|
+
if (statusCode === 408)
|
|
110
|
+
return "timeout";
|
|
111
|
+
if (statusCode === 429)
|
|
112
|
+
return "rate_limited";
|
|
113
|
+
if (error !== undefined)
|
|
114
|
+
return "provider_error";
|
|
115
|
+
if (statusCode >= 400)
|
|
116
|
+
return "provider_error";
|
|
117
|
+
return "none";
|
|
118
|
+
}
|
|
119
|
+
function statusFor(statusCode, error) {
|
|
120
|
+
return statusCode >= 200 && statusCode < 400 && error === undefined ? "succeeded" : "failed";
|
|
121
|
+
}
|
|
122
|
+
export function buildModelCallRecord(context, result) {
|
|
123
|
+
const usage = usageFromResponse(result.responseBody);
|
|
124
|
+
const status = statusFor(result.statusCode, result.error);
|
|
125
|
+
const metadata = {
|
|
126
|
+
dialect: context.dialect,
|
|
127
|
+
stream: context.stream,
|
|
128
|
+
http_status: result.statusCode,
|
|
129
|
+
duration_ms: result.durationMs,
|
|
130
|
+
requested_model: context.requestedModel ?? null,
|
|
131
|
+
observed_model: observedModel(result.responseBody) ?? null,
|
|
132
|
+
unknown_usage: usage === undefined,
|
|
133
|
+
unknown_cost: true
|
|
134
|
+
};
|
|
135
|
+
const error = status === "failed"
|
|
136
|
+
? {
|
|
137
|
+
kind: errorKind(result.statusCode, result.error),
|
|
138
|
+
message: result.error instanceof Error
|
|
139
|
+
? result.error.message
|
|
140
|
+
: result.error !== undefined
|
|
141
|
+
? String(result.error)
|
|
142
|
+
: responseText(result.responseBody).slice(0, 500),
|
|
143
|
+
retryable: result.statusCode >= 500
|
|
144
|
+
}
|
|
145
|
+
: undefined;
|
|
146
|
+
const record = {
|
|
147
|
+
schema: "model-call-record.v1",
|
|
148
|
+
schema_version: "v1",
|
|
149
|
+
schema_bundle_hash: MODEL_FUSION_SCHEMA_BUNDLE_HASH,
|
|
150
|
+
producer: "handoffkit-model-gateway",
|
|
151
|
+
producer_version: "0.1.0",
|
|
152
|
+
producer_git_sha: "0".repeat(40),
|
|
153
|
+
created_at: context.startedAt,
|
|
154
|
+
call_id: context.callId,
|
|
155
|
+
endpoint_id: context.endpointId ?? context.dialect,
|
|
156
|
+
...(providerRequestId(result.responseBody)
|
|
157
|
+
? { provider_request_id: providerRequestId(result.responseBody) }
|
|
158
|
+
: {}),
|
|
159
|
+
model: context.model ?? context.requestedModel ?? "unknown",
|
|
160
|
+
request_hash: requestHash(context.requestBody),
|
|
161
|
+
...(result.responseBody !== undefined
|
|
162
|
+
? { response_hash: responseHash(responseText(result.responseBody)) }
|
|
163
|
+
: {}),
|
|
164
|
+
messages: requestMessages(context.requestBody),
|
|
165
|
+
status,
|
|
166
|
+
side_effects: "none",
|
|
167
|
+
started_at: context.startedAt,
|
|
168
|
+
finished_at: new Date(new Date(context.startedAt).getTime() + result.durationMs).toISOString(),
|
|
169
|
+
latency_ms: result.durationMs,
|
|
170
|
+
...(usage !== undefined ? { usage } : {}),
|
|
171
|
+
...(error !== undefined ? { error } : {}),
|
|
172
|
+
metadata
|
|
173
|
+
};
|
|
174
|
+
assertModelCallRecordV1(record);
|
|
175
|
+
return record;
|
|
176
|
+
}
|
|
177
|
+
export function modelCallId() {
|
|
178
|
+
return `model_call_${randomUUID()}`;
|
|
179
|
+
}
|
|
180
|
+
export function responseBodyHash(body) {
|
|
181
|
+
return artifactHash(body);
|
|
182
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Backend } from "./backend.js";
|
|
2
|
+
import type { ProvenanceSink } from "./provenance.js";
|
|
3
|
+
/**
|
|
4
|
+
* The local-model gateway HTTP server. It fronts a single OpenAI Chat
|
|
5
|
+
* Completions backend (the owned mlx fork by default) and exposes the wire
|
|
6
|
+
* dialects each agent harness needs. M1 implements the OpenAI chat surface
|
|
7
|
+
* (opencode, Cursor IDE plan panel); the Anthropic Messages and OpenAI
|
|
8
|
+
* Responses adapters return 501 until M2/M3 land.
|
|
9
|
+
*/
|
|
10
|
+
export type GatewayOptions = {
|
|
11
|
+
backend: Backend;
|
|
12
|
+
/** Bind host; defaults to loopback. */
|
|
13
|
+
host?: string;
|
|
14
|
+
/** Bind port; defaults to an ephemeral free port. */
|
|
15
|
+
port?: number;
|
|
16
|
+
/** When set, require this bearer token (or matching `x-api-key`). */
|
|
17
|
+
authToken?: string;
|
|
18
|
+
/** Optional observation sink for model calls. */
|
|
19
|
+
provenance?: ProvenanceSink;
|
|
20
|
+
};
|
|
21
|
+
export type Gateway = {
|
|
22
|
+
/** Base URL clients should target (without the `/v1` suffix). */
|
|
23
|
+
url(): string;
|
|
24
|
+
port(): number;
|
|
25
|
+
close(): Promise<void>;
|
|
26
|
+
};
|
|
27
|
+
export declare function startGateway(options: GatewayOptions): Promise<Gateway>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { once } from "node:events";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { anthropicModelsResponse, handleAnthropicMessages, handleCountTokens } from "./adapters/anthropic.js";
|
|
4
|
+
import { effectiveModel, isStream, withDefaultModel } from "./adapters/chat.js";
|
|
5
|
+
import { handleResponses } from "./adapters/responses.js";
|
|
6
|
+
import { buildModelCallRecord, MODEL_CALL_ID_HEADER, modelCallId } from "./provenance.js";
|
|
7
|
+
export async function startGateway(options) {
|
|
8
|
+
const host = options.host ?? "127.0.0.1";
|
|
9
|
+
const { backend, authToken, provenance } = options;
|
|
10
|
+
const server = createServer((req, res) => {
|
|
11
|
+
void handle(req, res).catch((error) => {
|
|
12
|
+
writeJson(res, 502, {
|
|
13
|
+
error: { message: errorMessage(error), type: "upstream_error" }
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
async function handle(req, res) {
|
|
18
|
+
const method = req.method ?? "GET";
|
|
19
|
+
const path = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
20
|
+
if (path === "/health") {
|
|
21
|
+
writeJson(res, 200, { status: "ok" });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (authToken !== undefined && !authorized(req, authToken)) {
|
|
25
|
+
writeJson(res, 401, { error: { message: "unauthorized", type: "auth_error" } });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (method === "GET" && (path === "/v1/models" || path === "/models")) {
|
|
29
|
+
// Claude Code's discovery probe carries `anthropic-version` and expects
|
|
30
|
+
// the Anthropic-shaped model list; everyone else gets the OpenAI shape.
|
|
31
|
+
if (req.headers["anthropic-version"] !== undefined) {
|
|
32
|
+
await pipeUpstream(res, anthropicModelsResponse(backend.defaultModel));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
await pipeUpstream(res, await backend.models());
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (method === "POST" && (path === "/v1/chat/completions" || path === "/chat/completions")) {
|
|
39
|
+
const raw = await readJson(req, res);
|
|
40
|
+
if (raw === NO_BODY)
|
|
41
|
+
return;
|
|
42
|
+
const body = withDefaultModel(raw, backend.defaultModel);
|
|
43
|
+
await handleModelCall(res, provenance, {
|
|
44
|
+
dialect: "openai-chat",
|
|
45
|
+
body,
|
|
46
|
+
defaultModel: backend.defaultModel,
|
|
47
|
+
invoke: (callId, signal) => backend.chat(body, signal, { modelCallId: callId })
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (method === "POST" && path === "/v1/embeddings") {
|
|
52
|
+
const raw = await readJson(req, res);
|
|
53
|
+
if (raw === NO_BODY)
|
|
54
|
+
return;
|
|
55
|
+
await pipeUpstream(res, await backend.embeddings(withDefaultModel(raw, backend.defaultModel)));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (method === "POST" && path === "/v1/messages/count_tokens") {
|
|
59
|
+
const raw = await readJson(req, res);
|
|
60
|
+
if (raw === NO_BODY)
|
|
61
|
+
return;
|
|
62
|
+
await pipeUpstream(res, handleCountTokens(raw));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (method === "POST" && path === "/v1/messages") {
|
|
66
|
+
const raw = await readJson(req, res);
|
|
67
|
+
if (raw === NO_BODY)
|
|
68
|
+
return;
|
|
69
|
+
const body = raw;
|
|
70
|
+
await handleModelCall(res, provenance, {
|
|
71
|
+
dialect: "anthropic-messages",
|
|
72
|
+
body,
|
|
73
|
+
defaultModel: backend.defaultModel,
|
|
74
|
+
invoke: (callId, signal) => handleAnthropicMessages(backend, body, callId, signal)
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (method === "POST" && path === "/v1/responses") {
|
|
79
|
+
const raw = await readJson(req, res);
|
|
80
|
+
if (raw === NO_BODY)
|
|
81
|
+
return;
|
|
82
|
+
const body = raw;
|
|
83
|
+
await handleModelCall(res, provenance, {
|
|
84
|
+
dialect: "openai-responses",
|
|
85
|
+
body,
|
|
86
|
+
defaultModel: backend.defaultModel,
|
|
87
|
+
invoke: (callId, signal) => handleResponses(backend, body, callId, signal)
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
writeJson(res, 404, { error: { message: `no route for ${method} ${path}`, type: "not_found" } });
|
|
92
|
+
}
|
|
93
|
+
await new Promise((resolve, reject) => {
|
|
94
|
+
const onError = (error) => reject(error);
|
|
95
|
+
server.once("error", onError);
|
|
96
|
+
server.listen(options.port ?? 0, host, () => {
|
|
97
|
+
server.off("error", onError);
|
|
98
|
+
resolve();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
const address = server.address();
|
|
102
|
+
const port = typeof address === "object" && address !== null ? address.port : options.port ?? 0;
|
|
103
|
+
return {
|
|
104
|
+
url: () => `http://${host}:${port}`,
|
|
105
|
+
port: () => port,
|
|
106
|
+
close: async () => {
|
|
107
|
+
await new Promise((resolve, reject) => {
|
|
108
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
109
|
+
});
|
|
110
|
+
// Release a backend that owns a process (e.g. the MLX server) instead of
|
|
111
|
+
// leaking it when the gateway shuts down.
|
|
112
|
+
await backend.close?.();
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// ---- HTTP helpers (Node built-ins only) ----
|
|
117
|
+
const NO_BODY = Symbol("no-body");
|
|
118
|
+
async function readBody(req) {
|
|
119
|
+
const chunks = [];
|
|
120
|
+
for await (const chunk of req)
|
|
121
|
+
chunks.push(chunk);
|
|
122
|
+
return Buffer.concat(chunks);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Read and parse a JSON request body. On malformed JSON, write a 400 and
|
|
126
|
+
* return the NO_BODY sentinel so the caller stops processing.
|
|
127
|
+
*/
|
|
128
|
+
async function readJson(req, res) {
|
|
129
|
+
const buffer = await readBody(req);
|
|
130
|
+
if (buffer.length === 0)
|
|
131
|
+
return {};
|
|
132
|
+
try {
|
|
133
|
+
return JSON.parse(buffer.toString("utf8"));
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
writeJson(res, 400, { error: { message: "invalid JSON body", type: "bad_request" } });
|
|
137
|
+
return NO_BODY;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function writeJson(res, status, value) {
|
|
141
|
+
const payload = Buffer.from(JSON.stringify(value), "utf8");
|
|
142
|
+
res.statusCode = status;
|
|
143
|
+
res.setHeader("content-type", "application/json");
|
|
144
|
+
res.setHeader("content-length", String(payload.byteLength));
|
|
145
|
+
res.end(payload);
|
|
146
|
+
return payload;
|
|
147
|
+
}
|
|
148
|
+
async function handleModelCall(res, sink, route) {
|
|
149
|
+
const callId = modelCallId();
|
|
150
|
+
const started = Date.now();
|
|
151
|
+
const startedAt = new Date(started).toISOString();
|
|
152
|
+
const context = {
|
|
153
|
+
callId,
|
|
154
|
+
dialect: route.dialect,
|
|
155
|
+
requestedModel: effectiveModel(route.body, route.defaultModel),
|
|
156
|
+
model: effectiveModel(route.body, route.defaultModel),
|
|
157
|
+
stream: isStream(route.body),
|
|
158
|
+
requestBody: route.body,
|
|
159
|
+
startedAt,
|
|
160
|
+
endpointId: route.defaultModel ?? route.dialect
|
|
161
|
+
};
|
|
162
|
+
res.setHeader(MODEL_CALL_ID_HEADER, callId);
|
|
163
|
+
// Cancel upstream work if the client hangs up before we finish responding.
|
|
164
|
+
const aborter = new AbortController();
|
|
165
|
+
const onClose = () => {
|
|
166
|
+
if (!res.writableEnded)
|
|
167
|
+
aborter.abort();
|
|
168
|
+
};
|
|
169
|
+
res.once("close", onClose);
|
|
170
|
+
try {
|
|
171
|
+
const upstream = await route.invoke(callId, aborter.signal);
|
|
172
|
+
const body = await pipeUpstream(res, upstream);
|
|
173
|
+
sink?.onModelCall?.(buildModelCallRecord(context, {
|
|
174
|
+
statusCode: upstream.status,
|
|
175
|
+
responseBody: body,
|
|
176
|
+
durationMs: Date.now() - started
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
const statusCode = 502;
|
|
181
|
+
const payload = writeJson(res, statusCode, {
|
|
182
|
+
error: { message: errorMessage(error), type: "upstream_error" }
|
|
183
|
+
});
|
|
184
|
+
sink?.onModelCall?.(buildModelCallRecord(context, {
|
|
185
|
+
statusCode,
|
|
186
|
+
responseBody: payload,
|
|
187
|
+
durationMs: Date.now() - started,
|
|
188
|
+
error
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
res.off("close", onClose);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async function pipeUpstream(res, upstream) {
|
|
196
|
+
res.statusCode = upstream.status;
|
|
197
|
+
const contentType = upstream.headers.get("content-type");
|
|
198
|
+
if (contentType !== null)
|
|
199
|
+
res.setHeader("content-type", contentType);
|
|
200
|
+
const body = upstream.body;
|
|
201
|
+
if (body === null) {
|
|
202
|
+
res.end();
|
|
203
|
+
return Buffer.alloc(0);
|
|
204
|
+
}
|
|
205
|
+
const reader = body.getReader();
|
|
206
|
+
const chunks = [];
|
|
207
|
+
try {
|
|
208
|
+
for (;;) {
|
|
209
|
+
const { done, value } = await reader.read();
|
|
210
|
+
if (done)
|
|
211
|
+
break;
|
|
212
|
+
if (value !== undefined) {
|
|
213
|
+
const chunk = Buffer.from(value);
|
|
214
|
+
chunks.push(chunk);
|
|
215
|
+
if (!res.write(chunk))
|
|
216
|
+
await once(res, "drain");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
res.end();
|
|
222
|
+
}
|
|
223
|
+
return Buffer.concat(chunks);
|
|
224
|
+
}
|
|
225
|
+
function authorized(req, token) {
|
|
226
|
+
const auth = req.headers.authorization;
|
|
227
|
+
if (typeof auth === "string" && auth === `Bearer ${token}`)
|
|
228
|
+
return true;
|
|
229
|
+
const apiKey = req.headers["x-api-key"];
|
|
230
|
+
return typeof apiKey === "string" && apiKey === token;
|
|
231
|
+
}
|
|
232
|
+
function errorMessage(error) {
|
|
233
|
+
return error instanceof Error ? error.message : String(error);
|
|
234
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { PassThrough } from "node:stream";
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import { runAcpAgent } from "../acp-agent.js";
|
|
5
|
+
async function driveAgent(runner, requests) {
|
|
6
|
+
const input = new PassThrough();
|
|
7
|
+
const output = new PassThrough();
|
|
8
|
+
let raw = "";
|
|
9
|
+
output.on("data", (chunk) => {
|
|
10
|
+
raw += chunk.toString("utf8");
|
|
11
|
+
});
|
|
12
|
+
const done = runAcpAgent({ runner, input, output });
|
|
13
|
+
for (const request of requests) {
|
|
14
|
+
input.write(`${JSON.stringify(request)}\n`);
|
|
15
|
+
}
|
|
16
|
+
input.end();
|
|
17
|
+
await done;
|
|
18
|
+
return raw
|
|
19
|
+
.split("\n")
|
|
20
|
+
.filter((line) => line.trim().length > 0)
|
|
21
|
+
.map((line) => JSON.parse(line));
|
|
22
|
+
}
|
|
23
|
+
test("acp agent completes initialize, session/new, and session/prompt", async () => {
|
|
24
|
+
const runner = async (input) => ({
|
|
25
|
+
finalOutput: `FUSION_OK:${input.prompt}`,
|
|
26
|
+
runId: "run_acp_1",
|
|
27
|
+
status: "succeeded",
|
|
28
|
+
evidence: ["patch_artifact", "judge_synthesis"]
|
|
29
|
+
});
|
|
30
|
+
const messages = await driveAgent(runner, [
|
|
31
|
+
{ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } },
|
|
32
|
+
{ jsonrpc: "2.0", id: 2, method: "session/new", params: { cwd: "/tmp", mcpServers: [] } },
|
|
33
|
+
{
|
|
34
|
+
jsonrpc: "2.0",
|
|
35
|
+
id: 3,
|
|
36
|
+
method: "session/prompt",
|
|
37
|
+
params: { sessionId: "sess_1", prompt: [{ type: "text", text: "patch the bug" }] }
|
|
38
|
+
}
|
|
39
|
+
]);
|
|
40
|
+
const initialize = messages.find((message) => message.id === 1);
|
|
41
|
+
assert.ok(initialize?.result);
|
|
42
|
+
assert.equal(initialize.result.protocolVersion, 1);
|
|
43
|
+
const sessionNew = messages.find((message) => message.id === 2);
|
|
44
|
+
assert.equal((sessionNew?.result).sessionId, "sess_1");
|
|
45
|
+
const update = messages.find((message) => message.method === "session/update");
|
|
46
|
+
assert.ok(update);
|
|
47
|
+
const updateParams = update.params;
|
|
48
|
+
assert.match(updateParams.update.content.text, /FUSION_OK:patch the bug/);
|
|
49
|
+
const promptResult = messages.find((message) => message.id === 3);
|
|
50
|
+
const result = promptResult?.result;
|
|
51
|
+
assert.equal(result.stopReason, "end_turn");
|
|
52
|
+
assert.equal(result._meta.runId, "run_acp_1");
|
|
53
|
+
});
|
|
54
|
+
test("acp agent returns method-not-found for unknown methods", async () => {
|
|
55
|
+
const runner = async () => ({
|
|
56
|
+
finalOutput: "unused",
|
|
57
|
+
runId: "run",
|
|
58
|
+
status: "succeeded",
|
|
59
|
+
evidence: []
|
|
60
|
+
});
|
|
61
|
+
const messages = await driveAgent(runner, [
|
|
62
|
+
{ jsonrpc: "2.0", id: 9, method: "nonsense/method", params: {} }
|
|
63
|
+
]);
|
|
64
|
+
const error = messages.find((message) => message.id === 9);
|
|
65
|
+
assert.equal(error?.error?.code, -32601);
|
|
66
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { fetchAcpRegistry, installAcpAdapters } from "../acp-registry.js";
|
|
7
|
+
const FAKE_REGISTRY = {
|
|
8
|
+
agents: [
|
|
9
|
+
{
|
|
10
|
+
id: "codex-cli",
|
|
11
|
+
name: "Codex CLI",
|
|
12
|
+
version: "0.16.0",
|
|
13
|
+
description: "ACP adapter for OpenAI's coding assistant",
|
|
14
|
+
distribution: { type: "npm", package: "@zed-industries/codex-acp" }
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: "claude-agent",
|
|
18
|
+
name: "Claude Agent",
|
|
19
|
+
version: "0.46.0",
|
|
20
|
+
description: "ACP wrapper for Anthropic's Claude",
|
|
21
|
+
distribution: { type: "npm", package: "@agentclientprotocol/claude-agent-acp" }
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "no-distribution",
|
|
25
|
+
name: "Broken",
|
|
26
|
+
version: "1.0.0"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
};
|
|
30
|
+
function fakeFetcher(payload = FAKE_REGISTRY) {
|
|
31
|
+
return async () => payload;
|
|
32
|
+
}
|
|
33
|
+
test("fetchAcpRegistry normalizes agent metadata", async () => {
|
|
34
|
+
const registry = await fetchAcpRegistry(fakeFetcher());
|
|
35
|
+
const ids = registry.agents.map((agent) => agent.id);
|
|
36
|
+
assert.ok(ids.includes("codex-cli"));
|
|
37
|
+
assert.ok(ids.includes("claude-agent"));
|
|
38
|
+
});
|
|
39
|
+
test("installAcpAdapters writes metadata for known agents", async () => {
|
|
40
|
+
const dir = mkdtempSync(join(tmpdir(), "acp-registry-"));
|
|
41
|
+
try {
|
|
42
|
+
const installed = await installAcpAdapters({
|
|
43
|
+
agentIds: ["codex-cli", "claude-agent"],
|
|
44
|
+
installDir: dir,
|
|
45
|
+
fetcher: fakeFetcher()
|
|
46
|
+
});
|
|
47
|
+
assert.equal(installed.length, 2);
|
|
48
|
+
const codex = JSON.parse(readFileSync(join(dir, "codex-cli.json"), "utf8"));
|
|
49
|
+
assert.equal(codex.id, "codex-cli");
|
|
50
|
+
assert.equal(codex.version, "0.16.0");
|
|
51
|
+
assert.equal(codex.distribution.package, "@zed-industries/codex-acp");
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
rmSync(dir, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
test("installAcpAdapters rejects unknown ids and missing distribution", async () => {
|
|
58
|
+
const dir = mkdtempSync(join(tmpdir(), "acp-registry-"));
|
|
59
|
+
try {
|
|
60
|
+
await assert.rejects(() => installAcpAdapters({ agentIds: ["missing"], installDir: dir, fetcher: fakeFetcher() }), /no agent with id "missing"/);
|
|
61
|
+
await assert.rejects(() => installAcpAdapters({
|
|
62
|
+
agentIds: ["no-distribution"],
|
|
63
|
+
installDir: dir,
|
|
64
|
+
fetcher: fakeFetcher()
|
|
65
|
+
}), /no distribution metadata/);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
rmSync(dir, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|