@khanglvm/llm-router 2.3.1 → 2.3.2

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 (35) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +2 -2
  3. package/package.json +1 -1
  4. package/src/cli/router-module.js +32 -5
  5. package/src/node/coding-tool-config.js +138 -25
  6. package/src/node/large-request-log.js +54 -0
  7. package/src/node/litellm-context-catalog.js +13 -1
  8. package/src/node/local-server.js +10 -0
  9. package/src/node/ollama-client.js +195 -0
  10. package/src/node/ollama-hardware.js +94 -0
  11. package/src/node/ollama-install.js +230 -0
  12. package/src/node/provider-probe.js +69 -5
  13. package/src/node/web-console-client.js +36 -36
  14. package/src/node/web-console-server.js +478 -8
  15. package/src/node/web-console-styles.generated.js +1 -1
  16. package/src/node/web-console-ui/amp-utils.js +272 -0
  17. package/src/node/web-console-ui/api-client.js +128 -0
  18. package/src/node/web-console-ui/capability-utils.js +36 -0
  19. package/src/node/web-console-ui/config-editor-utils.js +20 -5
  20. package/src/node/web-console-ui/constants.js +140 -0
  21. package/src/node/web-console-ui/context-window-utils.js +262 -0
  22. package/src/node/web-console-ui/hooks/use-reorder-layout-animation.js +65 -0
  23. package/src/node/web-console-ui/provider-presets.js +211 -0
  24. package/src/node/web-console-ui/quick-start-utils.js +790 -0
  25. package/src/node/web-console-ui/utils.js +353 -0
  26. package/src/node/web-console-ui/web-search-utils.js +460 -0
  27. package/src/runtime/config.js +96 -9
  28. package/src/runtime/handler/fallback.js +71 -0
  29. package/src/runtime/handler/field-filter.js +39 -0
  30. package/src/runtime/handler/large-request-log.js +211 -0
  31. package/src/runtime/handler/provider-call.js +185 -15
  32. package/src/runtime/handler/reasoning-effort.js +11 -1
  33. package/src/runtime/handler/tool-name-sanitizer.js +258 -0
  34. package/src/runtime/handler.js +16 -3
  35. package/src/shared/coding-tool-bindings.js +3 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.3.2] - 2026-04-17
11
+
12
+ ### Fixed
13
+ - Web UI provider presets now correctly populate the Endpoints field during add-provider setup when the current value is still empty or still on a prior preset default, while preserving any manually entered endpoint or API key values.
14
+
10
15
  ## [2.3.0] - 2026-03-24
11
16
 
12
17
  ### Added
package/README.md CHANGED
@@ -18,13 +18,13 @@ llr start # start the local gateway
18
18
  llr ai-help # agent-oriented setup brief
19
19
  ```
20
20
 
21
- 1. Open the Web UI and add a provider (API key or OAuth login)
21
+ 1. Open the Web UI and add a provider (API key or OAuth login). Built-in provider presets prefill the usual endpoint and starter models.
22
22
  2. Create model aliases with routing strategy
23
23
  3. Start the gateway and point your tools at the local endpoint
24
24
 
25
25
  ## What You Can Do
26
26
 
27
- - **Add & manage providers** — connect any OpenAI/Anthropic-compatible API endpoint, test connectivity, auto-discover models
27
+ - **Add & manage providers** — connect any OpenAI/Anthropic-compatible API endpoint, start from built-in provider presets, test connectivity, auto-discover models
28
28
  - **Unified endpoint** — one local gateway that accepts both OpenAI and Anthropic request formats
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/llm-router",
3
- "version": "2.3.1",
3
+ "version": "2.3.2",
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",
@@ -123,7 +123,7 @@ const WEAK_MASTER_KEY_PATTERN = /(password|changeme|default|secret|token|admin|q
123
123
  export const CLOUDFLARE_FREE_SECRET_SIZE_LIMIT_BYTES = 5 * 1024;
124
124
  const CLOUDFLARE_FREE_TIER_PATTERN = /\bfree\b/i;
125
125
  const CLOUDFLARE_PAID_TIER_PATTERN = /\b(pro|business|enterprise|paid|unbound)\b/i;
126
- const MODEL_ALIAS_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
126
+ const MODEL_ALIAS_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:\-\[\]]*$/;
127
127
  const MODEL_ROUTING_STRATEGY_OPTIONS = [
128
128
  {
129
129
  value: "auto",
@@ -3931,7 +3931,11 @@ function buildFactoryDroidStatusSection(state = {}) {
3931
3931
  ["Settings File", state.settingsFilePath || resolveFactoryDroidSettingsFilePath({})],
3932
3932
  ["Backup File", state.backupFilePath || "(not created)"],
3933
3933
  ["Base URL", state.configuredBaseUrl || "(not set)"],
3934
+ ["Provider", state.configuredProvider || "(not set)"],
3934
3935
  ["Default Model", state.bindings?.defaultModel || "(not set)"],
3936
+ ["Mission Orchestrator", state.bindings?.missionOrchestratorModel || "(not set)"],
3937
+ ["Mission Worker", state.bindings?.missionWorkerModel || "(not set)"],
3938
+ ["Mission Validator", state.bindings?.missionValidatorModel || "(not set)"],
3935
3939
  ["Reasoning Effort", state.bindings?.reasoningEffort || "(not set)"],
3936
3940
  ["Error", state.error || "(none)"]
3937
3941
  ]);
@@ -6646,10 +6650,23 @@ async function doSetFactoryDroidRouting(context) {
6646
6650
  }
6647
6651
 
6648
6652
  const existingBindings = existingState.bindings || {};
6653
+ const resolvedDefaultModel = String(readArg(args, ["default-model", "defaultModel"], undefined) !== undefined
6654
+ ? readArg(args, ["default-model", "defaultModel"], "")
6655
+ : (existingBindings.defaultModel || pickDefaultManagedRoute(config) || "")).trim();
6656
+ const legacyMissionModel = String(readArg(args, ["mission-model", "missionModel"], undefined) !== undefined
6657
+ ? readArg(args, ["mission-model", "missionModel"], "")
6658
+ : "").trim();
6649
6659
  const bindings = {
6650
- defaultModel: String(readArg(args, ["default-model", "defaultModel"], undefined) !== undefined
6651
- ? readArg(args, ["default-model", "defaultModel"], "")
6652
- : (existingBindings.defaultModel || pickDefaultManagedRoute(config) || "")).trim(),
6660
+ defaultModel: resolvedDefaultModel,
6661
+ missionOrchestratorModel: String(readArg(args, ["mission-orchestrator-model", "missionOrchestratorModel"], undefined) !== undefined
6662
+ ? readArg(args, ["mission-orchestrator-model", "missionOrchestratorModel"], "")
6663
+ : (existingBindings.missionOrchestratorModel || legacyMissionModel || resolvedDefaultModel || "")).trim(),
6664
+ missionWorkerModel: String(readArg(args, ["mission-worker-model", "missionWorkerModel"], undefined) !== undefined
6665
+ ? readArg(args, ["mission-worker-model", "missionWorkerModel"], "")
6666
+ : (existingBindings.missionWorkerModel || legacyMissionModel || resolvedDefaultModel || "")).trim(),
6667
+ missionValidatorModel: String(readArg(args, ["mission-validator-model", "missionValidatorModel"], undefined) !== undefined
6668
+ ? readArg(args, ["mission-validator-model", "missionValidatorModel"], "")
6669
+ : (existingBindings.missionValidatorModel || legacyMissionModel || resolvedDefaultModel || "")).trim(),
6653
6670
  reasoningEffort: normalizeFactoryDroidReasoningEffort(
6654
6671
  readArg(args, ["reasoning-effort", "reasoningEffort"], undefined) !== undefined
6655
6672
  ? readArg(args, ["reasoning-effort", "reasoningEffort"], "")
@@ -6674,7 +6691,11 @@ async function doSetFactoryDroidRouting(context) {
6674
6691
  ["Settings File", patchResult.settingsFilePath],
6675
6692
  ["Backup File", patchResult.backupFilePath],
6676
6693
  ["Base URL", patchResult.baseUrl],
6694
+ ["Provider", patchResult.configuredProvider || "generic-chat-completion-api"],
6677
6695
  ["Default Model", patchResult.bindings?.defaultModel || "(not set)"],
6696
+ ["Mission Orchestrator", patchResult.bindings?.missionOrchestratorModel || "(not set)"],
6697
+ ["Mission Worker", patchResult.bindings?.missionWorkerModel || "(not set)"],
6698
+ ["Mission Validator", patchResult.bindings?.missionValidatorModel || "(not set)"],
6678
6699
  ["Reasoning Effort", patchResult.bindings?.reasoningEffort || "(not set)"]
6679
6700
  ]
6680
6701
  )
@@ -9430,6 +9451,8 @@ async function runAiHelpAction(context) {
9430
9451
  "### Factory Droid",
9431
9452
  "- required_gate=patch_gate_factory_droid=ready",
9432
9453
  `- enable/update route: ${CLI_COMMAND} config --operation=set-factory-droid-routing --enabled=true --default-model=<target_model_or_group>`,
9454
+ `- optional mission bindings: --mission-orchestrator-model=<route> --mission-worker-model=<route> --mission-validator-model=<route>`,
9455
+ `- legacy shortcut: --mission-model=<target_model_or_group> sets all three Mission defaults`,
9433
9456
  `- optional reasoning: --reasoning-effort=off|none|low|medium|high`,
9434
9457
  `- disable route: ${CLI_COMMAND} config --operation=set-factory-droid-routing --enabled=false`,
9435
9458
  "",
@@ -10701,6 +10724,10 @@ const routerModule = {
10701
10724
  { name: "master-key-length", required: false, description: "Generated master key length (min 24).", example: "--master-key-length=48" },
10702
10725
  { name: "master-key-prefix", required: false, description: "Generated master key prefix.", example: "--master-key-prefix=gw_" },
10703
10726
  { name: "default-model", required: false, description: `For set-codex-cli-routing / set-factory-droid-routing: managed route binding, or ${CODEX_CLI_INHERIT_MODEL_VALUE} to keep Codex's own model selection.`, example: "--default-model=chat.default" },
10727
+ { name: "mission-model", required: false, description: "Legacy shortcut for set-factory-droid-routing: applies the same managed route binding to all Factory Mission defaults.", example: "--mission-model=chat.deep" },
10728
+ { name: "mission-orchestrator-model", required: false, description: "For set-factory-droid-routing: managed route binding for Factory mission orchestration.", example: "--mission-orchestrator-model=chat.plan" },
10729
+ { name: "mission-worker-model", required: false, description: "For set-factory-droid-routing: managed route binding for Factory mission workers.", example: "--mission-worker-model=chat.build" },
10730
+ { name: "mission-validator-model", required: false, description: "For set-factory-droid-routing: managed route binding for Factory mission validators.", example: "--mission-validator-model=chat.review" },
10704
10731
  { name: "thinking-level", required: false, description: "For set-codex-cli-routing / set-claude-code-routing / set-claude-code-effort-level: reasoning level.", example: "--thinking-level=medium" },
10705
10732
  { name: "reasoning-effort", required: false, description: "For set-factory-droid-routing: reasoning effort level (off, none, low, medium, high).", example: "--reasoning-effort=medium" },
10706
10733
  { name: "factory-droid-settings-file", required: false, description: "Explicit Factory Droid settings.json path for routing/status operations.", example: "--factory-droid-settings-file=~/.factory/settings.json" },
@@ -10775,7 +10802,7 @@ const routerModule = {
10775
10802
  `${CLI_COMMAND} config --operation=set-codex-cli-routing --enabled=true --default-model=chat.default`,
10776
10803
  `${CLI_COMMAND} config --operation=set-claude-code-routing --enabled=true --primary-model=chat.default --default-haiku-model=chat.fast`,
10777
10804
  `${CLI_COMMAND} config --operation=set-claude-code-effort-level --thinking-level=high`,
10778
- `${CLI_COMMAND} config --operation=set-factory-droid-routing --enabled=true --default-model=chat.default --reasoning-effort=medium`,
10805
+ `${CLI_COMMAND} config --operation=set-factory-droid-routing --enabled=true --default-model=chat.default --mission-orchestrator-model=chat.plan --mission-worker-model=chat.build --mission-validator-model=chat.review --reasoning-effort=medium`,
10779
10806
  `${CLI_COMMAND} config --operation=set-amp-client-routing --enabled=true --amp-client-settings-scope=workspace`,
10780
10807
  `${CLI_COMMAND} config --operation=set-amp-config --patch-amp-client-config=true --amp-client-settings-scope=workspace --amp-client-url=${LOCAL_ROUTER_ORIGIN} --amp-client-api-key=gw_...`,
10781
10808
  `${CLI_COMMAND} config --operation=list-routing`,
@@ -966,11 +966,16 @@ export async function patchClaudeCodeEffortLevel({
966
966
  }
967
967
 
968
968
  const FACTORY_DROID_ROUTER_MARKER = "_llmRouterManaged";
969
+ const FACTORY_DROID_ROUTER_PROVIDER = "generic-chat-completion-api";
969
970
 
970
971
  function normalizeFactoryDroidBindings(bindings = {}) {
971
972
  const source = bindings && typeof bindings === "object" && !Array.isArray(bindings) ? bindings : {};
973
+ const legacyMissionModel = normalizeModelBinding(source.missionModel);
972
974
  return {
973
975
  defaultModel: normalizeModelBinding(source.defaultModel),
976
+ missionOrchestratorModel: normalizeModelBinding(source.missionOrchestratorModel) || legacyMissionModel,
977
+ missionWorkerModel: normalizeModelBinding(source.missionWorkerModel) || legacyMissionModel,
978
+ missionValidatorModel: normalizeModelBinding(source.missionValidatorModel) || legacyMissionModel,
974
979
  reasoningEffort: normalizeFactoryDroidReasoningEffort(source.reasoningEffort)
975
980
  };
976
981
  }
@@ -987,15 +992,95 @@ function findRouterManagedCustomModelIndex(customModels) {
987
992
  );
988
993
  }
989
994
 
995
+ function getRouterManagedCustomModel(customModels) {
996
+ const routerIndex = findRouterManagedCustomModelIndex(customModels);
997
+ return routerIndex >= 0 ? customModels[routerIndex] : null;
998
+ }
999
+
1000
+ function stripRouterManagedCustomModels(customModels) {
1001
+ if (!Array.isArray(customModels)) return [];
1002
+ return customModels.filter(
1003
+ (entry) => !(entry && typeof entry === "object" && entry[FACTORY_DROID_ROUTER_MARKER] === true)
1004
+ );
1005
+ }
1006
+
1007
+ function getNestedObjectValue(source, keys = []) {
1008
+ let current = source;
1009
+ for (const key of keys) {
1010
+ if (!current || typeof current !== "object" || Array.isArray(current) || !(key in current)) {
1011
+ return undefined;
1012
+ }
1013
+ current = current[key];
1014
+ }
1015
+ return current;
1016
+ }
1017
+
1018
+ function setNestedObjectValue(target, keys = [], value) {
1019
+ if (!target || typeof target !== "object" || Array.isArray(target) || !Array.isArray(keys) || keys.length === 0) {
1020
+ return;
1021
+ }
1022
+
1023
+ let current = target;
1024
+ for (let index = 0; index < keys.length - 1; index += 1) {
1025
+ const key = keys[index];
1026
+ const nextValue = current[key];
1027
+ if (!nextValue || typeof nextValue !== "object" || Array.isArray(nextValue)) {
1028
+ current[key] = {};
1029
+ }
1030
+ current = current[key];
1031
+ }
1032
+
1033
+ current[keys[keys.length - 1]] = value;
1034
+ }
1035
+
1036
+ function deleteNestedObjectValue(target, keys = []) {
1037
+ if (!target || typeof target !== "object" || Array.isArray(target) || !Array.isArray(keys) || keys.length === 0) {
1038
+ return;
1039
+ }
1040
+
1041
+ const parents = [];
1042
+ let current = target;
1043
+ for (let index = 0; index < keys.length - 1; index += 1) {
1044
+ const key = keys[index];
1045
+ if (!current[key] || typeof current[key] !== "object" || Array.isArray(current[key])) {
1046
+ return;
1047
+ }
1048
+ parents.push([current, key]);
1049
+ current = current[key];
1050
+ }
1051
+
1052
+ delete current[keys[keys.length - 1]];
1053
+
1054
+ for (let index = parents.length - 1; index >= 0; index -= 1) {
1055
+ const [parent, key] = parents[index];
1056
+ const value = parent[key];
1057
+ if (value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0) {
1058
+ delete parent[key];
1059
+ continue;
1060
+ }
1061
+ break;
1062
+ }
1063
+ }
1064
+
1065
+ function applyNestedBackupValue(target, keys = [], snapshot) {
1066
+ if (snapshot?.exists) {
1067
+ setNestedObjectValue(target, keys, String(snapshot.value || ""));
1068
+ return;
1069
+ }
1070
+ deleteNestedObjectValue(target, keys);
1071
+ }
1072
+
990
1073
  function captureFactoryDroidBackup(config) {
991
1074
  const customModels = Array.isArray(config?.customModels) ? config.customModels : [];
992
- const model = String(config?.model || "").trim();
993
- const reasoningEffort = String(config?.reasoningEffort || "").trim();
994
1075
  return {
995
1076
  tool: "factory-droid",
996
- version: 1,
997
- model: { exists: Boolean(model), value: model },
998
- reasoningEffort: { exists: Boolean(reasoningEffort), value: reasoningEffort },
1077
+ version: 2,
1078
+ model: getBackupValue(config?.model),
1079
+ sessionDefaultModel: getBackupValue(getNestedObjectValue(config, ["sessionDefaultSettings", "model"])),
1080
+ missionOrchestratorModel: getBackupValue(config?.missionOrchestratorModel),
1081
+ missionWorkerModel: getBackupValue(getNestedObjectValue(config, ["missionModelSettings", "workerModel"])),
1082
+ missionValidationWorkerModel: getBackupValue(getNestedObjectValue(config, ["missionModelSettings", "validationWorkerModel"])),
1083
+ reasoningEffort: getBackupValue(config?.reasoningEffort),
999
1084
  hadCustomModels: customModels.length > 0
1000
1085
  };
1001
1086
  }
@@ -1005,17 +1090,16 @@ function applyFactoryDroidBackup(config, backup = {}) {
1005
1090
  ? structuredClone(config)
1006
1091
  : {};
1007
1092
 
1008
- const customModels = Array.isArray(next.customModels) ? [...next.customModels] : [];
1009
- const routerIndex = findRouterManagedCustomModelIndex(customModels);
1010
- if (routerIndex >= 0) customModels.splice(routerIndex, 1);
1093
+ const customModels = stripRouterManagedCustomModels(next.customModels);
1011
1094
  if (customModels.length > 0) next.customModels = customModels;
1012
1095
  else delete next.customModels;
1013
1096
 
1014
- if (backup?.model?.exists) next.model = backup.model.value;
1015
- else delete next.model;
1016
-
1017
- if (backup?.reasoningEffort?.exists) next.reasoningEffort = backup.reasoningEffort.value;
1018
- else delete next.reasoningEffort;
1097
+ applyBackupValue(next, "model", backup?.model);
1098
+ applyNestedBackupValue(next, ["sessionDefaultSettings", "model"], backup?.sessionDefaultModel);
1099
+ applyBackupValue(next, "missionOrchestratorModel", backup?.missionOrchestratorModel);
1100
+ applyNestedBackupValue(next, ["missionModelSettings", "workerModel"], backup?.missionWorkerModel);
1101
+ applyNestedBackupValue(next, ["missionModelSettings", "validationWorkerModel"], backup?.missionValidationWorkerModel);
1102
+ applyBackupValue(next, "reasoningEffort", backup?.reasoningEffort);
1019
1103
 
1020
1104
  return next;
1021
1105
  }
@@ -1060,9 +1144,9 @@ export async function readFactoryDroidRoutingState({
1060
1144
  const settingsState = await readJsonObjectFile(resolvedSettingsPath, `Factory Droid settings file '${resolvedSettingsPath}'`);
1061
1145
  const backupState = await readJsonObjectFile(resolvedBackupPath, `Backup file '${resolvedBackupPath}'`);
1062
1146
  const customModels = Array.isArray(settingsState.data?.customModels) ? settingsState.data.customModels : [];
1063
- const routerIndex = findRouterManagedCustomModelIndex(customModels);
1064
- const routerEntry = routerIndex >= 0 ? customModels[routerIndex] : null;
1147
+ const routerEntry = getRouterManagedCustomModel(customModels);
1065
1148
  const configuredBaseUrl = routerEntry ? String(routerEntry.baseUrl || "").trim() : "";
1149
+ const configuredProvider = routerEntry ? String(routerEntry.provider || "").trim() : "";
1066
1150
  const routedViaRouter = Boolean(
1067
1151
  expectedBaseUrl
1068
1152
  && routerEntry
@@ -1077,8 +1161,15 @@ export async function readFactoryDroidRoutingState({
1077
1161
  backupExists: backupState.existed,
1078
1162
  routedViaRouter,
1079
1163
  configuredBaseUrl,
1164
+ configuredProvider,
1080
1165
  bindings: normalizeFactoryDroidBindings({
1081
- defaultModel: routerEntry?.model || settingsState.data?.model || "",
1166
+ defaultModel: getNestedObjectValue(settingsState.data, ["sessionDefaultSettings", "model"])
1167
+ || settingsState.data?.model
1168
+ || routerEntry?.model
1169
+ || "",
1170
+ missionOrchestratorModel: settingsState.data?.missionOrchestratorModel || "",
1171
+ missionWorkerModel: getNestedObjectValue(settingsState.data, ["missionModelSettings", "workerModel"]) || "",
1172
+ missionValidatorModel: getNestedObjectValue(settingsState.data, ["missionModelSettings", "validationWorkerModel"]) || "",
1082
1173
  reasoningEffort: normalizeFactoryDroidReasoningEffort(settingsState.data?.reasoningEffort)
1083
1174
  })
1084
1175
  };
@@ -1118,26 +1209,47 @@ export async function patchFactoryDroidSettingsFile({
1118
1209
  await writeJsonObjectFile(resolvedBackupPath, backup);
1119
1210
  }
1120
1211
 
1121
- const customModels = Array.isArray(nextSettings.customModels) ? [...nextSettings.customModels] : [];
1122
- const routerIndex = findRouterManagedCustomModelIndex(customModels);
1212
+ const customModels = stripRouterManagedCustomModels(nextSettings.customModels);
1123
1213
  const routerEntry = {
1124
1214
  [FACTORY_DROID_ROUTER_MARKER]: true,
1125
- model: normalizedBindings.defaultModel || "llm-router",
1215
+ model: normalizedBindings.defaultModel
1216
+ || normalizedBindings.missionOrchestratorModel
1217
+ || normalizedBindings.missionWorkerModel
1218
+ || normalizedBindings.missionValidatorModel
1219
+ || "llm-router",
1126
1220
  displayName: "LLM Router",
1127
1221
  baseUrl,
1128
1222
  apiKey: normalizedApiKey,
1129
- provider: "openai"
1223
+ provider: FACTORY_DROID_ROUTER_PROVIDER
1130
1224
  };
1131
1225
 
1132
- if (routerIndex >= 0) {
1133
- customModels[routerIndex] = routerEntry;
1134
- } else {
1135
- customModels.push(routerEntry);
1136
- }
1226
+ customModels.push(routerEntry);
1137
1227
  nextSettings.customModels = customModels;
1138
1228
 
1139
1229
  if (normalizedBindings.defaultModel) {
1140
1230
  nextSettings.model = normalizedBindings.defaultModel;
1231
+ setNestedObjectValue(nextSettings, ["sessionDefaultSettings", "model"], normalizedBindings.defaultModel);
1232
+ } else {
1233
+ delete nextSettings.model;
1234
+ deleteNestedObjectValue(nextSettings, ["sessionDefaultSettings", "model"]);
1235
+ }
1236
+
1237
+ if (normalizedBindings.missionOrchestratorModel) {
1238
+ nextSettings.missionOrchestratorModel = normalizedBindings.missionOrchestratorModel;
1239
+ } else {
1240
+ delete nextSettings.missionOrchestratorModel;
1241
+ }
1242
+
1243
+ if (normalizedBindings.missionWorkerModel) {
1244
+ setNestedObjectValue(nextSettings, ["missionModelSettings", "workerModel"], normalizedBindings.missionWorkerModel);
1245
+ } else {
1246
+ deleteNestedObjectValue(nextSettings, ["missionModelSettings", "workerModel"]);
1247
+ }
1248
+
1249
+ if (normalizedBindings.missionValidatorModel) {
1250
+ setNestedObjectValue(nextSettings, ["missionModelSettings", "validationWorkerModel"], normalizedBindings.missionValidatorModel);
1251
+ } else {
1252
+ deleteNestedObjectValue(nextSettings, ["missionModelSettings", "validationWorkerModel"]);
1141
1253
  }
1142
1254
 
1143
1255
  if (normalizedBindings.reasoningEffort) {
@@ -1152,6 +1264,7 @@ export async function patchFactoryDroidSettingsFile({
1152
1264
  backupFilePath: resolvedBackupPath,
1153
1265
  settingsCreated: !settingsState.existed,
1154
1266
  baseUrl,
1267
+ configuredProvider: FACTORY_DROID_ROUTER_PROVIDER,
1155
1268
  bindings: normalizedBindings
1156
1269
  };
1157
1270
  }
@@ -0,0 +1,54 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { promises as fs } from "node:fs";
4
+ import {
5
+ LARGE_REQUEST_LOG_PATH_ENV
6
+ } from "../runtime/handler/large-request-log.js";
7
+
8
+ export const DEFAULT_LARGE_REQUEST_LOG_FILENAME = ".llm-router.large-requests.jsonl";
9
+
10
+ function normalizeText(value, fallback = "") {
11
+ const text = String(value ?? fallback).trim();
12
+ return text || fallback;
13
+ }
14
+
15
+ function createEntryId() {
16
+ return `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
17
+ }
18
+
19
+ export function resolveLargeRequestLogPath(configPath = "", explicitPath = "", env = process.env) {
20
+ const envOverride = String(env?.[LARGE_REQUEST_LOG_PATH_ENV] || "").trim();
21
+ const override = String(explicitPath || envOverride).trim();
22
+ if (override) return path.resolve(override);
23
+
24
+ const resolvedConfigPath = String(configPath || "").trim();
25
+ if (resolvedConfigPath) {
26
+ const absoluteConfigPath = path.resolve(resolvedConfigPath);
27
+ const configDir = path.dirname(absoluteConfigPath);
28
+ const configName = path.basename(absoluteConfigPath);
29
+ const stem = configName
30
+ .replace(/\.[^.]+$/, "")
31
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
32
+ .replace(/-+/g, "-")
33
+ .replace(/^-+|-+$/g, "");
34
+ return path.join(configDir, `.${stem || "llm-router"}.large-requests.jsonl`);
35
+ }
36
+
37
+ return path.join(os.homedir(), DEFAULT_LARGE_REQUEST_LOG_FILENAME);
38
+ }
39
+
40
+ export function createLargeRequestLogEntry(entry = {}) {
41
+ return {
42
+ id: normalizeText(entry.id) || createEntryId(),
43
+ time: normalizeText(entry.time) || new Date().toISOString(),
44
+ ...entry
45
+ };
46
+ }
47
+
48
+ export async function appendLargeRequestLogEntry(filePath, entry) {
49
+ const targetPath = resolveLargeRequestLogPath("", filePath);
50
+ const normalized = createLargeRequestLogEntry(entry);
51
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
52
+ await fs.appendFile(targetPath, `${JSON.stringify(normalized)}\n`, { encoding: "utf8", mode: 0o600 });
53
+ return normalized;
54
+ }
@@ -73,12 +73,24 @@ function extractLiteLlmContextWindow(entry) {
73
73
  return null;
74
74
  }
75
75
 
76
+ function extractLiteLlmCapabilities(entry = {}) {
77
+ const capabilities = {};
78
+ let hasAny = false;
79
+ if (typeof entry.supports_response_schema === "boolean") {
80
+ capabilities.supportsResponseFormat = entry.supports_response_schema;
81
+ hasAny = true;
82
+ }
83
+ return hasAny ? capabilities : undefined;
84
+ }
85
+
76
86
  function createLiteLlmLookupResult(modelName, entry = {}) {
87
+ const capabilities = extractLiteLlmCapabilities(entry);
77
88
  return {
78
89
  model: String(modelName || "").trim(),
79
90
  contextWindow: extractLiteLlmContextWindow(entry),
80
91
  provider: String(entry?.litellm_provider || "").trim(),
81
- mode: String(entry?.mode || "").trim()
92
+ mode: String(entry?.mode || "").trim(),
93
+ ...(capabilities ? { capabilities } : {})
82
94
  };
83
95
  }
84
96
 
@@ -11,6 +11,8 @@ import { readConfigFile, getDefaultConfigPath } from "./config-store.js";
11
11
  import { FIXED_LOCAL_ROUTER_HOST, FIXED_LOCAL_ROUTER_PORT } from "./local-server-settings.js";
12
12
  import { readActivityLogSettings } from "../shared/local-router-defaults.js";
13
13
  import { appendActivityLogEntry, resolveActivityLogPath } from "./activity-log.js";
14
+ import { appendLargeRequestLogEntry, resolveLargeRequestLogPath } from "./large-request-log.js";
15
+ import { isLargeRequestLoggingEnabled } from "../runtime/handler/large-request-log.js";
14
16
 
15
17
  const DEFAULT_CONFIG_RELOAD_DEBOUNCE_MS = 300;
16
18
  const MAX_CONFIG_RELOAD_DEBOUNCE_MS = 5000;
@@ -240,6 +242,7 @@ export async function startLocalRouteServer({
240
242
  host = FIXED_LOCAL_ROUTER_HOST,
241
243
  configPath = getDefaultConfigPath(),
242
244
  activityLogPath = "",
245
+ largeRequestLogPath = "",
243
246
  watchConfig = true,
244
247
  configReloadDebounceMs = process.env.LLM_ROUTER_CONFIG_RELOAD_DEBOUNCE_MS,
245
248
  validateConfig,
@@ -249,6 +252,7 @@ export async function startLocalRouteServer({
249
252
  } = {}) {
250
253
  const reloadDebounceMs = resolveReloadDebounceMs(configReloadDebounceMs);
251
254
  const resolvedActivityLogPath = resolveActivityLogPath(configPath, activityLogPath);
255
+ const resolvedLargeRequestLogPath = resolveLargeRequestLogPath(configPath, largeRequestLogPath, process.env);
252
256
  let activityLogEnabled = true;
253
257
  const configStore = createLiveConfigStore({
254
258
  configPath,
@@ -278,6 +282,12 @@ export async function startLocalRouteServer({
278
282
  }).catch((error) => {
279
283
  console.warn(`[llm-router] Failed writing activity log: ${formatError(error)}`);
280
284
  });
285
+ },
286
+ onLargeRequestLog: (entry) => {
287
+ if (!isLargeRequestLoggingEnabled(process.env)) return;
288
+ void appendLargeRequestLogEntry(resolvedLargeRequestLogPath, entry).catch((error) => {
289
+ console.warn(`[llm-router] Failed writing large request log: ${formatError(error)}`);
290
+ });
281
291
  }
282
292
  });
283
293