@khanglvm/llm-router 2.3.7 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.4.0] - 2026-04-18
11
+
12
+ ### Added
13
+ - Claude Code can now store a router-managed `claudeCode.webSearchProvider` selection, with validation plus Web UI controls for choosing either built-in web search providers or hosted search routes.
14
+ - Native Claude web search and page-fetch tool calls can now be intercepted locally for non-AMP clients, so Claude-compatible traffic can use the router-managed shared web search stack instead of falling back to upstream-native tools.
15
+ - `yarn dev` now exposes a one-click "Sync production config" action that clones the current production config into the dedicated dev config while preserving the dev router's local server settings.
16
+
17
+ ### Fixed
18
+ - `yarn dev` now launches the detached router backend with the runtime start path, so the dedicated dev router port is honored instead of conflicting with the fixed production port.
19
+ - Dev-mode router reclaim now leaves startup-managed production instances alone when reclaiming non-fixed dev ports.
20
+ - The dev terminal and web console now clearly mark when you are operating in the isolated development sandbox.
21
+
10
22
  ## [2.3.7] - 2026-04-18
11
23
 
12
24
  ### Fixed
package/README.md CHANGED
@@ -29,6 +29,8 @@ llr ai-help # agent-oriented setup brief
29
29
  - **Model aliases with routing** — group models into stable alias names with weighted round-robin, quota-aware balancing, and automatic fallback
30
30
  - **Rate limiting** — set request caps per model or across all models over configurable time windows
31
31
  - **Coding tool routing** — one-click routing config for Codex CLI, Claude Code, Factory Droid, and AMP
32
+ - **Dev sandbox** — `yarn dev` runs the console against a dedicated dev config/router port, highlights dev mode in terminal + UI, and can clone the production config into the sandbox for quick iteration
33
+ - **Claude native web tools** — local handling for Claude web search and page fetch requests, with selectable Claude Code web-search providers from the shared Web Search config
32
34
  - **Seamless local updates** — `llr update` keeps the fixed local router endpoint online, drains in-flight requests, and automatically retries through backend restart windows
33
35
  - **Web search** — built-in web search for AMP and other router-managed tools
34
36
  - **Deployable** — run locally or deploy to Cloudflare Workers
@@ -40,6 +42,14 @@ llr ai-help # agent-oriented setup brief
40
42
 
41
43
  That means `llr update` can install a new package version and gracefully swap the backend without breaking active CLI or tool requests. Requests that arrive during the short backend handoff are deferred and retried automatically instead of failing immediately. The Web UI may reconnect during that window, but router-managed API traffic keeps the same public local endpoint.
42
44
 
45
+ ## Development Sandbox
46
+
47
+ ```bash
48
+ yarn dev
49
+ ```
50
+
51
+ Development mode uses the dedicated `~/.llm-router-dev.json` config and its own local router port so it can run alongside a startup-managed or manually started production router. The terminal and Web UI both show a dev-mode indicator, and the dev Web UI includes a one-click sync action to copy the current production config into the sandbox without changing the dev router binding.
52
+
43
53
  ## Web UI
44
54
 
45
55
  ### Alias & Fallback
@@ -66,6 +76,8 @@ Route Claude Code through the gateway with per-tier model bindings.
66
76
 
67
77
  ![Claude Code Routing](./assets/screenshots/web-ui-claude-code.png)
68
78
 
79
+ Claude Code can also select a shared Web Search provider or hosted search route from the router config. When Claude-compatible traffic uses native web-search or page-fetch tools, LLM Router can satisfy those calls through the selected shared web-search provider instead of relying on upstream-native web tooling.
80
+
69
81
  ### Factory Droid
70
82
 
71
83
  Route Factory Droid through the gateway via a managed custom model entry with reasoning effort control.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/llm-router",
3
- "version": "2.3.7",
3
+ "version": "2.4.0",
4
4
  "description": "LLM Router: single gateway endpoint for multi-provider LLMs with unified OpenAI+Anthropic format and seamless fallback",
5
5
  "keywords": [
6
6
  "llm-router",
@@ -239,6 +239,7 @@ export async function waitForRuntimeMatch(options = {}, deps = {}) {
239
239
 
240
240
  export function spawnStartProcess({
241
241
  cliPath,
242
+ startCommand = "start-runtime",
242
243
  configPath,
243
244
  host = FIXED_LOCAL_ROUTER_HOST,
244
245
  port = FIXED_LOCAL_ROUTER_PORT,
@@ -256,7 +257,7 @@ export function spawnStartProcess({
256
257
 
257
258
  const args = [
258
259
  finalCliPath,
259
- "start",
260
+ String(startCommand || "start-runtime").trim() || "start-runtime",
260
261
  `--config=${configPath}`,
261
262
  `--host=${host}`,
262
263
  `--port=${port}`,
@@ -290,7 +291,10 @@ export async function startDetachedRouterService(options = {}, deps = {}) {
290
291
 
291
292
  let child;
292
293
  try {
293
- child = spawnStartProcessFn(options, {
294
+ child = spawnStartProcessFn({
295
+ ...options,
296
+ startCommand: String(options?.startCommand || "start-runtime").trim() || "start-runtime"
297
+ }, {
294
298
  detached: true,
295
299
  stdio: ["ignore", "pipe", "pipe"],
296
300
  unref: false,
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { clearRuntimeState, getActiveRuntimeState } from "./instance-state.js";
3
+ import { FIXED_LOCAL_ROUTER_PORT } from "./local-server-settings.js";
3
4
  import { startupStatus, stopStartup } from "./startup-manager.js";
4
5
 
5
6
  export function parsePidList(text) {
@@ -119,7 +120,9 @@ export async function stopStartupManagedListener({ port, line, error }, deps = {
119
120
  let shouldStopStartup = false;
120
121
  if (activeRuntimeState?.managedByStartup) {
121
122
  shouldStopStartup = Number(activeRuntimeState.port) === Number(port);
122
- } else if (!activeRuntimeState) {
123
+ }
124
+
125
+ if (!shouldStopStartup && Number(port) === Number(FIXED_LOCAL_ROUTER_PORT)) {
123
126
  try {
124
127
  const status = await startupStatusFn();
125
128
  shouldStopStartup = Boolean(status?.running);
@@ -68,6 +68,7 @@ import {
68
68
  OLLAMA_KEEP_ALIVE_PATTERN,
69
69
  OLLAMA_PROVIDER_TYPE,
70
70
  configHasProvider,
71
+ normalizeClaudeCodeWebSearchProvider,
71
72
  normalizeOllamaConfig,
72
73
  normalizeRuntimeConfig,
73
74
  resolveProviderApiKey,
@@ -850,6 +851,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
850
851
  host = "127.0.0.1",
851
852
  port = 8788,
852
853
  configPath = getDefaultConfigPath(),
854
+ productionConfigPath = getDefaultConfigPath(),
853
855
  activityLogPath = "",
854
856
  routerHost = FIXED_LOCAL_ROUTER_HOST,
855
857
  routerPort = FIXED_LOCAL_ROUTER_PORT,
@@ -891,6 +893,8 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
891
893
  ? deps.loadWebConsoleDevAssets
892
894
  : loadWebConsoleDevAssets;
893
895
  const resolvedRouterCliPath = String(cliPathForRouter || process.env.LLM_ROUTER_CLI_PATH || process.argv[1] || "").trim();
896
+ const resolvedConfigPath = path.resolve(String(configPath || getDefaultConfigPath()).trim() || getDefaultConfigPath());
897
+ const resolvedProductionConfigPath = path.resolve(String(productionConfigPath || getDefaultConfigPath()).trim() || getDefaultConfigPath());
894
898
  const resolvedActivityLogPath = resolveActivityLogPath(configPath, activityLogPath);
895
899
  const startupControlsEnabled = !devMode;
896
900
  const defaultRouterSettings = {
@@ -1326,6 +1330,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1326
1330
  async function readClaudeCodeGlobalRoutingState(settings = {}, config = null) {
1327
1331
  const endpointUrl = buildAmpClientEndpointUrl(settings);
1328
1332
  const apiKey = String(config?.masterKey || "").trim();
1333
+ const webSearchProvider = normalizeClaudeCodeWebSearchProvider(config?.claudeCode?.webSearchProvider);
1329
1334
  try {
1330
1335
  const state = await readClaudeCodeRoutingState({
1331
1336
  endpointUrl,
@@ -1334,6 +1339,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1334
1339
  });
1335
1340
  return {
1336
1341
  ...state,
1342
+ webSearchProvider,
1337
1343
  endpointUrl,
1338
1344
  error: ""
1339
1345
  };
@@ -1354,6 +1360,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1354
1360
  subagentModel: "",
1355
1361
  thinkingLevel: ""
1356
1362
  },
1363
+ webSearchProvider,
1357
1364
  endpointUrl,
1358
1365
  error: error instanceof Error ? error.message : String(error)
1359
1366
  };
@@ -2181,7 +2188,11 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2181
2188
  return savedConfig;
2182
2189
  }
2183
2190
 
2184
- async function writeAndBroadcastConfig(parsed, { source = "" } = {}) {
2191
+ async function writeAndBroadcastConfig(parsed, {
2192
+ source = "",
2193
+ preserveMissingKeys = true,
2194
+ successMessage = ""
2195
+ } = {}) {
2185
2196
  const previousConfigState = await readConfigState(configPath);
2186
2197
  const previousConfig = previousConfigState.normalizedConfig || buildDefaultConfigObject();
2187
2198
  const previousLocalServer = getConfigLocalServer(previousConfigState);
@@ -2190,7 +2201,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2190
2201
  // This prevents partial writes (e.g. from scoped Ollama endpoints) from wiping
2191
2202
  // unrelated config sections like masterKey, modelAliases, amp, metadata, etc.
2192
2203
  const previousRaw = previousConfigState.rawConfig;
2193
- if (previousRaw && typeof previousRaw === "object" && parsed && typeof parsed === "object") {
2204
+ if (preserveMissingKeys && previousRaw && typeof previousRaw === "object" && parsed && typeof parsed === "object") {
2194
2205
  for (const key of Object.keys(previousRaw)) {
2195
2206
  if (!(key in parsed)) {
2196
2207
  parsed[key] = previousRaw[key];
@@ -2204,7 +2215,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2204
2215
  const nextLocalServer = readLocalServerSettings(savedConfig, previousLocalServer);
2205
2216
 
2206
2217
  if (source !== "autosave") {
2207
- addLog("success", `Config saved to ${path.basename(configPath)}.`);
2218
+ addLog("success", successMessage || `Config saved to ${path.basename(configPath)}.`);
2208
2219
  }
2209
2220
 
2210
2221
  const managedRuntime = await readManagedRuntime(previousLocalServer);
@@ -2269,6 +2280,54 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2269
2280
  };
2270
2281
  }
2271
2282
 
2283
+ async function syncConfigFromProduction() {
2284
+ if (!devMode) {
2285
+ const error = new Error("Production config sync is only available in dev mode.");
2286
+ error.statusCode = 409;
2287
+ throw error;
2288
+ }
2289
+
2290
+ if (resolvedConfigPath === resolvedProductionConfigPath) {
2291
+ const error = new Error("Current config already points to the production config file.");
2292
+ error.statusCode = 409;
2293
+ throw error;
2294
+ }
2295
+
2296
+ const currentConfigState = await readConfigState(configPath);
2297
+ const currentLocalServer = getConfigLocalServer(currentConfigState);
2298
+ const productionConfigState = await readConfigState(resolvedProductionConfigPath);
2299
+
2300
+ if (!productionConfigState.summary?.exists) {
2301
+ const error = new Error(`Production config file was not found at ${resolvedProductionConfigPath}.`);
2302
+ error.statusCode = 404;
2303
+ throw error;
2304
+ }
2305
+
2306
+ if (productionConfigState.parseError) {
2307
+ const error = new Error(`Production config JSON must parse before syncing: ${productionConfigState.parseError}`);
2308
+ error.statusCode = 400;
2309
+ throw error;
2310
+ }
2311
+
2312
+ const clonedConfig = applyLocalServerSettings(
2313
+ productionConfigState.normalizedConfig || buildDefaultConfigObject(),
2314
+ currentLocalServer
2315
+ );
2316
+
2317
+ const result = await writeAndBroadcastConfig(clonedConfig, {
2318
+ source: "production-sync",
2319
+ preserveMissingKeys: false,
2320
+ successMessage: `Synced ${path.basename(resolvedConfigPath)} from ${path.basename(resolvedProductionConfigPath)}.`
2321
+ });
2322
+
2323
+ return {
2324
+ ...result,
2325
+ message: `Synced dev config from ${resolvedProductionConfigPath}.`,
2326
+ syncedFrom: resolvedProductionConfigPath,
2327
+ syncedTo: resolvedConfigPath
2328
+ };
2329
+ }
2330
+
2272
2331
  let routerRestartPromise = null;
2273
2332
 
2274
2333
  async function restartManagedRouterWithSettings(settings, {
@@ -2334,6 +2393,12 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2334
2393
  : { ok: false };
2335
2394
 
2336
2395
  return {
2396
+ environment: {
2397
+ devMode,
2398
+ configPath: resolvedConfigPath,
2399
+ productionConfigPath: resolvedProductionConfigPath,
2400
+ canSyncProductionConfig: devMode && resolvedConfigPath !== resolvedProductionConfigPath
2401
+ },
2337
2402
  web: {
2338
2403
  host,
2339
2404
  port: actualWebPort,
@@ -2827,6 +2892,17 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2827
2892
  return;
2828
2893
  }
2829
2894
 
2895
+ if (method === "POST" && requestUrl.pathname === "/api/config/sync-production") {
2896
+ const synced = await syncConfigFromProduction();
2897
+ sendJson(res, 200, {
2898
+ ...synced.snapshot,
2899
+ message: synced.message,
2900
+ syncedFrom: synced.syncedFrom,
2901
+ syncedTo: synced.syncedTo
2902
+ });
2903
+ return;
2904
+ }
2905
+
2830
2906
  if (method === "POST" && requestUrl.pathname === "/api/config/test-provider") {
2831
2907
  const body = await readJsonBody(req);
2832
2908
  const endpoints = Array.isArray(body?.endpoints) ? body.endpoints.map((entry) => String(entry || "").trim()).filter(Boolean) : [];
@@ -3454,6 +3530,102 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
3454
3530
  return;
3455
3531
  }
3456
3532
 
3533
+ if (method === "POST" && requestUrl.pathname === "/api/claude-code/search-provider") {
3534
+ const body = await readJsonBody(req);
3535
+ const currentConfigState = await readConfigState(configPath);
3536
+
3537
+ let parsed;
3538
+ try {
3539
+ if (body?.config && typeof body.config === "object" && !Array.isArray(body.config)) {
3540
+ parsed = body.config;
3541
+ } else if (typeof body?.rawText === "string") {
3542
+ const rawText = String(body.rawText || "");
3543
+ parsed = rawText.trim() ? JSON.parse(rawText) : {};
3544
+ } else {
3545
+ parsed = currentConfigState.rawConfig && typeof currentConfigState.rawConfig === "object" && !Array.isArray(currentConfigState.rawConfig)
3546
+ ? { ...currentConfigState.rawConfig }
3547
+ : {};
3548
+ }
3549
+ } catch (error) {
3550
+ sendJson(res, 400, {
3551
+ error: `Config JSON parse failed: ${error instanceof Error ? error.message : String(error)}`
3552
+ });
3553
+ return;
3554
+ }
3555
+
3556
+ const nextConfig = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3557
+ const selectedProvider = normalizeClaudeCodeWebSearchProvider(
3558
+ body?.webSearchProvider
3559
+ ?? body?.searchProvider
3560
+ ?? body?.providerId
3561
+ );
3562
+
3563
+ if (selectedProvider) {
3564
+ let normalizedConfig;
3565
+ try {
3566
+ normalizedConfig = normalizeRuntimeConfig(nextConfig, { migrateToVersion: CONFIG_VERSION });
3567
+ } catch (error) {
3568
+ sendJson(res, 400, {
3569
+ error: `Config normalization failed: ${error instanceof Error ? error.message : String(error)}`
3570
+ });
3571
+ return;
3572
+ }
3573
+ const configuredProviders = Array.isArray(normalizedConfig?.webSearch?.providers) ? normalizedConfig.webSearch.providers : [];
3574
+ const selectedExists = configuredProviders.some((provider) => normalizeClaudeCodeWebSearchProvider(provider?.id) === selectedProvider);
3575
+ if (!selectedExists) {
3576
+ sendJson(res, 400, {
3577
+ error: `Claude Code web search provider '${selectedProvider}' must reference a configured webSearch provider.`
3578
+ });
3579
+ return;
3580
+ }
3581
+ }
3582
+
3583
+ if (selectedProvider) {
3584
+ const currentClaudeCode = nextConfig?.claudeCode && typeof nextConfig.claudeCode === "object" && !Array.isArray(nextConfig.claudeCode)
3585
+ ? nextConfig.claudeCode
3586
+ : (nextConfig?.["claude-code"] && typeof nextConfig["claude-code"] === "object" && !Array.isArray(nextConfig["claude-code"])
3587
+ ? nextConfig["claude-code"]
3588
+ : null);
3589
+ const nextClaudeCode = currentClaudeCode
3590
+ ? { ...currentClaudeCode }
3591
+ : {};
3592
+ nextClaudeCode.webSearchProvider = selectedProvider;
3593
+ nextConfig.claudeCode = nextClaudeCode;
3594
+ delete nextConfig["claude-code"];
3595
+ } else {
3596
+ const currentClaudeCode = nextConfig?.claudeCode && typeof nextConfig.claudeCode === "object" && !Array.isArray(nextConfig.claudeCode)
3597
+ ? nextConfig.claudeCode
3598
+ : (nextConfig?.["claude-code"] && typeof nextConfig["claude-code"] === "object" && !Array.isArray(nextConfig["claude-code"])
3599
+ ? nextConfig["claude-code"]
3600
+ : null);
3601
+ if (currentClaudeCode) {
3602
+ const nextClaudeCode = { ...currentClaudeCode };
3603
+ delete nextClaudeCode.webSearchProvider;
3604
+ delete nextClaudeCode["web-search-provider"];
3605
+ delete nextClaudeCode.searchProvider;
3606
+ delete nextClaudeCode["search-provider"];
3607
+ if (Object.keys(nextClaudeCode).length > 0) {
3608
+ nextConfig.claudeCode = nextClaudeCode;
3609
+ } else {
3610
+ nextConfig.claudeCode = {};
3611
+ }
3612
+ delete nextConfig["claude-code"];
3613
+ }
3614
+ }
3615
+
3616
+ const { snapshot } = await writeAndBroadcastConfig(nextConfig, { source: "claude-code-search-provider" });
3617
+ addLog(
3618
+ "success",
3619
+ selectedProvider ? "Claude Code search capability updated." : "Claude Code search capability cleared.",
3620
+ selectedProvider || "Default router search order"
3621
+ );
3622
+ sendJson(res, 200, {
3623
+ ...snapshot,
3624
+ message: selectedProvider ? "Claude Code search capability updated." : "Claude Code search capability cleared."
3625
+ });
3626
+ return;
3627
+ }
3628
+
3457
3629
  if (method === "POST" && requestUrl.pathname === "/api/claude-code/effort-level") {
3458
3630
  const body = await readJsonBody(req);
3459
3631
  const effortLevel = String(body?.effortLevel || body?.thinkingLevel || "").trim();
@@ -306,6 +306,48 @@ export function buildWebSearchProviderRows(config = {}, snapshot = null) {
306
306
  });
307
307
  }
308
308
 
309
+ export function buildClaudeCodeWebSearchProviderOptions(providerRows = [], selectedProviderId = "") {
310
+ const options = [];
311
+ const seen = new Set();
312
+
313
+ for (const row of Array.isArray(providerRows) ? providerRows : []) {
314
+ if (!row?.configured) continue;
315
+ const optionValue = String(row?.id || "").trim();
316
+ const optionKey = normalizeWebSearchProviderKey(optionValue);
317
+ if (!optionValue || !optionKey || seen.has(optionKey)) continue;
318
+
319
+ const label = row.kind === "hosted"
320
+ ? [String(row?.label || "").trim(), String(row?.modelId || "").trim()].filter(Boolean).join(" · ")
321
+ : (String(row?.label || optionValue).trim() || optionValue);
322
+ const hint = row.kind === "hosted"
323
+ ? (String(row?.routeId || optionValue).trim() || "Hosted web search route")
324
+ : (row.active === false ? "Configured but not ready" : "Configured provider");
325
+
326
+ options.push({
327
+ value: optionValue,
328
+ label,
329
+ hint,
330
+ groupKey: row.kind === "hosted" ? "hosted-web-search-routes" : "builtin-web-search-providers",
331
+ groupLabel: row.kind === "hosted" ? "Hosted search routes" : "Built-in search providers"
332
+ });
333
+ seen.add(optionKey);
334
+ }
335
+
336
+ const selectedValue = String(selectedProviderId || "").trim();
337
+ const selectedKey = normalizeWebSearchProviderKey(selectedValue);
338
+ if (selectedValue && selectedKey && !seen.has(selectedKey)) {
339
+ options.unshift({
340
+ value: selectedValue,
341
+ label: selectedValue,
342
+ hint: "Current config (not configured)",
343
+ groupKey: "current-claude-selection",
344
+ groupLabel: "Current selection"
345
+ });
346
+ }
347
+
348
+ return options;
349
+ }
350
+
309
351
  export function updateWebSearchConfig(config = {}, updates = {}) {
310
352
  const next = ensureAmpDraftConfigShape(config);
311
353
  const webSearch = ensureWebSearchConfigShape(next);
@@ -559,6 +559,12 @@ function normalizeAmpWebSearchProviderId(value) {
559
559
  return AMP_WEB_SEARCH_PROVIDER_IDS.includes(normalized) ? normalized : "";
560
560
  }
561
561
 
562
+ export function normalizeClaudeCodeWebSearchProvider(value) {
563
+ const normalized = String(value || "").trim();
564
+ if (!normalized) return "";
565
+ return normalizeAmpWebSearchProviderId(normalized) || normalized;
566
+ }
567
+
562
568
  function normalizeAmpWebSearchStrategy(value) {
563
569
  const normalized = String(value || "").trim().toLowerCase();
564
570
  if (!normalized) return "ordered";
@@ -750,6 +756,32 @@ function normalizeAmpWebSearchConfig(rawWebSearch) {
750
756
  };
751
757
  }
752
758
 
759
+ function normalizeClaudeCodeConfig(rawClaudeCode) {
760
+ if (!rawClaudeCode || typeof rawClaudeCode !== "object" || Array.isArray(rawClaudeCode)) {
761
+ return undefined;
762
+ }
763
+
764
+ const source = rawClaudeCode;
765
+ const hasWebSearchProvider = hasOwn(source, "webSearchProvider")
766
+ || hasOwn(source, "web-search-provider")
767
+ || hasOwn(source, "searchProvider")
768
+ || hasOwn(source, "search-provider");
769
+ const webSearchProvider = normalizeClaudeCodeWebSearchProvider(
770
+ source.webSearchProvider
771
+ ?? source["web-search-provider"]
772
+ ?? source.searchProvider
773
+ ?? source["search-provider"]
774
+ );
775
+
776
+ if (!hasWebSearchProvider) {
777
+ return undefined;
778
+ }
779
+
780
+ return webSearchProvider
781
+ ? { webSearchProvider }
782
+ : {};
783
+ }
784
+
753
785
  function supportsOpenAIHostedWebSearchRoute(provider, model) {
754
786
  const providerFormats = dedupeStrings([...(provider?.formats || []), provider?.format]);
755
787
  if (!providerFormats.includes(FORMATS.OPENAI)) return false;
@@ -1759,11 +1791,13 @@ export function normalizeRuntimeConfig(rawConfig, options = {}) {
1759
1791
  ? rawAmp
1760
1792
  : {};
1761
1793
  const rawWebSearch = raw.webSearch ?? raw["web-search"];
1794
+ const rawClaudeCode = raw.claudeCode ?? raw["claude-code"];
1762
1795
  const amp = normalizeAmpConfig(rawAmp);
1763
1796
  const webSearch = normalizeAmpWebSearchConfig(rawWebSearch)
1764
1797
  ?? (amp?.webSearch && typeof amp.webSearch === "object" && !Array.isArray(amp.webSearch)
1765
1798
  ? amp.webSearch
1766
1799
  : undefined);
1800
+ const claudeCode = normalizeClaudeCodeConfig(rawClaudeCode);
1767
1801
  const normalizedAmp = webSearch && amp?.webSearch && typeof amp.webSearch === "object" && !Array.isArray(amp.webSearch)
1768
1802
  ? Object.fromEntries(
1769
1803
  Object.entries(amp).filter(([key]) => key !== "webSearch")
@@ -1780,6 +1814,7 @@ export function normalizeRuntimeConfig(rawConfig, options = {}) {
1780
1814
  modelAliases,
1781
1815
  amp: normalizedAmp,
1782
1816
  ...(webSearch ? { webSearch } : {}),
1817
+ ...(claudeCode && Object.keys(claudeCode).length > 0 ? { claudeCode } : {}),
1783
1818
  ollama,
1784
1819
  metadata: sanitizeRuntimeMetadata(raw.metadata)
1785
1820
  };
@@ -2085,6 +2120,21 @@ function validateAmpConfig(config, routingIndex, errors) {
2085
2120
  }
2086
2121
  }
2087
2122
  }
2123
+
2124
+ const claudeCode = config?.claudeCode;
2125
+ if (claudeCode && typeof claudeCode === "object" && !Array.isArray(claudeCode)) {
2126
+ const selectedWebSearchProvider = normalizeClaudeCodeWebSearchProvider(claudeCode.webSearchProvider);
2127
+ if (selectedWebSearchProvider) {
2128
+ const configuredProviders = Array.isArray(webSearch?.providers) ? webSearch.providers : [];
2129
+ const selectedExists = configuredProviders.some((provider) => {
2130
+ const providerId = normalizeClaudeCodeWebSearchProvider(provider?.id);
2131
+ return providerId && providerId === selectedWebSearchProvider;
2132
+ });
2133
+ if (!selectedExists) {
2134
+ errors.push(`claudeCode.webSearchProvider '${selectedWebSearchProvider}' must reference a configured webSearch provider.`);
2135
+ }
2136
+ }
2137
+ }
2088
2138
  }
2089
2139
 
2090
2140
  export function validateRuntimeConfig(config, { requireMasterKey = false, requireProvider = false } = {}) {
@@ -2325,6 +2375,9 @@ export function sanitizeConfigForDisplay(config) {
2325
2375
  ...config,
2326
2376
  masterKey: config.masterKey ? maskSecret(config.masterKey) : undefined,
2327
2377
  ...(sanitizedWebSearch ? { webSearch: sanitizedWebSearch } : {}),
2378
+ ...(config.claudeCode && typeof config.claudeCode === "object" && !Array.isArray(config.claudeCode)
2379
+ ? { claudeCode: { ...config.claudeCode } }
2380
+ : {}),
2328
2381
  amp: sanitizedAmp,
2329
2382
  providers: (config.providers || []).map((provider) => ({
2330
2383
  ...provider,
@@ -5,6 +5,7 @@ import { buildCandidateKey } from "../state-store.js";
5
5
  import { consumeCandidateRateLimits, resolveWindowRange } from "../rate-limits.js";
6
6
  import {
7
7
  buildProviderHeaders,
8
+ normalizeClaudeCodeWebSearchProvider,
8
9
  resolveProviderFormat,
9
10
  resolveProviderUrl,
10
11
  resolveRouteReference
@@ -311,16 +312,29 @@ function hasSearchToolType(type) {
311
312
  if (!normalized) return false;
312
313
  return normalized === SEARCH_TOOL_NAME
313
314
  || normalized.startsWith("web_search_preview")
314
- || normalized === "web_search_20250305";
315
+ || normalized.startsWith("web_search_");
315
316
  }
316
317
 
317
318
  function hasSearchToolName(name) {
318
- const normalized = String(name || "").trim().toLowerCase();
319
- return normalized === SEARCH_TOOL_NAME || normalized === "web_search_preview";
319
+ const normalized = String(name || "").trim().toLowerCase().replace(/\s+/g, "_");
320
+ return normalized === SEARCH_TOOL_NAME
321
+ || normalized === "web_search_preview"
322
+ || normalized === "websearch";
323
+ }
324
+
325
+ function hasReadWebPageToolType(type) {
326
+ const normalized = String(type || "").trim().toLowerCase();
327
+ if (!normalized) return false;
328
+ return normalized === READ_WEB_PAGE_TOOL_NAME
329
+ || normalized === "web_fetch"
330
+ || normalized.startsWith("web_fetch_");
320
331
  }
321
332
 
322
333
  function hasReadWebPageToolName(name) {
323
- return String(name || "").trim().toLowerCase() === READ_WEB_PAGE_TOOL_NAME;
334
+ const normalized = String(name || "").trim().toLowerCase().replace(/\s+/g, "_");
335
+ return normalized === READ_WEB_PAGE_TOOL_NAME
336
+ || normalized === "web_fetch"
337
+ || normalized === "webfetch";
324
338
  }
325
339
 
326
340
  function hasInterceptableTool(tool) {
@@ -338,7 +352,7 @@ function hasInterceptableToolName(name) {
338
352
 
339
353
  function getToolName(tool) {
340
354
  if (!tool || typeof tool !== "object") return "";
341
- if (hasReadWebPageToolName(tool.name) || hasReadWebPageToolName(tool.function?.name)) {
355
+ if (hasReadWebPageToolType(tool.type) || hasReadWebPageToolName(tool.name) || hasReadWebPageToolName(tool.function?.name)) {
342
356
  return READ_WEB_PAGE_TOOL_NAME;
343
357
  }
344
358
  if (hasSearchToolType(tool.type) || hasSearchToolName(tool.name) || hasSearchToolName(tool.function?.name)) {
@@ -405,6 +419,28 @@ function normalizeSearchProviderId(value) {
405
419
  return AMP_WEB_SEARCH_PROVIDER_META.has(normalized) ? normalized : "";
406
420
  }
407
421
 
422
+ function normalizeSearchProviderSelection(value) {
423
+ return normalizeClaudeCodeWebSearchProvider(value) || "";
424
+ }
425
+
426
+ function hasNativeClaudeWebSearchTool(tool) {
427
+ const normalizedType = String(tool?.type || "").trim().toLowerCase();
428
+ return normalizedType === "web_search"
429
+ || (normalizedType.startsWith("web_search_") && !normalizedType.startsWith("web_search_preview"));
430
+ }
431
+
432
+ function hasNativeClaudeWebFetchTool(tool) {
433
+ const normalizedType = String(tool?.type || "").trim().toLowerCase();
434
+ return normalizedType === "web_fetch" || normalizedType.startsWith("web_fetch_");
435
+ }
436
+
437
+ function shouldUseClaudeCodeWebSearchSelection(runtimeConfig = {}, originalBody = {}) {
438
+ const selectedProvider = normalizeSearchProviderSelection(runtimeConfig?.claudeCode?.webSearchProvider);
439
+ if (!selectedProvider) return false;
440
+ const tools = Array.isArray(originalBody?.tools) ? originalBody.tools : [];
441
+ return tools.some((tool) => hasNativeClaudeWebSearchTool(tool) || hasNativeClaudeWebFetchTool(tool));
442
+ }
443
+
408
444
  function normalizeSearchRoutingStrategy(value) {
409
445
  const normalized = String(value || "").trim().toLowerCase();
410
446
  if (!normalized) return "ordered";
@@ -561,7 +597,7 @@ function normalizeConfiguredSearchProviders(rawProviders, raw = {}, env = {}) {
561
597
  ];
562
598
  }
563
599
 
564
- export function resolveAmpWebSearchConfig(runtimeConfig = {}, env = {}) {
600
+ export function resolveAmpWebSearchConfig(runtimeConfig = {}, env = {}, options = {}) {
565
601
  const amp = runtimeConfig?.amp && typeof runtimeConfig.amp === "object" ? runtimeConfig.amp : {};
566
602
  const raw = runtimeConfig?.webSearch && typeof runtimeConfig.webSearch === "object" && !Array.isArray(runtimeConfig.webSearch)
567
603
  ? runtimeConfig.webSearch
@@ -574,11 +610,17 @@ export function resolveAmpWebSearchConfig(runtimeConfig = {}, env = {}) {
574
610
  { min: MIN_SEARCH_COUNT, max: MAX_SEARCH_COUNT }
575
611
  );
576
612
  const providers = normalizeConfiguredSearchProviders(raw.providers, raw, env);
613
+ const selectedProviderId = shouldUseClaudeCodeWebSearchSelection(runtimeConfig, options?.originalBody)
614
+ ? normalizeSearchProviderSelection(runtimeConfig?.claudeCode?.webSearchProvider)
615
+ : "";
616
+ const selectedProviders = selectedProviderId
617
+ ? providers.filter((provider) => normalizeSearchProviderSelection(provider?.id) === selectedProviderId)
618
+ : [];
577
619
 
578
620
  return {
579
621
  strategy: normalizeSearchRoutingStrategy(raw.strategy ?? env.AMP_WEB_SEARCH_STRATEGY),
580
622
  count,
581
- providers
623
+ providers: selectedProviders.length > 0 ? selectedProviders : providers
582
624
  };
583
625
  }
584
626
 
@@ -895,8 +937,8 @@ function buildSearchProviderStatus(provider, evaluation, runtimeConfig = {}) {
895
937
  };
896
938
  }
897
939
 
898
- export async function buildAmpWebSearchSnapshot(runtimeConfig = {}, { env = {}, stateStore = null, now = Date.now() } = {}) {
899
- const settings = resolveAmpWebSearchConfig(runtimeConfig, env);
940
+ export async function buildAmpWebSearchSnapshot(runtimeConfig = {}, { env = {}, stateStore = null, now = Date.now(), originalBody = null } = {}) {
941
+ const settings = resolveAmpWebSearchConfig(runtimeConfig, env, { originalBody });
900
942
  const effectiveStateStore = resolveSearchStateStore(stateStore);
901
943
  const providers = [];
902
944
 
@@ -1198,7 +1240,8 @@ export async function executeAmpWebSearch(query, runtimeConfig = {}, env = {}, o
1198
1240
  const snapshot = await buildAmpWebSearchSnapshot(runtimeConfig, {
1199
1241
  env,
1200
1242
  stateStore: options.stateStore,
1201
- now: options.now
1243
+ now: options.now,
1244
+ originalBody: options.originalBody
1202
1245
  });
1203
1246
  const stateStore = resolveSearchStateStore(options.stateStore);
1204
1247
  const configuredProviders = snapshot.providers.filter((provider) => provider.ready);
@@ -1227,7 +1270,8 @@ export async function executeAmpWebSearch(query, runtimeConfig = {}, env = {}, o
1227
1270
  const refreshedSnapshot = await buildAmpWebSearchSnapshot(runtimeConfig, {
1228
1271
  env,
1229
1272
  stateStore,
1230
- now: options.now
1273
+ now: options.now,
1274
+ originalBody: options.originalBody
1231
1275
  });
1232
1276
  const refreshedProvider = refreshedSnapshot.providers.find((entry) => entry.id === providerStatus.id) || providerStatus;
1233
1277
  return {
@@ -1271,12 +1315,29 @@ export function shouldInterceptAmpWebSearch({ clientType, originalBody, runtimeC
1271
1315
  if (requestedToolNames.length === 0) {
1272
1316
  return false;
1273
1317
  }
1318
+ const requestsWebSearch = requestedToolNames.includes(SEARCH_TOOL_NAME);
1319
+ const requestsReadWebPage = requestedToolNames.includes(READ_WEB_PAGE_TOOL_NAME);
1274
1320
  const readyProviders = resolveAmpWebSearchConfig(runtimeConfig, env).providers.filter((provider) => {
1275
1321
  if (!isSearchProviderConfigured(provider)) return false;
1276
1322
  if (!isHostedSearchProvider(provider)) return true;
1277
1323
  const resolvedRoute = getResolvedHostedSearchRoute(runtimeConfig, provider);
1278
1324
  return Boolean(resolvedRoute && supportsResolvedHostedSearchRoute(resolvedRoute.provider, resolvedRoute.model));
1279
1325
  });
1326
+ if (
1327
+ clientType === "amp"
1328
+ && requestsWebSearch
1329
+ && !requestsReadWebPage
1330
+ && readyProviders.length === 0
1331
+ && runtimeConfig?.amp?.proxyWebSearchToUpstream === true
1332
+ && String(runtimeConfig?.amp?.upstreamUrl || "").trim()
1333
+ && String(runtimeConfig?.amp?.upstreamApiKey || "").trim()
1334
+ ) {
1335
+ return false;
1336
+ }
1337
+ const hasNativeClaudeWebTool = tools.some((tool) => hasNativeClaudeWebSearchTool(tool) || hasNativeClaudeWebFetchTool(tool));
1338
+ if (hasNativeClaudeWebTool) {
1339
+ return true;
1340
+ }
1280
1341
  if (readyProviders.length === 0) {
1281
1342
  return clientType === "amp" && requestedToolNames.includes(READ_WEB_PAGE_TOOL_NAME);
1282
1343
  }
@@ -2324,6 +2385,7 @@ export async function maybeInterceptAmpWebSearch({
2324
2385
  runtimeConfig,
2325
2386
  env,
2326
2387
  stateStore,
2388
+ originalBody,
2327
2389
  executeProviderRequest
2328
2390
  } = {}) {
2329
2391
  if (!(response instanceof Response)) {
@@ -2362,7 +2424,8 @@ export async function maybeInterceptAmpWebSearch({
2362
2424
  runtimeConfig,
2363
2425
  env,
2364
2426
  {
2365
- stateStore
2427
+ stateStore,
2428
+ originalBody
2366
2429
  }
2367
2430
  ));
2368
2431
  }
@@ -920,6 +920,7 @@ export async function makeProviderCall({
920
920
  runtimeConfig,
921
921
  env,
922
922
  stateStore,
923
+ originalBody: body,
923
924
  executeProviderRequest: async (followUpBody) => {
924
925
  const followUpResult = await executeSubscriptionRequest(followUpBody);
925
926
  return followUpResult?.response instanceof Response ? followUpResult.response : null;
@@ -1219,6 +1220,7 @@ export async function makeProviderCall({
1219
1220
  runtimeConfig,
1220
1221
  env,
1221
1222
  stateStore,
1223
+ originalBody: body,
1222
1224
  executeProviderRequest: async (followUpBody) => {
1223
1225
  try {
1224
1226
  return await executeHttpProviderRequest({