@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
@@ -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
- const resolved = resolveRequestModel(config, requestedModel, sourceFormat);
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: formatFiltered.eligible,
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
- body,
259
- sourceFormat,
260
- stream,
261
- candidate,
262
- requestHeaders: request.headers,
263
- env
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
+ }
@@ -62,7 +62,9 @@ export function initState(sourceFormat) {
62
62
  finishReason: null,
63
63
  usage: null,
64
64
  messageStartSent: false,
65
- textBlockClosed: false
65
+ textBlockClosed: false,
66
+ messageDeltaSent: false,
67
+ messageStopSent: false
66
68
  };
67
69
  }
68
70