llm-cli-gateway 1.1.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, readdirSync, statSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import path from "path";
4
+ import { parse as parseToml } from "toml";
4
5
  const FALLBACK_INFO = {
5
6
  claude: {
6
7
  description: "Anthropic's Claude Code CLI - best for code generation, analysis, and agentic coding tasks",
@@ -13,12 +14,17 @@ const FALLBACK_INFO = {
13
14
  modelOrder: ["opus", "sonnet", "haiku"],
14
15
  },
15
16
  codex: {
17
+ // Note: no hardcoded `defaultModel`. Let Codex CLI pick its own built-in default
18
+ // unless an explicit value is found via config.toml / env vars in applyCodexOverrides.
19
+ // This prevents the gateway from pinning a model that may become deprecated upstream.
16
20
  description: "OpenAI's Codex CLI - best for code execution in sandboxed environments",
17
21
  models: {
18
- o3: "Most capable reasoning model. Best for: complex multi-step problems, math, science",
19
- "o4-mini": "Fast reasoning model. Best for: coding tasks, quick iterations",
20
- "gpt-4.1": "Latest GPT-4 variant. Best for: general coding, instruction following",
22
+ "gpt-5.4": "Frontier coding and professional-work model. Best for: most Codex tasks, long-running agentic work",
23
+ "gpt-5.3-codex": "Specialized Codex model. Best for: agentic coding workflows with Codex-tuned behavior",
24
+ "gpt-5.2": "Strong general-purpose GPT-5 model. Best for: broad coding and reasoning tasks",
25
+ "gpt-5-pro": "Highest-capability GPT-5 model. Best for: deep reasoning and difficult professional workflows",
21
26
  },
27
+ modelOrder: ["gpt-5.3-codex", "gpt-5.4", "gpt-5.2", "gpt-5-pro"],
22
28
  },
23
29
  gemini: {
24
30
  description: "Google's Gemini CLI - best for multimodal tasks and Google ecosystem integration",
@@ -27,8 +33,25 @@ const FALLBACK_INFO = {
27
33
  "gemini-2.5-flash": "Fast model. Best for: quick responses, high throughput, cost-sensitive use",
28
34
  },
29
35
  },
36
+ grok: {
37
+ // No hardcoded `defaultModel`. Let Grok CLI pick its own built-in default
38
+ // unless an explicit value is found via env vars in applyGrokOverrides.
39
+ description: "xAI's Grok Build CLI - best for agentic coding tasks via xAI's Grok models",
40
+ models: {
41
+ "grok-build": "Default Grok model for code/agentic tasks. Best for: most Grok build sessions",
42
+ },
43
+ modelOrder: ["grok-build"],
44
+ },
30
45
  };
31
46
  const MODEL_CACHE_TTL_MS = 2 * 60 * 1000;
47
+ const MAX_GEMINI_HISTORY_FILES = 200;
48
+ const MAX_GEMINI_HISTORY_FILE_BYTES = 2 * 1024 * 1024;
49
+ const SOURCE_PRIORITY = {
50
+ fallback: 0,
51
+ observed: 1,
52
+ config: 2,
53
+ env: 3,
54
+ };
32
55
  let cachedInfo = null;
33
56
  export function getCliInfo(forceRefresh = false) {
34
57
  if (!forceRefresh && cachedInfo && Date.now() - cachedInfo.loadedAt < MODEL_CACHE_TTL_MS) {
@@ -38,6 +61,9 @@ export function getCliInfo(forceRefresh = false) {
38
61
  cachedInfo = { loadedAt: Date.now(), info };
39
62
  return info;
40
63
  }
64
+ export function clearModelRegistryCache() {
65
+ cachedInfo = null;
66
+ }
41
67
  export function resolveModelAlias(cli, model, info) {
42
68
  if (!model) {
43
69
  return undefined;
@@ -49,7 +75,14 @@ export function resolveModelAlias(cli, model, info) {
49
75
  const normalized = trimmed.toLowerCase();
50
76
  const cliInfo = info[cli];
51
77
  if (normalized === "default" || normalized === "latest") {
52
- return cliInfo.defaultModel ?? trimmed;
78
+ // If no default is configured, return undefined so the CLI picks its own
79
+ // built-in default. Avoids passing the literal string "default"/"latest"
80
+ // as a model name to the CLI.
81
+ return cliInfo.defaultModel;
82
+ }
83
+ const alias = resolveConfiguredAlias(cliInfo, normalized);
84
+ if (alias !== undefined) {
85
+ return alias;
53
86
  }
54
87
  if (cli === "gemini") {
55
88
  if (normalized === "flash" || normalized === "pro") {
@@ -64,163 +97,403 @@ function buildCliInfo() {
64
97
  claude: cloneInfo(FALLBACK_INFO.claude),
65
98
  codex: cloneInfo(FALLBACK_INFO.codex),
66
99
  gemini: cloneInfo(FALLBACK_INFO.gemini),
100
+ grok: cloneInfo(FALLBACK_INFO.grok),
67
101
  };
68
102
  applyClaudeOverrides(info.claude);
69
103
  applyCodexOverrides(info.codex);
70
104
  applyGeminiOverrides(info.gemini);
105
+ applyGrokOverrides(info.grok);
71
106
  return info;
72
107
  }
73
108
  function cloneInfo(source) {
74
- return {
109
+ const cloned = {
75
110
  description: source.description,
76
111
  models: { ...source.models },
77
112
  defaultModel: source.defaultModel,
113
+ defaultModelSource: source.defaultModelSource,
78
114
  modelOrder: source.modelOrder ? [...source.modelOrder] : undefined,
115
+ aliases: source.aliases ? { ...source.aliases } : undefined,
116
+ modelMetadata: source.modelMetadata ? { ...source.modelMetadata } : {},
117
+ warnings: source.warnings ? [...source.warnings] : [],
79
118
  };
119
+ Object.keys(cloned.models).forEach(model => {
120
+ cloned.modelMetadata[model] = cloned.modelMetadata[model] ?? {
121
+ source: "fallback",
122
+ sourceDetail: "Bundled fallback registry",
123
+ confidence: "low",
124
+ };
125
+ });
126
+ return cloned;
127
+ }
128
+ function addWarning(info, warning) {
129
+ info.warnings = info.warnings ?? [];
130
+ if (!info.warnings.includes(warning)) {
131
+ info.warnings.push(warning);
132
+ }
133
+ }
134
+ function isValidModelName(model) {
135
+ return model.length > 0 && !/[\u0000-\u001f\u007f]/.test(model) && !/\s/.test(model);
136
+ }
137
+ function addModel(info, model, description, metadata, options = {}) {
138
+ const normalized = model.trim();
139
+ if (!isValidModelName(normalized)) {
140
+ addWarning(info, `Ignored invalid model name from ${metadata.sourceDetail}`);
141
+ return;
142
+ }
143
+ const existingMetadata = info.modelMetadata?.[normalized];
144
+ const existingPriority = existingMetadata ? SOURCE_PRIORITY[existingMetadata.source] : -1;
145
+ const incomingPriority = SOURCE_PRIORITY[metadata.source];
146
+ if (!info.models[normalized] ||
147
+ options.preferDescription ||
148
+ incomingPriority > existingPriority) {
149
+ info.models[normalized] = description;
150
+ }
151
+ info.modelMetadata = info.modelMetadata ?? {};
152
+ if (!existingMetadata || incomingPriority >= existingPriority) {
153
+ info.modelMetadata[normalized] = metadata;
154
+ }
155
+ }
156
+ function setDefaultModel(info, model, source, sourceType) {
157
+ const normalized = model?.trim();
158
+ if (!normalized) {
159
+ return;
160
+ }
161
+ if (!isValidModelName(normalized)) {
162
+ addWarning(info, `Ignored invalid default model from ${source}`);
163
+ return;
164
+ }
165
+ addModel(info, normalized, `Configured default from ${source}`, {
166
+ source: sourceType,
167
+ sourceDetail: source,
168
+ confidence: sourceType === "env" ? "high" : "medium",
169
+ });
170
+ info.defaultModel = normalized;
171
+ info.defaultModelSource = source;
172
+ }
173
+ function addAlias(info, alias, target, source) {
174
+ const normalizedAlias = alias.trim().toLowerCase();
175
+ const normalizedTarget = target.trim();
176
+ if (!normalizedAlias || /\s/.test(normalizedAlias) || !normalizedTarget) {
177
+ addWarning(info, `Ignored invalid alias from ${source}`);
178
+ return;
179
+ }
180
+ if (normalizedTarget !== "default" && !isValidModelName(normalizedTarget)) {
181
+ addWarning(info, `Ignored invalid alias target from ${source}`);
182
+ return;
183
+ }
184
+ info.aliases = info.aliases ?? {};
185
+ info.aliases[normalizedAlias] = normalizedTarget;
186
+ }
187
+ function resolveConfiguredAlias(info, normalizedAlias) {
188
+ const target = info.aliases?.[normalizedAlias];
189
+ if (!target) {
190
+ return undefined;
191
+ }
192
+ if (target === "default") {
193
+ return info.defaultModel;
194
+ }
195
+ return target;
196
+ }
197
+ function addEnvModels(info, envName) {
198
+ const entries = parseEnvModelEntries(process.env[envName], envName);
199
+ entries.forEach(entry => {
200
+ addModel(info, entry.model, entry.description ?? `Configured via ${envName}`, {
201
+ source: "env",
202
+ sourceDetail: envName,
203
+ confidence: "high",
204
+ }, { preferDescription: Boolean(entry.description) });
205
+ });
206
+ }
207
+ function addEnvAliases(info, cli, envName) {
208
+ parseEnvAliasEntries(process.env[envName], envName, cli).forEach(entry => {
209
+ addAlias(info, entry.alias, entry.target, envName);
210
+ });
211
+ }
212
+ function addGlobalEnvAliases(info, cli) {
213
+ parseEnvAliasEntries(process.env.LLM_GATEWAY_MODEL_ALIASES, "LLM_GATEWAY_MODEL_ALIASES", cli).forEach(entry => {
214
+ addAlias(info, entry.alias, entry.target, "LLM_GATEWAY_MODEL_ALIASES");
215
+ });
80
216
  }
81
217
  function applyClaudeOverrides(info) {
82
- const settingsPath = path.join(homedir(), ".claude", "settings.json");
83
- const settingsLocalPath = path.join(homedir(), ".claude", "settings.local.json");
218
+ const settingsPath = process.env.CLAUDE_SETTINGS_PATH || path.join(homedir(), ".claude", "settings.json");
219
+ const settingsLocalPath = process.env.CLAUDE_SETTINGS_LOCAL_PATH ||
220
+ path.join(homedir(), ".claude", "settings.local.json");
84
221
  const envDefault = process.env.CLAUDE_DEFAULT_MODEL;
85
- const localDefault = readJsonValue(settingsLocalPath, "model");
86
- const settingsDefault = readJsonValue(settingsPath, "model");
87
- const defaultModel = envDefault || localDefault || settingsDefault;
88
- const defaultSource = envDefault
89
- ? "CLAUDE_DEFAULT_MODEL"
90
- : localDefault
91
- ? settingsLocalPath
92
- : settingsPath;
93
- if (defaultModel && typeof defaultModel === "string") {
94
- if (!info.models[defaultModel]) {
95
- info.models[defaultModel] = `Configured default from ${defaultSource}`;
96
- }
97
- info.defaultModel = defaultModel;
222
+ const localDefault = readJsonStringValue(settingsLocalPath, [["model"], ["model", "name"]], info);
223
+ const settingsDefault = readJsonStringValue(settingsPath, [["model"], ["model", "name"]], info);
224
+ if (settingsDefault) {
225
+ setDefaultModel(info, settingsDefault, settingsPath, "config");
98
226
  }
99
- const envModels = parseEnvModels(process.env.CLAUDE_MODELS);
100
- if (envModels.length > 0) {
101
- envModels.forEach(model => {
102
- if (!info.models[model]) {
103
- info.models[model] = "Configured via CLAUDE_MODELS";
104
- }
105
- });
227
+ if (localDefault) {
228
+ setDefaultModel(info, localDefault, settingsLocalPath, "config");
229
+ }
230
+ if (envDefault) {
231
+ setDefaultModel(info, envDefault, "CLAUDE_DEFAULT_MODEL", "env");
106
232
  }
233
+ addEnvModels(info, "CLAUDE_MODELS");
234
+ addEnvAliases(info, "claude", "CLAUDE_MODEL_ALIASES");
235
+ addGlobalEnvAliases(info, "claude");
107
236
  info.modelOrder = buildOrder(info, info.defaultModel);
108
237
  }
109
238
  function applyCodexOverrides(info) {
110
239
  const configPath = process.env.CODEX_CONFIG_PATH || path.join(homedir(), ".codex", "config.toml");
111
240
  const envDefault = process.env.CODEX_DEFAULT_MODEL;
112
- const envModels = parseEnvModels(process.env.CODEX_MODELS);
113
- const detectedModels = {};
114
- let defaultModel;
115
241
  if (existsSync(configPath)) {
116
- const content = readFileSync(configPath, "utf-8");
117
- const model = extractTomlString(content, "model");
118
- if (model) {
119
- detectedModels[model] = `Default from ${configPath}`;
120
- defaultModel = model;
242
+ try {
243
+ const content = readFileSync(configPath, "utf-8");
244
+ const parsed = parseToml(content);
245
+ const model = readStringProperty(parsed, "model");
246
+ setDefaultModel(info, model, configPath, "config");
247
+ const profiles = readRecordProperty(parsed, "profiles");
248
+ Object.entries(profiles).forEach(([profileName, profile]) => {
249
+ const profileModel = readStringProperty(profile, "model");
250
+ if (profileModel) {
251
+ addModel(info, profileModel, `Configured in Codex profile '${profileName}'`, {
252
+ source: "config",
253
+ sourceDetail: `${configPath} profile ${profileName}`,
254
+ confidence: "medium",
255
+ });
256
+ }
257
+ });
258
+ const notice = readRecordProperty(parsed, "notice");
259
+ const migrations = readRecordProperty(notice, "model_migrations");
260
+ Object.entries(migrations).forEach(([from, to]) => {
261
+ if (typeof to !== "string") {
262
+ return;
263
+ }
264
+ addModel(info, to, `Migration target for ${from}`, {
265
+ source: "config",
266
+ sourceDetail: `${configPath} notice.model_migrations`,
267
+ confidence: "medium",
268
+ });
269
+ addModel(info, from, `Legacy model (migrates to ${to})`, {
270
+ source: "config",
271
+ sourceDetail: `${configPath} notice.model_migrations`,
272
+ confidence: "medium",
273
+ });
274
+ });
121
275
  }
122
- const migrations = extractTomlTableMap(content, "notice.model_migrations");
123
- Object.entries(migrations).forEach(([from, to]) => {
124
- if (!detectedModels[to]) {
125
- detectedModels[to] = `Migrated from ${from}`;
126
- }
127
- if (!detectedModels[from]) {
128
- detectedModels[from] = `Legacy model (migrates to ${to})`;
129
- }
130
- });
131
- }
132
- envModels.forEach(model => {
133
- if (!detectedModels[model]) {
134
- detectedModels[model] = "Configured via CODEX_MODELS";
276
+ catch (error) {
277
+ const message = error instanceof Error ? error.message : String(error);
278
+ addWarning(info, `Could not parse Codex config ${configPath}: ${message}`);
135
279
  }
136
- });
137
- if (envDefault) {
138
- detectedModels[envDefault] = detectedModels[envDefault] || "Default from CODEX_DEFAULT_MODEL";
139
- defaultModel = envDefault;
140
280
  }
141
- if (Object.keys(detectedModels).length > 0) {
142
- info.models = detectedModels;
143
- info.defaultModel = defaultModel;
144
- }
145
- else {
146
- info.defaultModel = defaultModel;
281
+ addEnvModels(info, "CODEX_MODELS");
282
+ addEnvAliases(info, "codex", "CODEX_MODEL_ALIASES");
283
+ addGlobalEnvAliases(info, "codex");
284
+ if (envDefault) {
285
+ setDefaultModel(info, envDefault, "CODEX_DEFAULT_MODEL", "env");
147
286
  }
148
287
  info.modelOrder = buildOrder(info, info.defaultModel);
149
288
  }
150
289
  function applyGeminiOverrides(info) {
290
+ const settingsPath = process.env.GEMINI_SETTINGS_PATH || path.join(homedir(), ".gemini", "settings.json");
291
+ const settingsDefault = readJsonStringValue(settingsPath, [["model"], ["model", "name"], ["selectedModel"], ["defaultModel"]], info);
151
292
  const envDefault = process.env.GEMINI_DEFAULT_MODEL;
152
- const envModels = parseEnvModels(process.env.GEMINI_MODELS);
153
- const observed = collectGeminiModels();
154
- if (Object.keys(observed.models).length > 0) {
155
- info.models = observed.models;
156
- info.modelOrder = observed.order;
157
- info.defaultModel = observed.order[0];
158
- }
159
- envModels.forEach(model => {
160
- if (!info.models[model]) {
161
- info.models[model] = "Configured via GEMINI_MODELS";
162
- }
163
- });
293
+ if (settingsDefault) {
294
+ setDefaultModel(info, settingsDefault, settingsPath, "config");
295
+ }
296
+ if (!isModelDiscoveryDisabled()) {
297
+ const observed = collectGeminiModels();
298
+ observed.forEach(observation => {
299
+ addModel(info, observation.model, `Observed in local Gemini sessions (last seen ${observation.lastSeen})`, {
300
+ source: "observed",
301
+ sourceDetail: observation.filePath,
302
+ confidence: "low",
303
+ lastSeen: observation.lastSeen,
304
+ });
305
+ });
306
+ }
307
+ addEnvModels(info, "GEMINI_MODELS");
308
+ addEnvAliases(info, "gemini", "GEMINI_MODEL_ALIASES");
309
+ addGlobalEnvAliases(info, "gemini");
310
+ if (envDefault) {
311
+ setDefaultModel(info, envDefault, "GEMINI_DEFAULT_MODEL", "env");
312
+ }
313
+ info.modelOrder = buildOrder(info, info.defaultModel);
314
+ }
315
+ function applyGrokOverrides(info) {
316
+ const envDefault = process.env.GROK_DEFAULT_MODEL;
317
+ addEnvModels(info, "GROK_MODELS");
318
+ addEnvAliases(info, "grok", "GROK_MODEL_ALIASES");
319
+ addGlobalEnvAliases(info, "grok");
164
320
  if (envDefault) {
165
- info.models[envDefault] = info.models[envDefault] || "Default from GEMINI_DEFAULT_MODEL";
166
- info.defaultModel = envDefault;
321
+ setDefaultModel(info, envDefault, "GROK_DEFAULT_MODEL", "env");
167
322
  }
168
323
  info.modelOrder = buildOrder(info, info.defaultModel);
169
324
  }
170
- function readJsonValue(filePath, key) {
325
+ function readJsonStringValue(filePath, paths, info) {
171
326
  if (!existsSync(filePath)) {
172
327
  return undefined;
173
328
  }
174
329
  try {
175
330
  const content = readFileSync(filePath, "utf-8");
176
331
  const parsed = JSON.parse(content);
177
- const value = parsed?.[key];
178
- return typeof value === "string" ? value : undefined;
332
+ for (const pathParts of paths) {
333
+ const value = readPath(parsed, pathParts);
334
+ if (typeof value === "string" && value.trim()) {
335
+ return value;
336
+ }
337
+ }
338
+ return undefined;
179
339
  }
180
- catch {
340
+ catch (error) {
341
+ if (info) {
342
+ const message = error instanceof Error ? error.message : String(error);
343
+ addWarning(info, `Could not parse JSON config ${filePath}: ${message}`);
344
+ }
345
+ return undefined;
346
+ }
347
+ }
348
+ function readPath(value, pathParts) {
349
+ let current = value;
350
+ for (const part of pathParts) {
351
+ if (!current || typeof current !== "object" || Array.isArray(current)) {
352
+ return undefined;
353
+ }
354
+ current = current[part];
355
+ }
356
+ return current;
357
+ }
358
+ function readStringProperty(value, key) {
359
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
181
360
  return undefined;
182
361
  }
362
+ const prop = value[key];
363
+ return typeof prop === "string" && prop.trim() ? prop : undefined;
183
364
  }
184
- function parseEnvModels(value) {
365
+ function readRecordProperty(value, key) {
366
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
367
+ return {};
368
+ }
369
+ const prop = value[key];
370
+ return prop && typeof prop === "object" && !Array.isArray(prop)
371
+ ? prop
372
+ : {};
373
+ }
374
+ function parseEnvModelEntries(value, source) {
185
375
  if (!value) {
186
376
  return [];
187
377
  }
188
- return value
378
+ const trimmed = value.trim();
379
+ if (!trimmed) {
380
+ return [];
381
+ }
382
+ if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
383
+ try {
384
+ const parsed = JSON.parse(trimmed);
385
+ if (Array.isArray(parsed)) {
386
+ return parsed.flatMap(entry => {
387
+ if (typeof entry === "string") {
388
+ return [{ model: entry }];
389
+ }
390
+ if (entry && typeof entry === "object") {
391
+ const record = entry;
392
+ const model = firstString(record.model, record.id, record.name);
393
+ const description = firstString(record.description, record.desc);
394
+ return model ? [{ model, description }] : [];
395
+ }
396
+ return [];
397
+ });
398
+ }
399
+ if (parsed && typeof parsed === "object") {
400
+ return Object.entries(parsed).flatMap(([model, description]) => {
401
+ if (typeof description === "string") {
402
+ return [{ model, description }];
403
+ }
404
+ return [{ model }];
405
+ });
406
+ }
407
+ }
408
+ catch {
409
+ return [{ model: trimmed, description: `Configured via ${source}` }];
410
+ }
411
+ }
412
+ return trimmed
189
413
  .split(/[,\n]/)
190
414
  .map(entry => entry.trim())
191
- .filter(entry => entry.length > 0);
192
- }
193
- function extractTomlString(content, key) {
194
- const regex = new RegExp(`^\\s*${escapeRegex(key)}\\s*=\\s*\"([^\"]+)\"`, "m");
195
- const match = content.match(regex);
196
- return match ? match[1] : undefined;
415
+ .filter(Boolean)
416
+ .map(entry => {
417
+ const eqIndex = entry.indexOf("=");
418
+ if (eqIndex > 0) {
419
+ return {
420
+ model: entry.slice(0, eqIndex).trim(),
421
+ description: entry.slice(eqIndex + 1).trim() || undefined,
422
+ };
423
+ }
424
+ return { model: entry };
425
+ });
197
426
  }
198
- function extractTomlTableMap(content, tableName) {
199
- const tableRegex = new RegExp(`^\\s*\\[${escapeRegex(tableName)}\\]\\s*$`, "m");
200
- const tableMatch = content.match(tableRegex);
201
- if (!tableMatch || tableMatch.index === undefined) {
202
- return {};
427
+ function parseEnvAliasEntries(value, source, cli) {
428
+ if (!value) {
429
+ return [];
430
+ }
431
+ const trimmed = value.trim();
432
+ if (!trimmed) {
433
+ return [];
434
+ }
435
+ const entries = [];
436
+ const addEntry = (rawAlias, rawTarget) => {
437
+ if (typeof rawTarget !== "string") {
438
+ return;
439
+ }
440
+ let alias = rawAlias.trim();
441
+ if (source === "LLM_GATEWAY_MODEL_ALIASES") {
442
+ const prefix = `${cli}.`;
443
+ if (!alias.startsWith(prefix)) {
444
+ return;
445
+ }
446
+ alias = alias.slice(prefix.length);
447
+ }
448
+ entries.push({ alias, target: rawTarget });
449
+ };
450
+ if (trimmed.startsWith("{")) {
451
+ try {
452
+ const parsed = JSON.parse(trimmed);
453
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
454
+ Object.entries(parsed).forEach(([alias, target]) => addEntry(alias, target));
455
+ return entries;
456
+ }
457
+ }
458
+ catch {
459
+ return [];
460
+ }
203
461
  }
204
- const startIndex = tableMatch.index + tableMatch[0].length;
205
- const rest = content.slice(startIndex);
206
- const nextTable = rest.search(/^\s*\[[^\]]+\]\s*$/m);
207
- const block = nextTable >= 0 ? rest.slice(0, nextTable) : rest;
208
- const map = {};
209
- const lineRegex = /"([^"]+)"\s*=\s*"([^"]+)"/g;
210
- let match;
211
- while ((match = lineRegex.exec(block)) !== null) {
212
- map[match[1]] = match[2];
462
+ trimmed.split(/[,\n]/).forEach(entry => {
463
+ const eqIndex = entry.indexOf("=");
464
+ if (eqIndex <= 0) {
465
+ return;
466
+ }
467
+ addEntry(entry.slice(0, eqIndex), entry.slice(eqIndex + 1));
468
+ });
469
+ return entries;
470
+ }
471
+ function firstString(...values) {
472
+ for (const value of values) {
473
+ if (typeof value === "string" && value.trim()) {
474
+ return value;
475
+ }
213
476
  }
214
- return map;
477
+ return undefined;
215
478
  }
216
- function escapeRegex(value) {
217
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
479
+ function isModelDiscoveryDisabled() {
480
+ return (process.env.LLM_GATEWAY_DISABLE_MODEL_DISCOVERY === "1" ||
481
+ process.env.GEMINI_DISABLE_HISTORY_DISCOVERY === "1");
482
+ }
483
+ function parsePositiveInt(value, fallback) {
484
+ if (!value) {
485
+ return fallback;
486
+ }
487
+ const parsed = Number.parseInt(value, 10);
488
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
218
489
  }
219
490
  function collectGeminiModels() {
220
- const root = path.join(homedir(), ".gemini", "tmp");
491
+ const root = process.env.GEMINI_HISTORY_ROOT || path.join(homedir(), ".gemini", "tmp");
221
492
  if (!existsSync(root)) {
222
- return { models: {}, order: [] };
493
+ return [];
223
494
  }
495
+ const maxFiles = parsePositiveInt(process.env.GEMINI_HISTORY_MAX_FILES, MAX_GEMINI_HISTORY_FILES);
496
+ const maxFileBytes = parsePositiveInt(process.env.GEMINI_HISTORY_MAX_FILE_BYTES, MAX_GEMINI_HISTORY_FILE_BYTES);
224
497
  const candidates = [];
225
498
  const roots = safeReadDir(root);
226
499
  roots.forEach(entry => {
@@ -238,7 +511,9 @@ function collectGeminiModels() {
238
511
  const filePath = path.join(chatsPath, file.name);
239
512
  try {
240
513
  const stat = statSync(filePath);
241
- candidates.push({ filePath, mtimeMs: stat.mtimeMs });
514
+ if (stat.size <= maxFileBytes) {
515
+ candidates.push({ filePath, mtimeMs: stat.mtimeMs, size: stat.size });
516
+ }
242
517
  }
243
518
  catch {
244
519
  return;
@@ -246,44 +521,69 @@ function collectGeminiModels() {
246
521
  });
247
522
  });
248
523
  candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
249
- const maxFiles = 200;
250
524
  const recent = candidates.slice(0, maxFiles);
251
525
  const models = {};
252
526
  for (const candidate of recent) {
253
- const model = extractGeminiModel(candidate.filePath);
254
- if (!model) {
255
- continue;
256
- }
257
- const existing = models[model]?.lastSeen ?? 0;
258
- if (candidate.mtimeMs > existing) {
259
- models[model] = { lastSeen: candidate.mtimeMs };
260
- }
527
+ extractGeminiModels(candidate.filePath).forEach(model => {
528
+ const existing = models[model]?.lastSeenMs ?? 0;
529
+ if (candidate.mtimeMs > existing) {
530
+ models[model] = {
531
+ model,
532
+ lastSeen: formatDate(candidate.mtimeMs),
533
+ lastSeenMs: candidate.mtimeMs,
534
+ filePath: candidate.filePath,
535
+ };
536
+ }
537
+ });
261
538
  }
262
- const order = Object.entries(models)
263
- .sort((a, b) => {
264
- const versionDiff = extractModelVersion(b[0]) - extractModelVersion(a[0]);
539
+ return Object.values(models).sort((a, b) => {
540
+ const versionDiff = extractModelVersion(b.model) - extractModelVersion(a.model);
265
541
  if (versionDiff !== 0) {
266
542
  return versionDiff;
267
543
  }
268
- return b[1].lastSeen - a[1].lastSeen;
269
- })
270
- .map(([name]) => name);
271
- const describedModels = {};
272
- order.forEach(model => {
273
- describedModels[model] =
274
- `Observed in local Gemini sessions (last seen ${formatDate(models[model].lastSeen)})`;
544
+ return b.lastSeenMs - a.lastSeenMs;
275
545
  });
276
- return { models: describedModels, order };
277
546
  }
278
- function extractGeminiModel(filePath) {
547
+ function extractGeminiModels(filePath) {
279
548
  try {
280
549
  const content = readFileSync(filePath, "utf-8");
281
- const match = content.match(/"model"\s*:\s*"([^"]+)"/);
282
- return match ? match[1] : undefined;
550
+ const found = new Set();
551
+ try {
552
+ collectModelValues(JSON.parse(content), found);
553
+ }
554
+ catch {
555
+ const regex = /"model(?:Name|Id)?"\s*:\s*"([^"]+)"/g;
556
+ let match;
557
+ while ((match = regex.exec(content)) !== null) {
558
+ found.add(match[1]);
559
+ }
560
+ }
561
+ return [...found].filter(isValidModelName).slice(0, 20);
283
562
  }
284
563
  catch {
285
- return undefined;
564
+ return [];
565
+ }
566
+ }
567
+ function collectModelValues(value, found) {
568
+ if (!value || found.size >= 20) {
569
+ return;
286
570
  }
571
+ if (Array.isArray(value)) {
572
+ value.forEach(entry => collectModelValues(entry, found));
573
+ return;
574
+ }
575
+ if (typeof value !== "object") {
576
+ return;
577
+ }
578
+ Object.entries(value).forEach(([key, child]) => {
579
+ if ((key === "model" || key === "modelName" || key === "modelId") &&
580
+ typeof child === "string") {
581
+ found.add(child);
582
+ }
583
+ else {
584
+ collectModelValues(child, found);
585
+ }
586
+ });
287
587
  }
288
588
  function formatDate(timestampMs) {
289
589
  try {
@@ -334,11 +634,21 @@ function buildOrder(info, preferred) {
334
634
  }
335
635
  function pickLatestMatching(info, token) {
336
636
  const normalized = token.toLowerCase();
337
- const order = info.modelOrder ?? Object.keys(info.models);
338
- for (const model of order) {
339
- if (model.toLowerCase().includes(normalized)) {
340
- return model;
637
+ const candidates = Object.keys(info.models)
638
+ .filter(model => model.toLowerCase().includes(normalized))
639
+ .sort((a, b) => {
640
+ const versionDiff = extractModelVersion(b) - extractModelVersion(a);
641
+ if (versionDiff !== 0) {
642
+ return versionDiff;
341
643
  }
342
- }
343
- return undefined;
644
+ const aMetadata = info.modelMetadata?.[a];
645
+ const bMetadata = info.modelMetadata?.[b];
646
+ const priorityDiff = (bMetadata ? SOURCE_PRIORITY[bMetadata.source] : 0) -
647
+ (aMetadata ? SOURCE_PRIORITY[aMetadata.source] : 0);
648
+ if (priorityDiff !== 0) {
649
+ return priorityDiff;
650
+ }
651
+ return a.localeCompare(b);
652
+ });
653
+ return candidates[0];
344
654
  }