Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { apiKey?: string }> | undefined
)?.[providerId];
if (entry?.apiKey) return entry.apiKey;
const secretKeys: Record<string, string> = {
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();
Expand Down
90 changes: 74 additions & 16 deletions src/ui/chat-view-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string | undefined>)
| undefined;
private userMessageCallback: ((text: string) => void) | undefined;
private webviewReadyCallback: (() => void) | undefined;
private streamCompletedCallback:
Expand Down Expand Up @@ -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<string | undefined>,
): void {
this.apiKeyResolver = resolver;
}

/**
* Attach a skill registry. When set, user messages starting with
* `/<name>` are looked up in the registry and the matching skill's
Expand Down Expand Up @@ -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<string, string> = {};
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<string, unknown>;
Expand All @@ -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
Expand Down Expand Up @@ -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<void> {
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.
Expand Down
144 changes: 144 additions & 0 deletions test/unit/ui/chat-view-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>;
readFile: ReturnType<typeof vi.fn>;
};
};
}
).workspace;
const writeFile = ws.fs.writeFile as ReturnType<typeof vi.fn>;
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<typeof vi.fn>;
};
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<string | undefined>,
) => 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<string, string> },
];
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();
});
});
});
Loading