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.
Files changed (96) 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 +92 -10
  5. package/dist/cli/banner.js +1 -1
  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/headless.js +23 -11
  15. package/dist/cli/index.js +1377 -480
  16. package/dist/cli/keys.js +69 -0
  17. package/dist/cli/liveblock.js +45 -0
  18. package/dist/cli/log.js +19 -0
  19. package/dist/cli/markdown.js +158 -36
  20. package/dist/cli/mcp-panel.js +432 -0
  21. package/dist/cli/permission.js +84 -0
  22. package/dist/cli/picker.js +135 -0
  23. package/dist/cli/prompt.js +256 -56
  24. package/dist/cli/resume.js +15 -0
  25. package/dist/cli/rewind.js +51 -0
  26. package/dist/cli/runtime_errors.js +6 -0
  27. package/dist/cli/server.js +85 -24
  28. package/dist/cli/settings.js +307 -0
  29. package/dist/cli/statusline.js +1 -1
  30. package/dist/cli/theme.js +126 -108
  31. package/dist/cli/transcript.js +114 -0
  32. package/dist/cli/typeahead.js +371 -0
  33. package/dist/cli/ui.js +1021 -0
  34. package/dist/cli/updater.js +23 -21
  35. package/dist/cli/usage-panel.js +242 -0
  36. package/dist/cli/width.js +178 -0
  37. package/dist/core/capabilities.js +433 -0
  38. package/dist/core/catalog-util.js +27 -0
  39. package/dist/core/checkpoint.js +90 -0
  40. package/dist/core/context-stats.js +29 -0
  41. package/dist/core/cost.js +191 -0
  42. package/dist/core/formats.js +199 -0
  43. package/dist/core/history.js +51 -1
  44. package/dist/core/hooks.js +79 -0
  45. package/dist/core/i18n.js +42 -0
  46. package/dist/core/local-config.js +69 -0
  47. package/dist/core/locales/de.js +210 -0
  48. package/dist/core/locales/en.js +221 -0
  49. package/dist/core/locales/es.js +210 -0
  50. package/dist/core/locales/fr.js +210 -0
  51. package/dist/core/locales/ja.js +210 -0
  52. package/dist/core/locales/pt.js +210 -0
  53. package/dist/core/locales/ru.js +210 -0
  54. package/dist/core/locales/uk.js +210 -0
  55. package/dist/core/locales/zh.js +210 -0
  56. package/dist/core/loop.js +125 -49
  57. package/dist/core/memory.js +39 -12
  58. package/dist/core/models.js +33 -20
  59. package/dist/core/network.js +12 -11
  60. package/dist/core/notify.js +54 -0
  61. package/dist/core/planfile.js +43 -0
  62. package/dist/core/price-catalog.json +1 -0
  63. package/dist/core/prices.js +249 -0
  64. package/dist/core/reasoning-catalog.json +1 -0
  65. package/dist/core/reasoning.js +393 -0
  66. package/dist/core/redact.js +41 -0
  67. package/dist/core/secure-file.js +22 -0
  68. package/dist/core/session.js +16 -5
  69. package/dist/core/snapshot.js +14 -2
  70. package/dist/core/title.js +61 -0
  71. package/dist/core/types.js +1 -1
  72. package/dist/core/usage.js +43 -7
  73. package/dist/core/vision-catalog.json +1 -0
  74. package/dist/core/vision.js +123 -0
  75. package/dist/mcp/client.js +36 -5
  76. package/dist/mcp/http-client.js +174 -0
  77. package/dist/mcp/manager.js +330 -93
  78. package/dist/permissions/guard.js +385 -117
  79. package/dist/prompts/builder.js +46 -38
  80. package/dist/providers/anthropic.js +517 -0
  81. package/dist/providers/index.js +18 -1
  82. package/dist/providers/openai.js +138 -125
  83. package/dist/providers/shared.js +116 -0
  84. package/dist/providers/toolguard.js +101 -0
  85. package/dist/tools/bash.js +141 -51
  86. package/dist/tools/diagnostics.js +76 -48
  87. package/dist/tools/fs.js +77 -5
  88. package/dist/tools/index.js +2 -1
  89. package/dist/tools/mcp.js +72 -0
  90. package/dist/tools/packages.js +16 -1
  91. package/dist/tools/plan.js +8 -8
  92. package/dist/tools/questions.js +50 -54
  93. package/dist/tools/server.js +6 -0
  94. package/dist/tools/todo.js +20 -18
  95. package/dist/tools/web.js +143 -8
  96. package/package.json +1 -1
@@ -1,58 +1,66 @@
1
1
  import os from 'os';
2
2
  import path from 'path';
3
3
  import fs from 'fs/promises';
4
- import { MemoryManager } from '../core/memory.js';
4
+ import { MemoryManager, memoryForPrompt } from '../core/memory.js';
5
+ import { loadPlanForPrompt } from '../core/planfile.js';
5
6
  export async function buildSystemPrompt(planMode) {
6
7
  const isTermux = process.env.PREFIX?.includes('com.termux') || false;
7
8
  const envDesc = isTermux ? 'Android (Termux, ARM64)' : `${os.platform()} (${os.arch()})`;
8
- let prompt = `You are devx, an expert AI developer.\n`;
9
- prompt += `Operating System: ${envDesc}\n`;
10
- prompt += `Current Working Directory: ${process.cwd()}\n`;
11
- prompt += `Date: ${new Date().toISOString()}\n\n`;
9
+ let prompt = `You are devx, a coding assistant that works in the user's terminal.\n`;
10
+ prompt += `System: ${envDesc}. Working directory: ${process.cwd()}. Date: ${new Date().toISOString().slice(0, 10)}.\n\n`;
11
+ prompt += `HOW TO ANSWER\n`;
12
+ prompt += `- Write plainly and briefly, the way you would explain it to a colleague. No filler, no flattery, no repeating the question, no closing summary of what you just did.\n`;
13
+ prompt += `- Say the result first, then only the details that matter. Short sentences, everyday words. Explain a term only if the user is unlikely to know it.\n`;
14
+ prompt += `- Use the language the user writes in. Never use emoji.\n`;
15
+ prompt += `- Use lists or headings only when the content is really a list or has parts. A short answer is plain text.\n`;
16
+ prompt += `- If you are unsure or something failed, say so directly and say what you tried. Do not pretend it worked.\n\n`;
17
+ prompt += `WHEN TO ACT\n`;
18
+ prompt += `- Start working only when the user gives a task. A greeting, small talk or a question about you or devx gets a one or two sentence answer, with no tools: do not list folders, read files or ask menu questions. Then wait.\n`;
19
+ prompt += `- Never explore the project on your own initiative. Read or search files only when the task needs it.\n`;
20
+ prompt += `- Ask the user (with the 'ask_questions' tool, never as a plain text list) only when you cannot continue without the answer. If a sensible default exists, take it and say which.\n\n`;
21
+ prompt += `RULES\n`;
22
+ prompt += `- Never read, print or copy secrets: ~/.devxrc.json, ~/.devx/, .env files, private keys, tokens, API keys. If a task seems to need one, ask the user. If a secret shows up in an output anyway, do not repeat it.\n`;
23
+ prompt += `- If the user denies an action ("Action denied by user"), do not retry it or get around it with another command. Say what you wanted to do and offer another way, or ask.\n`;
24
+ prompt += `- If a tool or an MCP server fails, say what failed with the error in one line. Try one different approach at most, then report instead of repeating.\n\n`;
12
25
  if (planMode) {
13
- prompt += `MODE: PLAN (STRICT ARCHITECT & PLANNER MODE)\n`;
14
- prompt += `You are in PLAN mode. Your role is strictly to act as an expert Software Architect and Planner.\n\n`;
15
- prompt += `STRICT PLAN MODE RULES:\n`;
16
- prompt += `1. ABSOLUTELY NO FULL CODE DUMPS: You must NEVER output full code files, HTML, CSS, or script contents in your response! Do NOT say "скопируйте этот код", "вот полный код" or write full file implementations. Your output must strictly be a high-level architectural plan and structure.\n`;
17
- prompt += `2. NO MODIFYING TOOLS: You cannot write, edit, delete files or execute bash commands. Modifying tools are disabled.\n`;
18
- prompt += `3. EXPLORATION & RESEARCH: Inspect the existing codebase using 'readFile', 'listDir', 'search', and 'webSearch' to thoroughly understand architecture, existing code patterns, and dependencies.\n`;
19
- prompt += `4. MANDATORY INTERACTIVE QUESTIONS VIA 'ask_questions' TOOL:\n`;
20
- prompt += ` - Whenever you want to ask the user ANY question, clarify requirements, or get choices (genre, stack, mechanics, UI, design), you MUST NEVER write questions as plain text in your markdown output!\n`;
21
- prompt += ` - You MUST call the 'ask_questions' tool function call with the list of questions and options.\n`;
22
- prompt += ` - Outputting numbered questions in plain markdown text without calling 'ask_questions' is strictly prohibited.\n`;
23
- prompt += `5. FORMULATING THE PLAN & TODO LIST:\n`;
24
- prompt += ` - For multi-step tasks, formulate a clear, actionable todo checklist using the 'todo_list' tool or markdown checkboxes:\n`;
25
- prompt += ` [ ] Step 1 description\n`;
26
- prompt += ` [ ] Step 2 description\n`;
27
- prompt += ` - Present a structured **FINAL PLAN** in your response:\n`;
28
- prompt += ` - **🎯 Цель и архитектурный обзор**\n`;
29
- prompt += ` - **📁 Пошаговый список изменений по файлам** (какие файлы создаём/правим и какую логику реализуем в них, БЕЗ полного кода)\n`;
30
- prompt += ` - **🧪 План тестирования и проверки**\n`;
31
- prompt += `6. CALL 'plan_ready' TOOL: When your architectural plan is finalized and ready for execution, call the 'plan_ready' tool. Do NOT ask the user to type trigger words — devx automatically presents the user with an interactive [🚀 Go / ✏️ Other] prompt!\n`;
32
- prompt += `7. EXECUTION TRIGGER: When the user confirms with [🚀 Go], devx switches to AGENT mode to begin implementing the approved plan.\n`;
26
+ prompt += `MODE: PLAN. You only research and plan. You cannot write or edit files or run commands.\n`;
27
+ prompt += `- Inspect the project with the read, list and search tools, and the web search tool, only as far as the plan needs. For a small task look at the one or two files involved and stop.\n`;
28
+ prompt += `- Size the plan to the task. SMALL task (one file or a few lines, a clear fix): no headings, call 'plan_ready' with a one-line summary, the files and 2 to 4 short steps. LARGER task (several files, a new feature, design choices): write the plan with three parts, with headings in the language the user writes in: **Goal and architecture** (in Russian: **Цель и архитектурный обзор**); **Step-by-step changes by file** (**Пошаговый список изменений по файлам**: which files, what logic); **Testing and verification** (**План тестирования и проверки**). Keep each part short.\n`;
29
+ prompt += `- Never write out full code files. Describe what to change and why. Do not use 'todo_list' in PLAN mode: the plan itself is the result.\n`;
30
+ prompt += `- When the plan is ready, call 'plan_ready' with the summary, the files and the steps. devx shows the plan and asks the user to approve it (Go) or change it: do not ask for approval yourself. If the user asks for changes, update the plan and call 'plan_ready' again.\n`;
33
31
  }
34
32
  else {
35
- prompt += `MODE: AGENT (CODER / EXECUTOR MODE)\n`;
36
- prompt += `You are in AGENT mode. You have full access to bash, file creation, editing, package installation, diagnostics, and search tools.\n`;
37
- prompt += `EXECUTION & TODO PROGRESS TRACKING:\n`;
38
- prompt += `- For multi-step tasks, you can use the 'todo_list' tool to update the live task checklist, marking finished steps as 'completed' ([✓]) and the active step as 'in_progress' ([ ]).\n`;
39
- prompt += `- Execute tasks and approved plans step by step with high code quality.\n`;
40
- prompt += `SELF-HEALING & VERIFICATION:\n`;
41
- prompt += `- Whenever you modify or create files, use the 'diagnose_code' tool to check for any syntax or type errors.\n`;
42
- prompt += `- If any error is found, automatically fix it with 'edit_file' until all diagnostics pass cleanly.\n`;
43
- prompt += `- If you need dependencies, use 'install_package' to install them cleanly.\n`;
44
- prompt += `- Whenever you create or modify web applications, sites, HTML/CSS/JS, canvas games, or React/Vite frontend apps, automatically call the 'serve_preview' tool with action='start' to start the local preview server and display the mobile QR-code for the user!\n`;
45
- prompt += `- Use 'save_memory' to remember important architectural decisions, user preferences, or project rules.\n`;
46
- prompt += `- Always explain your actions briefly before using tools.\n`;
33
+ prompt += `MODE: AGENT. You can run commands, create and edit files, install packages and search.\n`;
34
+ prompt += `- Before using tools on a task, say in one short line what you are about to do. Do not narrate every step.\n`;
35
+ prompt += `- Do the whole task step by step. For a task with several steps, keep the checklist with 'todo_list'.\n`;
36
+ prompt += `- Read a file before you edit it. Change only what the task needs, with 'edit_file' for small changes. Do not rewrite a whole file or touch unrelated code.\n`;
37
+ prompt += `- 'bash' stops after 120 s and cuts long output: give long jobs a timeout or run them in the background, and print only what you need (head, tail, grep).\n`;
38
+ prompt += `- After you change code, run 'diagnose_code' and fix what it reports. Install missing dependencies with 'install_package'.\n`;
39
+ prompt += `- After you create or change a web page or frontend app, start the local preview with 'serve_preview' (action 'start').\n`;
40
+ if (MemoryManager.enabled)
41
+ prompt += `- Save lasting decisions and user preferences with 'save_memory'.\n`;
42
+ prompt += `- At the end say what was done and what the user should know or check, in a few lines.\n`;
47
43
  }
44
+ prompt += `\nMCP (external tools)\n`;
45
+ prompt += `- Servers live in ~/.devx/mcp.json (the user's, started at once) and .devx/mcp.json (this project, only after the user approved it). Their tools are named mcp__<server>__<tool>. Local programs only (command + args); a server that has just an address is reached with command 'npx', args ['-y','mcp-remote','<url>'].\n`;
46
+ if (!planMode)
47
+ prompt += `- If the user asks to connect a service over MCP, use 'mcp_manage': 'list' first, then 'add' with the real command (find it in the service's docs or with the web search tool, do not guess; ask with 'ask_questions' if you need a path or a choice). Secrets go into env, not args. Check the result: if the server did not connect, tell the user the error. 'import_claude' takes the servers from Claude Code if the user has it. The new tools work from the user's next message.\n`;
48
48
  // Load Project Memory Bank
49
49
  try {
50
- const memory = await MemoryManager.loadMemory();
50
+ const memory = MemoryManager.enabled ? memoryForPrompt(await MemoryManager.loadMemory()).text : '';
51
51
  if (memory.trim()) {
52
52
  prompt += `\n--- Project Knowledge & Memory Bank (.devx/memory.md) ---\n${memory}\n-------------------------------------------------------\n`;
53
53
  }
54
54
  }
55
55
  catch { }
56
+ // The plan the user approved (kept in the project, recent only): the work follows it.
57
+ try {
58
+ const plan = planMode ? '' : loadPlanForPrompt(process.cwd());
59
+ if (plan) {
60
+ prompt += `\n--- Approved plan (.devx/plan.md) ---\nFollow it for the task it was made for; if the user now asks for something else, ignore it.\n${plan}\n-------------------------------------\n`;
61
+ }
62
+ }
63
+ catch { }
56
64
  let currentDir = process.cwd();
57
65
  try {
58
66
  while (true) {
@@ -0,0 +1,517 @@
1
+ import { UsageTracker } from '../core/usage.js';
2
+ import { priceUsage } from '../core/prices.js';
3
+ import { ToolGuard } from './toolguard.js';
4
+ import { NOT_ABOUT_REASONING, PARAMETER_REJECTED, errorMessageOf, imageNotSentText, parseToolArguments, readSseData, toolTargetHint } from './shared.js';
5
+ /**
6
+ * Speaks the native Anthropic Messages API: `POST {origin}/v1/messages`.
7
+ *
8
+ * What differs from the OpenAI format (see openai.ts):
9
+ * - the key goes into `x-api-key` and the version into `anthropic-version`
10
+ * - the system prompt is a top-level field, `max_tokens` is required
11
+ * - content is a list of typed blocks: text, tool_use, tool_result, thinking
12
+ * - reasoning blocks carry a signature and are sent back unchanged with the next request
13
+ * - the stream has typed events (message_start, content_block_delta, ...)
14
+ * - usage separates cached input from the rest
15
+ */
16
+ const API_VERSION = '2023-06-01';
17
+ const DEFAULT_MAX_TOKENS = 16000;
18
+ // Fields the extra parameters may never overwrite.
19
+ const PROTECTED_FIELDS = new Set(['model', 'messages', 'stream', 'tools', 'system']);
20
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0);
21
+ /** "https://host/v1/" and "https://host" both mean the same endpoint root. */
22
+ export function anthropicRoot(baseUrl) {
23
+ return baseUrl.replace(/\/+$/, '').replace(/\/v1$/, '');
24
+ }
25
+ function splitDataUrl(dataUrl) {
26
+ const m = /^data:([^;,]+);base64,(.+)$/s.exec(dataUrl);
27
+ return m ? { mediaType: m[1], data: m[2] } : null;
28
+ }
29
+ /** Converts devx messages to the Anthropic shape. */
30
+ export function toAnthropicMessages(messages, options = {}) {
31
+ const systemParts = [];
32
+ const out = [];
33
+ let lastImageIdx = -1;
34
+ messages.forEach((m, i) => {
35
+ if (m.images && m.images.length > 0)
36
+ lastImageIdx = i;
37
+ });
38
+ const push = (role, blocks) => {
39
+ if (blocks.length === 0)
40
+ return;
41
+ const last = out[out.length - 1];
42
+ // Two messages of one role in a row are one message for this API.
43
+ if (last && last.role === role)
44
+ last.content.push(...blocks);
45
+ else
46
+ out.push({ role, content: blocks });
47
+ };
48
+ messages.forEach((m, idx) => {
49
+ if (m.role === 'system') {
50
+ if (m.content)
51
+ systemParts.push(m.content);
52
+ return;
53
+ }
54
+ if (m.role === 'tool') {
55
+ push('user', [{ type: 'tool_result', tool_use_id: m.tool_call_id, content: m.content || '(empty)' }]);
56
+ return;
57
+ }
58
+ if (m.role === 'assistant') {
59
+ const blocks = [];
60
+ if (options.thinking !== false) {
61
+ for (const t of m.thinking_blocks ?? [])
62
+ blocks.push({ ...t });
63
+ }
64
+ if (m.content)
65
+ blocks.push({ type: 'text', text: m.content });
66
+ for (const call of m.tool_calls ?? []) {
67
+ blocks.push({ type: 'tool_use', id: call.id, name: call.name, input: call.arguments ?? {} });
68
+ }
69
+ push('assistant', blocks.length > 0 ? blocks : [{ type: 'text', text: '(no content)' }]);
70
+ return;
71
+ }
72
+ const blocks = [];
73
+ let text = m.content || '';
74
+ if (m.images && m.images.length > 0) {
75
+ if (options.images === false) {
76
+ const note = imageNotSentText(m.images.map((img) => img.path));
77
+ text = text ? `${text}\n${note}` : note;
78
+ }
79
+ else if (idx === lastImageIdx) {
80
+ for (const img of m.images) {
81
+ const parts = splitDataUrl(img.dataUrl);
82
+ if (parts)
83
+ blocks.push({ type: 'image', source: { type: 'base64', media_type: parts.mediaType, data: parts.data } });
84
+ }
85
+ }
86
+ else {
87
+ const refs = m.images.map((img) => `[Attached Image: ${img.path}]`).join(' ');
88
+ text = text ? `${text}\n${refs}` : refs;
89
+ }
90
+ }
91
+ if (text || blocks.length === 0)
92
+ blocks.push({ type: 'text', text: text || '(empty)' });
93
+ push('user', blocks);
94
+ });
95
+ if (options.cache && out.length > 0) {
96
+ // One breakpoint at the end of the conversation: the whole prefix is read from the cache next time.
97
+ const last = out[out.length - 1];
98
+ const tail = last.content[last.content.length - 1];
99
+ if (tail && tail.type !== 'thinking' && tail.type !== 'redacted_thinking')
100
+ tail.cache_control = { type: 'ephemeral' };
101
+ }
102
+ return { system: systemParts.join('\n\n'), messages: out };
103
+ }
104
+ export class AnthropicProvider {
105
+ baseUrl;
106
+ apiKey;
107
+ model;
108
+ extraBody;
109
+ options;
110
+ id = 'anthropic-messages';
111
+ extrasRejected = false;
112
+ cacheRejected = false;
113
+ thinkingBlocksRejected = false;
114
+ maxTokensCap = null;
115
+ notices = [];
116
+ imageNoticeShown = false;
117
+ constructor(baseUrl, apiKey, model,
118
+ /** Extra request parameters, e.g. the reasoning setting from /effort. */
119
+ extraBody, options = {}) {
120
+ this.baseUrl = baseUrl;
121
+ this.apiKey = apiKey;
122
+ this.model = model;
123
+ this.extraBody = extraBody;
124
+ this.options = options;
125
+ }
126
+ drainNotices() {
127
+ return this.notices.splice(0);
128
+ }
129
+ priceContext() {
130
+ return { ...this.options.price, model: this.model, baseUrl: this.baseUrl };
131
+ }
132
+ toolGuard = new ToolGuard();
133
+ hasActiveExtras() {
134
+ return !this.extrasRejected && !!this.extraBody && Object.keys(this.extraBody).length > 0;
135
+ }
136
+ buildPayload(request, stream, probe = false) {
137
+ const converted = toAnthropicMessages(request.messages, { cache: !this.cacheRejected, thinking: !this.thinkingBlocksRejected, images: this.options.images });
138
+ if (this.options.images === false && !this.imageNoticeShown && request.messages.some((m) => m.images?.length)) {
139
+ this.imageNoticeShown = true;
140
+ this.notices.push(`${this.model} cannot read images, so the image was not sent. Switch to a model with vision (/model).`);
141
+ }
142
+ const payload = {
143
+ model: this.model,
144
+ max_tokens: this.maxTokensCap ?? DEFAULT_MAX_TOKENS,
145
+ stream,
146
+ messages: converted.messages,
147
+ };
148
+ if (converted.system) {
149
+ payload.system = this.cacheRejected
150
+ ? converted.system
151
+ : [{ type: 'text', text: converted.system, cache_control: { type: 'ephemeral' } }];
152
+ }
153
+ // Tools the gateway refused are left out.
154
+ const tools = this.toolGuard.apply(request.tools);
155
+ if (tools && tools.length > 0) {
156
+ payload.tools = tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters }));
157
+ if (!this.cacheRejected)
158
+ payload.tools[payload.tools.length - 1].cache_control = { type: 'ephemeral' };
159
+ }
160
+ // A probe (to find out which tool is refused) costs next to nothing and does not carry the reasoning setting.
161
+ if (probe) {
162
+ payload.max_tokens = 1;
163
+ payload.stream = false;
164
+ return payload;
165
+ }
166
+ if (this.hasActiveExtras()) {
167
+ for (const [key, value] of Object.entries(this.extraBody)) {
168
+ if (PROTECTED_FIELDS.has(key))
169
+ continue;
170
+ // `reasoning_effort` / `reasoning` are OpenAI-style: the Anthropic API refuses them as unknown fields.
171
+ if (key === 'reasoning_effort' || key === 'reasoning')
172
+ continue;
173
+ // The cap learned from an error must not be undone by the reasoning setting.
174
+ if (key === 'max_tokens' && this.maxTokensCap !== null)
175
+ continue;
176
+ payload[key] = value;
177
+ }
178
+ }
179
+ return payload;
180
+ }
181
+ headers() {
182
+ const headers = {
183
+ 'Content-Type': 'application/json',
184
+ 'anthropic-version': API_VERSION,
185
+ };
186
+ if (this.apiKey) {
187
+ headers['x-api-key'] = this.apiKey;
188
+ // Gateways in front of the API often read the key from the standard header instead.
189
+ let host = '';
190
+ try {
191
+ host = new URL(this.baseUrl).hostname.toLowerCase();
192
+ }
193
+ catch { }
194
+ if (host !== 'api.anthropic.com')
195
+ headers['Authorization'] = `Bearer ${this.apiKey}`;
196
+ }
197
+ return headers;
198
+ }
199
+ /**
200
+ * Posts the request. A 400 that complains about something devx added on its own (the
201
+ * reasoning setting, cache markers, signed reasoning blocks, a too large max_tokens) is
202
+ * repeated once without it, so an unusual gateway never makes the session unusable.
203
+ */
204
+ async send(request, stream) {
205
+ const url = `${anthropicRoot(this.baseUrl)}/v1/messages`;
206
+ const post = () => {
207
+ const bodyStr = JSON.stringify(this.buildPayload(request, stream));
208
+ UsageTracker.getInstance().recordRequest(Buffer.byteLength(bodyStr, 'utf8'));
209
+ return fetch(url, { method: 'POST', headers: this.headers(), body: bodyStr, signal: request.signal });
210
+ };
211
+ let res = await post();
212
+ let genericTry = false;
213
+ let toolsChecked = false;
214
+ const probeWith = async (tools) => {
215
+ const body = this.buildPayload({ ...request, tools: tools.length > 0 ? tools : undefined }, false, true);
216
+ const r = await fetch(url, { method: 'POST', headers: this.headers(), body: JSON.stringify(body), signal: request.signal });
217
+ await r.text().catch(() => '');
218
+ return r.ok;
219
+ };
220
+ for (let attempt = 0; attempt < 4 && !res.ok && (res.status === 400 || res.status === 422); attempt++) {
221
+ const text = await res.clone().text().catch(() => '');
222
+ const reason = errorMessageOf(text).replace(/\s+/g, ' ').slice(0, 110);
223
+ const cap = /max_tokens/i.test(text) ? this.capFromError(text) : null;
224
+ if (cap !== null) {
225
+ this.maxTokensCap = cap;
226
+ }
227
+ else if (/signature|redacted_thinking|thinking block/i.test(text) && !this.thinkingBlocksRejected) {
228
+ this.thinkingBlocksRejected = true;
229
+ this.notices.push(`The endpoint refused the saved reasoning blocks (${reason}). Continuing without them.`);
230
+ }
231
+ else if (/cache_control|cache control/i.test(text) && !this.cacheRejected) {
232
+ this.cacheRejected = true;
233
+ }
234
+ else if (this.hasActiveExtras() && PARAMETER_REJECTED.test(text)) {
235
+ this.extrasRejected = true;
236
+ this.notices.push(`The endpoint rejected the reasoning setting (${reason}). Continuing without it. /effort auto stops sending it for good.`);
237
+ }
238
+ else if (!toolsChecked && request.tools && request.tools.length > 0 && !NOT_ABOUT_REASONING.test(errorMessageOf(text))) {
239
+ // Refused, and it works without tools: a tool definition is what the gateway does not like.
240
+ toolsChecked = true;
241
+ const repair = await this.toolGuard.repair(this.toolGuard.apply(request.tools), probeWith);
242
+ if (repair.notice)
243
+ this.notices.push(repair.notice);
244
+ if (!repair.changed) {
245
+ if (this.hasActiveExtras() && !genericTry) {
246
+ genericTry = true;
247
+ this.extrasRejected = true;
248
+ }
249
+ else {
250
+ break;
251
+ }
252
+ }
253
+ }
254
+ else if (this.hasActiveExtras() && !genericTry && !NOT_ABOUT_REASONING.test(errorMessageOf(text))) {
255
+ // The refusal names nothing ("the model rejected this conversation or its parameters"). The reasoning
256
+ // setting is what devx added on its own, so it is tried without it once.
257
+ genericTry = true;
258
+ this.extrasRejected = true;
259
+ }
260
+ else {
261
+ break;
262
+ }
263
+ res = await post();
264
+ }
265
+ if (genericTry) {
266
+ if (res.ok) {
267
+ this.notices.push('The endpoint refused the request while a reasoning setting was sent; without it the request works. Continuing without it. /effort auto stops sending it for good.');
268
+ }
269
+ else {
270
+ this.extrasRejected = false; // it was not the setting: the real error is shown
271
+ }
272
+ }
273
+ return res;
274
+ }
275
+ /** "max_tokens: 16000 > 8192, which is the maximum allowed" -> 8192 */
276
+ capFromError(text) {
277
+ const current = this.maxTokensCap ?? DEFAULT_MAX_TOKENS;
278
+ const numbers = (errorMessageOf(text).match(/\d{3,7}/g) ?? []).map(Number).filter((n) => n >= 256 && n < current);
279
+ if (numbers.length === 0)
280
+ return null;
281
+ return Math.max(...numbers);
282
+ }
283
+ async failure(res) {
284
+ const text = await res.text();
285
+ UsageTracker.getInstance().recordResponseChunk(Buffer.byteLength(text, 'utf8'));
286
+ const err = new Error(`Provider HTTP Error ${res.status}: ${errorMessageOf(text)}`);
287
+ err.status = res.status;
288
+ throw err;
289
+ }
290
+ usageOf(raw, fallback) {
291
+ const guessed = !raw || (raw.input + raw.cacheRead + raw.cacheWrite + raw.output === 0);
292
+ const base = raw ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
293
+ // Anthropic reports the cached tokens apart from input_tokens; devx counts them all as prompt.
294
+ const promptTokens = base.input + base.cacheRead + base.cacheWrite || (guessed ? fallback?.prompt ?? 0 : 0);
295
+ const completionTokens = base.output || (guessed ? fallback?.completion ?? 0 : 0);
296
+ const usage = { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens };
297
+ if (base.cacheRead)
298
+ usage.cacheReadTokens = base.cacheRead;
299
+ if (base.cacheWrite)
300
+ usage.cacheWriteTokens = base.cacheWrite;
301
+ if (guessed && fallback)
302
+ usage.tokensEstimated = true;
303
+ const priced = priceUsage(this.priceContext(), usage);
304
+ if (priced.cost !== undefined) {
305
+ usage.cost = priced.cost;
306
+ usage.costSource = guessed && fallback && priced.costSource !== 'free' ? 'estimated' : priced.costSource;
307
+ }
308
+ UsageTracker.getInstance().recordUsage(usage);
309
+ return usage;
310
+ }
311
+ async chat(request) {
312
+ const res = await this.send(request, false);
313
+ if (!res.ok)
314
+ await this.failure(res);
315
+ const raw = await res.text();
316
+ UsageTracker.getInstance().recordResponseChunk(Buffer.byteLength(raw, 'utf8'));
317
+ let data;
318
+ try {
319
+ data = JSON.parse(raw);
320
+ }
321
+ catch {
322
+ throw new Error('Invalid JSON response from provider');
323
+ }
324
+ if (data.type === 'error' || data.error) {
325
+ throw new Error(`Provider Error: ${data.error?.message || JSON.stringify(data.error)}`);
326
+ }
327
+ let content = '';
328
+ const toolCalls = [];
329
+ const thinkingBlocks = [];
330
+ for (const block of Array.isArray(data.content) ? data.content : []) {
331
+ if (block.type === 'text')
332
+ content += block.text || '';
333
+ else if (block.type === 'thinking')
334
+ thinkingBlocks.push({ type: 'thinking', thinking: block.thinking || '', signature: block.signature || '' });
335
+ else if (block.type === 'redacted_thinking')
336
+ thinkingBlocks.push({ type: 'redacted_thinking', data: block.data || '' });
337
+ else if (block.type === 'tool_use') {
338
+ toolCalls.push({
339
+ id: block.id || `toolu_${Math.random().toString(36).slice(2, 9)}`,
340
+ name: block.name,
341
+ arguments: block.input && typeof block.input === 'object' ? block.input : {},
342
+ });
343
+ }
344
+ }
345
+ // A reply cut off by the token limit while a tool call was being written has no usable call.
346
+ if (data.stop_reason === 'max_tokens' && toolCalls.length > 0) {
347
+ for (const call of toolCalls) {
348
+ if (Object.keys(call.arguments).length === 0)
349
+ call.argsError = parseToolArguments('{').error;
350
+ }
351
+ }
352
+ const u = data.usage;
353
+ const usage = u
354
+ ? this.usageOf({
355
+ input: num(u.input_tokens), output: num(u.output_tokens),
356
+ cacheRead: num(u.cache_read_input_tokens), cacheWrite: num(u.cache_creation_input_tokens),
357
+ })
358
+ : undefined;
359
+ return {
360
+ content: content || null,
361
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
362
+ thinkingBlocks: thinkingBlocks.length > 0 ? thinkingBlocks : undefined,
363
+ usage,
364
+ };
365
+ }
366
+ async *chatStream(request) {
367
+ const res = await this.send(request, true);
368
+ if (!res.ok)
369
+ await this.failure(res);
370
+ const reader = res.body?.getReader();
371
+ if (!reader) {
372
+ const fallback = await this.chat(request);
373
+ yield { type: 'done', response: fallback };
374
+ return fallback;
375
+ }
376
+ if (request.signal) {
377
+ request.signal.addEventListener('abort', () => {
378
+ try {
379
+ reader.cancel().catch(() => { });
380
+ }
381
+ catch { }
382
+ });
383
+ }
384
+ let content = '';
385
+ let reasoningChars = 0;
386
+ const blocks = new Map();
387
+ const tally = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
388
+ let sawUsage = false;
389
+ let stopReason = '';
390
+ const readUsage = (u) => {
391
+ if (!u || typeof u !== 'object')
392
+ return;
393
+ sawUsage = true;
394
+ // Later events repeat or refine the numbers; a missing field must not erase an earlier value.
395
+ if (u.input_tokens !== undefined)
396
+ tally.input = num(u.input_tokens);
397
+ if (u.output_tokens !== undefined)
398
+ tally.output = num(u.output_tokens);
399
+ if (u.cache_read_input_tokens !== undefined)
400
+ tally.cacheRead = num(u.cache_read_input_tokens);
401
+ if (u.cache_creation_input_tokens !== undefined)
402
+ tally.cacheWrite = num(u.cache_creation_input_tokens);
403
+ };
404
+ try {
405
+ for await (const payload of readSseData(reader, request.signal, (n) => UsageTracker.getInstance().recordResponseChunk(n))) {
406
+ if (payload === '[DONE]')
407
+ continue;
408
+ let event;
409
+ try {
410
+ event = JSON.parse(payload);
411
+ }
412
+ catch {
413
+ continue;
414
+ }
415
+ switch (event.type) {
416
+ case 'error':
417
+ throw new Error(`Provider Stream Error: ${event.error?.message || JSON.stringify(event.error)}`);
418
+ case 'message_start':
419
+ readUsage(event.message?.usage);
420
+ break;
421
+ case 'content_block_start': {
422
+ const b = event.content_block || {};
423
+ const kind = b.type === 'tool_use' ? 'tool' : b.type === 'thinking' ? 'thinking' : b.type === 'redacted_thinking' ? 'redacted' : 'text';
424
+ blocks.set(event.index, {
425
+ kind,
426
+ text: kind === 'redacted' ? '' : b.text || b.thinking || '',
427
+ signature: b.signature || '',
428
+ id: b.id || '',
429
+ name: b.name || '',
430
+ json: kind === 'redacted' ? String(b.data || '') : '',
431
+ });
432
+ if (kind === 'tool')
433
+ yield { type: 'tool_generating', name: b.name || 'tool', bytes: 0 };
434
+ break;
435
+ }
436
+ case 'content_block_delta': {
437
+ const block = blocks.get(event.index);
438
+ const d = event.delta || {};
439
+ if (!block)
440
+ break;
441
+ if (d.type === 'text_delta' && d.text) {
442
+ block.text += d.text;
443
+ content += d.text;
444
+ yield { type: 'content_delta', delta: d.text };
445
+ }
446
+ else if (d.type === 'thinking_delta' && d.thinking) {
447
+ block.text += d.thinking;
448
+ reasoningChars += d.thinking.length;
449
+ yield { type: 'reasoning_delta', delta: d.thinking };
450
+ }
451
+ else if (d.type === 'signature_delta' && d.signature) {
452
+ block.signature += d.signature;
453
+ }
454
+ else if (d.type === 'input_json_delta' && typeof d.partial_json === 'string') {
455
+ block.json += d.partial_json;
456
+ yield { type: 'tool_generating', name: block.name || 'tool', bytes: block.json.length, targetHint: toolTargetHint(block.json) };
457
+ }
458
+ break;
459
+ }
460
+ case 'message_delta':
461
+ readUsage(event.usage);
462
+ if (event.delta?.stop_reason)
463
+ stopReason = event.delta.stop_reason;
464
+ break;
465
+ default:
466
+ break; // ping, content_block_stop, message_stop
467
+ }
468
+ }
469
+ }
470
+ finally {
471
+ try {
472
+ reader.releaseLock();
473
+ }
474
+ catch { }
475
+ }
476
+ const toolCalls = [];
477
+ const thinkingBlocks = [];
478
+ for (const index of [...blocks.keys()].sort((a, b) => a - b)) {
479
+ const b = blocks.get(index);
480
+ if (b.kind === 'thinking') {
481
+ // A reasoning block without a signature cannot be sent back; it would only make the next request fail.
482
+ if (b.signature)
483
+ thinkingBlocks.push({ type: 'thinking', thinking: b.text, signature: b.signature });
484
+ }
485
+ else if (b.kind === 'redacted') {
486
+ thinkingBlocks.push({ type: 'redacted_thinking', data: b.json });
487
+ }
488
+ else if (b.kind === 'tool') {
489
+ const parsed = parseToolArguments(b.json);
490
+ toolCalls.push({
491
+ id: b.id || `toolu_${Math.random().toString(36).slice(2, 9)}`,
492
+ name: b.name,
493
+ arguments: parsed.args,
494
+ ...(parsed.error ? { argsError: parsed.error } : {}),
495
+ });
496
+ }
497
+ }
498
+ if (!content && reasoningChars === 0 && toolCalls.length === 0) {
499
+ throw new Error(`Provider returned empty response (0 tokens generated). Please check your model '${this.model}', balance, or API key.`);
500
+ }
501
+ if (stopReason === 'max_tokens' && toolCalls.length === 0 && !content && reasoningChars > 0) {
502
+ this.notices.push('The reply was cut off by the output limit while the model was still thinking. Lower /effort or ask again.');
503
+ }
504
+ const usage = this.usageOf(sawUsage ? tally : null, {
505
+ prompt: Math.ceil(JSON.stringify(request.messages).length / 3.5),
506
+ completion: Math.ceil((content.length + reasoningChars) / 3.5),
507
+ });
508
+ const response = {
509
+ content: content || null,
510
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
511
+ thinkingBlocks: thinkingBlocks.length > 0 ? thinkingBlocks : undefined,
512
+ usage,
513
+ };
514
+ yield { type: 'done', response };
515
+ return response;
516
+ }
517
+ }
@@ -1,7 +1,24 @@
1
+ import { effectiveReasoning } from '../core/reasoning.js';
2
+ import { resolveFormat } from '../core/formats.js';
3
+ import { modelAcceptsImages } from '../core/vision.js';
1
4
  import { OpenAIProvider } from './openai.js';
5
+ import { AnthropicProvider } from './anthropic.js';
2
6
  export function createProvider(config) {
3
7
  const baseUrl = config.baseUrl || 'https://api.openai.com/v1';
4
8
  const apiKey = config.apiKey || process.env.OPENAI_API_KEY || '';
5
9
  const model = config.model || 'gpt-4o-mini';
6
- return new OpenAIProvider(baseUrl, apiKey, model);
10
+ // OpenAI or Anthropic wire format: by the base URL, or by what the gateway offers for this model.
11
+ const format = resolveFormat({ baseUrl, model, apiFormat: config.apiFormat });
12
+ // The reasoning level chosen with /effort, translated for this model and this API.
13
+ const { params } = effectiveReasoning({ ...config, model, baseUrl });
14
+ // Images are sent unless the catalogue says the model takes text only.
15
+ const options = { price: { provider: config.provider, prices: config.prices }, images: modelAcceptsImages(model) !== false };
16
+ if (format === 'anthropic') {
17
+ return new AnthropicProvider(baseUrl, apiKey || process.env.ANTHROPIC_API_KEY || '', model, params ?? undefined, options);
18
+ }
19
+ return new OpenAIProvider(baseUrl, apiKey, model, params ?? undefined, options);
20
+ }
21
+ /** Which format the provider for this config speaks (for the status line and notices). */
22
+ export function providerFormat(config) {
23
+ return resolveFormat({ baseUrl: config.baseUrl || 'https://api.openai.com/v1', model: config.model || 'gpt-4o-mini', apiFormat: config.apiFormat });
7
24
  }