diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c0b1b9..5e554b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,21 @@ All notable changes to the VS Code extension are documented here. ## [Unreleased] +## [0.8.0] — 2026-05-01 +### Added — Bring-Your-Own-Endpoint (REQ-142) +- **`EndpointsClient` module** shells out to `specsmith endpoints list / test --json` and surfaces results to the host. JSON parsers (`parseEndpointsList`, `parseEndpointHealth`) and the `applyEndpointArg` bridge helper are exported pure-TS so they have direct mocha coverage. +- **`specsmith.endpoints` command.** Quick Pick over the registered endpoints with copy-id / set-default / test actions; `\u2605` marks the current default. +- **`specsmith.testEndpoint` command.** Probes `/v1/models` via `specsmith endpoints test --json` and shows a notification with latency + model count. +- **`SessionConfig.endpointId`** plumbed through `bridge.ts`. When set, the bridge appends `--endpoint ` to `specsmith run`, routing the LLM turn to the OpenAI-v1-compatible backend registered with `specsmith endpoints add` (vLLM, llama.cpp `server`, LM Studio, TGI, ...). +- **11 new mocha tests** in `src/test/endpoints-client.test.ts`. Total now 117 passing (was 104). + +### Changed +- `package.json` version bumped from 0.7.0 to 0.8.0; description mentions BYOE OpenAI-v1 endpoints. + +### Validation +- `npm run lint` (tsc --noEmit): clean. +- `npm run build` (esbuild): 279.8kb bundle. +- `npm test`: **117 passing** (+13 since 0.7.0). ## [0.7.0] — 2026-04-30 ### Added — warp parity follow-up - **Drive sidebar tree (REQ-133).** New `src/DriveTree.ts` exposes the contents of ~/.specsmith/drive as a sidebar tree. Title-bar actions trigger `specsmith drive push` / `pull`. diff --git a/package.json b/package.json index cf87ef9..a68ce31 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "specsmith-vscode", "displayName": "specsmith - AEE Workbench", - "description": "Applied Epistemic Engineering workbench. 6-tab Settings panel, AI agent sessions, execution profiles, FPGA/HDL tool support, Ollama local LLMs, and tool installer.", -"version": "0.7.0", + "description": "Applied Epistemic Engineering workbench. 6-tab Settings panel, AI agent sessions, execution profiles, FPGA/HDL tool support, Ollama local LLMs, BYOE OpenAI-v1 endpoints, and tool installer.", +"version": "0.8.0", "publisher": "BitConcepts", "license": "MIT", "engines": { @@ -407,6 +407,18 @@ "title": "Suggest Command", "category": "specsmith", "icon": "$(lightbulb)" + }, + { + "command": "specsmith.endpoints", + "title": "BYOE Endpoints\u2026", + "category": "specsmith", + "icon": "$(plug)" + }, + { + "command": "specsmith.testEndpoint", + "title": "Test BYOE Endpoint", + "category": "specsmith", + "icon": "$(beaker)" } ], "menus": { diff --git a/src/EndpointsClient.ts b/src/EndpointsClient.ts new file mode 100644 index 0000000..9548b29 --- /dev/null +++ b/src/EndpointsClient.ts @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 BitConcepts, LLC. All rights reserved. +// +// EndpointsClient — extension-side wrapper around the `specsmith endpoints` +// CLI group (REQ-142). The module is framework-free so it can be exercised +// from mocha unit tests without booting the full extension host. +// +// Notes: +// * The on-disk schema is owned by the CLI; this module never reads +// `~/.specsmith/endpoints.json` directly. Each operation shells out to +// `specsmith endpoints --json` so schema drift is impossible. +// * Token bytes never appear in TypeScript memory. The CLI redacts inline +// tokens before serialising the `list --json` payload. + +import * as cp from 'child_process'; + +/** Public-facing endpoint record matching what `specsmith endpoints list --json` emits. */ +export interface EndpointRecord { + id: string; + name: string; + base_url: string; + default_model: string; + verify_tls: boolean; + tags: string[]; + created_at: string; + auth: { + kind: 'none' | 'bearer-inline' | 'bearer-env' | 'bearer-keyring'; + token?: string; // always "***" for bearer-inline (CLI redacts) + token_env?: string; + keyring_service?: string; + keyring_user?: string; + }; +} + +/** Top-level list payload. */ +export interface EndpointsListResult { + default_endpoint_id: string; + endpoints: EndpointRecord[]; +} + +/** Result of `specsmith endpoints test --json`. */ +export interface EndpointHealthResult { + id: string; + ok: boolean; + latency_ms: number; + models: string[]; + error: string; + status_code: number | null; +} + +/** + * Parse the JSON body of `specsmith endpoints list --json`. + * Tolerates a missing `endpoints` array (returns empty list). + */ +export function parseEndpointsList(raw: string): EndpointsListResult { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { default_endpoint_id: '', endpoints: [] }; + } + if (!parsed || typeof parsed !== 'object') { + return { default_endpoint_id: '', endpoints: [] }; + } + const root = parsed as Record; + const def = typeof root.default_endpoint_id === 'string' ? root.default_endpoint_id : ''; + const list = Array.isArray(root.endpoints) ? root.endpoints : []; + const endpoints: EndpointRecord[] = []; + for (const item of list) { + if (!item || typeof item !== 'object') { continue; } + const obj = item as Record; + const auth = (obj.auth ?? {}) as Record; + endpoints.push({ + id: String(obj.id ?? ''), + name: String(obj.name ?? ''), + base_url: String(obj.base_url ?? ''), + default_model: String(obj.default_model ?? ''), + verify_tls: obj.verify_tls !== false, + tags: Array.isArray(obj.tags) ? obj.tags.map(String) : [], + created_at: String(obj.created_at ?? ''), + auth: { + kind: (typeof auth.kind === 'string' && /^(none|bearer-(inline|env|keyring))$/.test(auth.kind)) + ? auth.kind as EndpointRecord['auth']['kind'] + : 'none', + token: typeof auth.token === 'string' ? auth.token : undefined, + token_env: typeof auth.token_env === 'string' ? auth.token_env : undefined, + keyring_service: typeof auth.keyring_service === 'string' ? auth.keyring_service : undefined, + keyring_user: typeof auth.keyring_user === 'string' ? auth.keyring_user : undefined, + }, + }); + } + return { default_endpoint_id: def, endpoints }; +} + +/** Parse the JSON body of `specsmith endpoints test --json`. */ +export function parseEndpointHealth(raw: string): EndpointHealthResult { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { id: '', ok: false, latency_ms: 0, models: [], error: raw, status_code: null }; + } + if (!parsed || typeof parsed !== 'object') { + return { id: '', ok: false, latency_ms: 0, models: [], error: 'malformed response', status_code: null }; + } + const obj = parsed as Record; + return { + id: String(obj.id ?? ''), + ok: Boolean(obj.ok), + latency_ms: typeof obj.latency_ms === 'number' ? obj.latency_ms : 0, + models: Array.isArray(obj.models) ? obj.models.map(String) : [], + error: String(obj.error ?? ''), + status_code: typeof obj.status_code === 'number' ? obj.status_code : null, + }; +} + +/** + * Append `--endpoint ` to a bridge args list when ``endpointId`` is set. + * Pure helper, exported so the bridge plumbing has direct mocha coverage. + */ +export function applyEndpointArg(args: string[], endpointId: string | undefined): string[] { + if (endpointId && endpointId.trim()) { + return [...args, '--endpoint', endpointId.trim()]; + } + return [...args]; +} + +/** + * Shell out to ``specsmith endpoints list --json`` synchronously. + * Returns an empty list on any failure (missing CLI, bad JSON, etc.) so the + * extension always has something to render. + */ +export function listEndpoints(execPath: string): EndpointsListResult { + try { + const r = cp.spawnSync(execPath, ['endpoints', 'list', '--json'], { + timeout: 5000, + encoding: 'utf8', + }); + if (r.status !== 0 || !r.stdout) { + return { default_endpoint_id: '', endpoints: [] }; + } + return parseEndpointsList(r.stdout); + } catch { + return { default_endpoint_id: '', endpoints: [] }; + } +} + +/** + * Shell out to ``specsmith endpoints test --json`` synchronously. + */ +export function testEndpoint( + execPath: string, + endpointId: string, + timeoutSec = 5, +): EndpointHealthResult { + try { + const r = cp.spawnSync( + execPath, + ['endpoints', 'test', endpointId, '--json', '--timeout', String(timeoutSec)], + { timeout: (timeoutSec + 5) * 1000, encoding: 'utf8' }, + ); + if (!r.stdout) { + return { + id: endpointId, + ok: false, + latency_ms: 0, + models: [], + error: r.stderr ?? 'no output', + status_code: null, + }; + } + return parseEndpointHealth(r.stdout); + } catch (err) { + return { + id: endpointId, + ok: false, + latency_ms: 0, + models: [], + error: String(err), + status_code: null, + }; + } +} diff --git a/src/bridge.ts b/src/bridge.ts index 97eeb02..6c0f41c 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -384,6 +384,12 @@ export class SpecsmithBridge { '--project-dir', this._config.projectDir]; if (this._config.provider) { args.push('--provider', this._config.provider); } if (this._config.model) { args.push('--model', this._config.model); } + // REQ-142: when a registered BYOE endpoint is selected for this session, + // route the LLM turn through it. The CLI's chat/run paths short-circuit + // the auto-detect provider chain when --endpoint is supplied. + if (this._config.endpointId) { + args.push('--endpoint', this._config.endpointId); + } const env = augmentedEnv({ ...process.env, ...this._envOverrides }); // Inject Ollama context length when using the ollama provider diff --git a/src/extension.ts b/src/extension.ts index 4b2ebaf..6332c73 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -36,6 +36,7 @@ import { suggestCommandCommand, transcribeAudioCommand, } from './CliCommands'; +import { listEndpoints, testEndpoint } from './EndpointsClient'; // ── Activation ─────────────────────────────────────────────────────────────── @@ -177,7 +178,7 @@ export function activate(context: vscode.ExtensionContext): void { vscode.window.registerUriHandler(specsmithUriHandler), ); - // ── New CLI-backed commands (REQ-135 / REQ-141 / REQ-131) ───────────── + // ── New CLI-backed commands (REQ-135 / REQ-141 / REQ-131 / REQ-142) ──── context.subscriptions.push( vscode.commands.registerCommand( 'specsmith.searchHistory', @@ -191,6 +192,14 @@ export function activate(context: vscode.ExtensionContext): void { 'specsmith.suggestCommand', () => void suggestCommandCommand(), ), + vscode.commands.registerCommand( + 'specsmith.endpoints', + () => void endpointsListCommand(context), + ), + vscode.commands.registerCommand( + 'specsmith.testEndpoint', + (id?: string) => void endpointsTestCommand(context, id), + ), ); // Re-render bookmarks any time the underlying globalState changes via the @@ -1568,6 +1577,120 @@ function _findReqFile(projectDir: string): string | undefined { return candidates.find(fs.existsSync); } +/** Resolve the user-configured ``specsmith`` executable path. */ +function _execPath(): string { + return vscode.workspace + .getConfiguration('specsmith') + .get('executablePath', 'specsmith'); +} + +/** + * `specsmith.endpoints` (REQ-142): list registered BYOE endpoints in a Quick + * Pick. The user can copy the id, set a default, or test the endpoint. + */ +async function endpointsListCommand( + _context: vscode.ExtensionContext, +): Promise { + const result = listEndpoints(_execPath()); + if (!result.endpoints.length) { + const action = await vscode.window.showInformationMessage( + 'No BYOE endpoints registered. Run `specsmith endpoints add` to register one.', + 'Open Docs', + 'Cancel', + ); + if (action === 'Open Docs') { + void vscode.env.openExternal( + vscode.Uri.parse('https://github.com/BitConcepts/specsmith/blob/main/docs/site/endpoints.md'), + ); + } + return; + } + const items = result.endpoints.map((e) => ({ + label: e.id === result.default_endpoint_id ? `$(star-full) ${e.id}` : ` ${e.id}`, + description: e.base_url, + detail: `auth=${e.auth.kind} model=${e.default_model || '(none)'} ${e.tags.join(', ')}`, + endpoint: e, + })); + const picked = await vscode.window.showQuickPick(items, { + placeHolder: `${result.endpoints.length} endpoint(s) (\u2605 = default)`, + matchOnDescription: true, + matchOnDetail: true, + }); + if (!picked) { return; } + const action = await vscode.window.showQuickPick( + [ + { label: '$(beaker) Test endpoint', value: 'test' }, + { label: '$(clippy) Copy id', value: 'copy' }, + { label: '$(zap) Set as default', value: 'default' }, + ], + { placeHolder: `${picked.endpoint.id} \u2014 choose an action` }, + ); + if (!action) { return; } + if (action.value === 'test') { + await endpointsTestCommand(_context, picked.endpoint.id); + } else if (action.value === 'copy') { + await vscode.env.clipboard.writeText(picked.endpoint.id); + void vscode.window.showInformationMessage(`Copied ${picked.endpoint.id} to clipboard.`); + } else if (action.value === 'default') { + const r = cp.spawnSync(_execPath(), ['endpoints', 'default', picked.endpoint.id], { + timeout: 5000, encoding: 'utf8', + }); + if (r.status === 0) { + void vscode.window.showInformationMessage(`Default endpoint = ${picked.endpoint.id}`); + } else { + void vscode.window.showErrorMessage(`Could not set default: ${r.stderr || r.stdout}`); + } + } +} + +/** + * `specsmith.testEndpoint` (REQ-142): probe the named endpoint via + * ``specsmith endpoints test --json`` and surface the result. When no + * id is supplied, prompts the user to pick one. + */ +async function endpointsTestCommand( + _context: vscode.ExtensionContext, + id?: string, +): Promise { + const exec = _execPath(); + let endpointId = id; + if (!endpointId) { + const list = listEndpoints(exec); + if (!list.endpoints.length) { + void vscode.window.showInformationMessage( + 'No BYOE endpoints registered. Run `specsmith endpoints add` first.', + ); + return; + } + const picked = await vscode.window.showQuickPick( + list.endpoints.map((e) => ({ + label: e.id, + description: e.base_url, + detail: `auth=${e.auth.kind} model=${e.default_model || '(none)'}`, + })), + { placeHolder: 'Pick an endpoint to test' }, + ); + if (!picked) { return; } + endpointId = picked.label; + } + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: `Testing ${endpointId}\u2026` }, + async () => { + const result = testEndpoint(exec, endpointId!); + if (result.ok) { + void vscode.window.showInformationMessage( + `\u2713 ${result.id} ok in ${Math.round(result.latency_ms)} ms` + + ` (${result.models.length} model(s)).`, + ); + } else { + void vscode.window.showErrorMessage( + `\u2717 ${result.id} failed: ${result.error || '(unknown error)'}`, + ); + } + }, + ); +} + // ── Sessions TreeDataProvider ───────────────────────────────────────────────── class SessionItem extends vscode.TreeItem { diff --git a/src/test/endpoints-client.test.ts b/src/test/endpoints-client.test.ts new file mode 100644 index 0000000..651c125 --- /dev/null +++ b/src/test/endpoints-client.test.ts @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 BitConcepts, LLC. All rights reserved. +// +// Unit tests for the BYOE EndpointsClient module (REQ-142, PR-3). +// Covers the pure JSON parsers and the bridge arg helper. The CLI shell-out +// paths (listEndpoints / testEndpoint) are exercised through the parsers +// against fixtures that mirror what `specsmith endpoints list --json` / +// `specsmith endpoints test --json` actually emit (PR-1). + +import * as assert from 'assert'; + +import { + applyEndpointArg, + parseEndpointHealth, + parseEndpointsList, +} from '../EndpointsClient'; + +suite('EndpointsClient — parseEndpointsList', () => { + test('returns an empty result when the body is not JSON', () => { + const out = parseEndpointsList('{not json'); + assert.deepStrictEqual(out, { default_endpoint_id: '', endpoints: [] }); + }); + + test('returns an empty result when the JSON is not an object', () => { + assert.deepStrictEqual(parseEndpointsList('"hello"'), { + default_endpoint_id: '', + endpoints: [], + }); + assert.deepStrictEqual(parseEndpointsList('[1, 2]'), { + default_endpoint_id: '', + endpoints: [], + }); + }); + + test('parses a happy-path payload with a single endpoint', () => { + const out = parseEndpointsList( + JSON.stringify({ + default_endpoint_id: 'home-vllm', + endpoints: [ + { + id: 'home-vllm', + name: 'Home vLLM', + base_url: 'http://10.0.0.4:8000/v1', + default_model: 'qwen2.5-coder', + verify_tls: true, + tags: ['local', 'coder'], + created_at: '2026-05-01T11:30:17Z', + auth: { kind: 'bearer-keyring', keyring_user: 'endpoint:home-vllm' }, + }, + ], + }), + ); + assert.strictEqual(out.default_endpoint_id, 'home-vllm'); + assert.strictEqual(out.endpoints.length, 1); + const e = out.endpoints[0]; + assert.strictEqual(e.id, 'home-vllm'); + assert.strictEqual(e.base_url, 'http://10.0.0.4:8000/v1'); + assert.strictEqual(e.auth.kind, 'bearer-keyring'); + assert.strictEqual(e.auth.keyring_user, 'endpoint:home-vllm'); + assert.deepStrictEqual(e.tags, ['local', 'coder']); + }); + + test('redacts inline tokens — never surfaces secret bytes', () => { + // The CLI is supposed to redact already, but the parser must also tolerate + // payloads that already contain "***" without misinterpreting them. + const out = parseEndpointsList( + JSON.stringify({ + default_endpoint_id: 'a', + endpoints: [ + { + id: 'a', + name: 'a', + base_url: 'http://e/v1', + auth: { kind: 'bearer-inline', token: '***' }, + }, + ], + }), + ); + assert.strictEqual(out.endpoints[0].auth.token, '***'); + }); + + test('rejects unknown auth kinds by falling back to none', () => { + const out = parseEndpointsList( + JSON.stringify({ + endpoints: [ + { + id: 'x', name: 'x', base_url: 'http://e/v1', + auth: { kind: 'unsupported-future-kind' }, + }, + ], + }), + ); + assert.strictEqual(out.endpoints[0].auth.kind, 'none'); + }); + + test('skips non-object items gracefully', () => { + const out = parseEndpointsList( + JSON.stringify({ endpoints: ['not an object', null, 42] }), + ); + assert.deepStrictEqual(out.endpoints, []); + }); +}); + +suite('EndpointsClient — parseEndpointHealth', () => { + test('parses an ok=true response', () => { + const out = parseEndpointHealth( + JSON.stringify({ + id: 'fake', ok: true, latency_ms: 23.4, + models: ['m1', 'm2'], error: '', status_code: 200, + }), + ); + assert.deepStrictEqual(out, { + id: 'fake', + ok: true, + latency_ms: 23.4, + models: ['m1', 'm2'], + error: '', + status_code: 200, + }); + }); + + test('parses an ok=false response with an error string', () => { + const out = parseEndpointHealth( + JSON.stringify({ + id: 'ghost', ok: false, latency_ms: 0, + models: [], error: 'connection refused', status_code: null, + }), + ); + assert.strictEqual(out.ok, false); + assert.strictEqual(out.error, 'connection refused'); + }); + + test('returns a sensible default when the body is malformed', () => { + const out = parseEndpointHealth('not json'); + assert.strictEqual(out.ok, false); + assert.strictEqual(out.models.length, 0); + assert.strictEqual(out.error, 'not json'); + }); +}); + +suite('EndpointsClient — applyEndpointArg', () => { + test('returns a copy of the args when endpointId is undefined', () => { + const base = ['run', '--json-events']; + const out = applyEndpointArg(base, undefined); + assert.deepStrictEqual(out, base); + assert.notStrictEqual(out, base, 'should return a fresh array'); + }); + + test('returns a copy of the args when endpointId is empty', () => { + assert.deepStrictEqual(applyEndpointArg(['run'], ''), ['run']); + assert.deepStrictEqual(applyEndpointArg(['run'], ' '), ['run']); + }); + + test('appends --endpoint when endpointId is set', () => { + assert.deepStrictEqual( + applyEndpointArg(['run', '--json-events'], 'home-vllm'), + ['run', '--json-events', '--endpoint', 'home-vllm'], + ); + }); + + test('trims surrounding whitespace from the endpoint id', () => { + assert.deepStrictEqual( + applyEndpointArg(['run'], ' home-vllm '), + ['run', '--endpoint', 'home-vllm'], + ); + }); +}); diff --git a/src/test/welcome-panel.test.ts b/src/test/welcome-panel.test.ts index f5657f1..b4c2bb6 100644 --- a/src/test/welcome-panel.test.ts +++ b/src/test/welcome-panel.test.ts @@ -1,15 +1,15 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 BitConcepts, LLC. All rights reserved. // -// Lightweight integrity tests for the 0.7.0 extension contributions. +// Lightweight integrity tests for the extension contributions surface. // // These tests live outside the Extension Host (plain Mocha), so they can't // instantiate the WelcomePanel webview directly. Instead they verify the // contract surface that downstream tooling (vsce package, the marketplace, // the activitybar) actually relies on: // -// * package.json declares each new command + view we added in 0.7.0 -// * package.json version was bumped to 0.7.0 +// * package.json declares the historical 0.7.0 commands + views +// * package.json version is at least 0.7.0 (forward-compatible check) // * the built `out/extension.js` bundle includes the new helper modules import * as assert from 'assert'; @@ -34,11 +34,18 @@ interface CommandEntry { interface ViewEntry { id: string; } - -suite('Extension contributions — 0.7.0 surface', () => { - test('package.json reports version 0.7.0', () => { +suite('Extension contributions \u2014 0.7.0 surface', () => { + test('package.json reports a version >= 0.7.0', () => { const pkg = readPackageJson(); - assert.strictEqual(pkg.version, '0.7.0'); + const ver = String(pkg.version ?? ''); + const m = ver.match(/^(\d+)\.(\d+)\.(\d+)/); + assert.ok(m, `package.json version is malformed: ${ver}`); + const [maj, min, patch] = m.slice(1).map(Number); + const ok = maj > 0 || (maj === 0 && (min > 7 || (min === 7 && patch >= 0))); + assert.ok( + ok, + `package.json version ${ver} must remain >= 0.7.0 (forward-compatible)`, + ); }); test('declares all new 0.7.0 commands', () => { diff --git a/src/types.ts b/src/types.ts index ce3bb28..23b36b9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -144,4 +144,10 @@ export interface SessionConfig { provider: string; model: string; sessionId: string; + /** Optional registered BYOE endpoint id (REQ-142). When set, the bridge + * appends `--endpoint ` to `specsmith run`, which routes the LLM + * turn to the OpenAI-v1-compatible endpoint instead of the auto-detect + * provider chain. The id resolution lives in the CLI; the extension + * just passes the string through. */ + endpointId?: string; }