@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 +9 -8
- package/src/browser/chat-handler.ts +4 -4
- package/src/browser/headless-bridge.ts +11 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/modes/components/login-prompt-input.ts +9 -0
- package/src/modes/components/oauth-selector.ts +107 -9
- package/src/modes/controllers/enterprise-oauth-login-flow.ts +94 -0
- package/src/modes/controllers/enterprise-oauth-login-transaction.ts +103 -0
- package/src/modes/controllers/litellm-login-flow.ts +109 -0
- package/src/modes/controllers/litellm-login-transaction.ts +132 -0
- package/src/modes/controllers/login-model.ts +9 -9
- package/src/modes/controllers/selector-controller.ts +235 -184
- package/src/modes/rpc/rpc-client.ts +5 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "20.
|
|
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.
|
|
64
|
-
"@f5-sales-demo/pi-agent-core": "20.
|
|
65
|
-
"@f5-sales-demo/pi-ai": "20.
|
|
66
|
-
"@f5-sales-demo/pi-natives": "20.
|
|
67
|
-
"@f5-sales-demo/pi-resource-management": "20.
|
|
68
|
-
"@f5-sales-demo/pi-tui": "20.
|
|
69
|
-
"@f5-sales-demo/pi-utils": "20.
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
const modelId = msg.model ??
|
|
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.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "20.6.0",
|
|
21
|
+
"commit": "fda51f1e37f9202c495558b6e33c0067e822967e",
|
|
22
|
+
"shortCommit": "fda51f1",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v20.
|
|
25
|
-
"commitDate": "2026-08-
|
|
26
|
-
"buildDate": "2026-08-
|
|
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/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.
|
|
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
|
-
|
|
147
|
-
|
|
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.#
|
|
180
|
-
this.#selectedIndex =
|
|
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.#
|
|
188
|
-
this.#selectedIndex =
|
|
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.#
|
|
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
|
+
}
|