openmausbot 0.1.78 → 0.1.80
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/assets/index-Cu8BkIfo.js +305 -0
- package/dist/assets/{index-BqDJqf2C.js → index-D6RZKWks.js} +1 -1
- package/dist/assets/index-tfpeJAIG.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +11 -5
- package/dist-server/drivers/agents-proxy.js +21 -6
- package/dist-server/enterprise/server/index.js +0 -1
- package/dist-server/index.js +15942 -6859
- package/dist-server/local-computer.js +0 -1
- package/dist-server/mcp-server.js +8 -1
- package/dist-server/openmausbot.js +9303 -786
- package/dist-server/pair-cli.js +9303 -786
- package/dist-server/proxy-paths.js +0 -1
- package/dist-server/server/box.js +98 -41
- package/dist-server/server/browser-engine.js +1 -1
- package/dist-server/server/browser-runtime.js +11 -3
- package/dist-server/server/browser-tool-shape.js +72 -0
- package/dist-server/server/claude-accounts.js +1 -0
- package/dist-server/server/cli-setup.js +1 -1
- package/dist-server/server/composio.js +18 -0
- package/dist-server/server/config.js +16 -4
- package/dist-server/server/container-computer.js +0 -12
- package/dist-server/server/drivers/acp/core.js +4 -19
- package/dist-server/server/drivers/agents-proxy.js +27 -2
- package/dist-server/server/drivers/boxagent.js +48 -4
- package/dist-server/server/drivers/chat-mcp-tools.js +368 -0
- package/dist-server/server/drivers/chat-tool-approval.js +42 -0
- package/dist-server/server/drivers/claude.js +86 -38
- package/dist-server/server/drivers/codex.js +225 -75
- package/dist-server/server/drivers/grok.js +4 -0
- package/dist-server/server/drivers/minimax.js +11 -3
- package/dist-server/server/drivers/openai-chat-protocol.js +92 -0
- package/dist-server/server/drivers/openai-chat.js +332 -88
- package/dist-server/server/drivers/openai-compat.js +4 -0
- package/dist-server/server/drivers/pi.js +1 -9
- package/dist-server/server/harness/registry.js +2 -2
- package/dist-server/server/index.js +267 -50
- package/dist-server/server/managed-desktop.js +205 -0
- package/dist-server/server/model-context-window.js +20 -0
- package/dist-server/server/proxy-paths.js +0 -1
- package/dist-server/server/redact.js +1 -0
- package/dist-server/server/room-handoffs.js +57 -9
- package/dist-server/server/store.js +13 -1
- package/dist-server/server/tts/chatterbox.js +83 -0
- package/dist-server/server/tts/index.js +45 -12
- package/dist-server/server/workspace.js +12 -2
- package/dist-server/shared/ask-question.js +28 -0
- package/dist-server/shared/computer-contention.js +17 -0
- package/dist-server/vps-container-mcp.js +11 -5
- package/enterprise/server/index.js +0 -1
- package/package.json +1 -1
- package/dist/assets/index-DjwAroIZ.js +0 -305
- package/dist/assets/index-Pbb6Ao0s.css +0 -1
- package/dist-server/computer-proxy.js +0 -1196
- package/dist-server/server/computer-proxy.js +0 -1070
- package/dist-server/server/remote-computer.js +0 -170
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export const MAX_CHAT_TOOL_CALLS = 32;
|
|
2
|
+
const MAX_ARGUMENT_CHARS = 256_000;
|
|
3
|
+
export class ChatProtocolError extends Error {
|
|
4
|
+
}
|
|
5
|
+
export function object(value) {
|
|
6
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
7
|
+
? value : undefined;
|
|
8
|
+
}
|
|
9
|
+
/** Opaque provider reasoning blocks must keep their original order/signatures.
|
|
10
|
+
* OpenRouter streams the sequence as arrays of blocks, not tool-call deltas. */
|
|
11
|
+
export class ChatReasoningDetails {
|
|
12
|
+
blocks = [];
|
|
13
|
+
chars = 0;
|
|
14
|
+
add(value) {
|
|
15
|
+
if (value === undefined || value === null)
|
|
16
|
+
return;
|
|
17
|
+
if (!Array.isArray(value) || value.some((block) => !object(block))) {
|
|
18
|
+
throw new ChatProtocolError("provider returned invalid reasoning details");
|
|
19
|
+
}
|
|
20
|
+
this.chars += JSON.stringify(value).length;
|
|
21
|
+
if (this.chars > 256_000)
|
|
22
|
+
throw new ChatProtocolError("provider reasoning details exceeded the size limit");
|
|
23
|
+
this.blocks.push(...value);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** One accumulator per completion: indexes identify deltas; IDs correlate results. */
|
|
27
|
+
export class ChatToolCalls {
|
|
28
|
+
calls = new Map();
|
|
29
|
+
argumentChars = 0;
|
|
30
|
+
add(value, streaming) {
|
|
31
|
+
if (value === undefined || value === null)
|
|
32
|
+
return;
|
|
33
|
+
if (!Array.isArray(value))
|
|
34
|
+
throw new ChatProtocolError("provider returned invalid tool_calls");
|
|
35
|
+
for (const [position, raw] of value.entries()) {
|
|
36
|
+
const delta = object(raw);
|
|
37
|
+
const index = streaming ? delta?.index : position;
|
|
38
|
+
if (!delta || !Number.isInteger(index) || Number(index) < 0 || Number(index) >= MAX_CHAT_TOOL_CALLS) {
|
|
39
|
+
throw new ChatProtocolError("provider returned an invalid tool-call index");
|
|
40
|
+
}
|
|
41
|
+
const call = this.calls.get(Number(index)) ?? { id: "", type: "function", function: { name: "", arguments: "" } };
|
|
42
|
+
if (delta.type !== undefined && delta.type !== "function")
|
|
43
|
+
throw new ChatProtocolError("unsupported tool-call type");
|
|
44
|
+
if (delta.id !== undefined) {
|
|
45
|
+
if (typeof delta.id !== "string" || !delta.id || delta.id.length > 256 || (call.id && call.id !== delta.id)) {
|
|
46
|
+
throw new ChatProtocolError("provider changed or omitted a tool-call ID");
|
|
47
|
+
}
|
|
48
|
+
call.id = delta.id;
|
|
49
|
+
}
|
|
50
|
+
if (delta.function !== undefined) {
|
|
51
|
+
const fn = object(delta.function);
|
|
52
|
+
if (!fn)
|
|
53
|
+
throw new ChatProtocolError("provider returned an invalid tool-call function");
|
|
54
|
+
for (const field of ["name", "arguments"]) {
|
|
55
|
+
if (fn[field] === undefined)
|
|
56
|
+
continue;
|
|
57
|
+
if (typeof fn[field] !== "string")
|
|
58
|
+
throw new ChatProtocolError("provider returned invalid tool-call arguments or name");
|
|
59
|
+
call.function[field] += fn[field];
|
|
60
|
+
if (field === "arguments")
|
|
61
|
+
this.argumentChars += fn[field].length;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (call.function.name.length > 64 || this.argumentChars > MAX_ARGUMENT_CHARS) {
|
|
65
|
+
throw new ChatProtocolError("provider tool calls exceeded the size limit");
|
|
66
|
+
}
|
|
67
|
+
this.calls.set(Number(index), call);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
finish(reason, malformedFrame) {
|
|
71
|
+
const calls = [...this.calls.entries()].sort(([a], [b]) => a - b).map(([, call]) => call);
|
|
72
|
+
if (malformedFrame)
|
|
73
|
+
throw new ChatProtocolError("provider stream contained a malformed completion frame");
|
|
74
|
+
if (reason === "function_call")
|
|
75
|
+
throw new ChatProtocolError("legacy function_call is unsupported; use structured tool_calls");
|
|
76
|
+
if (reason === "tool_calls" && !calls.length)
|
|
77
|
+
throw new ChatProtocolError("provider ended for tool calls without a complete call");
|
|
78
|
+
if (!calls.length)
|
|
79
|
+
return calls;
|
|
80
|
+
if (reason !== null && reason !== "tool_calls" && reason !== "stop") {
|
|
81
|
+
throw new ChatProtocolError("provider tool calls were truncated or malformed");
|
|
82
|
+
}
|
|
83
|
+
const ids = new Set();
|
|
84
|
+
for (const call of calls) {
|
|
85
|
+
if (!call.id || ids.has(call.id) || !/^[A-Za-z0-9_-]{1,64}$/.test(call.function.name)) {
|
|
86
|
+
throw new ChatProtocolError("provider returned an incomplete or duplicate tool call");
|
|
87
|
+
}
|
|
88
|
+
ids.add(call.id);
|
|
89
|
+
}
|
|
90
|
+
return calls;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import { newEventId, newId } from "../contracts.js";
|
|
2
|
+
import { redactSecretsInText } from "../redact.js";
|
|
3
|
+
import { toolDetailPreview } from "../tool-summary.js";
|
|
4
|
+
import { ChatToolSessionError, mountChatTools } from "./chat-mcp-tools.js";
|
|
5
|
+
import { createChatToolApproval } from "./chat-tool-approval.js";
|
|
6
|
+
import { ChatProtocolError, ChatReasoningDetails, ChatToolCalls, MAX_CHAT_TOOL_CALLS, object } from "./openai-chat-protocol.js";
|
|
2
7
|
import { appendNative } from "./native.js";
|
|
3
8
|
import { classifyError, computeBackoff, interruptibleDelay, RETRY_MAX_ATTEMPTS } from "./retry.js";
|
|
9
|
+
/** The message of a JSON error body a provider returned with HTTP 200.
|
|
10
|
+
* MiniMax reports auth, balance, and parameter failures as `base_resp`. */
|
|
11
|
+
function providerError(json) {
|
|
12
|
+
if (typeof json.error === "string")
|
|
13
|
+
return json.error;
|
|
14
|
+
const error = object(json.error);
|
|
15
|
+
if (error)
|
|
16
|
+
return typeof error.message === "string" ? error.message : "unknown error";
|
|
17
|
+
const code = json.base_resp?.status_code;
|
|
18
|
+
if (typeof code === "number" && code !== 0) {
|
|
19
|
+
const msg = typeof json.base_resp?.status_msg === "string" ? json.base_resp.status_msg : "";
|
|
20
|
+
return `upstream error ${code}${msg ? `: ${msg}` : ""}`;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
4
24
|
const usageFrom = (usage) => usage
|
|
5
25
|
? { input: usage.prompt_tokens ?? 0, output: usage.completion_tokens ?? 0 }
|
|
6
26
|
: null;
|
|
@@ -21,7 +41,7 @@ export function createOpenAIChatRuntime(options) {
|
|
|
21
41
|
turnId,
|
|
22
42
|
createdAt: new Date().toISOString(),
|
|
23
43
|
});
|
|
24
|
-
const complete = async (messages, model, stream, signal, onDelta) => {
|
|
44
|
+
const complete = async (messages, model, stream, signal, onDelta, tools = []) => {
|
|
25
45
|
// Idle timer that is renewed on every received chunk during streaming
|
|
26
46
|
const timeoutController = new AbortController();
|
|
27
47
|
let idleTimer = null;
|
|
@@ -40,22 +60,44 @@ export function createOpenAIChatRuntime(options) {
|
|
|
40
60
|
const response = await fetch(`${options.apiUrl}/chat/completions`, {
|
|
41
61
|
method: "POST",
|
|
42
62
|
headers: { authorization: `Bearer ${options.apiKey}`, "content-type": "application/json" },
|
|
43
|
-
body: JSON.stringify(
|
|
63
|
+
body: JSON.stringify({
|
|
64
|
+
...options.requestBody(model, messages, stream),
|
|
65
|
+
...(tools.length ? { tools } : {}),
|
|
66
|
+
}),
|
|
44
67
|
signal: activeSignal,
|
|
45
68
|
});
|
|
46
69
|
if (!response.ok) {
|
|
47
70
|
const body = await response.text().catch(() => "");
|
|
48
71
|
throw new Error(`${options.httpErrorLabel} HTTP ${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`);
|
|
49
72
|
}
|
|
50
|
-
if (!stream) {
|
|
73
|
+
if (!stream || response.headers.get("content-type")?.includes("application/json")) {
|
|
51
74
|
const json = await response.json();
|
|
75
|
+
const bodyError = providerError(json);
|
|
76
|
+
if (bodyError)
|
|
77
|
+
throw new ChatProtocolError(`provider returned a completion error: ${bodyError.slice(0, 200)}`);
|
|
52
78
|
const message = json.choices?.[0]?.message;
|
|
79
|
+
if (!message || !object(message) || !["content", "reasoning_content", "reasoning", "reasoning_details", "tool_calls", "function_call"].some((key) => key in message)) {
|
|
80
|
+
throw new ChatProtocolError("provider returned no completion message");
|
|
81
|
+
}
|
|
82
|
+
if (message?.function_call)
|
|
83
|
+
throw new ChatProtocolError("legacy function_call is unsupported; use structured tool_calls");
|
|
84
|
+
const calls = new ChatToolCalls();
|
|
85
|
+
calls.add(message?.tool_calls, false);
|
|
86
|
+
const details = new ChatReasoningDetails();
|
|
87
|
+
details.add(message.reasoning_details);
|
|
88
|
+
const reasoning = message.reasoning_content ?? message.reasoning;
|
|
89
|
+
const finishReason = json.choices?.[0]?.finish_reason ?? null;
|
|
90
|
+
activeSignal.throwIfAborted();
|
|
53
91
|
return {
|
|
54
92
|
text: typeof message?.content === "string" ? message.content : "",
|
|
55
|
-
reasoning: options.reasoning && typeof
|
|
56
|
-
?
|
|
93
|
+
reasoning: options.reasoning && typeof reasoning === "string"
|
|
94
|
+
? reasoning
|
|
57
95
|
: "",
|
|
58
96
|
usage: usageFrom(json.usage),
|
|
97
|
+
toolCalls: calls.finish(finishReason, false),
|
|
98
|
+
finishReason,
|
|
99
|
+
protocolReasoning: typeof reasoning === "string" ? reasoning : "",
|
|
100
|
+
protocolReasoningDetails: details.blocks,
|
|
59
101
|
};
|
|
60
102
|
}
|
|
61
103
|
if (!response.body) {
|
|
@@ -63,7 +105,13 @@ export function createOpenAIChatRuntime(options) {
|
|
|
63
105
|
}
|
|
64
106
|
let text = "";
|
|
65
107
|
let reasoning = "";
|
|
108
|
+
let protocolReasoning = "";
|
|
66
109
|
let usage = null;
|
|
110
|
+
let finishReason = null;
|
|
111
|
+
let malformedFrame = false;
|
|
112
|
+
let sawChoice = false;
|
|
113
|
+
const calls = new ChatToolCalls();
|
|
114
|
+
const details = new ChatReasoningDetails();
|
|
67
115
|
const reader = response.body.getReader();
|
|
68
116
|
const decoder = new TextDecoder();
|
|
69
117
|
let buffer = "";
|
|
@@ -71,12 +119,29 @@ export function createOpenAIChatRuntime(options) {
|
|
|
71
119
|
readLoop: for (;;) {
|
|
72
120
|
const { done, value } = await reader.read();
|
|
73
121
|
if (done) {
|
|
74
|
-
|
|
122
|
+
buffer += decoder.decode();
|
|
123
|
+
// MiniMax's api.minimax.io/v1 closes the connection after the
|
|
124
|
+
// finish_reason chunk and never sends `[DONE]`.
|
|
125
|
+
if (buffer.trim() === "data: [DONE]" || finishReason)
|
|
75
126
|
break;
|
|
76
|
-
|
|
127
|
+
if (!sawChoice) {
|
|
128
|
+
let body;
|
|
129
|
+
try {
|
|
130
|
+
body = JSON.parse(buffer);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
body = undefined;
|
|
134
|
+
}
|
|
135
|
+
const bodyError = body ? providerError(body) : null;
|
|
136
|
+
if (bodyError)
|
|
137
|
+
throw new ChatProtocolError(`provider returned a completion error: ${bodyError.slice(0, 200)}`);
|
|
138
|
+
}
|
|
139
|
+
throw new ChatProtocolError("Stream ended before completion");
|
|
77
140
|
}
|
|
78
141
|
resetIdleTimer();
|
|
79
142
|
buffer += decoder.decode(value, { stream: true });
|
|
143
|
+
if (buffer.length > 2_000_000)
|
|
144
|
+
throw new ChatProtocolError("provider stream frame exceeded the size limit");
|
|
80
145
|
let newline;
|
|
81
146
|
while ((newline = buffer.indexOf("\n")) !== -1) {
|
|
82
147
|
const line = buffer.slice(0, newline).trim();
|
|
@@ -91,11 +156,27 @@ export function createOpenAIChatRuntime(options) {
|
|
|
91
156
|
chunk = JSON.parse(data);
|
|
92
157
|
}
|
|
93
158
|
catch {
|
|
159
|
+
malformedFrame = true;
|
|
94
160
|
continue;
|
|
95
161
|
}
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
162
|
+
const chunkError = providerError(chunk);
|
|
163
|
+
if (chunkError)
|
|
164
|
+
throw new ChatProtocolError(`provider returned a streaming completion error: ${chunkError.slice(0, 200)}`);
|
|
165
|
+
const choice = chunk.choices?.find((row) => row.index === undefined || row.index === 0);
|
|
166
|
+
const delta = choice?.delta;
|
|
167
|
+
if (object(delta))
|
|
168
|
+
sawChoice = true;
|
|
169
|
+
if (delta?.function_call)
|
|
170
|
+
throw new ChatProtocolError("legacy function_call is unsupported; use structured tool_calls");
|
|
171
|
+
calls.add(delta?.tool_calls, true);
|
|
172
|
+
details.add(delta?.reasoning_details);
|
|
173
|
+
if (choice?.finish_reason)
|
|
174
|
+
finishReason = choice.finish_reason;
|
|
175
|
+
const reasoningPart = delta?.reasoning_content ?? delta?.reasoning;
|
|
176
|
+
if (typeof reasoningPart === "string")
|
|
177
|
+
protocolReasoning += reasoningPart;
|
|
178
|
+
const reasoningDelta = options.reasoning && typeof reasoningPart === "string"
|
|
179
|
+
? reasoningPart
|
|
99
180
|
: "";
|
|
100
181
|
const contentDelta = typeof delta?.content === "string" ? delta.content : "";
|
|
101
182
|
if (reasoningDelta) {
|
|
@@ -114,7 +195,10 @@ export function createOpenAIChatRuntime(options) {
|
|
|
114
195
|
finally {
|
|
115
196
|
await reader.cancel().catch(() => { });
|
|
116
197
|
}
|
|
117
|
-
|
|
198
|
+
activeSignal.throwIfAborted();
|
|
199
|
+
if (!sawChoice)
|
|
200
|
+
throw new ChatProtocolError("provider returned no streaming completion choice");
|
|
201
|
+
return { text, reasoning, usage, toolCalls: calls.finish(finishReason, malformedFrame), finishReason, protocolReasoning, protocolReasoningDetails: details.blocks };
|
|
118
202
|
}
|
|
119
203
|
finally {
|
|
120
204
|
if (idleTimer)
|
|
@@ -138,85 +222,233 @@ export function createOpenAIChatRuntime(options) {
|
|
|
138
222
|
const abort = new AbortController();
|
|
139
223
|
const messages = messagesFor(turn);
|
|
140
224
|
const model = turn.model || options.models().default;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
225
|
+
const secrets = [options.apiKey];
|
|
226
|
+
for (const integration of Object.values(turn.integrations ?? {})) {
|
|
227
|
+
const entries = object(integration);
|
|
228
|
+
const specs = entries && "command" in entries ? [entries] : Object.values(entries ?? {}).map(object);
|
|
229
|
+
for (const spec of specs) {
|
|
230
|
+
for (const [key, value] of Object.entries(object(spec?.env) ?? {})) {
|
|
231
|
+
if (/key|token|password|secret|authorization/i.test(key) && typeof value === "string" && value)
|
|
232
|
+
secrets.push(value);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const safeText = (text) => {
|
|
237
|
+
let safe = text;
|
|
238
|
+
for (const secret of secrets)
|
|
239
|
+
if (secret)
|
|
240
|
+
safe = safe.split(secret).join("[redacted]");
|
|
241
|
+
return redactSecretsInText(safe);
|
|
242
|
+
};
|
|
243
|
+
const preview = (value) => toolDetailPreview(JSON.parse(JSON.stringify(value, (_key, part) => typeof part === "string" ? safeText(part) : part)));
|
|
244
|
+
const native = (dir, msg) => appendNative(turn.threadId, {
|
|
245
|
+
dir, source: options.nativeLog.source,
|
|
246
|
+
msg: JSON.parse(JSON.stringify(msg, (_key, part) => typeof part === "string" ? safeText(part) : part)),
|
|
247
|
+
});
|
|
248
|
+
const approval = createChatToolApproval({
|
|
249
|
+
signal: abort.signal,
|
|
250
|
+
open: (ask) => emit({
|
|
251
|
+
...base(turn.threadId, turnId), type: "request.opened", requestType: "permission",
|
|
252
|
+
requestId: ask.id, tool: ask.tool, summary: ask.summary, allowSession: false,
|
|
253
|
+
}),
|
|
254
|
+
resolved: (ask, allowed, source) => emit({
|
|
255
|
+
...base(turn.threadId, turnId), type: "request.resolved", requestId: ask.id,
|
|
256
|
+
behavior: allowed ? "allow" : "deny", source,
|
|
257
|
+
}),
|
|
146
258
|
});
|
|
259
|
+
let resolveDone;
|
|
260
|
+
const done = new Promise((resolve) => { resolveDone = resolve; });
|
|
261
|
+
active.set(turn.threadId, { abort, turnId, done, approval });
|
|
147
262
|
emit({ ...base(turn.threadId, turnId), type: "turn.started" });
|
|
148
263
|
emit({ ...base(turn.threadId, turnId), type: "session.started", sessionId: null, model });
|
|
149
264
|
void (async () => {
|
|
150
|
-
let
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
265
|
+
let tools;
|
|
266
|
+
const usage = { input: 0, output: 0 };
|
|
267
|
+
let hasUsage = false;
|
|
268
|
+
let ok = false;
|
|
269
|
+
let stopReason = null;
|
|
270
|
+
let failure;
|
|
271
|
+
let toolFailed = false;
|
|
272
|
+
const denials = [];
|
|
273
|
+
const seenCalls = new Set();
|
|
274
|
+
try {
|
|
275
|
+
tools = await mountChatTools(options.tools === false ? undefined : turn.integrations, abort.signal);
|
|
276
|
+
for (let round = 0; round < 16; round++) {
|
|
277
|
+
abort.signal.throwIfAborted();
|
|
278
|
+
native("out", options.nativeLog.outgoing(turn, messages, model));
|
|
279
|
+
let attempt = 0;
|
|
280
|
+
let completion;
|
|
281
|
+
for (;;) {
|
|
282
|
+
let streamed = false;
|
|
283
|
+
const pending = { assistant_text: "", reasoning_text: "" };
|
|
284
|
+
const delta = (text, streamKind, flush = false) => {
|
|
285
|
+
let combined = pending[streamKind] + text;
|
|
286
|
+
// Mask complete matches before holding a suffix: otherwise a key
|
|
287
|
+
// such as "abab" could be split at its own repeated prefix.
|
|
288
|
+
for (const secret of secrets)
|
|
289
|
+
if (secret)
|
|
290
|
+
combined = combined.split(secret).join("[redacted]");
|
|
291
|
+
let hold = 0;
|
|
292
|
+
// A configured credential can straddle chunks. Hold any suffix
|
|
293
|
+
// that could be its prefix until the next chunk disambiguates it.
|
|
294
|
+
if (!flush)
|
|
295
|
+
for (const secret of secrets) {
|
|
296
|
+
for (let length = Math.min(secret.length - 1, combined.length); length > hold; length--) {
|
|
297
|
+
if (combined.endsWith(secret.slice(0, length))) {
|
|
298
|
+
hold = length;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
pending[streamKind] = hold ? combined.slice(-hold) : "";
|
|
304
|
+
const visible = safeText(hold ? combined.slice(0, -hold) : combined);
|
|
305
|
+
if (visible)
|
|
306
|
+
emit({ ...base(turn.threadId, turnId), type: "content.delta", streamKind, delta: visible });
|
|
307
|
+
};
|
|
308
|
+
try {
|
|
309
|
+
completion = await complete(messages, model, true, abort.signal, (text, streamKind) => {
|
|
310
|
+
streamed = true;
|
|
311
|
+
delta(text, streamKind);
|
|
312
|
+
}, tools.definitions);
|
|
313
|
+
delta("", "assistant_text", true);
|
|
314
|
+
delta("", "reasoning_text", true);
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
catch (value) {
|
|
318
|
+
const error = asError(value);
|
|
319
|
+
const verdict = classifyError(error);
|
|
320
|
+
// Once a call has been handled, never replay it through a turn retry.
|
|
321
|
+
if (options.retryScale === undefined || abort.signal.aborted || streamed || seenCalls.size ||
|
|
322
|
+
error instanceof ChatProtocolError || !verdict.transient || attempt >= RETRY_MAX_ATTEMPTS - 1)
|
|
323
|
+
throw error;
|
|
324
|
+
const delayMs = computeBackoff(attempt++);
|
|
325
|
+
emit({ ...base(turn.threadId, turnId), type: "turn.retrying", attempt, delayMs, reason: verdict.reason });
|
|
326
|
+
await interruptibleDelay(delayMs * options.retryScale, abort.signal).promise;
|
|
327
|
+
abort.signal.throwIfAborted();
|
|
328
|
+
}
|
|
167
329
|
}
|
|
330
|
+
native("in", options.nativeLog.incoming(completion));
|
|
168
331
|
if (completion.usage) {
|
|
169
|
-
|
|
332
|
+
usage.input += completion.usage.input;
|
|
333
|
+
usage.output += completion.usage.output;
|
|
334
|
+
hasUsage = true;
|
|
335
|
+
emit({ ...base(turn.threadId, turnId), type: "thread.token-usage.updated", ...usage });
|
|
170
336
|
}
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
type: "
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
type: "turn.retrying",
|
|
197
|
-
attempt,
|
|
198
|
-
delayMs,
|
|
199
|
-
reason: verdict.reason,
|
|
200
|
-
});
|
|
201
|
-
const outcome = await interruptibleDelay(delayMs * options.retryScale, abort.signal).promise;
|
|
202
|
-
if (outcome === "elapsed" && !abort.signal.aborted)
|
|
203
|
-
continue;
|
|
204
|
-
active.delete(turn.threadId);
|
|
205
|
-
emit({ ...base(turn.threadId, turnId), type: "turn.completed", ok: false, stopReason: "interrupted", cost: null });
|
|
206
|
-
return;
|
|
337
|
+
// Reasoning on a tool-call round belongs to the protocol, not a final reply.
|
|
338
|
+
const reply = completion.text.trim() ? completion.text : completion.toolCalls.length ? "" : completion.reasoning;
|
|
339
|
+
if (reply.trim())
|
|
340
|
+
emit({ ...base(turn.threadId, turnId), type: "item.completed", itemType: "assistant_text", text: safeText(reply) });
|
|
341
|
+
abort.signal.throwIfAborted();
|
|
342
|
+
if (!completion.toolCalls.length) {
|
|
343
|
+
if (!reply.trim())
|
|
344
|
+
throw new ChatProtocolError("provider returned an empty response");
|
|
345
|
+
if (completion.finishReason && completion.finishReason !== "stop") {
|
|
346
|
+
throw new ChatProtocolError(`provider did not finish the response (${completion.finishReason})`);
|
|
347
|
+
}
|
|
348
|
+
if (toolFailed) {
|
|
349
|
+
stopReason = "tool_error";
|
|
350
|
+
throw new ChatProtocolError("One or more tool operations failed or were denied. See the tool results; the final response is not an execution receipt.");
|
|
351
|
+
}
|
|
352
|
+
ok = true;
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
if (!tools.definitions.length)
|
|
356
|
+
throw new ChatProtocolError("provider returned tool calls, but no tools are available for this turn");
|
|
357
|
+
// Validate IDs for the entire batch before executing any of its calls.
|
|
358
|
+
for (const call of completion.toolCalls) {
|
|
359
|
+
if (seenCalls.has(call.id))
|
|
360
|
+
throw new ChatProtocolError("provider reused a tool-call ID; refusing to repeat an operation");
|
|
361
|
+
seenCalls.add(call.id);
|
|
207
362
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
...
|
|
213
|
-
type: "turn.completed",
|
|
214
|
-
ok: false,
|
|
215
|
-
stopReason: aborted ? "interrupted" : "error",
|
|
216
|
-
cost: null,
|
|
363
|
+
if (seenCalls.size > MAX_CHAT_TOOL_CALLS)
|
|
364
|
+
throw new ChatProtocolError("tool-call limit reached");
|
|
365
|
+
messages.push({ role: "assistant", content: completion.text || null, tool_calls: completion.toolCalls,
|
|
366
|
+
...(completion.protocolReasoning ? { reasoning_content: completion.protocolReasoning } : {}),
|
|
367
|
+
...(completion.protocolReasoningDetails.length ? { reasoning_details: completion.protocolReasoningDetails } : {}),
|
|
217
368
|
});
|
|
218
|
-
|
|
369
|
+
for (const call of completion.toolCalls) {
|
|
370
|
+
abort.signal.throwIfAborted();
|
|
371
|
+
let result;
|
|
372
|
+
let started = false;
|
|
373
|
+
let fatal;
|
|
374
|
+
try {
|
|
375
|
+
let args;
|
|
376
|
+
try {
|
|
377
|
+
args = JSON.parse(call.function.arguments);
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
throw new ChatProtocolError("tool arguments are not complete JSON");
|
|
381
|
+
}
|
|
382
|
+
if (!object(args))
|
|
383
|
+
throw new ChatProtocolError("tool arguments must be a JSON object");
|
|
384
|
+
tools.validate(call.function.name, args);
|
|
385
|
+
const inputPreview = preview(args);
|
|
386
|
+
const allowed = await approval.ask(call.function.name, inputPreview ?? "This tool has no arguments.");
|
|
387
|
+
abort.signal.throwIfAborted();
|
|
388
|
+
emit({ ...base(turn.threadId, turnId), type: "item.started", itemType: "tool", itemId: call.id,
|
|
389
|
+
title: call.function.name, ...(inputPreview ? { input: inputPreview } : {}),
|
|
390
|
+
});
|
|
391
|
+
started = true;
|
|
392
|
+
if (allowed) {
|
|
393
|
+
result = await tools.execute(call.function.name, args, abort.signal);
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
denials.push(call.function.name);
|
|
397
|
+
result = { ok: false, text: "Permission denied or expired; the tool was not executed." };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
if (error instanceof ChatToolSessionError)
|
|
402
|
+
fatal = error;
|
|
403
|
+
result = { ok: false, text: abort.signal.aborted
|
|
404
|
+
? "Tool interrupted; an operation already dispatched may have taken effect. Verify its state before retrying."
|
|
405
|
+
: safeText(asError(error).message).slice(0, 2_000) };
|
|
406
|
+
}
|
|
407
|
+
if (!started)
|
|
408
|
+
emit({ ...base(turn.threadId, turnId), type: "item.started", itemType: "tool", itemId: call.id, title: call.function.name });
|
|
409
|
+
const text = safeText(result.text);
|
|
410
|
+
const output = preview({ ok: result.ok, result: text });
|
|
411
|
+
emit({ ...base(turn.threadId, turnId), type: "item.completed", itemType: "tool", itemId: call.id, ok: result.ok, output });
|
|
412
|
+
if (!result.ok)
|
|
413
|
+
toolFailed = true;
|
|
414
|
+
messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify({ ok: result.ok, result: text }) });
|
|
415
|
+
abort.signal.throwIfAborted();
|
|
416
|
+
if (fatal)
|
|
417
|
+
throw fatal;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (!ok)
|
|
421
|
+
throw new ChatProtocolError("model-call limit reached before a final response");
|
|
422
|
+
}
|
|
423
|
+
catch (value) {
|
|
424
|
+
stopReason = abort.signal.aborted ? "interrupted" : stopReason ?? "error";
|
|
425
|
+
failure = safeText(asError(value).message).slice(0, 2_000);
|
|
426
|
+
}
|
|
427
|
+
finally {
|
|
428
|
+
approval.close();
|
|
429
|
+
let cleanupFailed = false;
|
|
430
|
+
try {
|
|
431
|
+
await tools?.close();
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
ok = false;
|
|
435
|
+
stopReason = "error";
|
|
436
|
+
cleanupFailed = true;
|
|
437
|
+
failure = "Tool processes could not be stopped; their execution state is uncertain.";
|
|
219
438
|
}
|
|
439
|
+
if (abort.signal.aborted) {
|
|
440
|
+
ok = false;
|
|
441
|
+
stopReason = "interrupted";
|
|
442
|
+
}
|
|
443
|
+
if (failure && (!abort.signal.aborted || cleanupFailed)) {
|
|
444
|
+
emit({ ...base(turn.threadId, turnId), type: "runtime.error", message: failure, terminal: !abort.signal.aborted });
|
|
445
|
+
}
|
|
446
|
+
active.delete(turn.threadId);
|
|
447
|
+
emit({ ...base(turn.threadId, turnId), type: "turn.completed", ok, stopReason, cost: null,
|
|
448
|
+
...(hasUsage && (options.includeUsageInCompleted || seenCalls.size) ? { usage } : {}),
|
|
449
|
+
...(denials.length ? { denials } : {}),
|
|
450
|
+
});
|
|
451
|
+
resolveDone();
|
|
220
452
|
}
|
|
221
453
|
})();
|
|
222
454
|
return { turnId };
|
|
@@ -235,14 +467,22 @@ export function createOpenAIChatRuntime(options) {
|
|
|
235
467
|
: { state: "unavailable", reason: options.unavailableReason },
|
|
236
468
|
adapter: {
|
|
237
469
|
provider: options.driverKind,
|
|
238
|
-
capabilities: { sessionModelSwitch: "in-session" },
|
|
470
|
+
capabilities: { sessionModelSwitch: "in-session", customMcp: options.tools !== false, agentsMcp: options.tools !== false, composioMcp: options.tools !== false },
|
|
239
471
|
sendTurn,
|
|
240
|
-
interruptTurn: async (threadId) =>
|
|
241
|
-
|
|
472
|
+
interruptTurn: async (threadId, turnId) => {
|
|
473
|
+
const turn = active.get(threadId);
|
|
474
|
+
if (!turn || (turnId && turn.turnId !== turnId))
|
|
475
|
+
return;
|
|
476
|
+
turn.abort.abort();
|
|
477
|
+
await turn.done;
|
|
478
|
+
},
|
|
479
|
+
respondToRequest: async (threadId, requestId, decision) => active.get(threadId)?.approval.answer(requestId, decision.behavior) ?? "unavailable",
|
|
242
480
|
hasSession: (threadId) => active.has(threadId),
|
|
243
481
|
stopAll: async () => {
|
|
244
|
-
|
|
245
|
-
|
|
482
|
+
const turns = [...active.values()];
|
|
483
|
+
for (const turn of turns)
|
|
484
|
+
turn.abort.abort();
|
|
485
|
+
await Promise.all(turns.map((turn) => turn.done));
|
|
246
486
|
},
|
|
247
487
|
onEvent: (listener) => {
|
|
248
488
|
listeners.add(listener);
|
|
@@ -251,12 +491,16 @@ export function createOpenAIChatRuntime(options) {
|
|
|
251
491
|
},
|
|
252
492
|
generateText: async (prompt) => {
|
|
253
493
|
const model = options.generateModel?.() ?? options.models().default;
|
|
254
|
-
const { text, reasoning } = await complete([{ role: "user", content: prompt }], model, false);
|
|
494
|
+
const { text, reasoning, toolCalls } = await complete([{ role: "user", content: prompt }], model, false);
|
|
495
|
+
if (toolCalls.length)
|
|
496
|
+
throw new ChatProtocolError("provider returned tool calls to a text-only helper");
|
|
255
497
|
return text.trim() ? text : reasoning;
|
|
256
498
|
},
|
|
257
499
|
dispose: async () => {
|
|
258
|
-
|
|
259
|
-
|
|
500
|
+
const turns = [...active.values()];
|
|
501
|
+
for (const turn of turns)
|
|
502
|
+
turn.abort.abort();
|
|
503
|
+
await Promise.all(turns.map((turn) => turn.done));
|
|
260
504
|
listeners.clear();
|
|
261
505
|
},
|
|
262
506
|
};
|
|
@@ -26,8 +26,11 @@ function isOpenRouterUrl(url) {
|
|
|
26
26
|
}
|
|
27
27
|
function decodeConfig(raw) {
|
|
28
28
|
const config = (raw ?? {});
|
|
29
|
+
if (config.tools !== undefined && typeof config.tools !== "boolean")
|
|
30
|
+
throw new Error("tools must be a boolean");
|
|
29
31
|
const envUrl = process.env.OPENAI_COMPAT_URL;
|
|
30
32
|
return {
|
|
33
|
+
...(config.tools !== undefined ? { tools: config.tools } : {}),
|
|
31
34
|
url: (typeof config.url === "string" && config.url ? config.url : envUrl || "https://openrouter.ai/api/v1")
|
|
32
35
|
.replace(/\/+$/, ""),
|
|
33
36
|
apiKeyEnv: typeof config.apiKeyEnv === "string" && config.apiKeyEnv
|
|
@@ -122,6 +125,7 @@ export const OpenAICompatDriver = {
|
|
|
122
125
|
driverKind: DRIVER_KIND,
|
|
123
126
|
apiKey,
|
|
124
127
|
apiUrl: config.url,
|
|
128
|
+
tools: config.tools,
|
|
125
129
|
models: () => catalog,
|
|
126
130
|
refreshModels: fetchModels,
|
|
127
131
|
requestBody: (model, messages, stream) => ({
|