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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- 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 };
|