@khanglvm/llm-router 2.3.5 → 2.3.7

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.
@@ -4,14 +4,18 @@ import { promises as fs } from "node:fs";
4
4
  import {
5
5
  CODEX_CLI_INHERIT_MODEL_VALUE,
6
6
  CLAUDE_CODE_EFFORT_LEVEL_SETTINGS_JSON_VALUE,
7
+ buildFactoryDroidRouterDisplayName,
8
+ buildFactoryDroidRouterModelId,
7
9
  isCodexCliInheritModelBinding,
10
+ isFactoryDroidRouterModelId,
8
11
  mapClaudeCodeThinkingLevelToTokens,
9
12
  mapClaudeCodeThinkingTokensToLevel,
10
13
  normalizeClaudeCodeThinkingLevel,
11
14
  normalizeClaudeCodeEffortLevel,
12
15
  migrateLegacyThinkingTokensToEffortLevel,
13
16
  normalizeCodexCliReasoningEffort,
14
- normalizeFactoryDroidReasoningEffort
17
+ normalizeFactoryDroidReasoningEffort,
18
+ resolveFactoryDroidRouterModelRef
15
19
  } from "../shared/coding-tool-bindings.js";
16
20
 
17
21
  const BACKUP_SUFFIX = ".llm_router_backup";
@@ -966,7 +970,38 @@ export async function patchClaudeCodeEffortLevel({
966
970
  }
967
971
 
968
972
  const FACTORY_DROID_ROUTER_MARKER = "_llmRouterManaged";
969
- const FACTORY_DROID_ROUTER_PROVIDER = "generic-chat-completion-api";
973
+ const FACTORY_DROID_OPENAI_PROVIDER = "openai";
974
+ const FACTORY_DROID_ANTHROPIC_PROVIDER = "anthropic";
975
+ const FACTORY_DROID_ROUTER_PROVIDERS = Object.freeze([
976
+ FACTORY_DROID_OPENAI_PROVIDER,
977
+ FACTORY_DROID_ANTHROPIC_PROVIDER
978
+ ]);
979
+
980
+ function dedupeStrings(values = []) {
981
+ const seen = new Set();
982
+ const out = [];
983
+ for (const value of values) {
984
+ const normalized = String(value || "").trim();
985
+ if (!normalized || seen.has(normalized)) continue;
986
+ seen.add(normalized);
987
+ out.push(normalized);
988
+ }
989
+ return out;
990
+ }
991
+
992
+ function normalizeFactoryDroidFormat(value) {
993
+ const normalized = String(value || "").trim().toLowerCase();
994
+ if (normalized === "openai") return "openai";
995
+ if (normalized === "claude" || normalized === "anthropic") return "claude";
996
+ return "";
997
+ }
998
+
999
+ function mapFactoryDroidFormatToProvider(format) {
1000
+ const normalized = normalizeFactoryDroidFormat(format);
1001
+ if (normalized === "claude") return FACTORY_DROID_ANTHROPIC_PROVIDER;
1002
+ if (normalized === "openai") return FACTORY_DROID_OPENAI_PROVIDER;
1003
+ return "";
1004
+ }
970
1005
 
971
1006
  function normalizeFactoryDroidBindings(bindings = {}) {
972
1007
  const source = bindings && typeof bindings === "object" && !Array.isArray(bindings) ? bindings : {};
@@ -980,15 +1015,276 @@ function normalizeFactoryDroidBindings(bindings = {}) {
980
1015
  };
981
1016
  }
982
1017
 
983
- function buildFactoryDroidBaseUrl(endpointUrl) {
1018
+ function buildFactoryDroidBaseUrl(endpointUrl, provider = FACTORY_DROID_OPENAI_PROVIDER) {
984
1019
  const normalized = normalizeHttpUrl(endpointUrl);
985
- return normalized ? `${normalized}/openai/v1` : "";
1020
+ const resolvedProvider = String(provider || "").trim().toLowerCase() || FACTORY_DROID_OPENAI_PROVIDER;
1021
+ if (!normalized) return "";
1022
+ return resolvedProvider === FACTORY_DROID_ANTHROPIC_PROVIDER
1023
+ ? `${normalized}/anthropic`
1024
+ : `${normalized}/openai/v1`;
1025
+ }
1026
+
1027
+ function inferFactoryDroidFormatFromModelId(modelId) {
1028
+ const normalized = String(modelId || "").trim().toLowerCase();
1029
+ if (!normalized) return "";
1030
+ if (/^(?:claude|opus|sonnet|haiku)(?=[-./\s]|$)/i.test(normalized)) return "claude";
1031
+ if (/^gpt(?=[-./\s]|$)/i.test(normalized)) return "openai";
1032
+ return "";
1033
+ }
1034
+
1035
+ function inferFactoryDroidFormatFromProviderId(providerId) {
1036
+ const normalized = String(providerId || "").trim().toLowerCase();
1037
+ if (!normalized) return "";
1038
+ if (normalized === "anthropic") return "claude";
1039
+ if (normalized === "openai") return "openai";
1040
+ return "";
1041
+ }
1042
+
1043
+ function getFactoryDroidProviderModelFormats(provider, model, modelId = "") {
1044
+ const resolvedModelId = String(modelId || model?.id || "").trim();
1045
+ const preferredFormat = normalizeFactoryDroidFormat(provider?.lastProbe?.modelPreferredFormat?.[resolvedModelId]);
1046
+ if (preferredFormat) return [preferredFormat];
1047
+
1048
+ return dedupeStrings([
1049
+ ...(provider?.lastProbe?.modelSupport?.[resolvedModelId] || []),
1050
+ ...(model?.formats || []),
1051
+ model?.format
1052
+ ])
1053
+ .map(normalizeFactoryDroidFormat)
1054
+ .filter(Boolean);
1055
+ }
1056
+
1057
+ function getFactoryDroidProviderFormats(provider) {
1058
+ return dedupeStrings([
1059
+ ...(provider?.formats || []),
1060
+ provider?.format
1061
+ ])
1062
+ .map(normalizeFactoryDroidFormat)
1063
+ .filter(Boolean);
1064
+ }
1065
+
1066
+ function getFactoryDroidAliasTargetRefs(alias) {
1067
+ const refs = [];
1068
+ const push = (entry) => {
1069
+ const ref = String(
1070
+ typeof entry === "string"
1071
+ ? entry
1072
+ : (entry?.ref || entry?.sourceRef || "")
1073
+ ).trim();
1074
+ if (ref) refs.push(ref);
1075
+ };
1076
+
1077
+ for (const entry of Array.isArray(alias?.targets) ? alias.targets : []) push(entry);
1078
+ for (const entry of Array.isArray(alias?.fallbackTargets) ? alias.fallbackTargets : []) push(entry);
1079
+
1080
+ return refs;
1081
+ }
1082
+
1083
+ function resolveFactoryDroidRouteFormat(modelRef, config = {}, seen = new Set()) {
1084
+ const normalizedModelRef = String(modelRef || "").trim();
1085
+ if (!normalizedModelRef || seen.has(normalizedModelRef)) return "";
1086
+
1087
+ if (normalizedModelRef.includes("/")) {
1088
+ const separatorIndex = normalizedModelRef.indexOf("/");
1089
+ const providerId = normalizedModelRef.slice(0, separatorIndex).trim();
1090
+ const modelId = normalizedModelRef.slice(separatorIndex + 1).trim();
1091
+ const provider = (Array.isArray(config?.providers) ? config.providers : [])
1092
+ .find((entry) => String(entry?.id || "").trim() === providerId);
1093
+ const model = Array.isArray(provider?.models)
1094
+ ? provider.models.find((entry) => String(entry?.id || "").trim() === modelId)
1095
+ : null;
1096
+ return getFactoryDroidProviderModelFormats(provider, model, modelId)[0]
1097
+ || inferFactoryDroidFormatFromModelId(modelId)
1098
+ || getFactoryDroidProviderFormats(provider)[0]
1099
+ || inferFactoryDroidFormatFromProviderId(providerId)
1100
+ || "";
1101
+ }
1102
+
1103
+ seen.add(normalizedModelRef);
1104
+ const aliases = config?.modelAliases && typeof config.modelAliases === "object" && !Array.isArray(config.modelAliases)
1105
+ ? config.modelAliases
1106
+ : {};
1107
+ const alias = aliases[normalizedModelRef];
1108
+ if (!alias || typeof alias !== "object" || Array.isArray(alias)) return "";
1109
+
1110
+ for (const targetRef of getFactoryDroidAliasTargetRefs(alias)) {
1111
+ const resolved = resolveFactoryDroidRouteFormat(targetRef, config, new Set(seen));
1112
+ if (resolved) return resolved;
1113
+ }
1114
+
1115
+ return "";
1116
+ }
1117
+
1118
+ function resolveFactoryDroidCustomModelProvider(modelRef, config = {}) {
1119
+ return mapFactoryDroidFormatToProvider(resolveFactoryDroidRouteFormat(modelRef, config))
1120
+ || FACTORY_DROID_OPENAI_PROVIDER;
1121
+ }
1122
+
1123
+ function resolveFactoryDroidProviderDisplayName(modelRef, config = {}) {
1124
+ const normalizedModelRef = String(modelRef || "").trim();
1125
+ if (!normalizedModelRef.includes("/")) return "";
1126
+ const separatorIndex = normalizedModelRef.indexOf("/");
1127
+ const providerId = normalizedModelRef.slice(0, separatorIndex).trim();
1128
+ const provider = (Array.isArray(config?.providers) ? config.providers : [])
1129
+ .find((entry) => String(entry?.id || "").trim() === providerId);
1130
+ return String(provider?.name || providerId || "").trim();
1131
+ }
1132
+
1133
+ function collectFactoryDroidAvailableModels(config = {}, bindings = {}) {
1134
+ const refs = [];
1135
+ const seen = new Set();
1136
+ const push = (value) => {
1137
+ const normalized = String(value || "").trim();
1138
+ if (!normalized || seen.has(normalized)) return;
1139
+ seen.add(normalized);
1140
+ refs.push(normalized);
1141
+ };
1142
+
1143
+ const aliases = config?.modelAliases && typeof config.modelAliases === "object" && !Array.isArray(config.modelAliases)
1144
+ ? config.modelAliases
1145
+ : {};
1146
+ for (const aliasId of Object.keys(aliases)) {
1147
+ push(aliasId);
1148
+ }
1149
+
1150
+ push(bindings?.defaultModel);
1151
+ push(bindings?.missionOrchestratorModel);
1152
+ push(bindings?.missionWorkerModel);
1153
+ push(bindings?.missionValidatorModel);
1154
+
1155
+ for (const provider of Array.isArray(config?.providers) ? config.providers : []) {
1156
+ if (provider?.enabled === false) continue;
1157
+ const providerId = String(provider?.id || "").trim();
1158
+ if (!providerId) continue;
1159
+ for (const model of Array.isArray(provider?.models) ? provider.models : []) {
1160
+ if (model?.enabled === false) continue;
1161
+ const modelId = String(model?.id || "").trim();
1162
+ if (!modelId) continue;
1163
+ push(`${providerId}/${modelId}`);
1164
+ }
1165
+ }
1166
+
1167
+ return refs;
1168
+ }
1169
+
1170
+ function buildFactoryDroidAvailableModelDescriptors(config = {}, bindings = {}) {
1171
+ return collectFactoryDroidAvailableModels(config, bindings)
1172
+ .map((modelRef) => {
1173
+ const kind = modelRef.includes("/") ? "model" : "alias";
1174
+ return {
1175
+ modelRef,
1176
+ kind,
1177
+ id: buildFactoryDroidRouterModelId(modelRef, { kind }),
1178
+ displayName: buildFactoryDroidRouterDisplayName(modelRef, {
1179
+ kind,
1180
+ providerName: kind === "model" ? resolveFactoryDroidProviderDisplayName(modelRef, config) : ""
1181
+ })
1182
+ };
1183
+ })
1184
+ .filter((entry) => String(entry.id || "").trim() && String(entry.modelRef || "").trim());
1185
+ }
1186
+
1187
+ function buildFactoryDroidRouteLookup(config = {}, bindings = {}) {
1188
+ const descriptors = buildFactoryDroidAvailableModelDescriptors(config, bindings);
1189
+ const byId = new Map();
1190
+ const byDisplayName = new Map();
1191
+
1192
+ for (const descriptor of descriptors) {
1193
+ if (descriptor.id) byId.set(descriptor.id, descriptor.modelRef);
1194
+ if (descriptor.displayName) byDisplayName.set(descriptor.displayName, descriptor.modelRef);
1195
+ }
1196
+
1197
+ return {
1198
+ descriptors,
1199
+ byId,
1200
+ byDisplayName
1201
+ };
1202
+ }
1203
+
1204
+ function resolveFactoryDroidRouteRefFromLookup(value, routeLookup) {
1205
+ const normalizedValue = String(value || "").trim();
1206
+ if (!normalizedValue || !routeLookup || typeof routeLookup !== "object") return "";
1207
+ if (routeLookup.byId instanceof Map && routeLookup.byId.has(normalizedValue)) {
1208
+ return String(routeLookup.byId.get(normalizedValue) || "").trim();
1209
+ }
1210
+ if (routeLookup.byDisplayName instanceof Map && routeLookup.byDisplayName.has(normalizedValue)) {
1211
+ return String(routeLookup.byDisplayName.get(normalizedValue) || "").trim();
1212
+ }
1213
+ return "";
1214
+ }
1215
+
1216
+ function getFactoryDroidCustomModelEntryByValue(customModels, value, { preferRouterManaged = false } = {}) {
1217
+ const normalizedValue = String(value || "").trim();
1218
+ if (!normalizedValue || !Array.isArray(customModels)) return null;
1219
+
1220
+ const entries = preferRouterManaged
1221
+ ? [
1222
+ ...customModels.filter((entry) => isFactoryDroidRouterManagedEntry(entry)),
1223
+ ...customModels.filter((entry) => !isFactoryDroidRouterManagedEntry(entry))
1224
+ ]
1225
+ : customModels;
1226
+
1227
+ return entries.find((entry) => entry && typeof entry === "object" && (
1228
+ String(entry.id || "").trim() === normalizedValue
1229
+ || String(entry.model || "").trim() === normalizedValue
1230
+ || String(entry.displayName || "").trim() === normalizedValue
1231
+ )) || null;
1232
+ }
1233
+
1234
+ function resolveFactoryDroidBindingModelRef(value, customModels, routeLookup = null) {
1235
+ const normalizedValue = String(value || "").trim();
1236
+ if (!normalizedValue) return "";
1237
+ const matchedEntry = getFactoryDroidCustomModelEntryByValue(customModels, normalizedValue, { preferRouterManaged: true });
1238
+ if (!matchedEntry) {
1239
+ return resolveFactoryDroidRouteRefFromLookup(normalizedValue, routeLookup)
1240
+ || resolveFactoryDroidRouterModelRef(normalizedValue);
1241
+ }
1242
+ return resolveFactoryDroidRouterModelRef(
1243
+ String(matchedEntry.model || matchedEntry.displayName || matchedEntry.id || normalizedValue).trim()
1244
+ );
1245
+ }
1246
+
1247
+ function getNextFactoryDroidCustomModelIndex(customModels) {
1248
+ if (!Array.isArray(customModels) || customModels.length === 0) return 0;
1249
+ let maxIndex = -1;
1250
+ for (const entry of customModels) {
1251
+ const parsed = Number(entry?.index);
1252
+ if (Number.isFinite(parsed)) maxIndex = Math.max(maxIndex, parsed);
1253
+ }
1254
+ return maxIndex >= 0 ? (maxIndex + 1) : customModels.length;
1255
+ }
1256
+
1257
+ function buildFactoryDroidCustomModelId(modelRef, index) {
1258
+ return buildFactoryDroidRouterModelId(modelRef) || `custom:llm-alias-llm-router-${Number(index)}`;
1259
+ }
1260
+
1261
+ function resolveFactoryDroidBindingSelectionValue(value, customModels, routeLookup = null) {
1262
+ const normalizedValue = String(value || "").trim();
1263
+ if (!normalizedValue) return "";
1264
+ const matchedEntry = getFactoryDroidCustomModelEntryByValue(customModels, normalizedValue, { preferRouterManaged: true });
1265
+ if (matchedEntry) {
1266
+ const matchedId = String(matchedEntry.id || "").trim();
1267
+ const preferredId = buildFactoryDroidCustomModelId(
1268
+ resolveFactoryDroidBindingModelRef(normalizedValue, customModels, routeLookup),
1269
+ Number(matchedEntry.index) || 0
1270
+ );
1271
+ if (matchedEntry[FACTORY_DROID_ROUTER_MARKER] === true || isFactoryDroidRouterModelId(matchedId)) {
1272
+ return preferredId || matchedId || normalizedValue;
1273
+ }
1274
+ return matchedId || preferredId || normalizedValue;
1275
+ }
1276
+ const resolvedRouteRef = resolveFactoryDroidRouteRefFromLookup(normalizedValue, routeLookup);
1277
+ if (resolvedRouteRef) return buildFactoryDroidCustomModelId(resolvedRouteRef, 0) || normalizedValue;
1278
+ return buildFactoryDroidCustomModelId(
1279
+ resolveFactoryDroidBindingModelRef(normalizedValue, customModels, routeLookup),
1280
+ 0
1281
+ ) || normalizedValue;
986
1282
  }
987
1283
 
988
1284
  function findRouterManagedCustomModelIndex(customModels) {
989
1285
  if (!Array.isArray(customModels)) return -1;
990
1286
  return customModels.findIndex(
991
- (entry) => entry && typeof entry === "object" && entry[FACTORY_DROID_ROUTER_MARKER] === true
1287
+ (entry) => isFactoryDroidRouterManagedEntry(entry)
992
1288
  );
993
1289
  }
994
1290
 
@@ -997,10 +1293,30 @@ function getRouterManagedCustomModel(customModels) {
997
1293
  return routerIndex >= 0 ? customModels[routerIndex] : null;
998
1294
  }
999
1295
 
1000
- function stripRouterManagedCustomModels(customModels) {
1296
+ function isFactoryDroidRouterManagedEntry(entry, { baseUrl = "" } = {}) {
1297
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
1298
+ if (entry[FACTORY_DROID_ROUTER_MARKER] === true) return true;
1299
+
1300
+ const entryId = String(entry.id || "").trim();
1301
+ if (isFactoryDroidRouterModelId(entryId)) return true;
1302
+
1303
+ const provider = String(entry.provider || "").trim().toLowerCase();
1304
+ if (!FACTORY_DROID_ROUTER_PROVIDERS.includes(provider)) return false;
1305
+
1306
+ const entryBaseUrl = String(entry.baseUrl || "").trim();
1307
+ if (baseUrl && entryBaseUrl === String(baseUrl || "").trim()) return true;
1308
+
1309
+ const apiKey = String(entry.apiKey || "").trim();
1310
+ return apiKey.startsWith("gw_") && (
1311
+ entryBaseUrl.includes("/openai/v1")
1312
+ || entryBaseUrl.includes("/anthropic")
1313
+ );
1314
+ }
1315
+
1316
+ function stripRouterManagedCustomModels(customModels, { baseUrl = "" } = {}) {
1001
1317
  if (!Array.isArray(customModels)) return [];
1002
1318
  return customModels.filter(
1003
- (entry) => !(entry && typeof entry === "object" && entry[FACTORY_DROID_ROUTER_MARKER] === true)
1319
+ (entry) => !isFactoryDroidRouterManagedEntry(entry, { baseUrl })
1004
1320
  );
1005
1321
  }
1006
1322
 
@@ -1136,21 +1452,34 @@ export async function readFactoryDroidRoutingState({
1136
1452
  settingsFilePath = "",
1137
1453
  backupFilePath = "",
1138
1454
  endpointUrl = "",
1455
+ config = {},
1139
1456
  homeDir = os.homedir()
1140
1457
  } = {}) {
1141
1458
  const resolvedSettingsPath = path.resolve(String(settingsFilePath || resolveFactoryDroidSettingsFilePath({ homeDir })).trim());
1142
1459
  const resolvedBackupPath = path.resolve(String(backupFilePath || resolveCodingToolBackupFilePath(resolvedSettingsPath)).trim());
1143
- const expectedBaseUrl = buildFactoryDroidBaseUrl(endpointUrl);
1460
+ const routeLookup = buildFactoryDroidRouteLookup(config);
1144
1461
  const settingsState = await readJsonObjectFile(resolvedSettingsPath, `Factory Droid settings file '${resolvedSettingsPath}'`);
1145
1462
  const backupState = await readJsonObjectFile(resolvedBackupPath, `Backup file '${resolvedBackupPath}'`);
1146
1463
  const customModels = Array.isArray(settingsState.data?.customModels) ? settingsState.data.customModels : [];
1147
- const routerEntry = getRouterManagedCustomModel(customModels);
1464
+ const resolvedDefaultModelValue = getNestedObjectValue(settingsState.data, ["sessionDefaultSettings", "model"])
1465
+ || settingsState.data?.model
1466
+ || "";
1467
+ const resolvedMissionOrchestratorValue = settingsState.data?.missionOrchestratorModel || "";
1468
+ const resolvedMissionWorkerValue = getNestedObjectValue(settingsState.data, ["missionModelSettings", "workerModel"]) || "";
1469
+ const resolvedMissionValidatorValue = getNestedObjectValue(settingsState.data, ["missionModelSettings", "validationWorkerModel"]) || "";
1470
+ const routerEntry = getFactoryDroidCustomModelEntryByValue(customModels, resolvedDefaultModelValue, { preferRouterManaged: true })
1471
+ || getRouterManagedCustomModel(customModels);
1148
1472
  const configuredBaseUrl = routerEntry ? String(routerEntry.baseUrl || "").trim() : "";
1149
1473
  const configuredProvider = routerEntry ? String(routerEntry.provider || "").trim() : "";
1474
+ const expectedBaseUrls = new Set(
1475
+ FACTORY_DROID_ROUTER_PROVIDERS
1476
+ .map((provider) => buildFactoryDroidBaseUrl(endpointUrl, provider))
1477
+ .filter(Boolean)
1478
+ );
1150
1479
  const routedViaRouter = Boolean(
1151
- expectedBaseUrl
1480
+ configuredBaseUrl
1152
1481
  && routerEntry
1153
- && configuredBaseUrl === expectedBaseUrl
1482
+ && expectedBaseUrls.has(configuredBaseUrl)
1154
1483
  );
1155
1484
 
1156
1485
  return {
@@ -1163,13 +1492,17 @@ export async function readFactoryDroidRoutingState({
1163
1492
  configuredBaseUrl,
1164
1493
  configuredProvider,
1165
1494
  bindings: normalizeFactoryDroidBindings({
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"]) || "",
1495
+ defaultModel: resolveFactoryDroidBindingModelRef(resolvedDefaultModelValue, customModels, routeLookup),
1496
+ missionOrchestratorModel: resolveFactoryDroidBindingModelRef(resolvedMissionOrchestratorValue, customModels, routeLookup),
1497
+ missionWorkerModel: resolveFactoryDroidBindingModelRef(resolvedMissionWorkerValue, customModels, routeLookup),
1498
+ missionValidatorModel: resolveFactoryDroidBindingModelRef(resolvedMissionValidatorValue, customModels, routeLookup),
1499
+ reasoningEffort: normalizeFactoryDroidReasoningEffort(settingsState.data?.reasoningEffort)
1500
+ }),
1501
+ bindingIds: normalizeFactoryDroidBindings({
1502
+ defaultModel: resolveFactoryDroidBindingSelectionValue(resolvedDefaultModelValue, customModels, routeLookup),
1503
+ missionOrchestratorModel: resolveFactoryDroidBindingSelectionValue(resolvedMissionOrchestratorValue, customModels, routeLookup),
1504
+ missionWorkerModel: resolveFactoryDroidBindingSelectionValue(resolvedMissionWorkerValue, customModels, routeLookup),
1505
+ missionValidatorModel: resolveFactoryDroidBindingSelectionValue(resolvedMissionValidatorValue, customModels, routeLookup),
1173
1506
  reasoningEffort: normalizeFactoryDroidReasoningEffort(settingsState.data?.reasoningEffort)
1174
1507
  })
1175
1508
  };
@@ -1181,14 +1514,16 @@ export async function patchFactoryDroidSettingsFile({
1181
1514
  endpointUrl = "",
1182
1515
  apiKey = "",
1183
1516
  bindings = {},
1517
+ config = {},
1184
1518
  captureBackup = true,
1185
1519
  homeDir = os.homedir()
1186
1520
  } = {}) {
1187
1521
  const resolvedSettingsPath = path.resolve(String(settingsFilePath || resolveFactoryDroidSettingsFilePath({ homeDir })).trim());
1188
1522
  const resolvedBackupPath = path.resolve(String(backupFilePath || resolveCodingToolBackupFilePath(resolvedSettingsPath)).trim());
1189
- const baseUrl = buildFactoryDroidBaseUrl(endpointUrl);
1523
+ const baseUrl = buildFactoryDroidBaseUrl(endpointUrl, FACTORY_DROID_OPENAI_PROVIDER);
1190
1524
  const normalizedApiKey = String(apiKey || "").trim();
1191
1525
  const normalizedBindings = normalizeFactoryDroidBindings(bindings);
1526
+ const routeLookup = buildFactoryDroidRouteLookup(config);
1192
1527
 
1193
1528
  if (!baseUrl) {
1194
1529
  throw new Error("Factory Droid endpoint URL must be a valid http:// or https:// URL.");
@@ -1203,51 +1538,90 @@ export async function patchFactoryDroidSettingsFile({
1203
1538
  const nextSettings = settingsState.data && typeof settingsState.data === "object" && !Array.isArray(settingsState.data)
1204
1539
  ? structuredClone(settingsState.data)
1205
1540
  : {};
1541
+ const existingCustomModels = Array.isArray(nextSettings.customModels) ? nextSettings.customModels : [];
1206
1542
 
1207
1543
  if (captureBackup && !backupHasData(existingBackup)) {
1208
1544
  const backup = settingsState.existed ? captureFactoryDroidBackup(nextSettings) : {};
1209
1545
  await writeJsonObjectFile(resolvedBackupPath, backup);
1210
1546
  }
1211
1547
 
1212
- const customModels = stripRouterManagedCustomModels(nextSettings.customModels);
1213
- const routerEntry = {
1214
- [FACTORY_DROID_ROUTER_MARKER]: true,
1215
- model: normalizedBindings.defaultModel
1216
- || normalizedBindings.missionOrchestratorModel
1217
- || normalizedBindings.missionWorkerModel
1218
- || normalizedBindings.missionValidatorModel
1219
- || "llm-router",
1220
- displayName: "LLM Router",
1221
- baseUrl,
1222
- apiKey: normalizedApiKey,
1223
- provider: FACTORY_DROID_ROUTER_PROVIDER
1224
- };
1548
+ const resolvedBindings = normalizeFactoryDroidBindings({
1549
+ defaultModel: resolveFactoryDroidBindingModelRef(normalizedBindings.defaultModel, existingCustomModels, routeLookup),
1550
+ missionOrchestratorModel: resolveFactoryDroidBindingModelRef(normalizedBindings.missionOrchestratorModel, existingCustomModels, routeLookup),
1551
+ missionWorkerModel: resolveFactoryDroidBindingModelRef(normalizedBindings.missionWorkerModel, existingCustomModels, routeLookup),
1552
+ missionValidatorModel: resolveFactoryDroidBindingModelRef(normalizedBindings.missionValidatorModel, existingCustomModels, routeLookup),
1553
+ reasoningEffort: normalizedBindings.reasoningEffort
1554
+ });
1225
1555
 
1226
- customModels.push(routerEntry);
1556
+ const customModels = stripRouterManagedCustomModels(existingCustomModels, { baseUrl });
1557
+ const availableModels = buildFactoryDroidAvailableModelDescriptors(config, resolvedBindings);
1558
+ const routerEntryStartIndex = getNextFactoryDroidCustomModelIndex(customModels);
1559
+ const routerEntries = availableModels.length > 0
1560
+ ? availableModels.map((descriptor, index) => {
1561
+ const entryIndex = routerEntryStartIndex + index;
1562
+ const modelId = buildFactoryDroidCustomModelId(descriptor.modelRef, entryIndex);
1563
+ const provider = resolveFactoryDroidCustomModelProvider(descriptor.modelRef, config);
1564
+ return {
1565
+ [FACTORY_DROID_ROUTER_MARKER]: true,
1566
+ model: descriptor.modelRef,
1567
+ id: modelId,
1568
+ index: entryIndex,
1569
+ displayName: descriptor.displayName,
1570
+ baseUrl: buildFactoryDroidBaseUrl(endpointUrl, provider),
1571
+ apiKey: normalizedApiKey,
1572
+ provider
1573
+ };
1574
+ })
1575
+ : [{
1576
+ [FACTORY_DROID_ROUTER_MARKER]: true,
1577
+ model: "llm-router",
1578
+ id: buildFactoryDroidCustomModelId("llm-router", routerEntryStartIndex),
1579
+ index: routerEntryStartIndex,
1580
+ displayName: buildFactoryDroidRouterDisplayName("llm-router", { kind: "alias" }),
1581
+ baseUrl,
1582
+ apiKey: normalizedApiKey,
1583
+ provider: FACTORY_DROID_OPENAI_PROVIDER
1584
+ }];
1585
+
1586
+ customModels.push(...routerEntries);
1227
1587
  nextSettings.customModels = customModels;
1588
+ const allCustomModels = nextSettings.customModels;
1228
1589
 
1229
1590
  if (normalizedBindings.defaultModel) {
1230
- nextSettings.model = normalizedBindings.defaultModel;
1231
- setNestedObjectValue(nextSettings, ["sessionDefaultSettings", "model"], normalizedBindings.defaultModel);
1591
+ const selectedModel = resolveFactoryDroidBindingSelectionValue(normalizedBindings.defaultModel, allCustomModels, routeLookup);
1592
+ nextSettings.model = selectedModel;
1593
+ setNestedObjectValue(nextSettings, ["sessionDefaultSettings", "model"], selectedModel);
1232
1594
  } else {
1233
1595
  delete nextSettings.model;
1234
1596
  deleteNestedObjectValue(nextSettings, ["sessionDefaultSettings", "model"]);
1235
1597
  }
1236
1598
 
1237
1599
  if (normalizedBindings.missionOrchestratorModel) {
1238
- nextSettings.missionOrchestratorModel = normalizedBindings.missionOrchestratorModel;
1600
+ nextSettings.missionOrchestratorModel = resolveFactoryDroidBindingSelectionValue(
1601
+ normalizedBindings.missionOrchestratorModel,
1602
+ allCustomModels,
1603
+ routeLookup
1604
+ );
1239
1605
  } else {
1240
1606
  delete nextSettings.missionOrchestratorModel;
1241
1607
  }
1242
1608
 
1243
1609
  if (normalizedBindings.missionWorkerModel) {
1244
- setNestedObjectValue(nextSettings, ["missionModelSettings", "workerModel"], normalizedBindings.missionWorkerModel);
1610
+ setNestedObjectValue(
1611
+ nextSettings,
1612
+ ["missionModelSettings", "workerModel"],
1613
+ resolveFactoryDroidBindingSelectionValue(normalizedBindings.missionWorkerModel, allCustomModels, routeLookup)
1614
+ );
1245
1615
  } else {
1246
1616
  deleteNestedObjectValue(nextSettings, ["missionModelSettings", "workerModel"]);
1247
1617
  }
1248
1618
 
1249
1619
  if (normalizedBindings.missionValidatorModel) {
1250
- setNestedObjectValue(nextSettings, ["missionModelSettings", "validationWorkerModel"], normalizedBindings.missionValidatorModel);
1620
+ setNestedObjectValue(
1621
+ nextSettings,
1622
+ ["missionModelSettings", "validationWorkerModel"],
1623
+ resolveFactoryDroidBindingSelectionValue(normalizedBindings.missionValidatorModel, allCustomModels, routeLookup)
1624
+ );
1251
1625
  } else {
1252
1626
  deleteNestedObjectValue(nextSettings, ["missionModelSettings", "validationWorkerModel"]);
1253
1627
  }
@@ -1259,13 +1633,32 @@ export async function patchFactoryDroidSettingsFile({
1259
1633
  }
1260
1634
 
1261
1635
  await writeJsonObjectFile(resolvedSettingsPath, nextSettings);
1636
+ const primaryEntry = resolvedBindings.defaultModel
1637
+ ? getFactoryDroidCustomModelEntryByValue(allCustomModels, resolvedBindings.defaultModel, { preferRouterManaged: true })
1638
+ : null;
1639
+ const configuredEntry = primaryEntry || getRouterManagedCustomModel(allCustomModels);
1262
1640
  return {
1263
1641
  settingsFilePath: resolvedSettingsPath,
1264
1642
  backupFilePath: resolvedBackupPath,
1265
1643
  settingsCreated: !settingsState.existed,
1266
- baseUrl,
1267
- configuredProvider: FACTORY_DROID_ROUTER_PROVIDER,
1268
- bindings: normalizedBindings
1644
+ baseUrl: String(configuredEntry?.baseUrl || baseUrl).trim(),
1645
+ configuredProvider: String(configuredEntry?.provider || FACTORY_DROID_OPENAI_PROVIDER).trim(),
1646
+ bindings: resolvedBindings,
1647
+ bindingIds: normalizeFactoryDroidBindings({
1648
+ defaultModel: normalizedBindings.defaultModel
1649
+ ? resolveFactoryDroidBindingSelectionValue(normalizedBindings.defaultModel, allCustomModels, routeLookup)
1650
+ : "",
1651
+ missionOrchestratorModel: normalizedBindings.missionOrchestratorModel
1652
+ ? resolveFactoryDroidBindingSelectionValue(normalizedBindings.missionOrchestratorModel, allCustomModels, routeLookup)
1653
+ : "",
1654
+ missionWorkerModel: normalizedBindings.missionWorkerModel
1655
+ ? resolveFactoryDroidBindingSelectionValue(normalizedBindings.missionWorkerModel, allCustomModels, routeLookup)
1656
+ : "",
1657
+ missionValidatorModel: normalizedBindings.missionValidatorModel
1658
+ ? resolveFactoryDroidBindingSelectionValue(normalizedBindings.missionValidatorModel, allCustomModels, routeLookup)
1659
+ : "",
1660
+ reasoningEffort: normalizedBindings.reasoningEffort
1661
+ })
1269
1662
  };
1270
1663
  }
1271
1664
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Local config persistence for ~/.llm-router.json.
2
+ * Local config persistence for the default and development config files.
3
3
  */
4
4
 
5
5
  import os from "node:os";
@@ -14,11 +14,16 @@ import {
14
14
  import { sanitizePersistedLocalServerConfig } from "./local-server-settings.js";
15
15
 
16
16
  export const DEFAULT_CONFIG_FILENAME = ".llm-router.json";
17
+ export const DEFAULT_DEV_CONFIG_FILENAME = ".llm-router-dev.json";
17
18
 
18
19
  export function getDefaultConfigPath() {
19
20
  return path.join(os.homedir(), DEFAULT_CONFIG_FILENAME);
20
21
  }
21
22
 
23
+ export function getDefaultDevConfigPath() {
24
+ return path.join(os.homedir(), DEFAULT_DEV_CONFIG_FILENAME);
25
+ }
26
+
22
27
  function normalizePersistedConfig(config, normalizeOptions = undefined) {
23
28
  return sanitizePersistedLocalServerConfig(
24
29
  normalizeRuntimeConfig(config, normalizeOptions)
@@ -6,6 +6,7 @@ import { FIXED_LOCAL_ROUTER_HOST, FIXED_LOCAL_ROUTER_PORT } from "./local-server
6
6
 
7
7
  const DEFAULT_INSTANCE_STATE_FILENAME = ".llm-router.runtime.json";
8
8
  const MAX_START_OUTPUT_CHARS = 4000;
9
+ export const RUNTIME_STATE_PATH_ENV = "LLM_ROUTER_RUNTIME_STATE_PATH";
9
10
 
10
11
  function sleep(ms) {
11
12
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -80,7 +81,9 @@ function runtimeMatchesStartOptions(runtime, {
80
81
  && normalized.requireAuth === normalizeBoolean(requireAuth, false);
81
82
  }
82
83
 
83
- export function getRuntimeStatePath() {
84
+ export function getRuntimeStatePath({ env = process.env } = {}) {
85
+ const override = String(env?.[RUNTIME_STATE_PATH_ENV] || "").trim();
86
+ if (override) return path.resolve(override);
84
87
  return path.join(os.homedir(), DEFAULT_INSTANCE_STATE_FILENAME);
85
88
  }
86
89