@khanglvm/llm-router 2.3.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +2 -2
  3. package/package.json +1 -1
  4. package/src/cli/router-module.js +32 -5
  5. package/src/node/coding-tool-config.js +138 -25
  6. package/src/node/large-request-log.js +54 -0
  7. package/src/node/litellm-context-catalog.js +13 -1
  8. package/src/node/local-server.js +10 -0
  9. package/src/node/ollama-client.js +195 -0
  10. package/src/node/ollama-hardware.js +94 -0
  11. package/src/node/ollama-install.js +230 -0
  12. package/src/node/provider-probe.js +69 -5
  13. package/src/node/web-console-client.js +36 -36
  14. package/src/node/web-console-server.js +478 -8
  15. package/src/node/web-console-styles.generated.js +1 -1
  16. package/src/node/web-console-ui/amp-utils.js +272 -0
  17. package/src/node/web-console-ui/api-client.js +128 -0
  18. package/src/node/web-console-ui/capability-utils.js +36 -0
  19. package/src/node/web-console-ui/config-editor-utils.js +20 -5
  20. package/src/node/web-console-ui/constants.js +140 -0
  21. package/src/node/web-console-ui/context-window-utils.js +262 -0
  22. package/src/node/web-console-ui/hooks/use-reorder-layout-animation.js +65 -0
  23. package/src/node/web-console-ui/provider-presets.js +211 -0
  24. package/src/node/web-console-ui/quick-start-utils.js +790 -0
  25. package/src/node/web-console-ui/utils.js +353 -0
  26. package/src/node/web-console-ui/web-search-utils.js +460 -0
  27. package/src/runtime/config.js +96 -9
  28. package/src/runtime/handler/fallback.js +71 -0
  29. package/src/runtime/handler/field-filter.js +39 -0
  30. package/src/runtime/handler/large-request-log.js +211 -0
  31. package/src/runtime/handler/provider-call.js +185 -15
  32. package/src/runtime/handler/reasoning-effort.js +11 -1
  33. package/src/runtime/handler/tool-name-sanitizer.js +258 -0
  34. package/src/runtime/handler.js +16 -3
  35. package/src/shared/coding-tool-bindings.js +3 -0
@@ -0,0 +1,460 @@
1
+ import { AMP_WEB_SEARCH_PROVIDER_OPTIONS, AMP_WEB_SEARCH_PROVIDER_META, AMP_WEB_SEARCH_DEFAULT_COUNT, AMP_WEB_SEARCH_MIN_COUNT, AMP_WEB_SEARCH_MAX_COUNT } from "./constants.js";
2
+ import { ensureAmpDraftConfigShape, parseAmpWebSearchInteger } from "./amp-utils.js";
3
+ import { safeClone } from "./utils.js";
4
+
5
+ export function ensureWebSearchConfigShape(config = {}) {
6
+ const next = config && typeof config === "object" && !Array.isArray(config)
7
+ ? config
8
+ : {};
9
+ const legacyWebSearch = next.amp?.webSearch && typeof next.amp.webSearch === "object" && !Array.isArray(next.amp.webSearch)
10
+ ? safeClone(next.amp.webSearch)
11
+ : null;
12
+
13
+ if (!next.webSearch || typeof next.webSearch !== "object" || Array.isArray(next.webSearch)) {
14
+ next.webSearch = legacyWebSearch || {};
15
+ }
16
+
17
+ if (next.amp && typeof next.amp === "object" && !Array.isArray(next.amp) && Object.prototype.hasOwnProperty.call(next.amp, "webSearch")) {
18
+ delete next.amp.webSearch;
19
+ }
20
+
21
+ const strategy = String(next.webSearch.strategy || "").trim();
22
+ next.webSearch.strategy = strategy === "quota-balance" ? "quota-balance" : "ordered";
23
+ if (next.webSearch.count !== undefined && next.webSearch.count !== null && String(next.webSearch.count).trim() !== "") {
24
+ next.webSearch.count = parseAmpWebSearchInteger(next.webSearch.count, AMP_WEB_SEARCH_DEFAULT_COUNT, {
25
+ min: AMP_WEB_SEARCH_MIN_COUNT,
26
+ max: AMP_WEB_SEARCH_MAX_COUNT
27
+ });
28
+ } else {
29
+ delete next.webSearch.count;
30
+ }
31
+ if (!Array.isArray(next.webSearch.providers)) {
32
+ next.webSearch.providers = [];
33
+ }
34
+ return next.webSearch;
35
+ }
36
+
37
+ export function isHostedWebSearchProviderId(value = "") {
38
+ const text = String(value || "").trim();
39
+ return text.includes("/");
40
+ }
41
+
42
+ export function normalizeWebSearchProviderKey(value = "") {
43
+ const text = String(value || "").trim();
44
+ return isHostedWebSearchProviderId(text) ? text : text.toLowerCase();
45
+ }
46
+
47
+ export function buildHostedWebSearchProviderId(providerId = "", modelId = "") {
48
+ const normalizedProviderId = String(providerId || "").trim();
49
+ const normalizedModelId = String(modelId || "").trim();
50
+ if (!normalizedProviderId || !normalizedModelId) return "";
51
+ return `${normalizedProviderId}/${normalizedModelId}`;
52
+ }
53
+
54
+ export function getWebSearchProviderFormats(provider = {}) {
55
+ return [...new Set(
56
+ [provider?.format, ...(Array.isArray(provider?.formats) ? provider.formats : [])]
57
+ .map((value) => String(value || "").trim().toLowerCase())
58
+ .filter((value) => value === "openai" || value === "claude")
59
+ )];
60
+ }
61
+
62
+ export function getWebSearchModelFormats(provider = {}, model = {}) {
63
+ const modelId = String(model?.id || "").trim();
64
+ const preferredFormat = modelId ? String(provider?.lastProbe?.modelPreferredFormat?.[modelId] || "").trim().toLowerCase() : "";
65
+ if (preferredFormat === "openai" || preferredFormat === "claude") {
66
+ return [preferredFormat];
67
+ }
68
+
69
+ const probedFormats = modelId
70
+ ? [...new Set(
71
+ (Array.isArray(provider?.lastProbe?.modelSupport?.[modelId]) ? provider.lastProbe.modelSupport[modelId] : [])
72
+ .map((value) => String(value || "").trim().toLowerCase())
73
+ .filter((value) => value === "openai" || value === "claude")
74
+ )]
75
+ : [];
76
+ if (probedFormats.length > 0) return probedFormats;
77
+
78
+ return [...new Set(
79
+ [model?.format, ...(Array.isArray(model?.formats) ? model.formats : [])]
80
+ .map((value) => String(value || "").trim().toLowerCase())
81
+ .filter((value) => value === "openai" || value === "claude")
82
+ )];
83
+ }
84
+
85
+ export function providerHasHostedWebSearchConnection(provider = {}) {
86
+ const subscriptionType = String(provider?.subscriptionType || provider?.subscription_type || "").trim().toLowerCase();
87
+ if (String(provider?.type || "").trim().toLowerCase() === "subscription") {
88
+ return subscriptionType === "chatgpt-codex";
89
+ }
90
+
91
+ const providerFormats = getWebSearchProviderFormats(provider);
92
+ if (!providerFormats.includes("openai")) return false;
93
+ return Boolean(String(provider?.baseUrlByFormat?.openai || provider?.baseUrl || "").trim());
94
+ }
95
+
96
+ export function providerHasHostedWebSearchAuth(provider = {}) {
97
+ if (String(provider?.type || "").trim().toLowerCase() === "subscription") {
98
+ return String(provider?.subscriptionType || provider?.subscription_type || "").trim().toLowerCase() === "chatgpt-codex";
99
+ }
100
+ if (String(provider?.apiKey || "").trim() || String(provider?.apiKeyEnv || "").trim()) return true;
101
+ if (String(provider?.auth?.type || "").trim().toLowerCase() === "none") return true;
102
+
103
+ return Object.keys(provider?.headers && typeof provider.headers === "object" ? provider.headers : {})
104
+ .some((key) => {
105
+ const normalized = String(key || "").trim().toLowerCase();
106
+ return normalized === "authorization" || normalized === "x-api-key";
107
+ });
108
+ }
109
+
110
+ export function modelSupportsHostedWebSearch(provider = {}, model = {}) {
111
+ if (!providerHasHostedWebSearchConnection(provider)) return false;
112
+ const modelFormats = getWebSearchModelFormats(provider, model);
113
+ return modelFormats.length === 0 || modelFormats.includes("openai");
114
+ }
115
+
116
+ export function hasWebSearchDraftField(entry = {}, keys = []) {
117
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
118
+ return keys.some((key) => Object.prototype.hasOwnProperty.call(entry, key) && entry[key] !== undefined && entry[key] !== null && String(entry[key]).trim() !== "");
119
+ }
120
+
121
+ export function normalizeBuiltinWebSearchProviderDraft(entry = {}, explicitId = "") {
122
+ const providerId = String(explicitId || entry?.id || "").trim().toLowerCase();
123
+ const providerMeta = AMP_WEB_SEARCH_PROVIDER_META[providerId];
124
+ if (!providerMeta) return null;
125
+
126
+ const credentialField = providerMeta.credentialField;
127
+ const credentialValue = String(entry?.[credentialField] || "").trim();
128
+ const hasExplicitCount = hasWebSearchDraftField(entry, ["count", "resultCount", "result-count", "resultsPerCall", "results-per-call"]);
129
+ const hasExplicitLimit = hasWebSearchDraftField(entry, ["limit", "monthlyLimit", "monthly-limit", "quota"]);
130
+ const hasExplicitRemaining = hasWebSearchDraftField(entry, ["remaining", "remainingQuota", "remaining-quota", "remainingQueries", "remaining-queries"]);
131
+ const includeQuotaDefaults = Boolean(credentialValue) || hasExplicitLimit || hasExplicitRemaining;
132
+ const defaultLimit = includeQuotaDefaults ? (Number(providerMeta.defaultLimit) || 0) : 0;
133
+ const count = parseAmpWebSearchInteger(
134
+ entry?.count ?? entry?.resultCount ?? entry?.["result-count"] ?? entry?.resultsPerCall ?? entry?.["results-per-call"],
135
+ AMP_WEB_SEARCH_DEFAULT_COUNT,
136
+ { min: AMP_WEB_SEARCH_MIN_COUNT, max: AMP_WEB_SEARCH_MAX_COUNT }
137
+ );
138
+ const limit = parseAmpWebSearchInteger(entry?.limit, defaultLimit, { min: 0 });
139
+ const remainingFallback = limit > 0 ? limit : 0;
140
+ const remaining = parseAmpWebSearchInteger(entry?.remaining, remainingFallback, { min: 0 });
141
+
142
+ return {
143
+ kind: "builtin",
144
+ id: providerId,
145
+ [credentialField]: credentialValue,
146
+ ...(hasExplicitCount && count !== AMP_WEB_SEARCH_DEFAULT_COUNT ? { count } : {}),
147
+ ...(hasExplicitLimit || (includeQuotaDefaults && limit > 0) ? { limit } : {}),
148
+ ...(hasExplicitRemaining || (includeQuotaDefaults && (limit > 0 || remaining > 0))
149
+ ? { remaining: limit > 0 ? Math.min(remaining, limit) : remaining }
150
+ : {})
151
+ };
152
+ }
153
+
154
+ export function normalizeHostedWebSearchProviderDraft(entry = {}, explicitId = "") {
155
+ const routeId = String(
156
+ explicitId
157
+ || entry?.id
158
+ || buildHostedWebSearchProviderId(entry?.providerId ?? entry?.provider, entry?.model ?? entry?.modelId)
159
+ ).trim();
160
+ if (!isHostedWebSearchProviderId(routeId)) return null;
161
+ const providerId = String(entry?.providerId ?? entry?.provider ?? routeId.slice(0, routeId.indexOf("/"))).trim();
162
+ const modelId = String(entry?.model ?? entry?.modelId ?? routeId.slice(routeId.indexOf("/") + 1)).trim();
163
+ const normalizedRouteId = buildHostedWebSearchProviderId(providerId, modelId);
164
+ if (!normalizedRouteId || normalizedRouteId !== routeId) return null;
165
+
166
+ return {
167
+ kind: "hosted",
168
+ id: normalizedRouteId,
169
+ providerId,
170
+ model: modelId
171
+ };
172
+ }
173
+
174
+ export function normalizeWebSearchProviderDraft(entry = {}, explicitId = "") {
175
+ return normalizeHostedWebSearchProviderDraft(entry, explicitId)
176
+ || normalizeBuiltinWebSearchProviderDraft(entry, explicitId);
177
+ }
178
+
179
+ export function buildHostedWebSearchCandidateGroups(config = {}, existingIds = new Set()) {
180
+ const providers = Array.isArray(config?.providers) ? config.providers : [];
181
+ return providers
182
+ .map((provider) => {
183
+ const providerId = String(provider?.id || "").trim();
184
+ if (!providerId || provider?.enabled === false || !providerHasHostedWebSearchConnection(provider) || !providerHasHostedWebSearchAuth(provider)) {
185
+ return null;
186
+ }
187
+ const models = (Array.isArray(provider?.models) ? provider.models : [])
188
+ .map((model) => {
189
+ const modelId = String(model?.id || "").trim();
190
+ const routeId = buildHostedWebSearchProviderId(providerId, modelId);
191
+ if (!modelId || !routeId || existingIds.has(routeId)) return null;
192
+ if (!modelSupportsHostedWebSearch(provider, model)) return null;
193
+ if (!modelId.toLowerCase().includes("gpt")) return null;
194
+ return {
195
+ value: modelId,
196
+ label: modelId,
197
+ routeId
198
+ };
199
+ })
200
+ .filter(Boolean);
201
+ if (models.length === 0) return null;
202
+ return {
203
+ providerId,
204
+ providerLabel: String(provider?.name || providerId).trim() || providerId,
205
+ providerHint: String(provider?.subscriptionType || "").trim().toLowerCase() === "chatgpt-codex"
206
+ ? "ChatGPT subscription"
207
+ : (String(provider?.baseUrlByFormat?.openai || provider?.baseUrl || "").trim() || "OpenAI-compatible endpoint"),
208
+ models
209
+ };
210
+ })
211
+ .filter(Boolean);
212
+ }
213
+
214
+ export function buildWebSearchProviderRows(config = {}, snapshot = null) {
215
+ const nextConfig = ensureAmpDraftConfigShape(config);
216
+ const webSearch = ensureWebSearchConfigShape(nextConfig);
217
+ const configuredProviders = Array.isArray(webSearch.providers)
218
+ ? webSearch.providers
219
+ .map((provider) => normalizeWebSearchProviderDraft(provider, provider?.id))
220
+ .filter(Boolean)
221
+ : [];
222
+ const configuredIds = new Set(configuredProviders.map((provider) => normalizeWebSearchProviderKey(provider.id)));
223
+ const orderedProviders = [
224
+ ...configuredProviders,
225
+ ...AMP_WEB_SEARCH_PROVIDER_OPTIONS
226
+ .filter((provider) => !configuredIds.has(provider.id))
227
+ .map((provider) => normalizeBuiltinWebSearchProviderDraft({ id: provider.id }, provider.id))
228
+ .filter(Boolean)
229
+ ];
230
+ const snapshotProviders = Array.isArray(snapshot?.providers) ? snapshot.providers : [];
231
+ const snapshotById = new Map(
232
+ snapshotProviders
233
+ .map((provider) => [normalizeWebSearchProviderKey(provider?.id), provider])
234
+ .filter(([providerId]) => Boolean(providerId))
235
+ );
236
+ const providerConfigById = new Map(
237
+ (Array.isArray(config?.providers) ? config.providers : [])
238
+ .map((provider) => [String(provider?.id || "").trim(), provider])
239
+ .filter(([providerId]) => Boolean(providerId))
240
+ );
241
+ const fallbackCount = parseAmpWebSearchInteger(webSearch?.count, AMP_WEB_SEARCH_DEFAULT_COUNT, {
242
+ min: AMP_WEB_SEARCH_MIN_COUNT,
243
+ max: AMP_WEB_SEARCH_MAX_COUNT
244
+ });
245
+
246
+ return orderedProviders.map((provider) => {
247
+ const normalizedId = normalizeWebSearchProviderKey(provider.id);
248
+ const runtimeState = snapshotById.get(normalizedId) || null;
249
+ const configuredIndex = configuredProviders.findIndex((entry) => normalizeWebSearchProviderKey(entry.id) === normalizedId);
250
+ const displayIndex = orderedProviders.findIndex((entry) => normalizeWebSearchProviderKey(entry.id) === normalizedId);
251
+ const isReady = provider.kind === "hosted"
252
+ ? runtimeState?.ready !== false
253
+ : Boolean(provider?.[AMP_WEB_SEARCH_PROVIDER_META[provider.id]?.credentialField || "apiKey"]);
254
+
255
+ if (provider.kind === "hosted") {
256
+ const sourceProvider = providerConfigById.get(provider.providerId) || null;
257
+ return {
258
+ id: provider.id,
259
+ key: provider.id,
260
+ kind: "hosted",
261
+ label: String(sourceProvider?.name || provider.providerId).trim() || provider.providerId,
262
+ providerId: provider.providerId,
263
+ modelId: provider.model,
264
+ routeId: provider.id,
265
+ configured: true,
266
+ configuredIndex,
267
+ configuredCount: configuredProviders.length,
268
+ displayIndex,
269
+ displayCount: orderedProviders.length,
270
+ active: isReady,
271
+ runtimeState
272
+ };
273
+ }
274
+
275
+ const providerMeta = AMP_WEB_SEARCH_PROVIDER_META[provider.id];
276
+ const credentialField = providerMeta?.credentialField || "apiKey";
277
+ const credentialValue = String(provider?.[credentialField] || "").trim();
278
+ const hasExplicitCount = hasWebSearchDraftField(provider, ["count"]);
279
+ const resultPerCall = parseAmpWebSearchInteger(provider?.count, fallbackCount, {
280
+ min: AMP_WEB_SEARCH_MIN_COUNT,
281
+ max: AMP_WEB_SEARCH_MAX_COUNT
282
+ });
283
+ return {
284
+ id: provider.id,
285
+ key: provider.id,
286
+ kind: "builtin",
287
+ label: providerMeta?.label || provider.id,
288
+ credentialField,
289
+ credentialLabel: providerMeta?.credentialLabel || "Credential",
290
+ credentialPlaceholder: providerMeta?.credentialPlaceholder || "",
291
+ credentialValue,
292
+ resultPerCall,
293
+ resultPerCallInput: hasExplicitCount
294
+ ? String(resultPerCall)
295
+ : (fallbackCount !== AMP_WEB_SEARCH_DEFAULT_COUNT ? String(fallbackCount) : ""),
296
+ limit: parseAmpWebSearchInteger(provider?.limit, providerMeta?.defaultLimit || 0, { min: 0 }),
297
+ remaining: parseAmpWebSearchInteger(provider?.remaining, providerMeta?.defaultLimit || 0, { min: 0 }),
298
+ configured: Boolean(credentialValue),
299
+ configuredIndex,
300
+ configuredCount: configuredProviders.length,
301
+ displayIndex,
302
+ displayCount: orderedProviders.length,
303
+ active: isReady,
304
+ runtimeState
305
+ };
306
+ });
307
+ }
308
+
309
+ export function updateWebSearchConfig(config = {}, updates = {}) {
310
+ const next = ensureAmpDraftConfigShape(config);
311
+ const webSearch = ensureWebSearchConfigShape(next);
312
+ if (updates.strategy !== undefined) {
313
+ webSearch.strategy = String(updates.strategy || "").trim() === "quota-balance" ? "quota-balance" : "ordered";
314
+ }
315
+ if (updates.count !== undefined) {
316
+ webSearch.count = parseAmpWebSearchInteger(updates.count, webSearch.count, { min: 1, max: 20 });
317
+ }
318
+ return next;
319
+ }
320
+
321
+ export function updateWebSearchProviderConfig(config = {}, providerId, updates = {}) {
322
+ const normalizedProviderId = String(providerId || "").trim().toLowerCase();
323
+ if (!AMP_WEB_SEARCH_PROVIDER_META[normalizedProviderId]) return ensureAmpDraftConfigShape(config);
324
+
325
+ const next = ensureAmpDraftConfigShape(config);
326
+ const webSearch = ensureWebSearchConfigShape(next);
327
+ const existingProviders = Array.isArray(webSearch.providers) ? webSearch.providers.slice() : [];
328
+ const existingIndex = existingProviders.findIndex((provider) => normalizeWebSearchProviderKey(provider?.id) === normalizedProviderId);
329
+ const existingProvider = existingIndex >= 0 ? existingProviders[existingIndex] : null;
330
+ const baseProvider = normalizeBuiltinWebSearchProviderDraft(
331
+ existingProvider || { id: normalizedProviderId },
332
+ normalizedProviderId
333
+ ) || normalizeBuiltinWebSearchProviderDraft({ id: normalizedProviderId }, normalizedProviderId);
334
+ const providerMeta = AMP_WEB_SEARCH_PROVIDER_META[normalizedProviderId];
335
+ const credentialField = providerMeta.credentialField;
336
+ const mergedProvider = normalizeBuiltinWebSearchProviderDraft({
337
+ ...(existingProvider && typeof existingProvider === "object" && !Array.isArray(existingProvider) ? existingProvider : {}),
338
+ ...baseProvider,
339
+ ...updates,
340
+ id: normalizedProviderId,
341
+ [credentialField]: updates[credentialField] !== undefined ? String(updates[credentialField] || "").trim() : baseProvider?.[credentialField]
342
+ }, normalizedProviderId);
343
+ const hasCredential = Boolean(String(mergedProvider?.[credentialField] || "").trim());
344
+ const shouldPersistCount = hasWebSearchDraftField(updates, ["count"])
345
+ ? Boolean(String(updates?.count || "").trim()) && Number(mergedProvider?.count) !== AMP_WEB_SEARCH_DEFAULT_COUNT
346
+ : hasWebSearchDraftField(existingProvider, ["count"]) && Number(mergedProvider?.count) !== AMP_WEB_SEARCH_DEFAULT_COUNT;
347
+ const shouldPersistLimit = hasCredential
348
+ || hasWebSearchDraftField(updates, ["limit"])
349
+ || hasWebSearchDraftField(existingProvider, ["limit"]);
350
+ const shouldPersistRemaining = hasCredential
351
+ || hasWebSearchDraftField(updates, ["remaining"])
352
+ || hasWebSearchDraftField(existingProvider, ["remaining"]);
353
+
354
+ const persistedProvider = {
355
+ id: normalizedProviderId,
356
+ ...(hasCredential ? { [credentialField]: String(mergedProvider?.[credentialField] || "").trim() } : {}),
357
+ ...(shouldPersistCount ? { count: Number(mergedProvider.count) } : {}),
358
+ ...(shouldPersistLimit && Number(mergedProvider?.limit) > 0 ? { limit: Number(mergedProvider.limit) } : {}),
359
+ ...(shouldPersistRemaining && (Number(mergedProvider?.limit) > 0 || Number(mergedProvider?.remaining) > 0)
360
+ ? { remaining: Number(mergedProvider.remaining) }
361
+ : {})
362
+ };
363
+
364
+ if (existingIndex >= 0) {
365
+ existingProviders[existingIndex] = persistedProvider;
366
+ } else {
367
+ existingProviders.push(persistedProvider);
368
+ }
369
+ webSearch.providers = existingProviders;
370
+ return next;
371
+ }
372
+
373
+ export function addHostedWebSearchProviderConfig(config = {}, providerId, modelId) {
374
+ const routeId = buildHostedWebSearchProviderId(providerId, modelId);
375
+ if (!routeId) return ensureAmpDraftConfigShape(config);
376
+
377
+ const next = ensureAmpDraftConfigShape(config);
378
+ const webSearch = ensureWebSearchConfigShape(next);
379
+ const providers = Array.isArray(webSearch.providers) ? webSearch.providers.slice() : [];
380
+ const routeKey = normalizeWebSearchProviderKey(routeId);
381
+ if (providers.some((provider) => normalizeWebSearchProviderKey(provider?.id) === routeKey)) {
382
+ return next;
383
+ }
384
+ providers.push({
385
+ id: routeId,
386
+ providerId: String(providerId || "").trim(),
387
+ model: String(modelId || "").trim()
388
+ });
389
+ webSearch.providers = providers;
390
+ return next;
391
+ }
392
+
393
+ export function removeWebSearchProviderConfig(config = {}, providerId) {
394
+ const normalizedProviderId = normalizeWebSearchProviderKey(providerId);
395
+ const next = ensureAmpDraftConfigShape(config);
396
+ const webSearch = ensureWebSearchConfigShape(next);
397
+ const providers = Array.isArray(webSearch.providers) ? webSearch.providers.slice() : [];
398
+ const filteredProviders = providers.filter((provider) => normalizeWebSearchProviderKey(provider?.id) !== normalizedProviderId);
399
+ if (filteredProviders.length === providers.length) return next;
400
+ webSearch.providers = filteredProviders;
401
+ return next;
402
+ }
403
+
404
+ export function moveWebSearchProviderConfig(config = {}, providerId, direction = "up") {
405
+ const normalizedProviderId = normalizeWebSearchProviderKey(providerId);
406
+ const next = ensureAmpDraftConfigShape(config);
407
+ const webSearch = ensureWebSearchConfigShape(next);
408
+ const providers = Array.isArray(webSearch.providers) ? webSearch.providers.slice() : [];
409
+ const providerById = new Map(
410
+ providers.map((provider) => [normalizeWebSearchProviderKey(provider?.id), provider]).filter(([id]) => Boolean(id))
411
+ );
412
+ const currentIndex = providers.findIndex((provider) => normalizeWebSearchProviderKey(provider?.id) === normalizedProviderId);
413
+
414
+ if (currentIndex !== -1) {
415
+ const targetIndex = direction === "down" ? currentIndex + 1 : currentIndex - 1;
416
+ if (targetIndex < 0 || targetIndex >= providers.length) return next;
417
+ const [movedProvider] = providers.splice(currentIndex, 1);
418
+ providers.splice(targetIndex, 0, movedProvider);
419
+ webSearch.providers = providers;
420
+ return next;
421
+ }
422
+
423
+ if (!AMP_WEB_SEARCH_PROVIDER_META[normalizedProviderId]) return next;
424
+
425
+ const displayOrder = [
426
+ ...providers.map((provider) => normalizeWebSearchProviderKey(provider?.id)).filter(Boolean),
427
+ ...AMP_WEB_SEARCH_PROVIDER_OPTIONS
428
+ .map((provider) => provider.id)
429
+ .filter((id) => !providerById.has(id))
430
+ ];
431
+ const displayIndex = displayOrder.indexOf(normalizedProviderId);
432
+ if (displayIndex === -1) return next;
433
+ const targetIndex = direction === "down" ? displayIndex + 1 : displayIndex - 1;
434
+ if (targetIndex < 0 || targetIndex >= displayOrder.length) return next;
435
+ const reordered = displayOrder.slice();
436
+ const [movedProviderId] = reordered.splice(displayIndex, 1);
437
+ reordered.splice(targetIndex, 0, movedProviderId);
438
+
439
+ const persistedIds = new Set(providerById.keys());
440
+ persistedIds.add(normalizedProviderId);
441
+ for (const id of reordered.slice(0, targetIndex + 1)) {
442
+ if (AMP_WEB_SEARCH_PROVIDER_META[id]) persistedIds.add(id);
443
+ }
444
+
445
+ webSearch.providers = reordered
446
+ .filter((id) => persistedIds.has(id))
447
+ .map((id) => providerById.get(id) || { id });
448
+ return next;
449
+ }
450
+
451
+ export function shouldImmediateAutosaveWebSearchProviderChange(providerId, field, value) {
452
+ const normalizedProviderId = String(providerId || "").trim().toLowerCase();
453
+ const providerMeta = AMP_WEB_SEARCH_PROVIDER_META[normalizedProviderId];
454
+ if (!providerMeta) return false;
455
+ if (String(field || "").trim() !== providerMeta.credentialField) return false;
456
+ return !String(value || "").trim();
457
+ }
458
+
459
+ // ── Internal dependency note ──
460
+ // safeClone is used by ensureWebSearchConfigShape and resolves from app.jsx scope.
@@ -37,11 +37,18 @@ const ALLOWED_RATE_LIMIT_WINDOW_UNITS = new Set([
37
37
  "week",
38
38
  "month"
39
39
  ]);
40
- const ALIAS_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
40
+ const ALIAS_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:\-\[\]]*$/;
41
41
  const SUBSCRIPTION_PROVIDER_TYPES = Object.freeze({
42
42
  CHATGPT_CODEX: "chatgpt-codex",
43
43
  CLAUDE_CODE: "claude-code"
44
44
  });
45
+ export const OLLAMA_PROVIDER_TYPE = "ollama";
46
+ export const OLLAMA_KEEP_ALIVE_PATTERN = /^(-1|0|\d+(s|m|h))$/;
47
+ export const OLLAMA_KEEP_ALIVE_OPTIONS = Object.freeze([
48
+ "5m", "10m", "30m", "1h", "24h", "-1", "0"
49
+ ]);
50
+ const OLLAMA_DEFAULT_BASE_URL = "http://localhost:11434";
51
+ const OLLAMA_DEFAULT_KEEP_ALIVE = "5m";
45
52
  let runtimeEnvCache = null;
46
53
 
47
54
  function readNodeRuntimeInfo() {
@@ -1208,6 +1215,26 @@ function normalizeAuthConfig(rawAuth) {
1208
1215
  };
1209
1216
  }
1210
1217
 
1218
+ // Keep in sync with CAPABILITY_DEFINITIONS in src/node/web-console-ui/capability-utils.js
1219
+ const MODEL_CAPABILITY_KEYS = [
1220
+ "supportsReasoning", "supportsThinking", "supportsResponseFormat",
1221
+ "supportsLogprobs", "supportsServiceTier", "supportsPrediction",
1222
+ "supportsStreamOptions"
1223
+ ];
1224
+
1225
+ function normalizeModelCapabilities(raw) {
1226
+ if (!raw || typeof raw !== "object") return undefined;
1227
+ const result = {};
1228
+ let hasAny = false;
1229
+ for (const key of MODEL_CAPABILITY_KEYS) {
1230
+ if (typeof raw[key] === "boolean") {
1231
+ result[key] = raw[key];
1232
+ hasAny = true;
1233
+ }
1234
+ }
1235
+ return hasAny ? result : undefined;
1236
+ }
1237
+
1211
1238
  function normalizeModelEntry(model) {
1212
1239
  if (typeof model === "string") {
1213
1240
  return { id: model };
@@ -1222,6 +1249,7 @@ function normalizeModelEntry(model) {
1222
1249
  model["silent-fallbacks"] ??
1223
1250
  model.fallbacks;
1224
1251
  const fallbackModels = dedupeStrings(toArray(rawFallbacks));
1252
+ const capabilities = normalizeModelCapabilities(model.capabilities);
1225
1253
  return {
1226
1254
  id,
1227
1255
  aliases: dedupeStrings(model.aliases || model.alias || []),
@@ -1232,7 +1260,8 @@ function normalizeModelEntry(model) {
1232
1260
  contextWindow: Number.isFinite(model.contextWindow) ? Number(model.contextWindow) : undefined,
1233
1261
  cost: model.cost,
1234
1262
  metadata: model.metadata && typeof model.metadata === "object" ? model.metadata : undefined,
1235
- ...(rawFallbacks !== undefined ? { fallbackModels } : {})
1263
+ ...(rawFallbacks !== undefined ? { fallbackModels } : {}),
1264
+ ...(capabilities ? { capabilities } : {})
1236
1265
  };
1237
1266
  }
1238
1267
 
@@ -1295,6 +1324,47 @@ function normalizeBaseUrlByFormat(value) {
1295
1324
  return Object.keys(out).length > 0 ? out : undefined;
1296
1325
  }
1297
1326
 
1327
+ function normalizeOllamaManagedModel(entry) {
1328
+ if (!entry || typeof entry !== "object") return { keepAlive: OLLAMA_DEFAULT_KEEP_ALIVE, pinned: false, autoLoad: false };
1329
+ const keepAlive = typeof entry.keepAlive === "string" && OLLAMA_KEEP_ALIVE_PATTERN.test(entry.keepAlive)
1330
+ ? entry.keepAlive : OLLAMA_DEFAULT_KEEP_ALIVE;
1331
+ const contextLength = Number.isFinite(entry.contextLength) && entry.contextLength > 0
1332
+ ? Math.round(entry.contextLength) : undefined;
1333
+ return {
1334
+ keepAlive,
1335
+ contextLength,
1336
+ pinned: entry.pinned === true,
1337
+ autoLoad: entry.autoLoad === true
1338
+ };
1339
+ }
1340
+
1341
+ export function normalizeOllamaConfig(raw) {
1342
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
1343
+ return { enabled: false, baseUrl: OLLAMA_DEFAULT_BASE_URL, autoConnect: true, defaultKeepAlive: OLLAMA_DEFAULT_KEEP_ALIVE, managedModels: {}, autoLoadModels: [] };
1344
+ }
1345
+ const baseUrl = sanitizeEndpointUrl(raw.baseUrl || raw["base-url"] || OLLAMA_DEFAULT_BASE_URL)
1346
+ || OLLAMA_DEFAULT_BASE_URL;
1347
+ const defaultKeepAlive = typeof raw.defaultKeepAlive === "string" && OLLAMA_KEEP_ALIVE_PATTERN.test(raw.defaultKeepAlive)
1348
+ ? raw.defaultKeepAlive : OLLAMA_DEFAULT_KEEP_ALIVE;
1349
+ const managedModels = {};
1350
+ if (raw.managedModels && typeof raw.managedModels === "object" && !Array.isArray(raw.managedModels)) {
1351
+ for (const [modelId, entry] of Object.entries(raw.managedModels)) {
1352
+ if (typeof modelId === "string" && modelId.trim()) {
1353
+ managedModels[modelId.trim()] = normalizeOllamaManagedModel(entry);
1354
+ }
1355
+ }
1356
+ }
1357
+ const autoLoadModels = dedupeStrings(toArray(raw.autoLoadModels || raw["auto-load-models"]));
1358
+ return {
1359
+ enabled: raw.enabled !== false,
1360
+ baseUrl,
1361
+ autoConnect: raw.autoConnect !== false,
1362
+ defaultKeepAlive,
1363
+ managedModels,
1364
+ autoLoadModels
1365
+ };
1366
+ }
1367
+
1298
1368
  function normalizeProvider(provider, index = 0) {
1299
1369
  if (!provider || typeof provider !== "object") return null;
1300
1370
 
@@ -1302,7 +1372,8 @@ function normalizeProvider(provider, index = 0) {
1302
1372
  const id = slugifyId(provider.id || provider.name || `provider-${index + 1}`);
1303
1373
  const providerType = provider.type || null;
1304
1374
  const isSubscription = providerType === "subscription";
1305
-
1375
+ const isOllama = providerType === OLLAMA_PROVIDER_TYPE;
1376
+
1306
1377
  // Subscription-specific fields
1307
1378
  const subscriptionType = isSubscription ? (provider.subscriptionType || provider.subscription_type || null) : null;
1308
1379
  const subscriptionProfile = isSubscription ? (provider.subscriptionProfile || provider.subscription_profile || id) : null;
@@ -1316,9 +1387,11 @@ function normalizeProvider(provider, index = 0) {
1316
1387
  );
1317
1388
 
1318
1389
  // Subscription providers have a fixed endpoint, so baseUrl is optional
1319
- const explicitBaseUrl = !isSubscription
1320
- ? sanitizeEndpointUrl(provider.baseUrl || provider["base-url"] || provider.endpoint || "")
1321
- : sanitizeEndpointUrl(provider.baseUrl || provider["base-url"] || provider.endpoint || "");
1390
+ // Ollama defaults to localhost:11434/v1 for OpenAI compat
1391
+ const ollamaDefaultUrl = OLLAMA_DEFAULT_BASE_URL + "/v1";
1392
+ const explicitBaseUrl = sanitizeEndpointUrl(
1393
+ provider.baseUrl || provider["base-url"] || provider.endpoint || (isOllama ? ollamaDefaultUrl : "")
1394
+ );
1322
1395
 
1323
1396
  const rawFormat = provider.format || provider.responseFormat || provider["response-format"];
1324
1397
  const preferredFormat = [FORMATS.OPENAI, FORMATS.CLAUDE].includes(rawFormat) ? rawFormat : undefined;
@@ -1336,7 +1409,9 @@ function normalizeProvider(provider, index = 0) {
1336
1409
  const defaultSubscriptionFormat = subscriptionType === SUBSCRIPTION_PROVIDER_TYPES.CLAUDE_CODE
1337
1410
  ? FORMATS.CLAUDE
1338
1411
  : FORMATS.OPENAI;
1339
- const defaultFormat = isSubscription ? defaultSubscriptionFormat : (orderedFormats[0] || FORMATS.OPENAI);
1412
+ const defaultFormat = isSubscription ? defaultSubscriptionFormat
1413
+ : isOllama ? FORMATS.OPENAI
1414
+ : (orderedFormats[0] || FORMATS.OPENAI);
1340
1415
 
1341
1416
  const baseUrl = explicitBaseUrl
1342
1417
  || (preferredFormat && baseUrlByFormat?.[preferredFormat])
@@ -1374,7 +1449,8 @@ function normalizeProvider(provider, index = 0) {
1374
1449
  enabled: provider.enabled !== false,
1375
1450
  baseUrl,
1376
1451
  baseUrlByFormat,
1377
- apiKey: typeof provider.apiKey === "string" ? provider.apiKey : (typeof provider.credential === "string" ? provider.credential : undefined),
1452
+ apiKey: isOllama ? (typeof provider.apiKey === "string" ? provider.apiKey : "ollama")
1453
+ : (typeof provider.apiKey === "string" ? provider.apiKey : (typeof provider.credential === "string" ? provider.credential : undefined)),
1378
1454
  apiKeyEnv: typeof provider.apiKeyEnv === "string" ? provider.apiKeyEnv : undefined,
1379
1455
  format: preferredFormat || defaultFormat,
1380
1456
  formats: orderedFormats.length > 0 ? orderedFormats : [defaultFormat],
@@ -1694,6 +1770,8 @@ export function normalizeRuntimeConfig(rawConfig, options = {}) {
1694
1770
  )
1695
1771
  : amp;
1696
1772
 
1773
+ const ollama = normalizeOllamaConfig(raw.ollama);
1774
+
1697
1775
  const normalized = {
1698
1776
  version: inferNormalizedConfigVersion(raw, providers, modelAliases),
1699
1777
  masterKey,
@@ -1702,6 +1780,7 @@ export function normalizeRuntimeConfig(rawConfig, options = {}) {
1702
1780
  modelAliases,
1703
1781
  amp: normalizedAmp,
1704
1782
  ...(webSearch ? { webSearch } : {}),
1783
+ ollama,
1705
1784
  metadata: sanitizeRuntimeMetadata(raw.metadata)
1706
1785
  };
1707
1786
  Object.defineProperty(normalized, NORMALIZATION_ISSUES_SYMBOL, {
@@ -3274,7 +3353,15 @@ export function resolveRequestedRoute(config, requestedModel, sourceFormat = FOR
3274
3353
  return resolvedAmpRoute;
3275
3354
  }
3276
3355
 
3277
- const resolvedRoute = resolveRequestedRouteCore(config, effectiveRequested, normalizedRequested, sourceFormat, routingIndex);
3356
+ let resolvedRoute = resolveRequestedRouteCore(config, effectiveRequested, normalizedRequested, sourceFormat, routingIndex);
3357
+
3358
+ if (!resolvedRoute?.primary && !String(effectiveRequested || "").includes("/")) {
3359
+ const bareRoute = resolveBareModelRoutePlan(config, effectiveRequested, normalizedRequested, sourceFormat, routingIndex, {});
3360
+ if (bareRoute?.primary) {
3361
+ resolvedRoute = bareRoute;
3362
+ }
3363
+ }
3364
+
3278
3365
  if (!resolvedRoute?.primary && effectiveRequested === DEFAULT_MODEL_ALIAS_ID) {
3279
3366
  return {
3280
3367
  ...resolvedRoute,