termux-dev 1.8.2 → 1.9.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 (51) hide show
  1. package/README.md +16 -6
  2. package/assets/banner.svg +1 -1
  3. package/assets/preview.svg +1 -1
  4. package/dist/cli/background-ui.js +8 -8
  5. package/dist/cli/files.js +2 -12
  6. package/dist/cli/finder.js +10 -2
  7. package/dist/cli/headless.js +3 -2
  8. package/dist/cli/index.js +131 -75
  9. package/dist/cli/keys.js +12 -3
  10. package/dist/cli/liveblock.js +47 -9
  11. package/dist/cli/mcp-panel.js +2 -0
  12. package/dist/cli/picker.js +2 -0
  13. package/dist/cli/prompt.js +54 -7
  14. package/dist/cli/provider-manager.js +197 -0
  15. package/dist/cli/providers.js +0 -5
  16. package/dist/cli/settings.js +38 -17
  17. package/dist/cli/typeahead.js +10 -17
  18. package/dist/cli/ui.js +33 -13
  19. package/dist/cli/update-screen.js +383 -0
  20. package/dist/cli/updater.js +119 -145
  21. package/dist/cli/usage-panel.js +17 -4
  22. package/dist/cli/width.js +18 -0
  23. package/dist/core/background.js +14 -0
  24. package/dist/core/context-stats.js +1 -0
  25. package/dist/core/cost.js +25 -11
  26. package/dist/core/custom-providers.js +161 -0
  27. package/dist/core/images.js +46 -0
  28. package/dist/core/local-config.js +3 -1
  29. package/dist/core/locales/de.js +69 -7
  30. package/dist/core/locales/en.js +69 -7
  31. package/dist/core/locales/es.js +69 -7
  32. package/dist/core/locales/fr.js +69 -7
  33. package/dist/core/locales/ja.js +69 -7
  34. package/dist/core/locales/pt.js +69 -7
  35. package/dist/core/locales/ru.js +69 -7
  36. package/dist/core/locales/uk.js +69 -7
  37. package/dist/core/locales/zh.js +69 -7
  38. package/dist/core/loop.js +32 -1
  39. package/dist/core/prices.js +8 -4
  40. package/dist/core/session-media.js +115 -0
  41. package/dist/core/session.js +17 -2
  42. package/dist/core/title.js +19 -2
  43. package/dist/core/types.js +1 -1
  44. package/dist/core/usage.js +41 -4
  45. package/dist/permissions/guard.js +3 -0
  46. package/dist/providers/anthropic.js +67 -13
  47. package/dist/providers/index.js +16 -3
  48. package/dist/providers/openai.js +99 -41
  49. package/dist/providers/shared.js +83 -0
  50. package/dist/tools/fs.js +32 -2
  51. package/package.json +1 -1
@@ -1,13 +1,11 @@
1
- import { execSync, spawn } from 'child_process';
1
+ import { spawn } from 'child_process';
2
2
  import path from 'path';
3
3
  import fs from 'fs/promises';
4
- import pc from 'picocolors';
5
- import { select } from '@inquirer/prompts';
6
- import { palette } from './ui.js';
7
4
  import { fileURLToPath } from 'url';
8
5
  const GITHUB_REPO_URL = 'https://github.com/apvcode/Termux-Dev';
9
- const REMOTE_PKG_URL = 'https://raw.githubusercontent.com/apvcode/Termux-Dev/main/package.json';
10
- const REMOTE_RELEASE_URL = 'https://api.github.com/repos/apvcode/Termux-Dev/releases/latest';
6
+ // Where to look for the newest version. Overridable (a mirror, a test server).
7
+ const REMOTE_PKG_URL = process.env.DEVX_UPDATE_PKG_URL || 'https://raw.githubusercontent.com/apvcode/Termux-Dev/main/package.json';
8
+ const REMOTE_RELEASE_URL = process.env.DEVX_UPDATE_RELEASE_URL || 'https://api.github.com/repos/apvcode/Termux-Dev/releases/latest';
11
9
  // Compare semantic versions (e.g. 1.1.0 > 1.0.0)
12
10
  export function isNewerVersion(remote, current) {
13
11
  const clean = (v) => v.replace(/^v/, '').trim();
@@ -54,6 +52,7 @@ export async function checkForUpdates(timeoutMs = 10000) {
54
52
  });
55
53
  let latestVersion = '';
56
54
  let releaseNotes = '';
55
+ let publishedAt = '';
57
56
  if (res.ok) {
58
57
  const pkg = await res.json();
59
58
  latestVersion = (pkg.version || '').replace(/^v/, '').trim();
@@ -71,6 +70,7 @@ export async function checkForUpdates(timeoutMs = 10000) {
71
70
  latestVersion = relTag;
72
71
  }
73
72
  releaseNotes = relData.body || '';
73
+ publishedAt = relData.published_at || '';
74
74
  }
75
75
  }
76
76
  catch { }
@@ -79,7 +79,8 @@ export async function checkForUpdates(timeoutMs = 10000) {
79
79
  updateAvailable: true,
80
80
  currentVersion,
81
81
  latestVersion,
82
- releaseNotes
82
+ releaseNotes,
83
+ publishedAt: publishedAt || undefined
83
84
  };
84
85
  }
85
86
  return {
@@ -98,163 +99,136 @@ export async function checkForUpdates(timeoutMs = 10000) {
98
99
  };
99
100
  }
100
101
  }
101
- async function isGitRepository(dir) {
102
+ /** How this devx was installed: from the npm package, or from a git clone (a developer's copy). */
103
+ export async function detectInstallMethod() {
104
+ const here = fileURLToPath(import.meta.url);
105
+ const root = path.resolve(path.dirname(here), '../../');
106
+ // DEVX_UPDATE_METHOD forces one of them (a copy in a git folder that is installed with npm, a test).
107
+ const forced = (process.env.DEVX_UPDATE_METHOD || '').toLowerCase();
108
+ if (forced === 'npm' || forced === 'git')
109
+ return { method: forced, root };
102
110
  try {
103
- await fs.stat(path.join(dir, '.git'));
104
- return true;
111
+ await fs.stat(path.join(root, '.git'));
112
+ return { method: 'git', root };
105
113
  }
106
114
  catch {
107
- return false;
115
+ return { method: 'npm', root };
108
116
  }
109
117
  }
110
- export async function performSelfUpdate(latestVersion) {
111
- const __filename = fileURLToPath(import.meta.url);
112
- const projectRoot = path.resolve(path.dirname(__filename), '../../');
113
- const isGit = await isGitRepository(projectRoot);
114
- console.log('\n' + pc.bold(pc.cyan('─── Starting devx Update ──────────────────────────')));
115
- if (!isGit) {
116
- // 1. Installed via global npm package: termux-dev
117
- process.stdout.write(` ${pc.cyan('›')} [1/1] ${pc.white(`Updating termux-dev via npm (v${latestVersion})...`)}\n`);
118
- const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
119
- let fIdx = 0;
120
- const interval = setInterval(() => {
121
- process.stdout.write(`\r ${pc.cyan(frames[fIdx++ % frames.length])} ${pc.dim(`npm install -g termux-dev@latest`)} `);
122
- }, 80);
123
- try {
124
- execSync('npm install -g termux-dev@latest --prefer-online', { stdio: 'pipe' });
125
- clearInterval(interval);
126
- process.stdout.write(`\r ${pc.green('✓')} ${pc.bold(pc.white(`Successfully updated termux-dev to v${latestVersion} via npm!`))}\n`);
127
- }
128
- catch (err) {
129
- clearInterval(interval);
130
- process.stdout.write(`\r ${pc.red('✖')} ${pc.red(`Update failed: ${err.message}`)}\n`);
131
- console.log(pc.yellow(`\nTo update manually, run: ${pc.bold('npm install -g termux-dev@latest')}`));
132
- return false;
133
- }
118
+ /** What a step of the update says when it fails. `not-published`: the version is on GitHub but not in npm yet. */
119
+ export class UpdateError extends Error {
120
+ kind;
121
+ constructor(message, kind = 'failed') {
122
+ super(message);
123
+ this.kind = kind;
124
+ this.name = 'UpdateError';
134
125
  }
135
- else {
136
- // 2. Installed via git clone (Developer mode)
137
- const steps = [
126
+ }
127
+ const OUTPUT_KEEP = 4000;
128
+ export const runCommand = (command, args, options = {}) => new Promise((resolve) => {
129
+ let output = '';
130
+ const keep = (chunk) => {
131
+ output = (output + chunk.toString('utf8')).slice(-OUTPUT_KEEP);
132
+ };
133
+ // On Windows npm is a .cmd file, which only a shell can start.
134
+ const child = spawn(command, args, { cwd: options.cwd, shell: process.platform === 'win32', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
135
+ child.stdout?.on('data', keep);
136
+ child.stderr?.on('data', keep);
137
+ child.on('error', (err) => resolve({ code: -1, output: output + String(err.message) }));
138
+ child.on('close', (code) => resolve({ code: code ?? -1, output }));
139
+ });
140
+ /** The last lines of a command's output, for the message of a failure. */
141
+ export function tail(output, lines = 4) {
142
+ return output
143
+ .split('\n')
144
+ .map((l) => l.trim())
145
+ .filter(Boolean)
146
+ .slice(-lines)
147
+ .join('\n');
148
+ }
149
+ function must(result, what) {
150
+ if (result.code !== 0)
151
+ throw new UpdateError(tail(result.output) || `${what} failed (exit code ${result.code})`);
152
+ return result;
153
+ }
154
+ /**
155
+ * The steps of an update, in order. Nothing is run until `run()` of a step is called.
156
+ *
157
+ * npm: ask the registry whether the version is there (GitHub knows a release before npm has the
158
+ * package: installing `latest` then would "update" to the old version), install exactly that
159
+ * version, and read back which version is installed.
160
+ * git: pull, install and build, link the command.
161
+ */
162
+ export function buildUpdateSteps(method, root, version, run = runCommand) {
163
+ if (method === 'npm') {
164
+ return [
138
165
  {
139
- title: 'Pulling latest updates from GitHub repository...',
140
- action: () => {
141
- execSync('git pull origin main', { cwd: projectRoot, stdio: 'pipe' });
142
- }
166
+ id: 'registry',
167
+ run: async () => {
168
+ const res = await run('npm', ['view', `termux-dev@${version}`, 'version', '--prefer-online']);
169
+ if (res.code !== 0) {
170
+ // A version that is not in the registry is an E404, not an older answer.
171
+ if (/E404|No match found/i.test(res.output))
172
+ throw new UpdateError(`termux-dev@${version} is not in the npm registry yet`, 'not-published');
173
+ throw new UpdateError(tail(res.output) || `npm view failed (exit code ${res.code})`);
174
+ }
175
+ if (res.output.trim().split('\n').pop()?.trim() !== version) {
176
+ throw new UpdateError(`termux-dev@${version} is not in the npm registry yet`, 'not-published');
177
+ }
178
+ },
143
179
  },
144
180
  {
145
- title: 'Building and compiling TypeScript sources...',
146
- action: () => {
147
- execSync('npm install', { cwd: projectRoot, stdio: 'pipe' });
148
- execSync('npm run build', { cwd: projectRoot, stdio: 'pipe' });
149
- }
181
+ id: 'install',
182
+ run: async () => {
183
+ must(await run('npm', ['install', '-g', `termux-dev@${version}`, '--prefer-online']), 'npm install');
184
+ },
150
185
  },
151
186
  {
152
- title: 'Ensuring binary execution permissions & linking...',
153
- action: () => {
187
+ id: 'verify',
188
+ run: async () => {
189
+ const res = await run('npm', ['ls', '-g', 'termux-dev', '--depth=0', '--json']);
190
+ let installed = '';
154
191
  try {
155
- execSync('npm link', { cwd: projectRoot, stdio: 'pipe' });
192
+ installed = JSON.parse(res.output.slice(res.output.indexOf('{')))?.dependencies?.['termux-dev']?.version ?? '';
156
193
  }
157
194
  catch { }
158
- }
159
- }
195
+ if (installed !== version)
196
+ throw new UpdateError(`npm reports ${installed || 'no version'} instead of ${version}`);
197
+ },
198
+ },
160
199
  ];
161
- for (let i = 0; i < steps.length; i++) {
162
- const step = steps[i];
163
- process.stdout.write(` ${pc.cyan('›')} [${i + 1}/${steps.length}] ${pc.white(step.title)}\n`);
164
- const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
165
- let fIdx = 0;
166
- const interval = setInterval(() => {
167
- process.stdout.write(`\r ${pc.cyan(frames[fIdx++ % frames.length])} ${pc.dim(step.title)} `);
168
- }, 80);
169
- try {
170
- await new Promise(resolve => setTimeout(resolve, 200));
171
- step.action();
172
- clearInterval(interval);
173
- process.stdout.write(`\r ${pc.green('✓')} ${pc.bold(pc.white(step.title))}\n`);
174
- }
175
- catch (err) {
176
- clearInterval(interval);
177
- process.stdout.write(`\r ${pc.red('✖')} ${pc.red(step.title)} - ${err.message}\n`);
178
- console.log(pc.red(`\nUpdate failed during step ${i + 1}: ${err.message}`));
179
- console.log(pc.yellow(`\nTo update manually, run: ${pc.bold('npm install -g termux-dev')}`));
180
- return false;
181
- }
182
- }
183
- }
184
- console.log(pc.bold(pc.green(`\nSuccessfully updated to v${latestVersion}! Restarting devx...\n`)));
185
- console.log(pc.bold(pc.cyan('──────────────────────────────────────────────────────\n')));
186
- await new Promise(r => setTimeout(r, 1000));
187
- try {
188
- const child = spawn(process.argv[0], process.argv.slice(1), {
189
- stdio: 'inherit',
190
- detached: true
191
- });
192
- child.unref();
193
- process.exit(0);
194
- }
195
- catch {
196
- process.exit(0);
197
200
  }
198
- return true;
201
+ return [
202
+ { id: 'pull', run: async () => void must(await run('git', ['pull', '--ff-only', 'origin', 'main'], { cwd: root }), 'git pull') },
203
+ {
204
+ id: 'build',
205
+ run: async () => {
206
+ must(await run('npm', ['install'], { cwd: root }), 'npm install');
207
+ must(await run('npm', ['run', 'build'], { cwd: root }), 'npm run build');
208
+ },
209
+ },
210
+ // Linking is a convenience: a failure here does not undo the update.
211
+ { id: 'link', run: async () => void (await run('npm', ['link'], { cwd: root })) },
212
+ ];
199
213
  }
200
- export async function runStartupUpdateCheck(config) {
201
- // If updates checking is disabled in settings, return immediately
202
- if (config.checkUpdates === false) {
203
- return;
204
- }
205
- const currentVersion = await getCurrentVersion();
206
- const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
207
- let fIdx = 0;
208
- let isDone = false;
209
- const spinner = setInterval(() => {
210
- if (isDone)
211
- return;
212
- // The colours of the theme, like the rest of the interface.
213
- const frame = palette.accent(frames[fIdx++ % frames.length]);
214
- process.stdout.write(`\r ${frame} ${palette.muted('Checking for updates from')} ${palette.accent('apvcode/Termux-Dev')} ${palette.faint(`(v${currentVersion})...`)} `);
215
- }, 75);
216
- const result = await checkForUpdates(10000);
217
- isDone = true;
218
- clearInterval(spinner);
219
- // Clear spinner line
220
- process.stdout.write('\r\x1b[K');
221
- if (result.updateAvailable) {
222
- console.log();
223
- console.log(palette.rule(' ┌───────────────────────────────────────────────────────────┐'));
224
- console.log(palette.rule(' │') + palette.bold(palette.accent(` A new update is available: `)) + palette.muted(`v${result.currentVersion}`) + palette.bold(palette.ok(` → v${result.latestVersion}`)) + ' '.repeat(Math.max(1, 23 - result.currentVersion.length - result.latestVersion.length)) + palette.rule('│'));
225
- console.log(palette.rule(' │') + palette.faint(` Repository: https://github.com/apvcode/Termux-Dev `) + palette.rule('│'));
226
- console.log(palette.rule(' └───────────────────────────────────────────────────────────┘'));
227
- console.log();
214
+ /** Runs the steps one after the other and stops at the first that fails. */
215
+ export async function runUpdateSteps(steps, events = {}, now = Date.now) {
216
+ for (let index = 0; index < steps.length; index++) {
217
+ events.onStart?.(index);
218
+ const started = now();
228
219
  try {
229
- const choice = await select({
230
- message: palette.bold('Do you want to update devx now?'),
231
- loop: false,
232
- choices: [
233
- {
234
- name: `Update now (Pull v${result.latestVersion}, rebuild & restart)`,
235
- value: 'update',
236
- description: 'Download latest code, compile TypeScript, and restart devx'
237
- },
238
- {
239
- name: 'Skip for now (Continue to chat)',
240
- value: 'skip',
241
- description: 'Keep current version and start session'
242
- }
243
- ]
244
- });
245
- if (choice === 'update') {
246
- await performSelfUpdate(result.latestVersion);
247
- }
220
+ await steps[index].run();
248
221
  }
249
- catch {
250
- // If user presses Ctrl+C / cancels prompt, continue
222
+ catch (err) {
223
+ return { ok: false, index, error: err instanceof UpdateError ? err : new UpdateError(String(err?.message || err)) };
251
224
  }
225
+ events.onDone?.(index, now() - started);
252
226
  }
253
- else {
254
- // Briefly show up to date message
255
- process.stdout.write(` ${palette.ok('✓')} ${palette.muted(`devx is up to date (v${result.currentVersion})`)}\n`);
256
- await new Promise(r => setTimeout(r, 450));
257
- // Clean up line
258
- process.stdout.write('\x1b[1A\x1b[2K');
259
- }
227
+ return { ok: true };
228
+ }
229
+ /** Starts devx again, with the same arguments, and ends this process when the new one ends. */
230
+ export function restartDevx() {
231
+ const child = spawn(process.argv[0], process.argv.slice(1), { stdio: 'inherit' });
232
+ child.on('exit', (code) => process.exit(code ?? 0));
233
+ child.on('error', () => process.exit(0));
260
234
  }
@@ -128,10 +128,23 @@ function buildRows(d, iw, detail) {
128
128
  const note = costNote(s);
129
129
  if (note && detail === 'full')
130
130
  line(' ' + palette.faint(truncateWidth(note, iw - 2)));
131
+ if (s.restoredCost && detail === 'full') {
132
+ line(' ' + palette.faint(truncateWidth(`Includes ${formatMoney(s.restoredCost, { estimated: s.costState.estimated })} from before this chat was resumed; the lines below count only since devx started.`, iw - 2)));
133
+ }
131
134
  const tokens = iw >= 40 ? `${fmtTokens(s.promptTokens)} in · ${fmtTokens(s.completionTokens)} out` : `${fmtTokens(s.promptTokens)}/${fmtTokens(s.completionTokens)}`;
132
135
  pair('Tokens', palette.text(tokens));
133
- if (s.cacheReadTokens > 0 && detail !== 'min')
134
- pair('Cached input', palette.text(fmtTokens(s.cacheReadTokens)));
136
+ if (detail !== 'min') {
137
+ // What the money was paid for. Every request resends the whole conversation, so the input
138
+ // is usually the biggest part, and the cache is what makes it cheaper.
139
+ const money = (v) => s.costParts && v !== undefined && iw >= 40 ? palette.faint(' ' + formatMoney(v, { estimated: s.costState.estimated })) : '';
140
+ const paid = (label, tokens, v) => pair(label, palette.text(fmtTokens(tokens)) + money(v));
141
+ paid(' Input', s.uncachedTokens, s.costParts?.input);
142
+ if (s.cacheReadTokens > 0)
143
+ paid(' Cached input', s.cacheReadTokens, s.costParts?.cacheRead);
144
+ if (s.cacheWriteTokens > 0)
145
+ paid(' Cache writes', s.cacheWriteTokens, s.costParts?.cacheWrite);
146
+ paid(' Output', s.completionTokens, s.costParts?.output);
147
+ }
135
148
  pair('Requests', palette.text(String(s.requestsCount)));
136
149
  // ── traffic
137
150
  if (detail === 'full') {
@@ -221,7 +234,7 @@ export function runUsagePanel(data, options = {}) {
221
234
  return;
222
235
  closed = true;
223
236
  input.off('data', onData);
224
- output.off?.('resize', draw);
237
+ stopWatching();
225
238
  block.erase();
226
239
  if (!wasRaw)
227
240
  input.setRawMode?.(false);
@@ -236,7 +249,7 @@ export function runUsagePanel(data, options = {}) {
236
249
  }
237
250
  };
238
251
  input.on('data', onData);
239
- output.on?.('resize', draw);
252
+ const stopWatching = block.watchResize(draw);
240
253
  draw();
241
254
  });
242
255
  }
package/dist/cli/width.js CHANGED
@@ -176,3 +176,21 @@ export function wrapText(text, width) {
176
176
  const unbreakable = text.split(/\s+/).some((word) => displayWidth(word) > width);
177
177
  return unbreakable ? wrapWidth(text, width) : wrapWords(text, width);
178
178
  }
179
+ /**
180
+ * How many rows up the top of a block is from the cursor, once the terminal is `cols` wide.
181
+ *
182
+ * A block drawn at one width is re-wrapped by the terminal when the window is resized: a line that
183
+ * was as wide as the old window now takes several rows, so counting the lines of the block is not
184
+ * enough to get back to its top (the top stays on screen and the block shows up twice).
185
+ *
186
+ * @param lineWidths visible width of each line of the block as it was drawn
187
+ * @param cursorRow the line the cursor is on
188
+ * @param cursorCol the column of the cursor in that line
189
+ */
190
+ export function rowsToTop(lineWidths, cursorRow, cursorCol, cols) {
191
+ const width = Math.max(1, cols);
192
+ let rows = 0;
193
+ for (let i = 0; i < cursorRow && i < lineWidths.length; i++)
194
+ rows += Math.max(1, Math.ceil(lineWidths[i] / width));
195
+ return rows + Math.floor(Math.max(0, cursorCol) / width);
196
+ }
@@ -236,8 +236,22 @@ export class BackgroundManager extends EventEmitter {
236
236
  return '';
237
237
  const text = this.notes.map((n) => n.text).join('\n\n');
238
238
  this.notes = [];
239
+ // What the prompt frame shows about unreported news is gone now: let it redraw.
240
+ this.emit('change');
239
241
  return `[devx · background tasks]\n${text}`;
240
242
  }
243
+ /**
244
+ * Tasks that ended badly and whose news the model has not received yet. The prompt frame shows
245
+ * this number: it goes away once devx has told the model about the failure.
246
+ */
247
+ unreportedFailures() {
248
+ const ids = new Set();
249
+ for (const note of this.notes) {
250
+ if (note.kind === 'exit' && this.tasks.get(note.taskId)?.info.status === 'failed')
251
+ ids.add(note.taskId);
252
+ }
253
+ return ids.size;
254
+ }
241
255
  // ───────────────────────── internals ─────────────────────────
242
256
  must(id) {
243
257
  const task = this.tasks.get(String(id));
@@ -23,6 +23,7 @@ export function contextBreakdown(messages, definitions, limit) {
23
23
  user, assistant, toolResults,
24
24
  images, imageCount,
25
25
  total,
26
+ inUse: messages.some((m) => m.role !== 'system') ? total : 0,
26
27
  compactAt: Math.floor(limit * CONTEXT_USAGE_RATIO),
27
28
  free: Math.max(0, limit - total),
28
29
  };
package/dist/core/cost.js CHANGED
@@ -12,8 +12,8 @@
12
12
  */
13
13
  const PER_MILLION = 1_000_000;
14
14
  const finite = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
15
- /** The cost of the tokens at the given rates. */
16
- export function computeCost(rates, usage) {
15
+ /** The cost of the tokens at the given rates, split by what was paid for. */
16
+ export function costParts(rates, usage) {
17
17
  const prompt = Math.max(0, usage.promptTokens || 0);
18
18
  const tier = rates.tier && prompt > rates.tier.over ? rates.tier : null;
19
19
  const input = tier?.input ?? rates.input;
@@ -23,17 +23,31 @@ export function computeCost(rates, usage) {
23
23
  const read = Math.min(prompt, Math.max(0, usage.cacheReadTokens || 0));
24
24
  const written = Math.min(prompt - read, Math.max(0, usage.cacheWriteTokens || 0));
25
25
  const plain = prompt - read - written;
26
- // Without a cache price the cached tokens cost as much as ordinary input.
27
- let cost = plain * input + read * (cacheRead ?? input) + written * (cacheWrite ?? input);
28
26
  const completion = Math.max(0, usage.completionTokens || 0);
29
27
  const reasoning = Math.min(completion, Math.max(0, usage.reasoningTokens || 0));
30
- if (rates.reasoning !== undefined && reasoning > 0) {
31
- cost += (completion - reasoning) * output + reasoning * rates.reasoning;
32
- }
33
- else {
34
- cost += completion * output;
35
- }
36
- return cost / PER_MILLION;
28
+ const out = rates.reasoning !== undefined && reasoning > 0
29
+ ? (completion - reasoning) * output + reasoning * rates.reasoning
30
+ : completion * output;
31
+ // Without a cache price the cached tokens cost as much as ordinary input.
32
+ return {
33
+ input: (plain * input) / PER_MILLION,
34
+ cacheRead: (read * (cacheRead ?? input)) / PER_MILLION,
35
+ cacheWrite: (written * (cacheWrite ?? input)) / PER_MILLION,
36
+ output: out / PER_MILLION,
37
+ };
38
+ }
39
+ /** The cost of the tokens at the given rates. */
40
+ export function computeCost(rates, usage) {
41
+ const p = costParts(rates, usage);
42
+ return p.input + p.cacheRead + p.cacheWrite + p.output;
43
+ }
44
+ /** The same parts scaled so that they add up to `total` (the cost the provider reported). */
45
+ export function scaleParts(parts, total) {
46
+ const sum = parts.input + parts.cacheRead + parts.cacheWrite + parts.output;
47
+ if (!(sum > 0) || !Number.isFinite(total))
48
+ return undefined;
49
+ const k = total / sum;
50
+ return { input: parts.input * k, cacheRead: parts.cacheRead * k, cacheWrite: parts.cacheWrite * k, output: parts.output * k };
37
51
  }
38
52
  /** A price as the OpenRouter list writes it: USD per token, often a string. */
39
53
  function perToken(value) {
@@ -0,0 +1,161 @@
1
+ import { isLocalBaseUrl } from './cost.js';
2
+ /** The key stored for a server that needs none (a local one): the OpenAI client wants some value and falls back to $OPENAI_API_KEY for an empty one. */
3
+ export const NO_KEY = 'no-key';
4
+ /** The id the old single "Custom" entry of the provider list had. */
5
+ const LEGACY_ID = 'custom';
6
+ const MAX_NAME = 40;
7
+ export function customProviders(config) {
8
+ const own = config.customProviders;
9
+ return own && typeof own === 'object' && !Array.isArray(own) ? own : {};
10
+ }
11
+ /** The saved providers, in the order they were added. */
12
+ export function listCustomProviders(config) {
13
+ return Object.entries(customProviders(config))
14
+ .filter(([, p]) => p && typeof p.baseUrl === 'string')
15
+ .map(([id, p]) => ({ id, name: String(p.name || id), baseUrl: p.baseUrl, apiKey: config.apiKeys?.[id] ?? '' }));
16
+ }
17
+ export function isCustomProvider(config, id) {
18
+ return !!id && !!customProviders(config)[id];
19
+ }
20
+ /** The name to show for a provider id: the user's name for a custom one, the label of a listed one, else the id. */
21
+ export function providerDisplayName(config, id, listed = []) {
22
+ if (!id)
23
+ return '';
24
+ const own = customProviders(config)[id];
25
+ if (own?.name)
26
+ return own.name;
27
+ return listed.find((p) => p.value === id)?.label ?? id;
28
+ }
29
+ /**
30
+ * The single "Custom" entry older versions had becomes a saved provider named Custom, so that
31
+ * whoever used it finds it in the list (with its address and key). Returns true when it did.
32
+ */
33
+ export function migrateLegacyCustom(config) {
34
+ if (customProviders(config)[LEGACY_ID])
35
+ return false;
36
+ const url = config.baseUrls?.[LEGACY_ID] || (config.provider === LEGACY_ID ? config.baseUrl : '');
37
+ if (!url)
38
+ return false;
39
+ config.customProviders = { ...customProviders(config), [LEGACY_ID]: { name: 'Custom', baseUrl: url } };
40
+ return true;
41
+ }
42
+ /** Is this name usable? A name must be unique (without regard to case), but a provider may keep its own. */
43
+ export function nameProblem(config, name, exceptId) {
44
+ const clean = name.trim();
45
+ if (!clean)
46
+ return 'empty';
47
+ if (Array.from(clean).length > MAX_NAME)
48
+ return 'long';
49
+ const taken = listCustomProviders(config).some((p) => p.id !== exceptId && p.name.trim().toLowerCase() === clean.toLowerCase());
50
+ return taken ? 'taken' : null;
51
+ }
52
+ /** An address of an API: http(s), with a host. The path stays as typed (`/v1`), only a trailing slash goes. */
53
+ export function checkBaseUrl(input) {
54
+ const text = input.trim();
55
+ if (!text)
56
+ return { ok: false, reason: 'empty' };
57
+ let url;
58
+ try {
59
+ url = new URL(text);
60
+ }
61
+ catch {
62
+ return { ok: false, reason: 'invalid' };
63
+ }
64
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
65
+ return { ok: false, reason: 'scheme' };
66
+ if (!url.hostname)
67
+ return { ok: false, reason: 'invalid' };
68
+ return { ok: true, url: text.replace(/\/+$/, '') };
69
+ }
70
+ /** A server on this machine or on the home network usually needs no key. */
71
+ export function usuallyNeedsNoKey(baseUrl) {
72
+ return isLocalBaseUrl(baseUrl);
73
+ }
74
+ /** `api.example.com` for the list. */
75
+ export const hostOfUrl = (url) => url.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
76
+ /** The key as the list shows it. */
77
+ export function keyHint(key) {
78
+ if (!key)
79
+ return '';
80
+ if (key === NO_KEY)
81
+ return 'no key';
82
+ if (key.length <= 8)
83
+ return '****';
84
+ return `${key.slice(0, 4)}...${key.slice(-4)}`;
85
+ }
86
+ // ───────────────────────── changing the saved providers ─────────────────────────
87
+ /** An id from the name that no listed and no saved provider has: `custom-my-gateway`, `custom-my-gateway-2`. */
88
+ export function newProviderId(config, name, listedIds = []) {
89
+ const slug = name
90
+ .toLowerCase()
91
+ .normalize('NFC')
92
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
93
+ .replace(/^-+|-+$/g, '')
94
+ .slice(0, 30) || 'provider';
95
+ const taken = new Set([...listedIds, ...Object.keys(customProviders(config))]);
96
+ let id = `custom-${slug}`;
97
+ for (let n = 2; taken.has(id); n++)
98
+ id = `custom-${slug}-${n}`;
99
+ return id;
100
+ }
101
+ function ensureMaps(config) {
102
+ const own = config.customProviders;
103
+ if (!own || typeof own !== 'object' || Array.isArray(own))
104
+ config.customProviders = {};
105
+ config.apiKeys = config.apiKeys || {};
106
+ config.baseUrls = config.baseUrls || {};
107
+ }
108
+ /** Adds a provider and returns its id. The name and address are expected to have been checked. */
109
+ export function addCustomProvider(config, input, listedIds = []) {
110
+ ensureMaps(config);
111
+ const id = newProviderId(config, input.name, listedIds);
112
+ config.customProviders[id] = { name: input.name.trim(), baseUrl: input.baseUrl };
113
+ config.baseUrls[id] = input.baseUrl;
114
+ config.apiKeys[id] = input.apiKey;
115
+ return id;
116
+ }
117
+ /**
118
+ * Changes the name, the address or the key of a saved provider. When it is the provider in use,
119
+ * the settings of the running session follow at once.
120
+ */
121
+ export function updateCustomProvider(config, id, patch) {
122
+ const own = customProviders(config)[id];
123
+ if (!own)
124
+ return false;
125
+ ensureMaps(config);
126
+ if (patch.name !== undefined)
127
+ config.customProviders[id] = { ...config.customProviders[id], name: patch.name.trim() };
128
+ if (patch.baseUrl !== undefined) {
129
+ config.customProviders[id] = { ...config.customProviders[id], baseUrl: patch.baseUrl };
130
+ config.baseUrls[id] = patch.baseUrl;
131
+ if (config.provider === id)
132
+ config.baseUrl = patch.baseUrl;
133
+ }
134
+ if (patch.apiKey !== undefined) {
135
+ config.apiKeys[id] = patch.apiKey;
136
+ if (config.provider === id)
137
+ config.apiKey = patch.apiKey;
138
+ }
139
+ return true;
140
+ }
141
+ /**
142
+ * Forgets a saved provider with its key. The provider in use keeps working until another is chosen
143
+ * (its address and key stay in the running settings) but it is no longer a provider of the list:
144
+ * the config forgets its id, so that saving does not write the key and the address back;
145
+ * `wasActive` tells the caller to say so.
146
+ */
147
+ export function removeCustomProvider(config, id) {
148
+ if (!customProviders(config)[id])
149
+ return { removed: false, wasActive: false };
150
+ const rest = { ...config.customProviders };
151
+ delete rest[id];
152
+ config.customProviders = rest;
153
+ if (config.apiKeys)
154
+ delete config.apiKeys[id];
155
+ if (config.baseUrls)
156
+ delete config.baseUrls[id];
157
+ const wasActive = config.provider === id;
158
+ if (wasActive)
159
+ delete config.provider;
160
+ return { removed: true, wasActive };
161
+ }