@alint-js/model-adapter-acp 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alint contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # `@alint-js/model-adapter-acp`
2
+
3
+ An OpenAI-compatible HTTP gateway for ACP coding agents.
4
+
5
+ ## What it does
6
+
7
+ The package keeps ACP session semantics outside alint. It exposes configured ACP agents as
8
+ OpenAI-compatible models and implements:
9
+
10
+ - `GET /v1/models`
11
+ - non-streaming and SSE text chat completions
12
+ - request-scoped OpenAI function tools exposed to ACP through MCP Streamable HTTP
13
+ - deferred `tool_calls` continuation through a later `role: tool` message
14
+ - `tool_choice`, usage, abort, continuation TTL, and permission-denial handling
15
+
16
+ The integration suite also runs a real `runAlint` file rule through `ctx.model()`, xsAI
17
+ `generateStructured`, this HTTP gateway, an ACP session, and the MCP `reportFindings` tool before
18
+ asserting the diagnostic returned by alint.
19
+
20
+ The MCP bridge uses a random route and bearer token for each completion turn. When the public
21
+ gateway address is not loopback, configure `mcpBaseUrl`; request Host headers are not trusted to
22
+ choose the authenticated MCP destination.
23
+
24
+ ACP has no equivalent for OpenAI `temperature`, and final ACP usage is unavailable while a
25
+ deferred tool call is pending. Use `onCompatibilityDiagnostic` to observe those cases. Pending
26
+ tool-call responses report zero usage because xsAI requires the OpenAI `usage` object.
27
+
28
+ ## How to use with a command
29
+
30
+ `createCommandModel` uses `tinyexec` to start one ACP process per completion. `startGateway` owns the loopback HTTP server and closes active ACP processes when it shuts down.
31
+
32
+ ```ts
33
+ import { createCommandModel, startGateway } from '@alint-js/model-adapter-acp'
34
+
35
+ const gateway = await startGateway({
36
+ cwd: process.cwd(),
37
+ models: [createCommandModel({
38
+ command: 'codex-acp',
39
+ cwd: process.cwd(),
40
+ id: 'codex',
41
+ name: 'Codex ACP',
42
+ })],
43
+ })
44
+
45
+ console.info(gateway.endpoint)
46
+ await gateway.shutdown()
47
+ ```
48
+
49
+ The alint CLI wraps this interface. Users normally set `driver = "acp"` on a provider model in global setup or `.alint/config.toml` instead of starting the gateway themselves.
50
+
51
+ ## How to embed an ACP connection
52
+
53
+ ```ts
54
+ import { createGateway } from '@alint-js/model-adapter-acp'
55
+ import { serve } from 'h3/node'
56
+
57
+ const app = createGateway({
58
+ cwd: process.cwd(),
59
+ mcpBaseUrl: 'http://127.0.0.1:7419',
60
+ models: [{
61
+ id: 'reviewer',
62
+ name: 'Review Agent',
63
+ openConnection: async () => ({
64
+ kind: 'stream',
65
+ stream: await openYourAcpStream(),
66
+ }),
67
+ }],
68
+ })
69
+
70
+ const server = await serve(app, { hostname: '127.0.0.1', port: 7419 }).ready()
71
+
72
+ // On shutdown, stop accepting HTTP first, then cancel retained ACP continuations.
73
+ await server.close()
74
+ await app.shutdown()
75
+ ```
76
+
77
+ `openConnection` accepts either an official ACP `AgentApp` or `Stream`. An embedding application can own a different transport or lifecycle policy without using the command adapter.
78
+
79
+ Other OpenAI-compatible consumers can use the resulting ordinary provider endpoint:
80
+
81
+ ```toml
82
+ [[providers]]
83
+ id = "acp"
84
+ type = "openai-compatible"
85
+ endpoint = "http://127.0.0.1:7419/v1"
86
+ ```
87
+
88
+ ## When to use
89
+
90
+ - You need existing OpenAI-compatible consumers to call an ACP coding agent.
91
+ - The ACP agent supports MCP over HTTP when request tools are used.
92
+ - The OpenAI client executes function calls and returns `role: tool` messages.
93
+
94
+ ## When not to use
95
+
96
+ - Use an alint agent adapter when a rule intentionally depends on agent-specific behavior.
97
+ - Do not use this package as an OpenAI Responses, realtime, audio, or image compatibility layer.
98
+ - Streaming tool calls and parallel tool calls are not implemented yet; text streaming is supported.
@@ -0,0 +1,54 @@
1
+ import { AgentApp, Stream } from "@agentclientprotocol/sdk";
2
+ import { H3 } from "h3";
3
+ //#region src/gateway.d.ts
4
+ interface CompatibilityDiagnostic {
5
+ field: string;
6
+ message: string;
7
+ value: unknown;
8
+ }
9
+ type GatewayApp = H3 & {
10
+ shutdown: () => Promise<void>;
11
+ };
12
+ interface GatewayModel {
13
+ id: string;
14
+ name: string;
15
+ openConnection: () => GatewayModelConnection | Promise<GatewayModelConnection>;
16
+ }
17
+ type GatewayModelConnection = {
18
+ agent: AgentApp;
19
+ kind: 'agent-app';
20
+ } | {
21
+ dispose?: () => Promise<void> | void;
22
+ kind: 'stream';
23
+ stream: Stream;
24
+ };
25
+ interface GatewayOptions {
26
+ continuationTtlMs?: number;
27
+ cwd?: string;
28
+ mcpBaseUrl?: string;
29
+ models: GatewayModel[];
30
+ onCompatibilityDiagnostic?: (diagnostic: CompatibilityDiagnostic) => void;
31
+ }
32
+ interface GatewayServer {
33
+ endpoint: string;
34
+ shutdown: () => Promise<void>;
35
+ }
36
+ /** Creates the OpenAI-compatible HTTP interface backed by configured ACP agents. */
37
+ declare function createGateway(options: GatewayOptions): GatewayApp;
38
+ /** Starts an ephemeral loopback gateway for one CLI run. */
39
+ declare function startGateway(options: GatewayOptions): Promise<GatewayServer>;
40
+ //#endregion
41
+ //#region src/command.d.ts
42
+ interface CommandModelOptions {
43
+ args?: string[];
44
+ command: string;
45
+ cwd: string;
46
+ env?: NodeJS.ProcessEnv;
47
+ id: string;
48
+ name: string;
49
+ onStderr?: (text: string) => void;
50
+ }
51
+ /** Creates a gateway model whose ACP connection is one CLI process per OpenAI request. */
52
+ declare function createCommandModel(options: CommandModelOptions): GatewayModel;
53
+ //#endregion
54
+ export { type CommandModelOptions, type CompatibilityDiagnostic, type GatewayApp, type GatewayModel, type GatewayModelConnection, type GatewayOptions, type GatewayServer, createCommandModel, createGateway, startGateway };
package/dist/index.mjs ADDED
@@ -0,0 +1,786 @@
1
+ import { Readable, Writable } from "node:stream";
2
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
3
+ import { PROTOCOL_VERSION, client, methods, ndJsonStream } from "@agentclientprotocol/sdk";
4
+ import { x } from "tinyexec";
5
+ import process from "node:process";
6
+ import { H3, defineEventHandler, getRouterParam } from "h3";
7
+ import { serve } from "h3/node";
8
+ import { array, boolean, literal, looseObject, number, object, optional, record, safeParse, string, union, unknown } from "valibot";
9
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
11
+ import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
12
+ import { fromJSONSchema } from "zod";
13
+ //#region src/command.ts
14
+ /** Creates a gateway model whose ACP connection is one CLI process per OpenAI request. */
15
+ function createCommandModel(options) {
16
+ return {
17
+ id: options.id,
18
+ name: options.name,
19
+ openConnection: () => openCommandConnection(options)
20
+ };
21
+ }
22
+ /**
23
+ * Releases the stdio observers and process owned by one command connection.
24
+ *
25
+ * Triggering workflow:
26
+ *
27
+ * {@link createGateway}
28
+ * -> `GatewayModelConnection.dispose`
29
+ * -> {@link disposeCommandConnection}
30
+ *
31
+ * Upstream:
32
+ * - {@link openCommandConnection}
33
+ *
34
+ * Downstream:
35
+ * - {@link stopProcess}
36
+ */
37
+ async function disposeCommandConnection(child, handleStderr, terminate) {
38
+ child.stderr?.off("data", handleStderr);
39
+ child.stdin?.end();
40
+ if (child.exitCode !== null || child.signalCode !== null) return;
41
+ const closed = waitForClose(child);
42
+ terminate();
43
+ if (await Promise.race([closed.then(() => true), setTimeout$1(1e3, false)])) return;
44
+ child.kill("SIGKILL");
45
+ await closed;
46
+ }
47
+ async function openCommandConnection(options) {
48
+ const execution = x(options.command, options.args ?? [], {
49
+ nodeOptions: {
50
+ cwd: options.cwd,
51
+ env: options.env,
52
+ stdio: [
53
+ "pipe",
54
+ "pipe",
55
+ "pipe"
56
+ ]
57
+ },
58
+ nodePath: false
59
+ });
60
+ const child = execution.process;
61
+ if (!child?.stdin || !child.stdout || !child.stderr) {
62
+ execution.kill();
63
+ throw new Error(`ACP command "${options.command}" did not expose stdin, stdout, and stderr pipes.`);
64
+ }
65
+ const handleStderr = (chunk) => {
66
+ options.onStderr?.(chunk.toString());
67
+ };
68
+ child.stderr.on("data", handleStderr);
69
+ return {
70
+ dispose: () => disposeCommandConnection(child, handleStderr, execution.kill.bind(execution)),
71
+ kind: "stream",
72
+ stream: ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout))
73
+ };
74
+ }
75
+ function waitForClose(child) {
76
+ return new Promise((resolve) => {
77
+ if (child.exitCode !== null || child.signalCode !== null) {
78
+ resolve();
79
+ return;
80
+ }
81
+ child.once("close", () => resolve());
82
+ });
83
+ }
84
+ //#endregion
85
+ //#region src/tool-bridge.ts
86
+ var ToolBridge = class {
87
+ id = crypto.randomUUID();
88
+ token = crypto.randomUUID();
89
+ #closed = false;
90
+ #deferred = /* @__PURE__ */ new Map();
91
+ #onCall;
92
+ #server;
93
+ #tools;
94
+ #transport;
95
+ constructor(tools, onCall) {
96
+ this.#tools = tools;
97
+ this.#onCall = onCall;
98
+ this.#server = new McpServer({
99
+ name: "alint-openai-tools",
100
+ version: "0.3.2"
101
+ }, { capabilities: { tools: {} } });
102
+ this.#transport = new WebStandardStreamableHTTPServerTransport({
103
+ enableJsonResponse: true,
104
+ sessionIdGenerator: () => crypto.randomUUID()
105
+ });
106
+ for (const tool of this.#tools) this.#server.registerTool(tool.function.name, {
107
+ description: tool.function.description,
108
+ inputSchema: fromJSONSchema(inputSchema(tool))
109
+ }, (arguments_) => this.#call(tool.function.name, arguments_));
110
+ }
111
+ async close(reason = /* @__PURE__ */ new Error("The ACP turn ended before the tool returned.")) {
112
+ if (this.#closed) return;
113
+ this.#closed = true;
114
+ for (const deferred of this.#deferred.values()) deferred.reject(reason);
115
+ this.#deferred.clear();
116
+ await this.#server.close();
117
+ }
118
+ config(origin) {
119
+ return {
120
+ headers: [{
121
+ name: "authorization",
122
+ value: `Bearer ${this.token}`
123
+ }],
124
+ name: "openai-request-tools",
125
+ type: "http",
126
+ url: new URL(`/_alint/mcp/${this.id}`, origin).href
127
+ };
128
+ }
129
+ /**
130
+ * Routes the gateway's authenticated MCP endpoint into the request-scoped
131
+ * transport. Tool calls then enter `#call` through `McpServer.registerTool`.
132
+ */
133
+ handleRequest(request) {
134
+ return this.#transport.handleRequest(request);
135
+ }
136
+ resolve(callId, content) {
137
+ const deferred = this.#deferred.get(callId);
138
+ if (!deferred) return false;
139
+ this.#deferred.delete(callId);
140
+ deferred.resolve(content);
141
+ return true;
142
+ }
143
+ start() {
144
+ return this.#server.connect(this.#transport);
145
+ }
146
+ /**
147
+ * Handles an MCP `tools/call` request from the ACP agent. It records one pending
148
+ * result, emits the equivalent OpenAI tool call through `#onCall`, and completes
149
+ * when `resolve` receives the next request's tool result. Unknown tools and
150
+ * parallel calls fail before any OpenAI-visible state is emitted.
151
+ */
152
+ async #call(name, arguments_) {
153
+ if (!this.#tools.some((tool) => tool.function.name === name)) throw new McpError(ErrorCode.InvalidParams, `Unknown request tool: ${name}`);
154
+ if (this.#deferred.size > 0) throw new McpError(ErrorCode.InvalidRequest, "Parallel request tool calls are not supported.");
155
+ const id = `call_${crypto.randomUUID()}`;
156
+ const content = new Promise((resolve, reject) => {
157
+ this.#deferred.set(id, {
158
+ reject,
159
+ resolve
160
+ });
161
+ });
162
+ this.#onCall({
163
+ arguments: JSON.stringify(arguments_),
164
+ id,
165
+ name
166
+ });
167
+ return { content: [{
168
+ text: await content,
169
+ type: "text"
170
+ }] };
171
+ }
172
+ };
173
+ function inputSchema(tool) {
174
+ return {
175
+ ...tool.function.parameters,
176
+ properties: tool.function.parameters?.properties ?? {},
177
+ type: "object"
178
+ };
179
+ }
180
+ //#endregion
181
+ //#region src/gateway.ts
182
+ const openAIFunctionToolSchema = object({
183
+ function: object({
184
+ description: optional(string()),
185
+ name: string(),
186
+ parameters: optional(record(string(), unknown())),
187
+ strict: optional(boolean())
188
+ }),
189
+ type: literal("function")
190
+ });
191
+ const chatCompletionRequestSchema = looseObject({
192
+ messages: array(unknown()),
193
+ model: string(),
194
+ parallel_tool_calls: optional(literal(false), false),
195
+ stream: optional(boolean(), false),
196
+ stream_options: optional(unknown()),
197
+ temperature: optional(number()),
198
+ tool_choice: optional(union([
199
+ literal("auto"),
200
+ literal("none"),
201
+ literal("required"),
202
+ object({
203
+ function: object({ name: string() }),
204
+ type: literal("function")
205
+ })
206
+ ])),
207
+ tools: optional(array(openAIFunctionToolSchema), [])
208
+ });
209
+ var PromiseQueue = class {
210
+ #failed = false;
211
+ #failure;
212
+ #items = [];
213
+ #reject;
214
+ #removeAbortListener;
215
+ #resolve;
216
+ fail(reason) {
217
+ this.#failed = true;
218
+ this.#failure = reason;
219
+ if (this.#reject) {
220
+ const reject = this.#reject;
221
+ this.#clearWaiter();
222
+ reject(reason);
223
+ }
224
+ }
225
+ next(signal) {
226
+ const item = this.#items.shift();
227
+ if (item) return Promise.resolve(item);
228
+ if (this.#failed) return Promise.reject(this.#failure);
229
+ if (signal?.aborted) return Promise.reject(signal.reason);
230
+ return new Promise((resolve, reject) => {
231
+ this.#resolve = resolve;
232
+ this.#reject = reject;
233
+ if (signal) {
234
+ const abort = () => {
235
+ this.#clearWaiter();
236
+ reject(signal.reason);
237
+ };
238
+ signal.addEventListener("abort", abort, { once: true });
239
+ this.#removeAbortListener = () => signal.removeEventListener("abort", abort);
240
+ }
241
+ });
242
+ }
243
+ push(item) {
244
+ if (this.#resolve) {
245
+ const resolve = this.#resolve;
246
+ this.#clearWaiter();
247
+ resolve(item);
248
+ return;
249
+ }
250
+ this.#items.push(item);
251
+ }
252
+ #clearWaiter() {
253
+ this.#removeAbortListener?.();
254
+ this.#removeAbortListener = void 0;
255
+ this.#reject = void 0;
256
+ this.#resolve = void 0;
257
+ }
258
+ };
259
+ var CompletionTurn = class {
260
+ get modelId() {
261
+ return this.#options.model.id;
262
+ }
263
+ #bridge;
264
+ #cancelled;
265
+ #cancelSession;
266
+ #connection;
267
+ #connectionDisposed = false;
268
+ #finished = false;
269
+ #options;
270
+ #outputs = new PromiseQueue();
271
+ #pendingCalls = /* @__PURE__ */ new Map();
272
+ #timer;
273
+ #toolCallCount = 0;
274
+ constructor(options) {
275
+ this.#options = options;
276
+ if (options.tools.length > 0) {
277
+ this.#bridge = new ToolBridge(options.tools, (call) => {
278
+ this.#toolCallCount += 1;
279
+ this.#pendingCalls.set(call.id, call);
280
+ options.onPending(call, this);
281
+ this.#outputs.push({
282
+ call,
283
+ type: "tool-call"
284
+ });
285
+ });
286
+ options.bridges.set(this.#bridge.id, this.#bridge);
287
+ }
288
+ }
289
+ armContinuation(callId) {
290
+ if (!this.#pendingCalls.has(callId) || this.#timer) return;
291
+ this.#timer = setTimeout(() => {
292
+ this.cancel(/* @__PURE__ */ new Error(`Tool call ${callId} expired.`));
293
+ }, this.#options.continuationTtlMs);
294
+ this.#timer.unref?.();
295
+ }
296
+ async cancel(reason) {
297
+ if (this.#cancelled) return;
298
+ this.#cancelled = reason;
299
+ this.#outputs.fail(reason);
300
+ this.#cancelSession?.().catch(() => {});
301
+ await this.#bridge?.close(reason);
302
+ await this.#disposeConnection();
303
+ this.#finish();
304
+ }
305
+ async nextOutput(signal) {
306
+ try {
307
+ return await this.#outputs.next(signal);
308
+ } catch (error) {
309
+ if (signal?.aborted) await this.cancel(/* @__PURE__ */ new Error("The OpenAI request was aborted."));
310
+ throw error;
311
+ }
312
+ }
313
+ resume(request, result) {
314
+ if (!this.#matchesContinuation(request, result.toolCallId)) return false;
315
+ const resumed = this.#bridge?.resolve(result.toolCallId, result.content) ?? false;
316
+ if (resumed && this.#timer) {
317
+ clearTimeout(this.#timer);
318
+ this.#timer = void 0;
319
+ }
320
+ if (resumed) this.#pendingCalls.delete(result.toolCallId);
321
+ return resumed;
322
+ }
323
+ start() {
324
+ this.#run().catch((error) => this.#outputs.fail(error));
325
+ }
326
+ #assertActive() {
327
+ if (this.#cancelled) throw this.#cancelled;
328
+ }
329
+ async #disposeConnection() {
330
+ if (this.#connectionDisposed || this.#connection?.kind !== "stream") return;
331
+ this.#connectionDisposed = true;
332
+ await this.#connection.dispose?.();
333
+ }
334
+ #finish() {
335
+ if (this.#finished) return;
336
+ this.#finished = true;
337
+ this.#options.onFinished(this, [...this.#pendingCalls.keys()]);
338
+ }
339
+ #matchesContinuation(request, toolCallId) {
340
+ const initialMessages = this.#options.request.messages;
341
+ const initialPrefix = request.messages.slice(0, initialMessages.length);
342
+ const assistantMessage = request.messages.at(-2);
343
+ const pendingCall = this.#pendingCalls.get(toolCallId);
344
+ if (!pendingCall || request.model !== this.#options.model.id || JSON.stringify(request.tools) !== JSON.stringify(this.#options.request.tools) || JSON.stringify(request.toolChoice) !== JSON.stringify(this.#options.request.toolChoice) || JSON.stringify(initialPrefix) !== JSON.stringify(initialMessages) || !isRecord(assistantMessage)) return false;
345
+ const toolCalls = assistantMessage.tool_calls;
346
+ return Array.isArray(toolCalls) && toolCalls.some((call) => isRecord(call) && call.id === toolCallId && isRecord(call.function) && call.function.name === pendingCall.name && call.function.arguments === pendingCall.arguments);
347
+ }
348
+ async #run() {
349
+ const { cwd, model, origin, request } = this.#options;
350
+ try {
351
+ const connection = await model.openConnection();
352
+ this.#connection = connection;
353
+ this.#assertActive();
354
+ const acpClient = authorizeRequestTools(client({ name: "alint-model-adapter-acp" }));
355
+ await this.#bridge?.start();
356
+ this.#assertActive();
357
+ await connectWith(acpClient, connection, async (context) => {
358
+ await context.request(methods.agent.initialize, {
359
+ clientCapabilities: {},
360
+ clientInfo: {
361
+ name: "alint-model-adapter-acp",
362
+ version: "0.3.2"
363
+ },
364
+ protocolVersion: PROTOCOL_VERSION
365
+ });
366
+ this.#assertActive();
367
+ await context.buildSession({
368
+ ...sessionExtensions(request.extensions),
369
+ cwd,
370
+ mcpServers: this.#bridge ? [this.#bridge.config(origin)] : []
371
+ }).withSession(async (session) => {
372
+ this.#cancelSession = () => context.notify(methods.agent.session.cancel, { sessionId: session.sessionId });
373
+ this.#assertActive();
374
+ for (let attempt = 0; attempt < 2; attempt += 1) {
375
+ const callCountBeforePrompt = this.#toolCallCount;
376
+ const prompt = session.prompt(attempt === 0 ? renderMessages(request) : "The request requires a tool call. Call the available function before returning an answer.");
377
+ let text = "";
378
+ for (;;) {
379
+ const message = await session.nextUpdate();
380
+ if (message.kind === "stop") {
381
+ await prompt;
382
+ if (message.stopReason === "cancelled") throw new Error("The ACP agent cancelled the prompt.");
383
+ if (requiresTool(request.toolChoice) && this.#toolCallCount === callCountBeforePrompt) break;
384
+ this.#outputs.push({
385
+ stopReason: message.stopReason,
386
+ text,
387
+ type: "completion",
388
+ usage: message.response.usage
389
+ });
390
+ return;
391
+ }
392
+ if (message.update.sessionUpdate === "agent_message_chunk" && message.update.content.type === "text") if (request.stream) this.#outputs.push({
393
+ text: message.update.content.text,
394
+ type: "text-delta"
395
+ });
396
+ else text += message.update.content.text;
397
+ }
398
+ }
399
+ throw new Error("The ACP agent did not satisfy tool_choice after one retry.");
400
+ });
401
+ });
402
+ } finally {
403
+ if (this.#timer) clearTimeout(this.#timer);
404
+ if (this.#bridge) {
405
+ this.#options.bridges.delete(this.#bridge.id);
406
+ await this.#bridge.close();
407
+ }
408
+ this.#finish();
409
+ await this.#disposeConnection();
410
+ }
411
+ }
412
+ };
413
+ /** Creates the OpenAI-compatible HTTP interface backed by configured ACP agents. */
414
+ function createGateway(options) {
415
+ const models = new Map(options.models.map((model) => [model.id, model]));
416
+ const activeTurns = /* @__PURE__ */ new Set();
417
+ const bridges = /* @__PURE__ */ new Map();
418
+ const pendingTurns = /* @__PURE__ */ new Map();
419
+ const configuredMcpOrigin = options.mcpBaseUrl ? new URL(options.mcpBaseUrl).origin : void 0;
420
+ const app = new H3();
421
+ app.get("/v1/models", defineEventHandler(() => ({
422
+ data: options.models.map((model) => ({
423
+ created: 0,
424
+ id: model.id,
425
+ object: "model",
426
+ owned_by: model.name
427
+ })),
428
+ object: "list"
429
+ })));
430
+ app.all("/_alint/mcp/:bridgeId", defineEventHandler(async (event) => {
431
+ const bridge = bridges.get(getRouterParam(event, "bridgeId") ?? "");
432
+ if (!bridge || event.req.headers.get("authorization") !== `Bearer ${bridge.token}`) return new Response(null, { status: 404 });
433
+ return bridge.handleRequest(event.req);
434
+ }));
435
+ app.post("/v1/chat/completions", defineEventHandler(async (event) => {
436
+ const request = chatCompletionRequest(await event.req.json().catch(() => void 0));
437
+ if (!request) {
438
+ event.res.status = 400;
439
+ return openAIError("Invalid chat completion request.", "invalid_request_error");
440
+ }
441
+ if (request.temperature !== void 0) options.onCompatibilityDiagnostic?.({
442
+ field: "temperature",
443
+ message: "ACP has no temperature equivalent; the configured agent controls sampling.",
444
+ value: request.temperature
445
+ });
446
+ const model = models.get(request.model);
447
+ if (!model) {
448
+ event.res.status = 404;
449
+ return openAIError(`Unknown model: ${request.model}`, "invalid_request_error");
450
+ }
451
+ const toolResult = lastToolResult(request.messages);
452
+ let turn;
453
+ if (request.stream && (request.tools.length > 0 || toolResult)) {
454
+ event.res.status = 400;
455
+ return openAIError("Streaming tool calls are not supported.", "invalid_request_error");
456
+ }
457
+ if (toolResult) {
458
+ const pendingTurn = pendingTurns.get(toolResult.toolCallId);
459
+ if (!pendingTurn || !pendingTurn.resume(request, toolResult)) {
460
+ event.res.status = 400;
461
+ return openAIError(`Unknown tool call: ${toolResult.toolCallId}`, "invalid_request_error");
462
+ }
463
+ pendingTurns.delete(toolResult.toolCallId);
464
+ turn = pendingTurn;
465
+ } else {
466
+ const selectedTools = toolsForChoice(request.tools, request.toolChoice);
467
+ const origin = mcpOrigin(event.req.url, configuredMcpOrigin);
468
+ if (requiresTool(request.toolChoice) && selectedTools.length === 0) {
469
+ event.res.status = 400;
470
+ return openAIError("tool_choice requires a function present in tools.", "invalid_request_error");
471
+ }
472
+ if (selectedTools.length > 0 && !origin) {
473
+ event.res.status = 500;
474
+ return openAIError("mcpBaseUrl is required when the gateway is not accessed through loopback.", "server_error");
475
+ }
476
+ turn = new CompletionTurn({
477
+ bridges,
478
+ continuationTtlMs: options.continuationTtlMs ?? 3e4,
479
+ cwd: options.cwd ?? process.cwd(),
480
+ model,
481
+ onFinished: (finishedTurn, pendingCallIds) => {
482
+ activeTurns.delete(finishedTurn);
483
+ for (const callId of pendingCallIds) pendingTurns.delete(callId);
484
+ },
485
+ onPending: (call, pendingTurn) => pendingTurns.set(call.id, pendingTurn),
486
+ origin: origin ?? new URL(event.req.url).origin,
487
+ request,
488
+ tools: selectedTools
489
+ });
490
+ activeTurns.add(turn);
491
+ turn.start();
492
+ }
493
+ if (request.stream) return streamCompletion(turn, model.id);
494
+ let output;
495
+ try {
496
+ output = await turn.nextOutput(event.req.signal);
497
+ } catch {
498
+ event.res.status = 500;
499
+ return openAIError("The ACP agent could not complete the request.", "server_error");
500
+ }
501
+ if (output.type === "tool-call") {
502
+ turn.armContinuation(output.call.id);
503
+ options.onCompatibilityDiagnostic?.({
504
+ field: "usage",
505
+ message: "ACP usage is unavailable while a deferred tool call is pending; zero usage was reported.",
506
+ value: null
507
+ });
508
+ return chatCompletion(turn.modelId, {
509
+ finishReason: "tool_calls",
510
+ message: {
511
+ content: null,
512
+ role: "assistant",
513
+ tool_calls: [{
514
+ function: {
515
+ arguments: output.call.arguments,
516
+ name: output.call.name
517
+ },
518
+ id: output.call.id,
519
+ type: "function"
520
+ }]
521
+ }
522
+ }, {
523
+ inputTokens: 0,
524
+ outputTokens: 0,
525
+ totalTokens: 0
526
+ });
527
+ }
528
+ if (output.type === "text-delta") throw new Error("Text deltas are only emitted for streaming requests.");
529
+ return chatCompletion(turn.modelId, {
530
+ finishReason: finishReason(output.stopReason),
531
+ message: {
532
+ content: output.text,
533
+ role: "assistant"
534
+ }
535
+ }, output.usage);
536
+ }));
537
+ return Object.assign(app, {
538
+ /**
539
+ * Cancels turns retained by the H3 application.
540
+ *
541
+ * Triggering workflow:
542
+ *
543
+ * {@link startGateway}
544
+ * -> `GatewayApp.shutdown`
545
+ * -> {@link CompletionTurn.cancel}
546
+ *
547
+ * Upstream:
548
+ * - {@link startGateway}
549
+ *
550
+ * Downstream:
551
+ * - {@link CompletionTurn.cancel}
552
+ */
553
+ async shutdown() {
554
+ await Promise.all([...activeTurns].map((turn) => turn.cancel(/* @__PURE__ */ new Error("The gateway is shutting down."))));
555
+ bridges.clear();
556
+ pendingTurns.clear();
557
+ } });
558
+ }
559
+ /** Starts an ephemeral loopback gateway for one CLI run. */
560
+ async function startGateway(options) {
561
+ const app = createGateway(options);
562
+ const server = await serve(app, {
563
+ hostname: "127.0.0.1",
564
+ port: 0,
565
+ silent: true
566
+ }).ready();
567
+ let stopped = false;
568
+ return {
569
+ endpoint: new URL("/v1/", server.url).href,
570
+ /**
571
+ * Stops adapter work before closing the loopback listener.
572
+ *
573
+ * Triggering workflow:
574
+ *
575
+ * `ModelAdapterRuntime.shutdown`
576
+ * -> `GatewayServer.shutdown`
577
+ * -> {@link GatewayApp.shutdown}
578
+ *
579
+ * Upstream:
580
+ * - {@link startGateway}
581
+ *
582
+ * Downstream:
583
+ * - {@link GatewayApp.shutdown}
584
+ * - `Server.close`
585
+ */
586
+ async shutdown() {
587
+ if (stopped) return;
588
+ stopped = true;
589
+ await app.shutdown();
590
+ await server.close(true);
591
+ }
592
+ };
593
+ }
594
+ /**
595
+ * Allows an agent to invoke only the request-scoped tools supplied by the OpenAI caller.
596
+ *
597
+ * Triggering workflow:
598
+ *
599
+ * {@link CompletionTurn.#run}
600
+ * -> ACP `session/request_permission`
601
+ * -> {@link authorizeRequestTools}
602
+ *
603
+ * The ACP Codex adapter marks MCP tool approvals with `is_mcp_tool_approval`. Other
604
+ * permission requests can execute commands or edit files, so they remain cancelled.
605
+ *
606
+ * Upstream:
607
+ * - {@link CompletionTurn.#run}
608
+ *
609
+ * Downstream:
610
+ * - ACP permission response
611
+ */
612
+ function authorizeRequestTools(client) {
613
+ return client.onRequest(methods.client.session.requestPermission, ({ params }) => {
614
+ const meta = params._meta;
615
+ const allowOnce = params.options.find((option) => option.kind === "allow_once");
616
+ if (isRecord(meta) && meta.is_mcp_tool_approval === true && allowOnce) return { outcome: {
617
+ optionId: allowOnce.optionId,
618
+ outcome: "selected"
619
+ } };
620
+ return { outcome: { outcome: "cancelled" } };
621
+ });
622
+ }
623
+ function chatCompletion(model, choice, usage) {
624
+ return {
625
+ choices: [{
626
+ finish_reason: choice.finishReason,
627
+ index: 0,
628
+ message: choice.message
629
+ }],
630
+ created: Math.floor(Date.now() / 1e3),
631
+ id: `chatcmpl-${crypto.randomUUID()}`,
632
+ model,
633
+ object: "chat.completion",
634
+ ...usage ? { usage: {
635
+ completion_tokens: usage.outputTokens,
636
+ prompt_tokens: usage.inputTokens,
637
+ total_tokens: usage.totalTokens
638
+ } } : {}
639
+ };
640
+ }
641
+ function chatCompletionChunk(id, created, model, choices) {
642
+ return {
643
+ choices,
644
+ created,
645
+ id,
646
+ model,
647
+ object: "chat.completion.chunk"
648
+ };
649
+ }
650
+ function chatCompletionRequest(value) {
651
+ const result = safeParse(chatCompletionRequestSchema, value);
652
+ if (!result.success) return;
653
+ const { messages, model, parallel_tool_calls: _parallelToolCalls, stream, stream_options: _streamOptions, temperature, tool_choice: toolChoice, tools, ...extensions } = result.output;
654
+ return {
655
+ extensions,
656
+ messages,
657
+ model,
658
+ stream,
659
+ temperature,
660
+ toolChoice,
661
+ tools
662
+ };
663
+ }
664
+ function connectWith(client, connection, operation) {
665
+ return connection.kind === "agent-app" ? client.connectWith(connection.agent, operation) : client.connectWith(connection.stream, operation);
666
+ }
667
+ function enqueueSse(controller, encoder, value) {
668
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`));
669
+ }
670
+ function finishReason(stopReason) {
671
+ switch (stopReason) {
672
+ case "cancelled":
673
+ case "end_turn": return "stop";
674
+ case "max_tokens":
675
+ case "max_turn_requests": return "length";
676
+ case "refusal": return "content_filter";
677
+ }
678
+ }
679
+ function isRecord(value) {
680
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
681
+ }
682
+ function lastToolResult(messages) {
683
+ const message = messages.at(-1);
684
+ if (!isRecord(message) || message.role !== "tool" || typeof message.tool_call_id !== "string") return;
685
+ return {
686
+ content: typeof message.content === "string" ? message.content : JSON.stringify(message.content),
687
+ toolCallId: message.tool_call_id
688
+ };
689
+ }
690
+ function mcpOrigin(requestUrl, configuredOrigin) {
691
+ if (configuredOrigin) return configuredOrigin;
692
+ const url = new URL(requestUrl);
693
+ return [
694
+ "127.0.0.1",
695
+ "[::1]",
696
+ "localhost"
697
+ ].includes(url.hostname) ? url.origin : void 0;
698
+ }
699
+ function openAIError(message, type) {
700
+ return { error: {
701
+ message,
702
+ type
703
+ } };
704
+ }
705
+ function renderMessages(request) {
706
+ const requiredTool = request.toolChoice && typeof request.toolChoice === "object" ? request.toolChoice.function.name : void 0;
707
+ return [
708
+ "Process this OpenAI chat transcript. Preserve message order and role intent.",
709
+ "Return only the assistant response requested by the transcript.",
710
+ request.toolChoice === "required" ? "You must call one of the supplied tools before answering." : "",
711
+ requiredTool ? `You must call the ${JSON.stringify(requiredTool)} tool before answering.` : "",
712
+ "",
713
+ JSON.stringify({ messages: request.messages })
714
+ ].join("\n");
715
+ }
716
+ function requiresTool(choice) {
717
+ return choice === "required" || typeof choice === "object";
718
+ }
719
+ function sessionExtensions(extensions) {
720
+ const { _meta: explicitMeta, ...fields } = extensions;
721
+ if (Object.keys(fields).length === 0 && !isRecord(explicitMeta)) return {};
722
+ return {
723
+ ...fields,
724
+ _meta: {
725
+ ...fields,
726
+ ...isRecord(explicitMeta) ? explicitMeta : {}
727
+ }
728
+ };
729
+ }
730
+ function streamCompletion(turn, model) {
731
+ const encoder = new TextEncoder();
732
+ const id = `chatcmpl-${crypto.randomUUID()}`;
733
+ const created = Math.floor(Date.now() / 1e3);
734
+ const body = new ReadableStream({
735
+ async cancel() {
736
+ await turn.cancel(/* @__PURE__ */ new Error("The OpenAI stream consumer cancelled the request."));
737
+ },
738
+ async start(controller) {
739
+ try {
740
+ for (;;) {
741
+ const output = await turn.nextOutput();
742
+ if (output.type === "text-delta") {
743
+ enqueueSse(controller, encoder, chatCompletionChunk(id, created, model, [{
744
+ delta: {
745
+ content: output.text,
746
+ role: "assistant"
747
+ },
748
+ index: 0
749
+ }]));
750
+ continue;
751
+ }
752
+ if (output.type === "tool-call") throw new Error("Streaming tool calls are not supported.");
753
+ enqueueSse(controller, encoder, chatCompletionChunk(id, created, model, [{
754
+ delta: {},
755
+ finish_reason: finishReason(output.stopReason),
756
+ index: 0
757
+ }]));
758
+ if (output.usage) enqueueSse(controller, encoder, {
759
+ ...chatCompletionChunk(id, created, model, []),
760
+ usage: {
761
+ completion_tokens: output.usage.outputTokens,
762
+ prompt_tokens: output.usage.inputTokens,
763
+ total_tokens: output.usage.totalTokens
764
+ }
765
+ });
766
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
767
+ controller.close();
768
+ return;
769
+ }
770
+ } catch (error) {
771
+ controller.error(error);
772
+ }
773
+ }
774
+ });
775
+ return new Response(body, { headers: {
776
+ "cache-control": "no-cache",
777
+ "content-type": "text/event-stream; charset=utf-8"
778
+ } });
779
+ }
780
+ function toolsForChoice(tools, choice) {
781
+ if (choice === "none") return [];
782
+ if (typeof choice === "object") return tools.filter((tool) => tool.function.name === choice.function.name);
783
+ return tools;
784
+ }
785
+ //#endregion
786
+ export { createCommandModel, createGateway, startGateway };
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@alint-js/model-adapter-acp",
3
+ "type": "module",
4
+ "version": "0.4.0",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./dist/index.d.mts",
8
+ "default": "./dist/index.mjs"
9
+ },
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "dependencies": {
16
+ "@agentclientprotocol/sdk": "^1.3.0",
17
+ "@modelcontextprotocol/sdk": "^1.30.0",
18
+ "h3": "2.0.1-rc.25",
19
+ "tinyexec": "^1.2.4",
20
+ "valibot": "^1.4.2",
21
+ "zod": "^4.4.3"
22
+ },
23
+ "devDependencies": {
24
+ "@alint-js/core": "0.4.0"
25
+ },
26
+ "scripts": {
27
+ "build": "tsdown",
28
+ "typecheck": "tsc -p tsconfig.json --noEmit",
29
+ "test": "vitest run --config vitest.config.ts"
30
+ }
31
+ }