@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 +17 -0
- package/package.json +8 -8
- package/src/cli.ts +1 -0
- package/src/config/model-registry.ts +13 -5
- package/src/config/vllm-config.ts +212 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/fleet-resolve.ts +6 -2
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/registry-resolve.ts +257 -0
- package/src/internal-urls/xcsh-protocol.ts +18 -0
- package/src/modes/components/vllm-model-selector.ts +38 -0
- package/src/modes/controllers/login-model.ts +14 -0
- package/src/modes/controllers/selector-controller.ts +155 -0
- package/src/modes/controllers/vllm-login-flow.ts +87 -0
- package/src/modes/controllers/vllm-login-transaction.ts +148 -0
- package/src/prompts/system/system-prompt.md +15 -2
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.
|
|
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.
|
|
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.
|
|
70
|
-
"@f5-sales-demo/xcsh-stats": "20.
|
|
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 =
|
|
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 =
|
|
1741
|
-
const items
|
|
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:
|
|
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.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "20.22.2",
|
|
21
|
+
"commit": "ed93a01366838afc5ccd503a0bc678af72c92fe4",
|
|
22
|
+
"shortCommit": "ed93a01",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v20.
|
|
25
|
-
"commitDate": "2026-08-
|
|
26
|
-
"buildDate": "2026-08-
|
|
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/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.
|
|
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
|
-
"
|
|
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
|
|
45
|
-
|
|
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
|