@f5xc-salesdemos/xcsh 15.5.0 → 15.6.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "15.5.0",
4
+ "version": "15.6.2",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
7
  "author": "Can Boluk",
@@ -46,12 +46,12 @@
46
46
  "dependencies": {
47
47
  "@agentclientprotocol/sdk": "0.16.1",
48
48
  "@mozilla/readability": "^0.6",
49
- "@f5xc-salesdemos/xcsh-stats": "15.5.0",
50
- "@f5xc-salesdemos/pi-agent-core": "15.5.0",
51
- "@f5xc-salesdemos/pi-ai": "15.5.0",
52
- "@f5xc-salesdemos/pi-natives": "15.5.0",
53
- "@f5xc-salesdemos/pi-tui": "15.5.0",
54
- "@f5xc-salesdemos/pi-utils": "15.5.0",
49
+ "@f5xc-salesdemos/xcsh-stats": "15.6.2",
50
+ "@f5xc-salesdemos/pi-agent-core": "15.6.2",
51
+ "@f5xc-salesdemos/pi-ai": "15.6.2",
52
+ "@f5xc-salesdemos/pi-natives": "15.6.2",
53
+ "@f5xc-salesdemos/pi-tui": "15.6.2",
54
+ "@f5xc-salesdemos/pi-utils": "15.6.2",
55
55
  "@sinclair/typebox": "^0.34",
56
56
  "@xterm/headless": "^6.0",
57
57
  "ajv": "^8.18",
@@ -43,12 +43,13 @@ function getLiteLLMBaseUrl(): string | undefined {
43
43
  // ---------------------------------------------------------------------------
44
44
 
45
45
  export interface GenerateModelsYmlOptions {
46
- /** Model IDs discovered from the proxy's /v1/models endpoint. */
47
- discoveredModels?: string[];
46
+ /** API base path for the litellm provider (e.g. "/v1" or "/api/v1"). Defaults to "/v1". */
47
+ apiBasePath?: string;
48
48
  }
49
49
 
50
50
  /** Generate models.yml content for LiteLLM proxy. */
51
51
  export function generateModelsYml(baseUrl: string, options?: GenerateModelsYmlOptions): string {
52
+ const apiBase = options?.apiBasePath ?? "/v1";
52
53
  const lines = [
53
54
  "# Auto-generated by xcsh for LiteLLM proxy",
54
55
  "# API key resolved from LITELLM_API_KEY env var at runtime",
@@ -57,22 +58,14 @@ export function generateModelsYml(baseUrl: string, options?: GenerateModelsYmlOp
57
58
  " anthropic:",
58
59
  ` baseUrl: "${baseUrl}/anthropic"`,
59
60
  " apiKey: LITELLM_API_KEY",
61
+ " litellm:",
62
+ ` baseUrl: "${baseUrl}${apiBase}"`,
63
+ " apiKey: LITELLM_API_KEY",
64
+ " api: openai-completions",
65
+ " discovery:",
66
+ " type: openai-compat",
60
67
  ];
61
68
 
62
- // When the proxy has been probed and models discovered, add a litellm provider
63
- // with openai-compat discovery so the model registry fetches real model catalog
64
- const discovered = options?.discoveredModels;
65
- if (discovered && discovered.length > 0) {
66
- lines.push(
67
- " litellm:",
68
- ` baseUrl: "${baseUrl}/v1"`,
69
- " apiKey: LITELLM_API_KEY",
70
- " api: openai-completions",
71
- " discovery:",
72
- " type: openai-compat",
73
- );
74
- }
75
-
76
69
  lines.push("");
77
70
  return lines.join("\n");
78
71
  }
@@ -81,6 +74,9 @@ export function generateModelsYml(baseUrl: string, options?: GenerateModelsYmlOp
81
74
  export function generateConfigYml(): string {
82
75
  return [
83
76
  "# Auto-generated by xcsh for LiteLLM proxy",
77
+ "modelRoles:",
78
+ " default: anthropic/claude-opus-4-6",
79
+ "",
84
80
  "providers:",
85
81
  " image: openai",
86
82
  "",
@@ -100,6 +96,25 @@ export function generateConfigYml(): string {
100
96
  ].join("\n");
101
97
  }
102
98
 
99
+ /**
100
+ * Ensure config.yml has a modelRoles.default entry.
101
+ * Existing configs from earlier versions may be missing this, causing xcsh
102
+ * to fall through to the "first available model" fallback which picks stale
103
+ * cached models that the proxy can't serve.
104
+ */
105
+ function healConfigYmlModelRoles(configPath: string): void {
106
+ try {
107
+ const content = fs.readFileSync(configPath, "utf-8");
108
+ if (content.includes("modelRoles:")) return; // Already has modelRoles
109
+ // Prepend modelRoles section
110
+ const healed = `modelRoles:\n default: anthropic/claude-opus-4-6\n\n${content}`;
111
+ fs.writeFileSync(configPath, healed);
112
+ logger.debug("Healed config.yml: added default modelRoles", { configPath });
113
+ } catch {
114
+ // Best-effort — don't block startup
115
+ }
116
+ }
117
+
103
118
  // ---------------------------------------------------------------------------
104
119
  // Backup
105
120
  // ---------------------------------------------------------------------------
@@ -129,6 +144,26 @@ function safeWrite(filePath: string, content: string): boolean {
129
144
  }
130
145
  }
131
146
 
147
+ /** Remove the model cache database so discovery re-runs fresh. */
148
+ function clearModelCache(modelsPath: string): void {
149
+ const cacheDbPath = path.join(path.dirname(modelsPath), "models.db");
150
+ try {
151
+ if (fs.existsSync(cacheDbPath)) {
152
+ fs.unlinkSync(cacheDbPath);
153
+ logger.debug("Cleared stale model cache", { cacheDbPath });
154
+ }
155
+ // Also remove WAL/SHM files if present
156
+ for (const suffix of ["-wal", "-shm"]) {
157
+ const walPath = `${cacheDbPath}${suffix}`;
158
+ if (fs.existsSync(walPath)) {
159
+ fs.unlinkSync(walPath);
160
+ }
161
+ }
162
+ } catch {
163
+ // Best-effort — don't block config repair
164
+ }
165
+ }
166
+
132
167
  // ---------------------------------------------------------------------------
133
168
  // Proxy connection probing
134
169
  // ---------------------------------------------------------------------------
@@ -137,14 +172,21 @@ export interface ProbeResult {
137
172
  reachable: boolean;
138
173
  models: string[];
139
174
  error?: string;
175
+ /** The API base path that worked (e.g. "/v1" or "/api/v1"). */
176
+ apiBasePath?: string;
140
177
  }
141
178
 
179
+ /** Candidate paths to probe for the models endpoint (tried in order). */
180
+ const MODELS_ENDPOINT_PATHS = ["/v1/models", "/api/v1/models"];
181
+
142
182
  /**
143
- * Probe a LiteLLM proxy's /v1/models endpoint to validate connectivity
144
- * and discover available models.
183
+ * Probe a LiteLLM proxy to validate connectivity and discover available models.
184
+ *
185
+ * Tries multiple endpoint paths in order (/v1/models, then /api/v1/models) to
186
+ * handle deployments where a frontend like Open WebUI intercepts /v1/*.
145
187
  *
146
188
  * Returns the list of model IDs on success, or an error on failure.
147
- * Uses a 3-second timeout to avoid blocking startup.
189
+ * Uses a 3-second timeout per endpoint to avoid blocking startup.
148
190
  */
149
191
  export async function probeLiteLLMConnection(
150
192
  baseUrl: string,
@@ -153,60 +195,63 @@ export async function probeLiteLLMConnection(
153
195
  ): Promise<ProbeResult> {
154
196
  const fetchImpl = options?.fetch ?? globalThis.fetch;
155
197
  const normalizedUrl = baseUrl.replace(/\/+$/, "");
198
+ let lastError = "";
156
199
 
157
- let response: Response;
158
- try {
159
- response = await fetchImpl(`${normalizedUrl}/v1/models`, {
160
- method: "GET",
161
- headers: {
162
- Accept: "application/json",
163
- Authorization: `Bearer ${apiKey}`,
164
- },
165
- signal: options?.signal ?? AbortSignal.timeout(3000),
166
- });
167
- } catch (err) {
168
- return {
169
- reachable: false,
170
- models: [],
171
- error: err instanceof Error ? err.message : String(err),
172
- };
173
- }
200
+ for (const endpointPath of MODELS_ENDPOINT_PATHS) {
201
+ const url = `${normalizedUrl}${endpointPath}`;
202
+ let response: Response;
203
+ try {
204
+ response = await fetchImpl(url, {
205
+ method: "GET",
206
+ headers: {
207
+ Accept: "application/json",
208
+ Authorization: `Bearer ${apiKey}`,
209
+ },
210
+ signal: options?.signal ?? AbortSignal.timeout(3000),
211
+ });
212
+ } catch (err) {
213
+ lastError = err instanceof Error ? err.message : String(err);
214
+ continue;
215
+ }
174
216
 
175
- if (!response.ok) {
176
- return {
177
- reachable: false,
178
- models: [],
179
- error: `HTTP ${response.status} ${response.statusText}`,
180
- };
181
- }
217
+ if (!response.ok) {
218
+ lastError = `HTTP ${response.status} ${response.statusText} from ${url}`;
219
+ continue;
220
+ }
182
221
 
183
- let payload: unknown;
184
- try {
185
- payload = await response.json();
186
- } catch {
187
- return {
188
- reachable: false,
189
- models: [],
190
- error: "Failed to parse response as JSON",
191
- };
192
- }
222
+ let payload: unknown;
223
+ try {
224
+ payload = await response.json();
225
+ } catch {
226
+ lastError = `Non-JSON response from ${url}`;
227
+ continue;
228
+ }
193
229
 
194
- // OpenAI-compatible /v1/models returns { data: [{ id: "model-name", ... }] }
195
- const models: string[] = [];
196
- if (
197
- payload &&
198
- typeof payload === "object" &&
199
- "data" in payload &&
200
- Array.isArray((payload as { data: unknown }).data)
201
- ) {
202
- for (const entry of (payload as { data: Array<{ id?: string }> }).data) {
203
- if (typeof entry.id === "string" && entry.id.length > 0) {
204
- models.push(entry.id);
230
+ // OpenAI-compatible /v1/models returns { data: [{ id: "model-name", ... }] }
231
+ const models: string[] = [];
232
+ if (
233
+ payload &&
234
+ typeof payload === "object" &&
235
+ "data" in payload &&
236
+ Array.isArray((payload as { data: unknown }).data)
237
+ ) {
238
+ for (const entry of (payload as { data: Array<{ id?: string }> }).data) {
239
+ if (typeof entry.id === "string" && entry.id.length > 0) {
240
+ models.push(entry.id);
241
+ }
205
242
  }
206
243
  }
244
+
245
+ if (models.length > 0) {
246
+ // Derive the API base path from the endpoint that worked
247
+ const apiBasePath = endpointPath.replace(/\/models$/, "");
248
+ return { reachable: true, models, apiBasePath };
249
+ }
250
+
251
+ lastError = `No models in response from ${url}`;
207
252
  }
208
253
 
209
- return { reachable: true, models };
254
+ return { reachable: false, models: [], error: lastError };
210
255
  }
211
256
 
212
257
  // ---------------------------------------------------------------------------
@@ -227,11 +272,13 @@ export function tryAutoConfigLiteLLM(modelsPath: string): boolean {
227
272
  if (!safeWrite(modelsPath, generateModelsYml(baseUrl))) return false;
228
273
  logger.debug("Auto-configured LiteLLM proxy", { modelsPath, baseUrl });
229
274
 
230
- // Write config.yml if it doesn't exist
275
+ // Write config.yml if it doesn't exist, or heal it if it's missing modelRoles
231
276
  const configPath = path.join(path.dirname(modelsPath), "config.yml");
232
277
  if (!fs.existsSync(configPath)) {
233
278
  safeWrite(configPath, generateConfigYml());
234
279
  logger.debug("Auto-generated default config", { configPath });
280
+ } else {
281
+ healConfigYmlModelRoles(configPath);
235
282
  }
236
283
 
237
284
  return true;
@@ -360,6 +407,9 @@ export function autoFixModelsConfig(modelsPath: string): FixResult {
360
407
  return { fixed: false, changes: [`Write failed: could not write to ${modelsPath}`] };
361
408
  }
362
409
 
410
+ // Clear stale model cache so discovery re-runs with the new config
411
+ clearModelCache(modelsPath);
412
+
363
413
  logger.debug("Auto-fixed LiteLLM config", { modelsPath, baseUrl });
364
414
  return { fixed: true, changes: [`Regenerated models.yml with baseUrl: ${baseUrl}/anthropic`] };
365
415
  }
@@ -416,16 +466,33 @@ export function startupHealthCheck(
416
466
  return fix.fixed;
417
467
  }
418
468
 
419
- // Case 4: Config OK and URL matches, but configVersion is missing or outdated
469
+ // Case 4: Config OK and URL matches check for structural issues
420
470
  try {
421
471
  const content = fs.readFileSync(modelsPath, "utf-8");
472
+
473
+ // 4a: configVersion is missing or outdated
422
474
  if (!content.includes(`configVersion: ${CURRENT_CONFIG_VERSION}`)) {
423
475
  logger.debug("Upgrading models.yml to configVersion", { version: CURRENT_CONFIG_VERSION });
424
476
  const fix = autoFixModelsConfig(modelsPath);
425
477
  return fix.fixed;
426
478
  }
479
+
480
+ // 4b: litellm discovery provider is missing (legacy v1-style config)
481
+ if (!content.includes("type: openai-compat")) {
482
+ logger.debug("Adding litellm discovery provider to models.yml");
483
+ const fix = autoFixModelsConfig(modelsPath);
484
+ return fix.fixed;
485
+ }
427
486
  } catch {
428
- // File read failed — skip version check, don't block startup
487
+ // File read failed — skip structural checks, don't block startup
488
+ }
489
+ }
490
+
491
+ // Always heal config.yml model roles (regardless of models.yml state)
492
+ if (hasLiteLLMEnv()) {
493
+ const configPath = path.join(path.dirname(modelsPath), "config.yml");
494
+ if (fs.existsSync(configPath)) {
495
+ healConfigYmlModelRoles(configPath);
429
496
  }
430
497
  }
431
498
 
@@ -437,12 +504,11 @@ export function startupHealthCheck(
437
504
  // ---------------------------------------------------------------------------
438
505
 
439
506
  /**
440
- * Probe the LiteLLM proxy and upgrade a v1 config to v2 with discovery.
507
+ * Probe the LiteLLM proxy and upgrade config with the correct API base path.
441
508
  *
442
509
  * This is an async operation that runs during the first ModelRegistry.refresh().
443
- * It validates proxy connectivity and, if successful, upgrades the config to
444
- * include a `litellm` provider with `openai-compat` discovery so the registry
445
- * discovers real models at runtime instead of relying on bundled model IDs.
510
+ * It validates proxy connectivity and, if successful, ensures the config uses
511
+ * the correct API base path (e.g. /api/v1 for Open WebUI deployments).
446
512
  *
447
513
  * Returns true if the config was upgraded (caller should reload).
448
514
  */
@@ -456,18 +522,15 @@ export async function probeAndUpgradeLiteLLMConfig(
456
522
  const apiKey = $env.LITELLM_API_KEY?.trim();
457
523
  if (!baseUrl || !apiKey) return false;
458
524
 
459
- // Check if discovery is already configured — no-op if so
525
+ let content: string;
460
526
  try {
461
- const content = fs.readFileSync(modelsPath, "utf-8");
462
- if (content.includes("type: openai-compat")) {
463
- return false;
464
- }
527
+ content = fs.readFileSync(modelsPath, "utf-8");
465
528
  } catch {
466
529
  // File doesn't exist or is unreadable — nothing to upgrade
467
530
  return false;
468
531
  }
469
532
 
470
- // Probe the proxy
533
+ // Probe the proxy to find the working API base path
471
534
  const probe = await probeLiteLLMConnection(baseUrl, apiKey, { fetch: options?.fetch });
472
535
  if (!probe.reachable) {
473
536
  logger.warn("LiteLLM proxy unreachable during upgrade probe — keeping existing config", {
@@ -482,17 +545,26 @@ export async function probeAndUpgradeLiteLLMConfig(
482
545
  return false;
483
546
  }
484
547
 
485
- // Upgrade: backup and regenerate with discovery
548
+ // Check if the config already has the correct discovery base path
549
+ const hasDiscovery = content.includes("type: openai-compat");
550
+ const correctBase = `${baseUrl}${probe.apiBasePath}`;
551
+ if (hasDiscovery && content.includes(correctBase)) {
552
+ return false; // Already correct
553
+ }
554
+
555
+ // Upgrade: backup and regenerate with correct base path
486
556
  backupIfExists(modelsPath);
487
- const newContent = generateModelsYml(baseUrl, { discoveredModels: probe.models });
557
+ const newContent = generateModelsYml(baseUrl, { apiBasePath: probe.apiBasePath });
488
558
  if (!safeWrite(modelsPath, newContent)) {
489
559
  return false;
490
560
  }
491
561
 
492
- logger.debug("Upgraded LiteLLM config with model discovery", {
562
+ logger.debug("Upgraded LiteLLM config", {
493
563
  modelsPath,
494
564
  baseUrl,
495
- discoveredModels: probe.models.length,
565
+ apiBasePath: probe.apiBasePath,
566
+ models: probe.models.length,
567
+ hadDiscovery: hasDiscovery,
496
568
  });
497
569
  return true;
498
570
  }
@@ -822,6 +822,29 @@ export class ModelRegistry {
822
822
  this.#backgroundRefresh = refreshPromise;
823
823
  }
824
824
 
825
+ /**
826
+ * Await the in-flight background refresh if one is running.
827
+ * Returns immediately if no background refresh is in progress.
828
+ */
829
+ async awaitBackgroundRefresh(): Promise<void> {
830
+ if (this.#backgroundRefresh) {
831
+ await this.#backgroundRefresh;
832
+ }
833
+ }
834
+
835
+ /**
836
+ * Check if any non-optional discoverable provider has no cached models yet.
837
+ * Returns true on first run when the model cache is empty.
838
+ */
839
+ hasUncachedDiscoverableProviders(): boolean {
840
+ for (const [, state] of this.#providerDiscoveryStates) {
841
+ if (state.status === "idle" && !state.optional) {
842
+ return true;
843
+ }
844
+ }
845
+ return false;
846
+ }
847
+
825
848
  async refreshProvider(providerId: string, strategy: ModelRefreshStrategy = "online"): Promise<void> {
826
849
  this.#reloadStaticModels();
827
850
  for (const selector of this.#suppressedSelectors.keys()) {
@@ -1283,10 +1306,22 @@ export class ModelRegistry {
1283
1306
  ): Promise<Model<Api>[]> {
1284
1307
  // Skip providers already handled by configured discovery (e.g. user-configured ollama with discovery.type)
1285
1308
  const configuredDiscoveryProviders = new Set(this.#discoverableProviders.map(p => p.provider));
1309
+
1310
+ // When a LiteLLM proxy is configured, providers with overridden baseUrls are
1311
+ // proxied through it. Their built-in discovery would query the proxy's model
1312
+ // listing endpoint, which may return model IDs the proxy can't serve for chat.
1313
+ // Skip them — the litellm discovery provider handles model listing instead.
1314
+ const proxiedProviders = hasLiteLLMEnv()
1315
+ ? new Set([...this.#providerOverrides.keys()].filter(id => this.#providerOverrides.get(id)?.baseUrl))
1316
+ : new Set<string>();
1317
+
1286
1318
  const managerOptions = (await this.#collectBuiltInModelManagerOptions()).filter(opts => {
1287
1319
  if (configuredDiscoveryProviders.has(opts.providerId)) {
1288
1320
  return false;
1289
1321
  }
1322
+ if (proxiedProviders.has(opts.providerId)) {
1323
+ return false;
1324
+ }
1290
1325
  return providerFilter ? providerFilter.has(opts.providerId) : true;
1291
1326
  });
1292
1327
  if (managerOptions.length === 0) {
package/src/sdk.ts CHANGED
@@ -28,6 +28,7 @@ import { AsyncJobManager } from "./async";
28
28
  import { createAutoresearchExtension } from "./autoresearch";
29
29
  import { loadCapability } from "./capability";
30
30
  import { type Rule, ruleCapability } from "./capability/rule";
31
+ import { hasLiteLLMEnv } from "./config/auto-config";
31
32
  import { ModelRegistry } from "./config/model-registry";
32
33
  import { formatModelString, parseModelPattern, parseModelString, resolveModelRoleValue } from "./config/model-resolver";
33
34
  import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./config/prompt-templates";
@@ -728,6 +729,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
728
729
  const modelMatchPreferences = {
729
730
  usageOrder: settings.getStorage()?.getModelUsageOrder(),
730
731
  };
732
+
733
+ // When LiteLLM is configured and no model cache exists yet (first run),
734
+ // await the background refresh so model discovery from the proxy completes
735
+ // before we select a default model. Bounded by the 3s probe timeout.
736
+ if (!options.modelRegistry && hasLiteLLMEnv() && modelRegistry.hasUncachedDiscoverableProviders()) {
737
+ await logger.time("awaitLiteLLMDiscovery", () => modelRegistry.awaitBackgroundRefresh());
738
+ }
739
+
731
740
  const defaultRoleSpec = logger.time("resolveDefaultModelRole", () =>
732
741
  resolveModelRoleValue(settings.getModelRole("default"), modelRegistry.getAvailable(), {
733
742
  settings,