@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.
- package/CHANGELOG.md +5 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/cli/router-module.js +32 -5
- package/src/node/coding-tool-config.js +138 -25
- package/src/node/large-request-log.js +54 -0
- package/src/node/litellm-context-catalog.js +13 -1
- package/src/node/local-server.js +10 -0
- package/src/node/ollama-client.js +195 -0
- package/src/node/ollama-hardware.js +94 -0
- package/src/node/ollama-install.js +230 -0
- package/src/node/provider-probe.js +69 -5
- package/src/node/web-console-client.js +36 -36
- package/src/node/web-console-server.js +478 -8
- package/src/node/web-console-styles.generated.js +1 -1
- package/src/node/web-console-ui/amp-utils.js +272 -0
- package/src/node/web-console-ui/api-client.js +128 -0
- package/src/node/web-console-ui/capability-utils.js +36 -0
- package/src/node/web-console-ui/config-editor-utils.js +20 -5
- package/src/node/web-console-ui/constants.js +140 -0
- package/src/node/web-console-ui/context-window-utils.js +262 -0
- package/src/node/web-console-ui/hooks/use-reorder-layout-animation.js +65 -0
- package/src/node/web-console-ui/provider-presets.js +211 -0
- package/src/node/web-console-ui/quick-start-utils.js +790 -0
- package/src/node/web-console-ui/utils.js +353 -0
- package/src/node/web-console-ui/web-search-utils.js +460 -0
- package/src/runtime/config.js +96 -9
- package/src/runtime/handler/fallback.js +71 -0
- package/src/runtime/handler/field-filter.js +39 -0
- package/src/runtime/handler/large-request-log.js +211 -0
- package/src/runtime/handler/provider-call.js +185 -15
- package/src/runtime/handler/reasoning-effort.js +11 -1
- package/src/runtime/handler/tool-name-sanitizer.js +258 -0
- package/src/runtime/handler.js +16 -3
- 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.
|
package/src/runtime/config.js
CHANGED
|
@@ -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
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
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
|
|
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 :
|
|
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
|
-
|
|
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,
|