@f5-sales-demo/xcsh 20.20.3 → 20.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -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/api-catalog-index.generated.ts +4657 -4774
- package/src/internal-urls/api-spec-index.generated.ts +21057 -21176
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/console-catalog.generated.ts +2 -2
- 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/terraform-index.generated.ts +4 -4
- package/src/internal-urls/terraform-resolve.ts +20 -9
- package/src/internal-urls/terraform-types.ts +11 -2
- 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,10 @@
|
|
|
26
36
|
|
|
27
37
|
### Fixed
|
|
28
38
|
|
|
39
|
+
- Consumed vLLM-advertised context limits with bounded output budgets while preserving compatibility
|
|
40
|
+
fallbacks when metadata is absent
|
|
41
|
+
([#2947](https://github.com/f5-sales-demo/xcsh/issues/2947),
|
|
42
|
+
[#3361](https://github.com/f5-sales-demo/xcsh/issues/3361)).
|
|
29
43
|
- Restored bare `xcsh update` as a backward-compatible executable updater while preserving
|
|
30
44
|
manifest-based resource updates and the explicit `xcsh self-update` command
|
|
31
45
|
([#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.0",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -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.0",
|
|
65
|
+
"@f5-sales-demo/pi-ai": "20.22.0",
|
|
66
|
+
"@f5-sales-demo/pi-natives": "20.22.0",
|
|
67
|
+
"@f5-sales-demo/pi-resource-management": "20.22.0",
|
|
68
|
+
"@f5-sales-demo/pi-tui": "20.22.0",
|
|
69
|
+
"@f5-sales-demo/pi-utils": "20.22.0",
|
|
70
|
+
"@f5-sales-demo/xcsh-stats": "20.22.0",
|
|
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
|
+
}
|