@f5-sales-demo/xcsh 19.103.4 → 19.105.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [19.104.0] - 2026-07-31
6
+
7
+ ### Added
8
+
9
+ - Added a post-login LiteLLM model chooser for GPT-5.6 Sol and Claude Opus 5 with High reasoning; authenticated users can switch later with `/model`
10
+
11
+ ### Fixed
12
+
13
+ - Restricted LiteLLM credential configuration files to owner-only permissions
14
+
5
15
  ## [19.51.5] - 2026-06-26
6
16
 
7
17
  ### Fixed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "19.103.4",
4
+ "version": "19.105.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -57,13 +57,13 @@
57
57
  "dependencies": {
58
58
  "@agentclientprotocol/sdk": "1.3.0",
59
59
  "@mozilla/readability": "^0.6",
60
- "@f5-sales-demo/xcsh-stats": "19.103.4",
61
- "@f5-sales-demo/pi-agent-core": "19.103.4",
62
- "@f5-sales-demo/pi-ai": "19.103.4",
63
- "@f5-sales-demo/pi-natives": "19.103.4",
64
- "@f5-sales-demo/pi-resource-management": "19.103.4",
65
- "@f5-sales-demo/pi-tui": "19.103.4",
66
- "@f5-sales-demo/pi-utils": "19.103.4",
60
+ "@f5-sales-demo/xcsh-stats": "19.105.0",
61
+ "@f5-sales-demo/pi-agent-core": "19.105.0",
62
+ "@f5-sales-demo/pi-ai": "19.105.0",
63
+ "@f5-sales-demo/pi-natives": "19.105.0",
64
+ "@f5-sales-demo/pi-resource-management": "19.105.0",
65
+ "@f5-sales-demo/pi-tui": "19.105.0",
66
+ "@f5-sales-demo/pi-utils": "19.105.0",
67
67
  "@sinclair/typebox": "^0.34",
68
68
  "@xterm/headless": "^6.0",
69
69
  "ajv": "^8.20",
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import * as fs from "node:fs";
17
17
  import * as path from "node:path";
18
- import { $env, logger, readProviderFromModelsYml } from "@f5-sales-demo/pi-utils";
18
+ import { $env, isEnoent, logger, readProviderFromModelsYml } from "@f5-sales-demo/pi-utils";
19
19
  import { DEFAULT_MODEL_ROLE } from "./settings-schema";
20
20
 
21
21
  /** Current config schema version. Bump when the generated format changes. */
@@ -81,6 +81,17 @@ export function generateModelsYml(baseUrl: string, options?: GenerateModelsYmlOp
81
81
  return lines.join("\n");
82
82
  }
83
83
 
84
+ /** Persist models.yml with owner-only permissions because it may contain a literal proxy credential. */
85
+ export async function writeLiteLLMModelsYml(filePath: string, content: string): Promise<void> {
86
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
87
+ try {
88
+ await fs.promises.chmod(filePath, 0o600);
89
+ } catch (error) {
90
+ if (!isEnoent(error)) throw error;
91
+ }
92
+ await fs.promises.writeFile(filePath, content, { encoding: "utf-8", mode: 0o600 });
93
+ }
94
+
84
95
  export interface LiteLLMConfig {
85
96
  baseUrl: string;
86
97
  apiKey: string;
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.103.4",
21
- "commit": "6645553ffbc502c2516e744745247a4876580191",
22
- "shortCommit": "6645553",
20
+ "version": "19.105.0",
21
+ "commit": "2df670b0ab2acc2e41d7574c0c40456abad497fd",
22
+ "shortCommit": "2df670b",
23
23
  "branch": "main",
24
- "tag": "v19.103.4",
25
- "commitDate": "2026-07-31T13:09:45Z",
26
- "buildDate": "2026-07-31T13:41:54.471Z",
24
+ "tag": "v19.105.0",
25
+ "commitDate": "2026-07-31T14:20:29Z",
26
+ "buildDate": "2026-07-31T14:50:23.668Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5-sales-demo/xcsh",
30
30
  "repoSlug": "f5-sales-demo/xcsh",
31
- "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/6645553ffbc502c2516e744745247a4876580191",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.103.4"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/2df670b0ab2acc2e41d7574c0c40456abad497fd",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.105.0"
33
33
  };
@@ -15,6 +15,7 @@ export * from "./hook-input";
15
15
  export * from "./hook-message";
16
16
  export * from "./hook-selector";
17
17
  export * from "./keybinding-hints";
18
+ export * from "./litellm-model-selector";
18
19
  export * from "./login-dialog";
19
20
  export * from "./model-selector";
20
21
  export * from "./oauth-selector";
@@ -0,0 +1,40 @@
1
+ import { Container, type SelectItem, SelectList, Spacer, Text } from "@f5-sales-demo/pi-tui";
2
+ import type { LiteLLMLoginModelChoice } from "../controllers/login-model";
3
+ import { getSelectListTheme } from "../theme/theme";
4
+ import { DynamicBorder } from "./dynamic-border";
5
+
6
+ export class LiteLLMModelSelectorComponent extends Container {
7
+ #selectList: SelectList;
8
+
9
+ constructor(
10
+ choices: readonly LiteLLMLoginModelChoice[],
11
+ onSelect: (choice: LiteLLMLoginModelChoice) => void,
12
+ onCancel: () => void,
13
+ ) {
14
+ super();
15
+
16
+ const items: SelectItem[] = choices.map(choice => ({
17
+ value: `${choice.provider}/${choice.modelId}`,
18
+ label: choice.label,
19
+ description: choice.description,
20
+ }));
21
+
22
+ this.addChild(new DynamicBorder());
23
+ this.addChild(new Spacer(1));
24
+ this.addChild(new Text("Select your default model:", 1, 0));
25
+ this.addChild(new Spacer(1));
26
+ this.#selectList = new SelectList(items, items.length, getSelectListTheme());
27
+ this.#selectList.onSelect = item => {
28
+ const choice = choices.find(candidate => `${candidate.provider}/${candidate.modelId}` === item.value);
29
+ if (choice) onSelect(choice);
30
+ };
31
+ this.#selectList.onCancel = onCancel;
32
+ this.addChild(this.#selectList);
33
+ this.addChild(new Spacer(1));
34
+ this.addChild(new DynamicBorder());
35
+ }
36
+
37
+ getSelectList(): SelectList {
38
+ return this.#selectList;
39
+ }
40
+ }
@@ -1,32 +1,66 @@
1
+ import { ThinkingLevel } from "@f5-sales-demo/pi-agent-core";
1
2
  import type { Model } from "@f5-sales-demo/pi-ai";
2
3
 
4
+ export interface LiteLLMLoginModelChoice {
5
+ label: string;
6
+ description: string;
7
+ provider: "anthropic" | "litellm";
8
+ modelId: "claude-opus-5" | "gpt-5.6-sol";
9
+ thinkingLevel: ThinkingLevel;
10
+ }
11
+
12
+ export const LITELLM_LOGIN_MODEL_CHOICES: readonly LiteLLMLoginModelChoice[] = [
13
+ {
14
+ label: "GPT-5.6 Sol",
15
+ description: "OpenAI-compatible model with high reasoning",
16
+ provider: "litellm",
17
+ modelId: "gpt-5.6-sol",
18
+ thinkingLevel: ThinkingLevel.High,
19
+ },
20
+ {
21
+ label: "Claude Opus 5",
22
+ description: "Anthropic Messages model with high reasoning",
23
+ provider: "anthropic",
24
+ modelId: "claude-opus-5",
25
+ thinkingLevel: ThinkingLevel.High,
26
+ },
27
+ ];
28
+
29
+ export function getAvailableLiteLLMLoginModelChoices(availableModelIds: readonly string[]): LiteLLMLoginModelChoice[] {
30
+ const available = new Set(availableModelIds);
31
+ return LITELLM_LOGIN_MODEL_CHOICES.filter(choice => available.has(choice.modelId));
32
+ }
33
+
3
34
  /**
4
35
  * Minimal session surface needed to apply a model after a successful login.
5
36
  * Kept structural so the login flow can call it without pulling in the full
6
37
  * AgentSession type, and so it stays trivially unit-testable.
7
38
  */
8
39
  interface ModelApplicableSession {
9
- model: Model | undefined;
10
40
  modelRegistry: { getAll(): Model[] };
11
- setModel(model: Model, role: "default", options?: { selector?: string }): Promise<void>;
41
+ setModel(model: Model, role: "default", options: { selector: string; thinkingLevel: ThinkingLevel }): Promise<void>;
42
+ setThinkingLevel(level: ThinkingLevel): void;
12
43
  }
13
44
 
14
45
  /**
15
- * After a successful `/login`, make the freshly-configured provider immediately
16
- * usable by setting it as the session's default model but only when the session
17
- * has no model yet, so we never override a model the user already chose.
46
+ * After a successful `/login`, apply the model the user explicitly selected as
47
+ * the active and persisted default model, including its thinking level.
18
48
  *
19
- * Returns true when a model was applied. The login wizard auto-selects a model id
20
- * from the provider's /models list; resolving it here (post-registry-refresh) is
21
- * what lets the LLM readiness gate lift without a manual `/model` step.
49
+ * Returns true when the exact provider/model pair resolves after registry refresh.
22
50
  */
23
51
  export async function applyModelAfterLogin(
24
52
  session: ModelApplicableSession,
25
- selectedModelId: string | undefined,
53
+ choice: LiteLLMLoginModelChoice,
26
54
  ): Promise<boolean> {
27
- if (session.model || !selectedModelId) return false;
28
- const resolved = session.modelRegistry.getAll().find(m => m.id === selectedModelId);
55
+ const resolved = session.modelRegistry
56
+ .getAll()
57
+ .find(model => model.provider === choice.provider && model.id === choice.modelId);
29
58
  if (!resolved) return false;
30
- await session.setModel(resolved, "default", { selector: resolved.id });
59
+ const selector = `${choice.provider}/${choice.modelId}`;
60
+ await session.setModel(resolved, "default", {
61
+ selector,
62
+ thinkingLevel: choice.thinkingLevel,
63
+ });
64
+ session.setThinkingLevel(choice.thinkingLevel);
31
65
  return true;
32
66
  }
@@ -13,6 +13,7 @@ import {
13
13
  healConfigYmlModelRoles,
14
14
  probeLiteLLMConnection,
15
15
  readLiteLLMConfig,
16
+ writeLiteLLMModelsYml,
16
17
  } from "../../config/auto-config";
17
18
  import { getRoleInfo } from "../../config/model-registry";
18
19
  import { formatModelSelectorValue } from "../../config/model-resolver";
@@ -46,6 +47,7 @@ import { AssistantMessageComponent } from "../components/assistant-message";
46
47
  import { ExtensionDashboard } from "../components/extensions";
47
48
  import { GutterBlock } from "../components/gutter-block";
48
49
  import { HistorySearchComponent } from "../components/history-search";
50
+ import { LiteLLMModelSelectorComponent } from "../components/litellm-model-selector";
49
51
  import { ModelSelectorComponent } from "../components/model-selector";
50
52
  import { OAuthSelectorComponent } from "../components/oauth-selector";
51
53
  import { PluginSelectorComponent } from "../components/plugin-selector";
@@ -58,7 +60,7 @@ import { ToolExecutionComponent } from "../components/tool-execution";
58
60
  import { TreeSelectorComponent } from "../components/tree-selector";
59
61
  import { UserMessageSelectorComponent } from "../components/user-message-selector";
60
62
  import type { SessionObserverRegistry } from "../session-observer-registry";
61
- import { applyModelAfterLogin } from "./login-model";
63
+ import { applyModelAfterLogin, getAvailableLiteLLMLoginModelChoices, LITELLM_LOGIN_MODEL_CHOICES } from "./login-model";
62
64
 
63
65
  const CALLBACK_SERVER_PROVIDERS = new Set<OAuthProvider>([
64
66
  "anthropic",
@@ -472,6 +474,69 @@ export class SelectorController {
472
474
  });
473
475
  }
474
476
 
477
+ async #showLiteLLMLoginModelSelector(availableModelIds: readonly string[]): Promise<boolean> {
478
+ const choices = getAvailableLiteLLMLoginModelChoices(availableModelIds);
479
+ const available = new Set(choices.map(choice => choice.modelId));
480
+ const unavailable = LITELLM_LOGIN_MODEL_CHOICES.filter(choice => !available.has(choice.modelId));
481
+
482
+ if (unavailable.length > 0) {
483
+ this.ctx.chatContainer.addChild(
484
+ new Text(
485
+ theme.fg(
486
+ "warning",
487
+ `Unavailable from this proxy: ${unavailable.map(choice => choice.label).join(", ")}`,
488
+ ),
489
+ 1,
490
+ 0,
491
+ ),
492
+ );
493
+ }
494
+
495
+ if (choices.length === 0) {
496
+ this.ctx.showError("LiteLLM login succeeded, but neither GPT-5.6 Sol nor Claude Opus 5 is available.");
497
+ return false;
498
+ }
499
+
500
+ const { promise, resolve } = Promise.withResolvers<boolean>();
501
+ let applying = false;
502
+ this.showSelector(done => {
503
+ const selector = new LiteLLMModelSelectorComponent(
504
+ choices,
505
+ choice => {
506
+ if (applying) return;
507
+ applying = true;
508
+ void (async () => {
509
+ try {
510
+ const applied = await applyModelAfterLogin(this.ctx.session, choice);
511
+ if (!applied) {
512
+ this.ctx.showError(`Model unavailable after refresh: ${choice.provider}/${choice.modelId}`);
513
+ applying = false;
514
+ return;
515
+ }
516
+ this.ctx.statusLine.invalidate();
517
+ this.ctx.updateEditorBorderColor();
518
+ done();
519
+ resolve(true);
520
+ this.ctx.ui.requestRender();
521
+ } catch (error) {
522
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
523
+ applying = false;
524
+ }
525
+ })();
526
+ },
527
+ () => {
528
+ if (applying) return;
529
+ done();
530
+ resolve(false);
531
+ this.ctx.ui.requestRender();
532
+ },
533
+ );
534
+ return { component: selector, focus: selector.getSelectList() };
535
+ });
536
+
537
+ return promise;
538
+ }
539
+
475
540
  async showPluginSelector(mode: "install" | "uninstall" = "install"): Promise<void> {
476
541
  const mgr = new MarketplaceManager({
477
542
  marketplacesRegistryPath: getMarketplacesRegistryPath(),
@@ -927,8 +992,7 @@ export class SelectorController {
927
992
  apiBasePath: probe.apiBasePath,
928
993
  apiKeyLiteral: result.apiKey,
929
994
  });
930
- fs.mkdirSync(path.dirname(modelsPath), { recursive: true });
931
- fs.writeFileSync(modelsPath, yml);
995
+ await writeLiteLLMModelsYml(modelsPath, yml);
932
996
 
933
997
  // Create/heal config.yml for model defaults
934
998
  const configPath = path.join(path.dirname(modelsPath), "config.yml");
@@ -940,11 +1004,24 @@ export class SelectorController {
940
1004
  // Force online refresh so the registry re-probes the new proxy
941
1005
  // instead of serving stale data from the in-process SQLite cache.
942
1006
  await this.ctx.session.modelRegistry.refresh("online");
1007
+ const modelSelected = await this.#showLiteLLMLoginModelSelector(probe.models);
943
1008
 
944
1009
  this.ctx.chatContainer.addChild(new Spacer(1));
945
1010
  this.ctx.chatContainer.addChild(
946
1011
  new Text(theme.fg("success", `LiteLLM configuration saved to ${modelsPath}`), 1, 0),
947
1012
  );
1013
+ this.ctx.chatContainer.addChild(
1014
+ new Text(
1015
+ theme.fg(
1016
+ modelSelected ? "dim" : "warning",
1017
+ modelSelected
1018
+ ? "Use /model to switch models without logging in again."
1019
+ : "No model selected. Use /model to choose one without logging in again.",
1020
+ ),
1021
+ 1,
1022
+ 0,
1023
+ ),
1024
+ );
948
1025
  this.ctx.ui.requestRender();
949
1026
  } catch (error: unknown) {
950
1027
  this.ctx.showError(`LiteLLM login failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -1143,29 +1220,17 @@ export class SelectorController {
1143
1220
 
1144
1221
  // Auto-retry once on network errors
1145
1222
  if (!probe.reachable && probe.error && !/\b(401|403|Unauthorized|Forbidden)\b/i.test(probe.error)) {
1146
- await new Promise(resolve => setTimeout(resolve, 1000));
1223
+ await Bun.sleep(1000);
1147
1224
  probe = await probeLiteLLMConnection(baseUrl, apiKey);
1148
1225
  }
1149
1226
 
1150
1227
  if (probe.reachable) {
1151
- // Auto-select best model
1152
- const preferenceOrder = ["claude-opus", "claude-sonnet", "claude", "gpt-4", "gpt"];
1153
- let selectedModel: string | undefined;
1154
- for (const pref of preferenceOrder) {
1155
- selectedModel = probe.models.find(m => m.toLowerCase().includes(pref));
1156
- if (selectedModel) break;
1157
- }
1158
- if (!selectedModel && probe.models.length > 0) {
1159
- selectedModel = probe.models[0];
1160
- }
1161
-
1162
1228
  // Save config
1163
1229
  const yml = generateModelsYml(baseUrl, {
1164
1230
  apiBasePath: probe.apiBasePath,
1165
1231
  apiKeyLiteral: apiKey,
1166
1232
  });
1167
- fs.mkdirSync(path.dirname(modelsPath), { recursive: true, mode: 0o700 });
1168
- fs.writeFileSync(modelsPath, yml, { mode: 0o600 });
1233
+ await writeLiteLLMModelsYml(modelsPath, yml);
1169
1234
 
1170
1235
  const configPath = path.join(path.dirname(modelsPath), "config.yml");
1171
1236
  if (!fs.existsSync(configPath)) {
@@ -1174,10 +1239,13 @@ export class SelectorController {
1174
1239
  healConfigYmlModelRoles(configPath);
1175
1240
 
1176
1241
  await this.ctx.session.modelRegistry.refresh("online");
1177
- // Make the freshly-configured provider usable immediately so the LLM
1178
- // readiness gate lifts without requiring a manual /model selection.
1179
- await applyModelAfterLogin(this.ctx.session, selectedModel);
1242
+ const modelSelected = await this.#showLiteLLMLoginModelSelector(probe.models);
1180
1243
  await this.ctx.refreshWelcomeAfterLogin();
1244
+ this.ctx.showStatus(
1245
+ modelSelected
1246
+ ? "LiteLLM configured. Use /model to switch models without logging in again."
1247
+ : "LiteLLM configured. Use /model to select a model.",
1248
+ );
1181
1249
  probeSuccess = true;
1182
1250
  } else {
1183
1251
  const errorMsg = probe.error ?? "connection failed";