mouaif 0.3.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 (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. package/src/util.js +151 -0
package/src/usage.js ADDED
@@ -0,0 +1,389 @@
1
+ 'use strict';
2
+
3
+ // Usage metrics — cost and live token speed for chat turns.
4
+ //
5
+ // Decision: docs/decisions.md §14. The AI client (src/ai.js) already
6
+ // emits `usage_input`, `usage_output`, and `done({ usage })` events on
7
+ // every chat. This module is a pure derivation layer: it consumes those
8
+ // numbers, the model record, and the resolved app-level pricing table
9
+ // and produces:
10
+ //
11
+ // - cost: { input, output, total, currency, known }
12
+ // - per-turn rate: tokens per second over the streaming window
13
+ // - formatted strings ready to drop into the chat UI
14
+ //
15
+ // No new SSE events, no new REST surface, no new runtime dependencies.
16
+ // The chat UI (frontend/src/components/chat/Chat.jsx) imports formatCost /
17
+ // formatTokPerSecond; the Settings UI lets the user edit the
18
+ // app-level pricing table through the existing /api/settings/app
19
+ // surface.
20
+
21
+ // ---- Built-in pricing table -------------------------------------------
22
+ //
23
+ // Best-effort defaults for model ids that the providers ship today.
24
+ // Adding a new model id is a one-line edit. The cost line falls back
25
+ // to `--` when the id is unknown; we deliberately do not hallucinate
26
+ // a price. The values are in USD per 1 000 tokens (input / output).
27
+ //
28
+ // Sourced from each provider's public pricing page at the time of
29
+ // this commit. They are best-effort and a future revision may add
30
+ // a "last verified" stamp.
31
+ const BUILTIN_PRICING = Object.freeze({
32
+ // OpenAI (https://openai.com/api/pricing/, as of mid-2026)
33
+ 'gpt-4o': { inputPer1K: 0.00250, outputPer1K: 0.01000 },
34
+ 'gpt-4o-mini': { inputPer1K: 0.00015, outputPer1K: 0.00060 },
35
+ 'gpt-4.1': { inputPer1K: 0.00200, outputPer1K: 0.00800 },
36
+ 'gpt-4.1-mini': { inputPer1K: 0.00040, outputPer1K: 0.00160 },
37
+ 'gpt-4.1-nano': { inputPer1K: 0.00010, outputPer1K: 0.00040 },
38
+ 'o3': { inputPer1K: 0.01000, outputPer1K: 0.04000 },
39
+ 'o3-mini': { inputPer1K: 0.00110, outputPer1K: 0.00440 },
40
+ 'o4-mini': { inputPer1K: 0.00110, outputPer1K: 0.00440 },
41
+ 'gpt-5': { inputPer1K: 0.00125, outputPer1K: 0.01000 },
42
+ 'gpt-5-mini': { inputPer1K: 0.00025, outputPer1K: 0.00200 },
43
+ 'gpt-5-nano': { inputPer1K: 0.00005, outputPer1K: 0.00040 },
44
+ // Anthropic (https://www.anthropic.com/pricing)
45
+ 'claude-3-5-sonnet-latest':{ inputPer1K: 0.00300, outputPer1K: 0.01500 },
46
+ 'claude-3-5-haiku-latest': { inputPer1K: 0.00080, outputPer1K: 0.00400 },
47
+ 'claude-3-opus-latest': { inputPer1K: 0.01500, outputPer1K: 0.07500 },
48
+ // The dated 3.5 snapshots are the same models as the `-latest` aliases
49
+ // above at the same list price — they ship in ANTHROPIC_MODEL_CATALOG, so
50
+ // leaving them out meant a cost line that read `--` for a model we could
51
+ // price exactly.
52
+ 'claude-3-5-sonnet-20241022': { inputPer1K: 0.00300, outputPer1K: 0.01500 },
53
+ 'claude-3-5-haiku-20241022': { inputPer1K: 0.00080, outputPer1K: 0.00400 },
54
+ 'claude-sonnet-4': { inputPer1K: 0.00300, outputPer1K: 0.01500 },
55
+ 'claude-sonnet-4-5': { inputPer1K: 0.00300, outputPer1K: 0.01500 },
56
+ 'claude-sonnet-4.5': { inputPer1K: 0.00300, outputPer1K: 0.01500 },
57
+ // Newer generation. Verified against the live vendor page
58
+ // (https://www.anthropic.com/pricing), not just the catalog fixture — the
59
+ // fixture had Haiku 4.5 at $0.8/$4, which is Haiku *3.5*'s rate and had
60
+ // been wrong here since this table was written. See
61
+ // scripts/test-model-pricing-coverage.js, which fails when a catalog id
62
+ // has no price here.
63
+ 'claude-sonnet-5': { inputPer1K: 0.00200, outputPer1K: 0.01000 },
64
+ 'claude-opus-4.5': { inputPer1K: 0.00500, outputPer1K: 0.02500 },
65
+ // Haiku 4.5 is $1/$5, not $0.8/$4 — the latter is Haiku 3.5 (the
66
+ // `claude-3-5-haiku-*` rows above), and the two were conflated here.
67
+ 'claude-haiku-4-5': { inputPer1K: 0.00100, outputPer1K: 0.00500 },
68
+ 'claude-haiku-4.5': { inputPer1K: 0.00100, outputPer1K: 0.00500 },
69
+ 'claude-opus-4': { inputPer1K: 0.01500, outputPer1K: 0.07500 },
70
+ 'claude-opus-4-1': { inputPer1K: 0.01500, outputPer1K: 0.07500 },
71
+ 'claude-opus-4.1': { inputPer1K: 0.01500, outputPer1K: 0.07500 },
72
+ // Google Gemini (https://ai.google.dev/pricing)
73
+ 'gemini-2.5-pro': { inputPer1K: 0.00125, outputPer1K: 0.01000 },
74
+ 'gemini-2.5-flash': { inputPer1K: 0.00030, outputPer1K: 0.00250 },
75
+ 'gemini-2.0-flash': { inputPer1K: 0.00010, outputPer1K: 0.00040 },
76
+ 'gemini-1.5-pro': { inputPer1K: 0.00125, outputPer1K: 0.00500 },
77
+ 'gemini-1.5-flash': { inputPer1K: 0.000075, outputPer1K: 0.00030 },
78
+ // Mistral (https://mistral.ai/pricing)
79
+ 'mistral-large-latest': { inputPer1K: 0.00200, outputPer1K: 0.00600 },
80
+ 'mistral-medium-latest': { inputPer1K: 0.00270, outputPer1K: 0.00810 },
81
+ 'mistral-small-latest': { inputPer1K: 0.00020, outputPer1K: 0.00060 },
82
+ 'codestral-latest': { inputPer1K: 0.00030, outputPer1K: 0.00090 },
83
+ 'ministral-3b-latest': { inputPer1K: 0.00004, outputPer1K: 0.00004 },
84
+ 'ministral-8b-latest': { inputPer1K: 0.00010, outputPer1K: 0.00010 },
85
+ 'pixtral-large-latest': { inputPer1K: 0.00200, outputPer1K: 0.00600 },
86
+ // Groq (https://groq.com/pricing)
87
+ 'llama-3.3-70b-versatile': { inputPer1K: 0.00059, outputPer1K: 0.00079 },
88
+ 'llama-3.1-8b-instant': { inputPer1K: 0.00005, outputPer1K: 0.00008 },
89
+ 'llama3-8b-8192': { inputPer1K: 0.00005, outputPer1K: 0.00008 },
90
+ 'llama3-70b-8192': { inputPer1K: 0.00059, outputPer1K: 0.00079 },
91
+ 'mixtral-8x7b-32768': { inputPer1K: 0.00024, outputPer1K: 0.00024 },
92
+ 'gemma2-9b-it': { inputPer1K: 0.00020, outputPer1K: 0.00020 },
93
+ 'qwen-qwq-32b': { inputPer1K: 0.00012, outputPer1K: 0.00018 },
94
+ // DeepSeek (https://api-docs.deepseek.com/quick_start/pricing)
95
+ 'deepseek-chat': { inputPer1K: 0.00027, outputPer1K: 0.00110 },
96
+ 'deepseek-reasoner': { inputPer1K: 0.00055, outputPer1K: 0.00219 },
97
+ 'deepseek-coder': { inputPer1K: 0.00014, outputPer1K: 0.00028 }
98
+ });
99
+
100
+ const DEFAULT_CURRENCY = 'USD';
101
+
102
+ // ---- Pricing resolution ----------------------------------------------
103
+
104
+ // resolvePricing(model, app) -> { inputPer1K, outputPer1K, source } | null
105
+ // Walks the resolution order documented in §14:
106
+ // 1. model.pricing (per-project, most specific)
107
+ // 2. app.modelPricing[modelId] (app-level override)
108
+ // 3. BUILTIN_PRICING[modelId] (best-effort defaults)
109
+ // 4. null (the UI renders `--`)
110
+ //
111
+ // `model` may be the project model record, the resolved model (with
112
+ // provider hydrated in), or just `{ id }` — only `id` and `pricing`
113
+ // are read.
114
+ // `app` is the app-level settings object (settings.getApp()).
115
+ function resolvePricing(model, app) {
116
+ if (!model || typeof model !== 'object' || !model.id) return null;
117
+ const id = model.id;
118
+ const fromModel = pickPricing(model.pricing);
119
+ if (fromModel) return Object.assign({ source: 'model' }, fromModel);
120
+ const appPricing = app && app.modelPricing;
121
+ if (appPricing && typeof appPricing === 'object') {
122
+ const fromApp = pickPricing(appPricing[id]);
123
+ if (fromApp) return Object.assign({ source: 'app' }, fromApp);
124
+ }
125
+ const fromBuiltIn = builtinPricingForId(id);
126
+ if (fromBuiltIn) return Object.assign({ source: 'builtin' }, fromBuiltIn);
127
+ return null;
128
+ }
129
+
130
+ function builtinPricingForId(id) {
131
+ if (!id) return null;
132
+ if (BUILTIN_PRICING[id]) return BUILTIN_PRICING[id];
133
+ // OpenRouter model ids are vendor-prefixed (e.g. openai/gpt-5-mini,
134
+ // anthropic/claude-sonnet-4.5). Reuse the built-in vendor price when
135
+ // the suffix exactly matches a known native model id.
136
+ const slash = String(id).lastIndexOf('/');
137
+ if (slash >= 0) {
138
+ const suffix = String(id).slice(slash + 1);
139
+ if (BUILTIN_PRICING[suffix]) return BUILTIN_PRICING[suffix];
140
+ }
141
+ return null;
142
+ }
143
+
144
+ function pickPricing(raw) {
145
+ if (!raw || typeof raw !== 'object') return null;
146
+ const input = numberOrNull(raw.inputPer1K);
147
+ const output = numberOrNull(raw.outputPer1K);
148
+ if (input == null && output == null) return null;
149
+ const out = { inputPer1K: input || 0, outputPer1K: output || 0 };
150
+ // Optional per-model prompt-cache pricing factors. When present they
151
+ // override the standard 10% / 125% tiers for this model (see computeCost);
152
+ // when absent the defaults apply. Carried through the resolution chain
153
+ // so an app-level or model-level override can tune a single model.
154
+ const read = factorOrNull(raw.cacheReadFactor);
155
+ const write = factorOrNull(raw.cacheWriteFactor);
156
+ if (read != null) out.cacheReadFactor = read;
157
+ if (write != null) out.cacheWriteFactor = write;
158
+ return out;
159
+ }
160
+
161
+ function numberOrNull(v) {
162
+ if (typeof v === 'number' && isFinite(v) && v >= 0) return v;
163
+ if (typeof v === 'string' && v.trim() !== '' && isFinite(Number(v)) && Number(v) >= 0) {
164
+ return Number(v);
165
+ }
166
+ return null;
167
+ }
168
+
169
+ // factorOrNull(v) — same acceptance as numberOrNull but returns null for
170
+ // 0 / missing so pickPricing only carries factors the user actually set.
171
+ function factorOrNull(v) {
172
+ if (v == null || v === '') return null;
173
+ const n = numberOrNull(v);
174
+ return n == null || n === 0 ? null : n;
175
+ }
176
+
177
+ // ---- Cost calculation -------------------------------------------------
178
+
179
+ // Anthropic prompt-cache pricing factors (docs: https://docs.claude.com/en/docs/build-with-claude/prompt-caching#pricing).
180
+ // Cache reads are billed at 10% of the base input rate; cache writes
181
+ // (creating/refreshing a cache entry) at 125% of the base input rate.
182
+ // These are the DEFAULTS and are uniform across Claude models today, but
183
+ // a pricing record can override them per model (see resolvePricing:
184
+ // `pricing.cacheReadFactor` / `pricing.cacheWriteFactor`) because the
185
+ // provider has changed these rates before and could again, or a future
186
+ // model could ship its own tiers.
187
+ const DEFAULT_CACHE_READ_FACTOR = 0.10;
188
+ const DEFAULT_CACHE_WRITE_FACTOR = 1.25;
189
+
190
+ // computeCost({ model, usage, app }) -> { input, output, total, currency, known }
191
+ //
192
+ // `usage` is `{ promptTokens, completionTokens, cacheReadTokens?,
193
+ // cacheCreationTokens? }` — the shape on the `done` event from the AI
194
+ // client (decision §10), with the two Anthropic cache fields optional.
195
+ // Missing keys are treated as 0. `model` and `app` are passed straight
196
+ // through to resolvePricing. Returns a `known: false` object when no
197
+ // pricing is found so the UI can render `--` cleanly.
198
+ //
199
+ // Cost is computed at the provider's tiered rates: cached reads at
200
+ // 10% of input, cache writes at 125% of input, and everything else
201
+ // (uncached prompt + completions) at the base rates. The factors come
202
+ // from the resolved pricing record (per-model overrides) with the
203
+ // defaults above. The `input` bucket keeps the base uncached prompt cost
204
+ // so a `usage` block with no cache fields prices identically to before
205
+ // this feature.
206
+ function computeCost({ model, usage, app } = {}) {
207
+ const u = usage || {};
208
+ const promptTokens = num(u.promptTokens);
209
+ const completionTokens = num(u.completionTokens);
210
+ const cacheReadTokens = num(u.cacheReadTokens);
211
+ const cacheCreationTokens = num(u.cacheCreationTokens);
212
+ const pricing = resolvePricing(model, app);
213
+ if (!pricing) {
214
+ return {
215
+ input: 0,
216
+ output: 0,
217
+ total: 0,
218
+ currency: DEFAULT_CURRENCY,
219
+ known: false
220
+ };
221
+ }
222
+ const cacheReadFactor = factorOrDefault(pricing.cacheReadFactor, DEFAULT_CACHE_READ_FACTOR);
223
+ const cacheWriteFactor = factorOrDefault(pricing.cacheWriteFactor, DEFAULT_CACHE_WRITE_FACTOR);
224
+ // Cached reads/writes are a subset of the reported prompt tokens, so
225
+ // the uncached input portion is what remains after subtracting them
226
+ // (never below zero — a provider reporting odd numbers must not
227
+ // produce negative cost).
228
+ const cachedTokens = cacheReadTokens + cacheCreationTokens;
229
+ const uncachedInput = Math.max(0, promptTokens - cachedTokens);
230
+ const input = (uncachedInput / 1000) * pricing.inputPer1K
231
+ + (cacheReadTokens / 1000) * pricing.inputPer1K * cacheReadFactor
232
+ + (cacheCreationTokens / 1000) * pricing.inputPer1K * cacheWriteFactor;
233
+ const output = (completionTokens / 1000) * pricing.outputPer1K;
234
+ const total = input + output;
235
+ return {
236
+ input,
237
+ output,
238
+ total,
239
+ currency: DEFAULT_CURRENCY,
240
+ known: true
241
+ };
242
+ }
243
+
244
+ // factorOrDefault(v, fallback) -> number
245
+ // A pricing factor must be a finite number >= 0 to be honored; anything
246
+ // else (absent, null, a bad value in a user-edited .mouaif.json) falls
247
+ // back to the standard factor.
248
+ function factorOrDefault(v, fallback) {
249
+ if (typeof v === 'number' && isFinite(v) && v >= 0) return v;
250
+ if (typeof v === 'string' && v.trim() !== '' && isFinite(Number(v)) && Number(v) >= 0) return Number(v);
251
+ return fallback;
252
+ }
253
+
254
+ function num(v) {
255
+ const n = Number(v);
256
+ return isFinite(n) && n >= 0 ? n : 0;
257
+ }
258
+
259
+ // ---- Formatting -------------------------------------------------------
260
+
261
+ // formatCost(amount, { locale } = {}) -> string
262
+ //
263
+ // Format a USD amount with 2–5 fractional digits. Values below
264
+ // 0.000005 round to `$0.00000`; $0 renders as `$0.00` so the row is
265
+ // never empty. The decimal separator respects the user's locale; the
266
+ // currency symbol is always `$` because pricing is USD-only (per §14).
267
+ //
268
+ // We don't use `Intl.NumberFormat`'s `style: 'currency'` because
269
+ // some locales (e.g. en-US) emit `US$` for that formatter, which
270
+ // would break the iOS-style list aesthetic. The decimal separator
271
+ // is still locale-aware via `Intl.NumberFormat({ useGrouping })`
272
+ // without the currency style, then we prepend a literal `$`.
273
+ function formatCost(amount, opts) {
274
+ const a = Number(amount);
275
+ if (!isFinite(a) || a < 0) return '--';
276
+ if (a === 0) return '$0.00';
277
+ const locale = (opts && opts.locale) || defaultLocale();
278
+ const digits = a < 0.01 ? 5 : a < 1 ? 5 : 2;
279
+ // toLocaleString picks the locale's decimal separator. We use
280
+ // maximumFractionDigits: 5 to match the spec ("2–5 significant
281
+ // figures") while still letting `0.00012` read as `0.00012`
282
+ // instead of `0.0001`.
283
+ const body = new Intl.NumberFormat(locale, {
284
+ useGrouping: false,
285
+ minimumFractionDigits: 2,
286
+ maximumFractionDigits: digits
287
+ }).format(a);
288
+ return '$' + body;
289
+ }
290
+
291
+ // formatTokPerSecond(rate) -> string
292
+ //
293
+ // Returns "37 tok/s" or "12.3 tok/s" depending on the magnitude.
294
+ // Renders `--` when the rate is unknown (NaN / negative / null).
295
+ function formatTokPerSecond(rate) {
296
+ const r = Number(rate);
297
+ if (!isFinite(r) || r < 0) return '--';
298
+ if (r === 0) return '0 tok/s';
299
+ if (r >= 100) return Math.round(r) + ' tok/s';
300
+ if (r >= 10) return r.toFixed(1).replace(/\.0$/, '') + ' tok/s';
301
+ return r.toFixed(2).replace(/\.?0+$/, '') + ' tok/s';
302
+ }
303
+
304
+ // formatTokens(n) -> string (e.g. 243 -> "243", 14830 -> "14.8K")
305
+ //
306
+ // Used by the per-turn meta line so large numbers don't push the row
307
+ // past 360 px. Matches the project's existing chat style.
308
+ function formatTokens(n) {
309
+ const v = Number(n);
310
+ if (!isFinite(v) || v < 0) return '--';
311
+ if (v < 1000) return String(Math.round(v));
312
+ if (v < 1000000) return (v / 1000).toFixed(v < 10000 ? 1 : 0) + 'K';
313
+ return (v / 1000000).toFixed(v < 10000000 ? 2 : 1) + 'M';
314
+ }
315
+
316
+ function defaultLocale() {
317
+ try {
318
+ if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
319
+ return Intl.DateTimeFormat().resolvedOptions().locale || 'en-US';
320
+ }
321
+ } catch { /* swallow */ }
322
+ return 'en-US';
323
+ }
324
+
325
+ // ---- Live token-speed counter ----------------------------------------
326
+
327
+ // A tiny accumulator for the per-turn streaming window. The chat UI
328
+ // holds one of these per active turn; the SSE callbacks poke samples
329
+ // in as deltas arrive.
330
+ //
331
+ // const counter = createCounter();
332
+ // on('message', (delta) => counter.add(estimateDeltaTokens(delta)));
333
+ // on('done', () => finalizeTurn(counter));
334
+ //
335
+ // We don't ask the upstream for per-delta token counts (no provider
336
+ // emits them) so the per-delta estimate is character-based: every 4
337
+ // characters of text is ~1 token. This is intentionally rough — the
338
+ // spec calls the counter "informational", and the final rate uses
339
+ // the upstream's authoritative `completionTokens` from `done`.
340
+ //
341
+ // finalizeTokens: the real completion token count from the upstream
342
+ // (or null if the stream ended before the upstream reported usage).
343
+ // When provided, the rate is `completionTokens / streamingMs`; when
344
+ // absent, we fall back to the sum of delta estimates.
345
+ function createCounter() {
346
+ let startedAt = 0;
347
+ let lastAt = 0;
348
+ let estimatedTokens = 0;
349
+ return {
350
+ add(deltaText) {
351
+ const now = Date.now();
352
+ if (!startedAt) startedAt = now;
353
+ lastAt = now;
354
+ // Rough heuristic: ~4 chars per token. We could weight this by
355
+ // word boundaries, but the counter is purely client-side
356
+ // ("informational", per decision §14) and the final value is
357
+ // overridden by the upstream's `done` payload anyway.
358
+ const len = typeof deltaText === 'string' ? deltaText.length : 0;
359
+ if (len > 0) estimatedTokens += len / 4;
360
+ },
361
+ // Returns the current best-guess rate. `finalizeTokens` is the
362
+ // upstream-reported completion token count; when it is null the
363
+ // heuristic sum is used instead.
364
+ rate(finalizeTokens) {
365
+ if (!startedAt) return 0;
366
+ const ms = (lastAt || Date.now()) - startedAt;
367
+ if (ms <= 0) return 0;
368
+ const tokens = (typeof finalizeTokens === 'number' && isFinite(finalizeTokens))
369
+ ? finalizeTokens
370
+ : estimatedTokens;
371
+ return (tokens / ms) * 1000;
372
+ },
373
+ // Resets the counter for a new turn. Called after `done` is
374
+ // received (or after the chat is re-entered) so the next user
375
+ // message starts from 0.
376
+ reset() { startedAt = 0; lastAt = 0; estimatedTokens = 0; }
377
+ };
378
+ }
379
+
380
+ module.exports = {
381
+ BUILTIN_PRICING,
382
+ builtinPricingForId,
383
+ resolvePricing,
384
+ computeCost,
385
+ formatCost,
386
+ formatTokPerSecond,
387
+ formatTokens,
388
+ createCounter
389
+ };
package/src/util.js ADDED
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ // Leaf utility helpers shared across the server. This module deliberately
4
+ // requires nothing else so any domain module can pull from it without
5
+ // creating a require cycle. Helpers that used to be copy-pasted into
6
+ // several files (err, joinUrl, the first-*string finders) live here so
7
+ // there is exactly one definition.
8
+
9
+ // err(code, message, extra) — a typed Error with a machine-readable
10
+ // `code`. `extra`, when given, is copied onto the error (path, size,
11
+ // maxBytes, ...). Shared by mcp.js, tags.js, files.js and the
12
+ // ai-endpoints error helpers, all of which previously had an identical
13
+ // private copy.
14
+ function err(code, message, extra) {
15
+ const e = new Error(message);
16
+ e.code = code;
17
+ if (extra) Object.assign(e, extra);
18
+ return e;
19
+ }
20
+
21
+ // joinUrl(base, path) — concatenate a base URL and a relative/absolute
22
+ // path without doubling or dropping the single separating slash.
23
+ // Previously duplicated in ai-endpoints.js and ai-stream.js.
24
+ function joinUrl(base, path) {
25
+ if (!base) return path;
26
+ if (base.endsWith('/') && path.startsWith('/')) return base + path.slice(1);
27
+ if (!base.endsWith('/') && !path.startsWith('/')) return base + '/' + path;
28
+ return base + path;
29
+ }
30
+
31
+ // firstStringField(obj, names) — return the first present string value
32
+ // under one of `names`, else ''. Used by the streaming parsers to pick
33
+ // a reasoning/thinking field out of a provider delta whose key varies
34
+ // (reasoning, reasoning_content, thinking, ...). Semantically distinct
35
+ // from firstStringValue below (this one checks named keys, in order).
36
+ function firstStringField(obj, names) {
37
+ if (!obj || typeof obj !== 'object') return '';
38
+ for (const name of names) {
39
+ if (typeof obj[name] === 'string') return obj[name];
40
+ }
41
+ return '';
42
+ }
43
+
44
+ // firstStringValue(value) — return the first string found among an
45
+ // object's own values, else ''. Used to derive a human-readable
46
+ // summary from a tool-call arguments object whose exact shape is not
47
+ // known ahead of time.
48
+ function firstStringValue(value) {
49
+ if (!value || typeof value !== 'object') return '';
50
+ for (const item of Object.values(value)) if (typeof item === 'string') return item;
51
+ return '';
52
+ }
53
+
54
+ // qs(q, name) — read a query-string value without the longhand
55
+ // `typeof q.x === 'string' ? q.x : ''` that was repeated ~40 times
56
+ // across the server handlers. Mirrors readJsonBody's string-only
57
+ // coercion: a non-string query value (e.g. ?a=1&a=2 arriving as an
58
+ // array) safely becomes ''.
59
+ function qs(q, name) {
60
+ return (q && typeof q[name] === 'string') ? q[name] : '';
61
+ }
62
+
63
+ // safeDecode(value) — decodeURIComponent that never throws. Every REST
64
+ // route that takes an id out of the path decodes the raw segment before
65
+ // handing it to a domain module; a malformed escape (`/api/chats/%zz`,
66
+ // `/api/projects/%zz/tags`, ...) used to raise a URIError from inside an
67
+ // async handler, which the HTTP dispatcher cannot catch: it surfaced as
68
+ // an unhandled rejection and, under Node's default
69
+ // `--unhandled-rejections=throw`, exited the process. Returning the raw
70
+ // segment on failure keeps the request a normal 400/404. Callers echo
71
+ // the value back through sendJSON, so nothing is interpreted as a path.
72
+ function safeDecode(value) {
73
+ if (typeof value !== 'string') return '';
74
+ try { return decodeURIComponent(value); } catch { return value; }
75
+ }
76
+ // projectModelRecord(record) — the only fields a project's `.mouaif.json`
77
+ // model entry may contribute to a request. Project models are identity and
78
+ // selection metadata only (decision §3): transport and credentials always
79
+ // come from the app-level provider connection. The project file is
80
+ // committed with the project, so a `baseUrl`, `apiKey`, `auth`, `headers`
81
+ // or `token` written there must never reach a request builder — it would
82
+ // point a global credential at an attacker-controlled endpoint.
83
+ //
84
+ // Allow-list, not deny-list: a field added later is ignored until it is
85
+ // listed here, instead of silently flowing through.
86
+ const PROJECT_MODEL_FIELDS = [
87
+ 'id', 'provider', 'label', 'contextWindow', 'pricing',
88
+ 'thinking', 'thinkingLevel', 'maxOutputTokens', 'maxTokens',
89
+ // Dictation. A transcription model carries its own descriptor — the request
90
+ // family, an optional non-conventional endpoint path, a language hint, a
91
+ // prompt, or the plain `true` that marks it as a dictation choice. Without
92
+ // it in this allow-list the field would be stripped before the request
93
+ // builder ever sees it, and every model would fall back to the inferred
94
+ // family.
95
+ 'transcription',
96
+ // The request family the dictation catalog classified a live row under
97
+ // (`openai-audio` for a model that can hear but has no
98
+ // /audio/transcriptions entry). It rides the same path as `transcription`
99
+ // and is honoured by transcribe.kindForModel, so the picker's "Sends as"
100
+ // read-out is what the request does.
101
+ 'kind'
102
+ ];
103
+
104
+ function projectModelRecord(record) {
105
+ const out = {};
106
+ if (!record || typeof record !== 'object') return out;
107
+ for (const key of PROJECT_MODEL_FIELDS) {
108
+ if (record[key] !== undefined) out[key] = record[key];
109
+ }
110
+ return out;
111
+ }
112
+
113
+ // errCodeToHttpStatus(code, def) — one source of truth for mapping the
114
+ // typed error codes the domain modules throw (files, tags, mcp, the
115
+ // project/file/tags/prompt/agent handlers) to HTTP status codes. The
116
+ // project and file handlers previously each hand-enumerated overlapping
117
+ // subsets of this map in their own <x>ErrorStatus switch; prompt/agents
118
+ // shared a smaller 422/400/500 trio. `def` is the fallback (projects/add
119
+ // default 400, the model-facing ones default 500).
120
+ const HTTP_STATUS_BY_CODE = {
121
+ EBADPATH: 400,
122
+ EBADINPUT: 400,
123
+ EOUTSIDE_PROJECT: 403,
124
+ EOUTSIDE_HOME: 403,
125
+ ENOENT: 404,
126
+ ENOTDIR: 400,
127
+ ENOTFILE: 400,
128
+ EISDIR: 400,
129
+ EACCES: 403,
130
+ EEXIST: 409,
131
+ EBINARY: 415,
132
+ ENOTIMAGE: 415,
133
+ ETOOLARGE: 413,
134
+ EREAD: 500,
135
+ MOUAIF_PROJECT_PARSE_ERROR: 422
136
+ };
137
+ function errCodeToHttpStatus(code, def) {
138
+ return HTTP_STATUS_BY_CODE[code] || (def === undefined ? 500 : def);
139
+ }
140
+
141
+ module.exports = {
142
+ err,
143
+ joinUrl,
144
+ qs,
145
+ safeDecode,
146
+ projectModelRecord,
147
+ PROJECT_MODEL_FIELDS,
148
+ firstStringField,
149
+ firstStringValue,
150
+ errCodeToHttpStatus
151
+ };