@f5-sales-demo/xcsh 20.5.1 → 20.6.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "20.5.1",
4
+ "version": "20.6.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",
@@ -44,6 +44,7 @@
44
44
  "fmt": "biome format --write . && bun run format-prompts",
45
45
  "format-prompts": "bun scripts/format-prompts.ts",
46
46
  "mermaid:gallery": "bun scripts/mermaid-gallery.ts",
47
+ "uat:auth-profiles": "bun scripts/auth-profile-rpc-uat.ts",
47
48
  "generate-docs-index": "bun scripts/generate-docs-index.ts",
48
49
  "generate-api-spec-index": "bun scripts/generate-api-spec-index.ts",
49
50
  "generate-branding-index": "bun scripts/generate-branding-index.ts",
@@ -60,13 +61,13 @@
60
61
  "dependencies": {
61
62
  "@agentclientprotocol/sdk": "1.3.0",
62
63
  "@mozilla/readability": "^0.6",
63
- "@f5-sales-demo/xcsh-stats": "20.5.1",
64
- "@f5-sales-demo/pi-agent-core": "20.5.1",
65
- "@f5-sales-demo/pi-ai": "20.5.1",
66
- "@f5-sales-demo/pi-natives": "20.5.1",
67
- "@f5-sales-demo/pi-resource-management": "20.5.1",
68
- "@f5-sales-demo/pi-tui": "20.5.1",
69
- "@f5-sales-demo/pi-utils": "20.5.1",
64
+ "@f5-sales-demo/xcsh-stats": "20.6.0",
65
+ "@f5-sales-demo/pi-agent-core": "20.6.0",
66
+ "@f5-sales-demo/pi-ai": "20.6.0",
67
+ "@f5-sales-demo/pi-natives": "20.6.0",
68
+ "@f5-sales-demo/pi-resource-management": "20.6.0",
69
+ "@f5-sales-demo/pi-tui": "20.6.0",
70
+ "@f5-sales-demo/pi-utils": "20.6.0",
70
71
  "@sinclair/typebox": "^0.34",
71
72
  "@xterm/headless": "^6.0",
72
73
  "ajv": "^8.20",
@@ -436,10 +436,10 @@ export class ChatHandler {
436
436
  if (!defaultModel) {
437
437
  throw new Error("Invalid baked default model selector");
438
438
  }
439
- const currentChoice = LITELLM_LOGIN_MODEL_CHOICES.find(
440
- choice => choice.provider === this.#session.model?.provider && choice.modelId === this.#session.model.id,
441
- );
442
- const modelId = msg.model ?? currentChoice?.modelId ?? defaultModel.id;
439
+ // An omitted model means the baked Office default, never whichever model a
440
+ // previous CLI/global session happened to leave active. An explicit pane
441
+ // selection still wins through msg.model.
442
+ const modelId = msg.model ?? defaultModel.id;
443
443
  const choice = LITELLM_LOGIN_MODEL_CHOICES.find(candidate => candidate.modelId === modelId);
444
444
  const matchingProviders = (["anthropic", "litellm"] as const).filter(candidate =>
445
445
  registry.find(candidate, modelId),
@@ -15,6 +15,8 @@
15
15
  * NOT browser-safe (node/bun): runs inside the full xcsh binary, never the pane.
16
16
  */
17
17
  import { getProjectDir, getXCSHConfigDir } from "@f5-sales-demo/pi-utils";
18
+ import { parseModelString } from "../config/model-resolver";
19
+ import { DEFAULT_MODEL_ROLE } from "../config/settings-schema";
18
20
  import { createAgentSession } from "../sdk";
19
21
  import { ContextService } from "../services/xcsh-context";
20
22
  import { deriveTenantEnv } from "../services/xcsh-env";
@@ -141,6 +143,10 @@ export async function startHeadlessChatBridge(deps: HeadlessBridgeDeps = default
141
143
  // selectProvider() reuses a dead bridge. The caller (startOfficeServe) treats
142
144
  // the rethrow as a non-fatal "pane only" fallback.
143
145
  try {
146
+ const officeDefault = parseModelString(DEFAULT_MODEL_ROLE);
147
+ if (!officeDefault) {
148
+ throw new Error("Invalid baked Office default model selector");
149
+ }
144
150
  // Create ONE headless Office session with the full CLI-parity builtin set
145
151
  // (OFFICE_TOOL_NAMES: bash/read/write/edit/grep/inspect_image/… — NO browser tools, which
146
152
  // would be hallucinated in a document task pane). The document's own tools
@@ -148,6 +154,11 @@ export async function startHeadlessChatBridge(deps: HeadlessBridgeDeps = default
148
154
  const { session } = await deps.createAgentSession({
149
155
  cwd,
150
156
  hasUI: false,
157
+ // Office must open on the production GPT profile regardless of a stale
158
+ // global/default role or resumable session. A saved pane model is applied
159
+ // afterward by its explicit configure frame and remains authoritative.
160
+ modelPattern: DEFAULT_MODEL_ROLE,
161
+ thinkingLevel: officeDefault.thinkingLevel,
151
162
  // Office conversations can contain private workbook and working-directory
152
163
  // data. Keep the entire headless session ephemeral instead of inheriting
153
164
  // createAgentSession's file-backed default.
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "20.5.1",
21
- "commit": "2ab4312e288d9147a956db45c74739efb6d950e4",
22
- "shortCommit": "2ab4312",
20
+ "version": "20.6.0",
21
+ "commit": "fda51f1e37f9202c495558b6e33c0067e822967e",
22
+ "shortCommit": "fda51f1",
23
23
  "branch": "main",
24
- "tag": "v20.5.1",
25
- "commitDate": "2026-08-05T22:06:08Z",
26
- "buildDate": "2026-08-05T22:34:37.560Z",
24
+ "tag": "v20.6.0",
25
+ "commitDate": "2026-08-06T11:20:36Z",
26
+ "buildDate": "2026-08-06T11:58:39.606Z",
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/2ab4312e288d9147a956db45c74739efb6d950e4",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.5.1"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/fda51f1e37f9202c495558b6e33c0067e822967e",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.6.0"
33
33
  };
@@ -0,0 +1,9 @@
1
+ import type { OAuthPrompt } from "@f5-sales-demo/pi-ai";
2
+ import { Input } from "@f5-sales-demo/pi-tui";
3
+
4
+ /** Build a login input with the display policy declared by its prompt contract. */
5
+ export function createLoginPromptInput(prompt: Partial<OAuthPrompt>): Input {
6
+ const input = new Input();
7
+ input.setMasked(prompt.secret === true);
8
+ return input;
9
+ }
@@ -1,5 +1,5 @@
1
1
  import { getOAuthProviders, type OAuthProviderInfo } from "@f5-sales-demo/pi-ai";
2
- import { Container, matchesKey, Spacer, TruncatedText } from "@f5-sales-demo/pi-tui";
2
+ import { Container, Input, matchesKey, Spacer, Text, TruncatedText } from "@f5-sales-demo/pi-tui";
3
3
  import { theme } from "../../modes/theme/theme";
4
4
  import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
5
5
  import type { AuthStorage } from "../../session/auth-storage";
@@ -8,8 +8,12 @@ import { DynamicBorder } from "./dynamic-border";
8
8
  * Component that renders an OAuth provider selector.
9
9
  */
10
10
  export class OAuthSelectorComponent extends Container {
11
+ static readonly MAX_VISIBLE_PROVIDERS = 10;
12
+
11
13
  #listContainer: Container;
12
14
  #allProviders: OAuthProviderInfo[] = [];
15
+ #filteredProviders: OAuthProviderInfo[] = [];
16
+ #searchInput: Input;
13
17
  #selectedIndex: number = 0;
14
18
  #mode: "login" | "logout";
15
19
  #authStorage: AuthStorage;
@@ -47,6 +51,10 @@ export class OAuthSelectorComponent extends Container {
47
51
  const title = mode === "login" ? "Select provider to login:" : "Select provider to logout:";
48
52
  this.addChild(new TruncatedText(theme.bold(title)));
49
53
  this.addChild(new Spacer(1));
54
+ this.addChild(new Text(theme.fg("muted", "Type to filter providers:"), 0, 0));
55
+ this.#searchInput = new Input();
56
+ this.addChild(this.#searchInput);
57
+ this.addChild(new Spacer(1));
50
58
  // Create list container
51
59
  this.#listContainer = new Container();
52
60
  this.addChild(this.#listContainer);
@@ -63,7 +71,20 @@ export class OAuthSelectorComponent extends Container {
63
71
  this.#stopSpinner();
64
72
  }
65
73
  #loadProviders(): void {
66
- this.#allProviders = getOAuthProviders();
74
+ this.#allProviders = getOAuthProviders().filter(provider => this.#mode === "login" || !provider.loginOnly);
75
+ this.#filteredProviders = this.#allProviders;
76
+ }
77
+
78
+ #filterProviders(query: string): void {
79
+ const normalizedQuery = query.trim().toLowerCase();
80
+ this.#filteredProviders = normalizedQuery
81
+ ? this.#allProviders.filter(provider =>
82
+ `${provider.name} ${provider.id}`.toLowerCase().includes(normalizedQuery),
83
+ )
84
+ : this.#allProviders;
85
+ this.#selectedIndex = 0;
86
+ this.#statusMessage = undefined;
87
+ this.#updateList();
67
88
  }
68
89
 
69
90
  #startValidation(): void {
@@ -143,8 +164,15 @@ export class OAuthSelectorComponent extends Container {
143
164
  }
144
165
  #updateList(): void {
145
166
  this.#listContainer.clear();
146
- for (let i = 0; i < this.#allProviders.length; i++) {
147
- const provider = this.#allProviders[i];
167
+ const maxVisible = OAuthSelectorComponent.MAX_VISIBLE_PROVIDERS;
168
+ const startIndex = Math.max(
169
+ 0,
170
+ Math.min(this.#selectedIndex - maxVisible + 1, this.#filteredProviders.length - maxVisible),
171
+ );
172
+ const endIndex = Math.min(startIndex + maxVisible, this.#filteredProviders.length);
173
+
174
+ for (let i = startIndex; i < endIndex; i++) {
175
+ const provider = this.#filteredProviders[i];
148
176
  if (!provider) continue;
149
177
  const isSelected = i === this.#selectedIndex;
150
178
  const isAvailable = provider.available;
@@ -167,32 +195,90 @@ export class OAuthSelectorComponent extends Container {
167
195
  const message =
168
196
  this.#mode === "login" ? "No OAuth providers available" : "No OAuth providers logged in. Use /login first.";
169
197
  this.#listContainer.addChild(new TruncatedText(theme.fg("muted", ` ${message}`), 0, 0));
198
+ } else if (this.#filteredProviders.length === 0) {
199
+ this.#listContainer.addChild(new TruncatedText(theme.fg("muted", " No matching providers"), 0, 0));
200
+ this.#listContainer.addChild(
201
+ new TruncatedText(theme.fg("muted", ` 0 matches (${this.#allProviders.length} total)`), 0, 0),
202
+ );
203
+ } else if (this.#searchInput.getValue()) {
204
+ const matchLabel = this.#filteredProviders.length === 1 ? "match" : "matches";
205
+ this.#listContainer.addChild(
206
+ new TruncatedText(
207
+ theme.fg(
208
+ "muted",
209
+ ` ${this.#filteredProviders.length} ${matchLabel} (${this.#allProviders.length} total)`,
210
+ ),
211
+ 0,
212
+ 0,
213
+ ),
214
+ );
215
+ } else {
216
+ this.#listContainer.addChild(
217
+ new TruncatedText(
218
+ theme.fg("muted", ` Showing ${startIndex + 1}-${endIndex} of ${this.#filteredProviders.length}`),
219
+ 0,
220
+ 0,
221
+ ),
222
+ );
170
223
  }
171
224
  if (this.#statusMessage) {
172
225
  this.#listContainer.addChild(new Spacer(1));
173
226
  this.#listContainer.addChild(new TruncatedText(theme.fg("warning", ` ${this.#statusMessage}`), 0, 0));
174
227
  }
228
+ this.#listContainer.addChild(new Spacer(1));
229
+ this.#listContainer.addChild(
230
+ new TruncatedText(theme.fg("muted", " Type to filter providers · Enter: select · Esc: clear/cancel"), 0, 0),
231
+ );
175
232
  }
176
233
  handleInput(keyData: string): void {
177
234
  // Up arrow
178
235
  if (matchesKey(keyData, "up")) {
179
- if (this.#allProviders.length > 0) {
180
- this.#selectedIndex = this.#selectedIndex === 0 ? this.#allProviders.length - 1 : this.#selectedIndex - 1;
236
+ if (this.#filteredProviders.length > 0) {
237
+ this.#selectedIndex =
238
+ this.#selectedIndex === 0 ? this.#filteredProviders.length - 1 : this.#selectedIndex - 1;
181
239
  }
182
240
  this.#statusMessage = undefined;
183
241
  this.#updateList();
184
242
  }
185
243
  // Down arrow
186
244
  else if (matchesKey(keyData, "down")) {
187
- if (this.#allProviders.length > 0) {
188
- this.#selectedIndex = this.#selectedIndex === this.#allProviders.length - 1 ? 0 : this.#selectedIndex + 1;
245
+ if (this.#filteredProviders.length > 0) {
246
+ this.#selectedIndex =
247
+ this.#selectedIndex === this.#filteredProviders.length - 1 ? 0 : this.#selectedIndex + 1;
189
248
  }
190
249
  this.#statusMessage = undefined;
191
250
  this.#updateList();
192
251
  }
252
+ // Page up
253
+ else if (matchesKey(keyData, "pageUp")) {
254
+ this.#selectedIndex = Math.max(0, this.#selectedIndex - OAuthSelectorComponent.MAX_VISIBLE_PROVIDERS);
255
+ this.#statusMessage = undefined;
256
+ this.#updateList();
257
+ }
258
+ // Page down
259
+ else if (matchesKey(keyData, "pageDown")) {
260
+ this.#selectedIndex = Math.min(
261
+ Math.max(0, this.#filteredProviders.length - 1),
262
+ this.#selectedIndex + OAuthSelectorComponent.MAX_VISIBLE_PROVIDERS,
263
+ );
264
+ this.#statusMessage = undefined;
265
+ this.#updateList();
266
+ }
267
+ // Home
268
+ else if (matchesKey(keyData, "home")) {
269
+ this.#selectedIndex = 0;
270
+ this.#statusMessage = undefined;
271
+ this.#updateList();
272
+ }
273
+ // End
274
+ else if (matchesKey(keyData, "end")) {
275
+ this.#selectedIndex = Math.max(0, this.#filteredProviders.length - 1);
276
+ this.#statusMessage = undefined;
277
+ this.#updateList();
278
+ }
193
279
  // Enter
194
280
  else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
195
- const selectedProvider = this.#allProviders[this.#selectedIndex];
281
+ const selectedProvider = this.#filteredProviders[this.#selectedIndex];
196
282
  if (selectedProvider?.available) {
197
283
  this.#statusMessage = undefined;
198
284
  this.stopValidation();
@@ -204,8 +290,20 @@ export class OAuthSelectorComponent extends Container {
204
290
  }
205
291
  // Escape or Ctrl+C
206
292
  else if (matchesSelectCancel(keyData)) {
293
+ if (this.#searchInput.getValue()) {
294
+ this.#searchInput.setValue("");
295
+ this.#filterProviders("");
296
+ return;
297
+ }
207
298
  this.stopValidation();
208
299
  this.#onCancelCallback();
209
300
  }
301
+ // Everything else edits the provider filter.
302
+ else {
303
+ const previousQuery = this.#searchInput.getValue();
304
+ this.#searchInput.handleInput(keyData);
305
+ const nextQuery = this.#searchInput.getValue();
306
+ if (nextQuery !== previousQuery) this.#filterProviders(nextQuery);
307
+ }
210
308
  }
211
309
  }
@@ -0,0 +1,94 @@
1
+ export type EnterpriseOAuthRecoveryAction = "retry" | "edit" | "cancel";
2
+ export type EnterpriseOAuthLoginAction = "initial" | EnterpriseOAuthRecoveryAction;
3
+ export type EnterpriseOAuthLoginStage = "authenticate" | "apply";
4
+
5
+ export interface EnterpriseOAuthRecoveryRequest {
6
+ stage: EnterpriseOAuthLoginStage;
7
+ error: string;
8
+ canEdit: true;
9
+ }
10
+
11
+ export type EnterpriseOAuthLoginResult = { status: "completed" } | { status: "cancelled" };
12
+
13
+ interface EnterpriseOAuthLoginFlowOptions<TSnapshot> {
14
+ capture(): Promise<TSnapshot> | TSnapshot;
15
+ authenticate(action: EnterpriseOAuthLoginAction): Promise<void>;
16
+ applyModel(): Promise<boolean>;
17
+ restore(snapshot: TSnapshot): Promise<void>;
18
+ recover(request: EnterpriseOAuthRecoveryRequest): Promise<EnterpriseOAuthRecoveryAction>;
19
+ sleep?(milliseconds: number): Promise<void>;
20
+ maxAutomaticRetries?: number;
21
+ }
22
+
23
+ function errorMessage(error: unknown): string {
24
+ return error instanceof Error ? error.message : String(error);
25
+ }
26
+
27
+ function isCancellation(error: unknown): boolean {
28
+ return (
29
+ (error instanceof Error && error.name === "AbortError") ||
30
+ /\b(login |authentication )?cancelled\b/i.test(errorMessage(error))
31
+ );
32
+ }
33
+
34
+ function isTransientAuthenticationError(error: unknown): boolean {
35
+ return /\b(fetch|network|timeout|timed out|ECONN(?:RESET|REFUSED)|EAI_AGAIN|429|5\d\d)\b/i.test(errorMessage(error));
36
+ }
37
+
38
+ async function restoreOrThrow<TSnapshot>(
39
+ options: EnterpriseOAuthLoginFlowOptions<TSnapshot>,
40
+ snapshot: TSnapshot,
41
+ originalError: unknown,
42
+ ): Promise<void> {
43
+ try {
44
+ await options.restore(snapshot);
45
+ } catch (rollbackError) {
46
+ throw new AggregateError([originalError, rollbackError], "Enterprise login failed and rollback was incomplete");
47
+ }
48
+ }
49
+
50
+ /** Coordinate enterprise OAuth, model application, bounded retries, and rollback. */
51
+ export async function runEnterpriseOAuthLoginFlow<TSnapshot>(
52
+ options: EnterpriseOAuthLoginFlowOptions<TSnapshot>,
53
+ ): Promise<EnterpriseOAuthLoginResult> {
54
+ const snapshot = await options.capture();
55
+ const sleep = options.sleep ?? Bun.sleep;
56
+ const maxAutomaticRetries = options.maxAutomaticRetries ?? 2;
57
+ let action: EnterpriseOAuthLoginAction = "initial";
58
+ let automaticRetries = 0;
59
+
60
+ while (true) {
61
+ let stage: EnterpriseOAuthLoginStage = "authenticate";
62
+ try {
63
+ await options.authenticate(action);
64
+ stage = "apply";
65
+ if (!(await options.applyModel())) {
66
+ throw new Error("Gemini 3.6 Flash High is unavailable after authentication");
67
+ }
68
+ return { status: "completed" };
69
+ } catch (error) {
70
+ await restoreOrThrow(options, snapshot, error);
71
+ if (isCancellation(error)) return { status: "cancelled" };
72
+
73
+ if (
74
+ stage === "authenticate" &&
75
+ isTransientAuthenticationError(error) &&
76
+ automaticRetries < maxAutomaticRetries
77
+ ) {
78
+ await sleep(250 * 2 ** automaticRetries);
79
+ automaticRetries += 1;
80
+ action = "retry";
81
+ continue;
82
+ }
83
+
84
+ const recoveryAction = await options.recover({
85
+ stage,
86
+ error: errorMessage(error),
87
+ canEdit: true,
88
+ });
89
+ if (recoveryAction === "cancel") return { status: "cancelled" };
90
+ action = recoveryAction;
91
+ automaticRetries = 0;
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,103 @@
1
+ import type { ThinkingLevel } from "@f5-sales-demo/pi-agent-core";
2
+ import type { Model } from "@f5-sales-demo/pi-ai";
3
+
4
+ type StoredCredential =
5
+ | { type: "api_key"; key: string }
6
+ | ({ type: "oauth" } & {
7
+ refresh: string;
8
+ access: string;
9
+ expires: number;
10
+ enterpriseUrl?: string;
11
+ projectId?: string;
12
+ tierId?: string;
13
+ email?: string;
14
+ accountId?: string;
15
+ });
16
+
17
+ interface EnterpriseOAuthTransactionSession {
18
+ model?: Model;
19
+ thinkingLevel?: ThinkingLevel;
20
+ modelRegistry: {
21
+ authStorage: {
22
+ get(provider: string): StoredCredential | undefined;
23
+ set(provider: string, credential: StoredCredential): Promise<void>;
24
+ remove(provider: string): Promise<void>;
25
+ };
26
+ refresh(mode: "online"): Promise<void>;
27
+ };
28
+ settings: {
29
+ getModelRoles(): Readonly<Record<string, string | undefined>>;
30
+ set(key: "modelRoles", value: Record<string, string>): void;
31
+ };
32
+ setModelTemporary(model: Model, thinkingLevel?: ThinkingLevel): Promise<void>;
33
+ setThinkingLevel(level: ThinkingLevel): void;
34
+ }
35
+
36
+ export interface EnterpriseOAuthLoginSnapshot {
37
+ credential?: StoredCredential;
38
+ model?: Model;
39
+ thinkingLevel?: ThinkingLevel;
40
+ modelRoles: Record<string, string>;
41
+ }
42
+
43
+ const CANONICAL_PROVIDER = "google-antigravity";
44
+
45
+ export function captureEnterpriseOAuthLoginState(
46
+ session: EnterpriseOAuthTransactionSession,
47
+ ): EnterpriseOAuthLoginSnapshot {
48
+ const credential = session.modelRegistry.authStorage.get(CANONICAL_PROVIDER);
49
+ const modelRoles = Object.fromEntries(
50
+ Object.entries(session.settings.getModelRoles()).filter(
51
+ (entry): entry is [string, string] => entry[1] !== undefined,
52
+ ),
53
+ );
54
+ return {
55
+ credential: credential ? { ...credential } : undefined,
56
+ model: session.model,
57
+ thinkingLevel: session.thinkingLevel,
58
+ modelRoles,
59
+ };
60
+ }
61
+
62
+ /** Restore all durable and active state that enterprise login can replace. */
63
+ export async function restoreEnterpriseOAuthLoginState(
64
+ session: EnterpriseOAuthTransactionSession,
65
+ snapshot: EnterpriseOAuthLoginSnapshot,
66
+ ): Promise<void> {
67
+ const rollbackErrors: unknown[] = [];
68
+ try {
69
+ if (snapshot.credential) {
70
+ await session.modelRegistry.authStorage.set(CANONICAL_PROVIDER, snapshot.credential);
71
+ } else {
72
+ await session.modelRegistry.authStorage.remove(CANONICAL_PROVIDER);
73
+ }
74
+ } catch (error) {
75
+ rollbackErrors.push(error);
76
+ }
77
+
78
+ try {
79
+ session.settings.set("modelRoles", { ...snapshot.modelRoles });
80
+ } catch (error) {
81
+ rollbackErrors.push(error);
82
+ }
83
+
84
+ try {
85
+ await session.modelRegistry.refresh("online");
86
+ } catch (error) {
87
+ rollbackErrors.push(error);
88
+ }
89
+
90
+ try {
91
+ if (snapshot.model) {
92
+ await session.setModelTemporary(snapshot.model, snapshot.thinkingLevel);
93
+ } else if (snapshot.thinkingLevel !== undefined) {
94
+ session.setThinkingLevel(snapshot.thinkingLevel);
95
+ }
96
+ } catch (error) {
97
+ rollbackErrors.push(error);
98
+ }
99
+
100
+ if (rollbackErrors.length > 0) {
101
+ throw new AggregateError(rollbackErrors, "Enterprise OAuth rollback was incomplete");
102
+ }
103
+ }
@@ -0,0 +1,109 @@
1
+ import type { ProbeResult } from "../../config/auto-config";
2
+ import { getAvailableLiteLLMLoginModelChoices, type LiteLLMLoginModelChoice } from "./login-model";
3
+
4
+ export interface LiteLLMLoginCredentials {
5
+ baseUrl: string;
6
+ apiKey: string;
7
+ }
8
+
9
+ export type LoginRecoveryAction = "retry" | "edit" | "cancel";
10
+ export type LiteLLMLoginStage = "probe" | "models" | "commit";
11
+
12
+ export interface LoginRecoveryRequest {
13
+ stage: LiteLLMLoginStage;
14
+ error: string;
15
+ canEdit: boolean;
16
+ }
17
+
18
+ export interface LiteLLMLoginCommit {
19
+ credentials: LiteLLMLoginCredentials;
20
+ probe: ProbeResult;
21
+ choice: LiteLLMLoginModelChoice;
22
+ }
23
+
24
+ export type LiteLLMLoginFlowResult = { status: "completed"; choice: LiteLLMLoginModelChoice } | { status: "cancelled" };
25
+
26
+ interface LiteLLMLoginFlowOptions {
27
+ collectCredentials(): Promise<LiteLLMLoginCredentials | null>;
28
+ probe(credentials: LiteLLMLoginCredentials): Promise<ProbeResult>;
29
+ selectModel(choices: readonly LiteLLMLoginModelChoice[]): Promise<LiteLLMLoginModelChoice | null>;
30
+ commit(input: LiteLLMLoginCommit): Promise<void>;
31
+ recover(request: LoginRecoveryRequest): Promise<LoginRecoveryAction>;
32
+ sleep?(milliseconds: number): Promise<void>;
33
+ maxAutomaticRetries?: number;
34
+ }
35
+
36
+ function errorMessage(error: unknown): string {
37
+ return error instanceof Error ? error.message : String(error);
38
+ }
39
+
40
+ function isAuthenticationError(message: string): boolean {
41
+ return /\b(401|403|Unauthorized|Forbidden)\b/i.test(message);
42
+ }
43
+
44
+ /**
45
+ * Coordinate LiteLLM discovery and selection without persisting anything until
46
+ * the user has selected one of the certified models.
47
+ */
48
+ export async function runLiteLLMLoginFlow(options: LiteLLMLoginFlowOptions): Promise<LiteLLMLoginFlowResult> {
49
+ const sleep = options.sleep ?? Bun.sleep;
50
+ const maxAutomaticRetries = options.maxAutomaticRetries ?? 2;
51
+
52
+ credentialsLoop: while (true) {
53
+ const credentials = await options.collectCredentials();
54
+ if (!credentials) return { status: "cancelled" };
55
+
56
+ let probe: ProbeResult;
57
+ let automaticRetries = 0;
58
+ while (true) {
59
+ try {
60
+ probe = await options.probe(credentials);
61
+ } catch (error) {
62
+ probe = { reachable: false, models: [], error: errorMessage(error) };
63
+ }
64
+
65
+ if (probe.reachable) break;
66
+ const error = probe.error ?? "connection failed";
67
+ if (!isAuthenticationError(error) && automaticRetries < maxAutomaticRetries) {
68
+ await sleep(250 * 2 ** automaticRetries);
69
+ automaticRetries += 1;
70
+ continue;
71
+ }
72
+
73
+ const action = await options.recover({ stage: "probe", error, canEdit: true });
74
+ if (action === "cancel") return { status: "cancelled" };
75
+ if (action === "edit") continue credentialsLoop;
76
+ automaticRetries = 0;
77
+ }
78
+
79
+ const choices = getAvailableLiteLLMLoginModelChoices(probe.models);
80
+ if (choices.length === 0) {
81
+ const action = await options.recover({
82
+ stage: "models",
83
+ error: "Neither GPT-5.6 Sol nor Claude Opus 5 is available from this proxy.",
84
+ canEdit: true,
85
+ });
86
+ if (action === "cancel") return { status: "cancelled" };
87
+ if (action === "edit") continue;
88
+ continue;
89
+ }
90
+
91
+ const choice = await options.selectModel(choices);
92
+ if (!choice) return { status: "cancelled" };
93
+
94
+ while (true) {
95
+ try {
96
+ await options.commit({ credentials, probe, choice });
97
+ return { status: "completed", choice };
98
+ } catch (error) {
99
+ const action = await options.recover({
100
+ stage: "commit",
101
+ error: errorMessage(error),
102
+ canEdit: true,
103
+ });
104
+ if (action === "cancel") return { status: "cancelled" };
105
+ if (action === "edit") continue credentialsLoop;
106
+ }
107
+ }
108
+ }
109
+ }