@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 +21 -0
- package/README.md +98 -0
- package/dist/index.d.mts +54 -0
- package/dist/index.mjs +786 -0
- package/package.json +31 -0
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.
|
package/dist/index.d.mts
ADDED
|
@@ -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
|
+
}
|