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
@@ -0,0 +1,399 @@
1
+ 'use strict';
2
+
3
+ // Dictation REST handlers.
4
+ //
5
+ // GET /api/ai/transcribe/models?projectDir=<abs>
6
+ // -> { models: [{ id, provider, label, kind, auth }], kinds: [...] }
7
+ // The models this project can dictate with, each with the request
8
+ // family it will use, plus the family list the UI's <select> renders.
9
+ //
10
+ // POST /api/ai/transcribe
11
+ // body: { projectDir, modelId, providerId?, audioBase64, mimeType?,
12
+ // filename?, language?, prompt?, chatId?, kind? }
13
+ // -> { text, model: { id, provider }, kind, bytes, durationMs,
14
+ // usage, cost }
15
+ //
16
+ // `chatId` is optional and is what makes a run *attributed*: the composer
17
+ // microphone sends it, so the priced run joins that chat's Total and the
18
+ // project total (messages.addChatCost). The Dictation page sends none, and its
19
+ // runs stay point-of-use only.
20
+ //
21
+ // Both are proxies, for the same reason the chat endpoint is one
22
+ // (docs/decisions.md section 10): the browser holds no provider credential.
23
+ // The recording is posted as base64 JSON, never as multipart from the client,
24
+ // so one code path handles the body, its size cap, and its error shape.
25
+ //
26
+ // The request/response *shapes* per provider family live in src/transcribe.js;
27
+ // this file only resolves the model, reads the body, performs the fetch, and
28
+ // maps typed errors onto HTTP status codes.
29
+
30
+ const {
31
+ sendJSON,
32
+ readJsonOr400,
33
+ resolveModel,
34
+ credentialForProvider,
35
+ messages,
36
+ settings
37
+ } = require('./server-shared.js');
38
+
39
+ const transcribe = require('./transcribe.js');
40
+ const modelList = require('./modelList.js');
41
+ const usageMetrics = require('./usage.js');
42
+ const serverShared = require('./server-shared.js');
43
+ const KIND_IDS = transcribe.KIND_IDS;
44
+
45
+ // UNKNOWN_COST — what a run with no usage report, or a model with no pricing,
46
+ // returns. `known: false` is the app's existing convention for "render `--`"
47
+ // (docs/decisions.md §14); a `$0.00` here would claim the run was free, which
48
+ // is exactly the lie the convention exists to avoid. A transcription is often
49
+ // unpriced for a real reason: `whisper-1` bills per minute of audio and
50
+ // reports no tokens at all.
51
+ const UNKNOWN_COST = Object.freeze({ input: 0, output: 0, total: 0, currency: 'USD', known: false });
52
+
53
+ // AUDIO_BASE64_MAX — the JSON body cap. base64 inflates by ~4/3, so this is
54
+ // the HTTP-layer twin of transcribe.MAX_AUDIO_BYTES (20 MB of audio).
55
+ const AUDIO_BASE64_MAX = Math.ceil(transcribe.MAX_AUDIO_BYTES * 4 / 3) + 1024;
56
+
57
+ // cachedModelRecord(provider, modelId) -> the live catalog row, or null.
58
+ //
59
+ // resolveModel rebuilds a live-catalog model from its id and provider (the
60
+ // picker deliberately does not persist them into the project's model list), so
61
+ // the capability report the catalog offered it on is not on the resolved
62
+ // record. The report is what decides the request family for a model that can
63
+ // hear but has no /audio/transcriptions entry, so it is read back from the same
64
+ // cache resolveModel prices from. Both slices are consulted: the transcription
65
+ // slice serves the purpose-built speech-to-text rows, and the chat slice is
66
+ // where OpenRouter files a Google Gemini model.
67
+ function cachedModelRecord(provider, modelId) {
68
+ if (!provider || !modelId) return null;
69
+ for (const purpose of ['transcription', 'chat']) {
70
+ const entry = serverShared.MODEL_LIST_CACHE.get(
71
+ serverShared.modelListCacheKey(provider, serverShared.credHashFor(provider), purpose)
72
+ );
73
+ if (!entry || !Array.isArray(entry.models)) continue;
74
+ const hit = entry.models.find((m) => m && m.id === modelId);
75
+ if (hit) return hit;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ // audioBufferFrom(base64) — decode, with the two failures that actually
81
+ // happen: a body that is not valid base64 at all, and a body that decodes to
82
+ // nothing (an empty recorder blob is the common one).
83
+ function audioBufferFrom(base64) {
84
+ if (typeof base64 !== 'string' || !base64.trim()) {
85
+ return { error: 'audioBase64 is required', code: 'EBADINPUT' };
86
+ }
87
+ const cleaned = base64.replace(/^data:[^;,]*;base64,/, '').trim();
88
+ if (cleaned.length > AUDIO_BASE64_MAX) {
89
+ return {
90
+ error: 'Recording is too large to transcribe (' + cleaned.length + ' base64 chars, max ' + AUDIO_BASE64_MAX + ')',
91
+ code: 'ETOOLARGE'
92
+ };
93
+ }
94
+ if (!/^[A-Za-z0-9+/=\r\n]+$/.test(cleaned)) {
95
+ return { error: 'audioBase64 is not valid base64', code: 'EBADINPUT' };
96
+ }
97
+ const audio = Buffer.from(cleaned, 'base64');
98
+ if (!audio.length) return { error: 'The recording is empty', code: 'EEMPTYAUDIO' };
99
+ return { audio };
100
+ }
101
+
102
+ // statusFor(code) — the typed codes this feature can produce, as HTTP statuses
103
+ // a user can act on: 404/400 for a wrong model, 413 for a long recording, 401
104
+ // when the credential is missing or rejected, 502/504 for an upstream problem.
105
+ function statusFor(code) {
106
+ switch (code) {
107
+ case 'EMODEL_NOT_FOUND':
108
+ case 'EPROVIDER_NOT_FOUND':
109
+ return 404;
110
+ case 'ENOBASEURL':
111
+ case 'EBADINPUT':
112
+ case 'EEMPTYAUDIO':
113
+ case 'EEMPTY':
114
+ return 400;
115
+ case 'ETOOLARGE':
116
+ return 413;
117
+ case 'ENOAUTH':
118
+ return 401;
119
+ case 'EUNREACHABLE':
120
+ return 502;
121
+ case 'ETIMEOUT':
122
+ return 504;
123
+ default:
124
+ return 502;
125
+ }
126
+ }
127
+
128
+ // fetchUpstream(url, init, timeoutMs) — one fetch with a hard deadline. The
129
+ // provider is a third party: without a timeout a stalled connection would hold
130
+ // the request (and the user's spinner) open indefinitely.
131
+ async function fetchUpstream(url, init, timeoutMs) {
132
+ const controller = new AbortController();
133
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
134
+ try {
135
+ return await fetch(url, Object.assign({}, init, { signal: controller.signal }));
136
+ } catch (e) {
137
+ if (controller.signal.aborted) {
138
+ const err = new Error('The provider did not respond within ' + Math.round(timeoutMs / 1000) + 's');
139
+ err.code = 'ETIMEOUT';
140
+ throw err;
141
+ }
142
+ const err = new Error('Could not reach the provider: ' + (e && e.message ? e.message : e));
143
+ err.code = 'EUNREACHABLE';
144
+ throw err;
145
+ } finally {
146
+ clearTimeout(timer);
147
+ }
148
+ }
149
+
150
+ async function handleTranscribe(req, res, parsed) {
151
+ const urlPath = parsed.pathname;
152
+ const method = req.method;
153
+
154
+ // GET /api/ai/transcribe/models?projectDir=<abs>
155
+ //
156
+ // The dictation catalog: project models plus whatever the connected
157
+ // providers currently offer. Both are needed because the app has no model
158
+ // editor — a project model is the *only* way to describe a model the live
159
+ // list cannot (a self-hosted endpoint, a per-model language default), while
160
+ // the live list is what makes a fresh install usable without hand-editing
161
+ // `.mouaif.json`.
162
+ //
163
+ // The live half is read as the provider's *transcription* slice where it has
164
+ // one. For OpenRouter that matters more than it sounds: its /models is sliced
165
+ // by output modality and defaults to `text`, so `openai/whisper-1` and the
166
+ // other 20 speech-to-text models are simply not in the chat catalog, while the
167
+ // audio-input *chat* rows that are (`openai/gpt-audio`,
168
+ // `google/gemini-2.5-flash`) are answered by `/audio/transcriptions` with
169
+ // `400 Model … does not exist`. A catalog built from the chat list could
170
+ // therefore offer only models that cannot transcribe.
171
+ //
172
+ // `?live=0` serves the project models alone: the catalog read is on the
173
+ // critical path of the page's first paint, and the live lists cost one
174
+ // upstream round trip per connected provider (cached for an hour
175
+ // afterwards). `?refresh=1` forces that work even when it is cached.
176
+ if (urlPath === '/api/ai/transcribe/models' && method === 'GET') {
177
+ const projectDir = typeof parsed.query.projectDir === 'string' ? parsed.query.projectDir : '';
178
+ const wantLive = parsed.query.live !== '0';
179
+ const force = parsed.query.refresh === '1';
180
+ let resolved;
181
+ try {
182
+ resolved = settings.getResolved(projectDir || null);
183
+ } catch (e) {
184
+ return sendJSON(res, 422, { error: e.message, code: e.code || 'EBADPROJECT' });
185
+ }
186
+ const all = Array.isArray(resolved.models) ? resolved.models : [];
187
+ const projectModels = transcribe.transcriptionCandidates(all);
188
+
189
+ const app = settings.getApp();
190
+ const connections = Array.isArray(app.providers) ? app.providers : [];
191
+ const connectedIds = connections.map((p) => p && p.id).filter(Boolean);
192
+
193
+ // Project models always come first: they are the user's own records, they
194
+ // carry their descriptor, and a hand-configured model must win over the
195
+ // upstream's entry for the same id (see the merge below).
196
+ const rows = projectModels.map((m) => ({
197
+ id: m.id,
198
+ provider: m.provider || '',
199
+ label: m.label || '',
200
+ kind: transcribe.kindForModel(m),
201
+ auth: m.auth || 'apikey',
202
+ source: 'project',
203
+ connected: connectedIds.includes(m.provider)
204
+ }));
205
+
206
+ const liveFailures = [];
207
+ if (wantLive && connectedIds.length) {
208
+ // The live half is read as the provider's *transcription* slice where it
209
+ // publishes one (OpenRouter: /models defaults to `output_modalities=text`,
210
+ // so its speech-to-text models are not in the chat list at all, while the
211
+ // audio-input chat models that are there are rejected by
212
+ // /audio/transcriptions). Providers without such a slice hand back their
213
+ // chat list, which the candidate filter below narrows as before.
214
+ //
215
+ // OpenRouter is the one provider where *both* slices are needed, which is
216
+ // why the transcription read cannot stand alone. Its transcription slice
217
+ // is the purpose-built speech-to-text catalogue (whisper, voxtral,
218
+ // `google/chirp-3`, … — 21 rows), and the models most people expect to
219
+ // dictate with are simply not in it: `google/gemini-3.5-flash` and every
220
+ // other Google Gemini row is a *chat* model that can hear, filed upstream
221
+ // under `output_modalities: ["text"]`. Reading only the transcription
222
+ // slice therefore left the picker with no Google chat model at all, which
223
+ // is what "Google models are not available on OpenRouter" looked like
224
+ // from the phone. The chat slice is read too, and the candidate filter
225
+ // keeps the rows that can actually take audio (see
226
+ // transcribe.audioChatModel) — served over /chat/completions rather than
227
+ // over the endpoint that answers `400 Model … does not exist` for them.
228
+ const reads = [{ purpose: 'transcription' }];
229
+ if (connectedIds.includes('openrouter')) reads.push({ purpose: 'chat' });
230
+ for (const read of reads) {
231
+ const { list, failures } = await modelList.liveModelsForMany(connectedIds, { force, ...read });
232
+ liveFailures.push(...failures);
233
+ const seen = new Set(rows.map((r) => r.provider + '\u0000' + r.id));
234
+ for (const m of list) {
235
+ if (!transcribe.isTranscriptionModel(m)) continue;
236
+ const key = (m.provider || '') + '\u0000' + m.id;
237
+ if (seen.has(key)) continue;
238
+ seen.add(key);
239
+ const row = {
240
+ id: m.id,
241
+ provider: m.provider || '',
242
+ label: m.label || '',
243
+ kind: transcribe.kindForModel(m),
244
+ auth: 'apikey',
245
+ source: 'live',
246
+ connected: true
247
+ };
248
+ // Carry the capability report through, so the picker can say *why* a
249
+ // model is on the list when its name does not: the input modalities are
250
+ // the "takes audio" half, the output modalities the "really produces a
251
+ // transcript" half (which is what the dictation slice is built from).
252
+ if (Array.isArray(m.inputModalities)) row.inputModalities = m.inputModalities;
253
+ if (Array.isArray(m.outputModalities)) row.outputModalities = m.outputModalities;
254
+ rows.push(row);
255
+ }
256
+ }
257
+ }
258
+
259
+ return sendJSON(res, 200, {
260
+ models: rows,
261
+ kinds: transcribe.TRANSCRIBE_KINDS,
262
+ // `total` stays the project model count: it is what the picker's empty
263
+ // state reports as "filtered out", and it must not change meaning
264
+ // because a provider happened to be reachable.
265
+ total: all.length,
266
+ providers: connectedIds,
267
+ // One unreachable provider must not empty the picker, so its failure is
268
+ // reported here instead of being turned into a request-level error.
269
+ liveFailures
270
+ });
271
+ }
272
+
273
+ // POST /api/ai/transcribe
274
+ if (urlPath === '/api/ai/transcribe' && method === 'POST') {
275
+ const body = await readJsonOr400(req, res);
276
+ if (!body) return;
277
+ const modelId = typeof body.modelId === 'string' ? body.modelId.trim() : '';
278
+ const providerId = typeof body.providerId === 'string' ? body.providerId.trim() : '';
279
+ const projectDir = typeof body.projectDir === 'string' ? body.projectDir : '';
280
+ if (!modelId) return sendJSON(res, 400, { error: 'modelId is required', code: 'EBADINPUT' });
281
+
282
+ const decoded = audioBufferFrom(body.audioBase64);
283
+ if (decoded.error) return sendJSON(res, statusFor(decoded.code), { error: decoded.error, code: decoded.code });
284
+
285
+ let model;
286
+ try {
287
+ model = resolveModel(modelId, projectDir, providerId);
288
+ } catch (e) {
289
+ return sendJSON(res, statusFor(e.code), { error: e.message, code: e.code || 'EBADMODEL' });
290
+ }
291
+
292
+ // A live catalog row is not a project record: `resolveModel` rebuilds it
293
+ // from the id and the provider alone, so the capability report the catalog
294
+ // selected on (`inputModalities` / `outputModalities`, carried by
295
+ // src/ai-endpoints.js) is not on it. Fold it back from the same cache
296
+ // resolveModel just read, so the route is decided by the same evidence the
297
+ // picker offered the row on. Without this, an audio chat model the catalog
298
+ // correctly listed would be sent to /audio/transcriptions — the endpoint
299
+ // that answers `400 Model … does not exist` for it.
300
+ const cached = cachedModelRecord(model.provider, model.id);
301
+ if (cached) {
302
+ model = Object.assign({}, model, {
303
+ inputModalities: cached.inputModalities,
304
+ outputModalities: cached.outputModalities,
305
+ kind: cached.kind
306
+ });
307
+ }
308
+ // The picker's own decision wins when it is on the request: it read the
309
+ // capability report directly and the user chose the model on that basis.
310
+ if (KIND_IDS.includes(body.kind)) model.kind = body.kind;
311
+
312
+ const kind = transcribe.kindForModel(model);
313
+ const descriptor = model.transcription && typeof model.transcription === 'object' ? model.transcription : {};
314
+ let request;
315
+ try {
316
+ request = transcribe.buildTranscribeRequest({
317
+ kind,
318
+ model,
319
+ apiKey: credentialForProvider(model.provider),
320
+ audio: decoded.audio,
321
+ mimeType: body.mimeType || transcribe.mimeTypeFor(body.filename),
322
+ filename: body.filename,
323
+ // A per-model default may be *overridden* per utterance (the page's
324
+ // language field), but an explicit body value always wins.
325
+ language: typeof body.language === 'string' && body.language
326
+ ? body.language
327
+ : (descriptor.language || ''),
328
+ prompt: typeof body.prompt === 'string' && body.prompt
329
+ ? body.prompt
330
+ : (descriptor.prompt || '')
331
+ });
332
+ } catch (e) {
333
+ return sendJSON(res, statusFor(e.code), { error: e.message, code: e.code || 'EBADREQUEST' });
334
+ }
335
+
336
+ const startedAt = Date.now();
337
+ let upstream;
338
+ let text;
339
+ try {
340
+ upstream = await fetchUpstream(
341
+ request.url,
342
+ { method: request.method, headers: request.headers, body: request.body },
343
+ transcribe.DEFAULT_TIMEOUT_MS
344
+ );
345
+ text = await upstream.text();
346
+ } catch (e) {
347
+ return sendJSON(res, statusFor(e.code), { error: e.message, code: e.code || 'EUPSTREAM' });
348
+ }
349
+
350
+ const parsedUpstream = transcribe.parseTranscribeResponse(kind, upstream.status, text);
351
+ if (parsedUpstream.error) {
352
+ return sendJSON(res, statusFor(parsedUpstream.code), {
353
+ error: parsedUpstream.error,
354
+ code: parsedUpstream.code,
355
+ upstreamStatus: upstream.status,
356
+ model: { id: model.id, provider: model.provider },
357
+ kind
358
+ });
359
+ }
360
+ // What the run cost, priced at the same rates the chat uses: the model's
361
+ // own `pricing` block, the app-level table, then the built-in defaults
362
+ // (docs/decisions.md §14). Everything needed is already resolved — the model
363
+ // record comes from resolveModel, which folds a live catalog entry's
364
+ // provider pricing in. A provider that reports no tokens costs nothing to
365
+ // guess at, so it stays unknown rather than zero.
366
+ const cost = parsedUpstream.usage
367
+ ? usageMetrics.computeCost({ model, usage: parsedUpstream.usage, app: settings.getApp() })
368
+ : UNKNOWN_COST;
369
+ // Attribute the run to the chat that asked for it, when there is one.
370
+ // A chat-attributed run is not a chat turn (no message is written — the
371
+ // transcript lands in the composer draft), but it is billed work, so its
372
+ // cost joins the chat's Total and the project total through the same
373
+ // persisted counters the message path maintains. The Dictation page sends
374
+ // no chatId: its runs stay point-of-use only, which is why the composer
375
+ // microphone and the page can report differently without disagreeing.
376
+ // Attribution never fails the run: the upstream call is already paid for,
377
+ // and a chat that vanished mid-take must not turn a transcript into an
378
+ // error.
379
+ const chatId = typeof body.chatId === 'string' ? body.chatId : '';
380
+ if (chatId && cost.known && Number.isFinite(cost.total) && cost.total > 0) {
381
+ try { messages.addChatCost(projectDir, chatId, cost.total); } catch { /* non-fatal */ }
382
+ }
383
+ return sendJSON(res, 200, {
384
+ text: parsedUpstream.text,
385
+ model: { id: model.id, provider: model.provider },
386
+ kind,
387
+ bytes: decoded.audio.length,
388
+ durationMs: Date.now() - startedAt,
389
+ // null (not {}) when the provider said nothing, so the client can tell
390
+ // "no report" from "zero tokens".
391
+ usage: parsedUpstream.usage || null,
392
+ cost
393
+ });
394
+ }
395
+
396
+ return false;
397
+ }
398
+
399
+ module.exports = { handleTranscribe };