@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,308 @@
1
+ import { withCorsHeaders } from "./http.js";
2
+
3
+ const AMP_MODEL_FIELD_PATHS = [
4
+ ["model"],
5
+ ["message", "model"],
6
+ ["response", "model"],
7
+ ["modelVersion"],
8
+ ["response", "modelVersion"]
9
+ ];
10
+
11
+ function isPlainObject(value) {
12
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
13
+ }
14
+
15
+ function readAmpVisibleModel(requestBody) {
16
+ return typeof requestBody?.model === "string" ? requestBody.model.trim() : "";
17
+ }
18
+
19
+ function setPathIfPresent(payload, path, value) {
20
+ if (!isPlainObject(payload) || !Array.isArray(path) || path.length === 0) return false;
21
+ let cursor = payload;
22
+ for (let index = 0; index < path.length - 1; index += 1) {
23
+ if (!isPlainObject(cursor?.[path[index]])) return false;
24
+ cursor = cursor[path[index]];
25
+ }
26
+
27
+ const key = path[path.length - 1];
28
+ if (typeof cursor?.[key] !== "string") return false;
29
+ if (cursor[key] === value) return false;
30
+ cursor[key] = value;
31
+ return true;
32
+ }
33
+
34
+ function rewriteAmpModelFields(payload, visibleModel) {
35
+ if (!visibleModel) return false;
36
+ let changed = false;
37
+ for (const path of AMP_MODEL_FIELD_PATHS) {
38
+ changed = setPathIfPresent(payload, path, visibleModel) || changed;
39
+ }
40
+ return changed;
41
+ }
42
+
43
+ function stripClaudeThinkingBlocks(payload) {
44
+ if (!isPlainObject(payload) || !Array.isArray(payload.content)) return false;
45
+ const hasToolUse = payload.content.some((block) => block?.type === "tool_use");
46
+ if (!hasToolUse) return false;
47
+
48
+ const filtered = payload.content.filter(
49
+ (block) => block?.type !== "thinking" && block?.type !== "redacted_thinking"
50
+ );
51
+ if (filtered.length === payload.content.length) return false;
52
+ payload.content = filtered;
53
+ return true;
54
+ }
55
+
56
+ function normalizeClaudeToolUseStopReason(payload) {
57
+ if (!isPlainObject(payload) || !Array.isArray(payload.content)) return false;
58
+ const hasToolUse = payload.content.some((block) => block?.type === "tool_use");
59
+ if (!hasToolUse) return false;
60
+ if (payload.stop_reason === "tool_use") return false;
61
+ payload.stop_reason = "tool_use";
62
+ return true;
63
+ }
64
+
65
+ function rewriteAmpPayload(payload, visibleModel) {
66
+ if (!isPlainObject(payload)) return false;
67
+ let changed = false;
68
+ changed = rewriteAmpModelFields(payload, visibleModel) || changed;
69
+ changed = normalizeClaudeToolUseStopReason(payload) || changed;
70
+ changed = stripClaudeThinkingBlocks(payload) || changed;
71
+ return changed;
72
+ }
73
+
74
+ function rewriteAmpSseEvent(rawEvent, visibleModel) {
75
+ const lines = String(rawEvent || "").split(/\r?\n/);
76
+ let changed = false;
77
+ const nextLines = lines.map((line) => {
78
+ if (!line.startsWith("data:")) return line;
79
+ const value = line.slice(5).trimStart();
80
+ if (!value || value === "[DONE]") return line;
81
+
82
+ try {
83
+ const payload = JSON.parse(value);
84
+ if (!rewriteAmpPayload(payload, visibleModel)) return line;
85
+ changed = true;
86
+ return `data: ${JSON.stringify(payload)}`;
87
+ } catch {
88
+ return line;
89
+ }
90
+ });
91
+
92
+ return {
93
+ changed,
94
+ event: `${nextLines.join("\n")}\n\n`
95
+ };
96
+ }
97
+
98
+ const AMP_STREAM_SUPPRESSED_BLOCK_TYPES = new Set([
99
+ "thinking",
100
+ "redacted_thinking"
101
+ ]);
102
+
103
+ function parseSseEventBlock(rawEvent) {
104
+ const lines = String(rawEvent || "").split(/\r?\n/);
105
+ let eventName = "";
106
+ const dataLines = [];
107
+
108
+ for (const line of lines) {
109
+ if (!line) continue;
110
+ if (line.startsWith("event:")) {
111
+ eventName = line.slice(6).trim();
112
+ continue;
113
+ }
114
+ if (line.startsWith("data:")) {
115
+ dataLines.push(line.slice(5).trimStart());
116
+ }
117
+ }
118
+
119
+ return {
120
+ eventName,
121
+ dataText: dataLines.join("\n").trim()
122
+ };
123
+ }
124
+
125
+ function formatSseEventBlock(eventName, payload) {
126
+ const lines = [];
127
+ if (eventName) {
128
+ lines.push(`event: ${eventName}`);
129
+ }
130
+ lines.push(`data: ${JSON.stringify(payload)}`);
131
+ return `${lines.join("\n")}\n\n`;
132
+ }
133
+
134
+ function rewriteAmpClaudeStreamPayload(state, payload, visibleModel) {
135
+ let changed = rewriteAmpPayload(payload, visibleModel);
136
+ const type = String(payload?.type || "").trim();
137
+
138
+ if (type === "content_block_start") {
139
+ const originalIndex = Number(payload.index);
140
+ const blockType = String(payload?.content_block?.type || "").trim();
141
+ if (Number.isFinite(originalIndex) && AMP_STREAM_SUPPRESSED_BLOCK_TYPES.has(blockType)) {
142
+ state.suppressedIndexes.add(originalIndex);
143
+ return {
144
+ changed: false,
145
+ suppressed: true,
146
+ payload
147
+ };
148
+ }
149
+
150
+ if (Number.isFinite(originalIndex)) {
151
+ const visibleIndex = state.nextVisibleIndex;
152
+ state.nextVisibleIndex += 1;
153
+ state.visibleIndexByOriginal.set(originalIndex, visibleIndex);
154
+ if (visibleIndex !== originalIndex) {
155
+ payload.index = visibleIndex;
156
+ changed = true;
157
+ }
158
+ }
159
+
160
+ return {
161
+ changed,
162
+ suppressed: false,
163
+ payload
164
+ };
165
+ }
166
+
167
+ if (type === "content_block_delta" || type === "content_block_stop") {
168
+ const originalIndex = Number(payload.index);
169
+ if (Number.isFinite(originalIndex) && state.suppressedIndexes.has(originalIndex)) {
170
+ return {
171
+ changed: false,
172
+ suppressed: true,
173
+ payload
174
+ };
175
+ }
176
+
177
+ const visibleIndex = state.visibleIndexByOriginal.get(originalIndex);
178
+ if (Number.isFinite(visibleIndex) && visibleIndex !== originalIndex) {
179
+ payload.index = visibleIndex;
180
+ changed = true;
181
+ }
182
+
183
+ return {
184
+ changed,
185
+ suppressed: false,
186
+ payload
187
+ };
188
+ }
189
+
190
+ return {
191
+ changed,
192
+ suppressed: false,
193
+ payload
194
+ };
195
+ }
196
+
197
+ function rewriteAmpStreamResponse(response, visibleModel) {
198
+ if (!(response instanceof Response) || !response.body || !visibleModel) return response;
199
+
200
+ const decoder = new TextDecoder();
201
+ const encoder = new TextEncoder();
202
+ let buffer = "";
203
+ const claudeStreamState = {
204
+ suppressedIndexes: new Set(),
205
+ visibleIndexByOriginal: new Map(),
206
+ nextVisibleIndex: 0
207
+ };
208
+
209
+ const stream = new ReadableStream({
210
+ async start(controller) {
211
+ const reader = response.body.getReader();
212
+
213
+ const emitEvent = (rawEvent) => {
214
+ if (!rawEvent) return;
215
+ const parsedEvent = parseSseEventBlock(rawEvent);
216
+ if (!parsedEvent.dataText || parsedEvent.dataText === "[DONE]") {
217
+ const rewritten = rewriteAmpSseEvent(rawEvent, visibleModel);
218
+ controller.enqueue(encoder.encode(rewritten.event));
219
+ return;
220
+ }
221
+
222
+ try {
223
+ const payload = JSON.parse(parsedEvent.dataText);
224
+ const rewritten = rewriteAmpClaudeStreamPayload(claudeStreamState, payload, visibleModel);
225
+ if (rewritten.suppressed) return;
226
+ controller.enqueue(encoder.encode(formatSseEventBlock(parsedEvent.eventName, rewritten.payload)));
227
+ } catch {
228
+ const rewritten = rewriteAmpSseEvent(rawEvent, visibleModel);
229
+ controller.enqueue(encoder.encode(rewritten.event));
230
+ }
231
+ };
232
+
233
+ try {
234
+ while (true) {
235
+ const { done, value } = await reader.read();
236
+ if (done) break;
237
+ buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
238
+
239
+ while (true) {
240
+ const separatorIndex = buffer.indexOf("\n\n");
241
+ if (separatorIndex < 0) break;
242
+ const rawEvent = buffer.slice(0, separatorIndex);
243
+ buffer = buffer.slice(separatorIndex + 2);
244
+ emitEvent(rawEvent);
245
+ }
246
+ }
247
+
248
+ buffer += decoder.decode();
249
+ if (buffer.trim()) emitEvent(buffer);
250
+ controller.close();
251
+ } catch (error) {
252
+ controller.error(error);
253
+ } finally {
254
+ try {
255
+ reader.releaseLock();
256
+ } catch {
257
+ // Ignore reader cleanup errors.
258
+ }
259
+ }
260
+ }
261
+ });
262
+
263
+ const headers = new Headers(response.headers);
264
+ headers.delete("content-length");
265
+
266
+ return new Response(stream, {
267
+ status: response.status,
268
+ statusText: response.statusText,
269
+ headers
270
+ });
271
+ }
272
+
273
+ export async function maybeRewriteAmpClientResponse(response, { clientType, requestBody, stream = false } = {}) {
274
+ if (clientType !== "amp" || !(response instanceof Response)) return response;
275
+
276
+ const visibleModel = readAmpVisibleModel(requestBody);
277
+ if (!visibleModel) return response;
278
+
279
+ const contentType = String(response.headers.get("content-type") || "").toLowerCase();
280
+ if (stream || contentType.includes("text/event-stream")) {
281
+ return rewriteAmpStreamResponse(response, visibleModel);
282
+ }
283
+
284
+ if (!contentType.includes("application/json") && !contentType.includes("+json")) {
285
+ return response;
286
+ }
287
+
288
+ let payload;
289
+ try {
290
+ payload = await response.clone().json();
291
+ } catch {
292
+ return response;
293
+ }
294
+
295
+ if (!rewriteAmpPayload(payload, visibleModel)) {
296
+ return response;
297
+ }
298
+
299
+ const headers = new Headers(response.headers);
300
+ headers.set("content-type", headers.get("content-type") || "application/json");
301
+ headers.delete("content-length");
302
+
303
+ return new Response(JSON.stringify(payload), {
304
+ status: response.status,
305
+ statusText: response.statusText,
306
+ headers: new Headers(withCorsHeaders(Object.fromEntries(headers.entries())))
307
+ });
308
+ }
@@ -0,0 +1,290 @@
1
+ import { jsonResponse, passthroughResponseWithCors } from "./http.js";
2
+
3
+ const AMP_PROXY_SCRUBBED_HEADERS = [
4
+ "authorization",
5
+ "x-api-key",
6
+ "x-goog-api-key",
7
+ "host",
8
+ "content-length",
9
+ "accept-encoding",
10
+ "proxy-authorization",
11
+ "proxy-authenticate",
12
+ "forwarded",
13
+ "via",
14
+ "x-forwarded-for",
15
+ "x-forwarded-host",
16
+ "x-forwarded-proto",
17
+ "x-forwarded-port",
18
+ "cf-connecting-ip",
19
+ "true-client-ip",
20
+ "x-real-ip",
21
+ "x-client-ip",
22
+ "cf-ray",
23
+ "cf-ipcountry",
24
+ "x-client-data",
25
+ "sec-ch-ua",
26
+ "sec-ch-ua-mobile",
27
+ "sec-ch-ua-platform",
28
+ "sec-fetch-site",
29
+ "sec-fetch-mode",
30
+ "sec-fetch-user",
31
+ "sec-fetch-dest"
32
+ ];
33
+
34
+ function isPlainObject(value) {
35
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
36
+ }
37
+
38
+ function isLoopbackAddress(value) {
39
+ const ip = String(value || "").trim().toLowerCase();
40
+ if (!ip) return false;
41
+ if (["127.0.0.1", "::1", "::ffff:127.0.0.1", "localhost"].includes(ip)) return true;
42
+ return ip.startsWith("127.");
43
+ }
44
+
45
+ function readClientIp(request) {
46
+ const direct = request.headers.get("x-real-ip") || request.headers.get("cf-connecting-ip");
47
+ if (direct) return String(direct).trim();
48
+ const forwardedFor = String(request.headers.get("x-forwarded-for") || "").trim();
49
+ if (!forwardedFor) return "";
50
+ const firstHop = forwardedFor.split(",")[0]?.trim();
51
+ return firstHop || "";
52
+ }
53
+
54
+ export function isAmpProxyEnabled(config) {
55
+ return Boolean(String(config?.amp?.upstreamUrl || "").trim());
56
+ }
57
+
58
+ export function isAmpManagementAllowed(request, config) {
59
+ if (config?.amp?.restrictManagementToLocalhost !== true) return true;
60
+ return isLoopbackAddress(readClientIp(request));
61
+ }
62
+
63
+ function readClientAuthToken(request) {
64
+ const authHeader = request.headers.get("authorization");
65
+ if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7).trim();
66
+ if (authHeader) return authHeader.trim();
67
+
68
+ for (const headerName of ["x-api-key", "x-goog-api-key"]) {
69
+ const headerValue = request.headers.get(headerName);
70
+ if (headerValue) return String(headerValue).trim();
71
+ }
72
+
73
+ try {
74
+ const requestUrl = new URL(request.url);
75
+ for (const key of ["key", "api_key", "auth_token"]) {
76
+ const value = requestUrl.searchParams.get(key);
77
+ if (value) return value.trim();
78
+ }
79
+ } catch {
80
+ // Ignore malformed request URLs and continue with empty token.
81
+ }
82
+
83
+ return "";
84
+ }
85
+
86
+ function removeQueryValuesMatching(url, key, match) {
87
+ if (!(url instanceof URL) || !match) return;
88
+ const values = url.searchParams.getAll(key);
89
+ if (values.length === 0) return;
90
+
91
+ url.searchParams.delete(key);
92
+ for (const value of values) {
93
+ if (value === match) continue;
94
+ url.searchParams.append(key, value);
95
+ }
96
+ }
97
+
98
+ function stripClientCredentialsFromAmpUpstreamUrl(upstreamUrl, request) {
99
+ const clientToken = readClientAuthToken(request);
100
+ if (!clientToken) return;
101
+
102
+ for (const key of ["key", "api_key", "auth_token"]) {
103
+ removeQueryValuesMatching(upstreamUrl, key, clientToken);
104
+ }
105
+ }
106
+
107
+ function buildAmpProxyHeaders(request, config) {
108
+ const headers = new Headers(request.headers);
109
+ const upstreamApiKey = String(config?.amp?.upstreamApiKey || "").trim();
110
+
111
+ for (const name of AMP_PROXY_SCRUBBED_HEADERS) {
112
+ headers.delete(name);
113
+ }
114
+
115
+ if (upstreamApiKey) {
116
+ headers.set("authorization", `Bearer ${upstreamApiKey}`);
117
+ headers.set("x-api-key", upstreamApiKey);
118
+ }
119
+
120
+ return headers;
121
+ }
122
+
123
+ function normalizeAmpUserPayload(payload) {
124
+ if (!isPlainObject(payload)) return null;
125
+ return {
126
+ ...payload,
127
+ freeTierEligibleIfWorkspaceAllows: true,
128
+ dailyGrantEnabledIfWorkspaceAllows: false
129
+ };
130
+ }
131
+
132
+ function normalizeAmpInternalPayload(requestUrl, payload) {
133
+ if (!isPlainObject(payload)) return null;
134
+
135
+ if (requestUrl.searchParams.has("getUserFreeTierStatus")) {
136
+ return {
137
+ ...payload,
138
+ result: {
139
+ ...(isPlainObject(payload.result) ? payload.result : {}),
140
+ canUseAmpFree: true,
141
+ isDailyGrantEnabled: false
142
+ }
143
+ };
144
+ }
145
+
146
+ if (requestUrl.searchParams.has("getUserInfo")) {
147
+ return {
148
+ ...payload,
149
+ result: normalizeAmpUserPayload(payload.result) || payload.result
150
+ };
151
+ }
152
+
153
+ return null;
154
+ }
155
+
156
+ function normalizeAmpManagementPayload(requestUrl, payload) {
157
+ if (requestUrl.pathname === "/api/user") {
158
+ return normalizeAmpUserPayload(payload);
159
+ }
160
+
161
+ if (requestUrl.pathname === "/api/internal") {
162
+ return normalizeAmpInternalPayload(requestUrl, payload);
163
+ }
164
+
165
+ return null;
166
+ }
167
+
168
+ function shouldNormalizeAmpManagementResponse(requestUrl) {
169
+ if (requestUrl.pathname === "/api/user") return true;
170
+ if (requestUrl.pathname !== "/api/internal") return false;
171
+ return requestUrl.searchParams.has("getUserFreeTierStatus") || requestUrl.searchParams.has("getUserInfo");
172
+ }
173
+
174
+ function isJsonLikeResponse(response) {
175
+ const contentType = String(response?.headers?.get("content-type") || "").toLowerCase();
176
+ return contentType.includes("application/json") || contentType.includes("+json");
177
+ }
178
+
179
+ function isAmpStreamingResponse(response) {
180
+ const contentType = String(response?.headers?.get("content-type") || "").toLowerCase();
181
+ return contentType.includes("text/event-stream");
182
+ }
183
+
184
+ async function gunzipBytes(bytes) {
185
+ if (typeof DecompressionStream !== "function") return null;
186
+ try {
187
+ const gzipStream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
188
+ const decompressed = await new Response(gzipStream).arrayBuffer();
189
+ return new Uint8Array(decompressed);
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+
195
+ async function maybeDecompressAmpProxyResponse(response) {
196
+ if (!(response instanceof Response) || !response.ok) return response;
197
+ if (response.headers.get("content-encoding")) return response;
198
+ if (isAmpStreamingResponse(response)) return response;
199
+
200
+ let bytes;
201
+ try {
202
+ bytes = new Uint8Array(await response.clone().arrayBuffer());
203
+ } catch {
204
+ return response;
205
+ }
206
+
207
+ if (bytes.length < 2 || bytes[0] !== 0x1f || bytes[1] !== 0x8b) {
208
+ return response;
209
+ }
210
+
211
+ const decompressed = await gunzipBytes(bytes);
212
+ if (!(decompressed instanceof Uint8Array)) return response;
213
+
214
+ const headers = new Headers(response.headers);
215
+ headers.delete("content-encoding");
216
+ headers.set("content-length", String(decompressed.byteLength));
217
+
218
+ return new Response(decompressed, {
219
+ status: response.status,
220
+ statusText: response.statusText,
221
+ headers
222
+ });
223
+ }
224
+
225
+ async function maybeNormalizeAmpManagementResponse(requestUrl, response) {
226
+ if (!shouldNormalizeAmpManagementResponse(requestUrl) || !response?.ok || !isJsonLikeResponse(response)) {
227
+ return null;
228
+ }
229
+
230
+ try {
231
+ const payload = await response.clone().json();
232
+ const normalizedPayload = normalizeAmpManagementPayload(requestUrl, payload);
233
+ if (!normalizedPayload) return null;
234
+ return passthroughResponseWithCors(new Response(JSON.stringify(normalizedPayload), {
235
+ status: response.status,
236
+ statusText: response.statusText,
237
+ headers: response.headers
238
+ }));
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ export async function proxyAmpUpstreamRequest({ request, config, bodyOverride } = {}) {
245
+ const upstreamBase = String(config?.amp?.upstreamUrl || "").trim();
246
+ if (!upstreamBase) {
247
+ return jsonResponse({
248
+ type: "error",
249
+ error: {
250
+ type: "configuration_error",
251
+ message: "AMP upstream proxy is not configured. Set config.amp.upstreamUrl to enable AMP management and fallback proxying."
252
+ }
253
+ }, 503);
254
+ }
255
+
256
+ const requestUrl = new URL(request.url);
257
+ const upstreamUrl = new URL(`${requestUrl.pathname}${requestUrl.search}`, upstreamBase);
258
+ stripClientCredentialsFromAmpUpstreamUrl(upstreamUrl, request);
259
+ const headers = buildAmpProxyHeaders(request, config);
260
+ const init = {
261
+ method: request.method,
262
+ headers,
263
+ redirect: "manual"
264
+ };
265
+
266
+ if (request.method !== "GET" && request.method !== "HEAD") {
267
+ if (bodyOverride !== undefined) {
268
+ init.body = bodyOverride;
269
+ } else {
270
+ const rawBody = await request.arrayBuffer();
271
+ if (rawBody.byteLength > 0) init.body = rawBody;
272
+ }
273
+ }
274
+
275
+ try {
276
+ const response = await fetch(upstreamUrl, init);
277
+ const preparedResponse = await maybeDecompressAmpProxyResponse(response);
278
+ const normalizedResponse = await maybeNormalizeAmpManagementResponse(requestUrl, preparedResponse);
279
+ if (normalizedResponse) return normalizedResponse;
280
+ return passthroughResponseWithCors(preparedResponse);
281
+ } catch (error) {
282
+ return jsonResponse({
283
+ type: "error",
284
+ error: {
285
+ type: "api_error",
286
+ message: `AMP upstream proxy failed: ${error instanceof Error ? error.message : String(error)}`
287
+ }
288
+ }, 503);
289
+ }
290
+ }
@@ -2,8 +2,23 @@ function parseAuthToken(request) {
2
2
  const authHeader = request.headers.get("Authorization");
3
3
  if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7).trim();
4
4
  if (authHeader && !authHeader.startsWith("Bearer ")) return authHeader.trim();
5
- const apiKey = request.headers.get("x-api-key");
6
- return apiKey ? apiKey.trim() : "";
5
+
6
+ for (const headerName of ["x-api-key", "x-goog-api-key"]) {
7
+ const headerValue = request.headers.get(headerName);
8
+ if (headerValue) return headerValue.trim();
9
+ }
10
+
11
+ try {
12
+ const url = new URL(request.url);
13
+ for (const key of ["key", "api_key", "auth_token"]) {
14
+ const value = url.searchParams.get(key);
15
+ if (value) return value.trim();
16
+ }
17
+ } catch {
18
+ // Ignore malformed request URLs and continue with empty token.
19
+ }
20
+
21
+ return "";
7
22
  }
8
23
 
9
24
  function timingSafeStringEqual(left, right) {