diff --git a/src/extension.ts b/src/extension.ts index 87bc198..dc369b6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -969,6 +969,28 @@ export async function activate( }, (template, ctx) => VariableResolver.resolve(template, ctx), ); + // Resolve a provider's apiKey for the model-discovery probe (YAML config, + // then SecretStorage/env fallback). Key-required endpoints (MLX, + // openai-compatible, vLLM) would otherwise return 401 -> "0 models found". + chatViewProvider.setApiKeyResolver(async (providerId) => { + const cfg = await resolveConfig(); + const entry = ( + cfg?.providers as Record | undefined + )?.[providerId]; + if (entry?.apiKey) return entry.apiKey; + const secretKeys: Record = { + claude: "champ.claude.apiKey", + openai: "champ.openai.apiKey", + gemini: "champ.gemini.apiKey", + "openai-compatible": "champ.openaiCompatible.apiKey", + vllm: "champ.vllm.apiKey", + }; + const secretKey = secretKeys[providerId]; + if (secretKey) { + return (await context.secrets.get(secretKey)) || undefined; + } + return undefined; + }); // Auto-label sessions + Smart Router model selection. chatViewProvider.onUserMessage((text) => { const active = agentManager?.getActive(); diff --git a/src/ui/chat-view-provider.ts b/src/ui/chat-view-provider.ts index f2421e4..932f5e9 100644 --- a/src/ui/chat-view-provider.ts +++ b/src/ui/chat-view-provider.ts @@ -10,7 +10,9 @@ */ import * as vscode from "vscode"; import * as path from "path"; +import * as os from "os"; import { execFile } from "child_process"; +import { upsertProviderInYaml } from "../config/yaml-writer"; import { type AgentController, PromptInjectionError, @@ -177,6 +179,9 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { private skillRegistry: ChatSkillRegistry | undefined; private skillContextProvider: SkillContextProvider | undefined; private skillVariableResolver: SkillVariableResolver | undefined; + private apiKeyResolver: + | ((providerId: string) => Promise) + | undefined; private userMessageCallback: ((text: string) => void) | undefined; private webviewReadyCallback: (() => void) | undefined; private streamCompletedCallback: @@ -249,6 +254,18 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { this.contextResolver = resolver; } + /** + * Attach a resolver that yields the apiKey for a provider id (from YAML + * config / SecretStorage). Used by the model-discovery probe so + * key-required endpoints (e.g. MLX/OpenAI-compatible) return models + * instead of a 401 -> "0 models found" (#123). + */ + setApiKeyResolver( + resolver: (providerId: string) => Promise, + ): void { + this.apiKeyResolver = resolver; + } + /** * Attach a skill registry. When set, user messages starting with * `/` are looked up in the registry and the matching skill's @@ -460,8 +477,17 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { const endpoint = isOllama ? `${msg.baseUrl.replace(/\/$/, "")}/api/tags` : `${msg.baseUrl.replace(/\/$/, "")}/v1/models`; + // Key-required endpoints (MLX/OpenAI-compatible, vLLM) return 401 + // without a Bearer key — attach the provider's stored key so the + // probe returns models instead of "0 models found" (#123). + const headers: Record = {}; + if (!isOllama && this.apiKeyResolver) { + const key = await this.apiKeyResolver(msg.provider); + if (key) headers.Authorization = `Bearer ${key}`; + } const resp = await fetch(endpoint, { signal: AbortSignal.timeout(5000), + headers, }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const body = (await resp.json()) as Record; @@ -486,25 +512,15 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { }); } } else if (isSaveSettingsRequest(msg)) { - // Update provider and model in VS Code global settings, then reload. - const config = vscode.workspace.getConfiguration("champ"); - await config.update( - "provider", + // Persist provider/model/baseUrl into .champ/config.yaml (the YAML-only + // config source since #118), then reload. The legacy champ.* VS Code + // settings were removed from package.json, so writing them here throws + // "not a registered configuration" (#123). + await this.persistProviderSettings( msg.provider, - vscode.ConfigurationTarget.Global, - ); - await config.update( - `${msg.provider}.model`, msg.model, - vscode.ConfigurationTarget.Global, + msg.baseUrl, ); - if (msg.baseUrl) { - await config.update( - `${msg.provider}.baseUrl`, - msg.baseUrl, - vscode.ConfigurationTarget.Global, - ); - } await vscode.commands.executeCommand("champ.reloadProvider"); } else if (isCopyToClipboardRequest(msg)) { // navigator.clipboard is blocked in VS Code webviews — route through extension host @@ -818,6 +834,48 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { } as never); } + /** + * Persist a provider selection (from the in-webview settings overlay or + * Add-Model dialogue) into .champ/config.yaml (#123). The YAML file is + * the single config source since #118; the legacy champ.* VS Code + * settings no longer exist. Targets the workspace .champ/config.yaml, + * falling back to ~/.champ/config.yaml when no workspace is open. + */ + private async persistProviderSettings( + providerId: string, + model: string, + baseUrl?: string, + ): Promise { + const root = + vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? os.homedir(); + const dirUri = vscode.Uri.file(path.join(root, ".champ")); + const fileUri = vscode.Uri.file(path.join(root, ".champ", "config.yaml")); + + let previousText: string | null = null; + try { + previousText = new TextDecoder().decode( + await vscode.workspace.fs.readFile(fileUri), + ); + } catch { + previousText = null; // no file yet + } + + const { yaml: updated } = upsertProviderInYaml( + previousText, + { providerId, baseUrl, model }, + { setActive: true }, + ); + try { + await vscode.workspace.fs.createDirectory(dirUri); + } catch { + // already exists + } + await vscode.workspace.fs.writeFile( + fileUri, + new TextEncoder().encode(updated), + ); + } + /** * Run a shell command (requested from a webview bash code-block "Run" button) * and stream stdout chunks back to the webview as TerminalOutputChunkMessage. diff --git a/test/unit/ui/chat-view-provider.test.ts b/test/unit/ui/chat-view-provider.test.ts index cbd9aea..57924b8 100644 --- a/test/unit/ui/chat-view-provider.test.ts +++ b/test/unit/ui/chat-view-provider.test.ts @@ -955,4 +955,148 @@ describe("ChatViewProvider", () => { expect(msg.activeSessionId).toBe("s1"); }); }); + + describe("save settings to YAML instead of VS Code settings (#123)", () => { + it("persists provider/model/baseUrl to .champ/config.yaml on saveSettings", async () => { + const vscode = await import("vscode"); + const ws = ( + vscode as unknown as { + workspace: { + fs: { + writeFile: ReturnType; + readFile: ReturnType; + }; + }; + } + ).workspace; + const writeFile = ws.fs.writeFile as ReturnType; + writeFile.mockClear(); + + const view = createMockWebviewView(postMessage); + provider.resolveWebviewView(view as never, {} as never, {} as never); + + view.fireMessage({ + type: "saveSettings", + provider: "openai-compatible", + model: "some-model", + baseUrl: "http://192.168.1.5:8000/v1", + }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(writeFile).toHaveBeenCalled(); + const [uri, bytes] = writeFile.mock.calls[0] as [unknown, Uint8Array]; + expect(String((uri as { fsPath: string }).fsPath)).toContain( + ".champ/config.yaml", + ); + const text = new TextDecoder().decode(bytes); + expect(text).toContain("provider: openai-compatible"); + expect(text).toContain("baseUrl: http://192.168.1.5:8000/v1"); + expect(text).toContain("model: some-model"); + }); + + it("does not call config.update for provider/model/baseUrl on saveSettings", async () => { + const vscode = await import("vscode"); + const cfg = vscode.workspace.getConfiguration("champ") as { + update: ReturnType; + }; + cfg.update.mockClear(); + + const view = createMockWebviewView(postMessage); + provider.resolveWebviewView(view as never, {} as never, {} as never); + + view.fireMessage({ + type: "saveSettings", + provider: "ollama", + model: "llama3.1", + baseUrl: "http://localhost:11434", + }); + await new Promise((resolve) => setImmediate(resolve)); + + const updates = cfg.update.mock.calls + .map((c) => String(c[0])) + .filter( + (k) => + k === "provider" || k.endsWith(".model") || k.endsWith(".baseUrl"), + ); + expect(updates).toEqual([]); + }); + }); + + describe("model discovery attaches the provider apiKey (#123)", () => { + it("sends an Authorization Bearer header on the /v1/models probe", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [{ id: "mlx-community--Qwen3-1.7B-4bit" }], + }), + }); + vi.stubGlobal("fetch", fetchMock); + + // Attach a key resolver so a known provider key is sent. + ( + provider as unknown as { + setApiKeyResolver: ( + cb: (id: string) => Promise, + ) => void; + } + ).setApiKeyResolver(() => Promise.resolve("dummy")); + + const view = createMockWebviewView(postMessage); + provider.resolveWebviewView(view as never, {} as never, {} as never); + + view.fireMessage({ + type: "discoverModels", + provider: "openai-compatible", + baseUrl: "http://192.168.1.5:8000", + }); + await new Promise((resolve) => setImmediate(resolve)); + + const [url, init] = fetchMock.mock.calls[0] as [ + string, + { headers?: Record }, + ]; + expect(url).toContain("/v1/models"); + expect(init.headers?.Authorization).toBe("Bearer dummy"); + + const posts = postMessage.mock.calls.filter( + (args) => (args[0] as { type: string }).type === "discoveredModels", + ); + expect(posts).toHaveLength(1); + const msg = posts[0][0] as { models: Array<{ name: string }> }; + expect(msg.models[0].name).toBe("mlx-community--Qwen3-1.7B-4bit"); + }); + + it("declares the endpoint as '0 models' when no key is attached and the server 401s", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + }); + vi.stubGlobal("fetch", fetchMock); + + const view = createMockWebviewView(postMessage); + provider.resolveWebviewView(view as never, {} as never, {} as never); + + view.fireMessage({ + type: "discoverModels", + provider: "openai-compatible", + baseUrl: "http://192.168.1.5:8000", + }); + await new Promise((resolve) => setImmediate(resolve)); + + const posts = postMessage.mock.calls.filter( + (args) => (args[0] as { type: string }).type === "discoveredModels", + ); + expect(posts).toHaveLength(1); + const msg = posts[0][0] as { + models: Array<{ name: string }>; + error?: string; + }; + expect(msg.models).toEqual([]); + expect(msg.error).toContain("401"); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + }); });