@khanglvm/llm-router 2.0.0-beta.1 → 2.0.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 (39) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +163 -426
  3. package/package.json +3 -3
  4. package/src/cli/router-module.js +2773 -2587
  5. package/src/cli-entry.js +32 -103
  6. package/src/node/activity-log.js +119 -0
  7. package/src/node/coding-tool-config.js +85 -11
  8. package/src/node/config-workflows.js +51 -12
  9. package/src/node/instance-state.js +1 -1
  10. package/src/node/litellm-context-catalog.js +184 -0
  11. package/src/node/local-server.js +23 -3
  12. package/src/node/port-reclaim.js +2 -2
  13. package/src/node/start-command.js +22 -22
  14. package/src/node/startup-manager.js +3 -3
  15. package/src/node/web-command.js +1 -1
  16. package/src/node/web-console-assets.js +1 -1
  17. package/src/node/web-console-client.js +34 -29
  18. package/src/node/web-console-server.js +420 -38
  19. package/src/node/web-console-styles.generated.js +1 -1
  20. package/src/node/web-console-ui/buffered-text-input.js +133 -0
  21. package/src/node/web-console-ui/config-editor-utils.js +57 -4
  22. package/src/node/web-console-ui/dropdown-placement.js +153 -0
  23. package/src/node/web-console-ui/select-search-utils.js +6 -0
  24. package/src/node/web-console-ui/transient-integer-input-utils.js +12 -0
  25. package/src/runtime/balancer.js +78 -1
  26. package/src/runtime/codex-request-transformer.js +16 -7
  27. package/src/runtime/config.js +448 -12
  28. package/src/runtime/handler/amp-response.js +5 -3
  29. package/src/runtime/handler/amp-web-search.js +2232 -0
  30. package/src/runtime/handler/fallback.js +30 -2
  31. package/src/runtime/handler/provider-call.js +353 -36
  32. package/src/runtime/handler/provider-translation.js +14 -0
  33. package/src/runtime/handler/request.js +128 -2
  34. package/src/runtime/handler/route-debug.js +36 -0
  35. package/src/runtime/handler.js +210 -20
  36. package/src/runtime/subscription-provider.js +1 -1
  37. package/src/shared/coding-tool-bindings.js +49 -0
  38. package/src/shared/local-router-defaults.js +62 -0
  39. package/src/translator/request/claude-to-openai.js +43 -0
@@ -181,12 +181,18 @@ function normalizeAmpModelMappingEntry(entry) {
181
181
  entry.ref ??
182
182
  ""
183
183
  ).trim();
184
+ const sourceRouteKey = normalizeAmpRouteKey(
185
+ entry.sourceRouteKey ??
186
+ entry["source-route-key"] ??
187
+ ""
188
+ );
184
189
 
185
- if (!from || !to) return null;
190
+ if (!from || (!to && !sourceRouteKey)) return null;
186
191
 
187
192
  return {
188
193
  from,
189
- to
194
+ ...(to ? { to } : {}),
195
+ ...(sourceRouteKey ? { sourceRouteKey } : {})
190
196
  };
191
197
  }
192
198
 
@@ -473,6 +479,289 @@ function normalizeAmpSubagentDefinitions(rawDefinitions) {
473
479
  return out;
474
480
  }
475
481
 
482
+ const AMP_WEB_SEARCH_PROVIDER_IDS = Object.freeze([
483
+ "brave",
484
+ "tavily",
485
+ "exa",
486
+ "searxng"
487
+ ]);
488
+
489
+ const AMP_WEB_SEARCH_PROVIDER_DEFAULT_LIMITS = Object.freeze({
490
+ brave: 1000,
491
+ tavily: 1000,
492
+ exa: 1000,
493
+ searxng: 0
494
+ });
495
+ const AMP_WEB_SEARCH_DEFAULT_COUNT = 5;
496
+ const AMP_WEB_SEARCH_MIN_COUNT = 1;
497
+ const AMP_WEB_SEARCH_MAX_COUNT = 20;
498
+
499
+ function looksLikeHostedWebSearchRouteId(value) {
500
+ const text = String(value || "").trim();
501
+ return text.includes("/") && parseRouteReference(text).type === "direct";
502
+ }
503
+
504
+ function buildHostedWebSearchRouteId(providerId, modelId) {
505
+ const normalizedProviderId = String(providerId || "").trim();
506
+ const normalizedModelId = String(modelId || "").trim();
507
+ if (!normalizedProviderId || !normalizedModelId) return "";
508
+ return `${normalizedProviderId}/${normalizedModelId}`;
509
+ }
510
+
511
+ function normalizeHostedWebSearchProviderRoute(entry, explicitId = "") {
512
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
513
+
514
+ const explicitRouteId = String(explicitId || "").trim();
515
+ const providerId = String(
516
+ entry.providerId
517
+ ?? entry.provider
518
+ ?? entry.routeProviderId
519
+ ?? entry["provider-id"]
520
+ ?? ""
521
+ ).trim();
522
+ const modelId = String(
523
+ entry.model
524
+ ?? entry.modelId
525
+ ?? entry.routeModelId
526
+ ?? entry["model-id"]
527
+ ?? ""
528
+ ).trim();
529
+ const routeId = String(
530
+ explicitRouteId
531
+ || entry.id
532
+ || buildHostedWebSearchRouteId(providerId, modelId)
533
+ ).trim();
534
+ if (!looksLikeHostedWebSearchRouteId(routeId)) return null;
535
+
536
+ const parsed = parseRouteReference(routeId);
537
+ if (parsed.type !== "direct") return null;
538
+ const normalizedProviderId = providerId || parsed.providerId;
539
+ const normalizedModelId = modelId || parsed.modelId;
540
+ const normalizedRouteId = buildHostedWebSearchRouteId(normalizedProviderId, normalizedModelId);
541
+ if (!normalizedRouteId || normalizedRouteId !== routeId) return null;
542
+
543
+ return {
544
+ id: normalizedRouteId,
545
+ providerId: normalizedProviderId,
546
+ model: normalizedModelId
547
+ };
548
+ }
549
+
550
+ function normalizeAmpWebSearchProviderId(value) {
551
+ const normalized = String(value || "").trim().toLowerCase();
552
+ return AMP_WEB_SEARCH_PROVIDER_IDS.includes(normalized) ? normalized : "";
553
+ }
554
+
555
+ function normalizeAmpWebSearchStrategy(value) {
556
+ const normalized = String(value || "").trim().toLowerCase();
557
+ if (!normalized) return "ordered";
558
+ if (normalized === "quota-balance" || normalized === "quota-balanced") return "quota-balance";
559
+ if (normalized === "quota-aware-weighted-rr") return "quota-balance";
560
+ return "ordered";
561
+ }
562
+
563
+ function parseNonNegativeInteger(value, fallback = 0) {
564
+ const parsed = Number(value);
565
+ if (!Number.isFinite(parsed) || parsed < 0) return fallback;
566
+ return Math.floor(parsed);
567
+ }
568
+
569
+ function parseAmpWebSearchCount(value, fallback = AMP_WEB_SEARCH_DEFAULT_COUNT) {
570
+ const parsed = Number(value);
571
+ if (!Number.isFinite(parsed)) return fallback;
572
+ return Math.min(AMP_WEB_SEARCH_MAX_COUNT, Math.max(AMP_WEB_SEARCH_MIN_COUNT, Math.floor(parsed)));
573
+ }
574
+
575
+ function hasOwnWebSearchProviderField(entry, keys = []) {
576
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
577
+ return keys.some((key) => Object.prototype.hasOwnProperty.call(entry, key) && entry[key] !== undefined && entry[key] !== null && String(entry[key]).trim() !== "");
578
+ }
579
+
580
+ function normalizeAmpWebSearchProviderEntry(entry, explicitId = "", { preserveUnconfigured = true, inheritedCount = AMP_WEB_SEARCH_DEFAULT_COUNT } = {}) {
581
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
582
+
583
+ const hostedRoute = normalizeHostedWebSearchProviderRoute(entry, explicitId);
584
+ if (hostedRoute) return hostedRoute;
585
+
586
+ const id = normalizeAmpWebSearchProviderId(
587
+ explicitId
588
+ || entry.id
589
+ || entry.provider
590
+ || entry.backend
591
+ || entry.name
592
+ );
593
+ if (!id) return null;
594
+
595
+ const apiKey = String(
596
+ entry.apiKey
597
+ ?? entry["api-key"]
598
+ ?? entry.key
599
+ ?? ""
600
+ ).trim();
601
+ const url = sanitizeEndpointUrl(
602
+ entry.url
603
+ ?? entry.baseUrl
604
+ ?? entry["base-url"]
605
+ ?? entry.searxngUrl
606
+ ?? entry["searxng-url"]
607
+ ?? ""
608
+ ).replace(/\/+$/, "");
609
+ const hasExplicitCount = hasOwnWebSearchProviderField(entry, ["count", "resultCount", "result-count", "resultsPerCall", "results-per-call"]);
610
+ const hasExplicitLimit = hasOwnWebSearchProviderField(entry, ["limit", "monthlyLimit", "monthly-limit", "quota"]);
611
+ const hasExplicitRemaining = hasOwnWebSearchProviderField(entry, ["remaining", "remainingQuota", "remaining-quota", "remainingQueries", "remaining-queries"]);
612
+ const hasCredential = id === "searxng" ? Boolean(url) : Boolean(apiKey);
613
+ if (!preserveUnconfigured && !hasCredential && !hasExplicitCount && !hasExplicitLimit && !hasExplicitRemaining) {
614
+ return null;
615
+ }
616
+
617
+ const count = parseAmpWebSearchCount(
618
+ entry.count
619
+ ?? entry.resultCount
620
+ ?? entry["result-count"]
621
+ ?? entry.resultsPerCall
622
+ ?? entry["results-per-call"],
623
+ inheritedCount
624
+ );
625
+ const includeQuotaDefaults = hasCredential || hasExplicitLimit || hasExplicitRemaining;
626
+ const limitFallback = includeQuotaDefaults ? (AMP_WEB_SEARCH_PROVIDER_DEFAULT_LIMITS[id] || 0) : 0;
627
+ const limit = parseNonNegativeInteger(
628
+ entry.limit
629
+ ?? entry.monthlyLimit
630
+ ?? entry["monthly-limit"]
631
+ ?? entry.quota
632
+ ?? limitFallback,
633
+ limitFallback
634
+ );
635
+ const remainingFallback = limit > 0 ? limit : 0;
636
+ const remaining = parseNonNegativeInteger(
637
+ entry.remaining
638
+ ?? entry.remainingQuota
639
+ ?? entry["remaining-quota"]
640
+ ?? entry.remainingQueries
641
+ ?? entry["remaining-queries"]
642
+ ?? remainingFallback,
643
+ remainingFallback
644
+ );
645
+
646
+ const normalizedEntry = {
647
+ id,
648
+ ...(id === "searxng"
649
+ ? (url ? { url } : {})
650
+ : (apiKey ? { apiKey } : {})),
651
+ ...(count !== AMP_WEB_SEARCH_DEFAULT_COUNT ? { count } : {}),
652
+ ...(hasExplicitLimit || (includeQuotaDefaults && limit > 0) ? { limit } : {}),
653
+ ...(hasExplicitRemaining || (includeQuotaDefaults && (limit > 0 || remaining > 0))
654
+ ? { remaining: limit > 0 ? Math.min(remaining, limit) : remaining }
655
+ : {})
656
+ };
657
+
658
+ return normalizedEntry;
659
+ }
660
+
661
+ function normalizeAmpWebSearchProviders(rawProviders, rawWebSearch = {}) {
662
+ const inheritedCount = parseAmpWebSearchCount(rawWebSearch.count, AMP_WEB_SEARCH_DEFAULT_COUNT);
663
+ if (Array.isArray(rawProviders)) {
664
+ const ordered = [];
665
+ const seen = new Set();
666
+ for (const entry of rawProviders) {
667
+ const normalized = normalizeAmpWebSearchProviderEntry(entry, "", {
668
+ preserveUnconfigured: true,
669
+ inheritedCount
670
+ });
671
+ if (!normalized || seen.has(normalized.id)) continue;
672
+ seen.add(normalized.id);
673
+ ordered.push(normalized);
674
+ }
675
+ return ordered;
676
+ }
677
+
678
+ if (rawProviders && typeof rawProviders === "object") {
679
+ const ordered = [];
680
+ const seen = new Set();
681
+ for (const [providerId, entry] of Object.entries(rawProviders)) {
682
+ const normalized = normalizeAmpWebSearchProviderEntry(entry, providerId, {
683
+ preserveUnconfigured: true,
684
+ inheritedCount
685
+ });
686
+ if (!normalized || seen.has(normalized.id)) continue;
687
+ seen.add(normalized.id);
688
+ ordered.push(normalized);
689
+ }
690
+ return ordered;
691
+ }
692
+
693
+ const legacyPreferredBackend = normalizeAmpWebSearchProviderId(
694
+ rawWebSearch.preferredBackend ?? rawWebSearch["preferred-backend"]
695
+ );
696
+ const legacyEntries = [
697
+ normalizeAmpWebSearchProviderEntry({
698
+ apiKey: rawWebSearch.braveApiKey ?? rawWebSearch["brave-api-key"],
699
+ count: rawWebSearch.count,
700
+ limit: rawWebSearch.braveMonthlyLimit ?? rawWebSearch["brave-monthly-limit"],
701
+ remaining: rawWebSearch.braveRemaining ?? rawWebSearch["brave-remaining"]
702
+ }, "brave", { preserveUnconfigured: false, inheritedCount }),
703
+ normalizeAmpWebSearchProviderEntry({
704
+ apiKey: rawWebSearch.tavilyApiKey ?? rawWebSearch["tavily-api-key"],
705
+ count: rawWebSearch.count,
706
+ limit: rawWebSearch.tavilyMonthlyLimit ?? rawWebSearch["tavily-monthly-limit"],
707
+ remaining: rawWebSearch.tavilyRemaining ?? rawWebSearch["tavily-remaining"]
708
+ }, "tavily", { preserveUnconfigured: false, inheritedCount }),
709
+ normalizeAmpWebSearchProviderEntry({
710
+ apiKey: rawWebSearch.exaApiKey ?? rawWebSearch["exa-api-key"],
711
+ count: rawWebSearch.count,
712
+ limit: rawWebSearch.exaMonthlyLimit ?? rawWebSearch["exa-monthly-limit"],
713
+ remaining: rawWebSearch.exaRemaining ?? rawWebSearch["exa-remaining"]
714
+ }, "exa", { preserveUnconfigured: false, inheritedCount }),
715
+ normalizeAmpWebSearchProviderEntry({
716
+ count: rawWebSearch.count,
717
+ url: rawWebSearch.searxngUrl ?? rawWebSearch["searxng-url"] ?? rawWebSearch.url
718
+ }, "searxng", { preserveUnconfigured: false, inheritedCount })
719
+ ].filter(Boolean);
720
+
721
+ if (!legacyPreferredBackend) return legacyEntries;
722
+
723
+ return [
724
+ ...legacyEntries.filter((entry) => entry.id === legacyPreferredBackend),
725
+ ...legacyEntries.filter((entry) => entry.id !== legacyPreferredBackend)
726
+ ];
727
+ }
728
+
729
+ function normalizeAmpWebSearchConfig(rawWebSearch) {
730
+ if (!rawWebSearch || typeof rawWebSearch !== "object" || Array.isArray(rawWebSearch)) return undefined;
731
+
732
+ const providers = normalizeAmpWebSearchProviders(rawWebSearch.providers, rawWebSearch);
733
+ const count = parseAmpWebSearchCount(rawWebSearch.count, AMP_WEB_SEARCH_DEFAULT_COUNT);
734
+
735
+ return {
736
+ strategy: normalizeAmpWebSearchStrategy(rawWebSearch.strategy),
737
+ count,
738
+ providers
739
+ };
740
+ }
741
+
742
+ function supportsOpenAIHostedWebSearchRoute(provider, model) {
743
+ const providerFormats = dedupeStrings([...(provider?.formats || []), provider?.format]);
744
+ if (!providerFormats.includes(FORMATS.OPENAI)) return false;
745
+
746
+ const modelId = String(model?.id || "").trim();
747
+ const preferredFormat = modelId
748
+ ? String(provider?.lastProbe?.modelPreferredFormat?.[modelId] || "").trim()
749
+ : "";
750
+ if (preferredFormat) {
751
+ return preferredFormat === FORMATS.OPENAI;
752
+ }
753
+
754
+ const probedFormats = modelId
755
+ ? dedupeStrings(provider?.lastProbe?.modelSupport?.[modelId] || [])
756
+ : [];
757
+ if (probedFormats.length > 0) {
758
+ return probedFormats.includes(FORMATS.OPENAI);
759
+ }
760
+
761
+ const modelFormats = dedupeStrings([...(model?.formats || []), model?.format]);
762
+ return modelFormats.length === 0 || modelFormats.includes(FORMATS.OPENAI);
763
+ }
764
+
476
765
  function normalizeAmpConfig(rawAmp) {
477
766
  const source = rawAmp && typeof rawAmp === "object" && !Array.isArray(rawAmp)
478
767
  ? rawAmp
@@ -484,6 +773,33 @@ function normalizeAmpConfig(rawAmp) {
484
773
  const hasRawModelRoutes = hasOwn(source, "rawModelRoutes") || hasOwn(source, "raw-model-routes");
485
774
  const hasOverrides = hasOwn(source, "overrides");
486
775
  const hasFallback = hasOwn(source, "fallback");
776
+ const hasWebSearch = hasOwn(source, "webSearch")
777
+ || hasOwn(source, "web-search")
778
+ || [
779
+ "preferredBackend",
780
+ "preferred-backend",
781
+ "count",
782
+ "braveApiKey",
783
+ "brave-api-key",
784
+ "tavilyApiKey",
785
+ "tavily-api-key",
786
+ "exaApiKey",
787
+ "exa-api-key",
788
+ "searxngUrl",
789
+ "searxng-url",
790
+ "braveMonthlyLimit",
791
+ "brave-monthly-limit",
792
+ "tavilyMonthlyLimit",
793
+ "tavily-monthly-limit",
794
+ "exaMonthlyLimit",
795
+ "exa-monthly-limit",
796
+ "braveRemaining",
797
+ "brave-remaining",
798
+ "tavilyRemaining",
799
+ "tavily-remaining",
800
+ "exaRemaining",
801
+ "exa-remaining"
802
+ ].some((key) => hasOwn(source, key));
487
803
  const normalizedSubagentDefinitions = normalizeAmpSubagentDefinitions(
488
804
  source.subagentDefinitions ?? source["subagent-definitions"]
489
805
  );
@@ -497,6 +813,7 @@ function normalizeAmpConfig(rawAmp) {
497
813
  .filter(Boolean);
498
814
  const normalizedOverrides = normalizeAmpOverrides(source.overrides);
499
815
  const normalizedFallback = normalizeAmpFallback(source.fallback);
816
+ const normalizedWebSearch = normalizeAmpWebSearchConfig(source.webSearch ?? source["web-search"] ?? source);
500
817
 
501
818
  return {
502
819
  upstreamUrl: sanitizeEndpointUrl(
@@ -565,6 +882,11 @@ function normalizeAmpConfig(rawAmp) {
565
882
  fallback: normalizedFallback
566
883
  }
567
884
  : {}),
885
+ ...(hasWebSearch && normalizedWebSearch !== undefined
886
+ ? {
887
+ webSearch: normalizedWebSearch
888
+ }
889
+ : {}),
568
890
  ...(normalizedSubagentDefinitions !== undefined
569
891
  ? {
570
892
  subagentDefinitions: normalizedSubagentDefinitions
@@ -1352,7 +1674,21 @@ export function normalizeRuntimeConfig(rawConfig, options = {}) {
1352
1674
  : (typeof raw["master-key"] === "string" ? raw["master-key"] : undefined);
1353
1675
 
1354
1676
  const defaultModel = rawDefaultModel;
1355
- const amp = normalizeAmpConfig(raw.amp ?? raw.ampcode ?? raw["amp-code"]);
1677
+ const rawAmp = raw.amp ?? raw.ampcode ?? raw["amp-code"];
1678
+ const rawAmpObject = rawAmp && typeof rawAmp === "object" && !Array.isArray(rawAmp)
1679
+ ? rawAmp
1680
+ : {};
1681
+ const rawWebSearch = raw.webSearch ?? raw["web-search"];
1682
+ const amp = normalizeAmpConfig(rawAmp);
1683
+ const webSearch = normalizeAmpWebSearchConfig(rawWebSearch)
1684
+ ?? (amp?.webSearch && typeof amp.webSearch === "object" && !Array.isArray(amp.webSearch)
1685
+ ? amp.webSearch
1686
+ : undefined);
1687
+ const normalizedAmp = webSearch && amp?.webSearch && typeof amp.webSearch === "object" && !Array.isArray(amp.webSearch)
1688
+ ? Object.fromEntries(
1689
+ Object.entries(amp).filter(([key]) => key !== "webSearch")
1690
+ )
1691
+ : amp;
1356
1692
 
1357
1693
  const normalized = {
1358
1694
  version: inferNormalizedConfigVersion(raw, providers, modelAliases),
@@ -1360,7 +1696,8 @@ export function normalizeRuntimeConfig(rawConfig, options = {}) {
1360
1696
  defaultModel,
1361
1697
  providers,
1362
1698
  modelAliases,
1363
- amp,
1699
+ amp: normalizedAmp,
1700
+ ...(webSearch ? { webSearch } : {}),
1364
1701
  metadata: sanitizeRuntimeMetadata(raw.metadata)
1365
1702
  };
1366
1703
  Object.defineProperty(normalized, NORMALIZATION_ISSUES_SYMBOL, {
@@ -1601,6 +1938,70 @@ function validateAmpConfig(config, routingIndex, errors) {
1601
1938
  if (!entry?.route) continue;
1602
1939
  validateAmpRouteReference(String(entry.route), routingIndex, errors, `AMP override signature '${entry.id || "(unknown)"}'`);
1603
1940
  }
1941
+
1942
+ const webSearch = config?.webSearch ?? amp?.webSearch;
1943
+ if (webSearch && typeof webSearch === "object" && !Array.isArray(webSearch)) {
1944
+ if (!["ordered", "quota-balance"].includes(String(webSearch.strategy || "ordered").trim())) {
1945
+ errors.push(`webSearch has unsupported strategy '${webSearch.strategy}'.`);
1946
+ }
1947
+
1948
+ const providers = Array.isArray(webSearch.providers) ? webSearch.providers : [];
1949
+ const seenProviderIds = new Set();
1950
+ for (const provider of providers) {
1951
+ const providerId = String(provider?.id || "").trim();
1952
+ if (!providerId) {
1953
+ errors.push("webSearch provider is missing id.");
1954
+ continue;
1955
+ }
1956
+ if (seenProviderIds.has(providerId)) {
1957
+ errors.push(`webSearch provider '${providerId}' is duplicated.`);
1958
+ } else {
1959
+ seenProviderIds.add(providerId);
1960
+ }
1961
+
1962
+ if (AMP_WEB_SEARCH_PROVIDER_IDS.includes(providerId)) {
1963
+ const count = provider?.count;
1964
+ if (count !== undefined && (!Number.isFinite(Number(count)) || Number(count) < AMP_WEB_SEARCH_MIN_COUNT || Number(count) > AMP_WEB_SEARCH_MAX_COUNT)) {
1965
+ errors.push(`webSearch provider '${providerId}' has invalid count '${count}'.`);
1966
+ }
1967
+
1968
+ const limit = provider?.limit;
1969
+ if (limit !== undefined && (!Number.isFinite(Number(limit)) || Number(limit) < 0)) {
1970
+ errors.push(`webSearch provider '${providerId}' has invalid limit '${limit}'.`);
1971
+ }
1972
+
1973
+ const remaining = provider?.remaining;
1974
+ if (remaining !== undefined && (!Number.isFinite(Number(remaining)) || Number(remaining) < 0)) {
1975
+ errors.push(`webSearch provider '${providerId}' has invalid remaining '${remaining}'.`);
1976
+ }
1977
+ if (
1978
+ remaining !== undefined
1979
+ && Number.isFinite(Number(limit))
1980
+ && Number(limit) > 0
1981
+ && Number(remaining) > Number(limit)
1982
+ ) {
1983
+ errors.push(`webSearch provider '${providerId}' remaining cannot exceed limit.`);
1984
+ }
1985
+ continue;
1986
+ }
1987
+
1988
+ const hostedRoute = normalizeHostedWebSearchProviderRoute(provider, providerId);
1989
+ if (!hostedRoute) {
1990
+ errors.push(`webSearch provider '${providerId}' is unsupported.`);
1991
+ continue;
1992
+ }
1993
+
1994
+ const resolvedRoute = resolveRouteReference(config, hostedRoute.id);
1995
+ if (!resolvedRoute?.provider || !resolvedRoute?.model) {
1996
+ errors.push(`webSearch provider '${providerId}' does not reference a configured provider/model route.`);
1997
+ continue;
1998
+ }
1999
+
2000
+ if (!supportsOpenAIHostedWebSearchRoute(resolvedRoute.provider, resolvedRoute.model)) {
2001
+ errors.push(`webSearch provider '${providerId}' must reference an OpenAI-compatible provider/model route.`);
2002
+ }
2003
+ }
2004
+ }
1604
2005
  }
1605
2006
 
1606
2007
  export function validateRuntimeConfig(config, { requireMasterKey = false, requireProvider = false } = {}) {
@@ -1810,15 +2211,38 @@ export function maskSecret(value) {
1810
2211
  }
1811
2212
 
1812
2213
  export function sanitizeConfigForDisplay(config) {
2214
+ const sanitizedWebSearch = config.webSearch && typeof config.webSearch === "object" && !Array.isArray(config.webSearch)
2215
+ ? {
2216
+ ...config.webSearch,
2217
+ providers: (Array.isArray(config.webSearch.providers) ? config.webSearch.providers : []).map((provider) => ({
2218
+ ...provider,
2219
+ apiKey: provider?.apiKey ? maskSecret(provider.apiKey) : undefined
2220
+ }))
2221
+ }
2222
+ : undefined;
2223
+ const sanitizedAmp = config.amp
2224
+ ? {
2225
+ ...config.amp,
2226
+ upstreamApiKey: config.amp.upstreamApiKey ? maskSecret(config.amp.upstreamApiKey) : undefined,
2227
+ ...(config.amp.webSearch && typeof config.amp.webSearch === "object" && !Array.isArray(config.amp.webSearch)
2228
+ ? {
2229
+ webSearch: {
2230
+ ...config.amp.webSearch,
2231
+ providers: (Array.isArray(config.amp.webSearch.providers) ? config.amp.webSearch.providers : []).map((provider) => ({
2232
+ ...provider,
2233
+ apiKey: provider?.apiKey ? maskSecret(provider.apiKey) : undefined
2234
+ }))
2235
+ }
2236
+ }
2237
+ : {})
2238
+ }
2239
+ : undefined;
2240
+
1813
2241
  return {
1814
2242
  ...config,
1815
2243
  masterKey: config.masterKey ? maskSecret(config.masterKey) : undefined,
1816
- amp: config.amp
1817
- ? {
1818
- ...config.amp,
1819
- upstreamApiKey: config.amp.upstreamApiKey ? maskSecret(config.amp.upstreamApiKey) : undefined
1820
- }
1821
- : undefined,
2244
+ ...(sanitizedWebSearch ? { webSearch: sanitizedWebSearch } : {}),
2245
+ amp: sanitizedAmp,
1822
2246
  providers: (config.providers || []).map((provider) => ({
1823
2247
  ...provider,
1824
2248
  apiKey: provider.apiKey ? maskSecret(provider.apiKey) : undefined
@@ -2528,7 +2952,7 @@ function resolveAmpConfiguredDefinitionTarget(definitions, routes, propertyName
2528
2952
  };
2529
2953
  }
2530
2954
 
2531
- function resolveAmpRawModelMappedTarget(config, requestedModel) {
2955
+ function resolveAmpRawModelMappedTarget(config, requestedModel, routingIndex) {
2532
2956
  const mappings = Array.isArray(config?.amp?.rawModelRoutes)
2533
2957
  ? config.amp.rawModelRoutes
2534
2958
  : [];
@@ -2538,6 +2962,18 @@ function resolveAmpRawModelMappedTarget(config, requestedModel) {
2538
2962
  if (!matchAmpModelPattern(mapping.from, requestedModel)) continue;
2539
2963
  const target = String(mapping.to || "").trim();
2540
2964
  if (target) return target;
2965
+
2966
+ const sourceRouteKey = String(mapping.sourceRouteKey || "").trim();
2967
+ if (!sourceRouteKey) continue;
2968
+
2969
+ const routeTarget = String(config?.amp?.routes?.[sourceRouteKey] || "").trim();
2970
+ if (routeTarget) return routeTarget;
2971
+
2972
+ const ampDefaultTarget = String(config?.amp?.defaultRoute || "").trim();
2973
+ if (ampDefaultTarget) return ampDefaultTarget;
2974
+
2975
+ const globalDefaultTarget = getDefaultRouteReference(config, routingIndex);
2976
+ if (globalDefaultTarget) return globalDefaultTarget;
2541
2977
  }
2542
2978
 
2543
2979
  return "";
@@ -2660,7 +3096,7 @@ function resolveAmpRequestedRoute(config, effectiveRequested, normalizedRequeste
2660
3096
  : {};
2661
3097
  const entityTarget = resolveAmpConfiguredDefinitionTarget(matchingEntities, routes);
2662
3098
  const signatureTarget = resolveAmpConfiguredDefinitionTarget(matchingSignatures, routes);
2663
- const rawMappedTarget = resolveAmpRawModelMappedTarget(config, effectiveRequested);
3099
+ const rawMappedTarget = resolveAmpRawModelMappedTarget(config, effectiveRequested, routingIndex);
2664
3100
 
2665
3101
  const entityRoute = entityTarget.target
2666
3102
  ? resolveLocalRoute(entityTarget.target, {
@@ -12,7 +12,9 @@ function isPlainObject(value) {
12
12
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
13
13
  }
14
14
 
15
- function readAmpVisibleModel(requestBody) {
15
+ function readAmpVisibleModel(requestBody, env = {}) {
16
+ const overrideModel = String(env?.LLM_ROUTER_DEBUG_AMP_VISIBLE_MODEL_OVERRIDE || "").trim();
17
+ if (overrideModel) return overrideModel;
16
18
  return typeof requestBody?.model === "string" ? requestBody.model.trim() : "";
17
19
  }
18
20
 
@@ -270,10 +272,10 @@ function rewriteAmpStreamResponse(response, visibleModel) {
270
272
  });
271
273
  }
272
274
 
273
- export async function maybeRewriteAmpClientResponse(response, { clientType, requestBody, stream = false } = {}) {
275
+ export async function maybeRewriteAmpClientResponse(response, { clientType, requestBody, stream = false, env = {} } = {}) {
274
276
  if (clientType !== "amp" || !(response instanceof Response)) return response;
275
277
 
276
- const visibleModel = readAmpVisibleModel(requestBody);
278
+ const visibleModel = readAmpVisibleModel(requestBody, env);
277
279
  if (!visibleModel) return response;
278
280
 
279
281
  const contentType = String(response.headers.get("content-type") || "").toLowerCase();