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