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,683 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Dictation — speech-to-text request shapes, one per provider family.
|
|
4
|
+
//
|
|
5
|
+
// Dictation is deliberately NOT part of the chat provider registry
|
|
6
|
+
// (src/ai-endpoints.js): a chat model and a transcription model are
|
|
7
|
+
// different products with different endpoints, different payloads and
|
|
8
|
+
// different credentials. Instead this module exposes exactly what
|
|
9
|
+
// src/server-handlers-transcribe.js needs:
|
|
10
|
+
//
|
|
11
|
+
// TRANSCRIBE_KINDS — the family ids the UI offers (<select>).
|
|
12
|
+
// kindForModel(model) — model record -> family id. An explicit
|
|
13
|
+
// `transcription.kind` on the model wins; otherwise
|
|
14
|
+
// the family is inferred from the provider id and
|
|
15
|
+
// the model id, so a plain OpenAI model record such
|
|
16
|
+
// as `whisper-1` just works with no extra setup.
|
|
17
|
+
// buildTranscribeRequest({ kind, model, apiKey, audio, mimeType, ... })
|
|
18
|
+
// — { url, method, headers, body } where `body` is a
|
|
19
|
+
// Uint8Array: multipart/form-data for the OpenAI
|
|
20
|
+
// family (which covers Groq, Mistral, OpenRouter and
|
|
21
|
+
// any OpenAI-shaped endpoint), raw bytes for Gemini.
|
|
22
|
+
// parseTranscribeResponse(kind, status, text)
|
|
23
|
+
// — { text, usage } on success, { error, code }
|
|
24
|
+
// otherwise. `usage` is the provider's own token
|
|
25
|
+
// report (or null when it reports none), which the
|
|
26
|
+
// HTTP layer prices with src/usage.js.
|
|
27
|
+
//
|
|
28
|
+
// The audio never leaves the machine except in this one proxied request, and
|
|
29
|
+
// the key stays server-side: the browser POSTs base64 to /api/ai/transcribe
|
|
30
|
+
// and never sees the credential (docs/decisions.md section 10).
|
|
31
|
+
|
|
32
|
+
// Family ids — the request shapes dictation can send. Adding one is a builder
|
|
33
|
+
// branch, a parser branch, and a label; the picker reads the list, so a new
|
|
34
|
+
// shape shows up in the UI without a frontend change.
|
|
35
|
+
//
|
|
36
|
+
// openai-compatible multipart POST /audio/transcriptions (OpenAI, Groq,
|
|
37
|
+
// Mistral, OpenRouter's speech-to-text slice, LM Studio…)
|
|
38
|
+
// openai-audio POST /chat/completions with an inline `input_audio`
|
|
39
|
+
// part. The route for a model that can hear but has no
|
|
40
|
+
// /audio/transcriptions entry — see audioChatModel.
|
|
41
|
+
// gemini POST /v1beta/models/{model}:generateContent with the
|
|
42
|
+
// audio inline as a base64 `inline_data` part.
|
|
43
|
+
const TRANSCRIBE_KINDS = Object.freeze([
|
|
44
|
+
{ id: 'openai-compatible', label: 'OpenAI-compatible (multipart /audio/transcriptions)' },
|
|
45
|
+
{ id: 'openai-audio', label: 'OpenAI-compatible (inline audio /chat/completions)' },
|
|
46
|
+
{ id: 'gemini', label: 'Gemini (inline audio)' }
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const KIND_IDS = TRANSCRIBE_KINDS.map((k) => k.id);
|
|
50
|
+
const DEFAULT_KIND = 'openai-compatible';
|
|
51
|
+
|
|
52
|
+
// Bounds. The composer sends at most ~2 minutes of Opus (~1.5 MB), and the
|
|
53
|
+
// HTTP layer caps the JSON body, but the audio itself is validated here too so
|
|
54
|
+
// a hand-rolled request cannot hand 100 MB to a provider.
|
|
55
|
+
const MAX_AUDIO_BYTES = 20 * 1024 * 1024;
|
|
56
|
+
const DEFAULT_TIMEOUT_MS = 60 * 1000;
|
|
57
|
+
|
|
58
|
+
// DEFAULT_TRANSCRIBE_PROMPT — what the inline-audio shapes ask the model to do.
|
|
59
|
+
// A multipart /audio/transcriptions call needs no instruction (the endpoint
|
|
60
|
+
// exists to transcribe), but an inline-audio call is a *chat* request, so the
|
|
61
|
+
// prompt is the difference between a transcript and a remark about the audio:
|
|
62
|
+
// with no prompt, OpenRouter's Google models answer "That is a variation of the
|
|
63
|
+
// classic English pangram…" instead of the sentence itself.
|
|
64
|
+
const DEFAULT_TRANSCRIBE_PROMPT = 'Transcribe this audio recording verbatim. Return only the transcript text, with punctuation, and no commentary.';
|
|
65
|
+
|
|
66
|
+
// audioFormatFor(mimeType, filename) -> 'wav' | 'mp3' | 'ogg' | 'webm' | 'flac' | 'm4a'
|
|
67
|
+
//
|
|
68
|
+
// The OpenAI chat API names an inline audio part's container with a bare label,
|
|
69
|
+
// not a MIME type. Anything unrecognised falls back to `webm`, which is what
|
|
70
|
+
// every Chromium browser's MediaRecorder produces — the only shape this app
|
|
71
|
+
// records in practice.
|
|
72
|
+
function audioFormatFor(mimeType, filename) {
|
|
73
|
+
const type = String(mimeType || '').toLowerCase() || String(mimeTypeFor(filename) || '').toLowerCase();
|
|
74
|
+
// Container first: `audio/webm;codecs=opus` is a WebM file that happens to
|
|
75
|
+
// carry Opus, and must not be labelled `ogg` just because the codec is named.
|
|
76
|
+
if (type.includes('webm')) return 'webm';
|
|
77
|
+
if (type.includes('ogg') || type.includes('oga')) return 'ogg';
|
|
78
|
+
if (type.includes('wav') || type.includes('wave')) return 'wav';
|
|
79
|
+
if (type.includes('mpeg') || type.includes('mp3')) return 'mp3';
|
|
80
|
+
if (type.includes('mp4') || type.includes('m4a') || type.includes('aac')) return 'm4a';
|
|
81
|
+
if (type.includes('flac')) return 'flac';
|
|
82
|
+
// A bare Opus stream, with no container named, is served as Ogg by every
|
|
83
|
+
// provider that accepts one.
|
|
84
|
+
if (type.includes('opus')) return 'ogg';
|
|
85
|
+
return 'webm';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// MODEL_HINTS — model-id substrings that mean "this is a transcription
|
|
89
|
+
// model", used only to decide the default family when the user has not
|
|
90
|
+
// declared one. Checked in order; the first hit wins.
|
|
91
|
+
const OPENAI_MODEL_HINTS = ['whisper', 'transcribe', 'transcription', 'voxtral', 'parakeet'];
|
|
92
|
+
|
|
93
|
+
// OPENAI_SHAPED_PROVIDERS — every provider whose base URL speaks the
|
|
94
|
+
// OpenAI-shaped multipart form. This is what decides the transport: which API a
|
|
95
|
+
// base URL speaks is a property of the *connection*, not of the model id.
|
|
96
|
+
//
|
|
97
|
+
// The list itself lives in src/providerShapes.js, one leaf module both
|
|
98
|
+
// consumers read. It no longer carries
|
|
99
|
+
// `anthropic`: that entry, which only this copy had, claimed the Messages API
|
|
100
|
+
// speaks OpenAI's multipart form, which it does not — pointing a Claude
|
|
101
|
+
// connection at `/audio/transcriptions` is a 404 either way.
|
|
102
|
+
const { OPENAI_SHAPED_PROVIDERS } = require('./providerShapes.js');
|
|
103
|
+
|
|
104
|
+
// kindForModel(model) — the dialect for one model record, in strict precedence:
|
|
105
|
+
//
|
|
106
|
+
// 1. an explicit `transcription.kind` on the model. This is the escape hatch
|
|
107
|
+
// for an endpoint that is not the convention — a self-hosted server that
|
|
108
|
+
// really does serve Gemma behind a generateContent path, say. It is the
|
|
109
|
+
// only thing that overrides the connection.
|
|
110
|
+
// 2. a `kind` the *live catalog* already decided (the dictation catalog
|
|
111
|
+
// stamps one per row; see transcribe.audioChatModel below).
|
|
112
|
+
// 3. a model that can *hear* but has no /audio/transcriptions entry: an
|
|
113
|
+
// OpenAI-shaped connection whose capability report names audio input
|
|
114
|
+
// routes through /chat/completions instead. See audioChatModel.
|
|
115
|
+
// 4. the *provider connection*: `gemini` speaks Gemini, everything else in
|
|
116
|
+
// the shipped registry speaks the OpenAI shape.
|
|
117
|
+
// 5. only when the provider is unknown, the id: `google/…` means Gemini.
|
|
118
|
+
//
|
|
119
|
+
// The order matters, and getting it wrong is not cosmetic. An earlier version
|
|
120
|
+
// keyed off the id first, so an OpenRouter model called `google/gemini-2.5-flash`
|
|
121
|
+
// resolved to the Gemini family and produced
|
|
122
|
+
// `https://openrouter.ai/api/v1/v1beta/models/…:generateContent` — a URL that
|
|
123
|
+
// cannot exist, because OpenRouter has no generateContent API. The model *name*
|
|
124
|
+
// says which Google model it is; the *connection* says how to talk to it.
|
|
125
|
+
function kindForModel(model) {
|
|
126
|
+
const m = model || {};
|
|
127
|
+
const explicit = m.transcription && typeof m.transcription.kind === 'string'
|
|
128
|
+
? m.transcription.kind
|
|
129
|
+
: '';
|
|
130
|
+
if (KIND_IDS.includes(explicit)) return explicit;
|
|
131
|
+
if (KIND_IDS.includes(m.kind)) return m.kind;
|
|
132
|
+
if (audioChatModel(m)) return 'openai-audio';
|
|
133
|
+
if (m.provider === 'gemini') return 'gemini';
|
|
134
|
+
if (OPENAI_SHAPED_PROVIDERS.includes(m.provider)) return 'openai-compatible';
|
|
135
|
+
const id = String(m.id || '').toLowerCase();
|
|
136
|
+
if (id.startsWith('google/')) return 'gemini';
|
|
137
|
+
return DEFAULT_KIND;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// audioChatModel(model) -> boolean
|
|
141
|
+
//
|
|
142
|
+
// Does this model have to be transcribed through /chat/completions with an
|
|
143
|
+
// inline audio part, rather than through /audio/transcriptions?
|
|
144
|
+
//
|
|
145
|
+
// OpenRouter is the reason this exists, and the reason is structural rather
|
|
146
|
+
// than cosmetic. Its /models catalog is sliced by output modality, and a Google
|
|
147
|
+
// chat model is filed under `output_modalities: ["text"]` — the transcription
|
|
148
|
+
// slice does not list it. So `google/gemini-3.5-flash` is offered by the chat
|
|
149
|
+
// picker, reports `audio` among its input modalities, answers a transcription
|
|
150
|
+
// with a perfect transcript through /chat/completions… and is answered by
|
|
151
|
+
// /audio/transcriptions with `400 Model … does not exist`, because that
|
|
152
|
+
// endpoint serves only the 21 rows of the transcription slice (`google/chirp-3`
|
|
153
|
+
// among them). "Google models are not available on OpenRouter" for dictation is
|
|
154
|
+
// exactly this: the Google models that can hear are all on the chat route.
|
|
155
|
+
//
|
|
156
|
+
// The signal is the provider's own capability report — audio in, and not
|
|
157
|
+
// already a declared transcriber (those go to the multipart route, which is
|
|
158
|
+
// what the speech-to-text slice is for):
|
|
159
|
+
//
|
|
160
|
+
// * `outputModalities` naming `transcription` -> not this: it has a real
|
|
161
|
+
// /audio/transcriptions entry, and that is the cheaper, purpose-built call;
|
|
162
|
+
// * `inputModalities` naming `audio` -> this, when the connection is
|
|
163
|
+
// OpenAI-shaped. A provider whose report we do have said the words, so it
|
|
164
|
+
// is an answer rather than a guess;
|
|
165
|
+
// * an id the hint list recognises (`…-transcribe`, `whisper-…`) -> not
|
|
166
|
+
// this: the name says where it belongs, and the name predates the report.
|
|
167
|
+
//
|
|
168
|
+
// Nothing here decides what is *offered* — that is isTranscriptionModel — only
|
|
169
|
+
// which of the two OpenAI-shaped routes the offered row is sent down.
|
|
170
|
+
//
|
|
171
|
+
// One id shape is refused outright: OpenRouter's `:batch` rows (`google/gemini-3.8-flash:batch`,
|
|
172
|
+
// and 76 like it). They are in the chat catalog with the same capability report
|
|
173
|
+
// as their interactive twins, and every one of them answers
|
|
174
|
+
// `404 This model is only available through the Batch API. Use the
|
|
175
|
+
// /api/beta/batches endpoint instead.` — the *endpoint* is a different product,
|
|
176
|
+
// reachable by submitting a job file and polling it, which is not something a
|
|
177
|
+
// microphone tap can do. Suffix-matching here (never on the vendor prefix) is
|
|
178
|
+
// the provider's own naming, so it is a report rather than a guess.
|
|
179
|
+
function audioChatModel(model) {
|
|
180
|
+
const m = model || {};
|
|
181
|
+
if (!OPENAI_SHAPED_PROVIDERS.includes(m.provider)) return false;
|
|
182
|
+
if (/:batch$/.test(String(m.id || '').toLowerCase())) return false;
|
|
183
|
+
if (hintedById(m)) return false;
|
|
184
|
+
const outputs = reportedOutputModalities(m);
|
|
185
|
+
if (outputs && outputs.includes('transcription')) return false;
|
|
186
|
+
return acceptsAudioInput(m);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// markedForTranscription(model) — the user said so, either with the short form
|
|
190
|
+
// (`transcription: true`) or the long form (`transcription: { kind, … }`).
|
|
191
|
+
function markedForTranscription(model) {
|
|
192
|
+
const t = model && model.transcription;
|
|
193
|
+
if (t === true) return true;
|
|
194
|
+
if (t && typeof t === 'object' && Object.keys(t).length) return true;
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// hintedById(model) — the model id looks like a speech-to-text model. This is
|
|
199
|
+
// only ever used to *narrow* a candidate list; it never changes the request
|
|
200
|
+
// shape on its own.
|
|
201
|
+
function hintedById(model) {
|
|
202
|
+
const id = String((model && model.id) || '').toLowerCase();
|
|
203
|
+
return OPENAI_MODEL_HINTS.some((hint) => id.includes(hint));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// isTranscriptionModel(model) — this model plausibly transcribes. Five signals,
|
|
207
|
+
// most trustworthy first:
|
|
208
|
+
//
|
|
209
|
+
// 1. the user marked it (`transcription: true|{…}`);
|
|
210
|
+
// 2. the provider said what the model *produces* and `transcription` is in
|
|
211
|
+
// that report. This settles the question in both directions: a report
|
|
212
|
+
// that names outputs and not `transcription` is a no, however much audio
|
|
213
|
+
// the row accepts — `openai/gpt-audio` and `google/gemini-2.5-flash` on
|
|
214
|
+
// OpenRouter both take audio input and are both answered by
|
|
215
|
+
// /audio/transcriptions with `400 Model … does not exist`. Only where
|
|
216
|
+
// there is no report at all does the filter guess from names;
|
|
217
|
+
// 3. its id looks like a speech-to-text model (whisper, voxtral, …);
|
|
218
|
+
// 4. it resolves to the Gemini family — there is no separate Gemini
|
|
219
|
+
// speech-to-text product; audio is an inline part on the general
|
|
220
|
+
// multimodal models, so the whole Gemini catalog counts;
|
|
221
|
+
// 5. the provider says the model accepts audio input (`inputModalities` from
|
|
222
|
+
// OpenRouter's `architecture` block) without saying what it produces.
|
|
223
|
+
//
|
|
224
|
+
// Signal 4 is why the family inference has to be precise (see kindForModel): a
|
|
225
|
+
// loose `id.includes('gemini')` test made every Google-ish row on OpenRouter "a
|
|
226
|
+
// Gemini model", so a 445-model catalog filtered down to little but Google
|
|
227
|
+
// entries — which reads as "there are only Google models".
|
|
228
|
+
//
|
|
229
|
+
// Signals 3 and 5 are what keep the answer honest where signal 2 cannot help: a
|
|
230
|
+
// provider that reports nothing but names (`whisper-1`) or nothing but input
|
|
231
|
+
// modalities (`mistralai/voxtral-small-24b-2507`) still gets its rows offered.
|
|
232
|
+
//
|
|
233
|
+
// This is a *filter*, never a guarantee: the fallback in
|
|
234
|
+
// transcriptionCandidates means a project whose models are all unrecognisable
|
|
235
|
+
// still gets offered everything, and the user picks.
|
|
236
|
+
function isTranscriptionModel(model) {
|
|
237
|
+
if (!model) return false;
|
|
238
|
+
if (markedForTranscription(model)) return true;
|
|
239
|
+
const outputs = reportedOutputModalities(model);
|
|
240
|
+
if (outputs) return outputs.includes('transcription') || audioChatModel(model);
|
|
241
|
+
if (hintedById(model) || kindForModel(model) === 'gemini') return true;
|
|
242
|
+
return acceptsAudioInput(model);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// reportedOutputModalities(model) — what the provider says this model produces
|
|
246
|
+
// (`architecture.output_modalities` upstream, carried onto the record as
|
|
247
|
+
// `outputModalities` by src/ai-endpoints.js), lowercased, or null when the
|
|
248
|
+
// provider said nothing. Absent is "unknown", never "no".
|
|
249
|
+
function reportedOutputModalities(model) {
|
|
250
|
+
const list = model && model.outputModalities;
|
|
251
|
+
if (!Array.isArray(list) || !list.length) return null;
|
|
252
|
+
return list.map((x) => String(x).toLowerCase());
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// acceptsAudioInput(model) — the provider listed `audio` among the model's
|
|
256
|
+
// input modalities. Absent (most providers, and every project model) means
|
|
257
|
+
// "unknown", never "no".
|
|
258
|
+
function acceptsAudioInput(model) {
|
|
259
|
+
const list = model && model.inputModalities;
|
|
260
|
+
return Array.isArray(list) && list.some((x) => String(x).toLowerCase() === 'audio');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// transcriptionCandidates(models) — the models a dictation picker should
|
|
264
|
+
// offer, in the order given:
|
|
265
|
+
//
|
|
266
|
+
// 1. every model that looks like it can transcribe (see
|
|
267
|
+
// isTranscriptionModel) — the union, so marking one model *adds* it
|
|
268
|
+
// rather than hiding the rest;
|
|
269
|
+
// 2. otherwise, every model — *unless* the provider reported what each of
|
|
270
|
+
// them produces and none of them transcribes. That is an answer, not a
|
|
271
|
+
// gap: a catalog of chat models that accept audio (`openai/gpt-audio`,
|
|
272
|
+
// `google/gemini-2.5-flash`) is rejected wholesale by
|
|
273
|
+
// /audio/transcriptions, so offering it is the provider error the
|
|
274
|
+
// filter exists to prevent. Only rows the provider left unclassified
|
|
275
|
+
// keep the fallback.
|
|
276
|
+
//
|
|
277
|
+
// `openai/gpt-audio` and `google/gemini-2.5-flash` are the two rows that used
|
|
278
|
+
// to sit on the wrong side of both halves and therefore read as "Google models
|
|
279
|
+
// are not available on OpenRouter": reported as producing `text`, so not
|
|
280
|
+
// transcription models, and offered by nobody. They are *not* rejected by the
|
|
281
|
+
// provider — only by /audio/transcriptions, the endpoint they were never
|
|
282
|
+
// supposed to take. Their report says `audio` goes in, which is enough to route
|
|
283
|
+
// them through /chat/completions instead (see audioChatModel), so isTranscriptionModel
|
|
284
|
+
// accepting them is the fix rather than a regression.
|
|
285
|
+
//
|
|
286
|
+
// The fallback in step 2 exists because a self-hosted endpoint (`…/v1` with a
|
|
287
|
+
// model called `parakeet` or `my-asr`) is perfectly valid and nothing here can
|
|
288
|
+
// recognise it — the user picks. Step 1 is deliberately *narrow*: a live
|
|
289
|
+
// catalog is hundreds of chat models, and offering all of them would bury the
|
|
290
|
+
// handful that transcribe.
|
|
291
|
+
function transcriptionCandidates(models) {
|
|
292
|
+
const list = (Array.isArray(models) ? models : []).filter((m) => m && m.id);
|
|
293
|
+
const recognisable = list.filter(isTranscriptionModel);
|
|
294
|
+
if (recognisable.length) return recognisable;
|
|
295
|
+
return list.some((m) => !reportedOutputModalities(m)) ? list : [];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// mimeTypeFor(filename) — best-effort content type from the recorder's file
|
|
299
|
+
// name. Containers we do not know are sent as application/octet-stream: every
|
|
300
|
+
// provider we support sniffs the bytes anyway, and guessing a wrong type is
|
|
301
|
+
// worse than saying nothing.
|
|
302
|
+
function mimeTypeFor(filename) {
|
|
303
|
+
const name = String(filename || '').toLowerCase();
|
|
304
|
+
if (name.endsWith('.webm')) return 'audio/webm';
|
|
305
|
+
if (name.endsWith('.ogg') || name.endsWith('.oga')) return 'audio/ogg';
|
|
306
|
+
if (name.endsWith('.mp4') || name.endsWith('.m4a')) return 'audio/mp4';
|
|
307
|
+
if (name.endsWith('.mp3')) return 'audio/mpeg';
|
|
308
|
+
if (name.endsWith('.wav')) return 'audio/wav';
|
|
309
|
+
if (name.endsWith('.flac')) return 'audio/flac';
|
|
310
|
+
return 'application/octet-stream';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// joinUrl() comes from src/util.js so the trailing-slash handling matches the
|
|
314
|
+
// chat client's (a baseUrl with a trailing slash does not double it).
|
|
315
|
+
const { joinUrl } = require('./util.js');
|
|
316
|
+
|
|
317
|
+
// defaultPathFor(kind) — the conventional endpoint appended to a baseUrl when
|
|
318
|
+
// the model record does not carry one.
|
|
319
|
+
function defaultPathFor(kind) {
|
|
320
|
+
if (kind === 'gemini') return '/v1beta/models/{model}:generateContent';
|
|
321
|
+
if (kind === 'openai-audio') return '/chat/completions';
|
|
322
|
+
return '/audio/transcriptions';
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// resolveUrl(kind, model) — the upstream URL. A model record may carry
|
|
326
|
+
// `transcription.path` for providers whose endpoint is not the convention
|
|
327
|
+
// (Azure's deployment URL, a self-hosted whisper.cpp server, ...). `{model}`
|
|
328
|
+
// is substituted for Gemini's per-model action path.
|
|
329
|
+
function resolveUrl(kind, model) {
|
|
330
|
+
const baseUrl = String(model.baseUrl || '').replace(/\/+$/, '');
|
|
331
|
+
if (!baseUrl) {
|
|
332
|
+
const e = new Error('No base URL for this model — configure the provider connection first');
|
|
333
|
+
e.code = 'ENOBASEURL';
|
|
334
|
+
throw e;
|
|
335
|
+
}
|
|
336
|
+
const path = model.transcription && typeof model.transcription.path === 'string' && model.transcription.path
|
|
337
|
+
? model.transcription.path
|
|
338
|
+
: defaultPathFor(kind);
|
|
339
|
+
return joinUrl(baseUrl, path.replace('{model}', encodeURIComponent(model.id || '')));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---- multipart/form-data ------------------------------------------------
|
|
343
|
+
//
|
|
344
|
+
// Built by hand rather than with FormData + fetch: the server-side `fetch` in
|
|
345
|
+
// this Node version happily takes a FormData, but hand-building keeps the
|
|
346
|
+
// request shape identical to the documented OpenAI curl form, keeps the byte
|
|
347
|
+
// count exact for the error messages, and makes the request builder a pure
|
|
348
|
+
// function a test can assert on.
|
|
349
|
+
function buildMultipartBody(parts) {
|
|
350
|
+
const boundary = '----mouaif' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
|
|
351
|
+
const chunks = [];
|
|
352
|
+
for (const part of parts) {
|
|
353
|
+
if (part.filename !== undefined) {
|
|
354
|
+
chunks.push(Buffer.from(
|
|
355
|
+
'--' + boundary + '\r\n'
|
|
356
|
+
+ 'Content-Disposition: form-data; name="' + part.name + '"; filename="' + part.filename + '"\r\n'
|
|
357
|
+
+ 'Content-Type: ' + (part.contentType || 'application/octet-stream') + '\r\n\r\n'
|
|
358
|
+
));
|
|
359
|
+
chunks.push(Buffer.from(part.data));
|
|
360
|
+
chunks.push(Buffer.from('\r\n'));
|
|
361
|
+
} else {
|
|
362
|
+
chunks.push(Buffer.from(
|
|
363
|
+
'--' + boundary + '\r\n'
|
|
364
|
+
+ 'Content-Disposition: form-data; name="' + part.name + '"\r\n\r\n'
|
|
365
|
+
+ part.value + '\r\n'
|
|
366
|
+
));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
chunks.push(Buffer.from('--' + boundary + '--\r\n'));
|
|
370
|
+
return { boundary, body: Buffer.concat(chunks) };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// buildTranscribeRequest(options)
|
|
374
|
+
//
|
|
375
|
+
// options:
|
|
376
|
+
// kind family id (kindForModel)
|
|
377
|
+
// model the resolved model record ({ id, provider, baseUrl, auth, ... })
|
|
378
|
+
// apiKey the credential for that provider (app-level connection)
|
|
379
|
+
// audio Buffer/Uint8Array of the recording
|
|
380
|
+
// mimeType content type of `audio` (mimeTypeFor)
|
|
381
|
+
// language optional BCP-47 / ISO-639-1 hint ('en', 'fr')
|
|
382
|
+
// prompt optional vocabulary or context hint the provider may use
|
|
383
|
+
//
|
|
384
|
+
// Returns { url, method, headers, body }.
|
|
385
|
+
function buildTranscribeRequest(options) {
|
|
386
|
+
const opts = options || {};
|
|
387
|
+
const kind = KIND_IDS.includes(opts.kind) ? opts.kind : DEFAULT_KIND;
|
|
388
|
+
const model = opts.model || {};
|
|
389
|
+
const audio = opts.audio;
|
|
390
|
+
if (!audio || !audio.length) {
|
|
391
|
+
const e = new Error('No audio to transcribe');
|
|
392
|
+
e.code = 'EEMPTYAUDIO';
|
|
393
|
+
throw e;
|
|
394
|
+
}
|
|
395
|
+
if (audio.length > MAX_AUDIO_BYTES) {
|
|
396
|
+
const e = new Error('Recording is too large to transcribe (' + audio.length + ' bytes, max ' + MAX_AUDIO_BYTES + ')');
|
|
397
|
+
e.code = 'ETOOLARGE';
|
|
398
|
+
throw e;
|
|
399
|
+
}
|
|
400
|
+
const language = typeof opts.language === 'string' ? opts.language.trim() : '';
|
|
401
|
+
const prompt = typeof opts.prompt === 'string' ? opts.prompt : '';
|
|
402
|
+
const url = resolveUrl(kind, model);
|
|
403
|
+
|
|
404
|
+
if (kind === 'gemini') {
|
|
405
|
+
// Gemini takes the audio inline as a base64 data part. No key is put on
|
|
406
|
+
// the URL — the header form keeps the credential out of logs.
|
|
407
|
+
const body = {
|
|
408
|
+
contents: [{
|
|
409
|
+
role: 'user',
|
|
410
|
+
parts: [
|
|
411
|
+
{ text: prompt || DEFAULT_TRANSCRIBE_PROMPT },
|
|
412
|
+
{ inline_data: { mime_type: opts.mimeType || 'audio/webm', data: Buffer.from(audio).toString('base64') } }
|
|
413
|
+
]
|
|
414
|
+
}],
|
|
415
|
+
generationConfig: { temperature: 0 }
|
|
416
|
+
};
|
|
417
|
+
if (language) {
|
|
418
|
+
body.contents[0].parts.push({ text: 'The recording is in the language with code "' + language + '". Transcribe it in that language.' });
|
|
419
|
+
}
|
|
420
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
421
|
+
if (opts.apiKey) headers['x-goog-api-key'] = opts.apiKey;
|
|
422
|
+
return { url, method: 'POST', headers, body: Buffer.from(JSON.stringify(body)) };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// OpenAI-shaped inline audio: an ordinary chat completion whose user message
|
|
426
|
+
// carries the recording as an `input_audio` part. This is the route for a
|
|
427
|
+
// model that can hear but has no /audio/transcriptions entry — on OpenRouter
|
|
428
|
+
// that is every Google chat model (`google/gemini-3.5-flash`, `~google/…`),
|
|
429
|
+
// which that endpoint rejects with `400 Model … does not exist`.
|
|
430
|
+
//
|
|
431
|
+
// The audio rides base64 in a JSON body (that is the only shape the OpenAI
|
|
432
|
+
// chat API defines for it), and the format is named by label rather than by
|
|
433
|
+
// MIME type: the field accepts `wav`/`mp3`/`ogg`/`webm`/`flac`/`m4a`, not
|
|
434
|
+
// `audio/webm;codecs=opus`. `audioFormatFor` does that mapping, and the bytes
|
|
435
|
+
// themselves are sniffed upstream, so a label that disagrees with the
|
|
436
|
+
// container is tolerated (verified against OpenRouter: `webm` bytes labelled
|
|
437
|
+
// `mp3` still transcribe).
|
|
438
|
+
if (kind === 'openai-audio') {
|
|
439
|
+
const body = {
|
|
440
|
+
model: model.id || '',
|
|
441
|
+
messages: [{
|
|
442
|
+
role: 'user',
|
|
443
|
+
content: [
|
|
444
|
+
{ type: 'text', text: prompt || DEFAULT_TRANSCRIBE_PROMPT },
|
|
445
|
+
{
|
|
446
|
+
type: 'input_audio',
|
|
447
|
+
input_audio: {
|
|
448
|
+
data: Buffer.from(audio).toString('base64'),
|
|
449
|
+
format: audioFormatFor(opts.mimeType, opts.filename)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
]
|
|
453
|
+
}]
|
|
454
|
+
};
|
|
455
|
+
if (language) {
|
|
456
|
+
// No `language` field exists on this shape, so the instruction is part of
|
|
457
|
+
// the prompt instead — the model is a language model, and it obeys.
|
|
458
|
+
body.messages[0].content.push({
|
|
459
|
+
type: 'text',
|
|
460
|
+
text: 'The recording is in the language with code "' + language + '". Transcribe it in that language.'
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
464
|
+
if (opts.apiKey) headers['Authorization'] = 'Bearer ' + opts.apiKey;
|
|
465
|
+
return { url, method: 'POST', headers, body: Buffer.from(JSON.stringify(body)) };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// OpenAI-shaped multipart: multipart/form-data with `file` + `model`,
|
|
469
|
+
// `language` and `prompt`. The field is named `file` (not `audio`) as the
|
|
470
|
+
// OpenAI media API documents; some self-hosted servers also accept `audio`,
|
|
471
|
+
// but `file` is the interoperable spelling.
|
|
472
|
+
const filename = typeof opts.filename === 'string' && opts.filename ? opts.filename : ('dictation' + extensionFor(opts.mimeType));
|
|
473
|
+
const fields = [
|
|
474
|
+
{ name: 'file', filename, contentType: opts.mimeType || mimeTypeFor(filename), data: audio },
|
|
475
|
+
{ name: 'model', value: model.id || '' }
|
|
476
|
+
];
|
|
477
|
+
if (language) fields.push({ name: 'language', value: language });
|
|
478
|
+
if (prompt) fields.push({ name: 'prompt', value: prompt });
|
|
479
|
+
const { boundary, body } = buildMultipartBody(fields);
|
|
480
|
+
const headers = { 'Content-Type': 'multipart/form-data; boundary=' + boundary };
|
|
481
|
+
if (opts.apiKey) headers['Authorization'] = 'Bearer ' + opts.apiKey;
|
|
482
|
+
return { url, method: 'POST', headers, body };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// extensionFor(mimeType) — the file name extension providers see. Only used
|
|
486
|
+
// for the multipart filename; content sniffing is the provider's job.
|
|
487
|
+
function extensionFor(mimeType) {
|
|
488
|
+
const type = String(mimeType || '').toLowerCase();
|
|
489
|
+
if (type.includes('webm')) return '.webm';
|
|
490
|
+
if (type.includes('ogg')) return '.ogg';
|
|
491
|
+
if (type.includes('mp4') || type.includes('m4a')) return '.m4a';
|
|
492
|
+
if (type.includes('mpeg')) return '.mp3';
|
|
493
|
+
if (type.includes('wav')) return '.wav';
|
|
494
|
+
if (type.includes('flac')) return '.flac';
|
|
495
|
+
return '.webm';
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ---- Response parsing ---------------------------------------------------
|
|
499
|
+
|
|
500
|
+
// usageFromResponse(kind, text) -> { promptTokens, completionTokens } | null
|
|
501
|
+
//
|
|
502
|
+
// What the provider says the request cost, in the shape src/usage.js
|
|
503
|
+
// `computeCost` prices (`promptTokens` / `completionTokens`, i.e. the whole
|
|
504
|
+
// input — a transcription has no cache buckets). Two report styles ship today:
|
|
505
|
+
//
|
|
506
|
+
// * OpenAI-shaped: `usage: { type: "tokens", input_tokens, output_tokens }`
|
|
507
|
+
// on the models that report it at all (`gpt-4o-transcribe`, `whisper-1`
|
|
508
|
+
// historically answers with the transcript and nothing else);
|
|
509
|
+
// * Gemini: `usageMetadata: { promptTokenCount, candidatesTokenCount }` on
|
|
510
|
+
// generateContent, where the audio rides in the prompt.
|
|
511
|
+
//
|
|
512
|
+
// `null` means "the provider did not say", which is different from zero: the
|
|
513
|
+
// cost line renders `--` rather than a fabricated `$0.00`. That matters here
|
|
514
|
+
// more than in chat, because the transcription models that bill by the minute
|
|
515
|
+
// (`whisper-1`) report no tokens at all.
|
|
516
|
+
function usageFromResponse(kind, text) {
|
|
517
|
+
let parsed;
|
|
518
|
+
try { parsed = JSON.parse(text || '{}'); } catch { return null; }
|
|
519
|
+
const raw = kind === 'gemini' ? parsed.usageMetadata : parsed.usage;
|
|
520
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
521
|
+
// An inline-audio chat completion reports its own `cost` alongside the token
|
|
522
|
+
// counts (OpenRouter's authoritative billed figure). It cannot ride in the
|
|
523
|
+
// `{ promptTokens, completionTokens }` shape the cost layer prices, so it is
|
|
524
|
+
// dropped here and the built-in table prices the run instead — the number is
|
|
525
|
+
// still on the response if a caller ever wants to prefer it.
|
|
526
|
+
const prompt = num(raw.promptTokenCount, raw.input_tokens, raw.prompt_tokens);
|
|
527
|
+
const completion = num(raw.candidatesTokenCount, raw.output_tokens, raw.completion_tokens);
|
|
528
|
+
if (prompt === null && completion === null) return null;
|
|
529
|
+
// A provider that reports one side only still tells us something usable.
|
|
530
|
+
return { promptTokens: prompt || 0, completionTokens: completion || 0 };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// num(...values) — the first of these that reads as a non-negative number, else
|
|
534
|
+
// null. Absent and zero are different answers here (see usageFromResponse).
|
|
535
|
+
function num(...values) {
|
|
536
|
+
for (const v of values) {
|
|
537
|
+
if (typeof v === 'number' && isFinite(v) && v >= 0) return v;
|
|
538
|
+
if (typeof v === 'string' && v.trim() !== '' && isFinite(Number(v)) && Number(v) >= 0) return Number(v);
|
|
539
|
+
}
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// unwrapGeminiText(text) — concatenate the transcript out of a generateContent
|
|
544
|
+
// response, skipping the safety/usage noise.
|
|
545
|
+
//
|
|
546
|
+
// Two response shapes carry a transcript, and a model uses one or the other:
|
|
547
|
+
//
|
|
548
|
+
// * a general model (`gemini-3.8-flash`) answers in `part.text` — the audio
|
|
549
|
+
// is just another input and the transcript is just another reply;
|
|
550
|
+
// * a purpose-built transcription model (`gemini-3.5-transcribe`) answers in
|
|
551
|
+
// `part.audioTranscription.text`, with `part.text` present but **empty**.
|
|
552
|
+
//
|
|
553
|
+
// Reading only `part.text` made the second shape look like a provider failure:
|
|
554
|
+
// HTTP 200, a real transcript in the body, and `EEMPTY` — "the provider
|
|
555
|
+
// returned no transcript" — for the user. Both fields are read here, in
|
|
556
|
+
// candidate order, so either shape parses. A part may carry both (a model that
|
|
557
|
+
// both answers and labels its audio); `text` wins for that part so one sentence
|
|
558
|
+
// is never emitted twice.
|
|
559
|
+
function unwrapGeminiText(text) {
|
|
560
|
+
let parsed;
|
|
561
|
+
try { parsed = JSON.parse(text || '{}'); } catch { return ''; }
|
|
562
|
+
const candidates = Array.isArray(parsed.candidates) ? parsed.candidates : [];
|
|
563
|
+
const out = [];
|
|
564
|
+
for (const candidate of candidates) {
|
|
565
|
+
const parts = candidate && candidate.content && Array.isArray(candidate.content.parts)
|
|
566
|
+
? candidate.content.parts
|
|
567
|
+
: [];
|
|
568
|
+
for (const part of parts) {
|
|
569
|
+
if (!part) continue;
|
|
570
|
+
if (typeof part.text === 'string' && part.text) { out.push(part.text); continue; }
|
|
571
|
+
const spoken = part.audioTranscription && part.audioTranscription.text;
|
|
572
|
+
if (typeof spoken === 'string' && spoken) out.push(spoken);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return out.join('').trim();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// unwrapOpenAIChatText(text) -> string | null
|
|
579
|
+
//
|
|
580
|
+
// The transcript out of an OpenAI-shaped chat completion. `content` may be a
|
|
581
|
+
// plain string or the newer array-of-parts form (`[{ type: 'text', text }]`),
|
|
582
|
+
// and models that think put the transcript in `content` with the reasoning
|
|
583
|
+
// beside it — so only `content` is read, never `reasoning`.
|
|
584
|
+
//
|
|
585
|
+
// `null` means the body was not a chat completion at all (the caller reports
|
|
586
|
+
// it as unreadable); `''` means a completion with nothing in it (no
|
|
587
|
+
// transcript). The two are different failures and the messages differ.
|
|
588
|
+
function unwrapOpenAIChatText(text) {
|
|
589
|
+
let parsed;
|
|
590
|
+
try { parsed = JSON.parse(text || '{}'); } catch { return null; }
|
|
591
|
+
const choices = Array.isArray(parsed.choices) ? parsed.choices : null;
|
|
592
|
+
if (!choices || !choices.length) return null;
|
|
593
|
+
const message = (choices[0] && choices[0].message) || null;
|
|
594
|
+
if (!message) return null;
|
|
595
|
+
const content = message.content;
|
|
596
|
+
if (typeof content === 'string') return content.trim();
|
|
597
|
+
if (Array.isArray(content)) {
|
|
598
|
+
return content
|
|
599
|
+
.map((part) => (part && typeof part.text === 'string' ? part.text : ''))
|
|
600
|
+
.join('')
|
|
601
|
+
.trim();
|
|
602
|
+
}
|
|
603
|
+
return '';
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// upstreamErrorMessage(kind, text) — the provider's own words, falling back to
|
|
607
|
+
// a truncated body. A silent failure here is worse than a long message: this
|
|
608
|
+
// is the only diagnostic the user gets for a misconfigured endpoint.
|
|
609
|
+
function upstreamErrorMessage(kind, text) {
|
|
610
|
+
const raw = String(text || '').trim();
|
|
611
|
+
try {
|
|
612
|
+
const parsed = JSON.parse(raw || '{}');
|
|
613
|
+
const message = (parsed.error && (parsed.error.message || parsed.error.status))
|
|
614
|
+
|| parsed.message
|
|
615
|
+
|| parsed.detail;
|
|
616
|
+
if (typeof message === 'string' && message.trim()) return message.trim();
|
|
617
|
+
} catch { /* not JSON: fall through to the raw body */ }
|
|
618
|
+
return raw.length > 300 ? raw.slice(0, 300) + '…' : raw;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// parseTranscribeResponse(kind, status, text)
|
|
622
|
+
//
|
|
623
|
+
// -> { text, usage } on success (`usage` is null when the
|
|
624
|
+
// provider reported no token counts)
|
|
625
|
+
// -> { error, code } on failure (the HTTP layer maps this
|
|
626
|
+
// to a status it can explain)
|
|
627
|
+
function parseTranscribeResponse(kind, status, text) {
|
|
628
|
+
const ok = typeof status === 'number' && status >= 200 && status < 300;
|
|
629
|
+
if (!ok) {
|
|
630
|
+
return {
|
|
631
|
+
error: upstreamErrorMessage(kind, text) || ('Transcription failed with HTTP ' + status),
|
|
632
|
+
code: status === 401 || status === 403 ? 'ENOAUTH' : 'EUPSTREAM',
|
|
633
|
+
status
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
const usage = usageFromResponse(kind, text);
|
|
637
|
+
if (kind === 'gemini') {
|
|
638
|
+
const transcript = unwrapGeminiText(text);
|
|
639
|
+
if (!transcript) return { error: 'The provider returned no transcript', code: 'EEMPTY' };
|
|
640
|
+
return { text: transcript, usage };
|
|
641
|
+
}
|
|
642
|
+
// The inline-audio chat shape answers with a completion, not a transcript
|
|
643
|
+
// object: `choices[0].message.content`. Read before the multipart branch,
|
|
644
|
+
// which expects `{ text }` and would call this response unreadable.
|
|
645
|
+
if (kind === 'openai-audio') {
|
|
646
|
+
const content = unwrapOpenAIChatText(text);
|
|
647
|
+
if (content === null) return { error: 'The provider returned an unreadable response', code: 'EBADUPSTREAM' };
|
|
648
|
+
if (!content) return { error: 'The provider returned no transcript', code: 'EEMPTY' };
|
|
649
|
+
return { text: content, usage };
|
|
650
|
+
}
|
|
651
|
+
let parsed;
|
|
652
|
+
try { parsed = JSON.parse(text || '{}'); } catch {
|
|
653
|
+
return { error: 'The provider returned an unreadable response', code: 'EBADUPSTREAM' };
|
|
654
|
+
}
|
|
655
|
+
if (typeof parsed.text === 'string') return { text: parsed.text.trim(), usage };
|
|
656
|
+
// Some self-hosted servers answer with { transcript } or { result }.
|
|
657
|
+
if (typeof parsed.transcript === 'string') return { text: parsed.transcript.trim(), usage };
|
|
658
|
+
if (typeof parsed.result === 'string') return { text: parsed.result.trim(), usage };
|
|
659
|
+
if (parsed.error) return { error: upstreamErrorMessage(kind, text), code: 'EUPSTREAM' };
|
|
660
|
+
return { error: 'The provider returned no transcript', code: 'EEMPTY' };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
module.exports = {
|
|
664
|
+
TRANSCRIBE_KINDS,
|
|
665
|
+
KIND_IDS,
|
|
666
|
+
DEFAULT_KIND,
|
|
667
|
+
MAX_AUDIO_BYTES,
|
|
668
|
+
DEFAULT_TIMEOUT_MS,
|
|
669
|
+
DEFAULT_TRANSCRIBE_PROMPT,
|
|
670
|
+
kindForModel,
|
|
671
|
+
audioChatModel,
|
|
672
|
+
transcriptionCandidates,
|
|
673
|
+
isTranscriptionModel,
|
|
674
|
+
reportedOutputModalities,
|
|
675
|
+
acceptsAudioInput,
|
|
676
|
+
mimeTypeFor,
|
|
677
|
+
audioFormatFor,
|
|
678
|
+
buildTranscribeRequest,
|
|
679
|
+
parseTranscribeResponse,
|
|
680
|
+
usageFromResponse,
|
|
681
|
+
extensionFor,
|
|
682
|
+
resolveUrl
|
|
683
|
+
};
|