@f5-sales-demo/xcsh 20.20.6 → 20.22.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/CHANGELOG.md CHANGED
@@ -6,6 +6,16 @@
6
6
 
7
7
  ### Added
8
8
 
9
+ - Added strict `xcsh://registry/provider/<namespace>/<type>` and
10
+ `xcsh://registry/module/<namespace>/<name>/<provider>` Terraform Registry lookups with bounded,
11
+ protocol-native discovery and actionable failures
12
+ ([#3019](https://github.com/f5-sales-demo/xcsh/issues/3019),
13
+ [#3361](https://github.com/f5-sales-demo/xcsh/issues/3361)).
14
+ - Added a transactional vLLM `/login` flow with validated HTTP(S) endpoints, masked optional keys,
15
+ pre-commit model probing, vLLM-only model selection, provider-only refresh, secure atomic
16
+ persistence, and rollback
17
+ ([#2947](https://github.com/f5-sales-demo/xcsh/issues/2947),
18
+ [#3361](https://github.com/f5-sales-demo/xcsh/issues/3361)).
9
19
  - Added provenance-validated, deterministic `render_map` PNG generation with OpenStreetMap tile
10
20
  policy compliance, schematic and text fallbacks, and optional atomic PNG/GeoJSON export
11
21
  ([#3202](https://github.com/f5-sales-demo/xcsh/issues/3202)).
@@ -26,6 +36,13 @@
26
36
 
27
37
  ### Fixed
28
38
 
39
+ - Isolated post-publication npm verification in a job-local writable prefix and failed immediately
40
+ for non-registry installation errors
41
+ ([#3375](https://github.com/f5-sales-demo/xcsh/issues/3375)).
42
+ - Consumed vLLM-advertised context limits with bounded output budgets while preserving compatibility
43
+ fallbacks when metadata is absent
44
+ ([#2947](https://github.com/f5-sales-demo/xcsh/issues/2947),
45
+ [#3361](https://github.com/f5-sales-demo/xcsh/issues/3361)).
29
46
  - Restored bare `xcsh update` as a backward-compatible executable updater while preserving
30
47
  manifest-based resource updates and the explicit `xcsh self-update` command
31
48
  ([#3249](https://github.com/f5-sales-demo/xcsh/issues/3249)).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "20.20.6",
4
+ "version": "20.22.2",
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",
@@ -61,13 +61,13 @@
61
61
  },
62
62
  "dependencies": {
63
63
  "@agentclientprotocol/sdk": "1.3.0",
64
- "@f5-sales-demo/pi-agent-core": "20.20.6",
65
- "@f5-sales-demo/pi-ai": "20.20.6",
66
- "@f5-sales-demo/pi-natives": "20.20.6",
67
- "@f5-sales-demo/pi-resource-management": "20.20.6",
68
- "@f5-sales-demo/pi-tui": "20.20.6",
69
- "@f5-sales-demo/pi-utils": "20.20.6",
70
- "@f5-sales-demo/xcsh-stats": "20.20.6",
64
+ "@f5-sales-demo/pi-agent-core": "20.22.2",
65
+ "@f5-sales-demo/pi-ai": "20.22.2",
66
+ "@f5-sales-demo/pi-natives": "20.22.2",
67
+ "@f5-sales-demo/pi-resource-management": "20.22.2",
68
+ "@f5-sales-demo/pi-tui": "20.22.2",
69
+ "@f5-sales-demo/pi-utils": "20.22.2",
70
+ "@f5-sales-demo/xcsh-stats": "20.22.2",
71
71
  "@mozilla/readability": "^0.6",
72
72
  "@sinclair/typebox": "^0.34",
73
73
  "@xterm/headless": "^6.0",
package/src/cli.ts CHANGED
@@ -118,6 +118,7 @@ function requestsHelp(args: readonly string[]): boolean {
118
118
  export function runCli(argv: string[]): Promise<void> {
119
119
  // --help and --version are handled by run() directly, don't rewrite those.
120
120
  // Everything else that isn't a known subcommand routes to "launch".
121
+ // Keeping this routing boundary explicit makes the CLI fallback easy to audit.
121
122
  const first = argv[0];
122
123
  if (!isSubcommand(first)) {
123
124
  try {
@@ -14,6 +14,7 @@ import {
14
14
  type Model,
15
15
  type ModelManagerOptions,
16
16
  type ModelRefreshStrategy,
17
+ NO_AUTH_API_KEY,
17
18
  type OAuthCredentials,
18
19
  type OAuthLoginCallbacks,
19
20
  openaiCodexModelManagerOptions,
@@ -31,6 +32,7 @@ import { type Static, Type } from "@sinclair/typebox";
31
32
  import { type ConfigError, ConfigFile } from "../config";
32
33
  import { hasLiteLLMEnv, probeAndUpgradeLiteLLMConfig, startupHealthCheck } from "../config/auto-config";
33
34
  import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
35
+ import { parseVllmModelsPayload } from "../config/vllm-config";
34
36
  import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
35
37
  import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
36
38
  import {
@@ -45,7 +47,7 @@ import { type Settings, settings } from "./settings";
45
47
 
46
48
  export type { CanonicalModelIndex, CanonicalModelRecord, CanonicalModelVariant, ModelEquivalenceConfig };
47
49
 
48
- export const kNoAuth = "N/A";
50
+ export const kNoAuth = NO_AUTH_API_KEY;
49
51
 
50
52
  export function isAuthenticated(apiKey: string | undefined | null): apiKey is string {
51
53
  return Boolean(apiKey) && apiKey !== kNoAuth;
@@ -1737,8 +1739,11 @@ export class ModelRegistry {
1737
1739
  if (!response.ok) {
1738
1740
  throw new Error(`HTTP ${response.status} from ${modelsUrl}`);
1739
1741
  }
1740
- const payload = (await response.json()) as { data?: Array<{ id: string }> };
1741
- const items = payload.data ?? [];
1742
+ const payload = await response.json();
1743
+ const items: Array<{ id: string; contextWindow?: number }> =
1744
+ providerConfig.provider === "vllm"
1745
+ ? parseVllmModelsPayload(payload)
1746
+ : ((payload as { data?: Array<{ id: string }> }).data ?? []);
1742
1747
  const discovered: Model<Api>[] = [];
1743
1748
  for (const item of items) {
1744
1749
  const id = item.id;
@@ -1753,8 +1758,11 @@ export class ModelRegistry {
1753
1758
  reasoning: false,
1754
1759
  input: ["text"],
1755
1760
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1756
- contextWindow: 128000,
1757
- maxTokens: 8192,
1761
+ contextWindow: item.contextWindow ?? 128000,
1762
+ maxTokens:
1763
+ item.contextWindow === undefined
1764
+ ? 8192
1765
+ : Math.max(1, Math.min(8192, Math.floor(item.contextWindow / 4))),
1758
1766
  headers,
1759
1767
  }),
1760
1768
  );
@@ -0,0 +1,212 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { Document, isMap, parseDocument } from "yaml";
5
+
6
+ const CONFIG_DIR_MODE = 0o700;
7
+ const MODELS_FILE_MODE = 0o600;
8
+ const DEFAULT_PROBE_TIMEOUT_MS = 5_000;
9
+ const CONTEXT_FIELDS = [
10
+ "max_model_len",
11
+ "max_model_length",
12
+ "max_context_length",
13
+ "context_length",
14
+ "context_window",
15
+ "max_sequence_length",
16
+ ] as const;
17
+
18
+ export const DEFAULT_VLLM_BASE_URL = "http://127.0.0.1:8000/v1";
19
+
20
+ export interface VllmConfig {
21
+ baseUrl: string;
22
+ }
23
+
24
+ export interface VllmDiscoveredModel {
25
+ id: string;
26
+ contextWindow?: number;
27
+ }
28
+
29
+ export interface VllmProbeResult {
30
+ models: VllmDiscoveredModel[];
31
+ }
32
+
33
+ export interface VllmProbeOptions {
34
+ fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
35
+ timeoutMs?: number;
36
+ signal?: AbortSignal;
37
+ }
38
+
39
+ export interface WriteVllmModelsConfigOptions {
40
+ authenticated: boolean;
41
+ }
42
+
43
+ export function normalizeVllmBaseUrl(value: string): string {
44
+ const trimmed = value.trim();
45
+ let parsed: URL;
46
+ try {
47
+ parsed = new URL(trimmed);
48
+ } catch {
49
+ throw new Error("vLLM Base URL must be a valid HTTP or HTTPS URL");
50
+ }
51
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
52
+ throw new Error("vLLM Base URL must use HTTP or HTTPS");
53
+ }
54
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) {
55
+ throw new Error("vLLM Base URL must not contain credentials, query parameters, or a fragment");
56
+ }
57
+ return parsed.toString().replace(/\/+$/, "");
58
+ }
59
+
60
+ function parseModelsDocument(content: string, filePath: string): ReturnType<typeof parseDocument> {
61
+ const document = parseDocument(content, { prettyErrors: false });
62
+ if (document.errors.length > 0) {
63
+ throw new Error(`Cannot update ${filePath}: invalid YAML (${document.errors[0]?.message ?? "parse error"})`);
64
+ }
65
+ if (document.contents !== null && !isMap(document.contents)) {
66
+ throw new Error(`Cannot update ${filePath}: the YAML root must be a map`);
67
+ }
68
+ const providers = document.get("providers", true);
69
+ if (providers !== undefined && providers !== null && !isMap(providers)) {
70
+ throw new Error(`Cannot update ${filePath}: providers must be a map`);
71
+ }
72
+ const vllm = document.getIn(["providers", "vllm"], true);
73
+ if (vllm !== undefined && vllm !== null && !isMap(vllm)) {
74
+ throw new Error(`Cannot update ${filePath}: providers.vllm must be a map`);
75
+ }
76
+ return document;
77
+ }
78
+
79
+ export function readVllmConfig(modelsPath: string): VllmConfig | undefined {
80
+ let content: string;
81
+ try {
82
+ content = fs.readFileSync(modelsPath, "utf8");
83
+ } catch (error) {
84
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
85
+ throw error;
86
+ }
87
+ const document = parseModelsDocument(content, modelsPath);
88
+ const baseUrl = document.getIn(["providers", "vllm", "baseUrl"]);
89
+ if (typeof baseUrl !== "string" || !baseUrl.trim()) return undefined;
90
+ return { baseUrl: normalizeVllmBaseUrl(baseUrl) };
91
+ }
92
+
93
+ function positiveInteger(value: unknown): number | undefined {
94
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return Math.floor(value);
95
+ if (typeof value === "string" && value.trim()) {
96
+ const parsed = Number(value);
97
+ if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed);
98
+ }
99
+ return undefined;
100
+ }
101
+
102
+ function discoveredContextWindow(entry: Record<string, unknown>): number | undefined {
103
+ for (const field of CONTEXT_FIELDS) {
104
+ const value = positiveInteger(entry[field]);
105
+ if (value !== undefined) return value;
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ export function parseVllmModelsPayload(payload: unknown): VllmDiscoveredModel[] {
111
+ if (typeof payload !== "object" || payload === null || !Array.isArray((payload as { data?: unknown }).data)) {
112
+ throw new Error("vLLM returned a malformed model catalog");
113
+ }
114
+
115
+ const models: VllmDiscoveredModel[] = [];
116
+ const seen = new Set<string>();
117
+ for (const rawEntry of (payload as { data: unknown[] }).data) {
118
+ if (typeof rawEntry !== "object" || rawEntry === null || typeof (rawEntry as { id?: unknown }).id !== "string") {
119
+ throw new Error("vLLM returned a malformed model catalog");
120
+ }
121
+ const entry = rawEntry as Record<string, unknown> & { id: string };
122
+ const id = entry.id.trim();
123
+ if (!id || seen.has(id)) continue;
124
+ seen.add(id);
125
+ const contextWindow = discoveredContextWindow(entry);
126
+ models.push(contextWindow === undefined ? { id } : { id, contextWindow });
127
+ }
128
+ if (models.length === 0) throw new Error("vLLM returned no models");
129
+ return models;
130
+ }
131
+
132
+ export async function probeVllmConnection(
133
+ baseUrl: string,
134
+ apiKey: string,
135
+ options: VllmProbeOptions = {},
136
+ ): Promise<VllmProbeResult> {
137
+ const normalizedBaseUrl = normalizeVllmBaseUrl(baseUrl);
138
+ const modelsUrl = `${normalizedBaseUrl}/models`;
139
+ const headers = new Headers({ Accept: "application/json" });
140
+ const trimmedApiKey = apiKey.trim();
141
+ if (trimmedApiKey) headers.set("Authorization", `Bearer ${trimmedApiKey}`);
142
+
143
+ const signal = options.signal ?? AbortSignal.timeout(options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS);
144
+ let response: Response;
145
+ try {
146
+ response = await (options.fetch ?? globalThis.fetch)(modelsUrl, { headers, signal });
147
+ } catch (error) {
148
+ const name = error instanceof Error ? error.name : "";
149
+ if (name === "TimeoutError" || name === "AbortError") {
150
+ throw new Error(`vLLM connection timed out at ${modelsUrl}`, { cause: error });
151
+ }
152
+ throw new Error(`Could not connect to vLLM at ${modelsUrl}`, { cause: error });
153
+ }
154
+
155
+ if (response.status === 401 || response.status === 403) {
156
+ throw new Error(`vLLM rejected the API key (HTTP ${response.status})`);
157
+ }
158
+ if (!response.ok) throw new Error(`vLLM model discovery failed with HTTP ${response.status}`);
159
+
160
+ let payload: unknown;
161
+ try {
162
+ payload = await response.json();
163
+ } catch (error) {
164
+ throw new Error("vLLM /models did not return valid JSON", { cause: error });
165
+ }
166
+ return { models: parseVllmModelsPayload(payload) };
167
+ }
168
+
169
+ async function atomicWrite(filePath: string, content: string): Promise<void> {
170
+ const directory = path.dirname(filePath);
171
+ await fs.promises.mkdir(directory, { recursive: true, mode: CONFIG_DIR_MODE });
172
+ await fs.promises.chmod(directory, CONFIG_DIR_MODE);
173
+
174
+ const tempPath = path.join(directory, `.${path.basename(filePath)}.${randomUUID()}.tmp`);
175
+ let handle: fs.promises.FileHandle | undefined;
176
+ try {
177
+ handle = await fs.promises.open(tempPath, "wx", MODELS_FILE_MODE);
178
+ await handle.writeFile(content, "utf8");
179
+ await handle.sync();
180
+ await handle.close();
181
+ handle = undefined;
182
+ await fs.promises.rename(tempPath, filePath);
183
+ await fs.promises.chmod(filePath, MODELS_FILE_MODE);
184
+ } catch (error) {
185
+ await handle?.close().catch(() => undefined);
186
+ await fs.promises.unlink(tempPath).catch(() => undefined);
187
+ throw error;
188
+ }
189
+ }
190
+
191
+ export async function writeVllmModelsConfig(
192
+ modelsPath: string,
193
+ baseUrl: string,
194
+ options: WriteVllmModelsConfigOptions,
195
+ ): Promise<void> {
196
+ let document: ReturnType<typeof parseDocument> | Document;
197
+ try {
198
+ document = parseModelsDocument(await fs.promises.readFile(modelsPath, "utf8"), modelsPath);
199
+ } catch (error) {
200
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
201
+ document = new Document({});
202
+ }
203
+
204
+ document.setIn(["providers", "vllm", "baseUrl"], normalizeVllmBaseUrl(baseUrl));
205
+ document.setIn(["providers", "vllm", "api"], "openai-completions");
206
+ document.setIn(["providers", "vllm", "discovery", "type"], "openai-compat");
207
+ if (options.authenticated) document.deleteIn(["providers", "vllm", "auth"]);
208
+ else document.setIn(["providers", "vllm", "auth"], "none");
209
+ document.deleteIn(["providers", "vllm", "apiKey"]);
210
+
211
+ await atomicWrite(modelsPath, document.toString({ lineWidth: 0 }));
212
+ }
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "20.20.6",
21
- "commit": "9e6b086eef492b654008d78ffd4f096385019004",
22
- "shortCommit": "9e6b086",
20
+ "version": "20.22.2",
21
+ "commit": "ed93a01366838afc5ccd503a0bc678af72c92fe4",
22
+ "shortCommit": "ed93a01",
23
23
  "branch": "main",
24
- "tag": "v20.20.6",
25
- "commitDate": "2026-08-21T13:13:58Z",
26
- "buildDate": "2026-08-21T13:37:09.862Z",
24
+ "tag": "v20.22.2",
25
+ "commitDate": "2026-08-25T17:12:23+00:00",
26
+ "buildDate": "2026-08-26T01:20:47.235Z",
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/9e6b086eef492b654008d78ffd4f096385019004",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.20.6"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/ed93a01366838afc5ccd503a0bc678af72c92fe4",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.22.2"
33
33
  };
@@ -335,8 +335,12 @@ function authorityGuidance(authority: string): string[] {
335
335
  return [
336
336
  "**Authority: author.** Create, update and delete content here directly — documentation,",
337
337
  "Terraform plans, howtos, diagrams, demo and traffic-generation scripts. You do not need to",
338
- "ask permission to author; you do need to follow the governed path:",
339
- "linked issue branch pull request CI auto-merge. Never commit to `main`.",
338
+ "ask permission to author; you do need to follow the governed path (Git SOPs):",
339
+ "1. **Comprehensive Issue First**: Always create a detailed GitHub issue before developing content.",
340
+ "2. **Feature Branch / Worktree**: Work in a dedicated `feature/`, `fix/`, `docs/`, or `chore/` branch or worktree. Never commit directly to `main`.",
341
+ "3. **PR & Linking**: Open a PR with explicit `Closes #N` issue linking.",
342
+ "4. **CI & Merge**: Poll CI until green, then squash merge.",
343
+ "5. **Post-Merge Hygiene**: Clean up local and remote feature branches, remove merged worktrees, and run `git fetch --prune`.",
340
344
  ];
341
345
  case AUTHORITY_DELEGATE:
342
346
  return [
@@ -35,6 +35,7 @@ export * from "./mcp-protocol";
35
35
  export * from "./memory-protocol";
36
36
  export * from "./parse";
37
37
  export * from "./plugin-resolve";
38
+ export * from "./registry-resolve";
38
39
  export * from "./router";
39
40
  export * from "./rule-protocol";
40
41
  export * from "./skill-protocol";
@@ -0,0 +1,257 @@
1
+ import type { InternalResource, InternalUrl } from "./types";
2
+
3
+ const DEFAULT_PROVIDER_BASE_URL = "https://registry.terraform.io/v1/providers";
4
+ const DEFAULT_MODULE_BASE_URL = "https://registry.terraform.io/v1/modules";
5
+ const DEFAULT_TIMEOUT_MS = 10_000;
6
+ const SOURCE_NAME = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
7
+
8
+ export type RegistryFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
9
+
10
+ export interface RegistryResolverDeps {
11
+ readonly fetch: RegistryFetch;
12
+ readonly providerBaseUrl: string;
13
+ readonly moduleBaseUrl: string;
14
+ readonly timeoutMs: number;
15
+ }
16
+
17
+ interface ProviderVersion {
18
+ readonly version: string;
19
+ readonly protocols?: readonly string[];
20
+ readonly platforms?: readonly { readonly os: string; readonly arch: string }[];
21
+ }
22
+
23
+ interface ModuleMetadata {
24
+ readonly id?: string;
25
+ readonly namespace: string;
26
+ readonly name: string;
27
+ readonly provider: string;
28
+ readonly version: string;
29
+ readonly description?: string;
30
+ readonly source?: string;
31
+ readonly verified?: boolean;
32
+ }
33
+
34
+ type RegistryRoute =
35
+ | { readonly kind: "provider"; readonly namespace: string; readonly type: string }
36
+ | { readonly kind: "module"; readonly namespace: string; readonly name: string; readonly provider: string };
37
+
38
+ function routeHelp(): string {
39
+ return "Expected xcsh://registry/provider/<namespace>/<type> or xcsh://registry/module/<namespace>/<name>/<provider>";
40
+ }
41
+
42
+ function parseRoute(url: InternalUrl): RegistryRoute {
43
+ const rawPath = url.rawPathname ?? url.pathname;
44
+ const segments = rawPath.startsWith("/") ? rawPath.slice(1).split("/") : rawPath.split("/");
45
+ if (segments[0] === "provider" && segments.length === 3) {
46
+ const [, namespace, type] = segments;
47
+ if (namespace && type) return validateRoute({ kind: "provider", namespace, type });
48
+ }
49
+ if (segments[0] === "module" && segments.length === 4) {
50
+ const [, namespace, name, provider] = segments;
51
+ if (namespace && name && provider) return validateRoute({ kind: "module", namespace, name, provider });
52
+ }
53
+ throw new Error(routeHelp());
54
+ }
55
+
56
+ function validateRoute(route: RegistryRoute): RegistryRoute {
57
+ const names =
58
+ route.kind === "provider" ? [route.namespace, route.type] : [route.namespace, route.name, route.provider];
59
+ for (const name of names) {
60
+ if (!SOURCE_NAME.test(name)) {
61
+ throw new Error(
62
+ `Invalid Terraform Registry source name "${name}": use lowercase ASCII letters, digits, and hyphens`,
63
+ );
64
+ }
65
+ }
66
+ return route;
67
+ }
68
+
69
+ function markdownResource(url: InternalUrl, content: string): InternalResource {
70
+ return {
71
+ url: url.href,
72
+ content,
73
+ contentType: "text/markdown",
74
+ size: Buffer.byteLength(content, "utf-8"),
75
+ sourcePath: url.href,
76
+ };
77
+ }
78
+
79
+ function providerVersions(value: unknown): readonly ProviderVersion[] | null {
80
+ if (typeof value !== "object" || value === null) return null;
81
+ const versions = (value as { versions?: unknown }).versions;
82
+ if (!Array.isArray(versions)) return null;
83
+ for (const entry of versions) {
84
+ if (typeof entry !== "object" || entry === null || typeof (entry as { version?: unknown }).version !== "string") {
85
+ return null;
86
+ }
87
+ }
88
+ return versions as ProviderVersion[];
89
+ }
90
+
91
+ function moduleMetadata(value: unknown): ModuleMetadata | null {
92
+ if (typeof value !== "object" || value === null) return null;
93
+ const item = value as Record<string, unknown>;
94
+ for (const key of ["namespace", "name", "provider", "version"] as const) {
95
+ if (typeof item[key] !== "string" || item[key].length === 0) return null;
96
+ }
97
+ return item as unknown as ModuleMetadata;
98
+ }
99
+
100
+ function renderProvider(namespace: string, type: string, versions: readonly ProviderVersion[]): string {
101
+ const lines = [
102
+ `# Terraform provider: ${namespace}/${type}`,
103
+ "",
104
+ `Source: \`${namespace}/${type}\``,
105
+ `Available versions: ${versions.length}`,
106
+ "",
107
+ "| Version | Protocols | Platforms |",
108
+ "|---------|-----------|-----------|",
109
+ ];
110
+ for (const entry of versions) {
111
+ const protocols = Array.isArray(entry.protocols) ? entry.protocols.join(", ") : "not advertised";
112
+ const platforms = Array.isArray(entry.platforms)
113
+ ? entry.platforms
114
+ .filter(platform => typeof platform?.os === "string" && typeof platform?.arch === "string")
115
+ .map(platform => `${platform.os}/${platform.arch}`)
116
+ .join(", ")
117
+ : "not advertised";
118
+ lines.push(`| ${entry.version} | ${protocols || "not advertised"} | ${platforms || "not advertised"} |`);
119
+ }
120
+ return lines.join("\n");
121
+ }
122
+
123
+ function renderModule(metadata: ModuleMetadata): string {
124
+ const address = `${metadata.namespace}/${metadata.name}/${metadata.provider}`;
125
+ const lines = [
126
+ `# Terraform module: ${address}`,
127
+ "",
128
+ `Source: \`${address}\``,
129
+ `Latest version: ${metadata.version}`,
130
+ `Verified: ${metadata.verified === true ? "yes" : "no"}`,
131
+ ];
132
+ if (metadata.description) lines.push(`Description: ${metadata.description}`);
133
+ if (metadata.source) lines.push(`Repository: ${metadata.source}`);
134
+ return lines.join("\n");
135
+ }
136
+
137
+ function failure(route: RegistryRoute, heading: string, detail: string): string {
138
+ const address =
139
+ route.kind === "provider"
140
+ ? `${route.namespace}/${route.type}`
141
+ : `${route.namespace}/${route.name}/${route.provider}`;
142
+ return `# ${heading}\n\nRegistry address: \`${address}\`\n\n${detail}`;
143
+ }
144
+
145
+ function errorMessage(error: unknown): string {
146
+ return error instanceof Error ? error.message : String(error);
147
+ }
148
+
149
+ export class RegistryResolver {
150
+ readonly #deps: RegistryResolverDeps;
151
+
152
+ constructor(deps: RegistryResolverDeps) {
153
+ this.#deps = deps;
154
+ }
155
+
156
+ async resolve(url: InternalUrl): Promise<InternalResource> {
157
+ const route = parseRoute(url);
158
+ const address =
159
+ route.kind === "provider"
160
+ ? `${route.namespace}/${route.type}/versions`
161
+ : `${route.namespace}/${route.name}/${route.provider}`;
162
+ const baseUrl = route.kind === "provider" ? this.#deps.providerBaseUrl : this.#deps.moduleBaseUrl;
163
+ const requestUrl = `${baseUrl.replace(/\/+$/, "")}/${address}`;
164
+
165
+ try {
166
+ const response = await this.#deps.fetch(requestUrl, {
167
+ headers: { Accept: "application/json" },
168
+ signal: AbortSignal.timeout(this.#deps.timeoutMs),
169
+ });
170
+ if (response.status === 404) {
171
+ const kind = route.kind === "provider" ? "Provider" : "Module";
172
+ return markdownResource(
173
+ url,
174
+ failure(
175
+ route,
176
+ `${kind} not found`,
177
+ `Verify the namespace and ${route.kind === "provider" ? "type" : "name/provider"}, then retry the exact lookup.`,
178
+ ),
179
+ );
180
+ }
181
+ if (!response.ok) {
182
+ return markdownResource(
183
+ url,
184
+ failure(
185
+ route,
186
+ "Terraform Registry request failed",
187
+ `The Registry returned HTTP ${response.status}. Try the lookup again; if it persists, verify Registry availability.`,
188
+ ),
189
+ );
190
+ }
191
+
192
+ let data: unknown;
193
+ try {
194
+ data = await response.json();
195
+ } catch {
196
+ return markdownResource(
197
+ url,
198
+ failure(
199
+ route,
200
+ "Terraform Registry invalid response",
201
+ "The Registry did not return valid JSON. No version or metadata was inferred.",
202
+ ),
203
+ );
204
+ }
205
+
206
+ if (route.kind === "provider") {
207
+ const versions = providerVersions(data);
208
+ if (!versions) {
209
+ return markdownResource(
210
+ url,
211
+ failure(
212
+ route,
213
+ "Terraform Registry invalid response",
214
+ "The provider response did not contain documented version entries. No version was inferred.",
215
+ ),
216
+ );
217
+ }
218
+ return markdownResource(url, renderProvider(route.namespace, route.type, versions));
219
+ }
220
+
221
+ const metadata = moduleMetadata(data);
222
+ if (!metadata) {
223
+ return markdownResource(
224
+ url,
225
+ failure(
226
+ route,
227
+ "Terraform Registry invalid response",
228
+ "The module response omitted required metadata. No module arguments were inferred.",
229
+ ),
230
+ );
231
+ }
232
+ return markdownResource(url, renderModule(metadata));
233
+ } catch (error) {
234
+ const timedOut =
235
+ error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError");
236
+ return markdownResource(
237
+ url,
238
+ failure(
239
+ route,
240
+ timedOut ? "Terraform Registry request timed out" : "Terraform Registry request failed",
241
+ timedOut
242
+ ? `The lookup timed out after ${this.#deps.timeoutMs} ms. Try the lookup again or verify network access to the Registry.`
243
+ : `Network error: ${errorMessage(error)}. Verify connectivity to the Registry and retry the exact lookup.`,
244
+ ),
245
+ );
246
+ }
247
+ }
248
+ }
249
+
250
+ export function createRegistryResolver(deps: Partial<RegistryResolverDeps> = {}): RegistryResolver {
251
+ return new RegistryResolver({
252
+ fetch: deps.fetch ?? globalThis.fetch,
253
+ providerBaseUrl: deps.providerBaseUrl ?? DEFAULT_PROVIDER_BASE_URL,
254
+ moduleBaseUrl: deps.moduleBaseUrl ?? DEFAULT_MODULE_BASE_URL,
255
+ timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS,
256
+ });
257
+ }
@@ -46,6 +46,7 @@ import { EMBEDDED_DOC_FILENAMES, EMBEDDED_DOCS } from "./docs-index.generated";
46
46
  import extensionApiContent from "./extension-api.md" with { type: "text" };
47
47
  import { createFleetResolver, type FleetDeps, type FleetResolver } from "./fleet-resolve";
48
48
  import { createPluginResolver, type GetPluginRoots, type PluginResolver } from "./plugin-resolve";
49
+ import { createRegistryResolver, type RegistryResolver, type RegistryResolverDeps } from "./registry-resolve";
49
50
  import { createSourceResolver, type SourceResolver } from "./source-resolve";
50
51
  import { createTerraformResolver, type TerraformResolver } from "./terraform-resolve";
51
52
  import type { TerraformIndex } from "./terraform-types";
@@ -57,6 +58,7 @@ const API_SPEC_HOST = "api-spec";
57
58
  const API_CATALOG_HOST = "api-catalog";
58
59
  const BRANDING_HOST = "branding";
59
60
  const TERRAFORM_HOST = "terraform";
61
+ const REGISTRY_HOST = "registry";
60
62
  const CONSOLE_HOST = "console";
61
63
  const EXTENSION_HOST = "extension";
62
64
  const PLUGIN_HOST = "plugin";
@@ -316,6 +318,8 @@ export interface InternalDocsProtocolOptions {
316
318
  readonly getPluginRoots?: GetPluginRoots;
317
319
  /** Injected so tests can classify without a git repo, a `gh` binary, or a network. */
318
320
  readonly fleetDeps?: Partial<FleetDeps>;
321
+ /** Injected so Registry tests can use a protocol-faithful local server. */
322
+ readonly registryDeps?: Partial<RegistryResolverDeps>;
319
323
  }
320
324
 
321
325
  export class InternalDocsProtocolHandler implements ProtocolHandler {
@@ -327,12 +331,14 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
327
331
  #apiSpecResolver: ApiSpecResolver | null;
328
332
  #apiCatalogResolver: ApiCatalogResolver | null;
329
333
  #terraformResolver: TerraformResolver | null;
334
+ #registryResolver: RegistryResolver | null = null;
330
335
  #consoleResolver: ConsoleResolver | null = null;
331
336
  #pluginResolver: PluginResolver | null = null;
332
337
  #changesResolver: ChangesResolver | null = null;
333
338
  #sourceResolver: SourceResolver | null = null;
334
339
  #fleetResolver: FleetResolver | null = null;
335
340
  readonly #fleetDeps: Partial<FleetDeps> | undefined;
341
+ readonly #registryDeps: Partial<RegistryResolverDeps> | undefined;
336
342
  readonly #getPluginRoots: GetPluginRoots | undefined;
337
343
 
338
344
  constructor(options: InternalDocsProtocolOptions = {}) {
@@ -345,6 +351,7 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
345
351
  this.#terraformResolver = null;
346
352
  this.#getPluginRoots = options.getPluginRoots;
347
353
  this.#fleetDeps = options.fleetDeps;
354
+ this.#registryDeps = options.registryDeps;
348
355
  }
349
356
 
350
357
  #getApiSpecResolver(): ApiSpecResolver {
@@ -381,6 +388,11 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
381
388
  return this.#terraformResolver;
382
389
  }
383
390
 
391
+ #getRegistryResolver(): RegistryResolver {
392
+ if (!this.#registryResolver) this.#registryResolver = createRegistryResolver(this.#registryDeps);
393
+ return this.#registryResolver;
394
+ }
395
+
384
396
  #getConsoleResolver(): ConsoleResolver {
385
397
  if (!this.#consoleResolver) {
386
398
  this.#consoleResolver = createConsoleResolver(loadConsoleCatalog(), loadConsoleFieldMetadata());
@@ -436,6 +448,10 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
436
448
  return this.#getTerraformResolver().resolve(url);
437
449
  }
438
450
 
451
+ if (host === REGISTRY_HOST) {
452
+ return this.#getRegistryResolver().resolve(url);
453
+ }
454
+
439
455
  if (host === PLUGIN_HOST) {
440
456
  return this.#getPluginResolver().resolve(url);
441
457
  }
@@ -504,6 +520,7 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
504
520
  const brandingEntry = `- [${BRANDING_HOST}](${SCHEME_PREFIX}${BRANDING_HOST}) — F5 XC branding and legacy name mapping (v${branding.version})`;
505
521
  const tf = loadTerraformIndex();
506
522
  const terraformEntry = `- [${TERRAFORM_HOST}/](${SCHEME_PREFIX}${TERRAFORM_HOST}/) — F5 XC Terraform provider (${Object.keys(tf.resources).length} resources, v${tf.version})`;
523
+ const registryEntry = `- [${REGISTRY_HOST}/provider/<namespace>/<type>](${SCHEME_PREFIX}${REGISTRY_HOST}/provider/hashicorp/random) — live Terraform provider and module Registry metadata`;
507
524
  const entries = [
508
525
  syntheticEntry,
509
526
  changesEntry,
@@ -513,6 +530,7 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
513
530
  apiCatalogEntry,
514
531
  brandingEntry,
515
532
  terraformEntry,
533
+ registryEntry,
516
534
  ...EMBEDDED_DOC_FILENAMES.map(f => `- [${f}](${SCHEME_PREFIX}${f})`),
517
535
  ];
518
536
  // Derived, not hand-maintained: the advertised count used to be a magic
@@ -0,0 +1,38 @@
1
+ import { Container, type SelectItem, SelectList, Spacer, Text } from "@f5-sales-demo/pi-tui";
2
+ import type { LoginModelChoice } from "../controllers/login-model";
3
+ import { getSelectListTheme } from "../theme/theme";
4
+ import { DynamicBorder } from "./dynamic-border";
5
+
6
+ export class VllmModelSelectorComponent extends Container {
7
+ #selectList: SelectList;
8
+
9
+ constructor(
10
+ choices: readonly LoginModelChoice[],
11
+ onSelect: (choice: LoginModelChoice) => void,
12
+ onCancel: () => void,
13
+ ) {
14
+ super();
15
+ const items: SelectItem[] = choices.map(choice => ({
16
+ value: choice.modelId,
17
+ label: choice.label,
18
+ description: choice.description,
19
+ }));
20
+ this.addChild(new DynamicBorder());
21
+ this.addChild(new Spacer(1));
22
+ this.addChild(new Text("Select your default vLLM model:", 1, 0));
23
+ this.addChild(new Spacer(1));
24
+ this.#selectList = new SelectList(items, items.length, getSelectListTheme());
25
+ this.#selectList.onSelect = item => {
26
+ const choice = choices.find(candidate => candidate.modelId === item.value);
27
+ if (choice) onSelect(choice);
28
+ };
29
+ this.#selectList.onCancel = onCancel;
30
+ this.addChild(this.#selectList);
31
+ this.addChild(new Spacer(1));
32
+ this.addChild(new DynamicBorder());
33
+ }
34
+
35
+ getSelectList(): SelectList {
36
+ return this.#selectList;
37
+ }
38
+ }
@@ -1,5 +1,6 @@
1
1
  import { ThinkingLevel } from "@f5-sales-demo/pi-agent-core";
2
2
  import { canonicalizeOAuthProviderId, type Model } from "@f5-sales-demo/pi-ai";
3
+ import type { VllmDiscoveredModel } from "../../config/vllm-config";
3
4
  import { applySubscriptionProfileRoles, type SubscriptionProfileId } from "../../routing/subscription-profiles";
4
5
 
5
6
  export interface LoginModelChoice {
@@ -53,6 +54,19 @@ export function getAvailableLiteLLMLoginModelChoices(availableModelIds: readonly
53
54
  return LITELLM_LOGIN_MODEL_CHOICES.filter(choice => available.has(choice.modelId));
54
55
  }
55
56
 
57
+ export function getVllmLoginModelChoices(models: readonly VllmDiscoveredModel[]): LoginModelChoice[] {
58
+ return models.map(model => ({
59
+ label: model.id,
60
+ description:
61
+ model.contextWindow === undefined
62
+ ? "Context limit not advertised; using compatibility defaults"
63
+ : `${model.contextWindow.toLocaleString("en-US")} token context`,
64
+ provider: "vllm",
65
+ modelId: model.id,
66
+ thinkingLevel: ThinkingLevel.Off,
67
+ }));
68
+ }
69
+
56
70
  /**
57
71
  * Minimal session surface needed to apply a model after a successful login.
58
72
  * Kept structural so the login flow can call it without pulling in the full
@@ -16,6 +16,12 @@ import { probeLiteLLMConnection, readLiteLLMConfig } from "../../config/auto-con
16
16
  import { getRoleInfo } from "../../config/model-registry";
17
17
  import { formatModelSelectorValue } from "../../config/model-resolver";
18
18
  import { settings } from "../../config/settings";
19
+ import {
20
+ DEFAULT_VLLM_BASE_URL,
21
+ normalizeVllmBaseUrl,
22
+ probeVllmConnection,
23
+ readVllmConfig,
24
+ } from "../../config/vllm-config";
19
25
  import { DebugSelectorComponent } from "../../debug";
20
26
  import { disableProvider, enableProvider } from "../../discovery";
21
27
  import { clearXcshPluginRootsCache, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
@@ -59,6 +65,7 @@ import { getPreset } from "../components/status-line/presets";
59
65
  import { ToolExecutionComponent } from "../components/tool-execution";
60
66
  import { TreeSelectorComponent } from "../components/tree-selector";
61
67
  import { UserMessageSelectorComponent } from "../components/user-message-selector";
68
+ import { VllmModelSelectorComponent } from "../components/vllm-model-selector";
62
69
  import type { SessionObserverRegistry } from "../session-observer-registry";
63
70
  import { runEnterpriseOAuthLoginFlow } from "./enterprise-oauth-login-flow";
64
71
  import {
@@ -72,7 +79,10 @@ import {
72
79
  GOOGLE_ANTIGRAVITY_LOGIN_MODEL_CHOICE,
73
80
  LITELLM_LOGIN_MODEL_CHOICES,
74
81
  type LiteLLMLoginModelChoice,
82
+ type LoginModelChoice,
75
83
  } from "./login-model";
84
+ import { runVllmLoginFlow } from "./vllm-login-flow";
85
+ import { commitVllmLogin } from "./vllm-login-transaction";
76
86
 
77
87
  const CALLBACK_SERVER_PROVIDERS = new Set<OAuthProvider>([
78
88
  "anthropic",
@@ -529,6 +539,57 @@ export class SelectorController {
529
539
  return promise;
530
540
  }
531
541
 
542
+ async #showVllmLoginModelSelector(choices: readonly LoginModelChoice[]): Promise<LoginModelChoice | null> {
543
+ const { promise, resolve } = Promise.withResolvers<LoginModelChoice | null>();
544
+ this.showSelector(done => {
545
+ const selector = new VllmModelSelectorComponent(
546
+ choices,
547
+ choice => {
548
+ done();
549
+ resolve(choice);
550
+ this.ctx.ui.requestRender();
551
+ },
552
+ () => {
553
+ done();
554
+ resolve(null);
555
+ this.ctx.ui.requestRender();
556
+ },
557
+ );
558
+ return { component: selector, focus: selector.getSelectList() };
559
+ });
560
+ return promise;
561
+ }
562
+
563
+ async #promptLoginValue(prompt: OAuthPrompt): Promise<string> {
564
+ this.ctx.chatContainer.addChild(new Spacer(1));
565
+ this.ctx.chatContainer.addChild(new Text(theme.fg("text", prompt.message), 1, 0));
566
+ if (prompt.placeholder) {
567
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0));
568
+ }
569
+ this.ctx.ui.requestRender();
570
+ const { promise, resolve, reject } = Promise.withResolvers<string>();
571
+ const input = createLoginPromptInput(prompt);
572
+ const closeInput = () => {
573
+ this.ctx.editorContainer.clear();
574
+ this.ctx.editorContainer.addChild(this.ctx.editor);
575
+ this.ctx.ui.setFocus(this.ctx.editor);
576
+ };
577
+ input.onSubmit = () => {
578
+ const value = input.getValue();
579
+ closeInput();
580
+ resolve(value);
581
+ };
582
+ input.onEscape = () => {
583
+ closeInput();
584
+ reject(new LoginPromptCancelled());
585
+ };
586
+ this.ctx.editorContainer.clear();
587
+ this.ctx.editorContainer.addChild(input);
588
+ this.ctx.ui.setFocus(input);
589
+ this.ctx.ui.requestRender();
590
+ return promise;
591
+ }
592
+
532
593
  async #showLoginRecovery(
533
594
  request: LoginRecoveryRequest | { stage: string; error: string; canEdit: boolean },
534
595
  flowLabel = "LiteLLM",
@@ -1057,6 +1118,97 @@ export class SelectorController {
1057
1118
  }
1058
1119
  }
1059
1120
 
1121
+ async #handleVllmLogin(): Promise<void> {
1122
+ this.ctx.showStatus("Configuring vLLM…");
1123
+ const modelsPath = path.join(getAgentDir(), "models.yml");
1124
+
1125
+ try {
1126
+ let defaultBaseUrl = readVllmConfig(modelsPath)?.baseUrl ?? DEFAULT_VLLM_BASE_URL;
1127
+ const storedCredential = this.ctx.session.modelRegistry.authStorage.get("vllm");
1128
+ const hadStoredKey = storedCredential?.type === "api_key" && storedCredential.key.length > 0;
1129
+ const flowResult = await runVllmLoginFlow({
1130
+ collectCredentials: async () => {
1131
+ try {
1132
+ let baseUrl: string;
1133
+ while (true) {
1134
+ const value = await this.#promptLoginValue({
1135
+ message: `vLLM Base URL [${defaultBaseUrl}]`,
1136
+ placeholder: DEFAULT_VLLM_BASE_URL,
1137
+ allowEmpty: true,
1138
+ });
1139
+ try {
1140
+ baseUrl = normalizeVllmBaseUrl(value.trim() || defaultBaseUrl);
1141
+ break;
1142
+ } catch (error) {
1143
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
1144
+ }
1145
+ }
1146
+ const apiKey = await this.#promptLoginValue({
1147
+ message: hadStoredKey
1148
+ ? "Optional vLLM API key [stored securely; leave blank to remove authentication]"
1149
+ : "Optional vLLM API key [leave blank for keyless local service]",
1150
+ allowEmpty: true,
1151
+ secret: true,
1152
+ });
1153
+ defaultBaseUrl = baseUrl;
1154
+ return { baseUrl, apiKey };
1155
+ } catch (error) {
1156
+ if (error instanceof LoginPromptCancelled) return null;
1157
+ throw error;
1158
+ }
1159
+ },
1160
+ probe: async credentials => {
1161
+ this.ctx.chatContainer.addChild(new Spacer(1));
1162
+ this.ctx.chatContainer.addChild(
1163
+ new Text(theme.fg("dim", `Connecting to ${credentials.baseUrl}/models…`), 1, 0),
1164
+ );
1165
+ this.ctx.ui.requestRender();
1166
+ const probe = await probeVllmConnection(credentials.baseUrl, credentials.apiKey);
1167
+ this.ctx.chatContainer.addChild(
1168
+ new Text(
1169
+ theme.fg("success", `${theme.status.success} OK — ${probe.models.length} vLLM models available`),
1170
+ 1,
1171
+ 0,
1172
+ ),
1173
+ );
1174
+ this.ctx.ui.requestRender();
1175
+ return probe;
1176
+ },
1177
+ selectModel: choices => this.#showVllmLoginModelSelector(choices),
1178
+ commit: input =>
1179
+ commitVllmLogin({
1180
+ modelsPath,
1181
+ credentials: input.credentials,
1182
+ choice: input.choice,
1183
+ session: this.ctx.session,
1184
+ }),
1185
+ recover: request => this.#showLoginRecovery(request, "vLLM"),
1186
+ });
1187
+
1188
+ if (flowResult.status === "cancelled") {
1189
+ this.ctx.showStatus("vLLM login cancelled. Existing configuration unchanged.");
1190
+ return;
1191
+ }
1192
+ this.ctx.statusLine.invalidate();
1193
+ this.ctx.updateEditorBorderColor();
1194
+ this.ctx.chatContainer.addChild(new Spacer(1));
1195
+ this.ctx.chatContainer.addChild(
1196
+ new Text(theme.fg("success", `${theme.status.success} vLLM configuration saved to ${modelsPath}`), 1, 0),
1197
+ );
1198
+ this.ctx.chatContainer.addChild(
1199
+ new Text(theme.fg("success", `Default model: vllm/${flowResult.choice.modelId} (thinking off)`), 1, 0),
1200
+ );
1201
+ if (this.ctx.session.modelRegistry.authStorage.get("vllm")) {
1202
+ this.ctx.chatContainer.addChild(
1203
+ new Text(theme.fg("dim", `API key saved only to ${getAgentDbPath()}`), 1, 0),
1204
+ );
1205
+ }
1206
+ this.ctx.ui.requestRender();
1207
+ } catch (error) {
1208
+ this.ctx.showError(`vLLM login failed: ${error instanceof Error ? error.message : String(error)}`);
1209
+ }
1210
+ }
1211
+
1060
1212
  async #handleOAuthLogin(providerId: string): Promise<void> {
1061
1213
  if (providerId === "openai") {
1062
1214
  this.#showOpenAIApiKeyGuidance();
@@ -1066,6 +1218,9 @@ export class SelectorController {
1066
1218
  if (providerId === "litellm") {
1067
1219
  return this.#handleLiteLLMLogin();
1068
1220
  }
1221
+ if (providerId === "vllm") {
1222
+ return this.#handleVllmLogin();
1223
+ }
1069
1224
 
1070
1225
  this.ctx.showStatus(`Logging in to ${providerId}…`);
1071
1226
  const manualInput = this.ctx.oauthManualInput;
@@ -0,0 +1,87 @@
1
+ import type { VllmProbeResult } from "../../config/vllm-config";
2
+ import { getVllmLoginModelChoices, type LoginModelChoice } from "./login-model";
3
+
4
+ export interface VllmLoginCredentials {
5
+ baseUrl: string;
6
+ apiKey: string;
7
+ }
8
+
9
+ export type LoginRecoveryAction = "retry" | "edit" | "cancel";
10
+ export type VllmLoginStage = "probe" | "commit";
11
+
12
+ export interface VllmLoginRecoveryRequest {
13
+ stage: VllmLoginStage;
14
+ error: string;
15
+ canEdit: boolean;
16
+ }
17
+
18
+ export interface VllmLoginCommit {
19
+ credentials: VllmLoginCredentials;
20
+ probe: VllmProbeResult;
21
+ choice: LoginModelChoice;
22
+ }
23
+
24
+ export type VllmLoginFlowResult = { status: "completed"; choice: LoginModelChoice } | { status: "cancelled" };
25
+
26
+ interface VllmLoginFlowOptions {
27
+ collectCredentials(): Promise<VllmLoginCredentials | null>;
28
+ probe(credentials: VllmLoginCredentials): Promise<VllmProbeResult>;
29
+ selectModel(choices: readonly LoginModelChoice[]): Promise<LoginModelChoice | null>;
30
+ commit(input: VllmLoginCommit): Promise<void>;
31
+ recover(request: VllmLoginRecoveryRequest): 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|API key)\b/i.test(message);
42
+ }
43
+
44
+ export async function runVllmLoginFlow(options: VllmLoginFlowOptions): Promise<VllmLoginFlowResult> {
45
+ const sleep = options.sleep ?? Bun.sleep;
46
+ const maxAutomaticRetries = options.maxAutomaticRetries ?? 2;
47
+
48
+ credentialsLoop: while (true) {
49
+ const credentials = await options.collectCredentials();
50
+ if (!credentials) return { status: "cancelled" };
51
+
52
+ let probe: VllmProbeResult;
53
+ let automaticRetries = 0;
54
+ while (true) {
55
+ try {
56
+ probe = await options.probe(credentials);
57
+ break;
58
+ } catch (error) {
59
+ const message = errorMessage(error);
60
+ if (!isAuthenticationError(message) && automaticRetries < maxAutomaticRetries) {
61
+ await sleep(250 * 2 ** automaticRetries);
62
+ automaticRetries += 1;
63
+ continue;
64
+ }
65
+ const action = await options.recover({ stage: "probe", error: message, canEdit: true });
66
+ if (action === "cancel") return { status: "cancelled" };
67
+ if (action === "edit") continue credentialsLoop;
68
+ automaticRetries = 0;
69
+ }
70
+ }
71
+
72
+ const choices = getVllmLoginModelChoices(probe.models);
73
+ const choice = choices.length === 1 ? choices[0] : await options.selectModel(choices);
74
+ if (!choice) return { status: "cancelled" };
75
+
76
+ while (true) {
77
+ try {
78
+ await options.commit({ credentials, probe, choice });
79
+ return { status: "completed", choice };
80
+ } catch (error) {
81
+ const action = await options.recover({ stage: "commit", error: errorMessage(error), canEdit: true });
82
+ if (action === "cancel") return { status: "cancelled" };
83
+ if (action === "edit") continue credentialsLoop;
84
+ }
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,148 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { ThinkingLevel } from "@f5-sales-demo/pi-agent-core";
4
+ import type { AuthCredential, Model } from "@f5-sales-demo/pi-ai";
5
+ import { writeVllmModelsConfig } from "../../config/vllm-config";
6
+ import { applyModelAfterLogin, type LoginModelChoice } from "./login-model";
7
+ import type { VllmLoginCredentials } from "./vllm-login-flow";
8
+
9
+ interface VllmTransactionSession {
10
+ model?: Model;
11
+ thinkingLevel?: ThinkingLevel;
12
+ modelRegistry: {
13
+ authStorage: {
14
+ get(provider: string): AuthCredential | undefined;
15
+ set(provider: string, credential: AuthCredential): Promise<void>;
16
+ remove(provider: string): Promise<void>;
17
+ };
18
+ refreshProvider(providerId: string, strategy: "online"): Promise<void>;
19
+ getAll(): Model[];
20
+ };
21
+ setModel(model: Model, role: "default", options: { selector: string; thinkingLevel: ThinkingLevel }): Promise<void>;
22
+ setModelTemporary?(model: Model, thinkingLevel?: ThinkingLevel): Promise<void>;
23
+ setThinkingLevel(level: ThinkingLevel): void;
24
+ settings: {
25
+ getModelRoles(): Readonly<Record<string, string | undefined>>;
26
+ set(key: "modelRoles", value: Record<string, string>): void;
27
+ };
28
+ }
29
+
30
+ interface CommitVllmLoginOptions {
31
+ modelsPath: string;
32
+ credentials: VllmLoginCredentials;
33
+ choice: LoginModelChoice;
34
+ session: VllmTransactionSession;
35
+ }
36
+
37
+ interface FileSnapshot {
38
+ existed: boolean;
39
+ content?: Buffer;
40
+ mode?: number;
41
+ }
42
+
43
+ interface DirectorySnapshot {
44
+ existed: boolean;
45
+ mode?: number;
46
+ }
47
+
48
+ function captureFile(filePath: string): FileSnapshot {
49
+ try {
50
+ const stat = fs.statSync(filePath);
51
+ return { existed: true, content: fs.readFileSync(filePath), mode: stat.mode & 0o777 };
52
+ } catch (error) {
53
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { existed: false };
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ async function restoreFile(filePath: string, snapshot: FileSnapshot): Promise<void> {
59
+ if (!snapshot.existed) {
60
+ await fs.promises.rm(filePath, { force: true });
61
+ return;
62
+ }
63
+ if (!snapshot.content) throw new Error(`Missing rollback content for ${filePath}`);
64
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
65
+ await fs.promises.writeFile(filePath, snapshot.content, { mode: snapshot.mode });
66
+ if (snapshot.mode !== undefined) await fs.promises.chmod(filePath, snapshot.mode);
67
+ }
68
+
69
+ function captureDirectory(directory: string): DirectorySnapshot {
70
+ try {
71
+ return { existed: true, mode: fs.statSync(directory).mode & 0o777 };
72
+ } catch (error) {
73
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { existed: false };
74
+ throw error;
75
+ }
76
+ }
77
+
78
+ async function restoreDirectory(directory: string, snapshot: DirectorySnapshot): Promise<void> {
79
+ if (snapshot.existed) {
80
+ if (snapshot.mode !== undefined) await fs.promises.chmod(directory, snapshot.mode);
81
+ return;
82
+ }
83
+ await fs.promises.rmdir(directory).catch(error => {
84
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT" && (error as NodeJS.ErrnoException).code !== "ENOTEMPTY") {
85
+ throw error;
86
+ }
87
+ });
88
+ }
89
+
90
+ export async function commitVllmLogin(options: CommitVllmLoginOptions): Promise<void> {
91
+ const { modelsPath, credentials, choice, session } = options;
92
+ const modelsSnapshot = captureFile(modelsPath);
93
+ const directory = path.dirname(modelsPath);
94
+ const directorySnapshot = captureDirectory(directory);
95
+ const previousCredential = session.modelRegistry.authStorage.get("vllm");
96
+ const previousModel = session.model;
97
+ const previousThinkingLevel = session.thinkingLevel;
98
+ const previousModelRoles = Object.fromEntries(
99
+ Object.entries(session.settings.getModelRoles()).filter(
100
+ (entry): entry is [string, string] => entry[1] !== undefined,
101
+ ),
102
+ );
103
+ const apiKey = credentials.apiKey.trim();
104
+
105
+ try {
106
+ await writeVllmModelsConfig(modelsPath, credentials.baseUrl, { authenticated: apiKey.length > 0 });
107
+ if (apiKey) await session.modelRegistry.authStorage.set("vllm", { type: "api_key", key: apiKey });
108
+ else await session.modelRegistry.authStorage.remove("vllm");
109
+
110
+ await session.modelRegistry.refreshProvider("vllm", "online");
111
+ const applied = await applyModelAfterLogin(session, choice);
112
+ if (!applied) throw new Error(`Model unavailable after refresh: ${choice.provider}/${choice.modelId}`);
113
+ } catch (error) {
114
+ const rollbackErrors: unknown[] = [];
115
+ try {
116
+ await restoreFile(modelsPath, modelsSnapshot);
117
+ await restoreDirectory(directory, directorySnapshot);
118
+ } catch (rollbackError) {
119
+ rollbackErrors.push(rollbackError);
120
+ }
121
+ try {
122
+ if (previousCredential) await session.modelRegistry.authStorage.set("vllm", previousCredential);
123
+ else await session.modelRegistry.authStorage.remove("vllm");
124
+ } catch (rollbackError) {
125
+ rollbackErrors.push(rollbackError);
126
+ }
127
+ try {
128
+ session.settings.set("modelRoles", previousModelRoles);
129
+ } catch (rollbackError) {
130
+ rollbackErrors.push(rollbackError);
131
+ }
132
+ try {
133
+ await session.modelRegistry.refreshProvider("vllm", "online");
134
+ if (previousModel && session.setModelTemporary) {
135
+ await session.setModelTemporary(previousModel, previousThinkingLevel);
136
+ } else if (previousThinkingLevel !== undefined) {
137
+ session.setThinkingLevel(previousThinkingLevel);
138
+ }
139
+ } catch (rollbackError) {
140
+ rollbackErrors.push(rollbackError);
141
+ }
142
+
143
+ if (rollbackErrors.length > 0) {
144
+ throw new AggregateError([error, ...rollbackErrors], "vLLM login failed and rollback was incomplete");
145
+ }
146
+ throw error;
147
+ }
148
+ }
@@ -41,8 +41,15 @@ plugins listed below, not an assumed set.
41
41
  Writing code is something you can do, but shipping feature code is not your job. Which repository
42
42
  you are standing in decides how you contribute, and that is declared rather than guessed: read
43
43
  `xcsh://fleet`. In a repository classified **content** you author directly — documentation,
44
- Terraform, howtos, demo and traffic-generation scripts — through the governed path. In one
45
- classified **developer** your deliverable is a rigorously-verified issue plus the specification,
44
+ Terraform, howtos, demo and traffic-generation scripts — through the governed path (Git SOPs).
45
+ When the GitHub plugin is installed in a **content** repository, you operate with the professional mastery of a skilled DevOps engineer:
46
+ 1. **Comprehensive Issue First**: Always create a detailed GitHub issue before developing content or making edits.
47
+ 2. **Feature Branch / Worktree**: Create a dedicated feature branch (`feature/<issue>-desc`) or worktree from `origin/main`. Never commit directly to `main`.
48
+ 3. **PR with Issue Link**: Stage specific files, run pre-commit lint gate, commit, push, and open a PR referencing `Closes #N`.
49
+ 4. **CI & Merge**: Poll CI until green using rate-limit aware polling, then squash merge (`--squash --delete-branch`).
50
+ 5. **Post-Merge Hygiene & Teardown**: Clean up after yourself — delete local/remote feature branches, remove merged worktrees, and run `git fetch --prune`. Never leave merged branches or worktrees behind.
51
+
52
+ In a repository classified **developer** your deliverable is a rigorously-verified issue plus the specification,
46
53
  review and documentation around it, and the implementation is delegated to a development
47
54
  environment (Claude Code / Codex). In one classified **scaffolding**, changes go through the
48
55
  governed path only. An unclassified repository is treated as **developer**. That document also
@@ -472,6 +479,12 @@ permitted only after exhausting the llms.txt hierarchy.
472
479
 
473
480
  **ACTIVATION GATE**: You **MUST NOT** generate Terraform HCL code or plans unless the user explicitly asks for "Terraform", "HCL", ".tf" files, or Terraform CLI commands. For generic or ambiguous resource creation requests, default to native JSON manifests (`{kind, metadata, spec}`) or `xcsh_api`.
474
481
 
482
+ Before referencing an external Terraform provider or module, resolve its exact source metadata through
483
+ `xcsh://registry/provider/<namespace>/<type>` or
484
+ `xcsh://registry/module/<namespace>/<name>/<provider>`. Never guess a namespace, provider type, module provider, or version.
485
+ Use the Registry response together with the configuration's compatibility requirements; do not mechanically
486
+ label the newest published version as compatible.
487
+
475
488
  HARD OVERRIDE — F5 Distributed Cloud Terraform Provider:
476
489
  - NEVER reference, recommend, or generate Terraform code using:
477
490
  - provider: volterraedge/volterra