termux-dev 1.6.0 → 1.8.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/README.md +214 -166
- package/assets/banner.svg +23 -28
- package/assets/preview.png +0 -0
- package/assets/preview.svg +92 -10
- package/dist/cli/banner.js +1 -1
- package/dist/cli/clipboard-write.js +55 -0
- package/dist/cli/clipboard.js +182 -106
- package/dist/cli/colors.js +67 -0
- package/dist/cli/confirm.js +50 -0
- package/dist/cli/doctor.js +4 -4
- package/dist/cli/effort.js +80 -0
- package/dist/cli/export.js +3 -3
- package/dist/cli/files.js +19 -5
- package/dist/cli/headless.js +23 -11
- package/dist/cli/index.js +1377 -480
- package/dist/cli/keys.js +69 -0
- package/dist/cli/liveblock.js +45 -0
- package/dist/cli/log.js +19 -0
- package/dist/cli/markdown.js +158 -36
- package/dist/cli/mcp-panel.js +432 -0
- package/dist/cli/permission.js +84 -0
- package/dist/cli/picker.js +135 -0
- package/dist/cli/prompt.js +256 -56
- package/dist/cli/resume.js +15 -0
- package/dist/cli/rewind.js +51 -0
- package/dist/cli/runtime_errors.js +6 -0
- package/dist/cli/server.js +85 -24
- package/dist/cli/settings.js +307 -0
- package/dist/cli/statusline.js +1 -1
- package/dist/cli/theme.js +126 -108
- package/dist/cli/transcript.js +114 -0
- package/dist/cli/typeahead.js +371 -0
- package/dist/cli/ui.js +1021 -0
- package/dist/cli/updater.js +23 -21
- package/dist/cli/usage-panel.js +242 -0
- package/dist/cli/width.js +178 -0
- package/dist/core/capabilities.js +433 -0
- package/dist/core/catalog-util.js +27 -0
- package/dist/core/checkpoint.js +90 -0
- package/dist/core/context-stats.js +29 -0
- package/dist/core/cost.js +191 -0
- package/dist/core/formats.js +199 -0
- package/dist/core/history.js +51 -1
- package/dist/core/hooks.js +79 -0
- package/dist/core/i18n.js +42 -0
- package/dist/core/local-config.js +69 -0
- package/dist/core/locales/de.js +210 -0
- package/dist/core/locales/en.js +221 -0
- package/dist/core/locales/es.js +210 -0
- package/dist/core/locales/fr.js +210 -0
- package/dist/core/locales/ja.js +210 -0
- package/dist/core/locales/pt.js +210 -0
- package/dist/core/locales/ru.js +210 -0
- package/dist/core/locales/uk.js +210 -0
- package/dist/core/locales/zh.js +210 -0
- package/dist/core/loop.js +125 -49
- package/dist/core/memory.js +39 -12
- package/dist/core/models.js +33 -20
- package/dist/core/network.js +12 -11
- package/dist/core/notify.js +54 -0
- package/dist/core/planfile.js +43 -0
- package/dist/core/price-catalog.json +1 -0
- package/dist/core/prices.js +249 -0
- package/dist/core/reasoning-catalog.json +1 -0
- package/dist/core/reasoning.js +393 -0
- package/dist/core/redact.js +41 -0
- package/dist/core/secure-file.js +22 -0
- package/dist/core/session.js +16 -5
- package/dist/core/snapshot.js +14 -2
- package/dist/core/title.js +61 -0
- package/dist/core/types.js +1 -1
- package/dist/core/usage.js +43 -7
- package/dist/core/vision-catalog.json +1 -0
- package/dist/core/vision.js +123 -0
- package/dist/mcp/client.js +36 -5
- package/dist/mcp/http-client.js +174 -0
- package/dist/mcp/manager.js +330 -93
- package/dist/permissions/guard.js +385 -117
- package/dist/prompts/builder.js +46 -38
- package/dist/providers/anthropic.js +517 -0
- package/dist/providers/index.js +18 -1
- package/dist/providers/openai.js +138 -125
- package/dist/providers/shared.js +116 -0
- package/dist/providers/toolguard.js +101 -0
- package/dist/tools/bash.js +141 -51
- package/dist/tools/diagnostics.js +76 -48
- package/dist/tools/fs.js +77 -5
- package/dist/tools/index.js +2 -1
- package/dist/tools/mcp.js +72 -0
- package/dist/tools/packages.js +16 -1
- package/dist/tools/plan.js +8 -8
- package/dist/tools/questions.js +50 -54
- package/dist/tools/server.js +6 -0
- package/dist/tools/todo.js +20 -18
- package/dist/tools/web.js +143 -8
- package/package.json +1 -1
package/dist/providers/openai.js
CHANGED
|
@@ -1,16 +1,102 @@
|
|
|
1
|
-
import { calculateCost } from '../core/pricing.js';
|
|
2
1
|
import { UsageTracker } from '../core/usage.js';
|
|
2
|
+
import { ToolGuard } from './toolguard.js';
|
|
3
|
+
import { NOT_ABOUT_REASONING, PARAMETER_REJECTED, errorMessageOf, imageNotSentText, openAiUsage, parseToolArguments, toolTargetHint } from './shared.js';
|
|
4
|
+
export { parseToolArguments };
|
|
5
|
+
// Fields the extra parameters may never overwrite.
|
|
6
|
+
const PROTECTED_FIELDS = new Set(['model', 'messages', 'stream', 'tools', 'stream_options']);
|
|
3
7
|
export class OpenAIProvider {
|
|
4
8
|
baseUrl;
|
|
5
9
|
apiKey;
|
|
6
10
|
model;
|
|
11
|
+
extraBody;
|
|
12
|
+
options;
|
|
7
13
|
id = 'openai-compatible';
|
|
8
|
-
|
|
14
|
+
/** Set once the endpoint has refused the extra (reasoning) parameters: they are not sent again. */
|
|
15
|
+
extrasRejected = false;
|
|
16
|
+
notices = [];
|
|
17
|
+
imageNoticeShown = false;
|
|
18
|
+
constructor(baseUrl, apiKey, model,
|
|
19
|
+
/** Extra request parameters, e.g. the reasoning setting from /effort. */
|
|
20
|
+
extraBody, options = {}) {
|
|
9
21
|
this.baseUrl = baseUrl;
|
|
10
22
|
this.apiKey = apiKey;
|
|
11
23
|
this.model = model;
|
|
24
|
+
this.extraBody = extraBody;
|
|
25
|
+
this.options = options;
|
|
12
26
|
}
|
|
13
|
-
|
|
27
|
+
priceContext() {
|
|
28
|
+
return { ...this.options.price, model: this.model, baseUrl: this.baseUrl };
|
|
29
|
+
}
|
|
30
|
+
/** Messages for the user produced while talking to the endpoint (shown once). */
|
|
31
|
+
drainNotices() {
|
|
32
|
+
return this.notices.splice(0);
|
|
33
|
+
}
|
|
34
|
+
toolGuard = new ToolGuard();
|
|
35
|
+
hasActiveExtras() {
|
|
36
|
+
return !this.extrasRejected && !!this.extraBody && Object.keys(this.extraBody).length > 0;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Posts the request. If the endpoint answers 400/422 and complains about a parameter
|
|
40
|
+
* while reasoning parameters are being sent, the request is repeated once without them:
|
|
41
|
+
* an unknown setting must never make the whole session unusable.
|
|
42
|
+
*/
|
|
43
|
+
async send(request, stream) {
|
|
44
|
+
const url = `${this.baseUrl}/chat/completions`;
|
|
45
|
+
const send = (body) => {
|
|
46
|
+
const bodyStr = JSON.stringify(body);
|
|
47
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
48
|
+
if (this.apiKey)
|
|
49
|
+
headers['Authorization'] = `Bearer ${this.apiKey}`;
|
|
50
|
+
UsageTracker.getInstance().recordRequest(Buffer.byteLength(bodyStr, 'utf8'));
|
|
51
|
+
return fetch(url, { method: 'POST', headers, body: bodyStr, signal: request.signal });
|
|
52
|
+
};
|
|
53
|
+
const post = () => send(this.buildPayload(request, stream));
|
|
54
|
+
let res = await post();
|
|
55
|
+
if (res.ok || (res.status !== 400 && res.status !== 422))
|
|
56
|
+
return res;
|
|
57
|
+
// Refused, and it works without tools: a tool definition is what the gateway does not like.
|
|
58
|
+
if (request.tools && request.tools.length > 0 && !NOT_ABOUT_REASONING.test(errorMessageOf(await res.clone().text().catch(() => '')))) {
|
|
59
|
+
const probe = async (tools) => {
|
|
60
|
+
const body = this.buildPayload({ ...request, tools: tools.length > 0 ? tools : undefined }, false, true);
|
|
61
|
+
const r = await send(body);
|
|
62
|
+
await r.text().catch(() => '');
|
|
63
|
+
return r.ok;
|
|
64
|
+
};
|
|
65
|
+
const repair = await this.toolGuard.repair(this.toolGuard.apply(request.tools), probe);
|
|
66
|
+
if (repair.notice)
|
|
67
|
+
this.notices.push(repair.notice);
|
|
68
|
+
if (repair.changed) {
|
|
69
|
+
res = await post();
|
|
70
|
+
if (res.ok || (res.status !== 400 && res.status !== 422))
|
|
71
|
+
return res;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!this.hasActiveExtras())
|
|
75
|
+
return res;
|
|
76
|
+
const text = await res.clone().text().catch(() => '');
|
|
77
|
+
// A gateway may word the refusal in its own way ("the model rejected this conversation or its
|
|
78
|
+
// parameters"): the reasoning setting is the one thing devx added, so it is tried without it once.
|
|
79
|
+
const known = PARAMETER_REJECTED.test(text);
|
|
80
|
+
if (!known && NOT_ABOUT_REASONING.test(errorMessageOf(text)))
|
|
81
|
+
return res;
|
|
82
|
+
this.extrasRejected = true;
|
|
83
|
+
let reason = text;
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(text);
|
|
86
|
+
reason = parsed.error?.message || parsed.message || text;
|
|
87
|
+
}
|
|
88
|
+
catch { }
|
|
89
|
+
const retry = await post();
|
|
90
|
+
if (!known && !retry.ok) {
|
|
91
|
+
// It was not the reasoning setting: it goes on being sent, and the real answer is shown.
|
|
92
|
+
this.extrasRejected = false;
|
|
93
|
+
return retry;
|
|
94
|
+
}
|
|
95
|
+
this.notices.push(`The endpoint rejected the reasoning setting (${String(reason).replace(/\s+/g, ' ').slice(0, 110)}). ` +
|
|
96
|
+
`Continuing without it. /effort auto stops sending it for good.`);
|
|
97
|
+
return retry;
|
|
98
|
+
}
|
|
99
|
+
buildPayload(request, stream, probe = false) {
|
|
14
100
|
const totalMsgs = request.messages.length;
|
|
15
101
|
let lastImageMsgIdx = -1;
|
|
16
102
|
for (let i = totalMsgs - 1; i >= 0; i--) {
|
|
@@ -25,7 +111,15 @@ export class OpenAIProvider {
|
|
|
25
111
|
messages: request.messages.map((m, idx) => {
|
|
26
112
|
let content = m.content || "";
|
|
27
113
|
if (m.images && m.images.length > 0) {
|
|
28
|
-
if (
|
|
114
|
+
if (this.options.images === false) {
|
|
115
|
+
const note = imageNotSentText(m.images.map(img => img.path));
|
|
116
|
+
content = m.content ? `${m.content}\n${note}` : note;
|
|
117
|
+
if (!this.imageNoticeShown) {
|
|
118
|
+
this.imageNoticeShown = true;
|
|
119
|
+
this.notices.push(`${this.model} cannot read images, so the image was not sent. Switch to a model with vision (/model).`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
else if (idx === lastImageMsgIdx) {
|
|
29
123
|
content = [
|
|
30
124
|
{ type: 'text', text: m.content || "" },
|
|
31
125
|
...m.images.map(img => ({
|
|
@@ -53,48 +147,33 @@ export class OpenAIProvider {
|
|
|
53
147
|
return msg;
|
|
54
148
|
}),
|
|
55
149
|
};
|
|
56
|
-
|
|
57
|
-
|
|
150
|
+
// Tools the gateway refused are left out; a probe is a request that costs next to nothing.
|
|
151
|
+
const tools = this.toolGuard.apply(request.tools);
|
|
152
|
+
if (tools && tools.length > 0) {
|
|
153
|
+
payload.tools = tools.map(t => ({
|
|
58
154
|
type: 'function',
|
|
59
155
|
function: t
|
|
60
156
|
}));
|
|
61
157
|
}
|
|
158
|
+
if (probe)
|
|
159
|
+
payload.max_tokens = 1;
|
|
62
160
|
if (stream) {
|
|
63
161
|
payload.stream_options = { include_usage: true };
|
|
64
162
|
}
|
|
163
|
+
if (this.hasActiveExtras()) {
|
|
164
|
+
for (const [key, value] of Object.entries(this.extraBody)) {
|
|
165
|
+
if (!PROTECTED_FIELDS.has(key))
|
|
166
|
+
payload[key] = value;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
65
169
|
return payload;
|
|
66
170
|
}
|
|
67
171
|
async chat(request) {
|
|
68
|
-
const
|
|
69
|
-
const payload = this.buildPayload(request, false);
|
|
70
|
-
const bodyStr = JSON.stringify(payload);
|
|
71
|
-
const headers = {
|
|
72
|
-
'Content-Type': 'application/json',
|
|
73
|
-
};
|
|
74
|
-
if (this.apiKey) {
|
|
75
|
-
headers['Authorization'] = `Bearer ${this.apiKey}`;
|
|
76
|
-
}
|
|
77
|
-
UsageTracker.getInstance().recordRequest(Buffer.byteLength(bodyStr, 'utf8'));
|
|
78
|
-
const res = await fetch(url, {
|
|
79
|
-
method: 'POST',
|
|
80
|
-
headers,
|
|
81
|
-
body: bodyStr,
|
|
82
|
-
signal: request.signal
|
|
83
|
-
});
|
|
172
|
+
const res = await this.send(request, false);
|
|
84
173
|
if (!res.ok) {
|
|
85
174
|
const errText = await res.text();
|
|
86
175
|
UsageTracker.getInstance().recordResponseChunk(Buffer.byteLength(errText, 'utf8'));
|
|
87
|
-
|
|
88
|
-
try {
|
|
89
|
-
const parsed = JSON.parse(errText);
|
|
90
|
-
if (parsed.error?.message) {
|
|
91
|
-
errMsg = parsed.error.message;
|
|
92
|
-
}
|
|
93
|
-
else if (parsed.message) {
|
|
94
|
-
errMsg = parsed.message;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
catch { }
|
|
176
|
+
const errMsg = errorMessageOf(errText);
|
|
98
177
|
const err = new Error(`Provider HTTP Error ${res.status}: ${errMsg}`);
|
|
99
178
|
err.status = res.status;
|
|
100
179
|
throw err;
|
|
@@ -115,77 +194,45 @@ export class OpenAIProvider {
|
|
|
115
194
|
const choice = data.choices?.[0]?.message || {};
|
|
116
195
|
let usage;
|
|
117
196
|
if (data.usage) {
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
usage
|
|
125
|
-
promptTokens,
|
|
126
|
-
completionTokens,
|
|
127
|
-
totalTokens,
|
|
128
|
-
cost
|
|
129
|
-
};
|
|
130
|
-
UsageTracker.getInstance().recordTokens(promptTokens, completionTokens, cost);
|
|
197
|
+
// LiteLLM puts the cost of the request into a header instead of the body.
|
|
198
|
+
const headerCost = Number(res.headers?.get?.('x-litellm-response-cost'));
|
|
199
|
+
const raw = Number.isFinite(headerCost) && res.headers.get('x-litellm-response-cost') !== null && data.usage.cost === undefined
|
|
200
|
+
? { ...data.usage, cost: headerCost }
|
|
201
|
+
: data.usage;
|
|
202
|
+
usage = openAiUsage(raw, this.priceContext());
|
|
203
|
+
UsageTracker.getInstance().recordUsage(usage);
|
|
131
204
|
}
|
|
132
205
|
return {
|
|
133
206
|
content: choice.content || null,
|
|
134
207
|
toolCalls: choice.tool_calls ? choice.tool_calls.map((tc) => {
|
|
135
|
-
|
|
136
|
-
try {
|
|
137
|
-
parsedArgs = JSON.parse(tc.function.arguments);
|
|
138
|
-
}
|
|
139
|
-
catch {
|
|
140
|
-
parsedArgs = {};
|
|
141
|
-
}
|
|
208
|
+
const parsed = parseToolArguments(tc.function.arguments);
|
|
142
209
|
return {
|
|
143
210
|
id: tc.id || `call_${Math.random().toString(36).substring(2, 9)}`,
|
|
144
211
|
name: tc.function.name,
|
|
145
|
-
arguments:
|
|
212
|
+
arguments: parsed.args,
|
|
213
|
+
...(parsed.error ? { argsError: parsed.error } : {})
|
|
146
214
|
};
|
|
147
215
|
}) : undefined,
|
|
148
216
|
usage
|
|
149
217
|
};
|
|
150
218
|
}
|
|
151
219
|
async *chatStream(request) {
|
|
152
|
-
const
|
|
153
|
-
const payload = this.buildPayload(request, true);
|
|
154
|
-
const bodyStr = JSON.stringify(payload);
|
|
155
|
-
const headers = {
|
|
156
|
-
'Content-Type': 'application/json',
|
|
157
|
-
};
|
|
158
|
-
if (this.apiKey) {
|
|
159
|
-
headers['Authorization'] = `Bearer ${this.apiKey}`;
|
|
160
|
-
}
|
|
161
|
-
UsageTracker.getInstance().recordRequest(Buffer.byteLength(bodyStr, 'utf8'));
|
|
162
|
-
const res = await fetch(url, {
|
|
163
|
-
method: 'POST',
|
|
164
|
-
headers,
|
|
165
|
-
body: bodyStr,
|
|
166
|
-
signal: request.signal
|
|
167
|
-
});
|
|
220
|
+
const res = await this.send(request, true);
|
|
168
221
|
if (!res.ok) {
|
|
169
222
|
const errText = await res.text();
|
|
170
223
|
UsageTracker.getInstance().recordResponseChunk(Buffer.byteLength(errText, 'utf8'));
|
|
171
|
-
|
|
172
|
-
try {
|
|
173
|
-
const parsed = JSON.parse(errText);
|
|
174
|
-
if (parsed.error?.message) {
|
|
175
|
-
errMsg = parsed.error.message;
|
|
176
|
-
}
|
|
177
|
-
else if (parsed.message) {
|
|
178
|
-
errMsg = parsed.message;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
catch { }
|
|
224
|
+
const errMsg = errorMessageOf(errText);
|
|
182
225
|
const err = new Error(`Provider HTTP Error ${res.status}: ${errMsg}`);
|
|
183
226
|
err.status = res.status;
|
|
184
227
|
throw err;
|
|
185
228
|
}
|
|
186
229
|
const reader = res.body?.getReader();
|
|
187
230
|
if (!reader) {
|
|
188
|
-
|
|
231
|
+
// No streaming body: fall back to a regular request. The agent loop only reads
|
|
232
|
+
// the final response from the 'done' chunk, so it has to be yielded.
|
|
233
|
+
const fallback = await this.chat(request);
|
|
234
|
+
yield { type: 'done', response: fallback };
|
|
235
|
+
return fallback;
|
|
189
236
|
}
|
|
190
237
|
if (request.signal) {
|
|
191
238
|
request.signal.addEventListener('abort', () => {
|
|
@@ -319,27 +366,7 @@ export class OpenAIProvider {
|
|
|
319
366
|
if (tc.function?.arguments)
|
|
320
367
|
existing.argsStr += tc.function.arguments;
|
|
321
368
|
toolMap.set(idx, existing);
|
|
322
|
-
|
|
323
|
-
let targetHint = undefined;
|
|
324
|
-
const args = existing.argsStr;
|
|
325
|
-
if (args) {
|
|
326
|
-
const pathMatch = args.match(/"(?:path|filePath|targetFile)"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/);
|
|
327
|
-
if (pathMatch && pathMatch[1]) {
|
|
328
|
-
targetHint = pathMatch[1];
|
|
329
|
-
}
|
|
330
|
-
else {
|
|
331
|
-
const cmdMatch = args.match(/"(?:command|cmd)"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/);
|
|
332
|
-
if (cmdMatch && cmdMatch[1]) {
|
|
333
|
-
targetHint = cmdMatch[1].length > 30 ? cmdMatch[1].slice(0, 27) + '...' : cmdMatch[1];
|
|
334
|
-
}
|
|
335
|
-
else {
|
|
336
|
-
const qMatch = args.match(/"(?:query|pattern)"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/);
|
|
337
|
-
if (qMatch && qMatch[1]) {
|
|
338
|
-
targetHint = `"${qMatch[1]}"`;
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
}
|
|
369
|
+
const targetHint = toolTargetHint(existing.argsStr);
|
|
343
370
|
yield {
|
|
344
371
|
type: 'tool_generating',
|
|
345
372
|
name: existing.name || 'tool',
|
|
@@ -372,36 +399,22 @@ export class OpenAIProvider {
|
|
|
372
399
|
}
|
|
373
400
|
}
|
|
374
401
|
const toolCalls = Array.from(toolMap.values()).map(tc => {
|
|
375
|
-
|
|
376
|
-
try {
|
|
377
|
-
parsedArgs = JSON.parse(tc.argsStr);
|
|
378
|
-
}
|
|
379
|
-
catch {
|
|
380
|
-
parsedArgs = {};
|
|
381
|
-
}
|
|
402
|
+
const parsed = parseToolArguments(tc.argsStr);
|
|
382
403
|
return {
|
|
383
404
|
id: tc.id || `call_${Math.random().toString(36).substring(2, 9)}`,
|
|
384
405
|
name: tc.name,
|
|
385
|
-
arguments:
|
|
406
|
+
arguments: parsed.args,
|
|
407
|
+
...(parsed.error ? { argsError: parsed.error } : {})
|
|
386
408
|
};
|
|
387
409
|
});
|
|
388
410
|
if (!accumulatedContent && !accumulatedReasoning && toolCalls.length === 0) {
|
|
389
411
|
throw new Error(`Provider returned empty response (0 tokens generated). Please check your model '${this.model}', balance, or API key.`);
|
|
390
412
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
? usageData.total_cost
|
|
397
|
-
: calculateCost(this.model, promptTokens, completionTokens);
|
|
398
|
-
usage = {
|
|
399
|
-
promptTokens,
|
|
400
|
-
completionTokens,
|
|
401
|
-
totalTokens,
|
|
402
|
-
cost
|
|
403
|
-
};
|
|
404
|
-
UsageTracker.getInstance().recordTokens(promptTokens, completionTokens, cost);
|
|
413
|
+
const usage = openAiUsage(usageData, this.priceContext(), {
|
|
414
|
+
prompt: Math.ceil(JSON.stringify(request.messages).length / 3.5),
|
|
415
|
+
completion: Math.ceil((accumulatedContent.length + accumulatedReasoning.length) / 3.5),
|
|
416
|
+
});
|
|
417
|
+
UsageTracker.getInstance().recordUsage(usage);
|
|
405
418
|
const finalResponse = {
|
|
406
419
|
content: accumulatedContent || null,
|
|
407
420
|
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { priceUsage } from '../core/prices.js';
|
|
2
|
+
/**
|
|
3
|
+
* Parses the JSON arguments of a tool call. Invalid JSON is reported instead of being
|
|
4
|
+
* silently replaced by `{}` (a truncated write_file would otherwise look like an empty call).
|
|
5
|
+
*/
|
|
6
|
+
export function parseToolArguments(raw) {
|
|
7
|
+
if (!raw || !raw.trim())
|
|
8
|
+
return { args: {} };
|
|
9
|
+
try {
|
|
10
|
+
const value = JSON.parse(raw);
|
|
11
|
+
if (value && typeof value === 'object' && !Array.isArray(value))
|
|
12
|
+
return { args: value };
|
|
13
|
+
return { args: {}, error: 'Tool arguments must be a JSON object.' };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return {
|
|
17
|
+
args: {},
|
|
18
|
+
error: 'The tool arguments were not valid JSON - the response was most likely cut off by the output token limit. ' +
|
|
19
|
+
'Nothing was executed. Retry with a smaller payload (for example, split a large file into several smaller writes).'
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** What the user should see while a tool call is still being written: the path, command or query. */
|
|
24
|
+
export function toolTargetHint(args) {
|
|
25
|
+
if (!args)
|
|
26
|
+
return undefined;
|
|
27
|
+
const pathMatch = args.match(/"(?:path|filePath|targetFile)"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/);
|
|
28
|
+
if (pathMatch && pathMatch[1])
|
|
29
|
+
return pathMatch[1];
|
|
30
|
+
const cmdMatch = args.match(/"(?:command|cmd)"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/);
|
|
31
|
+
if (cmdMatch && cmdMatch[1])
|
|
32
|
+
return cmdMatch[1].length > 30 ? cmdMatch[1].slice(0, 27) + '...' : cmdMatch[1];
|
|
33
|
+
const qMatch = args.match(/"(?:query|pattern)"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/);
|
|
34
|
+
if (qMatch && qMatch[1])
|
|
35
|
+
return `"${qMatch[1]}"`;
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
/** The message inside an error body of either API ({error:{message}} or {message}). */
|
|
39
|
+
export function errorMessageOf(text) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(text);
|
|
42
|
+
if (parsed?.error?.message)
|
|
43
|
+
return String(parsed.error.message);
|
|
44
|
+
if (typeof parsed?.error === 'string')
|
|
45
|
+
return parsed.error;
|
|
46
|
+
if (parsed?.message)
|
|
47
|
+
return String(parsed.message);
|
|
48
|
+
}
|
|
49
|
+
catch { }
|
|
50
|
+
return text;
|
|
51
|
+
}
|
|
52
|
+
/** What stands in the message when the model cannot read images. */
|
|
53
|
+
export function imageNotSentText(paths) {
|
|
54
|
+
return paths.map((p) => `[Attached Image: ${p} - not sent, this model cannot read images]`).join(' ');
|
|
55
|
+
}
|
|
56
|
+
const count = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0);
|
|
57
|
+
/** Usage of an OpenAI-format response, with the cost worked out. */
|
|
58
|
+
export function openAiUsage(raw, ctx, fallback) {
|
|
59
|
+
const promptTokens = count(raw?.prompt_tokens) || fallback?.prompt || 0;
|
|
60
|
+
const completionTokens = count(raw?.completion_tokens) || fallback?.completion || 0;
|
|
61
|
+
const cacheRead = count(raw?.prompt_tokens_details?.cached_tokens) || count(raw?.cache_read_input_tokens) || count(raw?.prompt_cache_hit_tokens);
|
|
62
|
+
const cacheWrite = count(raw?.prompt_tokens_details?.cache_write_tokens) || count(raw?.prompt_tokens_details?.cache_creation_tokens) || count(raw?.cache_creation_input_tokens);
|
|
63
|
+
const reasoning = count(raw?.completion_tokens_details?.reasoning_tokens);
|
|
64
|
+
// The endpoint sent no token counts: the numbers are our own estimate.
|
|
65
|
+
const guessed = !count(raw?.prompt_tokens) && !count(raw?.completion_tokens) && !!fallback;
|
|
66
|
+
const usage = {
|
|
67
|
+
promptTokens,
|
|
68
|
+
completionTokens,
|
|
69
|
+
totalTokens: count(raw?.total_tokens) || promptTokens + completionTokens,
|
|
70
|
+
};
|
|
71
|
+
if (cacheRead)
|
|
72
|
+
usage.cacheReadTokens = cacheRead;
|
|
73
|
+
if (cacheWrite)
|
|
74
|
+
usage.cacheWriteTokens = cacheWrite;
|
|
75
|
+
if (reasoning)
|
|
76
|
+
usage.reasoningTokens = reasoning;
|
|
77
|
+
const priced = priceUsage(ctx, usage, raw);
|
|
78
|
+
if (guessed)
|
|
79
|
+
usage.tokensEstimated = true;
|
|
80
|
+
if (priced.cost !== undefined) {
|
|
81
|
+
usage.cost = priced.cost;
|
|
82
|
+
usage.costSource = guessed && priced.costSource !== 'free' ? 'estimated' : priced.costSource;
|
|
83
|
+
}
|
|
84
|
+
return usage;
|
|
85
|
+
}
|
|
86
|
+
// An error that says the request had a parameter the endpoint does not accept.
|
|
87
|
+
export const PARAMETER_REJECTED = /reasoning|thinking|effort|budget|extra_body|unknown (field|parameter|argument|variable)|unrecognized|unsupported (parameter|value)|invalid (parameter|value|request)|additional propert|extra inputs|not permitted|not allowed|unexpected/i;
|
|
88
|
+
// A refusal that is about something else than the reasoning setting: the size of the conversation, the key, the quota,
|
|
89
|
+
// the model name. These are reported as they are; devx does not try the request again without the setting.
|
|
90
|
+
export const NOT_ABOUT_REASONING = /context|too (long|large|many)|maximum|token limit|max_tokens|quota|rate limit|credit|billing|balance|api key|unauthori[sz]ed|authentication|invalid.*model|model.*(not found|does not exist)/i;
|
|
91
|
+
/** Reads the data lines of a server-sent event stream; comments (heartbeats) and blanks are skipped. */
|
|
92
|
+
export async function* readSseData(reader, signal, onBytes) {
|
|
93
|
+
const decoder = new TextDecoder();
|
|
94
|
+
let buffer = '';
|
|
95
|
+
while (true) {
|
|
96
|
+
if (signal?.aborted)
|
|
97
|
+
return;
|
|
98
|
+
const { done, value } = await reader.read();
|
|
99
|
+
if (done || signal?.aborted)
|
|
100
|
+
break;
|
|
101
|
+
if (value)
|
|
102
|
+
onBytes?.(value.byteLength);
|
|
103
|
+
buffer += decoder.decode(value, { stream: true });
|
|
104
|
+
const lines = buffer.split('\n');
|
|
105
|
+
buffer = lines.pop() || '';
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
const trimmed = line.trim();
|
|
108
|
+
if (!trimmed || trimmed.startsWith(':') || !trimmed.startsWith('data:'))
|
|
109
|
+
continue;
|
|
110
|
+
yield trimmed.replace(/^data:\s*/, '');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const rest = buffer.trim();
|
|
114
|
+
if (rest.startsWith('data:'))
|
|
115
|
+
yield rest.replace(/^data:\s*/, '');
|
|
116
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A gateway can refuse the tool definitions of a request (a strict schema check of its own), and one
|
|
3
|
+
* odd definition from an MCP server then breaks every message. When a request is refused and it works
|
|
4
|
+
* without tools, the tools are looked at: first their schemas are simplified, and if that is not
|
|
5
|
+
* enough the ones that are refused are found (a halving search) and switched off for the session.
|
|
6
|
+
*/
|
|
7
|
+
const DROPPED_KEYS = new Set(['$schema', '$id', 'additionalProperties', 'default', 'examples', 'format', 'title']);
|
|
8
|
+
/** A plainer copy of a JSON schema: only what every gateway accepts. */
|
|
9
|
+
export function sanitizeSchema(node) {
|
|
10
|
+
if (Array.isArray(node))
|
|
11
|
+
return node.map(sanitizeSchema);
|
|
12
|
+
if (!node || typeof node !== 'object')
|
|
13
|
+
return node;
|
|
14
|
+
const out = {};
|
|
15
|
+
for (const [key, value] of Object.entries(node)) {
|
|
16
|
+
if (DROPPED_KEYS.has(key))
|
|
17
|
+
continue;
|
|
18
|
+
if (key === 'description' && typeof value === 'string') {
|
|
19
|
+
out[key] = value.replace(/\$\{/g, '$ {').slice(0, 1000);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (key === 'required' && Array.isArray(value) && value.length === 0)
|
|
23
|
+
continue;
|
|
24
|
+
// A property called "title" is a property, not the keyword: only schema nodes are cleaned like that.
|
|
25
|
+
out[key] = key === 'properties' && value && typeof value === 'object'
|
|
26
|
+
? Object.fromEntries(Object.entries(value).map(([name, schema]) => [name, sanitizeSchema(schema)]))
|
|
27
|
+
: sanitizeSchema(value);
|
|
28
|
+
}
|
|
29
|
+
if (out.type === 'object' && !out.properties)
|
|
30
|
+
out.properties = {};
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
export function sanitizeTool(tool) {
|
|
34
|
+
return {
|
|
35
|
+
...tool,
|
|
36
|
+
description: String(tool.description ?? '').replace(/\$\{/g, '$ {').slice(0, 1000),
|
|
37
|
+
parameters: sanitizeSchema(tool.parameters ?? { type: 'object', properties: {} }),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export class ToolGuard {
|
|
41
|
+
sanitize = false;
|
|
42
|
+
disabled = new Set();
|
|
43
|
+
/** The tools as they are sent now: the switched-off ones left out, the schemas simplified once that was needed. */
|
|
44
|
+
apply(tools) {
|
|
45
|
+
if (!tools)
|
|
46
|
+
return tools;
|
|
47
|
+
const kept = tools.filter((t) => !this.disabled.has(t.name));
|
|
48
|
+
return this.sanitize ? kept.map(sanitizeTool) : kept;
|
|
49
|
+
}
|
|
50
|
+
get switchedOff() {
|
|
51
|
+
return [...this.disabled];
|
|
52
|
+
}
|
|
53
|
+
async findOne(list, probe) {
|
|
54
|
+
if (list.length === 0)
|
|
55
|
+
return null;
|
|
56
|
+
if (list.length === 1)
|
|
57
|
+
return list[0].name;
|
|
58
|
+
const mid = Math.ceil(list.length / 2);
|
|
59
|
+
const left = list.slice(0, mid);
|
|
60
|
+
const right = list.slice(mid);
|
|
61
|
+
if (!(await probe(left)))
|
|
62
|
+
return this.findOne(left, probe);
|
|
63
|
+
if (!(await probe(right)))
|
|
64
|
+
return this.findOne(right, probe);
|
|
65
|
+
return null; // each half is accepted alone: the trouble is in how they go together
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* `probe` sends the request with the given tools (and nothing that costs tokens) and says whether it was accepted.
|
|
69
|
+
* Returns whether the tools were changed and what to tell the user.
|
|
70
|
+
*/
|
|
71
|
+
async repair(tools, probe) {
|
|
72
|
+
if (!tools || tools.length === 0)
|
|
73
|
+
return { changed: false };
|
|
74
|
+
// Without tools the request has to work, or the tools are not what is wrong.
|
|
75
|
+
if (!(await probe([])))
|
|
76
|
+
return { changed: false };
|
|
77
|
+
if (!this.sanitize) {
|
|
78
|
+
this.sanitize = true;
|
|
79
|
+
if (await probe(this.apply(tools))) {
|
|
80
|
+
return { changed: true, notice: 'The endpoint refused the tool definitions. They were simplified (schema extras removed) and the request works now.' };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const off = [];
|
|
84
|
+
for (let round = 0; round < 6; round++) {
|
|
85
|
+
const current = this.apply(tools);
|
|
86
|
+
if (current.length === 0 || (await probe(current)))
|
|
87
|
+
break;
|
|
88
|
+
const bad = await this.findOne(current, probe);
|
|
89
|
+
if (!bad)
|
|
90
|
+
break;
|
|
91
|
+
this.disabled.add(bad);
|
|
92
|
+
off.push(bad);
|
|
93
|
+
}
|
|
94
|
+
if (off.length === 0)
|
|
95
|
+
return { changed: this.sanitize };
|
|
96
|
+
return {
|
|
97
|
+
changed: true,
|
|
98
|
+
notice: `The endpoint refused ${off.length === 1 ? 'the tool' : 'the tools'} ${off.join(', ')}. ${off.length === 1 ? 'It is' : 'They are'} switched off for this session; the others work.`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|