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,1880 @@
1
+ 'use strict';
2
+
3
+ // Provider registry + request builders + event parsers for the AI client.
4
+ //
5
+ // Implements docs/decisions.md section 10: server-side proxy with SSE
6
+ // streaming for the configured providers. The mobile UI never holds an
7
+ // API key — it POSTs to /api/ai/chat and reads the SSE stream back.
8
+ //
9
+ // This module owns the provider definitions (ENDPOINTS), the model-list
10
+ // adapters (listModels / listTranscriptionModels / listImageModels), the request builders
11
+ // (BUILDERS), and the event parsers (PARSERS). The multi-turn streaming loop
12
+ // lives in src/ai-stream.js; the public facade is src/ai.js.
13
+ //
14
+ // Adding a provider is a localized change: an ENDPOINTS entry here, a
15
+ // BUILDERS + PARSERS entry at the bottom, and (if the auth shape
16
+ // differs) an entry in src/auth.js.
17
+
18
+ const { joinUrl, firstStringField } = require('./util.js');
19
+
20
+ // openAIShapedListModels(def, cred, signal) — shared fetch + error
21
+ // mapping for the OpenAI-shaped /models adapters (openai-compatible,
22
+ // openrouter, azure, mistral, groq, deepseek), which previously each
23
+ // carried an identical copy of this scaffold. `def`:
24
+ // name provider key (for typed errors)
25
+ // url the /models endpoint
26
+ // authHeader(cred) header builder (same shape as ENDPOINTS entries)
27
+ // thinkingFor model -> { kind } mapper for parseOpenAIShapedModels
28
+ // requireCred true -> 401/403 with no cred is a typed ENO_APIKEY
29
+ // (openai-compatible, azure, mistral, groq, deepseek);
30
+ // false -> unauthenticated list works (openrouter)
31
+ async function openAIShapedListModels(def, cred, signal) {
32
+ let r;
33
+ try {
34
+ r = await fetch(def.url, { headers: cred ? def.authHeader(cred) : {}, signal });
35
+ } catch (e) { throw unreachableError(def.name, e); }
36
+ if (def.requireCred !== false && (r.status === 401 || r.status === 403)) {
37
+ if (!cred) throw noApiKeyError(def.name);
38
+ throw httpError(r);
39
+ }
40
+ if (!r.ok) throw httpError(r);
41
+ const body = await r.json();
42
+ return parseOpenAIShapedModels(body, def.thinkingFor);
43
+ }
44
+
45
+ // ---- Provider endpoints ------------------------------------------------
46
+
47
+ const ENDPOINTS = {
48
+ 'openai-compatible': {
49
+ chatPath: '/chat/completions',
50
+ authHeader: (apiKey) => ({ 'Authorization': 'Bearer ' + apiKey }),
51
+ // GET {baseUrl}/models — OpenAI-shaped. Optional key in practice,
52
+ // but in this env the upstream returns 401 when no Authorization
53
+ // header is sent, so treat "no cred" as a typed ENO_APIKEY error
54
+ // instead of a generic upstream 401.
55
+ listModels: async (cred, signal) => openAIShapedListModels({
56
+ name: 'openai-compatible',
57
+ url: (ENDPOINTS['openai-compatible'].baseUrl || 'https://api.openai.com/v1') + '/models',
58
+ authHeader: ENDPOINTS['openai-compatible'].authHeader,
59
+ thinkingFor: (m) => thinkingForOpenAIModel(m.id)
60
+ }, cred, signal)
61
+ },
62
+ 'anthropic': {
63
+ baseUrl: 'https://api.anthropic.com',
64
+ chatPath: '/v1/messages',
65
+ anthropicVersion: '2023-06-01',
66
+ // Anthropic does not expose a public list-models endpoint; the
67
+ // /v1/models beta is OAuth-only and is not reachable with a
68
+ // standard API key. Ship a curated catalog that mirrors the
69
+ // models documented at https://docs.claude.com/en/docs/about-claude/models.
70
+ // Every current Claude model supports extended thinking with a raw
71
+ // token budget, so the descriptor is uniform across the catalog.
72
+ listModels: async () => parseCuratedModels(ANTHROPIC_MODEL_CATALOG, { kind: 'budget' }),
73
+ // Per the official `ant` CLI source and the platform.claude.com
74
+ // docs: API-key auth uses the `x-api-key` header; OAuth user_oauth
75
+ // tokens use `Authorization: Bearer ...` and require the
76
+ // `anthropic-beta: oauth-2025-04-20` header. The header is decided
77
+ // at request time from model.auth so the same provider can serve
78
+ // both flows.
79
+ authHeader: (cred, model) => {
80
+ if (model && model.auth === 'oauth') {
81
+ return {
82
+ 'Authorization': 'Bearer ' + cred,
83
+ 'anthropic-beta': 'oauth-2025-04-20'
84
+ };
85
+ }
86
+ return { 'x-api-key': cred, 'anthropic-version': '2023-06-01' };
87
+ }
88
+ },
89
+ 'gemini': {
90
+ // Gemini uses a per-model action path; see buildRequest.
91
+ baseUrl: 'https://generativelanguage.googleapis.com',
92
+ authHeader: (apiKey) => ({ 'x-goog-api-key': apiKey }),
93
+ // GET /v1beta/models?key=<key> — Gemini-shaped (no Bearer header;
94
+ // key is a query param). The unauthenticated call used to be
95
+ // public, but the public list endpoint now returns 403 without
96
+ // a key, so a missing cred is a typed ENO_APIKEY error rather
97
+ // than a generic upstream 403.
98
+ listModels: async (cred, signal) => {
99
+ const url = ENDPOINTS.gemini.baseUrl + '/v1beta/models?pageSize=200'
100
+ + (cred ? '&key=' + encodeURIComponent(cred) : '');
101
+ let r;
102
+ try { r = await fetch(url, { signal }); }
103
+ catch (e) { throw unreachableError('gemini', e); }
104
+ if (r.status === 401 || r.status === 403) {
105
+ if (!cred) throw noApiKeyError('gemini');
106
+ throw httpError(r);
107
+ }
108
+ if (!r.ok) throw httpError(r);
109
+ const body = await r.json();
110
+ return parseGeminiModels(body);
111
+ },
112
+ // The same /v1beta/models response, read through the image slice: it keeps
113
+ // the `predict`-only Imagen rows the chat list drops, so the agent
114
+ // editor's model picker can offer them. There is no separate Gemini image
115
+ // endpoint — the catalogue is one list, filtered by generation method.
116
+ listImageModels: async (cred, signal) => {
117
+ const url = ENDPOINTS.gemini.baseUrl + '/v1beta/models?pageSize=200'
118
+ + (cred ? '&key=' + encodeURIComponent(cred) : '');
119
+ let r;
120
+ try { r = await fetch(url, { signal }); }
121
+ catch (e) { throw unreachableError('gemini', e); }
122
+ if (r.status === 401 || r.status === 403) {
123
+ if (!cred) throw noApiKeyError('gemini');
124
+ throw httpError(r);
125
+ }
126
+ if (!r.ok) throw httpError(r);
127
+ const body = await r.json();
128
+ return parseGeminiModels(body, { slice: 'image' });
129
+ }
130
+ },
131
+ 'ollama': {
132
+ baseUrl: 'http://127.0.0.1:11434',
133
+ chatPath: '/api/chat',
134
+ // No auth header. Ollama streams NDJSON, not SSE — we adapt below.
135
+ authHeader: () => ({}),
136
+ streamFormat: 'ndjson',
137
+ // GET /api/tags — Ollama's local catalog (no auth). When the local
138
+ // server is not running, fetch() throws TypeError("fetch failed")
139
+ // (Node 18+ collapses ECONNREFUSED / ENOTFOUND into a generic
140
+ // failure). Surface that as EUNREACHABLE so the HTTP layer can
141
+ // return 503 "service unavailable" instead of a misleading 502
142
+ // "bad gateway".
143
+ listModels: async (cred, signal) => {
144
+ const url = ENDPOINTS.ollama.baseUrl + '/api/tags';
145
+ let r;
146
+ try { r = await fetch(url, { signal }); }
147
+ catch (e) { throw unreachableError('ollama', e); }
148
+ if (!r.ok) throw httpError(r);
149
+ const body = await r.json();
150
+ return parseOllamaModels(body);
151
+ },
152
+ // Ollama exposes reasoning as a boolean `think` flag on the chat
153
+ // request for thinking-capable models (deepseek-r1, qwq, gpt-oss).
154
+ thinkingDescriptor: { kind: 'toggle' }
155
+ },
156
+ 'github-copilot': {
157
+ // The base URL points at the Copilot API. Calls require a
158
+ // short-lived Copilot token that is derived per-request from the
159
+ // GitHub OAuth token stored in the keychain. The exchange is
160
+ // performed by oauth-github-copilot.js and the resolved token
161
+ // lands on model.__accessToken (replacing the GitHub token that
162
+ // requireApiKey wrote there). The auth header is identical to
163
+ // the openai-compatible path: a plain Bearer credential.
164
+ baseUrl: 'https://api.githubcopilot.com',
165
+ chatPath: '/chat/completions',
166
+ authHeader: (cred) => ({ 'Authorization': 'Bearer ' + cred }),
167
+ // Copilot does not expose a public list-models endpoint. Return
168
+ // a small curated list of the model ids the Copilot API actually
169
+ // serves today. Kept in sync with the public Copilot docs; the
170
+ // `contextWindow` field is the upstream maximum. Thinking support
171
+ // is inferred per family: OpenAI reasoning models take effort
172
+ // levels, Claude models take a token budget, Gemini 2.5+ takes a
173
+ // thinking budget.
174
+ listModels: async () => parseCuratedModels(COPILOT_MODEL_CATALOG, (m) => {
175
+ const id = String(m.id || '');
176
+ if (/^(gpt-5|o\d)/.test(id)) return { kind: 'levels', levels: OPENAI_THINKING_LEVELS.slice() };
177
+ if (/^claude-/.test(id)) return { kind: 'budget' };
178
+ if (/^gemini-(2\.5|[3-9])/.test(id)) return { kind: 'budget' };
179
+ return undefined;
180
+ }),
181
+ // Copilot requires a handful of editor-identifying headers. The
182
+ // values mirror the public Copilot CLI; they identify this
183
+ // client as a third-party tool without sending PII. Tests can
184
+ // override these by setting model.headers at the call site.
185
+ staticHeaders: {
186
+ 'Editor-Version': 'vscode/1.95.0',
187
+ 'Editor-Plugin-Version': 'copilot/1.0.0',
188
+ 'Editor-Schema-Version': 'v1',
189
+ 'User-Agent': 'mouaif/1.0',
190
+ 'Copilot-Integration-Id': 'mouaif'
191
+ }
192
+ },
193
+ // OpenRouter: OpenAI-shaped chat completions at
194
+ // https://openrouter.ai/api/v1/chat/completions. API-key only
195
+ // (OpenRouter does not expose an OAuth flow); the key lives in
196
+ // the standard OpenAI keyring namespace so users do not have to
197
+ // juggle a second credential store. Per OpenRouter's docs, every
198
+ // request should carry an `HTTP-Referer` and `X-OpenRouter-Title`
199
+ // header so the app shows up correctly on the public leaderboard.
200
+ // The X-OpenRouter-Title is resolved at request time from
201
+ // app.openRouter.appName; the shipped defaults are the floor
202
+ // (mouaif is the only client that makes these calls).
203
+ 'openrouter': {
204
+ baseUrl: 'https://openrouter.ai/api/v1',
205
+ chatPath: '/chat/completions',
206
+ authHeader: (cred) => ({ 'Authorization': 'Bearer ' + cred }),
207
+ // GET /api/v1/models — OpenAI-shaped. OpenRouter allows the public
208
+ // list unauthenticated, so missing cred is fine here; upstream
209
+ // errors are surfaced as EUPSTREAM with the upstream status, and
210
+ // a network failure is EUNREACHABLE (handled like the others).
211
+ listModels: async (cred, signal) => openAIShapedListModels({
212
+ name: 'openrouter',
213
+ url: ENDPOINTS.openrouter.baseUrl + '/models',
214
+ authHeader: ENDPOINTS.openrouter.authHeader,
215
+ thinkingFor: thinkingForOpenRouterModel,
216
+ requireCred: false
217
+ }, cred, signal),
218
+ // POST {baseUrl}/audio/transcriptions takes only the *speech-to-text*
219
+ // models, and they are not in the list above. /models is sliced by
220
+ // output modality and defaults to `output_modalities=text`, so the 21
221
+ // transcription models (`openai/whisper-1`, `openai/gpt-4o-transcribe`,
222
+ // `google/chirp-3`, `mistralai/voxtral-mini-transcribe`, …) are absent
223
+ // from the chat catalog — asking for one of the chat models it *does*
224
+ // carry (an audio-input chat row such as `openai/gpt-audio`) answers
225
+ // `400 Model openai/gpt-audio does not exist`. The dictation catalog
226
+ // therefore reads this slice instead of filtering the chat one.
227
+ listTranscriptionModels: async (cred, signal) => openAIShapedListModels({
228
+ name: 'openrouter',
229
+ url: ENDPOINTS.openrouter.baseUrl + '/models?output_modalities=transcription',
230
+ authHeader: ENDPOINTS.openrouter.authHeader,
231
+ thinkingFor: thinkingForOpenRouterModel,
232
+ requireCred: false
233
+ }, cred, signal),
234
+ // GET {baseUrl}/images/models — OpenRouter's *image* catalogue, and the
235
+ // same story as the transcription slice above, one product further out:
236
+ // /models defaults to `output_modalities=text`, so most of the 52 image
237
+ // models are simply not in the chat list at all (`openai/gpt-image-2`,
238
+ // `black-forest-labs/flux.2-max`, the whole Recraft/Seedream/Krea families).
239
+ // This slice lets the agent editor's model picker offer an image model so a
240
+ // subagent can be pinned to one. Unauthenticated like /models (its docs
241
+ // call it anonymously too).
242
+ listImageModels: async (cred, signal) => openAIShapedListModels({
243
+ name: 'openrouter',
244
+ url: ENDPOINTS.openrouter.baseUrl + '/images/models',
245
+ authHeader: ENDPOINTS.openrouter.authHeader,
246
+ thinkingFor: thinkingForOpenRouterModel,
247
+ requireCred: false
248
+ }, cred, signal),
249
+ staticHeaders: {
250
+ 'HTTP-Referer': 'https://mouaif.local',
251
+ // The current OpenRouter API uses `X-OpenRouter-Title` as the
252
+ // canonical attribution header. The earlier `X-Title` alias is
253
+ // still accepted for back-compat but is silently dropped on
254
+ // newer versions of the SSO/leaderboard path, so it is no
255
+ // longer the primary signal. We send BOTH names so older and
256
+ // newer OpenRouter releases both attribute the request
257
+ // correctly. The values are placeholders; the real ones are
258
+ // resolved at request time from app.openRouter.appName via
259
+ // resolveOpenRouterStaticHeaders(). The shipped 'mouaif' is
260
+ // the fallback when the user has not configured one.
261
+ 'X-OpenRouter-Title': 'mouaif',
262
+ 'X-Title': 'mouaif'
263
+ }
264
+ },
265
+ // Azure OpenAI: OpenAI-shaped chat completions at
266
+ // https://<resource>.openai.azure.com/openai/deployments/<deployment>/
267
+ // chat/completions?api-version=<version>. The resource name is the
268
+ // first path segment of the base URL; the deployment name is the
269
+ // model id (a custom deployment, not a raw model name). This is the
270
+ // standard OpenAI SDK request shape with `api-version` as a query
271
+ // parameter — no Azure-specific headers — so the openai-compatible
272
+ // builder and parser apply unchanged. The API key goes in the
273
+ // standard `api-key` header (the SDK does this too; Azure rejects
274
+ // `Authorization: Bearer` unless you use Entra ID instead).
275
+ 'azure': {
276
+ baseUrl: '',
277
+ chatPath: '/chat/completions',
278
+ authHeader: (cred) => ({ 'api-key': cred }),
279
+ // GET /openai/models?api-version=<version> — OpenAI-shaped.
280
+ // Azure requires an `api-version` query param on every call and
281
+ // a valid deployment key, so the missing-cred path surfaces as
282
+ // ENO_APIKEY. The api-version is taken from the provider record
283
+ // (model.apiVersion, settable in the provider form) or defaults
284
+ // to a recent GA release (2024-10-21, which supports
285
+ // stream_options.include_usage for chat completions).
286
+ listModels: async (cred, signal, model) => openAIShapedListModels({
287
+ name: 'azure',
288
+ url: joinUrl(ENDPOINTS.azure.baseUrl, '/openai/models?api-version=' + encodeURIComponent((model && model.apiVersion) || '2024-10-21')),
289
+ authHeader: ENDPOINTS.azure.authHeader,
290
+ thinkingFor: (mm) => thinkingForOpenAIModel(mm.id)
291
+ }, cred, signal),
292
+ // Every Azure request must carry an api-version query parameter.
293
+ // model.apiVersion is user-settable (provider form); the builder
294
+ // appends it unless the caller already set one.
295
+ apiVersion: '2024-10-21'
296
+ },
297
+ // Mistral: OpenAI-shaped chat completions at
298
+ // https://api.mistral.ai/v1/chat/completions with a Bearer key.
299
+ // The model catalog (GET /v1/models) is OpenAI-shaped, so the
300
+ // openai-compatible builder and parser apply unchanged.
301
+ 'mistral': {
302
+ baseUrl: 'https://api.mistral.ai/v1',
303
+ chatPath: '/chat/completions',
304
+ authHeader: (cred) => ({ 'Authorization': 'Bearer ' + cred }),
305
+ listModels: async (cred, signal) => openAIShapedListModels({
306
+ name: 'mistral',
307
+ url: ENDPOINTS.mistral.baseUrl + '/models',
308
+ authHeader: ENDPOINTS.mistral.authHeader,
309
+ thinkingFor: (mm) => thinkingForOpenAIModel(mm.id)
310
+ }, cred, signal),
311
+ },
312
+ // Groq: OpenAI-shaped chat completions at
313
+ // https://api.groq.com/openai/v1/chat/completions with a Bearer key.
314
+ // The catalog (GET /openai/v1/models) is OpenAI-shaped, so the
315
+ // openai-compatible builder and parser apply unchanged.
316
+ 'groq': {
317
+ baseUrl: 'https://api.groq.com/openai/v1',
318
+ chatPath: '/chat/completions',
319
+ authHeader: (cred) => ({ 'Authorization': 'Bearer ' + cred }),
320
+ listModels: async (cred, signal) => openAIShapedListModels({
321
+ name: 'groq',
322
+ url: ENDPOINTS.groq.baseUrl + '/models',
323
+ authHeader: ENDPOINTS.groq.authHeader,
324
+ thinkingFor: (mm) => thinkingForOpenAIModel(mm.id)
325
+ }, cred, signal),
326
+ },
327
+ // DeepSeek: OpenAI-shaped chat completions at
328
+ // https://api.deepseek.com/chat/completions with a Bearer key.
329
+ // The catalog (GET /models) is OpenAI-shaped, so the openai-
330
+ // compatible builder and parser apply unchanged.
331
+ 'deepseek': {
332
+ baseUrl: 'https://api.deepseek.com',
333
+ chatPath: '/chat/completions',
334
+ authHeader: (cred) => ({ 'Authorization': 'Bearer ' + cred }),
335
+ listModels: async (cred, signal) => openAIShapedListModels({
336
+ name: 'deepseek',
337
+ url: ENDPOINTS.deepseek.baseUrl + '/models',
338
+ authHeader: ENDPOINTS.deepseek.authHeader,
339
+ thinkingFor: (mm) => thinkingForOpenAIModel(mm.id)
340
+ }, cred, signal),
341
+ }
342
+ };
343
+
344
+ // resolveOpenRouterStaticHeaders() — returns the shipped static headers for
345
+ // the OpenRouter endpoint verbatim. The per-install app name override was
346
+ // removed; only the shipped defaults are used.
347
+ //
348
+ // Exists as a function so the call site (buildOpenAIRequest) can branch on
349
+ // `model.provider === 'openrouter'` without an ENDPOINTS mutation.
350
+ function resolveOpenRouterStaticHeaders() {
351
+ return ENDPOINTS.openrouter.staticHeaders;
352
+ }
353
+
354
+ // ---- Model-list adapters ----------------------------------------------
355
+ //
356
+ // The chat <select> is populated dynamically from the upstream
357
+ // /models endpoint. Each ENDPOINTS entry above carries a listModels(cred)
358
+ // that returns a normalized [{ id, label, contextWindow? }]. Curated
359
+ // catalogs (Copilot) are listed inline so the picker still works when the
360
+ // upstream has no public list endpoint.
361
+ //
362
+ // A provider may also carry an optional listTranscriptionModels(cred): the
363
+ // slice of its catalog that can transcribe, when that is not simply "the
364
+ // same list, filtered" (OpenRouter slices /models by output modality). The
365
+ // dictation catalog prefers it and falls back to listModels + filtering, so
366
+ // only a provider that really needs the distinction pays for one.
367
+
368
+ const COPILOT_MODEL_CATALOG = [
369
+ { id: 'gpt-4o', label: 'GPT-4o', contextWindow: 128000 },
370
+ { id: 'gpt-4.1', label: 'GPT-4.1', contextWindow: 1047576 },
371
+ { id: 'gpt-5', label: 'GPT-5', contextWindow: 400000 },
372
+ { id: 'gpt-5-mini', label: 'GPT-5 mini', contextWindow: 400000 },
373
+ { id: 'claude-sonnet-4', label: 'Claude Sonnet 4', contextWindow: 200000 },
374
+ { id: 'claude-sonnet-4.5',label: 'Claude Sonnet 4.5', contextWindow: 200000 },
375
+ { id: 'claude-opus-4', label: 'Claude Opus 4', contextWindow: 200000 },
376
+ { id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', contextWindow: 1048576 }
377
+ ];
378
+
379
+ const ANTHROPIC_MODEL_CATALOG = [
380
+ { id: 'claude-opus-4', label: 'Claude Opus 4', contextWindow: 200000 },
381
+ { id: 'claude-opus-4.1', label: 'Claude Opus 4.1', contextWindow: 200000 },
382
+ { id: 'claude-opus-4.5', label: 'Claude Opus 4.5', contextWindow: 200000 },
383
+ { id: 'claude-sonnet-4', label: 'Claude Sonnet 4', contextWindow: 200000 },
384
+ { id: 'claude-sonnet-4.5',label: 'Claude Sonnet 4.5', contextWindow: 200000 },
385
+ { id: 'claude-sonnet-5', label: 'Claude Sonnet 5', contextWindow: 1000000 },
386
+ { id: 'claude-haiku-4.5', label: 'Claude Haiku 4.5', contextWindow: 200000 },
387
+ { id: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet (legacy)', contextWindow: 200000 },
388
+ { id: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku (legacy)', contextWindow: 200000 }
389
+ ];
390
+
391
+ function httpError(resp) {
392
+ const e = new Error('upstream ' + resp.status + ' ' + resp.statusText);
393
+ e.code = 'EUPSTREAM';
394
+ e.status = resp.status;
395
+ return e;
396
+ }
397
+
398
+ // noApiKeyError(provider, hint) — typed error thrown by listModels adapters
399
+ // when a provider requires a credential but none is configured. Lets the
400
+ // HTTP layer distinguish "add a key" (400 ENO_APIKEY) from "upstream
401
+ // misbehaved" (502 EUPSTREAM) so the chat UI can show an actionable
402
+ // message instead of a generic "model list failed".
403
+ function noApiKeyError(provider, hint) {
404
+ const e = new Error('No API key configured for ' + provider + '. ' + (hint || 'Add one in Settings \u2192 Providers.'));
405
+ e.code = 'ENO_APIKEY';
406
+ e.provider = provider;
407
+ return e;
408
+ }
409
+
410
+ // unreachableError(provider, cause) — typed error thrown when the upstream
411
+ // is not reachable (ECONNREFUSED, ENOTFOUND, fetch failed). Distinct from
412
+ // EUPSTREAM (upstream answered with a non-2xx) so the HTTP layer can
413
+ // return 503 "service unavailable" instead of 502 "bad gateway".
414
+ function unreachableError(provider, cause) {
415
+ const e = new Error('Cannot reach ' + provider + ' upstream: ' + (cause && cause.message ? cause.message : String(cause || 'unknown')));
416
+ e.code = 'EUNREACHABLE';
417
+ e.provider = provider;
418
+ e.cause = cause;
419
+ return e;
420
+ }
421
+
422
+ // abortedError(provider) — typed error thrown when the upstream call was
423
+ // aborted by the per-call AbortController (timeout). Distinct from
424
+ // EUNREACHABLE so the HTTP layer can return 504 "gateway timeout" with
425
+ // a clear message instead of 503.
426
+ function abortedError(provider) {
427
+ const e = new Error('Timed out waiting for ' + provider + ' upstream');
428
+ e.code = 'EABORTED';
429
+ e.provider = provider;
430
+ return e;
431
+ }
432
+
433
+ // ---- Thinking capability descriptors ----------------------------------
434
+ //
435
+ // Each live model record may carry a `thinking` field describing the
436
+ // reasoning controls the provider actually accepts for that model:
437
+ //
438
+ // { kind: 'levels', levels: ['low','medium','high'] } — effort presets
439
+ // { kind: 'budget' } — raw token budget
440
+ //
441
+ // The chat UI builds its thinking dropdown from this descriptor when
442
+ // present; when absent it falls back to the generic presets. The
443
+ // request builders accept any provider-reported level verbatim (they
444
+ // no longer whitelist only low/medium/high) so new upstream values
445
+ // (e.g. OpenAI's "minimal"/"xhigh") work without a client update.
446
+
447
+ // Effort levels known to be valid on OpenAI-shaped reasoning endpoints.
448
+ // Order matters: the UI shows them in this sequence.
449
+ const OPENAI_THINKING_LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh'];
450
+
451
+ // thinkingForOpenAIModel(id) — best-effort static inference for the
452
+ // openai-compatible provider, whose /models endpoint does not report
453
+ // reasoning support. Returns undefined when the id gives no signal so
454
+ // the UI falls back to generic presets.
455
+ function thinkingForOpenAIModel(id) {
456
+ const s = String(id || '').toLowerCase();
457
+ // OpenAI reasoning families (o1/o3/o4, gpt-5*), plus common
458
+ // reasoning-flagged models on OpenAI-shaped third-party endpoints.
459
+ // OpenAI reasoning models take effort levels; reasoning models from
460
+ // other vendors behind an OpenAI-shaped gateway (DeepSeek's `deepseek-
461
+ // reasoner`, Qwen's `qwq` / `-thinking` variants, Kimi's `k2-thinking`)
462
+ // also accept `reasoning_effort` levels or a boolean toggle — levels
463
+ // are the safe common denominator for those too.
464
+ const isReasoning = /^(o\d|gpt-5)/.test(s)
465
+ || /reasoning|think|\br1\b|qwq/.test(s)
466
+ || /deepseek-(reasoner|r1)/.test(s)
467
+ || /k2-thinking/.test(s);
468
+ if (!isReasoning) return undefined;
469
+ return { kind: 'levels', levels: OPENAI_THINKING_LEVELS.slice() };
470
+ }
471
+
472
+ // thinkingForOpenRouterModel(m) — OpenRouter reports per-model
473
+ // `supported_parameters` (and on some revisions a `reasoning` block)
474
+ // on GET /api/v1/models. Map that onto our descriptor.
475
+ function thinkingForOpenRouterModel(m) {
476
+ const sp = Array.isArray(m && m.supported_parameters) ? m.supported_parameters : [];
477
+ const supportsReasoning = sp.indexOf('reasoning') >= 0
478
+ || sp.indexOf('reasoning_effort') >= 0
479
+ || sp.indexOf('include_reasoning') >= 0;
480
+ if (!supportsReasoning) return undefined;
481
+ // Anthropic-family models behind OpenRouter take a token budget;
482
+ // everything else takes effort levels. OpenRouter accepts either
483
+ // shape on its /chat/completions, so levels are a safe default.
484
+ if (/^anthropic\//.test(String(m.id || ''))) return { kind: 'budget' };
485
+ return { kind: 'levels', levels: OPENAI_THINKING_LEVELS.slice() };
486
+ }
487
+
488
+ function parseOpenAIShapedModels(body, thinkingFor) {
489
+ const arr = Array.isArray(body && body.data) ? body.data : [];
490
+ const out = [];
491
+ for (const m of arr) {
492
+ if (!m || !m.id) continue;
493
+ const rec = {
494
+ id: String(m.id),
495
+ label: m.id,
496
+ contextWindow: typeof m.context_window === 'number' ? m.context_window : undefined
497
+ };
498
+ // OpenRouter advertises real per-model prices on GET /api/v1/models
499
+ // (`pricing.prompt` / `pricing.completion`, strings in $ per TOKEN)
500
+ // plus cache rates (`pricing.input_cache_read` / `pricing.input_cache_write`,
501
+ // also $ per token). When present, fold them into the record as a
502
+ // `pricing` block so the cost line uses the provider's actual numbers
503
+ // instead of the best-effort built-in table. All values are optional
504
+ // and validated — a missing or malformed field is dropped, never a
505
+ // crash.
506
+ const pricing = openRouterPricingFromModel(m);
507
+ if (pricing) rec.pricing = pricing;
508
+ // OpenRouter advertises each model's input *and* output modalities
509
+ // (`architecture.input_modalities` / `architecture.output_modalities`).
510
+ // Both are carried through because the dictation catalog selects on them:
511
+ //
512
+ // * `output_modalities: ["transcription"]` is the definitive "this is a
513
+ // speech-to-text model" — and those rows only exist in a *sliced* view
514
+ // of /models that the chat list never sees (see the openrouter
515
+ // `listTranscriptionModels` adapter);
516
+ // * a reported output list *without* `transcription` is the definitive
517
+ // "this is not one", however much audio the row accepts
518
+ // (`openai/gpt-audio`, `google/gemini-2.5-flash`). Sending one of those
519
+ // to /audio/transcriptions answers `400 Model … does not exist`, which
520
+ // is exactly what a picker selecting on audio input alone offered;
521
+ // * `input_modalities` stays as the fallback capability signal for a
522
+ // provider that reports what goes in but not what comes out.
523
+ const inputs = modalityList(m, 'input_modalities');
524
+ if (inputs) rec.inputModalities = inputs;
525
+ const outputs = modalityList(m, 'output_modalities');
526
+ if (outputs) rec.outputModalities = outputs;
527
+ // `supported_parameters` is carried through for the same reason the
528
+ // modalities are: it is the provider's own report of what each model can
529
+ // be *asked* for, and an image model picker needs it to tell a generator
530
+ // from an editor. OpenRouter is the provider that publishes it; a row that
531
+ // requires `input_references` cannot draw from a prompt alone, and
532
+ // offering it in a picture picker is a menu entry that cannot work.
533
+ // Absent stays absent — unknown is never "no".
534
+ const supported = supportedParameters(m);
535
+ if (supported) rec.supportedParameters = supported;
536
+ const thinking = typeof thinkingFor === 'function' ? thinkingFor(m) : undefined;
537
+ if (thinking) rec.thinking = thinking;
538
+ out.push(rec);
539
+ }
540
+ return out;
541
+ }
542
+
543
+ // supportedParameters(m) — OpenRouter's per-model `supported_parameters` map
544
+ // (parameter name -> its descriptor), carried onto the record so a consumer
545
+ // can consult one parameter without the whole upstream body. Returns null
546
+ // when the provider sent nothing usable.
547
+ function supportedParameters(m) {
548
+ const raw = m && m.supported_parameters;
549
+ if (!raw || typeof raw !== 'object') return null;
550
+ // Some OpenAI-shaped servers send an array of names instead of a map; both
551
+ // are accepted, and anything else is treated as "said nothing".
552
+ if (Array.isArray(raw)) {
553
+ const out = {};
554
+ for (const name of raw) if (typeof name === 'string') out[name] = {};
555
+ return Object.keys(out).length ? out : null;
556
+ }
557
+ const out = {};
558
+ for (const key of Object.keys(raw)) {
559
+ if (typeof key === 'string' && key) out[key] = raw[key];
560
+ }
561
+ return Object.keys(out).length ? out : null;
562
+ }
563
+
564
+ // modalityList(m, key) — one of `architecture.input_modalities` /
565
+ // `architecture.output_modalities` as a lowercased, validated string list, or
566
+ // null when the provider said nothing. Absent is "unknown", never "no": the
567
+ // dictation filter treats a report it *did* get as authoritative and falls
568
+ // back to name/capability guessing only when there is none.
569
+ function modalityList(m, key) {
570
+ const arch = m && m.architecture;
571
+ const list = arch && arch[key];
572
+ if (!Array.isArray(list)) return null;
573
+ const out = list
574
+ .filter((x) => typeof x === 'string')
575
+ .map((x) => x.toLowerCase());
576
+ return out.length ? out : null;
577
+ }
578
+ // openRouterPricingFromModel(m) -> { inputPer1K, outputPer1K, cacheReadFactor?, cacheWriteFactor? } | undefined
579
+ //
580
+ // OpenRouter's GET /api/v1/models returns, per model:
581
+ // pricing: { prompt: "0.000003", completion: "0.000015", // $ per TOKEN, strings
582
+ // input_cache_read: "0.0000003", // $ per TOKEN (prompt cache read)
583
+ // input_cache_write: "0.00000375" } // $ per TOKEN (prompt cache write)
584
+ // // newer models may instead ship prompt_cache_read_breakpoints /
585
+ // // prompt_cache_write_breakpoints with the same $ per token values
586
+ // Values are in dollars per single token, so we multiply by 1000 to get
587
+ // the per-1K shape the rest of src/usage.js uses. Cache rates are turned
588
+ // into unit-free factors (cache rate ÷ base prompt rate) so the cost
589
+ // layer can apply them to any input price.
590
+ function openRouterPricingFromModel(m) {
591
+ const p = m && m.pricing;
592
+ const promptStr = p && (p.prompt ?? p.prompt_per_token);
593
+ const completionStr = p && (p.completion ?? p.completion_per_token);
594
+ const inputPer1K = pricePer1K(promptStr);
595
+ const outputPer1K = pricePer1K(completionStr);
596
+ if (inputPer1K == null && outputPer1K == null) return undefined;
597
+ const out = {};
598
+ if (inputPer1K != null) out.inputPer1K = inputPer1K;
599
+ if (outputPer1K != null) out.outputPer1K = outputPer1K;
600
+ // Cache factors, when the provider publishes them: read = input_cache_read /
601
+ // prompt, write = input_cache_write / prompt (both $ per token, so the
602
+ // ratio is unit-free). Fall back to the breakpoint arrays for models
603
+ // that ship those instead.
604
+ const read = cacheFactor(p && (p.input_cache_read ?? p.input_cache_read_per_token), promptStr)
605
+ ?? breakpointFactor(m && m.prompt_cache_read_breakpoints, promptStr);
606
+ const write = cacheFactor(p && (p.input_cache_write ?? p.input_cache_write_per_token), promptStr)
607
+ ?? breakpointFactor(m && m.prompt_cache_write_breakpoints, promptStr);
608
+ if (read != null) out.cacheReadFactor = read;
609
+ if (write != null) out.cacheWriteFactor = write;
610
+ return out;
611
+ }
612
+
613
+ // pricePer1K(v) — OpenRouter prices are in $ per single token
614
+ // (strings, e.g. "0.000003" for $3 per 1M). Multiply by 1000 to get
615
+ // the per-1K shape the rest of src/usage.js uses. Returns null when
616
+ // unusable.
617
+ function pricePer1K(v) {
618
+ if (v == null || v === '') return null;
619
+ const n = Number(v);
620
+ if (!isFinite(n) || n < 0) return null;
621
+ return n * 1000;
622
+ }
623
+
624
+ // cacheFactor(cachePriceStr, basePriceStr) -> number | null
625
+ // Ratio of a cache rate to the base prompt rate (both $ per token, so
626
+ // the ratio is unit-free). Missing or garbage values yield null (the
627
+ // caller's default applies).
628
+ function cacheFactor(cachePriceStr, basePriceStr) {
629
+ if (cachePriceStr == null || cachePriceStr === '') return null;
630
+ const n = Number(cachePriceStr);
631
+ if (!isFinite(n) || n < 0) return null;
632
+ const base = Number(basePriceStr);
633
+ if (!isFinite(base) || base <= 0) return null;
634
+ const factor = n / base;
635
+ // A factor must be a sane positive ratio (0.01–10); anything else is
636
+ // a malformed provider response and should not distort pricing.
637
+ return (factor > 0.01 && factor < 10) ? factor : null;
638
+ }
639
+
640
+ // breakpointFactor(breakpoints, basePriceStr) -> number | null
641
+ // The cache breakpoints carry the cached price at each tier
642
+ // (`cost.prompt`, $ per token). The factor is cached price / base
643
+ // prompt price. Missing base prompt price or missing/garbage breakpoints
644
+ // yield null (the caller's default applies).
645
+ function breakpointFactor(breakpoints, basePriceStr) {
646
+ const arr = Array.isArray(breakpoints) ? breakpoints : [];
647
+ for (const b of arr) {
648
+ const cost = b && b.cost;
649
+ const v = cost && (cost.prompt ?? cost.prompt_per_token);
650
+ if (v == null || v === '') continue;
651
+ const n = Number(v);
652
+ if (!isFinite(n) || n < 0) continue;
653
+ const base = Number(basePriceStr);
654
+ if (base == null || base <= 0) return null;
655
+ const factor = n / base;
656
+ // A factor must be a sane positive ratio (0.01–10); anything else is
657
+ // a malformed provider response and should not distort pricing.
658
+ return (factor > 0.01 && factor < 10) ? factor : null;
659
+ }
660
+ return null;
661
+ }
662
+
663
+ function parseGeminiModels(body, opts) {
664
+ // Gemini: { models: [{ name: 'models/<id>', displayName, inputTokenLimit, ... }] }
665
+ // opts.slice — 'chat' (default) keeps only rows the chat route can use;
666
+ // 'image' additionally keeps `predict`-only rows (Imagen), so the image
667
+ // slice can offer them without leaking an unusable chat model into the
668
+ // picker's chat list.
669
+ const slice = opts && opts.slice === 'image' ? 'image' : 'chat';
670
+ const arr = Array.isArray(body && body.models) ? body.models : [];
671
+ const out = [];
672
+ for (const m of arr) {
673
+ if (!m || !m.name) continue;
674
+ const id = String(m.name).replace(/^models\//, '');
675
+ const methods = Array.isArray(m.supportedGenerationMethods) ? m.supportedGenerationMethods : [];
676
+ // Keep a model that can answer a request this slice can send: a text/image
677
+ // chat model answers `generateContent`, and an Imagen model answers
678
+ // `predict` (its `:predict` image API). The chat list drops the
679
+ // `predict`-only rows so it never offers a model that cannot chat; the
680
+ // image slice keeps them so the agent editor's model picker can pin one.
681
+ const methodsOk = slice === 'image'
682
+ ? ['generateContent', 'predict', 'predictLongRunning']
683
+ : ['generateContent'];
684
+ if (methods.length && !methods.some((x) => methodsOk.includes(x))) continue;
685
+ const rec = {
686
+ id,
687
+ label: m.displayName || id,
688
+ contextWindow: typeof m.inputTokenLimit === 'number' ? m.inputTokenLimit : undefined
689
+ };
690
+ // Carry Gemini's own report of what a model produces so an image picker
691
+ // can tell an Imagen row (produces an image) from a text model without
692
+ // guessing on the id alone. A row that answers only `predict` (Imagen) or
693
+ // names an image product is treated as an image producer.
694
+ if (/imagen/i.test(id) || /(^|-)image($|-)/i.test(id)
695
+ || (methods.length && methods.includes('predict') && !methods.includes('generateContent'))) {
696
+ rec.outputModalities = ['image'];
697
+ }
698
+ // Gemini 2.5+ models accept generationConfig.thinkingConfig with a
699
+ // raw thinkingBudget token count.
700
+ if (/gemini-(2\.5|[3-9])/.test(id)) rec.thinking = { kind: 'budget' };
701
+ out.push(rec);
702
+ }
703
+ return out;
704
+ }
705
+
706
+ function parseOllamaModels(body) {
707
+ const arr = Array.isArray(body && body.models) ? body.models : [];
708
+ const out = [];
709
+ for (const m of arr) {
710
+ if (!m || !m.name) continue;
711
+ out.push({
712
+ id: String(m.name),
713
+ label: m.name,
714
+ contextWindow: undefined // Ollama doesn't expose context in /api/tags.
715
+ });
716
+ }
717
+ return out;
718
+ }
719
+
720
+ function parseCuratedModels(catalog, thinkingFor) {
721
+ return catalog.map((m) => {
722
+ const rec = {
723
+ id: m.id,
724
+ label: m.label || m.id,
725
+ contextWindow: m.contextWindow
726
+ };
727
+ const thinking = typeof thinkingFor === 'function'
728
+ ? thinkingFor(m)
729
+ : (thinkingFor || m.thinking);
730
+ if (thinking) rec.thinking = thinking;
731
+ return rec;
732
+ });
733
+ }
734
+
735
+ // normalizeModelList(out) — stable, friendly order (by id ascending) and
736
+ // deduped. Both model-list entry points go through it so a caller cannot tell
737
+ // the chat catalog from the speech-to-text one by shape alone.
738
+ function normalizeModelList(out) {
739
+ const seen = new Set();
740
+ const dedup = [];
741
+ for (const m of (Array.isArray(out) ? out : []).sort((a, b) => a.id.localeCompare(b.id))) {
742
+ if (!m || !m.id || seen.has(m.id)) continue;
743
+ seen.add(m.id);
744
+ dedup.push(m);
745
+ }
746
+ return dedup;
747
+ }
748
+ // listModels(provider, cred, signal) -> Promise<[{ id, label, contextWindow? }]>
749
+ // Returns the live list for a provider; throws on upstream error so the
750
+ // caller can surface a typed error to the chat UI.
751
+ async function listModels(provider, cred, signal) {
752
+ const def = ENDPOINTS[provider];
753
+ if (!def || typeof def.listModels !== 'function') {
754
+ const e = new Error('no listModels for provider: ' + provider);
755
+ e.code = 'ENO_LIST';
756
+ throw e;
757
+ }
758
+ return normalizeModelList(await def.listModels(cred, signal));
759
+ }
760
+ // listTranscriptionModels(provider, cred, signal)
761
+ // -> Promise<[...] | null>
762
+ //
763
+ // The slice of a provider's catalog that can transcribe, when that is not
764
+ // simply "the chat list, filtered": OpenRouter's /models is sliced by output
765
+ // modality and defaults to `text`, so its 21 speech-to-text models are absent
766
+ // from the chat list and /audio/transcriptions rejects the audio-input chat
767
+ // models that *are* in it. `null` means "this provider has no separate
768
+ // speech-to-text catalog" — the caller filters the chat list instead, which is
769
+ // what every provider but OpenRouter needs. Throws the same typed errors as
770
+ // listModels.
771
+ async function listTranscriptionModels(provider, cred, signal) {
772
+ const def = ENDPOINTS[provider];
773
+ if (!def || typeof def.listTranscriptionModels !== 'function') return null;
774
+ return normalizeModelList(await def.listTranscriptionModels(cred, signal));
775
+ }
776
+
777
+ // listImageModels(provider, cred, signal) -> Promise<[...] | null>
778
+ //
779
+ // The slice of a provider's catalogue that can generate pictures, when that
780
+ // is not "the chat list, filtered": OpenRouter's /models defaults to
781
+ // `output_modalities=text`, so its image catalogue is a different endpoint
782
+ // (`/images/models`) that the chat list never sees; Gemini's catalogue is one
783
+ // list where the chat slice drops the `predict`-only Imagen rows. `null` means
784
+ // "this provider has no separate image catalogue" — a caller reading this slice
785
+ // falls back to the chat list. OpenRouter and Gemini have one; every other
786
+ // provider does not.
787
+ async function listImageModels(provider, cred, signal) {
788
+ const def = ENDPOINTS[provider];
789
+ if (!def || typeof def.listImageModels !== 'function') return null;
790
+ return normalizeModelList(await def.listImageModels(cred, signal));
791
+ }
792
+
793
+ function endpointFor(model) {
794
+ const def = ENDPOINTS[model.provider];
795
+ if (!def) {
796
+ const e = new Error('Unknown provider: ' + model.provider);
797
+ e.code = 'EUNKNOWN_PROVIDER';
798
+ throw e;
799
+ }
800
+ return def;
801
+ }
802
+
803
+ // requireApiKey(model, def) — returns true when the model has a usable
804
+ // credential (or doesn't need one), throws a typed error otherwise.
805
+ // The def is the ENDPOINTS[model.provider] record, passed in by the
806
+ // caller so we can branch on the provider's auth shape (e.g. Ollama's
807
+ // authHeader: () => ({}) doesn't take a credential at all).
808
+ //
809
+ // Async because the OAuth path may need to proactively refresh an
810
+ // expiring access token (and persist the new blob) before the chat
811
+ // hits the wire. The API-key path is sync-fast and does not await.
812
+ async function requireApiKey(model, def) {
813
+ if (model.auth === 'oauth') {
814
+ // OAuth path: the access token comes from the OS keychain via
815
+ // src/auth.js. The keychain is keyed by auth provider (openai,
816
+ // anthropic, google, github-copilot), not by AI client provider
817
+ // (openai-compatible, etc). The mapping from the model record's
818
+ // `provider` to the auth provider lives in src/auth.js
819
+ // (AI_TO_AUTH_PROVIDER); the same function (authProviderFor) is
820
+ // what the auth subsystem uses to look up accounts, so adding a
821
+ // new AI client only requires one edit.
822
+ const authMod = require('./auth.js');
823
+ const authProvider = authMod.authProviderFor(model);
824
+ if (!authProvider) {
825
+ const e = new Error('Cannot resolve auth provider for "' + model.provider + '"');
826
+ e.code = 'EUNKNOWN_PROVIDER';
827
+ throw e;
828
+ }
829
+ const lookModel = Object.assign({}, model, { provider: authProvider });
830
+ const token = authMod.tokenForModel(lookModel);
831
+ if (!token) {
832
+ const e = new Error(
833
+ 'No OAuth account is signed in for provider "' + authProvider + '"' +
834
+ (model.oauthAccount ? '' : ' (no oauthAccount on model, no signed-in account found)') +
835
+ '. Sign in via the loopback callback.'
836
+ );
837
+ e.code = 'ENOAUTH';
838
+ throw e;
839
+ }
840
+ let parsed;
841
+ try { parsed = JSON.parse(token); }
842
+ catch {
843
+ const e = new Error('Stored OAuth token for "' + authProvider + '" is not valid JSON');
844
+ e.code = 'EOAUTH_BLOB';
845
+ throw e;
846
+ }
847
+ if (!parsed.accessToken) {
848
+ const e = new Error('Stored OAuth token for "' + authProvider + '" has no accessToken');
849
+ e.code = 'EOAUTH_BLOB';
850
+ throw e;
851
+ }
852
+ // Proactive refresh. If the stored access_token expires within
853
+ // OAUTH_REFRESH_LEAD_MS (or is already past), and the provider
854
+ // has a registered refresher + we have a refresh_token, swap
855
+ // it for a fresh one and persist the new blob to the keychain.
856
+ // Skipped silently when no refresher is registered yet, or when
857
+ // the user signed in via a flow that did not issue a
858
+ // refresh_token. A failure here is non-fatal: we fall through
859
+ // with the existing token; the upstream will return a clean
860
+ // 401 if the token is actually stale, which the user can
861
+ // recover from by re-signing in.
862
+ const now = Date.now();
863
+ const nearExpiry = typeof parsed.expiresAt === 'number'
864
+ ? (parsed.expiresAt - now) <= OAUTH_REFRESH_LEAD_MS
865
+ : false;
866
+ if (nearExpiry && parsed.refreshToken) {
867
+ const refresher = authMod.getRefresher(authProvider);
868
+ if (refresher) {
869
+ const baseUrl = (model.baseUrl
870
+ || (ENDPOINTS[authProvider] && ENDPOINTS[authProvider].baseUrl)
871
+ || null);
872
+ const account = lookModel.oauthAccount || parsed.account || null;
873
+ try {
874
+ const next = await refresher({
875
+ provider: authProvider,
876
+ account,
877
+ refreshToken: parsed.refreshToken,
878
+ scope: parsed.scope || null,
879
+ baseUrl
880
+ });
881
+ if (next && next.accessToken) {
882
+ parsed = Object.assign({}, parsed, {
883
+ accessToken: next.accessToken,
884
+ refreshToken: next.refreshToken || parsed.refreshToken,
885
+ expiresAt: typeof next.expiresAt === 'number' ? next.expiresAt : parsed.expiresAt,
886
+ scope: next.scope || parsed.scope
887
+ });
888
+ // Persist the new blob. Best-effort: a keyring failure
889
+ // here does not poison the in-memory token we are about
890
+ // to use, and the next chat will re-refresh as needed.
891
+ try {
892
+ await authMod.setToken(authProvider, account || 'default', JSON.stringify(parsed));
893
+ } catch { /* swallow; in-memory token is still valid for this chat */ }
894
+ }
895
+ } catch { /* swallow; fall through to the stored token */ }
896
+ }
897
+ }
898
+ model.__accessToken = parsed.accessToken;
899
+ // Per-request Copilot token exchange. The keychain holds a
900
+ // long-lived GitHub OAuth token; api.githubcopilot.com expects
901
+ // a short-lived Copilot API token in the Authorization header.
902
+ // The exchange is performed on every chat; the resulting token
903
+ // is request-scoped (we never persist it) and a revocation
904
+ // takes effect within minutes rather than at next sign-in.
905
+ // Failure to exchange (no subscription, revoked token, network)
906
+ // is surfaced as a typed error so the UI can prompt the user.
907
+ if (model.provider === 'github-copilot') {
908
+ let copilot;
909
+ try {
910
+ copilot = await exchangeCopilotTokenIfNeeded(model, parsed);
911
+ } catch (e) {
912
+ // Re-throw with the typed code preserved.
913
+ throw e;
914
+ }
915
+ if (copilot) model.__accessToken = copilot;
916
+ }
917
+ return true;
918
+ }
919
+ if (!model.apiKey || typeof model.apiKey !== 'string') {
920
+ // Providers whose authHeader takes no parameters (Ollama today)
921
+ // don't need a credential. We detect that by arity: zero = no
922
+ // credential, one or more = needs a key. Robust to future providers.
923
+ if (def && typeof def.authHeader === 'function' && def.authHeader.length === 0) return true;
924
+ const e = new Error('Model "' + model.id + '" has no apiKey.');
925
+ e.code = 'ENOAPIKEY';
926
+ throw e;
927
+ }
928
+ return true;
929
+ }
930
+
931
+ // Refresh lead window. If a stored access token expires within this
932
+ // many ms, we proactively swap it for a fresh one before the chat
933
+ // hits the wire. 60s matches the SDK's typical advisory-refresh
934
+ // threshold and is short enough that a flaky refresh never holds a
935
+ // chat hostage for long.
936
+ const OAUTH_REFRESH_LEAD_MS = 60 * 1000;
937
+
938
+ // Copilot token cache. The exchange is per-request but the result
939
+ // is request-scoped — there's no value in caching it across chats
940
+ // because it expires in ~30 min and a fresh derivation costs one
941
+ // HTTPS round-trip. We keep a small in-process LRU so a chat that
942
+ // streams multiple turns does not re-exchange on every turn; each
943
+ // entry tracks the GitHub token + the resolved Copilot token + its
944
+ // expiry. The cache key includes the GitHub token, so a sign-out /
945
+ // sign-in of a different account starts fresh.
946
+ const _copilotCache = new Map(); // key: githubToken -> { copilotToken, expiresAt, ts }
947
+ const COPILOT_CACHE_TTL_MS = 5 * 60 * 1000; // drop cache entries after 5 min idle
948
+
949
+ function copilotCacheGet(githubToken) {
950
+ const entry = _copilotCache.get(githubToken);
951
+ if (!entry) return null;
952
+ if (Date.now() - entry.ts > COPILOT_CACHE_TTL_MS) { _copilotCache.delete(githubToken); return null; }
953
+ if (typeof entry.expiresAt === 'number' && entry.expiresAt - Date.now() <= OAUTH_REFRESH_LEAD_MS) {
954
+ _copilotCache.delete(githubToken);
955
+ return null;
956
+ }
957
+ // Touch on read so a streaming chat does not evict its own entry.
958
+ entry.ts = Date.now();
959
+ return entry;
960
+ }
961
+ function copilotCachePut(githubToken, copilotToken, expiresAt) {
962
+ // Cap the map at 16 entries — should never hit this in practice
963
+ // (one per signed-in account per process), but defensive against
964
+ // memory growth in a long-lived server.
965
+ if (_copilotCache.size >= 16) {
966
+ const first = _copilotCache.keys().next().value;
967
+ if (first !== undefined) _copilotCache.delete(first);
968
+ }
969
+ _copilotCache.set(githubToken, { copilotToken, expiresAt, ts: Date.now() });
970
+ }
971
+ function copilotCacheClear() { _copilotCache.clear(); }
972
+
973
+ // exchangeCopilotTokenIfNeeded(model, parsedBlob) — derives a
974
+ // short-lived Copilot API token from the GitHub OAuth token stored
975
+ // in the keychain. Returns the token string, or throws a typed
976
+ // error. Caches per-GitHub-token to avoid re-deriving within a
977
+ // streaming chat.
978
+ async function exchangeCopilotTokenIfNeeded(model, parsedBlob) {
979
+ const githubToken = (parsedBlob && parsedBlob.accessToken) || model.__accessToken;
980
+ if (!githubToken) {
981
+ const e = new Error('GitHub Copilot: no GitHub access token on the stored blob');
982
+ e.code = 'EOAUTH_BLOB';
983
+ throw e;
984
+ }
985
+ const cached = copilotCacheGet(githubToken);
986
+ if (cached) return cached.copilotToken;
987
+ let oauthCopilot;
988
+ try {
989
+ oauthCopilot = require('./oauth-github-copilot.js');
990
+ } catch {
991
+ const e = new Error('GitHub Copilot OAuth module is not available in this build');
992
+ e.code = 'EMODULE';
993
+ throw e;
994
+ }
995
+ let out;
996
+ try {
997
+ out = await oauthCopilot.exchangeCopilotToken({ githubToken });
998
+ } catch (e) {
999
+ // Re-throw with the typed code preserved (ESSO_REQUIRED, ENOCOPILOT,
1000
+ // EUPSTREAM, EPARSE, ETOKEN).
1001
+ throw e;
1002
+ }
1003
+ if (!out || !out.token) {
1004
+ const e = new Error('GitHub Copilot: token exchange returned no token');
1005
+ e.code = 'ETOKEN';
1006
+ throw e;
1007
+ }
1008
+ copilotCachePut(githubToken, out.token, out.expiresAt);
1009
+ return out.token;
1010
+ }
1011
+
1012
+ // ---- Request builders --------------------------------------------------
1013
+
1014
+ // joinUrl() is shared from src/util.js.
1015
+
1016
+ // Returns the effective bearer-style credential: the OAuth access token
1017
+ // if one was resolved by requireApiKey, otherwise the plain apiKey.
1018
+ function credential(model) {
1019
+ return model.__accessToken || model.apiKey;
1020
+ }
1021
+
1022
+ // Providers whose upstream accepts OpenAI's `prompt_cache_key`. The field
1023
+ // is OpenAI-specific: OpenAI and Azure OpenAI document it, and OpenRouter
1024
+ // forwards it to the OpenAI-family upstream it routes to. It is deliberately
1025
+ // NOT sent to every OpenAI-shaped provider — a strict gateway that rejects
1026
+ // unknown body fields would turn the optimisation into a 400 — and never to
1027
+ // Anthropic, which uses explicit `cache_control` breakpoints instead
1028
+ // (see docs/features/prompt-caching.md).
1029
+ const PROMPT_CACHE_KEY_PROVIDERS = new Set(['openai-compatible', 'azure', 'openrouter']);
1030
+
1031
+ function buildOpenAIRequest(model, messages, stream, specs, requestOpts) {
1032
+ const def = ENDPOINTS[model.provider] || ENDPOINTS['openai-compatible'];
1033
+ // Fall back to the per-provider defaultBaseUrl when the saved
1034
+ // record's baseUrl is empty. Without this, an openai-compatible or
1035
+ // openrouter model with baseUrl: '' would collapse the URL to the
1036
+ // relative path '/chat/completions' instead of the upstream
1037
+ // endpoint. The Settings UI snaps baseUrl to defaultBaseUrl on
1038
+ // save, but a hand-edited .mouaif.json or a future provider that
1039
+ // forgets to set one would otherwise break.
1040
+ const baseUrl = (model && model.baseUrl) || (def && def.baseUrl) || '';
1041
+ const headers = { 'Content-Type': 'application/json' };
1042
+ // Auth header: a provider with its own authHeader (azure's `api-key`,
1043
+ // the Bearer-key providers) uses it; the generic fallback is the
1044
+ // OpenAI Bearer header.
1045
+ const authFn = (def && typeof def.authHeader === 'function')
1046
+ ? def.authHeader
1047
+ : ENDPOINTS['openai-compatible'].authHeader;
1048
+ Object.assign(headers, authFn(credential(model)));
1049
+ // Per-provider static headers. github-copilot requires editor
1050
+ // identification headers; openrouter carries the per-install
1051
+ // X-OpenRouter-Title (canonical) + X-Title (deprecated alias)
1052
+ // + HTTP-Referer, resolved at request time from
1053
+ // app.openRouter.appName. The order (auth first,
1054
+ // static second) means a caller-supplied model.headers can
1055
+ // still override the defaults — useful for tests and for a
1056
+ // future per-model override.
1057
+ if (def && def.staticHeaders) {
1058
+ Object.assign(headers, model.provider === 'openrouter'
1059
+ ? resolveOpenRouterStaticHeaders()
1060
+ : def.staticHeaders);
1061
+ }
1062
+ if (model && model.headers && typeof model.headers === 'object') Object.assign(headers, model.headers);
1063
+ // Claude routed through OpenRouter supports Anthropic prompt caching,
1064
+ // but only when the OpenAI-shaped request carries explicit
1065
+ // cache_control breakpoints on message content blocks. The native
1066
+ // Anthropic builder adds them; the plain OpenAI body has none, so
1067
+ // Claude-via-OpenRouter would get a 0% cache hit on every turn. Inject
1068
+ // the same breakpoints (last system message + penultimate message) the
1069
+ // native path uses. Other OpenAI-shaped providers are untouched.
1070
+ const effectiveMessages = isOpenRouterAnthropicModel(model)
1071
+ ? injectOpenRouterAnthropicCache(messages)
1072
+ : messages;
1073
+
1074
+ const body = {
1075
+ model: model.id,
1076
+ messages: effectiveMessages,
1077
+ stream: !!stream
1078
+ };
1079
+ if (stream && (model.provider === 'openai-compatible' || model.provider === 'openrouter'
1080
+ || model.provider === 'azure' || model.provider === 'mistral' || model.provider === 'groq' || model.provider === 'deepseek')) {
1081
+ // OpenAI-shaped streaming APIs do not include final token usage by
1082
+ // default. Request it explicitly so the chat's Context/Cost line is
1083
+ // based on upstream counts instead of staying at zero.
1084
+ body.stream_options = { include_usage: true };
1085
+ }
1086
+ if (stream && model.provider === 'openrouter') {
1087
+ // OpenRouter only includes its authoritative billed `usage.cost` when
1088
+ // asked. Prefer that over local pricing when present.
1089
+ body.usage = { include: true };
1090
+ }
1091
+ // OpenAI-family prompt caching. The upstream caches prompt prefixes
1092
+ // automatically, but it can only serve a warm cache when consecutive
1093
+ // requests of one conversation land on the same machine. `prompt_cache_key`
1094
+ // is the documented routing hint for exactly that: a stable key per
1095
+ // conversation raises the hit rate, and the hits are already reported back
1096
+ // through `prompt_tokens_details.cached_tokens` (see src/usage.js). The key
1097
+ // is supplied by the caller (src/ai-stream.js) because only it knows the
1098
+ // chat id; a request with no chat omits the field.
1099
+ const cacheKey = requestOpts && requestOpts.promptCacheKey;
1100
+ if (cacheKey && PROMPT_CACHE_KEY_PROVIDERS.has(model.provider)) {
1101
+ body.prompt_cache_key = String(cacheKey);
1102
+ }
1103
+ // Azure OpenAI requires an `api-version` query parameter on every
1104
+ // request. The provider form lets the user set model.apiVersion
1105
+ // (defaults to the ENDPOINTS default below); an explicit query
1106
+ // already present in the base URL wins. Azure's URL shape is
1107
+ // https://<res>.openai.azure.com/openai/deployments/<deploy>/chat/
1108
+ // completions?api-version=<version> — the query goes on the joined
1109
+ // deployment URL (after the /chat/completions suffix).
1110
+ let effectiveUrl = joinUrl(baseUrl, ENDPOINTS['openai-compatible'].chatPath);
1111
+ if (model.provider === 'azure') {
1112
+ const apiVersion = (model && model.apiVersion)
1113
+ || (def && def.apiVersion)
1114
+ || '2024-10-21';
1115
+ if (effectiveUrl.indexOf('api-version=') < 0) {
1116
+ const separator = effectiveUrl.indexOf('?') >= 0 ? '&' : '?';
1117
+ effectiveUrl = effectiveUrl + separator + 'api-version=' + encodeURIComponent(apiVersion);
1118
+ }
1119
+ }
1120
+ // Inject thinking level (reasoning_effort) for OpenAI-compatible
1121
+ // providers. Empty string means off/default. Any non-empty value is
1122
+ // passed through verbatim — the valid set comes from the provider
1123
+ // (surface via the model's `thinking.levels` descriptor) and varies
1124
+ // by model ("minimal"/"low"/"medium"/"high"/"xhigh" today), so
1125
+ // whitelisting here would just lag the upstream.
1126
+ if (model.thinkingLevel) {
1127
+ const tl = String(model.thinkingLevel).trim();
1128
+ if (tl) body.reasoning_effort = tl;
1129
+ }
1130
+ // Inject a max output cap for OpenAI-compatible providers. Empty string
1131
+ // means upstream default (never set the field); a positive integer sets
1132
+ // max_completion_tokens. OpenAI-shaped chat-completions endpoints accept
1133
+ // max_completion_tokens on both classic and reasoning models.
1134
+ const maxOut = String(model.maxOutputTokens || '').trim();
1135
+ if (maxOut && /^\d+$/.test(maxOut) && parseInt(maxOut, 10) > 0) {
1136
+ body.max_completion_tokens = parseInt(maxOut, 10);
1137
+ }
1138
+ return {
1139
+ url: effectiveUrl,
1140
+ headers,
1141
+ body
1142
+ };
1143
+ }
1144
+
1145
+ function openAIContentToAnthropic(content) {
1146
+ if (!Array.isArray(content)) return content;
1147
+ return content.map((part) => {
1148
+ if (!part || typeof part !== 'object') return { type: 'text', text: String(part || '') };
1149
+ if (part.type === 'text') return { type: 'text', text: part.text || '' };
1150
+ if (part.type === 'image_url' && part.image_url && typeof part.image_url.url === 'string') {
1151
+ const m = part.image_url.url.match(/^data:([^;]+);base64,(.*)$/);
1152
+ if (m) return { type: 'image', source: { type: 'base64', media_type: m[1], data: m[2] } };
1153
+ }
1154
+ return { type: 'text', text: '' };
1155
+ }).filter((part) => part.type !== 'text' || part.text);
1156
+ }
1157
+
1158
+ function openAIContentToGeminiParts(content) {
1159
+ if (!Array.isArray(content)) return [{ text: content == null ? '' : String(content) }];
1160
+ return content.map((part) => {
1161
+ if (!part || typeof part !== 'object') return { text: String(part || '') };
1162
+ if (part.type === 'text') return { text: part.text || '' };
1163
+ if (part.type === 'image_url' && part.image_url && typeof part.image_url.url === 'string') {
1164
+ const m = part.image_url.url.match(/^data:([^;]+);base64,(.*)$/);
1165
+ if (m) return { inlineData: { mimeType: m[1], data: m[2] } };
1166
+ }
1167
+ return { text: '' };
1168
+ }).filter((part) => part.text || part.inlineData);
1169
+ }
1170
+
1171
+ function systemContentText(content) {
1172
+ if (content == null) return '';
1173
+ if (typeof content === 'string') return content;
1174
+ if (Array.isArray(content)) {
1175
+ return content.map((part) => {
1176
+ if (part == null) return '';
1177
+ if (typeof part === 'string') return part;
1178
+ if (typeof part === 'object' && part.type === 'text') return part.text || '';
1179
+ return '';
1180
+ }).filter(Boolean).join('\n');
1181
+ }
1182
+ return String(content);
1183
+ }
1184
+
1185
+ // openAIToolsToAnthropic(specs) -> [{ name, description, input_schema }]
1186
+ // Converts the OpenAI-shaped tool specs ({ type:'function',
1187
+ // function:{ name, description, parameters } }) into Anthropic's native
1188
+ // tool form. The `parameters` block is already JSON Schema, which is
1189
+ // exactly what Anthropic's input_schema expects.
1190
+ function openAIToolsToAnthropic(specs) {
1191
+ return (Array.isArray(specs) ? specs : [])
1192
+ .map((s) => {
1193
+ const fn = s && s.function;
1194
+ if (!fn || typeof fn.name !== 'string' || !fn.name) return null;
1195
+ return {
1196
+ name: fn.name,
1197
+ description: typeof fn.description === 'string' ? fn.description : '',
1198
+ input_schema: fn.parameters || { type: 'object', properties: {} }
1199
+ };
1200
+ })
1201
+ .filter(Boolean);
1202
+ }
1203
+
1204
+ // openAIMessageToAnthropic(m) -> Anthropic message | null
1205
+ // Converts one OpenAI-shaped conversation message to the Anthropic shape:
1206
+ // - assistant + tool_calls -> text block (when present) + tool_use blocks
1207
+ // - role 'tool' -> user message with a tool_result block
1208
+ // - everything else -> role + content converted via
1209
+ // openAIContentToAnthropic (images etc.)
1210
+ // Returns null for a message with no representable content.
1211
+ function openAIMessageToAnthropic(m) {
1212
+ if (!m || typeof m !== 'object') return null;
1213
+ if (m.role === 'tool') {
1214
+ // OpenAI tool-result -> Anthropic tool_result block inside a user
1215
+ // message. tool_use_id must reference a prior tool_use block's id
1216
+ // in the same conversation; the tool loop preserves the upstream id
1217
+ // across the assistant(tool_use) -> tool_result round trip.
1218
+ return {
1219
+ role: 'user',
1220
+ content: [{
1221
+ type: 'tool_result',
1222
+ tool_use_id: m.tool_call_id || m.toolCallId || '',
1223
+ content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content == null ? '' : m.content)
1224
+ }]
1225
+ };
1226
+ }
1227
+ if (m.role === 'assistant') {
1228
+ const blocks = [];
1229
+ if (typeof m.content === 'string' && m.content) blocks.push({ type: 'text', text: m.content });
1230
+ else if (Array.isArray(m.content)) blocks.push(...openAIContentToAnthropic(m.content));
1231
+ if (Array.isArray(m.tool_calls)) {
1232
+ for (const tc of m.tool_calls) {
1233
+ const fn = tc.function || {};
1234
+ let input = {};
1235
+ if (typeof fn.arguments === 'string') {
1236
+ try { input = JSON.parse(fn.arguments); } catch { input = {}; }
1237
+ } else if (fn.arguments && typeof fn.arguments === 'object') {
1238
+ input = fn.arguments;
1239
+ }
1240
+ blocks.push({ type: 'tool_use', id: tc.id || undefined, name: fn.name || 'tool', input });
1241
+ }
1242
+ }
1243
+ if (!blocks.length) return null;
1244
+ return { role: 'assistant', content: blocks };
1245
+ }
1246
+ const content = openAIContentToAnthropic(m.content);
1247
+ if (content == null || content === '') return null;
1248
+ return { role: m.role || 'user', content };
1249
+ }
1250
+
1251
+ // markPenultimateMessage(messages) — add a cache_control breakpoint to the
1252
+ // second-to-last converted message. The penultimate message is the deepest
1253
+ // point of the stable, replayed prefix: everything before the current turn
1254
+ // (system, tools, and the full history) is re-sent byte-identically on every
1255
+ // request — each tool round and every follow-up user turn — so the next
1256
+ // request reads it from cache at the discounted rate. The FINAL message is
1257
+ // never marked: a breakpoint on the current turn is ignored by the API and
1258
+ // its content is not part of the stable prefix anyway. Plain-text messages
1259
+ // are wrapped in a text block because cache_control is only honored on
1260
+ // object blocks.
1261
+ function markPenultimateMessage(messages) {
1262
+ const target = messages[messages.length - 2];
1263
+ if (!target || !target.content) return;
1264
+ if (!Array.isArray(target.content)) {
1265
+ target.content = [{ type: 'text', text: String(target.content), cache_control: { type: 'ephemeral' } }];
1266
+ return;
1267
+ }
1268
+ const last = target.content[target.content.length - 1];
1269
+ if (last && typeof last === 'object' && !last.cache_control) {
1270
+ last.cache_control = { type: 'ephemeral' };
1271
+ }
1272
+ }
1273
+
1274
+ // isOpenRouterAnthropicModel(model) — true for a Claude model routed
1275
+ // through OpenRouter. OpenRouter forwards Anthropic prompt caching but
1276
+ // only when the OpenAI-shaped request carries explicit `cache_control`
1277
+ // breakpoints on message content blocks; the vanilla OpenAI body has
1278
+ // none, so Claude-via-OpenRouter never gets a cache hit without this.
1279
+ // The slug is vendor-prefixed (`anthropic/claude-...`); we match the
1280
+ // vendor prefix so every current and future Claude slug is covered.
1281
+ function isOpenRouterAnthropicModel(model) {
1282
+ return !!(model && model.provider === 'openrouter'
1283
+ && typeof model.id === 'string'
1284
+ && /^anthropic\//i.test(model.id));
1285
+ }
1286
+
1287
+ // markOpenAIMessageCache(message) — add a cache_control breakpoint to an
1288
+ // OpenAI-shaped message by wrapping/annotating its content parts. Mirrors
1289
+ // markPenultimateMessage but for the OpenAI content shape OpenRouter
1290
+ // consumes: a plain string is wrapped in a single text part, and the last
1291
+ // part of an existing array gets the marker. Returns a NEW message object
1292
+ // so the caller never mutates the shared conversation array.
1293
+ function markOpenAIMessageCache(message) {
1294
+ if (!message || message.content == null) return message;
1295
+ if (typeof message.content === 'string') {
1296
+ if (!message.content) return message;
1297
+ return Object.assign({}, message, {
1298
+ content: [{ type: 'text', text: message.content, cache_control: { type: 'ephemeral' } }]
1299
+ });
1300
+ }
1301
+ if (Array.isArray(message.content) && message.content.length) {
1302
+ const parts = message.content.map((p) => (p && typeof p === 'object') ? Object.assign({}, p) : p);
1303
+ for (let i = parts.length - 1; i >= 0; i--) {
1304
+ if (parts[i] && typeof parts[i] === 'object') {
1305
+ if (!parts[i].cache_control) parts[i].cache_control = { type: 'ephemeral' };
1306
+ break;
1307
+ }
1308
+ }
1309
+ return Object.assign({}, message, { content: parts });
1310
+ }
1311
+ return message;
1312
+ }
1313
+
1314
+ // injectOpenRouterAnthropicCache(messages) — return a copy of the
1315
+ // conversation with cache_control breakpoints placed the same way
1316
+ // buildAnthropicRequest places them on the native path: the LAST system
1317
+ // message (the stable profile + agent files + custom prompt block) and
1318
+ // the penultimate message (the deepest point of the replayed prefix:
1319
+ // system + history). The final message is never marked — a breakpoint on
1320
+ // the current turn is ignored and its content is not part of the stable
1321
+ // prefix. The input array is never mutated.
1322
+ function injectOpenRouterAnthropicCache(messages) {
1323
+ if (!Array.isArray(messages) || !messages.length) return messages;
1324
+ const out = messages.slice();
1325
+ // Last system message → cache the stable system prefix.
1326
+ for (let i = out.length - 1; i >= 0; i--) {
1327
+ if (out[i] && out[i].role === 'system') { out[i] = markOpenAIMessageCache(out[i]); break; }
1328
+ }
1329
+ // Penultimate breakpoint → cache system + tools + history once there is
1330
+ // any history to replay (caching engages from the second request).
1331
+ // Walk back from the penultimate message to the deepest one that can
1332
+ // actually carry a breakpoint. In the agentic tool loop the penultimate
1333
+ // message is often an assistant tool-call message with `content: null`
1334
+ // (the OpenAI shape keeps tool calls in `tool_calls`, not in content),
1335
+ // and cache_control is only honored on a content block. Marking that
1336
+ // message is a no-op, which on the native path is harmless (the system
1337
+ // block still clears the minimum) but here would leave ONLY the small
1338
+ // system block marked — usually below Claude's minimum cacheable length,
1339
+ // so every tool round got a 0% cache hit. Skip content-less messages so
1340
+ // a real breakpoint always lands.
1341
+ for (let i = out.length - 2; i >= 0; i--) {
1342
+ const m = out[i];
1343
+ if (!m || m.role === 'system' || m.content == null || m.content === '') continue;
1344
+ out[i] = markOpenAIMessageCache(out[i]);
1345
+ break;
1346
+ }
1347
+ return out;
1348
+ }
1349
+
1350
+ function buildAnthropicRequest(model, messages, stream, specs) {
1351
+ const systemMsgs = messages.filter(m => m.role === 'system');
1352
+ const systemContent = systemMsgs.map(m => systemContentText(m.content)).filter(Boolean).join('\n\n');
1353
+ const chatMessages = messages.filter(m => m.role !== 'system');
1354
+ const maxOutput = String(model.maxOutputTokens || '').trim();
1355
+ const maxOutputNum = (maxOutput && /^\d+$/.test(maxOutput) && parseInt(maxOutput, 10) > 0)
1356
+ ? parseInt(maxOutput, 10)
1357
+ : 0;
1358
+ // Prompt caching is generally available on the Messages API, so cache
1359
+ // markers work with both API-key and OAuth authentication. OAuth keeps its
1360
+ // required oauth-2025-04-20 beta header; API-key requests need no beta.
1361
+ // Convert the conversation to Anthropic's native shape: assistant
1362
+ // tool_calls become tool_use blocks, `tool` role messages become
1363
+ // tool_result user messages. Without this conversion the multi-turn
1364
+ // tool loop could never run against Claude. Conversion happens first
1365
+ // because empty segments drop out — the penultimate breakpoint below
1366
+ // must target the array actually sent upstream.
1367
+ const convertedMessages = chatMessages.map(openAIMessageToAnthropic).filter(Boolean);
1368
+ // Cache breakpoint on the penultimate message. Anthropic only honors a
1369
+ // cache_control marker when the prompt prefix before it exceeds the
1370
+ // per-model minimum cacheable length (1024 tokens for Sonnet 3.5/3.7,
1371
+ // 4096 for Sonnet 4 / Opus 4 / Haiku 4.5). The system block alone — and
1372
+ // even the system block plus a small tool set — is usually below that,
1373
+ // so a system-only breakpoint is silently ignored and caching never
1374
+ // happens. The penultimate-message breakpoint guarantees the cached
1375
+ // prefix (system + tools + history) clears the minimum whenever there
1376
+ // is any history to replay: caching engages from the second request of
1377
+ // a conversation, even with every tool switched off.
1378
+ if (convertedMessages.length >= 2) markPenultimateMessage(convertedMessages);
1379
+ const body = {
1380
+ model: model.id,
1381
+ max_tokens: maxOutputNum || 1024,
1382
+ // Anthropic prompt caching: the system message (profile + agent files +
1383
+ // custom prompt + feature summary) is stable across every turn of a
1384
+ // multi-tool conversation, so marking it with cache_control means the
1385
+ // second and subsequent turns read it from cache (~90% discount).
1386
+ // The system block is sent as an array so the cache_control field is
1387
+ // accepted; a plain string would silently ignore it.
1388
+ system: systemContent
1389
+ ? [{ type: 'text', text: systemContent, cache_control: { type: 'ephemeral' } }]
1390
+ : undefined,
1391
+ messages: convertedMessages,
1392
+ stream: !!stream
1393
+ };
1394
+ // Native Anthropic tools, converted from the same OpenAI-shaped specs
1395
+ // the other providers advertise. Marking the LAST tool definition with
1396
+ // cache_control extends the cached prefix far past the system block, so
1397
+ // the tool marker alone usually clears the minimum cacheable length.
1398
+ // The penultimate-message breakpoint above still applies for requests
1399
+ // whose prefix is short (no tools, or a small tool set).
1400
+ const tools = openAIToolsToAnthropic(specs);
1401
+ if (tools.length) {
1402
+ body.tools = tools;
1403
+ body.tools[body.tools.length - 1].cache_control = { type: 'ephemeral' };
1404
+ }
1405
+ // Inject thinking budget for Anthropic. The thinking level maps to
1406
+ // a budget_tokens value. When the level is a plain number string, use
1407
+ // it directly as budget_tokens. Known presets: "low"=2048, "medium"=8192, "high"=16384.
1408
+ // An empty string means no thinking block (default).
1409
+ if (model.thinkingLevel) {
1410
+ const tl = String(model.thinkingLevel).trim();
1411
+ let budget = 0;
1412
+ if (tl === 'low') budget = 2048;
1413
+ else if (tl === 'medium') budget = 8192;
1414
+ else if (tl === 'high') budget = 16384;
1415
+ else {
1416
+ const n = parseInt(tl, 10);
1417
+ if (isFinite(n) && n > 0) budget = n;
1418
+ }
1419
+ if (budget > 0) {
1420
+ // The budget is capped so a bogus "999999" from a project file cannot
1421
+ // ask for an absurd thinking window. max_tokens must clear the
1422
+ // thinking budget (Anthropic rejects the request otherwise), so it is
1423
+ // derived from the same capped value — computing it from the
1424
+ // uncapped `budget` produced `budget_tokens: 100000` next to
1425
+ // `max_tokens: 200256` for a 200000-token request.
1426
+ const capped = Math.min(budget, 100000);
1427
+ body.thinking = { type: 'enabled', budget_tokens: capped };
1428
+ if (body.max_tokens < capped + 256) body.max_tokens = capped + 256;
1429
+ }
1430
+ }
1431
+ return {
1432
+ // model.baseUrl wins when set, so test mocks and Anthropic-compatible
1433
+ // proxies (Bedrock, Vertex, Foundry) can route the call. Production
1434
+ // Anthropic usage leaves baseUrl unset, in which case the
1435
+ // per-provider default applies.
1436
+ url: joinUrl(model.baseUrl || ENDPOINTS.anthropic.baseUrl, ENDPOINTS.anthropic.chatPath),
1437
+ headers: {
1438
+ 'Content-Type': 'application/json',
1439
+ ...ENDPOINTS.anthropic.authHeader(credential(model), model)
1440
+ },
1441
+ body
1442
+ };
1443
+ }
1444
+
1445
+ function buildGeminiRequest(model, messages, stream) {
1446
+ // Gemini uses ?alt=sse for streaming responses.
1447
+ const url = joinUrl(ENDPOINTS.gemini.baseUrl, '/v1beta/models/' + encodeURIComponent(model.id) + ':' + (stream ? 'streamGenerateContent?alt=sse' : 'generateContent'));
1448
+ const systemMsgs = messages.filter(m => m.role === 'system');
1449
+ // systemContentText handles both shapes a system message can take: a
1450
+ // plain string, and the block array the subagent runner pushes
1451
+ // (`[{ type: 'text', text }]`). Joining `m.content` directly sent
1452
+ // "[object Object]" as the entire system instruction.
1453
+ const systemContent = systemMsgs.map(m => systemContentText(m.content)).filter(Boolean).join('\n\n');
1454
+ const contents = messages
1455
+ .filter(m => m.role !== 'system')
1456
+ .map(m => ({ role: m.role === 'assistant' ? 'model' : 'user', parts: openAIContentToGeminiParts(m.content) }));
1457
+ const body = { contents };
1458
+ if (systemContent) body.systemInstruction = { role: 'system', parts: [{ text: systemContent }] };
1459
+ // Inject thinking budget for Gemini 2.5+ models. The thinking level
1460
+ // maps to generationConfig.thinkingConfig.thinkingBudget. Known
1461
+ // presets: "low"=2048, "medium"=8192, "high"=16384; a plain number
1462
+ // string is used directly. Empty string leaves thinking at the
1463
+ // upstream default (dynamic).
1464
+ if (model.thinkingLevel) {
1465
+ const tl = String(model.thinkingLevel).trim();
1466
+ let budget = 0;
1467
+ if (tl === 'low') budget = 2048;
1468
+ else if (tl === 'medium') budget = 8192;
1469
+ else if (tl === 'high') budget = 16384;
1470
+ else {
1471
+ const n = parseInt(tl, 10);
1472
+ if (isFinite(n) && n > 0) budget = n;
1473
+ }
1474
+ if (budget > 0) {
1475
+ body.generationConfig = Object.assign({}, body.generationConfig, {
1476
+ thinkingConfig: { thinkingBudget: budget }
1477
+ });
1478
+ }
1479
+ }
1480
+ return {
1481
+ url,
1482
+ headers: { 'Content-Type': 'application/json', ...ENDPOINTS.gemini.authHeader(credential(model)) },
1483
+ body
1484
+ };
1485
+ }
1486
+
1487
+ function buildOllamaRequest(model, messages, stream) {
1488
+ // Ollama's /api/chat takes OpenAI-shaped messages but expects `content`
1489
+ // to be a string: a block array (the subagent runner's system message)
1490
+ // is rejected or mis-parsed upstream. Flatten the same way the Anthropic
1491
+ // and Gemini builders do.
1492
+ const normalized = messages.map((m) => (
1493
+ m && Array.isArray(m.content)
1494
+ ? Object.assign({}, m, { content: systemContentText(m.content) })
1495
+ : m
1496
+ ));
1497
+ const body = { model: model.id, messages: normalized, stream: !!stream };
1498
+ // Ollama's reasoning switch is a boolean `think` flag. Any non-empty
1499
+ // thinking level means "on"; empty means upstream default (off for
1500
+ // most models).
1501
+ if (model.thinkingLevel && String(model.thinkingLevel).trim()) body.think = true;
1502
+ return {
1503
+ url: joinUrl(model.baseUrl || ENDPOINTS.ollama.baseUrl, ENDPOINTS.ollama.chatPath),
1504
+ headers: { 'Content-Type': 'application/json' },
1505
+ body
1506
+ };
1507
+ }
1508
+
1509
+ const BUILDERS = {
1510
+ 'openai-compatible': buildOpenAIRequest,
1511
+ 'anthropic': buildAnthropicRequest,
1512
+ 'gemini': buildGeminiRequest,
1513
+ 'ollama': buildOllamaRequest,
1514
+ 'github-copilot': buildOpenAIRequest, // same shape; ENOAUTH gate above
1515
+ 'openrouter': buildOpenAIRequest, // same shape; apikey-only, reuses staticHeaders
1516
+ 'azure': buildOpenAIRequest, // OpenAI-shaped; api-version appended below
1517
+ 'mistral': buildOpenAIRequest,
1518
+ 'groq': buildOpenAIRequest,
1519
+ 'deepseek': buildOpenAIRequest
1520
+ };
1521
+
1522
+ // ---- Event parsers -----------------------------------------------------
1523
+ // Each parser reads one event from the upstream and emits zero or more
1524
+ // normalized events. The normalized event names are:
1525
+ // message -> { delta: 'text' }
1526
+ // reasoning -> { delta: 'thinking/reasoning text' }
1527
+ // done -> { usage: { promptTokens, completionTokens } }
1528
+ // error -> { code, message }
1529
+ // Anything else from the upstream is passed through as `passthrough` so
1530
+ // the UI can render it for debugging.
1531
+
1532
+ const PARSERS = {
1533
+ 'openai-compatible': parseOpenAISSE,
1534
+ 'github-copilot': parseOpenAISSE,
1535
+ 'openrouter': parseOpenAISSE, // OpenAI-shaped SSE; [DONE] sentinel suppressed
1536
+ 'azure': parseOpenAISSE,
1537
+ 'mistral': parseOpenAISSE,
1538
+ 'groq': parseOpenAISSE,
1539
+ 'deepseek': parseOpenAISSE,
1540
+ 'anthropic': parseAnthropicSSE,
1541
+ 'gemini': parseGeminiSSE,
1542
+ 'ollama': parseOllamaNDJSON
1543
+ };
1544
+
1545
+ function firstFiniteNumber(...values) {
1546
+ const n = firstFiniteNumberOrNull(...values);
1547
+ return n == null ? 0 : n;
1548
+ }
1549
+
1550
+ function firstFiniteNumberOrNull(...values) {
1551
+ for (const value of values) {
1552
+ if (value === undefined || value === null || value === '') continue;
1553
+ const n = Number(value);
1554
+ if (isFinite(n) && n >= 0) return n;
1555
+ }
1556
+ return null;
1557
+ }
1558
+
1559
+ // firstStringField() is shared from src/util.js.
1560
+
1561
+ function* parseOpenAISSE(eventName, data) {
1562
+ if (!data) return;
1563
+ // OpenAI uses the literal "[DONE]" as a stream terminator. Suppress it.
1564
+ if (data.trim() === '[DONE]') return;
1565
+ let obj;
1566
+ try { obj = JSON.parse(data); } catch { yield { name: 'passthrough', data: { raw: data } }; return; }
1567
+ if (obj.error) {
1568
+ yield { name: 'error', data: { code: 'EUPSTREAM', message: obj.error.message || String(obj.error) } };
1569
+ return;
1570
+ }
1571
+ const choice = obj.choices && obj.choices[0];
1572
+ const delta = choice && choice.delta;
1573
+ const reasoning = firstStringField(delta, [
1574
+ 'reasoning',
1575
+ 'reasoning_content',
1576
+ 'reasoningContent',
1577
+ 'thinking',
1578
+ 'thought',
1579
+ 'chain_of_thought'
1580
+ ]);
1581
+ if (reasoning) {
1582
+ yield { name: 'reasoning', data: { delta: reasoning } };
1583
+ }
1584
+ if (choice && choice.message) {
1585
+ const messageReasoning = firstStringField(choice.message, [
1586
+ 'reasoning',
1587
+ 'reasoning_content',
1588
+ 'reasoningContent',
1589
+ 'thinking',
1590
+ 'thought',
1591
+ 'chain_of_thought'
1592
+ ]);
1593
+ if (messageReasoning) yield { name: 'reasoning', data: { delta: messageReasoning } };
1594
+ }
1595
+ if (delta && typeof delta.content === 'string') {
1596
+ yield { name: 'message', data: { delta: delta.content } };
1597
+ }
1598
+ // OpenAI tool calls stream as a `delta.tool_calls` array. The id
1599
+ // appears on the first delta for a given index; subsequent deltas
1600
+ // fill in `function.name` and the streamed `function.arguments`
1601
+ // string. We accumulate by index and emit a `tool_call` event when
1602
+ // we see a `finish_reason === 'tool_calls'`.
1603
+ if (choice && Array.isArray(choice.delta && choice.delta.tool_calls)) {
1604
+ for (const tc of choice.delta.tool_calls) {
1605
+ yield { name: 'tool_call_delta', data: tc };
1606
+ }
1607
+ }
1608
+ if (choice && choice.finish_reason) {
1609
+ yield { name: 'finish', data: { reason: choice.finish_reason } };
1610
+ }
1611
+ if (obj.usage) {
1612
+ const promptTokens = firstFiniteNumber(
1613
+ obj.usage.prompt_tokens,
1614
+ obj.usage.input_tokens,
1615
+ obj.usage.promptTokens,
1616
+ obj.usage.inputTokens
1617
+ );
1618
+ const completionTokens = firstFiniteNumber(
1619
+ obj.usage.completion_tokens,
1620
+ obj.usage.output_tokens,
1621
+ obj.usage.completionTokens,
1622
+ obj.usage.outputTokens
1623
+ );
1624
+ // OpenAI and OpenAI-shaped providers report cache hits as a subset
1625
+ // of prompt_tokens. Accept snake_case, camelCase, DeepSeek's
1626
+ // prompt_cache_hit_tokens, and nested variants; OpenRouter and
1627
+ // compatible gateways may preserve any of these shapes.
1628
+ const promptDetails = obj.usage.prompt_tokens_details || obj.usage.promptTokensDetails || {};
1629
+ const cacheReadTokens = firstFiniteNumber(
1630
+ promptDetails.cached_tokens,
1631
+ promptDetails.cachedTokens,
1632
+ obj.usage.cached_tokens,
1633
+ obj.usage.cachedTokens,
1634
+ obj.usage.prompt_cache_hit_tokens,
1635
+ obj.usage.promptCacheHitTokens
1636
+ );
1637
+ const providerCost = firstFiniteNumberOrNull(
1638
+ obj.usage.cost,
1639
+ obj.usage.total_cost,
1640
+ obj.usage.totalCost
1641
+ );
1642
+ // OpenRouter additionally reports a cost split under cost_details
1643
+ // (docs: usage.cost_details.upstream_inference_prompt_cost /
1644
+ // completions_cost). Capture it so persisted segments can show a
1645
+ // real input/output cost breakdown instead of a bare total. Missing
1646
+ // cost fields stay null; otherwise an absent field would look like a
1647
+ // provider-authoritative $0.00 and suppress local pricing fallback.
1648
+ const cd = (obj.usage && obj.usage.cost_details) || {};
1649
+ const providerCostInput = firstFiniteNumberOrNull(
1650
+ cd.upstream_inference_prompt_cost,
1651
+ cd.prompt_cost,
1652
+ cd.input_cost
1653
+ );
1654
+ const providerCostOutput = firstFiniteNumberOrNull(
1655
+ cd.upstream_inference_completions_cost,
1656
+ cd.completion_cost,
1657
+ cd.completions_cost,
1658
+ cd.output_cost
1659
+ );
1660
+ yield {
1661
+ name: 'done',
1662
+ data: {
1663
+ usage: { promptTokens, completionTokens, cacheReadTokens },
1664
+ providerCost,
1665
+ providerCostInput,
1666
+ providerCostOutput
1667
+ }
1668
+ };
1669
+ }
1670
+ }
1671
+
1672
+ // Some models routed through OpenRouter (notably MiniMax) occasionally put
1673
+ // their private tool-call serialization in `delta.content` instead of using
1674
+ // the OpenAI `delta.tool_calls` field. OpenRouter forwards that text verbatim:
1675
+ //
1676
+ // ]<]minimax[>[<tool_call> ... <invoke name="search_files"> ...
1677
+ //
1678
+ // Parse that compatibility form only after a complete upstream turn. Keeping
1679
+ // it out of parseOpenAISSE avoids interpreting ordinary XML/code examples as
1680
+ // calls, and the MiniMax sentinel makes the fallback deliberately narrow.
1681
+ function parseMiniMaxTextToolCalls(text) {
1682
+ const source = String(text || '');
1683
+ if (source.indexOf(']<]minimax[>[') < 0 || source.indexOf('<tool_call>') < 0) {
1684
+ return { text: source, calls: [] };
1685
+ }
1686
+
1687
+ const calls = [];
1688
+ const blockRe = /\]<\]minimax\[>\[<tool_call>[\s\S]*?\]<\]minimax\[>\[<\/tool_call>/g;
1689
+ let match;
1690
+ while ((match = blockRe.exec(source))) {
1691
+ const block = match[0];
1692
+ const invoke = /<invoke\s+name=["']([^"']+)["']\s*>([\s\S]*?)<\/invoke>/.exec(block);
1693
+ if (!invoke) continue;
1694
+ const args = {};
1695
+ const argRe = /<([A-Za-z_][\w.-]*)>([\s\S]*?)<\/\1>/g;
1696
+ let arg;
1697
+ while ((arg = argRe.exec(invoke[2]))) {
1698
+ const value = arg[2].trim();
1699
+ try { args[arg[1]] = JSON.parse(value); }
1700
+ catch { args[arg[1]] = value; }
1701
+ }
1702
+ calls.push({
1703
+ id: 'call_minimax_' + (calls.length + 1),
1704
+ name: invoke[1],
1705
+ arguments: JSON.stringify(args)
1706
+ });
1707
+ }
1708
+
1709
+ if (!calls.length) return { text: source, calls: [] };
1710
+ return { text: source.replace(blockRe, '').trim(), calls };
1711
+ }
1712
+
1713
+ // parseAnthropicSSE(eventName, data, toolAcc) — Anthropic stream parser.
1714
+ //
1715
+ // The third argument is a shared per-turn accumulator for tool_use
1716
+ // blocks. Anthropic streams a tool call as three separate frames:
1717
+ // content_block_start (type: 'tool_use', id, name, input: {})
1718
+ // content_block_delta (type: 'input_json_delta', partial_json: '...')
1719
+ // content_block_stop (index of the finished block)
1720
+ // The caller in src/ai-stream.js re-creates the generator for every SSE
1721
+ // frame, so the accumulator must live outside the generator (one per
1722
+ // upstream turn); a per-generator map is used for direct one-shot usage
1723
+ // (tests). Each finished block is emitted as a `tool_call_delta` shaped
1724
+ // like the OpenAI accumulator expects: { index, id, function: { name,
1725
+ // arguments } }.
1726
+ function* parseAnthropicSSE(eventName, data, toolAcc) {
1727
+ const acc = toolAcc || new Map();
1728
+ if (!data) return;
1729
+ let obj;
1730
+ try { obj = JSON.parse(data); } catch { yield { name: 'passthrough', data: { raw: data } }; return; }
1731
+ switch (obj.type) {
1732
+ case 'message_start':
1733
+ // usage is reported here for input tokens.
1734
+ if (obj.message && obj.message.usage) {
1735
+ const usage = obj.message.usage;
1736
+ const uncachedInputTokens = Number(usage.input_tokens) || 0;
1737
+ const cacheReadTokens = Number(usage.cache_read_input_tokens) || 0;
1738
+ const cacheCreationTokens = Number(usage.cache_creation_input_tokens) || 0;
1739
+ yield { name: 'usage_input', data: {
1740
+ // Anthropic reports three disjoint input buckets. Normalize them
1741
+ // to the provider-neutral contract where promptTokens is the full
1742
+ // prompt total; computeCost subtracts the cache buckets to recover
1743
+ // the full-rate, uncached portion.
1744
+ promptTokens: uncachedInputTokens + cacheReadTokens + cacheCreationTokens,
1745
+ cacheReadTokens,
1746
+ cacheCreationTokens
1747
+ } };
1748
+ }
1749
+ break;
1750
+ case 'content_block_start':
1751
+ if (obj.content_block && obj.content_block.type === 'tool_use') {
1752
+ const block = obj.content_block;
1753
+ acc.set(obj.index, { id: block.id, name: block.name, partial: '' });
1754
+ }
1755
+ break;
1756
+ case 'content_block_delta':
1757
+ if (obj.delta && obj.delta.type === 'text_delta' && typeof obj.delta.text === 'string') {
1758
+ yield { name: 'message', data: { delta: obj.delta.text } };
1759
+ } else if (obj.delta && obj.delta.type === 'thinking_delta' && typeof obj.delta.thinking === 'string') {
1760
+ yield { name: 'reasoning', data: { delta: obj.delta.thinking } };
1761
+ } else if (obj.delta && obj.delta.type === 'signature_delta') {
1762
+ // Anthropic signs extended-thinking blocks; the signature is not user-facing.
1763
+ } else if (obj.delta && obj.delta.type === 'input_json_delta' && typeof obj.delta.partial_json === 'string') {
1764
+ const entry = acc.get(obj.index);
1765
+ if (entry) entry.partial += obj.delta.partial_json;
1766
+ }
1767
+ break;
1768
+ case 'content_block_stop': {
1769
+ const entry = acc.get(obj.index);
1770
+ if (entry && entry.name) {
1771
+ acc.delete(obj.index);
1772
+ yield {
1773
+ name: 'tool_call_delta',
1774
+ data: {
1775
+ index: obj.index,
1776
+ id: entry.id || undefined,
1777
+ function: { name: entry.name, arguments: entry.partial || '{}' }
1778
+ }
1779
+ };
1780
+ }
1781
+ break;
1782
+ }
1783
+ case 'message_delta':
1784
+ if (obj.usage && typeof obj.usage.output_tokens === 'number') {
1785
+ yield { name: 'usage_output', data: { completionTokens: obj.usage.output_tokens } };
1786
+ }
1787
+ break;
1788
+ case 'message_stop':
1789
+ yield { name: 'done', data: {} };
1790
+ break;
1791
+ case 'error':
1792
+ yield { name: 'error', data: { code: 'EUPSTREAM', message: obj.error && obj.error.message || 'anthropic error' } };
1793
+ break;
1794
+ }
1795
+ }
1796
+
1797
+ function* parseGeminiSSE(eventName, data) {
1798
+ if (!data) return;
1799
+ let obj;
1800
+ try { obj = JSON.parse(data); } catch { yield { name: 'passthrough', data: { raw: data } }; return; }
1801
+ const cand = obj.candidates && obj.candidates[0];
1802
+ if (cand && cand.content && cand.content.parts) {
1803
+ for (const part of cand.content.parts) {
1804
+ if (typeof part.thought === 'string') yield { name: 'reasoning', data: { delta: part.thought } };
1805
+ else if (part.thought === true && typeof part.text === 'string') yield { name: 'reasoning', data: { delta: part.text } };
1806
+ else if (typeof part.text === 'string') yield { name: 'message', data: { delta: part.text } };
1807
+ }
1808
+ }
1809
+ if (cand && cand.finishReason) yield { name: 'finish', data: { reason: cand.finishReason } };
1810
+ if (obj.usageMetadata) {
1811
+ const metadata = obj.usageMetadata;
1812
+ yield {
1813
+ name: 'done',
1814
+ data: { usage: {
1815
+ // Gemini includes cachedContentTokenCount in promptTokenCount, so
1816
+ // it maps directly to the provider-neutral cache-read subset.
1817
+ promptTokens: metadata.promptTokenCount || 0,
1818
+ completionTokens: metadata.candidatesTokenCount || 0,
1819
+ cacheReadTokens: metadata.cachedContentTokenCount || 0
1820
+ } }
1821
+ };
1822
+ }
1823
+ if (obj.error) {
1824
+ yield { name: 'error', data: { code: 'EUPSTREAM', message: obj.error.message || 'gemini error' } };
1825
+ }
1826
+ }
1827
+
1828
+ function* parseOllamaNDJSON(_eventName, data) {
1829
+ if (!data) return;
1830
+ let obj;
1831
+ try { obj = JSON.parse(data); } catch { yield { name: 'passthrough', data: { raw: data } }; return; }
1832
+ if (obj.message) {
1833
+ const reasoning = firstStringField(obj.message, [
1834
+ 'thinking',
1835
+ 'reasoning',
1836
+ 'reasoning_content',
1837
+ 'reasoningContent',
1838
+ 'thought'
1839
+ ]);
1840
+ if (reasoning) yield { name: 'reasoning', data: { delta: reasoning } };
1841
+ }
1842
+ if (obj.message && typeof obj.message.content === 'string') {
1843
+ yield { name: 'message', data: { delta: obj.message.content } };
1844
+ }
1845
+ if (obj.done) {
1846
+ yield {
1847
+ name: 'done',
1848
+ data: {
1849
+ usage: {
1850
+ promptTokens: obj.prompt_eval_count || 0,
1851
+ completionTokens: obj.eval_count || 0
1852
+ }
1853
+ }
1854
+ };
1855
+ }
1856
+ if (obj.error) {
1857
+ yield { name: 'error', data: { code: 'EUPSTREAM', message: obj.error } };
1858
+ }
1859
+ }
1860
+
1861
+ module.exports = {
1862
+ ENDPOINTS,
1863
+ listModels,
1864
+ listTranscriptionModels,
1865
+ listImageModels,
1866
+ endpointFor,
1867
+ requireApiKey,
1868
+ BUILDERS,
1869
+ PARSERS,
1870
+ parseMiniMaxTextToolCalls,
1871
+ // Exported so scripts/test-model-lists.js can pin the Gemini catalog
1872
+ // parser.
1873
+ parseGeminiModels,
1874
+ // Curated catalogs, exported so scripts/test-model-pricing-coverage.js can
1875
+ // assert that every model id we *offer* also has a built-in price.
1876
+ ANTHROPIC_MODEL_CATALOG,
1877
+ COPILOT_MODEL_CATALOG,
1878
+ // Copilot token cache clear (OAuth refresher + tests)
1879
+ copilotCacheClear
1880
+ };