@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +337 -41
  3. package/package.json +19 -3
  4. package/src/cli/router-module.js +7331 -3805
  5. package/src/cli/wrangler-toml.js +1 -1
  6. package/src/cli-entry.js +162 -24
  7. package/src/node/amp-client-config.js +426 -0
  8. package/src/node/coding-tool-config.js +763 -0
  9. package/src/node/config-store.js +49 -18
  10. package/src/node/instance-state.js +213 -12
  11. package/src/node/listen-port.js +5 -37
  12. package/src/node/local-server-settings.js +122 -0
  13. package/src/node/local-server.js +3 -2
  14. package/src/node/provider-probe.js +13 -0
  15. package/src/node/start-command.js +282 -40
  16. package/src/node/startup-manager.js +64 -29
  17. package/src/node/web-command.js +106 -0
  18. package/src/node/web-console-assets.js +26 -0
  19. package/src/node/web-console-client.js +56 -0
  20. package/src/node/web-console-dev-assets.js +258 -0
  21. package/src/node/web-console-server.js +3146 -0
  22. package/src/node/web-console-styles.generated.js +1 -0
  23. package/src/node/web-console-ui/config-editor-utils.js +616 -0
  24. package/src/node/web-console-ui/lib/utils.js +6 -0
  25. package/src/node/web-console-ui/rate-limit-utils.js +144 -0
  26. package/src/node/web-console-ui/select-search-utils.js +36 -0
  27. package/src/runtime/codex-request-transformer.js +46 -5
  28. package/src/runtime/codex-response-transformer.js +268 -35
  29. package/src/runtime/config.js +1394 -35
  30. package/src/runtime/handler/amp-gemini.js +913 -0
  31. package/src/runtime/handler/amp-response.js +308 -0
  32. package/src/runtime/handler/amp.js +290 -0
  33. package/src/runtime/handler/auth.js +17 -2
  34. package/src/runtime/handler/provider-call.js +168 -50
  35. package/src/runtime/handler/provider-translation.js +937 -26
  36. package/src/runtime/handler/request.js +149 -6
  37. package/src/runtime/handler/route-debug.js +22 -1
  38. package/src/runtime/handler.js +449 -9
  39. package/src/runtime/subscription-auth.js +1 -6
  40. package/src/shared/local-router-defaults.js +62 -0
  41. package/src/translator/index.js +3 -1
  42. package/src/translator/request/openai-to-claude.js +217 -6
  43. package/src/translator/response/openai-to-claude.js +206 -58
@@ -0,0 +1,913 @@
1
+ import { FORMATS } from "../../translator/index.js";
2
+ import { listConfiguredModels, resolveRequestModel } from "../config.js";
3
+ import { jsonResponse, withCorsHeaders } from "./http.js";
4
+
5
+ let toolCallCounter = 0;
6
+
7
+ const GEMINI_SUPPORTED_METHODS = ["generateContent", "streamGenerateContent"];
8
+ const GOOGLE_MODEL_PATH_PATTERNS = [
9
+ "/api/provider/google/v1beta1",
10
+ "/api/provider/google/v1beta",
11
+ "/api/provider/google/v1",
12
+ "/api/provider/google",
13
+ "/v1/publishers/google",
14
+ "/publishers/google"
15
+ ];
16
+
17
+ function normalizePath(pathname) {
18
+ const text = String(pathname || "").trim() || "/";
19
+ if (text.length > 1 && text.endsWith("/")) return text.slice(0, -1);
20
+ return text;
21
+ }
22
+
23
+ function parseInlineData(part) {
24
+ if (!part || typeof part !== "object") return null;
25
+ return part.inlineData || part.inline_data || null;
26
+ }
27
+
28
+ function buildDataUriFromInlineData(inlineData) {
29
+ if (!inlineData || typeof inlineData !== "object") return "";
30
+ const data = String(inlineData.data || "").trim();
31
+ if (!data) return "";
32
+ const mimeType = String(inlineData.mimeType || inlineData.mime_type || "application/octet-stream").trim() || "application/octet-stream";
33
+ return `data:${mimeType};base64,${data}`;
34
+ }
35
+
36
+ function parseJsonObjectSafely(value, fallback = {}) {
37
+ if (value && typeof value === "object" && !Array.isArray(value)) return value;
38
+ const text = String(value || "").trim();
39
+ if (!text) return fallback;
40
+ try {
41
+ const parsed = JSON.parse(text);
42
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fallback;
43
+ } catch {
44
+ return fallback;
45
+ }
46
+ }
47
+
48
+ function safeJsonStringify(value, fallback = "{}") {
49
+ try {
50
+ return JSON.stringify(value);
51
+ } catch {
52
+ return fallback;
53
+ }
54
+ }
55
+
56
+ function generateToolCallId() {
57
+ toolCallCounter += 1;
58
+ return `call_amp_${Date.now()}_${toolCallCounter}`;
59
+ }
60
+
61
+ function mapGeminiThinkingConfigToEffort(config) {
62
+ if (!config || typeof config !== "object") return undefined;
63
+ const explicit = String(config.thinkingLevel || config.thinking_level || "").trim().toLowerCase();
64
+ if (explicit) return explicit;
65
+ const budget = Number(config.thinkingBudget ?? config.thinking_budget);
66
+ if (!Number.isFinite(budget) || budget <= 0) return undefined;
67
+ if (budget <= 1024) return "low";
68
+ if (budget <= 4096) return "medium";
69
+ return "high";
70
+ }
71
+
72
+ function toMessageContent(textParts, imageUrls) {
73
+ const items = [];
74
+
75
+ for (const text of textParts) {
76
+ const value = String(text || "");
77
+ if (!value) continue;
78
+ items.push({ type: "text", text: value });
79
+ }
80
+
81
+ for (const url of imageUrls) {
82
+ const value = String(url || "").trim();
83
+ if (!value) continue;
84
+ items.push({ type: "image_url", image_url: { url: value } });
85
+ }
86
+
87
+ if (items.length === 0) return "";
88
+ if (items.length === 1 && items[0].type === "text") return items[0].text;
89
+ return items;
90
+ }
91
+
92
+ function enqueuePendingToolCall(queueByName, name, id) {
93
+ if (!name || !id) return;
94
+ const list = queueByName.get(name) || [];
95
+ list.push(id);
96
+ queueByName.set(name, list);
97
+ }
98
+
99
+ function dequeuePendingToolCall(queueByName, name) {
100
+ const list = queueByName.get(name) || [];
101
+ const next = list.shift() || "";
102
+ if (list.length > 0) {
103
+ queueByName.set(name, list);
104
+ } else {
105
+ queueByName.delete(name);
106
+ }
107
+ return next;
108
+ }
109
+
110
+ function convertGeminiContentToMessages(content, queueByName) {
111
+ if (!content || typeof content !== "object") return [];
112
+
113
+ const role = String(content.role || "user").trim().toLowerCase() === "model"
114
+ ? "assistant"
115
+ : "user";
116
+ const parts = Array.isArray(content.parts) ? content.parts : [];
117
+ const textParts = [];
118
+ const imageUrls = [];
119
+ const toolCalls = [];
120
+ const toolResults = [];
121
+
122
+ for (const part of parts) {
123
+ if (!part || typeof part !== "object") continue;
124
+
125
+ if (typeof part.text === "string" && part.text) {
126
+ textParts.push(part.text);
127
+ }
128
+
129
+ const inlineData = parseInlineData(part);
130
+ const imageUrl = buildDataUriFromInlineData(inlineData);
131
+ if (imageUrl) {
132
+ imageUrls.push(imageUrl);
133
+ }
134
+
135
+ if (part.functionCall && typeof part.functionCall === "object") {
136
+ const name = String(part.functionCall.name || "").trim() || "tool";
137
+ const id = String(part.functionCall.id || "").trim() || generateToolCallId();
138
+ toolCalls.push({
139
+ id,
140
+ type: "function",
141
+ function: {
142
+ name,
143
+ arguments: safeJsonStringify(part.functionCall.args || {}, "{}")
144
+ }
145
+ });
146
+ enqueuePendingToolCall(queueByName, name, id);
147
+ }
148
+
149
+ if (part.functionResponse && typeof part.functionResponse === "object") {
150
+ const name = String(part.functionResponse.name || "").trim() || "tool";
151
+ const id = String(part.functionResponse.id || "").trim() || dequeuePendingToolCall(queueByName, name) || generateToolCallId();
152
+ toolResults.push({
153
+ role: "tool",
154
+ tool_call_id: id,
155
+ content: safeJsonStringify(part.functionResponse.response || {}, "{}")
156
+ });
157
+ }
158
+ }
159
+
160
+ const messages = [];
161
+ const contentValue = toMessageContent(textParts, imageUrls);
162
+
163
+ if (role === "assistant") {
164
+ if (contentValue || toolCalls.length > 0) {
165
+ messages.push({
166
+ role: "assistant",
167
+ content: typeof contentValue === "string" ? contentValue : (contentValue || ""),
168
+ ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {})
169
+ });
170
+ }
171
+ } else if (contentValue) {
172
+ messages.push({
173
+ role: "user",
174
+ content: contentValue
175
+ });
176
+ }
177
+
178
+ if (toolResults.length > 0) {
179
+ messages.push(...toolResults);
180
+ }
181
+
182
+ return messages;
183
+ }
184
+
185
+ function convertGeminiToolsToOpenAITools(tools) {
186
+ const out = [];
187
+ for (const tool of (Array.isArray(tools) ? tools : [])) {
188
+ if (!tool || typeof tool !== "object") continue;
189
+ if (hasGeminiWebSearchTool([tool])) {
190
+ out.push({ type: "web_search" });
191
+ }
192
+ const declarations = Array.isArray(tool.functionDeclarations)
193
+ ? tool.functionDeclarations
194
+ : (Array.isArray(tool.function_declarations) ? tool.function_declarations : []);
195
+ for (const declaration of declarations) {
196
+ if (!declaration || typeof declaration !== "object") continue;
197
+ const name = String(declaration.name || "").trim();
198
+ if (!name) continue;
199
+ out.push({
200
+ type: "function",
201
+ function: {
202
+ name,
203
+ description: typeof declaration.description === "string" ? declaration.description : "",
204
+ parameters: declaration.parametersJsonSchema || declaration.parameters_json_schema || declaration.parameters || { type: "object", properties: {} }
205
+ }
206
+ });
207
+ }
208
+ }
209
+ return out;
210
+ }
211
+
212
+ export function hasGeminiWebSearchTool(tools) {
213
+ for (const tool of (Array.isArray(tools) ? tools : [])) {
214
+ if (!tool || typeof tool !== "object") continue;
215
+ if (
216
+ (tool.googleSearch && typeof tool.googleSearch === "object")
217
+ || (tool.google_search && typeof tool.google_search === "object")
218
+ || (tool.googleSearchRetrieval && typeof tool.googleSearchRetrieval === "object")
219
+ || (tool.google_search_retrieval && typeof tool.google_search_retrieval === "object")
220
+ ) {
221
+ return true;
222
+ }
223
+ }
224
+ return false;
225
+ }
226
+
227
+ function convertGeminiToolChoice(toolConfig) {
228
+ const functionCallingConfig = toolConfig?.functionCallingConfig || toolConfig?.function_calling_config;
229
+ if (!functionCallingConfig || typeof functionCallingConfig !== "object") return undefined;
230
+
231
+ const mode = String(functionCallingConfig.mode || "").trim().toUpperCase();
232
+ const allowed = Array.isArray(functionCallingConfig.allowedFunctionNames)
233
+ ? functionCallingConfig.allowedFunctionNames.map((value) => String(value || "").trim()).filter(Boolean)
234
+ : [];
235
+
236
+ if (mode === "NONE") return "none";
237
+ if (allowed.length === 1) {
238
+ return {
239
+ type: "function",
240
+ function: { name: allowed[0] }
241
+ };
242
+ }
243
+ if (mode === "ANY" || mode === "REQUIRED") return "required";
244
+ if (mode === "AUTO") return "auto";
245
+ return undefined;
246
+ }
247
+
248
+ export function extractAmpGeminiRouteInfo(pathname) {
249
+ const path = normalizePath(pathname);
250
+
251
+ for (const prefix of GOOGLE_MODEL_PATH_PATTERNS) {
252
+ if (!(path === prefix || path.startsWith(`${prefix}/`))) continue;
253
+ const relative = path.slice(prefix.length) || "/";
254
+
255
+ if (relative === "/models" || relative === "/models/") {
256
+ return { type: "models" };
257
+ }
258
+
259
+ if (!relative.startsWith("/models/")) continue;
260
+
261
+ const modelAction = relative.slice("/models/".length);
262
+ if (!modelAction) {
263
+ return { type: "models" };
264
+ }
265
+
266
+ const colonIndex = modelAction.indexOf(":");
267
+ if (colonIndex < 0) {
268
+ return {
269
+ type: "model",
270
+ model: decodeURIComponent(modelAction)
271
+ };
272
+ }
273
+
274
+ const model = decodeURIComponent(modelAction.slice(0, colonIndex));
275
+ const method = modelAction.slice(colonIndex + 1);
276
+ if (!model || !GEMINI_SUPPORTED_METHODS.includes(method)) return null;
277
+
278
+ return {
279
+ type: "request",
280
+ model,
281
+ method,
282
+ stream: method === "streamGenerateContent"
283
+ };
284
+ }
285
+
286
+ return null;
287
+ }
288
+
289
+ export function convertAmpGeminiRequestToOpenAI(body, spec) {
290
+ const payload = body && typeof body === "object" ? body : {};
291
+ const out = {
292
+ model: spec?.model || String(payload.model || payload.modelName || "").trim() || "smart",
293
+ messages: [],
294
+ stream: Boolean(spec?.stream)
295
+ };
296
+
297
+ const systemInstruction = payload.systemInstruction || payload.system_instruction;
298
+ if (systemInstruction && typeof systemInstruction === "object") {
299
+ const systemParts = Array.isArray(systemInstruction.parts) ? systemInstruction.parts : [];
300
+ const texts = systemParts
301
+ .map((part) => (part && typeof part === "object" && typeof part.text === "string") ? part.text : "")
302
+ .filter(Boolean);
303
+ if (texts.length > 0) {
304
+ out.messages.push({
305
+ role: "system",
306
+ content: texts.join("\n")
307
+ });
308
+ }
309
+ }
310
+
311
+ const pendingToolCalls = new Map();
312
+ for (const content of (Array.isArray(payload.contents) ? payload.contents : [])) {
313
+ out.messages.push(...convertGeminiContentToMessages(content, pendingToolCalls));
314
+ }
315
+
316
+ const generationConfig = payload.generationConfig || payload.generation_config;
317
+ if (generationConfig && typeof generationConfig === "object") {
318
+ if (Number.isFinite(Number(generationConfig.temperature))) out.temperature = Number(generationConfig.temperature);
319
+ if (Number.isFinite(Number(generationConfig.maxOutputTokens ?? generationConfig.max_output_tokens))) out.max_tokens = Number(generationConfig.maxOutputTokens ?? generationConfig.max_output_tokens);
320
+ if (Number.isFinite(Number(generationConfig.topP ?? generationConfig.top_p))) out.top_p = Number(generationConfig.topP ?? generationConfig.top_p);
321
+ if (Number.isFinite(Number(generationConfig.topK ?? generationConfig.top_k))) out.top_k = Number(generationConfig.topK ?? generationConfig.top_k);
322
+ const stopSequences = Array.isArray(generationConfig.stopSequences)
323
+ ? generationConfig.stopSequences
324
+ : (Array.isArray(generationConfig.stop_sequences) ? generationConfig.stop_sequences : []);
325
+ if (stopSequences.length > 0) {
326
+ out.stop = stopSequences.map((value) => String(value || "")).filter(Boolean);
327
+ }
328
+ if (Number.isFinite(Number(generationConfig.candidateCount ?? generationConfig.candidate_count))) {
329
+ out.n = Number(generationConfig.candidateCount ?? generationConfig.candidate_count);
330
+ }
331
+ const reasoningEffort = mapGeminiThinkingConfigToEffort(generationConfig.thinkingConfig || generationConfig.thinking_config);
332
+ if (reasoningEffort) out.reasoning_effort = reasoningEffort;
333
+ }
334
+
335
+ const tools = convertGeminiToolsToOpenAITools(payload.tools);
336
+ if (tools.length > 0) out.tools = tools;
337
+
338
+ const toolChoice = convertGeminiToolChoice(payload.toolConfig || payload.tool_config);
339
+ if (toolChoice !== undefined) out.tool_choice = toolChoice;
340
+
341
+ return out;
342
+ }
343
+
344
+ function mapOpenAIFinishReason(reason) {
345
+ switch (String(reason || "").trim().toLowerCase()) {
346
+ case "length":
347
+ return "MAX_TOKENS";
348
+ case "content_filter":
349
+ return "SAFETY";
350
+ case "tool_calls":
351
+ case "stop":
352
+ default:
353
+ return "STOP";
354
+ }
355
+ }
356
+
357
+ function buildGeminiUsageMetadata(usage) {
358
+ if (!usage || typeof usage !== "object") return undefined;
359
+ return {
360
+ promptTokenCount: Number(usage.prompt_tokens || 0),
361
+ candidatesTokenCount: Number(usage.completion_tokens || 0),
362
+ totalTokenCount: Number(usage.total_tokens || 0),
363
+ ...(Number(usage?.completion_tokens_details?.reasoning_tokens || 0) > 0
364
+ ? { thoughtsTokenCount: Number(usage.completion_tokens_details.reasoning_tokens) }
365
+ : {})
366
+ };
367
+ }
368
+
369
+ function buildGeminiPartsFromOpenAIMessage(message = {}) {
370
+ const parts = [];
371
+ const reasoning = String(message.reasoning_content || "").trim();
372
+ if (reasoning) {
373
+ parts.push({ thought: true, text: reasoning });
374
+ }
375
+
376
+ if (typeof message.content === "string" && message.content) {
377
+ parts.push({ text: message.content });
378
+ } else if (Array.isArray(message.content)) {
379
+ for (const item of message.content) {
380
+ if (!item || typeof item !== "object") continue;
381
+ if (typeof item.text === "string" && item.text) {
382
+ parts.push({ text: item.text });
383
+ }
384
+ }
385
+ }
386
+
387
+ const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
388
+ for (const toolCall of toolCalls) {
389
+ if (!toolCall || typeof toolCall !== "object") continue;
390
+ const name = String(toolCall.function?.name || toolCall.name || "").trim();
391
+ if (!name) continue;
392
+ parts.push({
393
+ functionCall: {
394
+ ...(String(toolCall.id || "").trim() ? { id: String(toolCall.id).trim() } : {}),
395
+ name,
396
+ args: parseToolArgumentsObject(toolCall.function?.arguments)
397
+ }
398
+ });
399
+ }
400
+
401
+ if (parts.length === 0) {
402
+ parts.push({ text: "" });
403
+ }
404
+ return parts;
405
+ }
406
+
407
+ export function convertOpenAIResponseToAmpGeminiPayload(openAIResponse) {
408
+ const response = openAIResponse && typeof openAIResponse === "object" ? openAIResponse : {};
409
+ const candidates = [];
410
+
411
+ for (const choice of (Array.isArray(response.choices) ? response.choices : [])) {
412
+ candidates.push({
413
+ index: Number.isFinite(choice?.index) ? Number(choice.index) : 0,
414
+ content: {
415
+ role: "model",
416
+ parts: buildGeminiPartsFromOpenAIMessage(choice?.message || {})
417
+ },
418
+ finishReason: mapOpenAIFinishReason(choice?.finish_reason)
419
+ });
420
+ }
421
+
422
+ return {
423
+ candidates: candidates.length > 0
424
+ ? candidates
425
+ : [{ index: 0, content: { role: "model", parts: [{ text: "" }] }, finishReason: "STOP" }],
426
+ model: response.model,
427
+ ...(buildGeminiUsageMetadata(response.usage) ? { usageMetadata: buildGeminiUsageMetadata(response.usage) } : {})
428
+ };
429
+ }
430
+
431
+ function createAmpGeminiSseChunk(payload) {
432
+ return `data: ${JSON.stringify(payload)}\n\n`;
433
+ }
434
+
435
+ function copyRouterDebugHeaders(response, headersLike = {}) {
436
+ const headers = new Headers(withCorsHeaders(headersLike));
437
+ if (!(response instanceof Response)) return headers;
438
+
439
+ for (const [name, value] of response.headers.entries()) {
440
+ if (!String(name || "").toLowerCase().startsWith("x-llm-router-")) continue;
441
+ headers.set(name, value);
442
+ }
443
+
444
+ return headers;
445
+ }
446
+
447
+ export function createAmpGeminiStreamResponse(payload, status = 200) {
448
+ return new Response(createAmpGeminiSseChunk(payload), {
449
+ status,
450
+ headers: copyRouterDebugHeaders(null, {
451
+ "Content-Type": "text/event-stream",
452
+ "Cache-Control": "no-cache",
453
+ Connection: "keep-alive"
454
+ })
455
+ });
456
+ }
457
+
458
+ function normalizeDeltaTextParts(content) {
459
+ if (typeof content === "string") {
460
+ return content ? [{ text: content }] : [];
461
+ }
462
+
463
+ if (!Array.isArray(content)) return [];
464
+
465
+ const parts = [];
466
+ for (const item of content) {
467
+ if (!item || typeof item !== "object") continue;
468
+ if (typeof item.text === "string" && item.text) {
469
+ parts.push({ text: item.text });
470
+ }
471
+ }
472
+ return parts;
473
+ }
474
+
475
+ function tryParseNumber(value) {
476
+ if (value === "") return null;
477
+ const number = Number(value);
478
+ return Number.isFinite(number) ? number : null;
479
+ }
480
+
481
+ function readQuotedStringToken(chars, startIndex) {
482
+ if (chars[startIndex] !== '"') return null;
483
+ let index = startIndex + 1;
484
+ let escaped = false;
485
+ while (index < chars.length) {
486
+ const char = chars[index];
487
+ if (char === "\\" && !escaped) {
488
+ escaped = true;
489
+ index += 1;
490
+ continue;
491
+ }
492
+ if (char === '"' && !escaped) {
493
+ return {
494
+ token: chars.slice(startIndex, index + 1).join(""),
495
+ nextIndex: index + 1
496
+ };
497
+ }
498
+ escaped = false;
499
+ index += 1;
500
+ }
501
+ return {
502
+ token: chars.slice(startIndex).join(""),
503
+ nextIndex: chars.length
504
+ };
505
+ }
506
+
507
+ function parseJsonStringToken(token) {
508
+ try {
509
+ return JSON.parse(token);
510
+ } catch {
511
+ return token.startsWith('"') && token.endsWith('"') ? token.slice(1, -1) : token;
512
+ }
513
+ }
514
+
515
+ function captureBalancedSegment(chars, startIndex) {
516
+ const startChar = chars[startIndex];
517
+ const endChar = startChar === "{" ? "}" : (startChar === "[" ? "]" : "");
518
+ if (!endChar) return null;
519
+
520
+ let index = startIndex;
521
+ let depth = 0;
522
+ let inString = false;
523
+ let escaped = false;
524
+
525
+ while (index < chars.length) {
526
+ const char = chars[index];
527
+ if (inString) {
528
+ if (char === "\\" && !escaped) {
529
+ escaped = true;
530
+ index += 1;
531
+ continue;
532
+ }
533
+ if (char === '"' && !escaped) {
534
+ inString = false;
535
+ } else {
536
+ escaped = false;
537
+ }
538
+ index += 1;
539
+ continue;
540
+ }
541
+
542
+ if (char === '"') {
543
+ inString = true;
544
+ index += 1;
545
+ continue;
546
+ }
547
+
548
+ if (char === startChar) {
549
+ depth += 1;
550
+ } else if (char === endChar) {
551
+ depth -= 1;
552
+ if (depth === 0) {
553
+ return {
554
+ segment: chars.slice(startIndex, index + 1).join(""),
555
+ nextIndex: index + 1
556
+ };
557
+ }
558
+ }
559
+
560
+ index += 1;
561
+ }
562
+
563
+ return {
564
+ segment: chars.slice(startIndex).join(""),
565
+ nextIndex: chars.length
566
+ };
567
+ }
568
+
569
+ function tolerantParseObjectString(input) {
570
+ const raw = String(input || "").trim();
571
+ const start = raw.indexOf("{");
572
+ const end = raw.lastIndexOf("}");
573
+ if (start < 0 || end <= start) return {};
574
+
575
+ const chars = Array.from(raw.slice(start + 1, end));
576
+ const result = {};
577
+ let index = 0;
578
+
579
+ while (index < chars.length) {
580
+ while (index < chars.length && /[\s,]/.test(chars[index])) index += 1;
581
+ if (index >= chars.length) break;
582
+
583
+ if (chars[index] !== '"') {
584
+ while (index < chars.length && chars[index] !== ",") index += 1;
585
+ continue;
586
+ }
587
+
588
+ const keyToken = readQuotedStringToken(chars, index);
589
+ if (!keyToken) break;
590
+ const key = parseJsonStringToken(keyToken.token);
591
+ index = keyToken.nextIndex;
592
+
593
+ while (index < chars.length && /\s/.test(chars[index])) index += 1;
594
+ if (index >= chars.length || chars[index] !== ":") break;
595
+ index += 1;
596
+ while (index < chars.length && /\s/.test(chars[index])) index += 1;
597
+ if (index >= chars.length) break;
598
+
599
+ const char = chars[index];
600
+ if (char === '"') {
601
+ const valueToken = readQuotedStringToken(chars, index);
602
+ result[key] = valueToken ? parseJsonStringToken(valueToken.token) : "";
603
+ index = valueToken ? valueToken.nextIndex : chars.length;
604
+ } else if (char === "{" || char === "[") {
605
+ const segment = captureBalancedSegment(chars, index);
606
+ const segmentText = segment?.segment || "";
607
+ try {
608
+ result[key] = JSON.parse(segmentText);
609
+ } catch {
610
+ result[key] = segmentText;
611
+ }
612
+ index = segment ? segment.nextIndex : chars.length;
613
+ } else {
614
+ let endIndex = index;
615
+ while (endIndex < chars.length && chars[endIndex] !== ",") endIndex += 1;
616
+ const token = chars.slice(index, endIndex).join("").trim();
617
+ if (token === "true") {
618
+ result[key] = true;
619
+ } else if (token === "false") {
620
+ result[key] = false;
621
+ } else if (token === "null") {
622
+ result[key] = null;
623
+ } else {
624
+ const number = tryParseNumber(token);
625
+ result[key] = number ?? token;
626
+ }
627
+ index = endIndex;
628
+ }
629
+
630
+ while (index < chars.length && /[\s,]/.test(chars[index])) index += 1;
631
+ }
632
+
633
+ return result;
634
+ }
635
+
636
+ function parseToolArgumentsObject(value) {
637
+ const text = String(value || "").trim();
638
+ if (!text || text === "{}") return {};
639
+
640
+ try {
641
+ const parsed = JSON.parse(text);
642
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
643
+ return parsed;
644
+ }
645
+ } catch {
646
+ // Fall through to tolerant parser.
647
+ }
648
+
649
+ return tolerantParseObjectString(text);
650
+ }
651
+
652
+ function accumulateToolCallDelta(state, toolCall) {
653
+ if (!toolCall || typeof toolCall !== "object") return;
654
+ const index = Number.isInteger(toolCall.index) ? toolCall.index : state.toolCalls.size;
655
+ const existing = state.toolCalls.get(index) || { id: "", name: "", argumentsText: "" };
656
+ const id = String(toolCall.id || "").trim();
657
+ const name = String(toolCall.function?.name || "").trim();
658
+ const argumentsText = String(toolCall.function?.arguments || "");
659
+
660
+ if (id) existing.id = id;
661
+ if (name) existing.name = name;
662
+ if (argumentsText) existing.argumentsText += argumentsText;
663
+
664
+ state.toolCalls.set(index, existing);
665
+ }
666
+
667
+ function flushToolCallAccumulator(state) {
668
+ const parts = [];
669
+ const indexes = [...state.toolCalls.keys()].sort((left, right) => left - right);
670
+ for (const index of indexes) {
671
+ const accumulator = state.toolCalls.get(index);
672
+ if (!accumulator) continue;
673
+ parts.push({
674
+ functionCall: {
675
+ ...(accumulator.id ? { id: accumulator.id } : {}),
676
+ name: accumulator.name || "tool",
677
+ args: parseToolArgumentsObject(accumulator.argumentsText)
678
+ }
679
+ });
680
+ }
681
+ state.toolCalls.clear();
682
+ return parts;
683
+ }
684
+
685
+ function convertOpenAIStreamChunkToGeminiPayloads(openAIChunk, state) {
686
+ const chunk = openAIChunk && typeof openAIChunk === "object" ? openAIChunk : {};
687
+ const payloads = [];
688
+
689
+ for (const choice of (Array.isArray(chunk.choices) ? chunk.choices : [])) {
690
+ const delta = choice?.delta && typeof choice.delta === "object" ? choice.delta : null;
691
+ if (delta) {
692
+ const reasoning = String(delta.reasoning_content || "").trim();
693
+ if (reasoning) {
694
+ payloads.push({
695
+ candidates: [{
696
+ content: {
697
+ role: "model",
698
+ parts: [{ thought: true, text: reasoning }]
699
+ }
700
+ }]
701
+ });
702
+ }
703
+
704
+ const textParts = normalizeDeltaTextParts(delta.content);
705
+ if (textParts.length > 0) {
706
+ payloads.push({
707
+ candidates: [{
708
+ content: {
709
+ role: "model",
710
+ parts: textParts
711
+ }
712
+ }]
713
+ });
714
+ }
715
+
716
+ for (const toolCall of (Array.isArray(delta.tool_calls) ? delta.tool_calls : [])) {
717
+ accumulateToolCallDelta(state, toolCall);
718
+ }
719
+ }
720
+
721
+ if (choice?.finish_reason) {
722
+ const toolParts = flushToolCallAccumulator(state);
723
+ const candidate = {
724
+ finishReason: mapOpenAIFinishReason(choice.finish_reason),
725
+ ...(toolParts.length > 0
726
+ ? {
727
+ content: {
728
+ role: "model",
729
+ parts: toolParts
730
+ }
731
+ }
732
+ : {})
733
+ };
734
+ payloads.push({ candidates: [candidate] });
735
+ }
736
+ }
737
+
738
+ const usageMetadata = buildGeminiUsageMetadata(chunk.usage);
739
+ if (usageMetadata) {
740
+ payloads.push({
741
+ candidates: [],
742
+ usageMetadata
743
+ });
744
+ }
745
+
746
+ return payloads;
747
+ }
748
+
749
+ function parseSseEventData(rawEvent) {
750
+ const lines = String(rawEvent || "").split(/\r?\n/);
751
+ const dataLines = [];
752
+ for (const line of lines) {
753
+ if (!line.startsWith("data:")) continue;
754
+ dataLines.push(line.slice(5).trimStart());
755
+ }
756
+ return dataLines.join("\n");
757
+ }
758
+
759
+ function createAmpGeminiStreamResponseFromOpenAIStream(response) {
760
+ if (!(response instanceof Response) || !response.body) {
761
+ return jsonResponse({ error: { message: "Provider did not return a streaming body for AMP Gemini request." } }, 502);
762
+ }
763
+
764
+ const encoder = new TextEncoder();
765
+ const decoder = new TextDecoder();
766
+ const state = {
767
+ toolCalls: new Map()
768
+ };
769
+
770
+ const stream = new ReadableStream({
771
+ async start(controller) {
772
+ const reader = response.body.getReader();
773
+ let buffer = "";
774
+
775
+ const emitPayloads = (payloads) => {
776
+ for (const payload of payloads) {
777
+ controller.enqueue(encoder.encode(createAmpGeminiSseChunk(payload)));
778
+ }
779
+ };
780
+
781
+ const handleEvent = (rawEvent) => {
782
+ const eventData = parseSseEventData(rawEvent);
783
+ if (!eventData || eventData === "[DONE]") return;
784
+
785
+ let parsed;
786
+ try {
787
+ parsed = JSON.parse(eventData);
788
+ } catch {
789
+ return;
790
+ }
791
+
792
+ emitPayloads(convertOpenAIStreamChunkToGeminiPayloads(parsed, state));
793
+ };
794
+
795
+ try {
796
+ while (true) {
797
+ const { done, value } = await reader.read();
798
+ if (done) break;
799
+ buffer += decoder.decode(value, { stream: true });
800
+
801
+ while (true) {
802
+ const separatorIndex = buffer.indexOf("\n\n");
803
+ if (separatorIndex < 0) break;
804
+ const rawEvent = buffer.slice(0, separatorIndex);
805
+ buffer = buffer.slice(separatorIndex + 2);
806
+ handleEvent(rawEvent);
807
+ }
808
+ }
809
+
810
+ buffer += decoder.decode();
811
+ if (buffer.trim()) {
812
+ handleEvent(buffer);
813
+ }
814
+ controller.close();
815
+ } catch (error) {
816
+ controller.error(error);
817
+ } finally {
818
+ try {
819
+ reader.releaseLock();
820
+ } catch {
821
+ // Ignore reader cleanup errors.
822
+ }
823
+ }
824
+ }
825
+ });
826
+
827
+ return new Response(stream, {
828
+ status: response.status,
829
+ headers: copyRouterDebugHeaders(response, {
830
+ "Content-Type": "text/event-stream",
831
+ "Cache-Control": "no-cache",
832
+ Connection: "keep-alive"
833
+ })
834
+ });
835
+ }
836
+
837
+ export async function adaptOpenAIResponseToAmpGeminiResponse(response, { stream = false } = {}) {
838
+ if (stream) {
839
+ return createAmpGeminiStreamResponseFromOpenAIStream(response);
840
+ }
841
+
842
+ let payload;
843
+ try {
844
+ payload = convertOpenAIResponseToAmpGeminiPayload(await response.json());
845
+ } catch {
846
+ return jsonResponse({ error: { message: "Provider returned invalid JSON for AMP Gemini response." } }, 502);
847
+ }
848
+
849
+ return new Response(JSON.stringify(payload), {
850
+ status: response.status,
851
+ headers: copyRouterDebugHeaders(response, {
852
+ "Content-Type": "application/json"
853
+ })
854
+ });
855
+ }
856
+
857
+ function buildGeminiModelRow(row) {
858
+ return {
859
+ name: `models/${row.id}`,
860
+ baseModelId: row.id,
861
+ displayName: row.id,
862
+ description: `${row.provider_name || row.provider_id || "provider"} / ${row.id}`,
863
+ supportedGenerationMethods: [...GEMINI_SUPPORTED_METHODS]
864
+ };
865
+ }
866
+
867
+ export function buildAmpGeminiModelsPayload(config) {
868
+ const seen = new Set();
869
+ const models = [];
870
+
871
+ for (const row of listConfiguredModels(config)) {
872
+ const id = String(row?.id || "").split("/").slice(1).join("/") || String(row?.id || "");
873
+ if (!id || seen.has(id)) continue;
874
+ seen.add(id);
875
+ models.push(buildGeminiModelRow({ ...row, id }));
876
+ }
877
+
878
+ return { models };
879
+ }
880
+
881
+ export function buildAmpGeminiModelPayload(config, modelId) {
882
+ const payload = buildAmpGeminiModelsPayload(config);
883
+ const requested = String(modelId || "").trim();
884
+ const target = payload.models.find((row) => row.name === `models/${requested}` || row.baseModelId === requested);
885
+ if (target) return target || null;
886
+ if (!requested) return null;
887
+
888
+ const resolved = resolveRequestModel(config, requested, FORMATS.OPENAI, {
889
+ clientType: "amp",
890
+ providerHint: "google"
891
+ });
892
+ if (!resolved?.primary) return null;
893
+
894
+ const targetModelId = String(resolved.primary.requestModelId || resolved.resolvedModel || "").split("/").slice(1).join("/")
895
+ || String(resolved.primary.requestModelId || resolved.resolvedModel || "");
896
+ const fallback = payload.models.find((row) => row.name === `models/${targetModelId}` || row.baseModelId === targetModelId);
897
+ if (!fallback) return null;
898
+
899
+ let reason = "route fallback";
900
+ if (resolved.routeType === "amp-default-model") reason = "defaultModel fallback";
901
+ if (resolved.routeType === "amp-subagent") {
902
+ const names = Array.isArray(resolved.routeMetadata?.amp?.subagents) ? resolved.routeMetadata.amp.subagents.join(", ") : "subagent";
903
+ reason = `subagent mapping (${names})`;
904
+ }
905
+
906
+ return {
907
+ ...fallback,
908
+ name: `models/${requested}` ,
909
+ baseModelId: requested,
910
+ displayName: requested,
911
+ description: `${fallback.description} (${reason} -> ${targetModelId})`
912
+ };
913
+ }