@khanglvm/llm-router 2.3.6 → 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.
@@ -12,7 +12,6 @@ import {
12
12
  areLocalServerSettingsEqual,
13
13
  formatStartupDetail,
14
14
  formatStartupLabel,
15
- getFixedLocalRouterOrigin,
16
15
  readLocalServerSettings
17
16
  } from "./local-server-settings.js";
18
17
  import { appendActivityLogEntry, clearActivityLogFile, createActivityLogEntry, readActivityLogEntries, resolveActivityLogPath } from "./activity-log.js";
@@ -69,6 +68,7 @@ import {
69
68
  OLLAMA_KEEP_ALIVE_PATTERN,
70
69
  OLLAMA_PROVIDER_TYPE,
71
70
  configHasProvider,
71
+ normalizeClaudeCodeWebSearchProvider,
72
72
  normalizeOllamaConfig,
73
73
  normalizeRuntimeConfig,
74
74
  resolveProviderApiKey,
@@ -444,8 +444,17 @@ function formatHostForUrl(host, port) {
444
444
  return `[${value}]:${port}`;
445
445
  }
446
446
 
447
+ function buildManagedRouterOrigin(settings = {}) {
448
+ const port = Number.isInteger(Number(settings?.port)) ? Number(settings.port) : FIXED_LOCAL_ROUTER_PORT;
449
+ const configuredHost = normalizeRuntimeHost(settings?.host || FIXED_LOCAL_ROUTER_HOST);
450
+ const host = isWildcardRuntimeHost(configuredHost) || isLoopbackRuntimeHost(configuredHost)
451
+ ? FIXED_LOCAL_ROUTER_HOST
452
+ : configuredHost;
453
+ return `http://${formatHostForUrl(host, port)}`;
454
+ }
455
+
447
456
  function buildAmpClientEndpointUrl(settings = {}) {
448
- return getFixedLocalRouterOrigin();
457
+ return buildManagedRouterOrigin(settings);
449
458
  }
450
459
 
451
460
  function buildCodexCliEndpointUrl(settings = {}) {
@@ -465,7 +474,7 @@ function buildFactoryDroidEndpointUrl(settings = {}) {
465
474
 
466
475
  function buildRouterEndpoints({ host, port, running }) {
467
476
  if (!running) return [];
468
- const origin = getFixedLocalRouterOrigin();
477
+ const origin = buildManagedRouterOrigin({ host, port });
469
478
  return [
470
479
  { label: "Unified", url: `${origin}/route` },
471
480
  { label: "Anthropic", url: `${origin}/anthropic` },
@@ -805,8 +814,10 @@ function writeJsonLine(res, payload) {
805
814
 
806
815
  function resolveRouterOptions(current, body) {
807
816
  return {
808
- host: FIXED_LOCAL_ROUTER_HOST,
809
- port: FIXED_LOCAL_ROUTER_PORT,
817
+ host: normalizeRuntimeHost(body?.host || current?.host || FIXED_LOCAL_ROUTER_HOST),
818
+ port: Number.isInteger(Number(body?.port))
819
+ ? Number(body.port)
820
+ : (Number.isInteger(Number(current?.port)) ? Number(current.port) : FIXED_LOCAL_ROUTER_PORT),
810
821
  watchConfig: body?.watchConfig === undefined ? current.watchConfig : body.watchConfig === true,
811
822
  requireAuth: body?.requireAuth === undefined ? current.requireAuth : body.requireAuth === true,
812
823
  watchBinary: body?.watchBinary === undefined ? current.watchBinary : body.watchBinary === true
@@ -815,8 +826,8 @@ function resolveRouterOptions(current, body) {
815
826
 
816
827
  function getRouterStateSettings(routerState) {
817
828
  return {
818
- host: FIXED_LOCAL_ROUTER_HOST,
819
- port: FIXED_LOCAL_ROUTER_PORT,
829
+ host: normalizeRuntimeHost(routerState?.host || FIXED_LOCAL_ROUTER_HOST),
830
+ port: Number.isInteger(Number(routerState?.port)) ? Number(routerState.port) : FIXED_LOCAL_ROUTER_PORT,
820
831
  watchConfig: routerState?.watchConfig !== false,
821
832
  watchBinary: routerState?.watchBinary !== false,
822
833
  requireAuth: routerState?.requireAuth === true
@@ -840,6 +851,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
840
851
  host = "127.0.0.1",
841
852
  port = 8788,
842
853
  configPath = getDefaultConfigPath(),
854
+ productionConfigPath = getDefaultConfigPath(),
843
855
  activityLogPath = "",
844
856
  routerHost = FIXED_LOCAL_ROUTER_HOST,
845
857
  routerPort = FIXED_LOCAL_ROUTER_PORT,
@@ -881,7 +893,17 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
881
893
  ? deps.loadWebConsoleDevAssets
882
894
  : loadWebConsoleDevAssets;
883
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());
884
898
  const resolvedActivityLogPath = resolveActivityLogPath(configPath, activityLogPath);
899
+ const startupControlsEnabled = !devMode;
900
+ const defaultRouterSettings = {
901
+ host: normalizeRuntimeHost(routerHost || FIXED_LOCAL_ROUTER_HOST),
902
+ port: Number.isInteger(Number(routerPort)) ? Number(routerPort) : FIXED_LOCAL_ROUTER_PORT,
903
+ watchConfig: routerWatchConfig !== false,
904
+ watchBinary: routerWatchBinary !== false,
905
+ requireAuth: routerRequireAuth === true
906
+ };
885
907
 
886
908
  async function readWebSearchState(config = null) {
887
909
  if (!config || typeof config !== "object") return null;
@@ -1308,6 +1330,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1308
1330
  async function readClaudeCodeGlobalRoutingState(settings = {}, config = null) {
1309
1331
  const endpointUrl = buildAmpClientEndpointUrl(settings);
1310
1332
  const apiKey = String(config?.masterKey || "").trim();
1333
+ const webSearchProvider = normalizeClaudeCodeWebSearchProvider(config?.claudeCode?.webSearchProvider);
1311
1334
  try {
1312
1335
  const state = await readClaudeCodeRoutingState({
1313
1336
  endpointUrl,
@@ -1316,6 +1339,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1316
1339
  });
1317
1340
  return {
1318
1341
  ...state,
1342
+ webSearchProvider,
1319
1343
  endpointUrl,
1320
1344
  error: ""
1321
1345
  };
@@ -1336,6 +1360,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1336
1360
  subagentModel: "",
1337
1361
  thinkingLevel: ""
1338
1362
  },
1363
+ webSearchProvider,
1339
1364
  endpointUrl,
1340
1365
  error: error instanceof Error ? error.message : String(error)
1341
1366
  };
@@ -1718,11 +1743,11 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1718
1743
  let activityLogEnabled = true;
1719
1744
 
1720
1745
  const routerState = {
1721
- host: FIXED_LOCAL_ROUTER_HOST,
1722
- port: FIXED_LOCAL_ROUTER_PORT,
1723
- watchConfig: routerWatchConfig,
1724
- watchBinary: routerWatchBinary,
1725
- requireAuth: routerRequireAuth,
1746
+ host: defaultRouterSettings.host,
1747
+ port: defaultRouterSettings.port,
1748
+ watchConfig: defaultRouterSettings.watchConfig,
1749
+ watchBinary: defaultRouterSettings.watchBinary,
1750
+ requireAuth: defaultRouterSettings.requireAuth,
1726
1751
  lastError: ""
1727
1752
  };
1728
1753
 
@@ -1987,6 +2012,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1987
2012
  }
1988
2013
 
1989
2014
  async function stopUntrackedStartupRuntime({ reason = "Stopped startup-managed LLM Router." } = {}) {
2015
+ if (!startupControlsEnabled) return false;
1990
2016
  const startup = await startupStatusFn().catch(() => null);
1991
2017
  if (!startup?.running) return false;
1992
2018
  await stopStartupFn();
@@ -1996,6 +2022,12 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1996
2022
  }
1997
2023
 
1998
2024
  async function startStartupOwnedRouter(settings, { restart = false } = {}) {
2025
+ if (!startupControlsEnabled) {
2026
+ const error = new Error("Startup service controls are disabled in dev mode.");
2027
+ error.statusCode = 409;
2028
+ throw error;
2029
+ }
2030
+
1999
2031
  await clearRuntimeStateFn();
2000
2032
  const detail = await installStartupFn({
2001
2033
  configPath,
@@ -2058,7 +2090,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2058
2090
  };
2059
2091
  }
2060
2092
 
2061
- const startup = await startupStatusFn().catch(() => null);
2093
+ const startup = startupControlsEnabled ? await startupStatusFn().catch(() => null) : null;
2062
2094
  const activeRuntime = await readManagedRuntime(configLocalServer);
2063
2095
  if (activeRuntime) {
2064
2096
  return {
@@ -2075,7 +2107,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2075
2107
  await stopExternalRuntime(externalRuntime, {
2076
2108
  reason: `Stopped an existing LLM Router instance so the web console can manage ${configLocalServer.host}:${configLocalServer.port} during ${reason}.`
2077
2109
  });
2078
- } else {
2110
+ } else if (startupControlsEnabled) {
2079
2111
  await stopUntrackedStartupRuntime({
2080
2112
  reason: `Stopped the startup-managed LLM Router instance so the web console can manage ${configLocalServer.host}:${configLocalServer.port} during ${reason}.`
2081
2113
  });
@@ -2156,7 +2188,11 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2156
2188
  return savedConfig;
2157
2189
  }
2158
2190
 
2159
- async function writeAndBroadcastConfig(parsed, { source = "" } = {}) {
2191
+ async function writeAndBroadcastConfig(parsed, {
2192
+ source = "",
2193
+ preserveMissingKeys = true,
2194
+ successMessage = ""
2195
+ } = {}) {
2160
2196
  const previousConfigState = await readConfigState(configPath);
2161
2197
  const previousConfig = previousConfigState.normalizedConfig || buildDefaultConfigObject();
2162
2198
  const previousLocalServer = getConfigLocalServer(previousConfigState);
@@ -2165,7 +2201,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2165
2201
  // This prevents partial writes (e.g. from scoped Ollama endpoints) from wiping
2166
2202
  // unrelated config sections like masterKey, modelAliases, amp, metadata, etc.
2167
2203
  const previousRaw = previousConfigState.rawConfig;
2168
- if (previousRaw && typeof previousRaw === "object" && parsed && typeof parsed === "object") {
2204
+ if (preserveMissingKeys && previousRaw && typeof previousRaw === "object" && parsed && typeof parsed === "object") {
2169
2205
  for (const key of Object.keys(previousRaw)) {
2170
2206
  if (!(key in parsed)) {
2171
2207
  parsed[key] = previousRaw[key];
@@ -2179,7 +2215,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2179
2215
  const nextLocalServer = readLocalServerSettings(savedConfig, previousLocalServer);
2180
2216
 
2181
2217
  if (source !== "autosave") {
2182
- addLog("success", `Config saved to ${path.basename(configPath)}.`);
2218
+ addLog("success", successMessage || `Config saved to ${path.basename(configPath)}.`);
2183
2219
  }
2184
2220
 
2185
2221
  const managedRuntime = await readManagedRuntime(previousLocalServer);
@@ -2244,6 +2280,54 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2244
2280
  };
2245
2281
  }
2246
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
+
2247
2331
  let routerRestartPromise = null;
2248
2332
 
2249
2333
  async function restartManagedRouterWithSettings(settings, {
@@ -2270,13 +2354,21 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2270
2354
  const configState = await readConfigState(configPath);
2271
2355
  const configLocalServer = getConfigLocalServer(configState);
2272
2356
  const activityLog = resolveActivityLogSnapshot(configState.normalizedConfig);
2273
- const startup = await startupStatusFn().catch((error) => ({
2274
- manager: "unknown",
2275
- serviceId: "llm-router",
2276
- installed: false,
2277
- running: false,
2278
- detail: error instanceof Error ? error.message : String(error)
2279
- }));
2357
+ const startup = startupControlsEnabled
2358
+ ? await startupStatusFn().catch((error) => ({
2359
+ manager: "unknown",
2360
+ serviceId: "llm-router",
2361
+ installed: false,
2362
+ running: false,
2363
+ detail: error instanceof Error ? error.message : String(error)
2364
+ }))
2365
+ : {
2366
+ manager: "disabled",
2367
+ serviceId: "llm-router",
2368
+ installed: false,
2369
+ running: false,
2370
+ detail: "Startup service controls are disabled in dev mode."
2371
+ };
2280
2372
  const managedRuntime = await readManagedRuntime(configLocalServer);
2281
2373
  const externalRuntime = managedRuntime ? null : await readExternalRuntime(configLocalServer);
2282
2374
  const portProbe = probeRouterPort(configLocalServer);
@@ -2301,6 +2393,12 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2301
2393
  : { ok: false };
2302
2394
 
2303
2395
  return {
2396
+ environment: {
2397
+ devMode,
2398
+ configPath: resolvedConfigPath,
2399
+ productionConfigPath: resolvedProductionConfigPath,
2400
+ canSyncProductionConfig: devMode && resolvedConfigPath !== resolvedProductionConfigPath
2401
+ },
2304
2402
  web: {
2305
2403
  host,
2306
2404
  port: actualWebPort,
@@ -2315,9 +2413,12 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2315
2413
  router: routerSnapshot,
2316
2414
  startup: {
2317
2415
  ...startup,
2318
- label: formatStartupLabel(startup),
2319
- friendlyDetail: formatStartupDetail(startup),
2320
- defaults: configLocalServer
2416
+ label: startupControlsEnabled ? formatStartupLabel(startup) : "Startup disabled in dev mode",
2417
+ friendlyDetail: startupControlsEnabled
2418
+ ? formatStartupDetail(startup)
2419
+ : "Development web console will not install, stop, or replace startup-managed routers.",
2420
+ defaults: configLocalServer,
2421
+ available: startupControlsEnabled
2321
2422
  },
2322
2423
  ampClient: {
2323
2424
  global: ampClientGlobal
@@ -2473,8 +2574,8 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2473
2574
  nextOptions = persisted.savedSettings;
2474
2575
  }
2475
2576
 
2476
- const startup = await startupStatusFn().catch(() => null);
2477
- const preferStartupOwnership = Boolean(startup?.installed);
2577
+ const startup = startupControlsEnabled ? await startupStatusFn().catch(() => null) : null;
2578
+ const preferStartupOwnership = startupControlsEnabled && Boolean(startup?.installed);
2478
2579
  const runningRuntime = await readManagedRuntime(nextOptions);
2479
2580
  const webConsoleConflict = getWebConsoleConflictMessage(nextOptions);
2480
2581
 
@@ -2501,7 +2602,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2501
2602
  await stopExternalRuntime(externalRuntime, {
2502
2603
  reason: "Stopped another LLM Router instance before starting the managed router."
2503
2604
  });
2504
- } else {
2605
+ } else if (startupControlsEnabled) {
2505
2606
  await stopUntrackedStartupRuntime({
2506
2607
  reason: "Stopped the startup-managed LLM Router instance before starting the managed router."
2507
2608
  });
@@ -2791,6 +2892,17 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2791
2892
  return;
2792
2893
  }
2793
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
+
2794
2906
  if (method === "POST" && requestUrl.pathname === "/api/config/test-provider") {
2795
2907
  const body = await readJsonBody(req);
2796
2908
  const endpoints = Array.isArray(body?.endpoints) ? body.endpoints.map((entry) => String(entry || "").trim()).filter(Boolean) : [];
@@ -3418,6 +3530,102 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
3418
3530
  return;
3419
3531
  }
3420
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
+
3421
3629
  if (method === "POST" && requestUrl.pathname === "/api/claude-code/effort-level") {
3422
3630
  const body = await readJsonBody(req);
3423
3631
  const effortLevel = String(body?.effortLevel || body?.thinkingLevel || "").trim();
@@ -3733,6 +3941,10 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
3733
3941
  }
3734
3942
 
3735
3943
  if (method === "POST" && requestUrl.pathname === "/api/startup/enable") {
3944
+ if (!startupControlsEnabled) {
3945
+ sendJson(res, 409, { error: "Startup service controls are unavailable in dev mode." });
3946
+ return;
3947
+ }
3736
3948
  const body = await readJsonBody(req);
3737
3949
  const configState = await readConfigState(configPath);
3738
3950
  if (configState.parseError) {
@@ -3795,6 +4007,10 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
3795
4007
  }
3796
4008
 
3797
4009
  if (method === "POST" && requestUrl.pathname === "/api/startup/disable") {
4010
+ if (!startupControlsEnabled) {
4011
+ sendJson(res, 409, { error: "Startup service controls are unavailable in dev mode." });
4012
+ return;
4013
+ }
3798
4014
  await readJsonBody(req);
3799
4015
  const statusBefore = await startupStatusFn().catch(() => null);
3800
4016
  if (!statusBefore?.installed) {
@@ -4245,6 +4461,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
4245
4461
 
4246
4462
  addLog("info", `Web console listening on http://${formatHostForUrl(host, actualWebPort)}`);
4247
4463
  if (devMode) addLog("info", "Development mode enabled for web assets.");
4464
+ if (!startupControlsEnabled) addLog("info", "Startup service controls disabled in dev mode.");
4248
4465
  startConfigWatcher();
4249
4466
  startActivityLogWatcher();
4250
4467
 
@@ -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,