termux-dev 1.6.0 → 1.8.1

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.
Files changed (99) hide show
  1. package/README.md +214 -166
  2. package/assets/banner.svg +23 -28
  3. package/assets/preview.png +0 -0
  4. package/assets/preview.svg +101 -10
  5. package/dist/cli/banner.js +3 -4
  6. package/dist/cli/clipboard-write.js +55 -0
  7. package/dist/cli/clipboard.js +182 -106
  8. package/dist/cli/colors.js +67 -0
  9. package/dist/cli/confirm.js +50 -0
  10. package/dist/cli/doctor.js +4 -4
  11. package/dist/cli/effort.js +80 -0
  12. package/dist/cli/export.js +3 -3
  13. package/dist/cli/files.js +19 -5
  14. package/dist/cli/finder.js +241 -0
  15. package/dist/cli/headless.js +23 -11
  16. package/dist/cli/index.js +1557 -744
  17. package/dist/cli/keys.js +69 -0
  18. package/dist/cli/liveblock.js +45 -0
  19. package/dist/cli/log.js +19 -0
  20. package/dist/cli/logo.js +30 -0
  21. package/dist/cli/markdown.js +174 -37
  22. package/dist/cli/mcp-panel.js +432 -0
  23. package/dist/cli/permission.js +84 -0
  24. package/dist/cli/picker.js +143 -0
  25. package/dist/cli/prompt.js +256 -56
  26. package/dist/cli/resume.js +15 -0
  27. package/dist/cli/rewind.js +51 -0
  28. package/dist/cli/runtime_errors.js +6 -0
  29. package/dist/cli/server.js +85 -24
  30. package/dist/cli/settings.js +307 -0
  31. package/dist/cli/statusline.js +1 -1
  32. package/dist/cli/theme.js +126 -108
  33. package/dist/cli/transcript.js +134 -0
  34. package/dist/cli/typeahead.js +371 -0
  35. package/dist/cli/ui.js +1022 -0
  36. package/dist/cli/updater.js +23 -21
  37. package/dist/cli/usage-panel.js +242 -0
  38. package/dist/cli/welcome.js +56 -0
  39. package/dist/cli/width.js +178 -0
  40. package/dist/core/capabilities.js +433 -0
  41. package/dist/core/catalog-util.js +27 -0
  42. package/dist/core/checkpoint.js +90 -0
  43. package/dist/core/context-stats.js +29 -0
  44. package/dist/core/cost.js +191 -0
  45. package/dist/core/formats.js +199 -0
  46. package/dist/core/history.js +51 -1
  47. package/dist/core/hooks.js +79 -0
  48. package/dist/core/i18n.js +42 -0
  49. package/dist/core/local-config.js +69 -0
  50. package/dist/core/locales/de.js +265 -0
  51. package/dist/core/locales/en.js +276 -0
  52. package/dist/core/locales/es.js +265 -0
  53. package/dist/core/locales/fr.js +265 -0
  54. package/dist/core/locales/ja.js +265 -0
  55. package/dist/core/locales/pt.js +265 -0
  56. package/dist/core/locales/ru.js +265 -0
  57. package/dist/core/locales/uk.js +265 -0
  58. package/dist/core/locales/zh.js +265 -0
  59. package/dist/core/loop.js +125 -49
  60. package/dist/core/memory.js +39 -12
  61. package/dist/core/models.js +33 -20
  62. package/dist/core/network.js +12 -11
  63. package/dist/core/notify.js +54 -0
  64. package/dist/core/planfile.js +43 -0
  65. package/dist/core/price-catalog.json +1 -0
  66. package/dist/core/prices.js +249 -0
  67. package/dist/core/reasoning-catalog.json +1 -0
  68. package/dist/core/reasoning.js +393 -0
  69. package/dist/core/redact.js +41 -0
  70. package/dist/core/secure-file.js +22 -0
  71. package/dist/core/session.js +16 -5
  72. package/dist/core/snapshot.js +14 -2
  73. package/dist/core/title.js +61 -0
  74. package/dist/core/types.js +1 -1
  75. package/dist/core/usage.js +43 -7
  76. package/dist/core/vision-catalog.json +1 -0
  77. package/dist/core/vision.js +123 -0
  78. package/dist/mcp/client.js +36 -5
  79. package/dist/mcp/http-client.js +174 -0
  80. package/dist/mcp/manager.js +330 -93
  81. package/dist/permissions/guard.js +385 -117
  82. package/dist/prompts/builder.js +46 -38
  83. package/dist/providers/anthropic.js +517 -0
  84. package/dist/providers/index.js +18 -1
  85. package/dist/providers/openai.js +138 -125
  86. package/dist/providers/shared.js +116 -0
  87. package/dist/providers/toolguard.js +101 -0
  88. package/dist/tools/bash.js +141 -51
  89. package/dist/tools/diagnostics.js +76 -48
  90. package/dist/tools/fs.js +77 -5
  91. package/dist/tools/index.js +2 -1
  92. package/dist/tools/mcp.js +72 -0
  93. package/dist/tools/packages.js +16 -1
  94. package/dist/tools/plan.js +8 -8
  95. package/dist/tools/questions.js +50 -54
  96. package/dist/tools/server.js +6 -0
  97. package/dist/tools/todo.js +20 -18
  98. package/dist/tools/web.js +143 -8
  99. package/package.json +1 -1
@@ -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
- constructor(baseUrl, apiKey, model) {
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
- buildPayload(request, stream) {
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 (idx === lastImageMsgIdx) {
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
- if (request.tools && request.tools.length > 0) {
57
- payload.tools = request.tools.map(t => ({
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 url = `${this.baseUrl}/chat/completions`;
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
- let errMsg = errText;
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
- const promptTokens = data.usage.prompt_tokens || 0;
119
- const completionTokens = data.usage.completion_tokens || 0;
120
- const totalTokens = data.usage.total_tokens || (promptTokens + completionTokens);
121
- const cost = typeof data.usage.total_cost === 'number'
122
- ? data.usage.total_cost
123
- : calculateCost(this.model, promptTokens, completionTokens);
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
- let parsedArgs = {};
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: parsedArgs
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 url = `${this.baseUrl}/chat/completions`;
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
- let errMsg = errText;
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
- return await this.chat(request);
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
- // Extract live target hint (e.g. path or command being generated)
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
- let parsedArgs = {};
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: parsedArgs
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
- let usage;
392
- const promptTokens = usageData?.prompt_tokens || Math.ceil(JSON.stringify(request.messages).length / 3.5);
393
- const completionTokens = usageData?.completion_tokens || Math.ceil((accumulatedContent.length + accumulatedReasoning.length) / 3.5);
394
- const totalTokens = usageData?.total_tokens || (promptTokens + completionTokens);
395
- const cost = typeof usageData?.total_cost === 'number'
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
+ }