@khanglvm/llm-router 1.3.1 → 2.0.0-beta.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/CHANGELOG.md +39 -0
- package/README.md +337 -41
- package/package.json +19 -3
- package/src/cli/router-module.js +7331 -3805
- package/src/cli/wrangler-toml.js +1 -1
- package/src/cli-entry.js +162 -24
- package/src/node/amp-client-config.js +426 -0
- package/src/node/coding-tool-config.js +763 -0
- package/src/node/config-store.js +49 -18
- package/src/node/instance-state.js +213 -12
- package/src/node/listen-port.js +5 -37
- package/src/node/local-server-settings.js +122 -0
- package/src/node/local-server.js +3 -2
- package/src/node/provider-probe.js +13 -0
- package/src/node/start-command.js +282 -40
- package/src/node/startup-manager.js +64 -29
- package/src/node/web-command.js +106 -0
- package/src/node/web-console-assets.js +26 -0
- package/src/node/web-console-client.js +56 -0
- package/src/node/web-console-dev-assets.js +258 -0
- package/src/node/web-console-server.js +3146 -0
- package/src/node/web-console-styles.generated.js +1 -0
- package/src/node/web-console-ui/config-editor-utils.js +616 -0
- package/src/node/web-console-ui/lib/utils.js +6 -0
- package/src/node/web-console-ui/rate-limit-utils.js +144 -0
- package/src/node/web-console-ui/select-search-utils.js +36 -0
- package/src/runtime/codex-request-transformer.js +46 -5
- package/src/runtime/codex-response-transformer.js +268 -35
- package/src/runtime/config.js +1394 -35
- package/src/runtime/handler/amp-gemini.js +913 -0
- package/src/runtime/handler/amp-response.js +308 -0
- package/src/runtime/handler/amp.js +290 -0
- package/src/runtime/handler/auth.js +17 -2
- package/src/runtime/handler/provider-call.js +168 -50
- package/src/runtime/handler/provider-translation.js +937 -26
- package/src/runtime/handler/request.js +149 -6
- package/src/runtime/handler/route-debug.js +22 -1
- package/src/runtime/handler.js +449 -9
- package/src/runtime/subscription-auth.js +1 -6
- package/src/shared/local-router-defaults.js +62 -0
- package/src/translator/index.js +3 -1
- package/src/translator/request/openai-to-claude.js +217 -6
- package/src/translator/response/openai-to-claude.js +206 -58
package/src/runtime/handler.js
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { corsResponse, jsonResponse } from "./handler/http.js";
|
|
24
24
|
import {
|
|
25
25
|
detectUserRequestFormat,
|
|
26
|
+
isAmpManagementPath,
|
|
26
27
|
isJsonRequest,
|
|
27
28
|
isStreamingEnabled,
|
|
28
29
|
normalizePath,
|
|
@@ -30,6 +31,18 @@ import {
|
|
|
30
31
|
resolveApiRoute,
|
|
31
32
|
resolveMaxRequestBodyBytes
|
|
32
33
|
} from "./handler/request.js";
|
|
34
|
+
import {
|
|
35
|
+
isAmpManagementAllowed,
|
|
36
|
+
isAmpProxyEnabled,
|
|
37
|
+
proxyAmpUpstreamRequest
|
|
38
|
+
} from "./handler/amp.js";
|
|
39
|
+
import {
|
|
40
|
+
adaptOpenAIResponseToAmpGeminiResponse,
|
|
41
|
+
buildAmpGeminiModelPayload,
|
|
42
|
+
buildAmpGeminiModelsPayload,
|
|
43
|
+
convertAmpGeminiRequestToOpenAI,
|
|
44
|
+
hasGeminiWebSearchTool
|
|
45
|
+
} from "./handler/amp-gemini.js";
|
|
33
46
|
import {
|
|
34
47
|
isRequestFromAllowedIp,
|
|
35
48
|
resolveAllowedOrigin,
|
|
@@ -56,6 +69,7 @@ import {
|
|
|
56
69
|
recordRouteAttempt,
|
|
57
70
|
recordRouteSkip,
|
|
58
71
|
setRouteSelectedCandidate,
|
|
72
|
+
setRouteToolDebug,
|
|
59
73
|
withRouteDebugHeaders
|
|
60
74
|
} from "./handler/route-debug.js";
|
|
61
75
|
|
|
@@ -89,6 +103,207 @@ function hasNextEligibleCandidate(entries, startIndex) {
|
|
|
89
103
|
return false;
|
|
90
104
|
}
|
|
91
105
|
|
|
106
|
+
function extractBuiltInToolTypes(body) {
|
|
107
|
+
const tools = Array.isArray(body?.tools) ? body.tools : [];
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
const types = [];
|
|
110
|
+
|
|
111
|
+
for (const tool of tools) {
|
|
112
|
+
if (!tool || typeof tool !== "object") continue;
|
|
113
|
+
const type = String(tool.type || "").trim();
|
|
114
|
+
if (!type) continue;
|
|
115
|
+
if (type === "function") continue;
|
|
116
|
+
if (seen.has(type)) continue;
|
|
117
|
+
seen.add(type);
|
|
118
|
+
types.push(type);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return types;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isWebSearchToolType(type) {
|
|
125
|
+
const normalized = String(type || "").trim().toLowerCase();
|
|
126
|
+
if (!normalized) return false;
|
|
127
|
+
return normalized.startsWith("web_search");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function hasWebSearchTool(toolTypes) {
|
|
131
|
+
return Array.isArray(toolTypes) && toolTypes.some((type) => isWebSearchToolType(type));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function hasAmpUpstreamApiKey(config) {
|
|
135
|
+
return Boolean(String(config?.amp?.upstreamApiKey || "").trim());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function shouldProxyAmpWebSearchRequest(clientType, toolTypes, config) {
|
|
139
|
+
return clientType === "amp"
|
|
140
|
+
&& hasWebSearchTool(toolTypes)
|
|
141
|
+
&& config?.amp?.proxyWebSearchToUpstream === true
|
|
142
|
+
&& isAmpProxyEnabled(config)
|
|
143
|
+
&& hasAmpUpstreamApiKey(config);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildAmpWebSearchProxyDebugState(env, requestedModel, toolTypes) {
|
|
147
|
+
const routeDebug = buildRouteDebugState(isRoutingDebugEnabled(env), {
|
|
148
|
+
requestedModel,
|
|
149
|
+
routeType: "amp-proxy",
|
|
150
|
+
routeRef: "amp.upstream",
|
|
151
|
+
routeStrategy: "ordered"
|
|
152
|
+
});
|
|
153
|
+
setRouteToolDebug(routeDebug, toolTypes, "amp-web-search:proxy-upstream");
|
|
154
|
+
return routeDebug;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isChatGPTCodexCandidate(candidate) {
|
|
158
|
+
const provider = candidate?.provider;
|
|
159
|
+
if (!provider || provider.type !== "subscription") return false;
|
|
160
|
+
const subscriptionType = String(provider.subscriptionType || provider.subscription_type || "").trim().toLowerCase();
|
|
161
|
+
return subscriptionType === "chatgpt-codex";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const WEB_SEARCH_UNAVAILABLE_HINTS = [
|
|
165
|
+
"web search credits are unavailable in this session",
|
|
166
|
+
"web access unavailable (out of credits)",
|
|
167
|
+
"web access unavailable"
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
function extractAssistantTextFragments(payload) {
|
|
171
|
+
const fragments = [];
|
|
172
|
+
if (!payload || typeof payload !== "object") return fragments;
|
|
173
|
+
|
|
174
|
+
if (typeof payload.output_text === "string" && payload.output_text.trim()) {
|
|
175
|
+
fragments.push(payload.output_text.trim());
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (Array.isArray(payload.choices)) {
|
|
179
|
+
for (const choice of payload.choices) {
|
|
180
|
+
const content = choice?.message?.content;
|
|
181
|
+
if (typeof content === "string" && content.trim()) {
|
|
182
|
+
fragments.push(content.trim());
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (payload.type === "message" && Array.isArray(payload.content)) {
|
|
188
|
+
for (const block of payload.content) {
|
|
189
|
+
if (typeof block?.text === "string" && block.text.trim()) {
|
|
190
|
+
fragments.push(block.text.trim());
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (Array.isArray(payload.output)) {
|
|
196
|
+
for (const item of payload.output) {
|
|
197
|
+
if (item?.type !== "message" || item.role !== "assistant" || !Array.isArray(item.content)) continue;
|
|
198
|
+
for (const block of item.content) {
|
|
199
|
+
if (typeof block?.text === "string" && block.text.trim()) {
|
|
200
|
+
fragments.push(block.text.trim());
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (typeof block?.refusal === "string" && block.refusal.trim()) {
|
|
204
|
+
fragments.push(block.refusal.trim());
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return fragments;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function detectSemanticWebSearchFailure(response, toolTypes, stream = false) {
|
|
214
|
+
if (stream) return "";
|
|
215
|
+
if (!(response instanceof Response)) return "";
|
|
216
|
+
if (!Array.isArray(toolTypes) || !toolTypes.some((type) => isWebSearchToolType(type))) return "";
|
|
217
|
+
|
|
218
|
+
let fragments = [];
|
|
219
|
+
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
|
|
220
|
+
|
|
221
|
+
if (contentType.includes("json")) {
|
|
222
|
+
try {
|
|
223
|
+
const payload = await response.clone().json();
|
|
224
|
+
fragments = extractAssistantTextFragments(payload);
|
|
225
|
+
} catch {
|
|
226
|
+
fragments = [];
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (fragments.length === 0) {
|
|
231
|
+
try {
|
|
232
|
+
const raw = await response.clone().text();
|
|
233
|
+
if (raw.trim()) fragments = [raw.trim()];
|
|
234
|
+
} catch {
|
|
235
|
+
fragments = [];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const normalized = fragments.join("\n").toLowerCase();
|
|
240
|
+
if (!normalized) return "";
|
|
241
|
+
|
|
242
|
+
for (const hint of WEB_SEARCH_UNAVAILABLE_HINTS) {
|
|
243
|
+
if (normalized.includes(hint)) return hint;
|
|
244
|
+
}
|
|
245
|
+
return "";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function createSemanticWebSearchFailureResult(response) {
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
status: response instanceof Response ? response.status : 200,
|
|
252
|
+
retryable: false,
|
|
253
|
+
errorKind: "search_unavailable",
|
|
254
|
+
response
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function createSemanticWebSearchFailureClassification() {
|
|
259
|
+
return {
|
|
260
|
+
category: "search_unavailable",
|
|
261
|
+
retryable: false,
|
|
262
|
+
retryOrigin: false,
|
|
263
|
+
allowFallback: true,
|
|
264
|
+
originCooldownMs: 0
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function prioritizeAmpToolAwareCandidates(candidates, toolTypes, options = {}) {
|
|
269
|
+
const toolTypeList = Array.isArray(toolTypes) ? toolTypes : [];
|
|
270
|
+
if (options?.clientType !== "amp") {
|
|
271
|
+
return {
|
|
272
|
+
candidates,
|
|
273
|
+
routingHint: ""
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (!toolTypeList.some((type) => isWebSearchToolType(type))) {
|
|
277
|
+
return {
|
|
278
|
+
candidates,
|
|
279
|
+
routingHint: ""
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (!Array.isArray(candidates) || candidates.length <= 1) {
|
|
283
|
+
return {
|
|
284
|
+
candidates,
|
|
285
|
+
routingHint: "amp-web-search-request"
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const prioritized = candidates
|
|
290
|
+
.map((candidate, index) => ({
|
|
291
|
+
candidate,
|
|
292
|
+
index,
|
|
293
|
+
penalty: isChatGPTCodexCandidate(candidate) ? 1 : 0
|
|
294
|
+
}))
|
|
295
|
+
.sort((left, right) => left.penalty - right.penalty || left.index - right.index)
|
|
296
|
+
.map((entry) => entry.candidate);
|
|
297
|
+
|
|
298
|
+
const changed = prioritized.some((candidate, index) => candidate !== candidates[index]);
|
|
299
|
+
return {
|
|
300
|
+
candidates: prioritized,
|
|
301
|
+
routingHint: changed
|
|
302
|
+
? "amp-web-search:prefer-non-codex"
|
|
303
|
+
: "amp-web-search-request"
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
92
307
|
async function handleRouteRequest(request, env, getConfig, sourceFormatHint, options = {}) {
|
|
93
308
|
let config;
|
|
94
309
|
try {
|
|
@@ -104,6 +319,9 @@ async function handleRouteRequest(request, env, getConfig, sourceFormatHint, opt
|
|
|
104
319
|
}
|
|
105
320
|
|
|
106
321
|
if (!configHasProvider(config)) {
|
|
322
|
+
if (options.clientType === "amp" && isAmpProxyEnabled(config)) {
|
|
323
|
+
return proxyAmpUpstreamRequest({ request, config });
|
|
324
|
+
}
|
|
107
325
|
return jsonResponse({
|
|
108
326
|
type: "error",
|
|
109
327
|
error: {
|
|
@@ -136,19 +354,44 @@ async function handleRouteRequest(request, env, getConfig, sourceFormatHint, opt
|
|
|
136
354
|
const sourceFormat = sourceFormatHint === "auto"
|
|
137
355
|
? detectUserRequestFormat(request, body, FORMATS.CLAUDE)
|
|
138
356
|
: sourceFormatHint;
|
|
357
|
+
const builtInToolTypes = extractBuiltInToolTypes(body);
|
|
139
358
|
|
|
140
359
|
const requestedModel = body?.model || "smart";
|
|
141
360
|
const stream = isStreamingEnabled(sourceFormat, body);
|
|
142
361
|
|
|
143
|
-
|
|
362
|
+
if (shouldProxyAmpWebSearchRequest(options.clientType, builtInToolTypes, config)) {
|
|
363
|
+
const routeDebug = buildAmpWebSearchProxyDebugState(env, requestedModel, builtInToolTypes);
|
|
364
|
+
if (routeDebug.enabled) {
|
|
365
|
+
console.warn(
|
|
366
|
+
`[llm-router] tool routing request=${requestedModel} tools=${builtInToolTypes.join(",")} hint=${routeDebug.toolRouting || "none"}`
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
return withRouteDebugHeaders(await proxyAmpUpstreamRequest({
|
|
370
|
+
request,
|
|
371
|
+
config,
|
|
372
|
+
bodyOverride: JSON.stringify(body || {})
|
|
373
|
+
}), routeDebug);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const resolved = resolveRequestModel(config, requestedModel, sourceFormat, {
|
|
377
|
+
clientType: options.clientType,
|
|
378
|
+
providerHint: options.providerHint
|
|
379
|
+
});
|
|
144
380
|
if (!resolved.primary) {
|
|
381
|
+
if (options.clientType === "amp" && resolved.allowAmpProxy !== false && isAmpProxyEnabled(config)) {
|
|
382
|
+
return proxyAmpUpstreamRequest({
|
|
383
|
+
request,
|
|
384
|
+
config,
|
|
385
|
+
bodyOverride: JSON.stringify(body || {})
|
|
386
|
+
});
|
|
387
|
+
}
|
|
145
388
|
return jsonResponse({
|
|
146
389
|
type: "error",
|
|
147
390
|
error: {
|
|
148
391
|
type: "configuration_error",
|
|
149
392
|
message: resolved.error || `No matching model found for "${requestedModel}" and no default provider/model configured.`
|
|
150
393
|
}
|
|
151
|
-
}, 400);
|
|
394
|
+
}, Number.isInteger(resolved?.statusCode) ? resolved.statusCode : 400);
|
|
152
395
|
}
|
|
153
396
|
|
|
154
397
|
const runtimeFlags = options.runtimeFlags || resolveRuntimeFlags(options, env);
|
|
@@ -177,6 +420,13 @@ async function handleRouteRequest(request, env, getConfig, sourceFormatHint, opt
|
|
|
177
420
|
for (const skipped of formatFiltered.skipped) {
|
|
178
421
|
recordRouteSkip(routeDebug, skipped.candidate, skipped.reason);
|
|
179
422
|
}
|
|
423
|
+
const prioritizedCandidates = prioritizeAmpToolAwareCandidates(formatFiltered.eligible, builtInToolTypes, options);
|
|
424
|
+
setRouteToolDebug(routeDebug, builtInToolTypes, prioritizedCandidates.routingHint);
|
|
425
|
+
if (routeDebug.enabled && builtInToolTypes.length > 0) {
|
|
426
|
+
console.warn(
|
|
427
|
+
`[llm-router] tool routing request=${requestedModel} tools=${builtInToolTypes.join(",")} hint=${prioritizedCandidates.routingHint || "none"}`
|
|
428
|
+
);
|
|
429
|
+
}
|
|
180
430
|
|
|
181
431
|
if (formatFiltered.eligible.length === 0) {
|
|
182
432
|
return withRouteDebugHeaders(jsonResponse({
|
|
@@ -196,7 +446,7 @@ async function handleRouteRequest(request, env, getConfig, sourceFormatHint, opt
|
|
|
196
446
|
strategy: runtimeFlags.statefulRoutingEnabled && resolved.routeType === "alias"
|
|
197
447
|
? resolved.routeStrategy
|
|
198
448
|
: "ordered",
|
|
199
|
-
candidates:
|
|
449
|
+
candidates: prioritizedCandidates.candidates,
|
|
200
450
|
stateStore,
|
|
201
451
|
config,
|
|
202
452
|
now
|
|
@@ -255,12 +505,14 @@ async function handleRouteRequest(request, env, getConfig, sourceFormatHint, opt
|
|
|
255
505
|
while (attempt < maxAttempts) {
|
|
256
506
|
attempt += 1;
|
|
257
507
|
result = await makeProviderCall({
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
508
|
+
body,
|
|
509
|
+
sourceFormat,
|
|
510
|
+
stream,
|
|
511
|
+
requestKind: options.requestKind,
|
|
512
|
+
candidate,
|
|
513
|
+
requestHeaders: request.headers,
|
|
514
|
+
env,
|
|
515
|
+
clientType: options.clientType
|
|
264
516
|
});
|
|
265
517
|
|
|
266
518
|
if (!quotaConsumed && shouldConsumeQuotaFromResult(result)) {
|
|
@@ -272,6 +524,18 @@ async function handleRouteRequest(request, env, getConfig, sourceFormatHint, opt
|
|
|
272
524
|
}
|
|
273
525
|
|
|
274
526
|
if (result.ok) {
|
|
527
|
+
const semanticSearchFailure = await detectSemanticWebSearchFailure(result.response, builtInToolTypes, stream);
|
|
528
|
+
if (semanticSearchFailure) {
|
|
529
|
+
classification = createSemanticWebSearchFailureClassification();
|
|
530
|
+
result = createSemanticWebSearchFailureResult(result.response);
|
|
531
|
+
recordRouteAttempt(routeDebug, candidate, result.status, classification, attempt);
|
|
532
|
+
if (routeDebug.enabled) {
|
|
533
|
+
console.warn(
|
|
534
|
+
`[llm-router] semantic web-search failure request=${requestedModel} candidate=${candidate.requestModelId} hint=${semanticSearchFailure}`
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
275
539
|
await clearCandidateRoutingState(stateStore, entry.candidateKey);
|
|
276
540
|
setRouteSelectedCandidate(routeDebug, candidate, { overwrite: true });
|
|
277
541
|
recordRouteAttempt(routeDebug, candidate, result.status, null, attempt);
|
|
@@ -432,6 +696,31 @@ export function createFetchHandler(options) {
|
|
|
432
696
|
}));
|
|
433
697
|
}
|
|
434
698
|
|
|
699
|
+
if (isAmpManagementPath(url.pathname)) {
|
|
700
|
+
let config;
|
|
701
|
+
try {
|
|
702
|
+
config = preloadedConfig || await loadRuntimeConfig(options.getConfig, env);
|
|
703
|
+
} catch (error) {
|
|
704
|
+
return respond(jsonResponse({
|
|
705
|
+
type: "error",
|
|
706
|
+
error: {
|
|
707
|
+
type: "configuration_error",
|
|
708
|
+
message: `Failed reading runtime config: ${error instanceof Error ? error.message : String(error)}`
|
|
709
|
+
}
|
|
710
|
+
}, 500));
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (authValidated !== true && !validateAuth(request, config, options)) {
|
|
714
|
+
return respond(jsonResponse({ error: "Unauthorized" }, 401));
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (!isAmpManagementAllowed(request, config)) {
|
|
718
|
+
return respond(jsonResponse({ error: "Forbidden" }, 403));
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return respond(await proxyAmpUpstreamRequest({ request, config }));
|
|
722
|
+
}
|
|
723
|
+
|
|
435
724
|
const route = resolveApiRoute(url.pathname, request.method);
|
|
436
725
|
if (route?.type === "models") {
|
|
437
726
|
const config = preloadedConfig || await loadRuntimeConfig(options.getConfig, env);
|
|
@@ -444,6 +733,154 @@ export function createFetchHandler(options) {
|
|
|
444
733
|
}));
|
|
445
734
|
}
|
|
446
735
|
|
|
736
|
+
if (["amp-gemini-models", "amp-gemini-model", "amp-gemini"].includes(route?.type)) {
|
|
737
|
+
let config;
|
|
738
|
+
try {
|
|
739
|
+
config = preloadedConfig || await loadRuntimeConfig(options.getConfig, env);
|
|
740
|
+
} catch (error) {
|
|
741
|
+
return respond(jsonResponse({
|
|
742
|
+
type: "error",
|
|
743
|
+
error: {
|
|
744
|
+
type: "configuration_error",
|
|
745
|
+
message: `Failed reading runtime config: ${error instanceof Error ? error.message : String(error)}`
|
|
746
|
+
}
|
|
747
|
+
}, 500));
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (authValidated !== true && !validateAuth(request, config, options)) {
|
|
751
|
+
return respond(jsonResponse({ error: "Unauthorized" }, 401));
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (route.type === "amp-gemini-models") {
|
|
755
|
+
return respond(jsonResponse(buildAmpGeminiModelsPayload(config)));
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (route.type === "amp-gemini-model") {
|
|
759
|
+
const modelPayload = buildAmpGeminiModelPayload(config, route.modelHint);
|
|
760
|
+
if (modelPayload) {
|
|
761
|
+
return respond(jsonResponse(modelPayload));
|
|
762
|
+
}
|
|
763
|
+
if (isAmpProxyEnabled(config)) {
|
|
764
|
+
return respond(await proxyAmpUpstreamRequest({ request, config }));
|
|
765
|
+
}
|
|
766
|
+
return respond(jsonResponse({
|
|
767
|
+
error: {
|
|
768
|
+
code: 404,
|
|
769
|
+
message: `AMP Gemini model '${route.modelHint}' not found.`,
|
|
770
|
+
status: "NOT_FOUND"
|
|
771
|
+
}
|
|
772
|
+
}, 404));
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const hasContentType = Boolean(request.headers.get("content-type"));
|
|
776
|
+
if (hasContentType && !isJsonRequest(request)) {
|
|
777
|
+
return respond(jsonResponse({ error: "Unsupported Media Type. Use application/json." }, 415));
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
let body;
|
|
781
|
+
try {
|
|
782
|
+
body = await parseJsonBodyWithLimit(request, resolveMaxRequestBodyBytes(env));
|
|
783
|
+
} catch (error) {
|
|
784
|
+
if (error && typeof error === "object" && error.code === "REQUEST_BODY_TOO_LARGE") {
|
|
785
|
+
return respond(jsonResponse({ error: "Request body too large" }, 413));
|
|
786
|
+
}
|
|
787
|
+
return respond(jsonResponse({ error: "Invalid JSON" }, 400));
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const geminiToolTypes = hasGeminiWebSearchTool(body?.tools) ? ["web_search"] : [];
|
|
791
|
+
const requestedModel = route.modelHint || body?.model || "smart";
|
|
792
|
+
if (shouldProxyAmpWebSearchRequest("amp", geminiToolTypes, config)) {
|
|
793
|
+
const routeDebug = buildAmpWebSearchProxyDebugState(env, requestedModel, geminiToolTypes);
|
|
794
|
+
if (routeDebug.enabled) {
|
|
795
|
+
console.warn(
|
|
796
|
+
`[llm-router] tool routing request=${requestedModel} tools=${geminiToolTypes.join(",")} hint=${routeDebug.toolRouting || "none"}`
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
return respond(withRouteDebugHeaders(await proxyAmpUpstreamRequest({
|
|
800
|
+
request,
|
|
801
|
+
config,
|
|
802
|
+
bodyOverride: JSON.stringify(body || {})
|
|
803
|
+
}), routeDebug));
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const translatedBody = convertAmpGeminiRequestToOpenAI(body, {
|
|
807
|
+
model: route.modelHint || body?.model,
|
|
808
|
+
method: route.methodHint,
|
|
809
|
+
stream: route.streamHint
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
const resolved = resolveRequestModel(config, translatedBody.model, FORMATS.OPENAI, {
|
|
813
|
+
clientType: "amp",
|
|
814
|
+
providerHint: "google"
|
|
815
|
+
});
|
|
816
|
+
if (!resolved.primary) {
|
|
817
|
+
if (isAmpProxyEnabled(config)) {
|
|
818
|
+
return respond(await proxyAmpUpstreamRequest({
|
|
819
|
+
request,
|
|
820
|
+
config,
|
|
821
|
+
bodyOverride: JSON.stringify(body || {})
|
|
822
|
+
}));
|
|
823
|
+
}
|
|
824
|
+
return respond(jsonResponse({
|
|
825
|
+
error: {
|
|
826
|
+
code: Number.isInteger(resolved?.statusCode) ? resolved.statusCode : 400,
|
|
827
|
+
message: resolved.error || `No matching model found for AMP Gemini request '${translatedBody.model}'.`,
|
|
828
|
+
status: "INVALID_ARGUMENT"
|
|
829
|
+
}
|
|
830
|
+
}, Number.isInteger(resolved?.statusCode) ? resolved.statusCode : 400));
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
let stateStore = null;
|
|
834
|
+
if (runtimeFlags.statefulRoutingEnabled) {
|
|
835
|
+
try {
|
|
836
|
+
stateStore = await ensureStateStore(env, runtimeFlags);
|
|
837
|
+
} catch (error) {
|
|
838
|
+
return respond(jsonResponse({
|
|
839
|
+
type: "error",
|
|
840
|
+
error: {
|
|
841
|
+
type: "configuration_error",
|
|
842
|
+
message: `Failed initializing routing state: ${error instanceof Error ? error.message : String(error)}`
|
|
843
|
+
}
|
|
844
|
+
}, 500));
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const translatedHeaders = new Headers(request.headers);
|
|
849
|
+
translatedHeaders.set("content-type", "application/json");
|
|
850
|
+
const translatedRequest = new Request(request.url, {
|
|
851
|
+
method: "POST",
|
|
852
|
+
headers: translatedHeaders,
|
|
853
|
+
body: JSON.stringify(translatedBody)
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
const routeResponse = await handleRouteRequest(translatedRequest, env, options.getConfig, FORMATS.OPENAI, {
|
|
857
|
+
...options,
|
|
858
|
+
preloadedConfig: config,
|
|
859
|
+
authValidated: true,
|
|
860
|
+
clientType: "amp",
|
|
861
|
+
providerHint: "google",
|
|
862
|
+
requestKind: "chat-completions",
|
|
863
|
+
stateStore,
|
|
864
|
+
runtimeFlags
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
if (routeResponse.status >= 400) {
|
|
868
|
+
return respond(routeResponse);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
return respond(await adaptOpenAIResponseToAmpGeminiResponse(routeResponse, {
|
|
872
|
+
stream: route.streamHint === true
|
|
873
|
+
}));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (route?.type === "amp-proxy") {
|
|
877
|
+
const config = preloadedConfig || await loadRuntimeConfig(options.getConfig, env);
|
|
878
|
+
if (authValidated !== true && !validateAuth(request, config, options)) {
|
|
879
|
+
return respond(jsonResponse({ error: "Unauthorized" }, 401));
|
|
880
|
+
}
|
|
881
|
+
return respond(await proxyAmpUpstreamRequest({ request, config }));
|
|
882
|
+
}
|
|
883
|
+
|
|
447
884
|
if (route?.type === "route") {
|
|
448
885
|
let stateStore = null;
|
|
449
886
|
if (runtimeFlags.statefulRoutingEnabled) {
|
|
@@ -464,6 +901,9 @@ export function createFetchHandler(options) {
|
|
|
464
901
|
...options,
|
|
465
902
|
preloadedConfig,
|
|
466
903
|
authValidated,
|
|
904
|
+
clientType: route.clientType,
|
|
905
|
+
providerHint: route.providerHint,
|
|
906
|
+
requestKind: route.requestKind,
|
|
467
907
|
stateStore,
|
|
468
908
|
runtimeFlags
|
|
469
909
|
});
|
|
@@ -188,7 +188,6 @@ export async function getValidAccessToken(profileId, options = {}) {
|
|
|
188
188
|
* @param {string} redirectUri - Redirect URI used in auth request
|
|
189
189
|
* @param {Object} [options] - Options
|
|
190
190
|
* @param {string} [options.subscriptionType] - Subscription type
|
|
191
|
-
* @param {string} [options.state] - OAuth state
|
|
192
191
|
* @returns {Promise<Object>} Token data
|
|
193
192
|
*/
|
|
194
193
|
async function exchangeCodeForTokens(code, codeVerifier, redirectUri, options = {}) {
|
|
@@ -200,9 +199,6 @@ async function exchangeCodeForTokens(code, codeVerifier, redirectUri, options =
|
|
|
200
199
|
redirect_uri: redirectUri,
|
|
201
200
|
client_id: config.clientId
|
|
202
201
|
};
|
|
203
|
-
if (typeof options.state === 'string' && options.state.trim()) {
|
|
204
|
-
body.state = options.state.trim();
|
|
205
|
-
}
|
|
206
202
|
|
|
207
203
|
const response = await fetch(config.tokenUrl, {
|
|
208
204
|
method: 'POST',
|
|
@@ -295,8 +291,7 @@ export async function loginWithBrowser(profileId, options = {}) {
|
|
|
295
291
|
}
|
|
296
292
|
|
|
297
293
|
const tokens = await exchangeCodeForTokens(code, pkce.verifier, redirectUri, {
|
|
298
|
-
subscriptionType: options.subscriptionType
|
|
299
|
-
state
|
|
294
|
+
subscriptionType: options.subscriptionType
|
|
300
295
|
});
|
|
301
296
|
await saveTokens(tokenProfileKey, tokens);
|
|
302
297
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export const LOCAL_ROUTER_HOST = "127.0.0.1";
|
|
2
|
+
export const LOCAL_ROUTER_PORT = 8376;
|
|
3
|
+
export const LOCAL_ROUTER_ORIGIN = `http://${LOCAL_ROUTER_HOST}:${LOCAL_ROUTER_PORT}`;
|
|
4
|
+
export const LOCAL_ROUTER_OPENAI_BASE_URL = `${LOCAL_ROUTER_ORIGIN}/openai/v1`;
|
|
5
|
+
export const LOCAL_ROUTER_ANTHROPIC_BASE_URL = `${LOCAL_ROUTER_ORIGIN}/anthropic`;
|
|
6
|
+
|
|
7
|
+
function toBoolean(value, fallback = false) {
|
|
8
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
9
|
+
if (typeof value === "boolean") return value;
|
|
10
|
+
const normalized = String(value).trim().toLowerCase();
|
|
11
|
+
if (["1", "true", "yes", "y", "on"].includes(normalized)) return true;
|
|
12
|
+
if (["0", "false", "no", "n", "off"].includes(normalized)) return false;
|
|
13
|
+
return fallback;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isPlainObject(value) {
|
|
17
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildLocalRouterSettings(source = {}, fallback = {}) {
|
|
21
|
+
const base = {
|
|
22
|
+
watchConfig: toBoolean(fallback?.watchConfig, true),
|
|
23
|
+
watchBinary: toBoolean(fallback?.watchBinary, true),
|
|
24
|
+
requireAuth: toBoolean(fallback?.requireAuth, false)
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
host: LOCAL_ROUTER_HOST,
|
|
29
|
+
port: LOCAL_ROUTER_PORT,
|
|
30
|
+
watchConfig: toBoolean(source?.watchConfig, base.watchConfig),
|
|
31
|
+
watchBinary: toBoolean(source?.watchBinary, base.watchBinary),
|
|
32
|
+
requireAuth: toBoolean(source?.requireAuth, base.requireAuth)
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildPersistedLocalServerMetadata(source = {}, fallback = {}) {
|
|
37
|
+
const resolved = buildLocalRouterSettings(source, fallback);
|
|
38
|
+
const defaults = buildLocalRouterSettings();
|
|
39
|
+
const metadata = {};
|
|
40
|
+
|
|
41
|
+
if (resolved.watchConfig !== defaults.watchConfig) metadata.watchConfig = resolved.watchConfig;
|
|
42
|
+
if (resolved.watchBinary !== defaults.watchBinary) metadata.watchBinary = resolved.watchBinary;
|
|
43
|
+
if (resolved.requireAuth !== defaults.requireAuth) metadata.requireAuth = resolved.requireAuth;
|
|
44
|
+
|
|
45
|
+
return metadata;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function sanitizeRuntimeMetadata(metadata) {
|
|
49
|
+
if (!isPlainObject(metadata)) return {};
|
|
50
|
+
|
|
51
|
+
const next = { ...metadata };
|
|
52
|
+
if (Object.prototype.hasOwnProperty.call(next, "localServer")) {
|
|
53
|
+
const localServer = buildPersistedLocalServerMetadata(next.localServer);
|
|
54
|
+
if (Object.keys(localServer).length > 0) {
|
|
55
|
+
next.localServer = localServer;
|
|
56
|
+
} else {
|
|
57
|
+
delete next.localServer;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return next;
|
|
62
|
+
}
|
package/src/translator/index.js
CHANGED