@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
@@ -15,6 +15,7 @@ import {
15
15
  getFixedLocalRouterOrigin,
16
16
  readLocalServerSettings
17
17
  } from "./local-server-settings.js";
18
+ import { appendActivityLogEntry, clearActivityLogFile, createActivityLogEntry, readActivityLogEntries, resolveActivityLogPath } from "./activity-log.js";
18
19
  import { listListeningPids, reclaimPort } from "./port-reclaim.js";
19
20
  import { probeProvider, probeProviderEndpointMatrix } from "./provider-probe.js";
20
21
  import { installStartup, startupStatus, stopStartup, uninstallStartup } from "./startup-manager.js";
@@ -50,11 +51,32 @@ import {
50
51
  resolveProviderApiKey,
51
52
  validateRuntimeConfig
52
53
  } from "../runtime/config.js";
54
+ import {
55
+ buildAmpWebSearchSnapshot,
56
+ testHostedWebSearchProviderRoute
57
+ } from "../runtime/handler/amp-web-search.js";
58
+ import { resolveRuntimeFlags, resolveStateStoreOptions } from "../runtime/handler/runtime-policy.js";
59
+ import { createStateStore } from "../runtime/state-store.js";
60
+ import {
61
+ CODEX_CLI_INHERIT_MODEL_VALUE,
62
+ isCodexCliInheritModelBinding,
63
+ normalizeClaudeCodeThinkingLevel,
64
+ normalizeCodexCliReasoningEffort
65
+ } from "../shared/coding-tool-bindings.js";
66
+ import { applyActivityLogSettings, readActivityLogSettings } from "../shared/local-router-defaults.js";
67
+ import {
68
+ createLiteLlmContextLookupHelper,
69
+ LITELLM_CONTEXT_CATALOG_URL
70
+ } from "./litellm-context-catalog.js";
53
71
 
54
72
  const JSON_BODY_LIMIT_BYTES = 2 * 1024 * 1024;
55
73
  const MAX_LOG_ENTRIES = 150;
56
74
  const WEB_CONSOLE_APP_JS = readFileSync(fileURLToPath(new URL("./web-console-client.js", import.meta.url)), "utf8");
57
75
 
76
+ function createLogStateSignature(entries = []) {
77
+ return JSON.stringify((entries || []).map((entry) => `${entry?.id || ""}:${entry?.time || ""}`));
78
+ }
79
+
58
80
  async function loadWebConsoleDevAssets() {
59
81
  const module = await import("./web-console-dev-assets.js");
60
82
  return module.startWebConsoleDevAssets;
@@ -576,6 +598,19 @@ async function ensureJsonObjectFileExists(filePath, initialValue = {}) {
576
598
  }
577
599
  }
578
600
 
601
+ async function ensureTextFileExists(filePath, initialValue = "") {
602
+ try {
603
+ await fs.access(filePath);
604
+ } catch (error) {
605
+ if (!(error && typeof error === "object" && error.code === "ENOENT")) {
606
+ throw error;
607
+ }
608
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
609
+ await fs.writeFile(filePath, String(initialValue || ""), { encoding: "utf8", mode: 0o600 });
610
+ await fs.chmod(filePath, 0o600);
611
+ }
612
+ }
613
+
579
614
  export async function openFileInEditor(editorId, filePath, platform = process.platform) {
580
615
  const targetPath = path.resolve(filePath);
581
616
 
@@ -774,6 +809,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
774
809
  host = "127.0.0.1",
775
810
  port = 8788,
776
811
  configPath = getDefaultConfigPath(),
812
+ activityLogPath = "",
777
813
  routerHost = FIXED_LOCAL_ROUTER_HOST,
778
814
  routerPort = FIXED_LOCAL_ROUTER_PORT,
779
815
  routerWatchConfig = true,
@@ -809,10 +845,32 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
809
845
  const ampClientCwd = typeof deps.ampClientCwd === "string" && deps.ampClientCwd.trim() ? deps.ampClientCwd : process.cwd();
810
846
  const codexCliEnv = deps.codexCliEnv && typeof deps.codexCliEnv === "object" ? deps.codexCliEnv : process.env;
811
847
  const claudeCodeEnv = deps.claudeCodeEnv && typeof deps.claudeCodeEnv === "object" ? deps.claudeCodeEnv : process.env;
848
+ const runtimeEnv = deps.runtimeEnv && typeof deps.runtimeEnv === "object" ? deps.runtimeEnv : process.env;
812
849
  const loadWebConsoleDevAssetsFn = typeof deps.loadWebConsoleDevAssets === "function"
813
850
  ? deps.loadWebConsoleDevAssets
814
851
  : loadWebConsoleDevAssets;
815
852
  const resolvedRouterCliPath = String(cliPathForRouter || process.env.LLM_ROUTER_CLI_PATH || process.argv[1] || "").trim();
853
+ const resolvedActivityLogPath = resolveActivityLogPath(configPath, activityLogPath);
854
+
855
+ async function readWebSearchState(config = null) {
856
+ if (!config || typeof config !== "object") return null;
857
+ const runtimeFlags = resolveRuntimeFlags({ runtime: "node" }, runtimeEnv);
858
+ const stateStore = await createStateStore(resolveStateStoreOptions({
859
+ runtime: "node",
860
+ defaultStateStoreBackend: "file"
861
+ }, runtimeEnv, runtimeFlags));
862
+
863
+ try {
864
+ return await buildAmpWebSearchSnapshot(config, {
865
+ env: runtimeEnv,
866
+ stateStore
867
+ });
868
+ } finally {
869
+ if (typeof stateStore?.close === "function") {
870
+ await stateStore.close();
871
+ }
872
+ }
873
+ }
816
874
 
817
875
  async function resolvePreferredAmpSettingsTarget() {
818
876
  const envOverride = String(ampClientEnv?.AMP_SETTINGS_FILE || "").trim();
@@ -910,15 +968,23 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
910
968
 
911
969
  function normalizeCodexBindingsInput(bindings = {}, config = {}) {
912
970
  const source = bindings && typeof bindings === "object" && !Array.isArray(bindings) ? bindings : {};
971
+ const defaultModel = String(source.defaultModel || "").trim();
913
972
  return {
914
- defaultModel: String(source.defaultModel || pickDefaultManagedRoute(config) || "").trim()
973
+ defaultModel: isCodexCliInheritModelBinding(defaultModel)
974
+ ? CODEX_CLI_INHERIT_MODEL_VALUE
975
+ : String(defaultModel || pickDefaultManagedRoute(config) || "").trim(),
976
+ thinkingLevel: normalizeCodexCliReasoningEffort(source.thinkingLevel)
915
977
  };
916
978
  }
917
979
 
918
980
  function normalizeCodexBindingState(bindings = {}) {
919
981
  const source = bindings && typeof bindings === "object" && !Array.isArray(bindings) ? bindings : {};
982
+ const defaultModel = String(source.defaultModel || "").trim();
920
983
  return {
921
- defaultModel: String(source.defaultModel || "").trim()
984
+ defaultModel: isCodexCliInheritModelBinding(defaultModel)
985
+ ? CODEX_CLI_INHERIT_MODEL_VALUE
986
+ : defaultModel,
987
+ thinkingLevel: normalizeCodexCliReasoningEffort(source.thinkingLevel)
922
988
  };
923
989
  }
924
990
 
@@ -990,10 +1056,11 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
990
1056
  description,
991
1057
  default_reasoning_level: "medium",
992
1058
  supported_reasoning_levels: [
1059
+ { effort: "minimal", description: "Minimum reasoning for the fastest supported responses" },
993
1060
  { effort: "low", description: "Fast responses with lighter reasoning" },
994
1061
  { effort: "medium", description: "Balances speed and reasoning depth for everyday tasks" },
995
1062
  { effort: "high", description: "Greater reasoning depth for complex problems" },
996
- { effort: "xhigh", description: "Extra high reasoning depth for complex problems" }
1063
+ { effort: "xhigh", description: "Extra high reasoning depth for complex problems on supported models" }
997
1064
  ],
998
1065
  shell_type: "shell_command",
999
1066
  visibility: "list",
@@ -1024,6 +1091,9 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1024
1091
  ? config.modelAliases
1025
1092
  : {};
1026
1093
  const boundModel = String(bindings?.defaultModel || "").trim();
1094
+ if (isCodexCliInheritModelBinding(boundModel)) {
1095
+ return { models: [] };
1096
+ }
1027
1097
  const catalogEntries = new Map();
1028
1098
  const aliasIds = new Set(
1029
1099
  Object.keys(aliases)
@@ -1063,7 +1133,8 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1063
1133
  defaultOpusModel: String(source.defaultOpusModel || "").trim(),
1064
1134
  defaultSonnetModel: String(source.defaultSonnetModel || "").trim(),
1065
1135
  defaultHaikuModel: String(source.defaultHaikuModel || "").trim(),
1066
- subagentModel: String(source.subagentModel || "").trim()
1136
+ subagentModel: String(source.subagentModel || "").trim(),
1137
+ thinkingLevel: normalizeClaudeCodeThinkingLevel(source.thinkingLevel)
1067
1138
  };
1068
1139
  }
1069
1140
 
@@ -1072,7 +1143,12 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1072
1143
  }
1073
1144
 
1074
1145
  function areCodexBindingsEqual(left = {}, right = {}) {
1075
- return String(left?.defaultModel || "").trim() === String(right?.defaultModel || "").trim();
1146
+ const normalizedLeft = normalizeCodexBindingState(left);
1147
+ const normalizedRight = normalizeCodexBindingState(right);
1148
+ return (
1149
+ normalizedLeft.defaultModel === normalizedRight.defaultModel
1150
+ && normalizedLeft.thinkingLevel === normalizedRight.thinkingLevel
1151
+ );
1076
1152
  }
1077
1153
 
1078
1154
  function areClaudeBindingsEqual(left = {}, right = {}) {
@@ -1082,6 +1158,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1082
1158
  && String(left?.defaultSonnetModel || "").trim() === String(right?.defaultSonnetModel || "").trim()
1083
1159
  && String(left?.defaultHaikuModel || "").trim() === String(right?.defaultHaikuModel || "").trim()
1084
1160
  && String(left?.subagentModel || "").trim() === String(right?.subagentModel || "").trim()
1161
+ && normalizeClaudeCodeThinkingLevel(left?.thinkingLevel) === normalizeClaudeCodeThinkingLevel(right?.thinkingLevel)
1085
1162
  );
1086
1163
  }
1087
1164
 
@@ -1091,13 +1168,26 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1091
1168
 
1092
1169
  function reconcileCodexBindingsForConfig(bindings = {}, previousConfig = {}, nextConfig = {}) {
1093
1170
  const currentBindings = normalizeCodexBindingState(bindings);
1171
+ if (isCodexCliInheritModelBinding(currentBindings.defaultModel)) {
1172
+ return {
1173
+ defaultModel: CODEX_CLI_INHERIT_MODEL_VALUE,
1174
+ thinkingLevel: currentBindings.thinkingLevel
1175
+ };
1176
+ }
1094
1177
  const rewriteContext = buildManagedRouteRewriteContext(previousConfig, nextConfig);
1095
1178
  const nextDefaultModel = reconcileManagedRouteBinding(currentBindings.defaultModel, rewriteContext);
1096
1179
  return {
1097
- defaultModel: nextDefaultModel || pickDefaultManagedRoute(nextConfig)
1180
+ defaultModel: nextDefaultModel || pickDefaultManagedRoute(nextConfig),
1181
+ thinkingLevel: currentBindings.thinkingLevel
1098
1182
  };
1099
1183
  }
1100
1184
 
1185
+ function formatCodexBindingLabel(defaultModel = "") {
1186
+ return isCodexCliInheritModelBinding(defaultModel)
1187
+ ? "Inherit Codex CLI model"
1188
+ : (String(defaultModel || "").trim() || "No model selected");
1189
+ }
1190
+
1101
1191
  function reconcileClaudeBindingsForConfig(bindings = {}, previousConfig = {}, nextConfig = {}) {
1102
1192
  const currentBindings = normalizeClaudeBindingState(bindings);
1103
1193
  const rewriteContext = buildManagedRouteRewriteContext(previousConfig, nextConfig);
@@ -1106,7 +1196,8 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1106
1196
  defaultOpusModel: reconcileManagedRouteBinding(currentBindings.defaultOpusModel, rewriteContext),
1107
1197
  defaultSonnetModel: reconcileManagedRouteBinding(currentBindings.defaultSonnetModel, rewriteContext),
1108
1198
  defaultHaikuModel: reconcileManagedRouteBinding(currentBindings.defaultHaikuModel, rewriteContext),
1109
- subagentModel: reconcileManagedRouteBinding(currentBindings.subagentModel, rewriteContext)
1199
+ subagentModel: reconcileManagedRouteBinding(currentBindings.subagentModel, rewriteContext),
1200
+ thinkingLevel: currentBindings.thinkingLevel
1110
1201
  };
1111
1202
  }
1112
1203
 
@@ -1134,7 +1225,8 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1134
1225
  routedViaRouter: false,
1135
1226
  configuredBaseUrl: "",
1136
1227
  bindings: {
1137
- defaultModel: ""
1228
+ defaultModel: "",
1229
+ thinkingLevel: ""
1138
1230
  },
1139
1231
  endpointUrl,
1140
1232
  error: error instanceof Error ? error.message : String(error)
@@ -1170,7 +1262,8 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1170
1262
  defaultOpusModel: "",
1171
1263
  defaultSonnetModel: "",
1172
1264
  defaultHaikuModel: "",
1173
- subagentModel: ""
1265
+ subagentModel: "",
1266
+ thinkingLevel: ""
1174
1267
  },
1175
1268
  endpointUrl,
1176
1269
  error: error instanceof Error ? error.message : String(error)
@@ -1263,7 +1356,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1263
1356
  if (endpointOrKeyChanged) {
1264
1357
  addLog("info", "Updated Codex CLI route to match the local router.", buildCodexCliEndpointUrl(nextSettings));
1265
1358
  } else {
1266
- addLog("info", "Updated Codex CLI bindings to match the saved router config.", bindings.defaultModel || "No model selected");
1359
+ addLog("info", "Updated Codex CLI bindings to match the saved router config.", formatCodexBindingLabel(bindings.defaultModel));
1267
1360
  }
1268
1361
  return true;
1269
1362
  } catch (error) {
@@ -1411,10 +1504,45 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1411
1504
  onProgress
1412
1505
  });
1413
1506
  };
1507
+ const testHostedWebSearchProviderFn = typeof deps.testHostedWebSearchProvider === "function"
1508
+ ? deps.testHostedWebSearchProvider
1509
+ : async ({ runtimeConfig, providerId, modelId }) => {
1510
+ const resolvedProviderId = String(providerId || "").trim();
1511
+ const resolvedModelId = String(modelId || "").trim();
1512
+ if (!resolvedProviderId || !resolvedModelId) {
1513
+ const error = new Error("Provider id and model id are required before testing hosted web search.");
1514
+ error.statusCode = 400;
1515
+ throw error;
1516
+ }
1517
+
1518
+ try {
1519
+ return await testHostedWebSearchProviderRoute({
1520
+ runtimeConfig,
1521
+ routeId: `${resolvedProviderId}/${resolvedModelId}`,
1522
+ env: runtimeEnv
1523
+ });
1524
+ } catch (error) {
1525
+ const message = error instanceof Error ? error.message : String(error);
1526
+ if (error && typeof error === "object" && Number.isFinite(error.statusCode)) {
1527
+ throw error;
1528
+ }
1529
+ const wrapped = new Error(message);
1530
+ wrapped.statusCode = (
1531
+ message.includes("not configured")
1532
+ || message.includes("not OpenAI-compatible")
1533
+ || message.includes("is required")
1534
+ ) ? 400 : 502;
1535
+ throw wrapped;
1536
+ }
1537
+ };
1538
+ const lookupLiteLlmContextWindowFn = typeof deps.lookupLiteLlmContextWindow === "function"
1539
+ ? deps.lookupLiteLlmContextWindow
1540
+ : createLiteLlmContextLookupHelper();
1414
1541
 
1415
1542
  const eventClients = new Set();
1416
1543
  const devEventClients = new Set();
1417
1544
  const logs = [];
1545
+ let logStateSignature = createLogStateSignature(logs);
1418
1546
  let webServer = null;
1419
1547
  let closing = false;
1420
1548
  let resolveDone;
@@ -1423,7 +1551,10 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1423
1551
  let actualWebPort = Number(port);
1424
1552
  let configWatcher = null;
1425
1553
  let configWatchTimer = null;
1554
+ let activityLogWatcher = null;
1555
+ let activityLogWatchTimer = null;
1426
1556
  let ignoreConfigWatchUntil = 0;
1557
+ let activityLogEnabled = true;
1427
1558
 
1428
1559
  const routerState = {
1429
1560
  host: FIXED_LOCAL_ROUTER_HOST,
@@ -1456,20 +1587,89 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1456
1587
  }
1457
1588
  }
1458
1589
 
1590
+ function replaceLogs(nextEntries = []) {
1591
+ logs.splice(0, logs.length, ...nextEntries);
1592
+ logStateSignature = createLogStateSignature(logs);
1593
+ }
1594
+
1595
+ function resolveActivityLogSnapshot(config = undefined) {
1596
+ if (config !== undefined) {
1597
+ const settings = readActivityLogSettings(config);
1598
+ activityLogEnabled = settings.enabled;
1599
+ return settings;
1600
+ }
1601
+ return { enabled: activityLogEnabled };
1602
+ }
1603
+
1604
+ function emitLogState(config = undefined) {
1605
+ pushEvent("logs", {
1606
+ logs,
1607
+ activityLog: resolveActivityLogSnapshot(config)
1608
+ });
1609
+ }
1610
+
1611
+ async function syncLogsFromFile({ config = undefined, broadcast = false } = {}) {
1612
+ const nextEntries = await readActivityLogEntries(resolvedActivityLogPath, {
1613
+ limit: MAX_LOG_ENTRIES
1614
+ });
1615
+ const nextSignature = createLogStateSignature(nextEntries);
1616
+ if (nextSignature === logStateSignature) return false;
1617
+ replaceLogs(nextEntries);
1618
+ if (broadcast) {
1619
+ emitLogState(config);
1620
+ }
1621
+ return true;
1622
+ }
1623
+
1624
+ function scheduleActivityLogRefresh(reason = "change") {
1625
+ if (activityLogWatchTimer) clearTimeout(activityLogWatchTimer);
1626
+ activityLogWatchTimer = setTimeout(() => {
1627
+ activityLogWatchTimer = null;
1628
+ if (closing) return;
1629
+ void syncLogsFromFile({ broadcast: true }).catch(() => {});
1630
+ }, reason === "clear" ? 0 : 80);
1631
+ }
1632
+
1633
+ function startActivityLogWatcher() {
1634
+ const activityLogDir = path.dirname(resolvedActivityLogPath);
1635
+ const activityLogFile = path.basename(resolvedActivityLogPath);
1636
+ try {
1637
+ activityLogWatcher = fsWatch(activityLogDir, (_eventType, filename) => {
1638
+ if (closing) return;
1639
+ if (filename && String(filename) !== activityLogFile) return;
1640
+ scheduleActivityLogRefresh("watch");
1641
+ });
1642
+ } catch {
1643
+ activityLogWatcher = null;
1644
+ }
1645
+ }
1646
+
1459
1647
  function addLog(level, message, detail = "") {
1460
- const entry = {
1461
- id: `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`,
1648
+ if (!activityLogEnabled) return null;
1649
+ const entry = createActivityLogEntry({
1462
1650
  level,
1463
1651
  message,
1464
1652
  detail,
1465
- time: new Date().toISOString()
1466
- };
1653
+ source: "web-console",
1654
+ category: "router",
1655
+ kind: "router-event"
1656
+ });
1467
1657
  logs.unshift(entry);
1468
1658
  logs.splice(MAX_LOG_ENTRIES);
1659
+ logStateSignature = createLogStateSignature(logs);
1469
1660
  pushEvent("log", entry);
1661
+ void appendActivityLogEntry(resolvedActivityLogPath, entry).catch(() => {});
1470
1662
  return entry;
1471
1663
  }
1472
1664
 
1665
+ try {
1666
+ const initialConfigState = await readConfigState(configPath);
1667
+ resolveActivityLogSnapshot(initialConfigState.normalizedConfig);
1668
+ await syncLogsFromFile({ config: initialConfigState.normalizedConfig });
1669
+ } catch {
1670
+ activityLogEnabled = true;
1671
+ }
1672
+
1473
1673
  const devReloadScript = devMode ? String.raw`<script>
1474
1674
  (() => {
1475
1675
  const source = new EventSource("/__dev/events");
@@ -1602,7 +1802,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1602
1802
  };
1603
1803
  }
1604
1804
 
1605
- async function stopExternalRuntime(runtime, { reason = "Stopped another llm-router instance." } = {}) {
1805
+ async function stopExternalRuntime(runtime, { reason = "Stopped another LLM Router instance." } = {}) {
1606
1806
  if (!runtime || Number(runtime.pid) === Number(process.pid)) return false;
1607
1807
 
1608
1808
  const runtimeUrl = `http://${formatHostForUrl(runtime.host, runtime.port)}`;
@@ -1615,7 +1815,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1615
1815
 
1616
1816
  const stopped = await stopProcessByPidFn(runtime.pid);
1617
1817
  if (!stopped?.ok) {
1618
- const error = new Error(stopped?.reason || `Failed stopping llm-router pid ${runtime.pid}.`);
1818
+ const error = new Error(stopped?.reason || `Failed stopping LLM Router pid ${runtime.pid}.`);
1619
1819
  error.statusCode = 409;
1620
1820
  throw error;
1621
1821
  }
@@ -1625,7 +1825,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1625
1825
  return true;
1626
1826
  }
1627
1827
 
1628
- async function stopUntrackedStartupRuntime({ reason = "Stopped startup-managed llm-router." } = {}) {
1828
+ async function stopUntrackedStartupRuntime({ reason = "Stopped startup-managed LLM Router." } = {}) {
1629
1829
  const startup = await startupStatusFn().catch(() => null);
1630
1830
  if (!startup?.running) return false;
1631
1831
  await stopStartupFn();
@@ -1662,7 +1862,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1662
1862
  }
1663
1863
 
1664
1864
  if (!runtime) {
1665
- const startError = new Error(`Startup-managed llm-router did not become ready on http://${settings.host}:${settings.port}.`);
1865
+ const startError = new Error(`Startup-managed LLM Router did not become ready on http://${settings.host}:${settings.port}.`);
1666
1866
  startError.statusCode = 500;
1667
1867
  throw startError;
1668
1868
  }
@@ -1712,11 +1912,11 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1712
1912
  const externalRuntime = await readExternalRuntime(configLocalServer);
1713
1913
  if (externalRuntime) {
1714
1914
  await stopExternalRuntime(externalRuntime, {
1715
- reason: `Stopped existing llm-router so the web console can manage ${configLocalServer.host}:${configLocalServer.port} during ${reason}.`
1915
+ reason: `Stopped an existing LLM Router instance so the web console can manage ${configLocalServer.host}:${configLocalServer.port} during ${reason}.`
1716
1916
  });
1717
1917
  } else {
1718
1918
  await stopUntrackedStartupRuntime({
1719
- reason: `Stopped startup-managed llm-router so the web console can manage ${configLocalServer.host}:${configLocalServer.port} during ${reason}.`
1919
+ reason: `Stopped the startup-managed LLM Router instance so the web console can manage ${configLocalServer.host}:${configLocalServer.port} during ${reason}.`
1720
1920
  });
1721
1921
  }
1722
1922
 
@@ -1777,12 +1977,31 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1777
1977
  };
1778
1978
  }
1779
1979
 
1980
+ async function persistActivityLogConfig(enabled) {
1981
+ const currentConfigState = await readConfigState(configPath);
1982
+ if (currentConfigState.parseError) {
1983
+ const error = new Error(`Config JSON must parse before saving activity log settings: ${currentConfigState.parseError}`);
1984
+ error.statusCode = 400;
1985
+ throw error;
1986
+ }
1987
+
1988
+ const nextConfig = applyActivityLogSettings(
1989
+ currentConfigState.normalizedConfig || buildDefaultConfigObject(),
1990
+ { enabled: enabled === true }
1991
+ );
1992
+ ignoreConfigWatchUntil = Date.now() + 800;
1993
+ const savedConfig = await writeConfigFile(nextConfig, configPath, { migrateToVersion: CONFIG_VERSION });
1994
+ resolveActivityLogSnapshot(savedConfig);
1995
+ return savedConfig;
1996
+ }
1997
+
1780
1998
  async function writeAndBroadcastConfig(parsed, { source = "" } = {}) {
1781
1999
  const previousConfigState = await readConfigState(configPath);
1782
2000
  const previousConfig = previousConfigState.normalizedConfig || buildDefaultConfigObject();
1783
2001
  const previousLocalServer = getConfigLocalServer(previousConfigState);
1784
2002
  ignoreConfigWatchUntil = Date.now() + 800;
1785
2003
  const savedConfig = await writeConfigFile(parsed, configPath, { migrateToVersion: CONFIG_VERSION });
2004
+ resolveActivityLogSnapshot(savedConfig);
1786
2005
  const nextLocalServer = readLocalServerSettings(savedConfig, previousLocalServer);
1787
2006
 
1788
2007
  if (source !== "autosave") {
@@ -1870,6 +2089,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1870
2089
  async function buildSnapshot() {
1871
2090
  const configState = await readConfigState(configPath);
1872
2091
  const configLocalServer = getConfigLocalServer(configState);
2092
+ const activityLog = resolveActivityLogSnapshot(configState.normalizedConfig);
1873
2093
  const startup = await startupStatusFn().catch((error) => ({
1874
2094
  manager: "unknown",
1875
2095
  serviceId: "llm-router",
@@ -1891,6 +2111,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1891
2111
  const ampClientGlobal = await readAmpGlobalRoutingState(configLocalServer);
1892
2112
  const codexCliGlobal = await readCodexCliGlobalRoutingState(configLocalServer, configState.normalizedConfig);
1893
2113
  const claudeCodeGlobal = await readClaudeCodeGlobalRoutingState(configLocalServer, configState.normalizedConfig);
2114
+ const webSearch = await readWebSearchState(configState.normalizedConfig).catch(() => null);
1894
2115
 
1895
2116
  return {
1896
2117
  web: {
@@ -1914,6 +2135,8 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1914
2135
  ampClient: {
1915
2136
  global: ampClientGlobal
1916
2137
  },
2138
+ webSearch,
2139
+ ampWebSearch: webSearch,
1917
2140
  codingTools: {
1918
2141
  codexCli: codexCliGlobal,
1919
2142
  claudeCode: claudeCodeGlobal
@@ -1923,6 +2146,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1923
2146
  },
1924
2147
  editors: detectAvailableEditorsFn(),
1925
2148
  externalRuntime,
2149
+ activityLog,
1926
2150
  logs
1927
2151
  };
1928
2152
  }
@@ -1939,14 +2163,23 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1939
2163
  configWatchTimer = null;
1940
2164
  if (closing) return;
1941
2165
  if (Date.now() < ignoreConfigWatchUntil) return;
1942
- addLog("info", `Config file changed on disk (${reason}).`);
1943
- void reconcileManagedRouterWithConfig({ reason: `config-watch:${reason}` })
1944
- .catch((reconcileError) => {
2166
+ void (async () => {
2167
+ let latestConfigState = null;
2168
+ try {
2169
+ latestConfigState = await readConfigState(configPath);
2170
+ resolveActivityLogSnapshot(latestConfigState.normalizedConfig);
2171
+ } catch {
2172
+ latestConfigState = null;
2173
+ }
2174
+ addLog("info", `Config file changed on disk (${reason}).`);
2175
+ try {
2176
+ await reconcileManagedRouterWithConfig({ reason: `config-watch:${reason}` });
2177
+ } catch (reconcileError) {
1945
2178
  addLog("warn", "Managed router auto-start skipped.", reconcileError instanceof Error ? reconcileError.message : String(reconcileError));
1946
- })
1947
- .finally(() => {
1948
- void broadcastState();
1949
- });
2179
+ } finally {
2180
+ await broadcastState().catch(() => {});
2181
+ }
2182
+ })();
1950
2183
  }, 150);
1951
2184
  }
1952
2185
 
@@ -1995,7 +2228,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
1995
2228
 
1996
2229
  const stopped = await stopProcessByPidFn(activeRuntime.pid);
1997
2230
  if (!stopped?.ok) {
1998
- const stopError = new Error(stopped?.reason || `Failed stopping llm-router pid ${activeRuntime.pid}.`);
2231
+ const stopError = new Error(stopped?.reason || `Failed stopping LLM Router pid ${activeRuntime.pid}.`);
1999
2232
  stopError.statusCode = 409;
2000
2233
  throw stopError;
2001
2234
  }
@@ -2070,11 +2303,11 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2070
2303
  const externalRuntime = await readExternalRuntime(nextOptions);
2071
2304
  if (externalRuntime) {
2072
2305
  await stopExternalRuntime(externalRuntime, {
2073
- reason: "Stopped another llm-router instance before starting the managed router."
2306
+ reason: "Stopped another LLM Router instance before starting the managed router."
2074
2307
  });
2075
2308
  } else {
2076
2309
  await stopUntrackedStartupRuntime({
2077
- reason: "Stopped startup-managed llm-router before starting the managed router."
2310
+ reason: "Stopped the startup-managed LLM Router instance before starting the managed router."
2078
2311
  });
2079
2312
  }
2080
2313
 
@@ -2124,7 +2357,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2124
2357
  requireAuth: nextOptions.requireAuth
2125
2358
  });
2126
2359
  if (!started?.ok) {
2127
- const startError = new Error(started?.errorMessage || `Failed to start llm-router on http://${nextOptions.host}:${nextOptions.port}.`);
2360
+ const startError = new Error(started?.errorMessage || `Failed to start LLM Router on http://${nextOptions.host}:${nextOptions.port}.`);
2128
2361
  startError.statusCode = 500;
2129
2362
  throw startError;
2130
2363
  }
@@ -2178,6 +2411,14 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2178
2411
  configWatcher.close();
2179
2412
  configWatcher = null;
2180
2413
  }
2414
+ if (activityLogWatchTimer) {
2415
+ clearTimeout(activityLogWatchTimer);
2416
+ activityLogWatchTimer = null;
2417
+ }
2418
+ if (activityLogWatcher) {
2419
+ activityLogWatcher.close();
2420
+ activityLogWatcher = null;
2421
+ }
2181
2422
 
2182
2423
  for (const client of eventClients) {
2183
2424
  client.end();
@@ -2261,6 +2502,33 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2261
2502
  return;
2262
2503
  }
2263
2504
 
2505
+ if (method === "POST" && requestUrl.pathname === "/api/activity-log/settings") {
2506
+ const body = await readJsonBody(req);
2507
+ const enabled = body?.enabled === false ? false : true;
2508
+ const savedConfig = await persistActivityLogConfig(enabled);
2509
+ await syncLogsFromFile({ config: savedConfig });
2510
+ if (enabled) {
2511
+ addLog("info", "Activity logging enabled.");
2512
+ }
2513
+ const snapshot = await broadcastState();
2514
+ sendJson(res, 200, {
2515
+ ...snapshot,
2516
+ message: enabled ? "Activity log enabled." : "Activity log disabled."
2517
+ });
2518
+ return;
2519
+ }
2520
+
2521
+ if (method === "POST" && requestUrl.pathname === "/api/activity-log/clear") {
2522
+ await clearActivityLogFile(resolvedActivityLogPath);
2523
+ replaceLogs([]);
2524
+ emitLogState();
2525
+ sendJson(res, 200, {
2526
+ ...(await buildSnapshot()),
2527
+ message: "Activity log cleared."
2528
+ });
2529
+ return;
2530
+ }
2531
+
2264
2532
  if (method === "POST" && requestUrl.pathname === "/api/config/validate") {
2265
2533
  const body = await readJsonBody(req);
2266
2534
  const rawText = body?.rawText !== undefined
@@ -2322,6 +2590,48 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2322
2590
  return;
2323
2591
  }
2324
2592
 
2593
+ if (method === "POST" && requestUrl.pathname === "/api/config/test-web-search-provider") {
2594
+ const body = await readJsonBody(req);
2595
+ const providerId = String(body?.providerId || "").trim();
2596
+ const modelId = String(body?.modelId || "").trim();
2597
+ const rawText = body?.rawText !== undefined
2598
+ ? String(body.rawText || "")
2599
+ : `${JSON.stringify(body?.config || {}, null, 2)}\n`;
2600
+
2601
+ if (!providerId || !modelId) {
2602
+ sendJson(res, 400, { error: "Provider id and model id are required before testing hosted web search." });
2603
+ return;
2604
+ }
2605
+
2606
+ let normalizedConfig = null;
2607
+ try {
2608
+ const parsed = body?.config && typeof body.config === "object" && !Array.isArray(body.config)
2609
+ ? body.config
2610
+ : (rawText.trim() ? JSON.parse(rawText) : {});
2611
+ normalizedConfig = normalizeRuntimeConfig(parsed, { migrateToVersion: CONFIG_VERSION });
2612
+ } catch (error) {
2613
+ sendJson(res, 400, { error: `Current config draft is invalid JSON: ${error instanceof Error ? error.message : String(error)}` });
2614
+ return;
2615
+ }
2616
+
2617
+ addLog("info", "Testing hosted web search route.", `${providerId}/${modelId}`);
2618
+ try {
2619
+ const result = await testHostedWebSearchProviderFn({
2620
+ runtimeConfig: normalizedConfig,
2621
+ providerId,
2622
+ modelId
2623
+ });
2624
+ addLog("success", "Hosted web search route is ready.", `${providerId}/${modelId}`);
2625
+ sendJson(res, 200, { result });
2626
+ } catch (error) {
2627
+ addLog("warn", "Hosted web search route test failed.", error instanceof Error ? error.message : String(error));
2628
+ sendJson(res, Number(error?.statusCode) || 502, {
2629
+ error: error instanceof Error ? error.message : String(error)
2630
+ });
2631
+ }
2632
+ return;
2633
+ }
2634
+
2325
2635
  if (method === "POST" && requestUrl.pathname === "/api/config/test-provider-stream") {
2326
2636
  const body = await readJsonBody(req);
2327
2637
  const endpoints = Array.isArray(body?.endpoints) ? body.endpoints.map((entry) => String(entry || "").trim()).filter(Boolean) : [];
@@ -2400,6 +2710,34 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2400
2710
  return;
2401
2711
  }
2402
2712
 
2713
+ if (method === "POST" && requestUrl.pathname === "/api/config/litellm-context-lookup") {
2714
+ const body = await readJsonBody(req);
2715
+ const models = Array.isArray(body?.models) ? body.models.map((entry) => String(entry || "").trim()).filter(Boolean) : [];
2716
+ if (models.length === 0) {
2717
+ sendJson(res, 400, { error: "At least one model id is required before looking up context windows." });
2718
+ return;
2719
+ }
2720
+
2721
+ try {
2722
+ const result = await lookupLiteLlmContextWindowFn({
2723
+ models,
2724
+ limit: body?.limit
2725
+ });
2726
+ sendJson(res, 200, {
2727
+ result,
2728
+ source: {
2729
+ provider: "litellm",
2730
+ url: LITELLM_CONTEXT_CATALOG_URL
2731
+ }
2732
+ });
2733
+ } catch (error) {
2734
+ sendJson(res, Number(error?.statusCode) || 502, {
2735
+ error: error instanceof Error ? error.message : String(error)
2736
+ });
2737
+ }
2738
+ return;
2739
+ }
2740
+
2403
2741
  if (method === "POST" && requestUrl.pathname === "/api/config/save") {
2404
2742
  const body = await readJsonBody(req);
2405
2743
  let parsed;
@@ -2586,7 +2924,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2586
2924
  const snapshot = await broadcastState();
2587
2925
  sendJson(res, 200, {
2588
2926
  ...snapshot,
2589
- message: "AMP now routes via LLM-Router.",
2927
+ message: "AMP now routes via LLM Router.",
2590
2928
  amp: {
2591
2929
  patchResult,
2592
2930
  bootstrapDefaultRoute: bootstrap.bootstrapRouteRef || "",
@@ -2660,7 +2998,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2660
2998
  const snapshot = await broadcastState();
2661
2999
  sendJson(res, 200, {
2662
3000
  ...snapshot,
2663
- message: "Codex CLI now routes via LLM-Router.",
3001
+ message: "Codex CLI now routes via LLM Router.",
2664
3002
  codingTools: {
2665
3003
  ...(snapshot.codingTools || {}),
2666
3004
  codexCli: {
@@ -2689,7 +3027,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2689
3027
  return;
2690
3028
  }
2691
3029
  if (!routingState.routedViaRouter) {
2692
- sendJson(res, 400, { error: "Connect Codex CLI to LLM-Router before updating model bindings." });
3030
+ sendJson(res, 400, { error: "Connect Codex CLI to LLM Router before updating model bindings." });
2693
3031
  return;
2694
3032
  }
2695
3033
 
@@ -2702,7 +3040,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2702
3040
  captureBackup: false,
2703
3041
  env: codexCliEnv
2704
3042
  });
2705
- addLog("success", "Codex CLI model binding updated.", patchResult.bindings.defaultModel || "No model selected");
3043
+ addLog("success", "Codex CLI model binding updated.", formatCodexBindingLabel(patchResult.bindings.defaultModel));
2706
3044
  const snapshot = await broadcastState();
2707
3045
  sendJson(res, 200, {
2708
3046
  ...snapshot,
@@ -2773,7 +3111,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2773
3111
  const snapshot = await broadcastState();
2774
3112
  sendJson(res, 200, {
2775
3113
  ...snapshot,
2776
- message: "Claude Code now routes via LLM-Router.",
3114
+ message: "Claude Code now routes via LLM Router.",
2777
3115
  codingTools: {
2778
3116
  ...(snapshot.codingTools || {}),
2779
3117
  claudeCode: {
@@ -2802,7 +3140,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2802
3140
  return;
2803
3141
  }
2804
3142
  if (!routingState.routedViaRouter) {
2805
- sendJson(res, 400, { error: "Connect Claude Code to LLM-Router before updating model bindings." });
3143
+ sendJson(res, 400, { error: "Connect Claude Code to LLM Router before updating model bindings." });
2806
3144
  return;
2807
3145
  }
2808
3146
 
@@ -2831,6 +3169,49 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2831
3169
  return;
2832
3170
  }
2833
3171
 
3172
+ if (method === "POST" && requestUrl.pathname === "/api/file/open") {
3173
+ const body = await readJsonBody(req);
3174
+ const editorId = String(body?.editorId || "default").trim() || "default";
3175
+ const rawFilePath = String(body?.filePath || "").trim();
3176
+ const ensureMode = String(body?.ensureMode || "none").trim() || "none";
3177
+ if (!rawFilePath) {
3178
+ sendJson(res, 400, { error: "A file path is required." });
3179
+ return;
3180
+ }
3181
+ if (!["none", "text", "jsonObject"].includes(ensureMode)) {
3182
+ sendJson(res, 400, { error: "Unsupported file ensure mode." });
3183
+ return;
3184
+ }
3185
+
3186
+ const filePath = path.resolve(rawFilePath);
3187
+ if (ensureMode === "jsonObject") {
3188
+ await ensureJsonObjectFileExists(filePath, {});
3189
+ } else if (ensureMode === "text") {
3190
+ await ensureTextFileExists(filePath, "");
3191
+ } else {
3192
+ try {
3193
+ await fs.access(filePath);
3194
+ } catch (error) {
3195
+ if (error && typeof error === "object" && error.code === "ENOENT") {
3196
+ const missingFileError = new Error(`File does not exist yet: ${filePath}`);
3197
+ missingFileError.statusCode = 404;
3198
+ throw missingFileError;
3199
+ }
3200
+ throw error;
3201
+ }
3202
+ }
3203
+
3204
+ await openFileInEditorFn(editorId, filePath);
3205
+ addLog("info", `Opened file in ${editorId}.`, filePath);
3206
+ sendJson(res, 200, {
3207
+ ok: true,
3208
+ editorId,
3209
+ filePath,
3210
+ ensureMode
3211
+ });
3212
+ return;
3213
+ }
3214
+
2834
3215
  if (method === "POST" && requestUrl.pathname === "/api/amp/config/open") {
2835
3216
  const body = await readJsonBody(req);
2836
3217
  const editorId = String(body?.editorId || "default").trim() || "default";
@@ -2952,7 +3333,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
2952
3333
  const externalRuntime = await readExternalRuntime();
2953
3334
  if (externalRuntime) {
2954
3335
  await stopExternalRuntime(externalRuntime, {
2955
- reason: "Stopped another llm-router instance before enabling startup."
3336
+ reason: "Stopped another LLM Router instance before enabling startup."
2956
3337
  });
2957
3338
  }
2958
3339
 
@@ -3134,6 +3515,7 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
3134
3515
  addLog("info", `Web console listening on http://${formatHostForUrl(host, actualWebPort)}`);
3135
3516
  if (devMode) addLog("info", "Development mode enabled for web assets.");
3136
3517
  startConfigWatcher();
3518
+ startActivityLogWatcher();
3137
3519
 
3138
3520
  try {
3139
3521
  await reconcileManagedRouterWithConfig({ reason: "web-console-startup" });