From 6eef8c93752bb3316af9a4a1983006f9153400dd Mon Sep 17 00:00:00 2001 From: John Hu Date: Sat, 11 Jul 2026 07:20:40 +0800 Subject: [PATCH 01/12] feat(providers): provider registry with bailian preset and custom OpenAI-compatible providers Minerva only knew anthropic/openai as a hardcoded union, so pointing it at Alibaba Bailian (DashScope) or any other OpenAI-compatible endpoint was impossible. Replace the union with a data-driven registry: built-ins plus user-defined providers (name + baseUrl + apiKeyEnv + defaultModel), with bailian shipped as a preset. Overriding a built-in keeps its kind so an endpoint override (e.g. the intl DashScope host) can't change the wire protocol. Uses @ai-sdk/openai-compatible rather than createOpenAI({baseURL}) because @ai-sdk/openai v4 routes the callable provider to the Responses API, which compatible endpoints like DashScope don't implement, and its chat path emits strict-JSON-schema fields they commonly reject. resolveApiKey centralizes key precedence (explicit > env var > stored key) ahead of the settings-stored keys added in the next slices. --- bun.lock | 3 + packages/providers/package.json | 1 + packages/providers/src/ai-sdk.ts | 6 +- packages/providers/src/registry.ts | 176 +++++++++++++++++++++-- packages/providers/test/registry.test.ts | 81 ++++++++++- 5 files changed, 250 insertions(+), 17 deletions(-) diff --git a/bun.lock b/bun.lock index 5044d06..71d5015 100644 --- a/bun.lock +++ b/bun.lock @@ -66,6 +66,7 @@ "dependencies": { "@ai-sdk/anthropic": "^4", "@ai-sdk/openai": "^4", + "@ai-sdk/openai-compatible": "^3", "ai": "^7", "zod": "^4", }, @@ -78,6 +79,8 @@ "@ai-sdk/openai": ["@ai-sdk/openai@4.0.11", "", { "dependencies": { "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.7" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2PLnVPBsdJusgquPVYgPWd3ox4lbUFkp7DVUSmM0lQ4VISQoNxdgo6TvFAHgwQn9wJ/ckVYVRLU7+BvHc7T8ww=="], + "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@3.0.7", "", { "dependencies": { "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.7" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y8RVpm8BZKNHDxlbNVt/GYmoBBqLx10m4d7bOzOnRD8V2aP3Cr6/g/bfubR4EkbW1ySrnPr62svZIeND2fdp3Q=="], + "@ai-sdk/provider": ["@ai-sdk/provider@4.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw=="], "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.7", "", { "dependencies": { "@ai-sdk/provider": "4.0.3", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OSm5/5kdrHa11WIOo5LYgDKnxYWp5aB/wx5EXRHi0jpUGduMDeB6oht9U6p+UNNWIP3F/EqPpV8d7vdP/iRnqg=="], diff --git a/packages/providers/package.json b/packages/providers/package.json index 53f7705..541228e 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -9,6 +9,7 @@ "dependencies": { "@ai-sdk/anthropic": "^4", "@ai-sdk/openai": "^4", + "@ai-sdk/openai-compatible": "^3", "ai": "^7", "zod": "^4" } diff --git a/packages/providers/src/ai-sdk.ts b/packages/providers/src/ai-sdk.ts index adbbc71..6ae4a17 100644 --- a/packages/providers/src/ai-sdk.ts +++ b/packages/providers/src/ai-sdk.ts @@ -26,11 +26,15 @@ export const DEFAULT_ANTHROPIC_MODEL = "claude-opus-4-8"; export interface AnthropicProviderOptions { model?: string; apiKey?: string; + baseURL?: string; } export function createAnthropicProvider(options: AnthropicProviderOptions = {}): ModelProvider { const modelId = options.model ?? DEFAULT_ANTHROPIC_MODEL; - const anthropic = createAnthropic(options.apiKey ? { apiKey: options.apiKey } : {}); + const anthropic = createAnthropic({ + ...(options.apiKey ? { apiKey: options.apiKey } : {}), + ...(options.baseURL ? { baseURL: options.baseURL } : {}), + }); return createAiSdkProvider(anthropic(modelId), `anthropic/${modelId}`); } diff --git a/packages/providers/src/registry.ts b/packages/providers/src/registry.ts index db31515..a0fc3c9 100644 --- a/packages/providers/src/registry.ts +++ b/packages/providers/src/registry.ts @@ -1,36 +1,126 @@ import { createOpenAI } from "@ai-sdk/openai"; +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import { createAiSdkProvider, createAnthropicProvider, DEFAULT_ANTHROPIC_MODEL } from "./ai-sdk"; import type { ModelProvider } from "./types"; /** * Model references select both provider and model: "openai/gpt-5.2", - * "anthropic/claude-opus-4-8". A bare model id defaults to Anthropic, - * keeping slice-1 invocations (--model claude-opus-4-8) working. + * "bailian/qwen-plus". A bare model id defaults to Anthropic, keeping + * slice-1 invocations (--model claude-opus-4-8) working. + * + * Providers are a registry: built-ins plus user-defined OpenAI-compatible + * endpoints from settings (see buildProviderRegistry). */ export const DEFAULT_OPENAI_MODEL = "gpt-5.2"; -export type ProviderName = "anthropic" | "openai"; +export type ProviderKind = "anthropic" | "openai" | "openai-compatible"; + +export interface ProviderDef { + kind: ProviderKind; + /** Environment variable consulted for the API key, e.g. DASHSCOPE_API_KEY. */ + apiKeyEnv: string; + /** Endpoint; required for openai-compatible, optional override for the rest. */ + baseURL?: string | undefined; + /** Prefill for the config panel; never used to complete bare refs. */ + defaultModel?: string | undefined; +} + +export type ProviderRegistry = Record; + +export const BUILTIN_PROVIDERS: ProviderRegistry = { + anthropic: { + kind: "anthropic", + apiKeyEnv: "ANTHROPIC_API_KEY", + defaultModel: DEFAULT_ANTHROPIC_MODEL, + }, + openai: { + kind: "openai", + apiKeyEnv: "OPENAI_API_KEY", + defaultModel: DEFAULT_OPENAI_MODEL, + }, + // Alibaba Bailian (DashScope), OpenAI-compatible mode. China endpoint; + // override baseUrl in settings for dashscope-intl.aliyuncs.com. + bailian: { + kind: "openai-compatible", + apiKeyEnv: "DASHSCOPE_API_KEY", + baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", + defaultModel: "qwen-plus", + }, +}; + +/** A provider entry as written in settings.json. */ +export interface CustomProviderConfig { + baseUrl?: string | undefined; + apiKeyEnv?: string | undefined; + defaultModel?: string | undefined; +} + +// Names must survive ref parsing ("name/model") and env-var derivation. +const PROVIDER_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; + +/** + * Merge user-defined providers over the built-ins. Overriding a built-in + * keeps its kind (an endpoint override must not change the wire protocol); + * a new name defines an OpenAI-compatible provider and requires a baseUrl. + */ +export function buildProviderRegistry( + custom?: Record, +): ProviderRegistry { + const registry: ProviderRegistry = { ...BUILTIN_PROVIDERS }; + for (const [name, config] of Object.entries(custom ?? {})) { + const builtin = BUILTIN_PROVIDERS[name]; + if (builtin) { + registry[name] = { + ...builtin, + ...(config.baseUrl !== undefined ? { baseURL: config.baseUrl } : {}), + ...(config.apiKeyEnv !== undefined ? { apiKeyEnv: config.apiKeyEnv } : {}), + ...(config.defaultModel !== undefined ? { defaultModel: config.defaultModel } : {}), + }; + continue; + } + if (!PROVIDER_NAME_PATTERN.test(name)) { + throw new Error( + `invalid provider name "${name}" — use lowercase letters, digits, and dashes`, + ); + } + if (!config.baseUrl) { + throw new Error(`custom provider "${name}" needs a baseUrl (an OpenAI-compatible endpoint)`); + } + registry[name] = { + kind: "openai-compatible", + apiKeyEnv: config.apiKeyEnv ?? `${name.toUpperCase().replaceAll("-", "_")}_API_KEY`, + baseURL: config.baseUrl, + ...(config.defaultModel !== undefined ? { defaultModel: config.defaultModel } : {}), + }; + } + return registry; +} export interface ModelRef { - provider: ProviderName; + provider: string; model: string; } -export function parseModelRef(ref: string): ModelRef { +export function parseModelRef( + ref: string, + providers: ProviderRegistry = BUILTIN_PROVIDERS, +): ModelRef { const slash = ref.indexOf("/"); if (slash === -1) { // "--model openai" would otherwise become an Anthropic model literally // named "openai" and fail confusingly at request time. - if (ref === "anthropic" || ref === "openai") { + if (providers[ref]) { throw new Error(`"${ref}" is a provider, not a model — use ${ref}/`); } return { provider: "anthropic", model: ref }; } const provider = ref.slice(0, slash); const model = ref.slice(slash + 1); - if (provider !== "anthropic" && provider !== "openai") { - throw new Error(`unknown provider "${provider}" — supported: anthropic, openai`); + if (!providers[provider]) { + throw new Error( + `unknown provider "${provider}" — supported: ${Object.keys(providers).join(", ")}`, + ); } if (!model) { throw new Error(`model reference "${ref}" is missing a model id`); @@ -38,29 +128,85 @@ export function parseModelRef(ref: string): ModelRef { return { provider, model }; } +function providerDef(provider: string, providers: ProviderRegistry): ProviderDef { + const def = providers[provider]; + if (!def) { + throw new Error( + `unknown provider "${provider}" — supported: ${Object.keys(providers).join(", ")}`, + ); + } + return def; +} + /** The environment variable holding the API key for a provider. */ -export function apiKeyEnvVar(provider: ProviderName): string { - return provider === "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY"; +export function apiKeyEnvVar( + provider: string, + providers: ProviderRegistry = BUILTIN_PROVIDERS, +): string { + return providerDef(provider, providers).apiKeyEnv; +} + +export interface ResolveApiKeyOptions { + /** Key passed programmatically (e.g. just entered in the config panel). */ + explicit?: string | undefined; + env?: Record; + /** Keys persisted in global settings, by provider name. */ + storedKeys?: Record; +} + +/** Precedence: explicit > provider's env var > key stored in settings. */ +export function resolveApiKey( + provider: string, + providers: ProviderRegistry, + options: ResolveApiKeyOptions = {}, +): string | undefined { + const def = providerDef(provider, providers); + const env = options.env ?? process.env; + return options.explicit ?? env[def.apiKeyEnv] ?? options.storedKeys?.[provider]; } export interface ProviderFromRefOptions { apiKey?: string; + providers?: ProviderRegistry; } export function createProviderFromRef( ref: string, options: ProviderFromRefOptions = {}, ): ModelProvider { - const { provider, model } = parseModelRef(ref); - switch (provider) { + const providers = options.providers ?? BUILTIN_PROVIDERS; + const { provider, model } = parseModelRef(ref, providers); + const def = providerDef(provider, providers); + switch (def.kind) { case "openai": { - const openai = createOpenAI(options.apiKey ? { apiKey: options.apiKey } : {}); + const openai = createOpenAI({ + ...(options.apiKey ? { apiKey: options.apiKey } : {}), + ...(def.baseURL ? { baseURL: def.baseURL } : {}), + }); const modelId = model || DEFAULT_OPENAI_MODEL; - return createAiSdkProvider(openai(modelId), `openai/${modelId}`); + return createAiSdkProvider(openai(modelId), `${provider}/${modelId}`); + } + case "openai-compatible": { + if (!def.baseURL) { + throw new Error(`provider "${provider}" needs a baseUrl`); + } + const compatible = createOpenAICompatible({ + name: provider, + baseURL: def.baseURL, + // DashScope and most compatible endpoints only report token usage + // on streams when explicitly asked (stream_options.include_usage). + includeUsage: true, + ...(options.apiKey ? { apiKey: options.apiKey } : {}), + }); + return createAiSdkProvider(compatible(model), `${provider}/${model}`); } default: { const modelId = model || DEFAULT_ANTHROPIC_MODEL; - return createAnthropicProvider({ model: modelId, ...options }); + return createAnthropicProvider({ + model: modelId, + ...(options.apiKey !== undefined ? { apiKey: options.apiKey } : {}), + ...(def.baseURL !== undefined ? { baseURL: def.baseURL } : {}), + }); } } } diff --git a/packages/providers/test/registry.test.ts b/packages/providers/test/registry.test.ts index df79cfc..a10960d 100644 --- a/packages/providers/test/registry.test.ts +++ b/packages/providers/test/registry.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { apiKeyEnvVar, createProviderFromRef, parseModelRef } from "../src"; +import { + apiKeyEnvVar, + buildProviderRegistry, + createProviderFromRef, + parseModelRef, + resolveApiKey, +} from "../src"; describe("model references", () => { test("bare model ids default to anthropic", () => { @@ -15,16 +21,33 @@ describe("model references", () => { provider: "anthropic", model: "claude-opus-4-8", }); + expect(parseModelRef("bailian/qwen-plus")).toEqual({ + provider: "bailian", + model: "qwen-plus", + }); }); test("unknown providers and empty models are rejected", () => { expect(() => parseModelRef("mistral/large")).toThrow("unknown provider"); + expect(() => parseModelRef("mistral/large")).toThrow("bailian"); expect(() => parseModelRef("openai/")).toThrow("missing a model id"); }); test("a bare provider name is rejected with a hint", () => { expect(() => parseModelRef("openai")).toThrow("openai/"); expect(() => parseModelRef("anthropic")).toThrow("anthropic/"); + expect(() => parseModelRef("bailian")).toThrow("bailian/"); + }); + + test("custom registry providers parse in refs", () => { + const providers = buildProviderRegistry({ + deepseek: { baseUrl: "https://api.deepseek.com/v1" }, + }); + expect(parseModelRef("deepseek/deepseek-chat", providers)).toEqual({ + provider: "deepseek", + model: "deepseek-chat", + }); + expect(() => parseModelRef("deepseek", providers)).toThrow("deepseek/"); }); test("createProviderFromRef builds ids with the provider prefix", () => { @@ -34,10 +57,66 @@ describe("model references", () => { expect(createProviderFromRef("claude-opus-4-8", { apiKey: "sk-test" }).id).toBe( "anthropic/claude-opus-4-8", ); + expect(createProviderFromRef("bailian/qwen-plus", { apiKey: "sk-test" }).id).toBe( + "bailian/qwen-plus", + ); }); test("each provider maps to its key env var", () => { expect(apiKeyEnvVar("anthropic")).toBe("ANTHROPIC_API_KEY"); expect(apiKeyEnvVar("openai")).toBe("OPENAI_API_KEY"); + expect(apiKeyEnvVar("bailian")).toBe("DASHSCOPE_API_KEY"); + }); +}); + +describe("provider registry", () => { + test("custom providers default kind, apiKeyEnv, and merge over builtins", () => { + const providers = buildProviderRegistry({ + "my-proxy": { baseUrl: "https://llm.example.com/v1", defaultModel: "some-model" }, + }); + expect(providers["my-proxy"]).toEqual({ + kind: "openai-compatible", + apiKeyEnv: "MY_PROXY_API_KEY", + baseURL: "https://llm.example.com/v1", + defaultModel: "some-model", + }); + }); + + test("overriding a builtin keeps its kind (intl bailian endpoint)", () => { + const providers = buildProviderRegistry({ + bailian: { baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" }, + }); + expect(providers.bailian).toEqual({ + kind: "openai-compatible", + apiKeyEnv: "DASHSCOPE_API_KEY", + baseURL: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + defaultModel: "qwen-plus", + }); + }); + + test("a custom provider without a baseUrl is rejected", () => { + expect(() => buildProviderRegistry({ deepseek: {} })).toThrow("needs a baseUrl"); + }); + + test("invalid provider names are rejected", () => { + expect(() => buildProviderRegistry({ "Bad Name": { baseUrl: "https://x" } })).toThrow( + "invalid provider name", + ); + expect(() => buildProviderRegistry({ "1x": { baseUrl: "https://x" } })).toThrow( + "invalid provider name", + ); + }); + + test("resolveApiKey precedence: explicit > env > stored", () => { + const providers = buildProviderRegistry(); + const env = { DASHSCOPE_API_KEY: "from-env" }; + const storedKeys = { bailian: "from-settings" }; + expect(resolveApiKey("bailian", providers, { explicit: "from-panel", env, storedKeys })).toBe( + "from-panel", + ); + expect(resolveApiKey("bailian", providers, { env, storedKeys })).toBe("from-env"); + expect(resolveApiKey("bailian", providers, { env: {}, storedKeys })).toBe("from-settings"); + expect(resolveApiKey("bailian", providers, { env: {} })).toBeUndefined(); + expect(() => resolveApiKey("nope", providers, { env: {} })).toThrow("unknown provider"); }); }); From 3704b959107651fc592a8097be2a0fb74d561727 Mon Sep 17 00:00:00 2001 From: John Hu Date: Sat, 11 Jul 2026 07:22:37 +0800 Subject: [PATCH 02/12] feat(kernel): model/provider settings schema and 0600 global persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings gain a default model ref and per-provider entries (baseUrl, apiKeyEnv, defaultModel, apiKey) so the upcoming config panel has somewhere durable to write. API keys are honored only from the global layer — a project settings file is shareable/committable, so a key found there is stripped during merge rather than trusted. Runtime.writeTextFile learns an optional mode, and the new updateGlobalSettings helper always writes the global file as 0600: writeFile's mode applies only at creation, so an explicit chmod also tightens files that predate key storage. defaultDataDir is extracted so the CLI can compute the same ~/.minerva path as the kernel. --- packages/kernel/src/kernel.ts | 4 +- packages/kernel/src/runtime.ts | 17 ++++- packages/kernel/src/settings.ts | 54 +++++++++++++++ packages/kernel/test/settings.test.ts | 98 +++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 packages/kernel/test/settings.test.ts diff --git a/packages/kernel/src/kernel.ts b/packages/kernel/src/kernel.ts index f22a772..c272784 100644 --- a/packages/kernel/src/kernel.ts +++ b/packages/kernel/src/kernel.ts @@ -26,7 +26,7 @@ import { connectMcpServers, type McpConnection } from "./mcp"; import { isSessionModeId, SESSION_MODES } from "./permissions"; import { defaultRuntime, type Runtime } from "./runtime"; import { parseEventLog, projectDir, Session } from "./session"; -import { loadSettings } from "./settings"; +import { defaultDataDir, loadSettings } from "./settings"; import { builtinTools, type KernelTool } from "./tools"; export interface KernelOptions { @@ -56,7 +56,7 @@ export class MinervaKernel { constructor(transport: Transport, options: KernelOptions) { this.#provider = options.provider; this.#runtime = options.runtime ?? defaultRuntime; - this.#dataDir = options.dataDir ?? join(this.#runtime.homedir(), ".minerva"); + this.#dataDir = options.dataDir ?? defaultDataDir(this.#runtime); this.#tools = options.tools ?? builtinTools(); this.#systemPrompt = options.systemPrompt ?? defaultSystemPrompt; diff --git a/packages/kernel/src/runtime.ts b/packages/kernel/src/runtime.ts index bd562df..da03447 100644 --- a/packages/kernel/src/runtime.ts +++ b/packages/kernel/src/runtime.ts @@ -6,7 +6,7 @@ */ import { spawn } from "node:child_process"; -import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { appendFile, chmod, mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; export interface ExecResult { @@ -23,9 +23,14 @@ export interface ExecOptions { signal?: AbortSignal | undefined; } +export interface WriteTextFileOptions { + /** Unix permission bits to enforce on the file (e.g. 0o600 for secrets). */ + mode?: number | undefined; +} + export interface Runtime { readTextFile(path: string): Promise; - writeTextFile(path: string, content: string): Promise; + writeTextFile(path: string, content: string, options?: WriteTextFileOptions): Promise; appendTextFile(path: string, content: string): Promise; mkdirp(path: string): Promise; exec(command: string, options: ExecOptions): Promise; @@ -45,7 +50,13 @@ const PIPE_GRACE_MS = 1_000; export const defaultRuntime: Runtime = { readTextFile: (path) => readFile(path, "utf8"), - writeTextFile: (path, content) => writeFile(path, content, "utf8"), + writeTextFile: async (path, content, options) => { + const mode = options?.mode; + await writeFile(path, content, { encoding: "utf8", ...(mode !== undefined ? { mode } : {}) }); + // writeFile's mode only applies when it creates the file; chmod covers + // pre-existing files that must be tightened (e.g. stored API keys). + if (mode !== undefined) await chmod(path, mode); + }, appendTextFile: (path, content) => appendFile(path, content, "utf8"), mkdirp: async (path) => { await mkdir(path, { recursive: true }); diff --git a/packages/kernel/src/settings.ts b/packages/kernel/src/settings.ts index 906f530..9c2d2bc 100644 --- a/packages/kernel/src/settings.ts +++ b/packages/kernel/src/settings.ts @@ -21,16 +21,30 @@ export interface McpServerConfig { env?: Record; } +/** A model provider entry — built-in override or custom OpenAI-compatible endpoint. */ +export interface ProviderSettings { + baseUrl?: string | undefined; + apiKeyEnv?: string | undefined; + defaultModel?: string | undefined; + /** Only honored in the GLOBAL layer; project-layer keys are ignored. */ + apiKey?: string | undefined; +} + export interface MinervaSettings { permissions?: Partial; defaultMode?: string | undefined; mcpServers?: Record; + /** Default model ref, e.g. "bailian/qwen-plus". */ + model?: string | undefined; + providers?: Record; } export interface ResolvedSettings { rules: PermissionRules; defaultMode?: string | undefined; mcpServers: Record; + model?: string | undefined; + providers: Record; } export function globalSettingsPath(dataDir: string): string { @@ -57,9 +71,28 @@ export async function loadSettings( defaultMode: project.defaultMode ?? global.defaultMode, // Per-name override: a project redefining "github" replaces the global one. mcpServers: { ...global.mcpServers, ...project.mcpServers }, + model: project.model ?? global.model, + providers: mergeProviders(global.providers, project.providers), }; } +/** + * Per-name shallow merge, project over global — except API keys, which are + * global-only: a shared project settings file must never carry (or shadow) + * a secret. + */ +function mergeProviders( + global: Record | undefined, + project: Record | undefined, +): Record { + const merged: Record = { ...global }; + for (const [name, { apiKey: _ignored, ...entry }] of Object.entries(project ?? {})) { + const base = merged[name]; + merged[name] = base ? { ...base, ...entry } : entry; + } + return merged; +} + /** Persist an "always allow" decision into the project settings file. */ export async function persistAllowRule(runtime: Runtime, cwd: string, rule: string): Promise { const path = projectSettingsPath(cwd); @@ -74,6 +107,27 @@ export async function persistAllowRule(runtime: Runtime, cwd: string, rule: stri await runtime.writeTextFile(path, `${JSON.stringify(next, null, 2)}\n`); } +/** + * Read-modify-write the GLOBAL settings file. Always (re)writes it as 0600: + * this is the file that may hold API keys entered in the config panel. + */ +export async function updateGlobalSettings( + runtime: Runtime, + dataDir: string, + update: (current: MinervaSettings) => MinervaSettings, +): Promise { + const path = globalSettingsPath(dataDir); + const current = await readSettingsFile(runtime, path); + const next = update(current); + await runtime.mkdirp(dirname(path)); + await runtime.writeTextFile(path, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 }); +} + +/** The default config/session root, shared by the kernel and its hosts. */ +export function defaultDataDir(runtime: Runtime): string { + return join(runtime.homedir(), ".minerva"); +} + async function readSettingsFile(runtime: Runtime, path: string): Promise { let raw: string; try { diff --git a/packages/kernel/test/settings.test.ts b/packages/kernel/test/settings.test.ts new file mode 100644 index 0000000..aeda208 --- /dev/null +++ b/packages/kernel/test/settings.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + defaultRuntime, + globalSettingsPath, + loadSettings, + type MinervaSettings, + updateGlobalSettings, +} from "../src"; + +function tempDirs() { + return { + cwd: mkdtempSync(join(tmpdir(), "minerva-settings-proj-")), + dataDir: mkdtempSync(join(tmpdir(), "minerva-settings-data-")), + }; +} + +function writeSettings(path: string, settings: MinervaSettings) { + writeFileSync(path, JSON.stringify(settings)); +} + +describe("model + provider settings", () => { + test("model prefers the project layer; providers merge per name", async () => { + const { cwd, dataDir } = tempDirs(); + writeSettings(globalSettingsPath(dataDir), { + model: "bailian/qwen-plus", + providers: { + bailian: { apiKey: "sk-global", defaultModel: "qwen-plus" }, + deepseek: { baseUrl: "https://api.deepseek.com/v1" }, + }, + }); + mkdirSync(join(cwd, ".minerva")); + writeSettings(join(cwd, ".minerva", "settings.json"), { + model: "anthropic/claude-opus-4-8", + providers: { + bailian: { baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" }, + }, + }); + + const settings = await loadSettings(defaultRuntime, dataDir, cwd); + expect(settings.model).toBe("anthropic/claude-opus-4-8"); + expect(settings.providers.bailian).toEqual({ + apiKey: "sk-global", + defaultModel: "qwen-plus", + baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + }); + expect(settings.providers.deepseek).toEqual({ baseUrl: "https://api.deepseek.com/v1" }); + }); + + test("an apiKey in project settings is ignored — keys are global-only", async () => { + const { cwd, dataDir } = tempDirs(); + mkdirSync(join(cwd, ".minerva")); + writeSettings(join(cwd, ".minerva", "settings.json"), { + providers: { bailian: { apiKey: "sk-injected-by-repo", defaultModel: "qwen-max" } }, + }); + + const settings = await loadSettings(defaultRuntime, dataDir, cwd); + expect(settings.providers.bailian).toEqual({ defaultModel: "qwen-max" }); + }); +}); + +describe("updateGlobalSettings", () => { + test("creates the global file with mode 0600", async () => { + const { dataDir } = tempDirs(); + await updateGlobalSettings(defaultRuntime, dataDir, (current) => ({ + ...current, + model: "bailian/qwen-plus", + providers: { bailian: { apiKey: "sk-secret" } }, + })); + + const path = globalSettingsPath(dataDir); + expect(statSync(path).mode & 0o777).toBe(0o600); + const { cwd } = tempDirs(); + const settings = await loadSettings(defaultRuntime, dataDir, cwd); + expect(settings.model).toBe("bailian/qwen-plus"); + expect(settings.providers.bailian?.apiKey).toBe("sk-secret"); + }); + + test("tightens a pre-existing world-readable file and keeps other fields", async () => { + const { dataDir } = tempDirs(); + const path = globalSettingsPath(dataDir); + writeSettings(path, { defaultMode: "plan" }); + chmodSync(path, 0o644); + + await updateGlobalSettings(defaultRuntime, dataDir, (current) => ({ + ...current, + providers: { bailian: { apiKey: "sk-secret" } }, + })); + + expect(statSync(path).mode & 0o777).toBe(0o600); + const { cwd } = tempDirs(); + const settings = await loadSettings(defaultRuntime, dataDir, cwd); + expect(settings.defaultMode).toBe("plan"); + expect(settings.providers.bailian?.apiKey).toBe("sk-secret"); + }); +}); From e14a311c1386434828ffbf2c857257691b65c22d Mon Sep 17 00:00:00 2001 From: John Hu Date: Sat, 11 Jul 2026 07:26:48 +0800 Subject: [PATCH 03/12] feat(protocol): minerva/config/set_model live model switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontends need a way to change provider/model without restarting the process (the /config panel applies changes to the very next prompt). The new extension method persists the model ref — and optionally a provider definition plus API key — to global settings, then swaps the kernel's live provider. Provider construction stays out of the kernel: hosts inject a resolveProvider factory, keeping the kernel free of AI SDK knowledge (design decision: kernel only sees ModelProvider). Hosts that omit it (none today) reject the method. Persist happens before resolve so a resolver that re-reads settings sees the just-entered key; a resolver failure rolls the model ref back so a rejected switch can't brick the next startup. Each open session logs a session.model_changed audit event, which replay ignores, keeping old kernels' logs resumable. --- packages/client/src/client.ts | 14 +++ packages/kernel/src/events.ts | 3 + packages/kernel/src/kernel.ts | 80 ++++++++++++++++- packages/kernel/test/config.test.ts | 135 ++++++++++++++++++++++++++++ packages/protocol/src/types.ts | 22 +++++ 5 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 packages/kernel/test/config.test.ts diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 36d4700..2bee08d 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -1,6 +1,8 @@ import { AGENT_METHODS, CLIENT_METHODS, + type ConfigSetModelParams, + type ConfigSetModelResult, Connection, type InitializeResult, MINERVA_METHODS, @@ -116,6 +118,18 @@ export class MinervaClient { await this.#connection.request(AGENT_METHODS.sessionSetMode, { sessionId, modeId }); } + /** + * Persist model/provider config and swap the kernel's live provider; + * returns the new provider id (e.g. "bailian/qwen-plus"). + */ + async setModel(params: ConfigSetModelParams): Promise { + const result = await this.#connection.request( + MINERVA_METHODS.configSetModel, + params, + ); + return result.providerId; + } + /** Summarize and reset the model context; returns the summary. */ async compact(sessionId: string): Promise { // Same guard as prompt(): a rejected overlapping call must not clear the diff --git a/packages/kernel/src/events.ts b/packages/kernel/src/events.ts index e7e6c2e..114a3db 100644 --- a/packages/kernel/src/events.ts +++ b/packages/kernel/src/events.ts @@ -18,6 +18,9 @@ export type SessionEvent = } | { type: "session.resumed"; provider: string; at: string } | { type: "session.mode_changed"; modeId: string; at: string } + // Audit trail for a live model switch (minerva/config/set_model). Replay + // skips unknown/informational events, so logs carrying it stay resumable. + | { type: "session.model_changed"; provider: string; at: string } | { type: "user.message"; text: string; at: string } | { type: "assistant.message"; diff --git a/packages/kernel/src/kernel.ts b/packages/kernel/src/kernel.ts index c272784..1fc0315 100644 --- a/packages/kernel/src/kernel.ts +++ b/packages/kernel/src/kernel.ts @@ -2,6 +2,8 @@ import { join } from "node:path"; import { AGENT_METHODS, CLIENT_METHODS, + type ConfigSetModelParams, + type ConfigSetModelResult, Connection, type InitializeResult, JSON_RPC_ERROR_CODES, @@ -26,11 +28,23 @@ import { connectMcpServers, type McpConnection } from "./mcp"; import { isSessionModeId, SESSION_MODES } from "./permissions"; import { defaultRuntime, type Runtime } from "./runtime"; import { parseEventLog, projectDir, Session } from "./session"; -import { defaultDataDir, loadSettings } from "./settings"; +import { + defaultDataDir, + loadSettings, + type ProviderSettings, + updateGlobalSettings, +} from "./settings"; import { builtinTools, type KernelTool } from "./tools"; export interface KernelOptions { provider: ModelProvider; + /** + * Host-supplied factory for switching models at runtime + * (minerva/config/set_model). Injected rather than built in so the kernel + * never sees provider construction (or the AI SDK); hosts that omit it + * simply reject the method. + */ + resolveProvider?: (modelRef: string) => ModelProvider | Promise; runtime?: Runtime; /** Root for session logs and config; defaults to ~/.minerva. */ dataDir?: string | undefined; @@ -48,6 +62,7 @@ export class MinervaKernel { #sessions = new Map(); #mcp = new Map(); #provider: ModelProvider; + #resolveProvider: KernelOptions["resolveProvider"]; #runtime: Runtime; #dataDir: string; #tools: KernelTool[]; @@ -55,6 +70,7 @@ export class MinervaKernel { constructor(transport: Transport, options: KernelOptions) { this.#provider = options.provider; + this.#resolveProvider = options.resolveProvider; this.#runtime = options.runtime ?? defaultRuntime; this.#dataDir = options.dataDir ?? defaultDataDir(this.#runtime); this.#tools = options.tools ?? builtinTools(); @@ -78,6 +94,9 @@ export class MinervaKernel { this.#connection.handleRequest(MINERVA_METHODS.sessionCompact, (params) => this.#sessionCompact(params), ); + this.#connection.handleRequest(MINERVA_METHODS.configSetModel, (params) => + this.#configSetModel(params), + ); this.#connection.handleNotification(AGENT_METHODS.sessionCancel, (params) => { const { sessionId } = params as { sessionId?: string }; if (sessionId) this.#sessions.get(sessionId)?.cancel(); @@ -304,6 +323,65 @@ export class MinervaKernel { return { summary }; } + async #configSetModel(params: unknown): Promise { + const { modelRef, provider, apiKey } = (params ?? {}) as Partial; + if (typeof modelRef !== "string" || modelRef.length === 0) { + throw new RpcError(JSON_RPC_ERROR_CODES.INVALID_PARAMS, "config/set_model requires modelRef"); + } + const resolve = this.#resolveProvider; + if (!resolve) { + throw new RpcError( + JSON_RPC_ERROR_CODES.INVALID_REQUEST, + "model configuration is not supported by this host", + ); + } + + // Persist before resolving: the host's resolver re-reads settings, so a + // key entered alongside the switch must already be on disk. + const slash = modelRef.indexOf("/"); + const providerName = provider?.name ?? (slash === -1 ? "anthropic" : modelRef.slice(0, slash)); + let previousModel: string | undefined; + await updateGlobalSettings(this.#runtime, this.#dataDir, (current) => { + previousModel = current.model; + const entry: ProviderSettings = { + ...current.providers?.[providerName], + ...(provider?.baseUrl !== undefined ? { baseUrl: provider.baseUrl } : {}), + ...(provider?.apiKeyEnv !== undefined ? { apiKeyEnv: provider.apiKeyEnv } : {}), + ...(provider?.defaultModel !== undefined ? { defaultModel: provider.defaultModel } : {}), + ...(apiKey !== undefined ? { apiKey } : {}), + }; + const touched = provider !== undefined || apiKey !== undefined; + return { + ...current, + model: modelRef, + ...(touched ? { providers: { ...current.providers, [providerName]: entry } } : {}), + }; + }); + + let next: ModelProvider; + try { + next = await resolve(modelRef); + } catch (error) { + // Roll back the model ref so a rejected switch can't brick the next + // startup; the provider entry/key stays — it's valid on its own. + await updateGlobalSettings(this.#runtime, this.#dataDir, (current) => ({ + ...current, + model: previousModel, + })).catch(() => {}); + throw new RpcError( + JSON_RPC_ERROR_CODES.INVALID_PARAMS, + error instanceof Error ? error.message : String(error), + ); + } + + this.#provider = next; + const at = now(); + for (const session of this.#sessions.values()) { + session.append({ type: "session.model_changed", provider: next.id, at }); + } + return { providerId: next.id }; + } + /** For tests and diagnostics. */ getSession(sessionId: string): Session | undefined { return this.#sessions.get(sessionId); diff --git a/packages/kernel/test/config.test.ts b/packages/kernel/test/config.test.ts new file mode 100644 index 0000000..976a4b8 --- /dev/null +++ b/packages/kernel/test/config.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + AGENT_METHODS, + CLIENT_METHODS, + Connection, + createInProcTransportPair, + MINERVA_METHODS, + PROTOCOL_VERSION, + type SessionUpdateParams, +} from "@minerva/protocol"; +import type { ModelProvider, TurnEvent } from "@minerva/providers"; +import { createScriptedProvider } from "@minerva/providers"; +import { + createKernel, + globalSettingsPath, + type KernelOptions, + type MinervaSettings, + projectDir, +} from "../src"; + +const FINISH_STOP: TurnEvent = { type: "finish", finishReason: "stop", usage: {} }; + +function textTurn(text: string): TurnEvent[] { + return [{ type: "text-delta", text }, FINISH_STOP]; +} + +function scripted(id: string, turns: TurnEvent[][]): ModelProvider { + return { ...createScriptedProvider(turns), id }; +} + +async function setup(options: { + provider: ModelProvider; + resolveProvider?: KernelOptions["resolveProvider"]; +}) { + const cwd = mkdtempSync(join(tmpdir(), "minerva-config-proj-")); + const dataDir = mkdtempSync(join(tmpdir(), "minerva-config-data-")); + const [clientTransport, kernelTransport] = createInProcTransportPair(); + const kernel = createKernel(kernelTransport, { + provider: options.provider, + dataDir, + ...(options.resolveProvider ? { resolveProvider: options.resolveProvider } : {}), + }); + const client = new Connection(clientTransport); + const updates: SessionUpdateParams[] = []; + client.handleNotification(CLIENT_METHODS.sessionUpdate, (params) => { + updates.push(params as SessionUpdateParams); + }); + await client.request(AGENT_METHODS.initialize, { protocolVersion: PROTOCOL_VERSION }); + const { sessionId } = await client.request<{ sessionId: string }>(AGENT_METHODS.sessionNew, { + cwd, + }); + return { client, updates, sessionId, cwd, dataDir, kernel }; +} + +function agentText(updates: SessionUpdateParams[]): string { + return updates + .filter((u) => u.update.sessionUpdate === "agent_message_chunk") + .map((u) => (u.update as { content: { text: string } }).content.text) + .join(""); +} + +describe("minerva/config/set_model", () => { + test("persists config, swaps the live provider, and logs the switch", async () => { + const h = await setup({ + provider: scripted("anthropic/claude-opus-4-8", [textTurn("from anthropic")]), + resolveProvider: (modelRef) => scripted(modelRef, [textTurn("from bailian")]), + }); + + await h.client.request(AGENT_METHODS.sessionPrompt, { + sessionId: h.sessionId, + prompt: [{ type: "text", text: "hi" }], + }); + + const result = await h.client.request<{ providerId: string }>(MINERVA_METHODS.configSetModel, { + modelRef: "bailian/qwen-plus", + provider: { name: "bailian" }, + apiKey: "sk-dashscope-test", + }); + expect(result.providerId).toBe("bailian/qwen-plus"); + + await h.client.request(AGENT_METHODS.sessionPrompt, { + sessionId: h.sessionId, + prompt: [{ type: "text", text: "hi again" }], + }); + expect(agentText(h.updates)).toBe("from anthropicfrom bailian"); + + const path = globalSettingsPath(h.dataDir); + expect(statSync(path).mode & 0o777).toBe(0o600); + const settings = JSON.parse(readFileSync(path, "utf8")) as MinervaSettings; + expect(settings.model).toBe("bailian/qwen-plus"); + expect(settings.providers?.bailian?.apiKey).toBe("sk-dashscope-test"); + + await h.kernel.getSession(h.sessionId)?.flush(); + const log = readFileSync(join(projectDir(h.dataDir, h.cwd), `${h.sessionId}.jsonl`), "utf8"); + expect(log).toContain('"session.model_changed"'); + expect(log).toContain('"bailian/qwen-plus"'); + }); + + test("a host without resolveProvider rejects the method", async () => { + const h = await setup({ provider: scripted("scripted", [textTurn("hello")]) }); + await expect( + h.client.request(MINERVA_METHODS.configSetModel, { modelRef: "bailian/qwen-plus" }), + ).rejects.toThrow("not supported"); + }); + + test("a resolver failure surfaces as an error and rolls back the model ref", async () => { + const h = await setup({ + provider: scripted("scripted", [textTurn("hello")]), + resolveProvider: () => { + throw new Error('unknown provider "nope"'); + }, + }); + await expect( + h.client.request(MINERVA_METHODS.configSetModel, { modelRef: "nope/some-model" }), + ).rejects.toThrow("unknown provider"); + + const settings = JSON.parse( + readFileSync(globalSettingsPath(h.dataDir), "utf8"), + ) as MinervaSettings; + expect(settings.model).toBeUndefined(); + }); + + test("modelRef is required", async () => { + const h = await setup({ + provider: scripted("scripted", [textTurn("hello")]), + resolveProvider: (ref) => scripted(ref, []), + }); + await expect(h.client.request(MINERVA_METHODS.configSetModel, {})).rejects.toThrow( + "requires modelRef", + ); + }); +}); diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index bcee4b9..8024e56 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -30,6 +30,8 @@ export const MINERVA_METHODS = { sessionsList: "minerva/sessions/list", /** Frontend → kernel: summarize the conversation and reset model context. */ sessionCompact: "minerva/session/compact", + /** Frontend → kernel: persist model/provider config and swap the live provider. */ + configSetModel: "minerva/config/set_model", } as const; /** Kernel → frontend. */ @@ -119,6 +121,26 @@ export interface SessionCompactResult { summary: string; } +// --- minerva/config/set_model ------------------------------------------ + +export interface ConfigSetModelParams { + /** Model reference, e.g. "bailian/qwen-plus". */ + modelRef: string; + /** Upsert this provider definition into global settings before switching. */ + provider?: { + name: string; + baseUrl?: string | undefined; + apiKeyEnv?: string | undefined; + defaultModel?: string | undefined; + }; + /** Stored in global settings only (file mode 0600). Omit to keep the env/stored key. */ + apiKey?: string; +} + +export interface ConfigSetModelResult { + providerId: string; +} + // --- minerva/sessions/list --------------------------------------------- export interface SessionsListParams { From ac8266ce8e102828354b185b50dee5699216a19d Mon Sep 17 00:00:00 2001 From: John Hu Date: Sat, 11 Jul 2026 07:28:54 +0800 Subject: [PATCH 04/12] feat(cli): resolve model and keys from settings at startup The entrypoint now loads settings before building the kernel: the model ref resolves as --model flag > MINERVA_MODEL > settings.model > default, and API keys as env var > settings-stored key, honoring custom provider definitions (baseUrl/apiKeyEnv) from settings. It also injects the resolveProvider factory for minerva/config/set_model, which re-reads settings on every switch so a key the config panel just persisted is picked up without a restart. The missing-key path still exits, but the message now knows about custom key env vars (e.g. DASHSCOPE_API_KEY) and points at /config; the TUI path stops exiting in the next slice when the panel lands. --- packages/cli/src/args.ts | 16 +++++-- packages/cli/src/index.tsx | 87 +++++++++++++++++++++++++++------- packages/cli/test/args.test.ts | 37 ++++++++------- 3 files changed, 102 insertions(+), 38 deletions(-) diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index 5de8399..11ef7df 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -5,7 +5,9 @@ interface CliArgs { command: "tui" | "acp"; - model: string; + /** Model ref from -m/--model; null when the flag was not given, so the + * entrypoint can fall back to MINERVA_MODEL and then settings. */ + model: string | null; resume: string | null; } @@ -26,18 +28,22 @@ Commands: Options: -c, --continue Resume the most recent session for this directory -r, --resume Resume a specific session - -m, --model Model as [provider/]model, e.g. openai/gpt-5.2 or + -m, --model Model as [provider/]model, e.g. bailian/qwen-plus or claude-opus-4-8 (default: ${defaultModel}, env: MINERVA_MODEL) -h, --help Show this help Environment: ANTHROPIC_API_KEY Key for anthropic/* models OPENAI_API_KEY Key for openai/* models - MINERVA_DATA_DIR Session/config root (default: ~/.minerva)`; + DASHSCOPE_API_KEY Key for bailian/* models (Alibaba Bailian) + MINERVA_DATA_DIR Session/config root (default: ~/.minerva) + +Model, provider, and API-key configuration can also live in settings +(~/.minerva/settings.json) — run the TUI and use /config to set it up.`; } -export function parseCliArgs(argv: string[], defaultModel: string): ParsedCli { - let model = defaultModel; +export function parseCliArgs(argv: string[]): ParsedCli { + let model: string | null = null; let resume: string | null = null; let command: CliArgs["command"] = "tui"; diff --git a/packages/cli/src/index.tsx b/packages/cli/src/index.tsx index 1a87349..c3f29a2 100644 --- a/packages/cli/src/index.tsx +++ b/packages/cli/src/index.tsx @@ -1,12 +1,21 @@ #!/usr/bin/env bun import { MinervaClient } from "@minerva/client"; -import { createKernel } from "@minerva/kernel"; +import { + createKernel, + defaultDataDir, + defaultRuntime, + loadSettings, + type ResolvedSettings, +} from "@minerva/kernel"; import { createInProcTransportPair } from "@minerva/protocol"; import { apiKeyEnvVar, + buildProviderRegistry, createProviderFromRef, DEFAULT_ANTHROPIC_MODEL, + type ModelProvider, parseModelRef, + resolveApiKey, } from "@minerva/providers"; import { render } from "ink"; import { runAcpHost } from "./acp"; @@ -14,35 +23,83 @@ import { App } from "./app"; import { parseCliArgs, usage } from "./args"; import { createPermissionBridge } from "./permission-bridge"; -const defaultModel = process.env.MINERVA_MODEL ?? DEFAULT_ANTHROPIC_MODEL; -const parsed = parseCliArgs(process.argv.slice(2), defaultModel); +const usageDefault = process.env.MINERVA_MODEL ?? DEFAULT_ANTHROPIC_MODEL; +const parsed = parseCliArgs(process.argv.slice(2)); if (parsed.kind === "help") { - console.log(usage(defaultModel)); + console.log(usage(usageDefault)); process.exit(0); } if (parsed.kind === "error") { - console.error(`${parsed.message}\n\n${usage(defaultModel)}`); + console.error(`${parsed.message}\n\n${usage(usageDefault)}`); process.exit(1); } -const { command, model, resume } = parsed.args; +const { command, resume } = parsed.args; -let providerName: ReturnType["provider"]; -try { - providerName = parseModelRef(model).provider; -} catch (cause) { +const runtime = defaultRuntime; +const dataDir = process.env.MINERVA_DATA_DIR ?? defaultDataDir(runtime); +const cwd = process.cwd(); + +function fail(cause: unknown): never { console.error(cause instanceof Error ? cause.message : String(cause)); process.exit(1); } -const keyVar = apiKeyEnvVar(providerName); -if (!process.env[keyVar]) { + +function storedKeys(settings: ResolvedSettings): Record { + return Object.fromEntries( + Object.entries(settings.providers).map(([name, entry]) => [name, entry.apiKey]), + ); +} + +let settings: ResolvedSettings; +let registry: ReturnType; +try { + settings = await loadSettings(runtime, dataDir, cwd); + registry = buildProviderRegistry(settings.providers); +} catch (cause) { + fail(cause); +} + +// Precedence: --model flag > MINERVA_MODEL > settings > built-in default. +const model = + parsed.args.model ?? process.env.MINERVA_MODEL ?? settings.model ?? DEFAULT_ANTHROPIC_MODEL; + +let providerName: string; +try { + providerName = parseModelRef(model, registry).provider; +} catch (cause) { + fail(cause); +} +const apiKey = resolveApiKey(providerName, registry, { + env: process.env, + storedKeys: storedKeys(settings), +}); +if (!apiKey) { + const keyVar = apiKeyEnvVar(providerName, registry); console.error(`${keyVar} is not set (required for ${model}). Export it and try again:`); console.error(` export ${keyVar}="..."`); + console.error("or run `minerva` and use /config to store a key in settings."); process.exit(1); } +/** + * Host-injected factory for minerva/config/set_model. Re-reads settings so + * a key or provider the config panel just persisted is picked up. + */ +const resolveProvider = async (modelRef: string): Promise => { + const latest = await loadSettings(runtime, dataDir, cwd); + const providers = buildProviderRegistry(latest.providers); + const ref = parseModelRef(modelRef, providers); + const key = resolveApiKey(ref.provider, providers, { + env: process.env, + storedKeys: storedKeys(latest), + }); + return createProviderFromRef(modelRef, { providers, ...(key ? { apiKey: key } : {}) }); +}; + const kernelOptions = { - provider: createProviderFromRef(model), - dataDir: process.env.MINERVA_DATA_DIR, + provider: createProviderFromRef(model, { providers: registry, apiKey }), + resolveProvider, + dataDir, }; if (command === "acp") { @@ -51,8 +108,6 @@ if (command === "acp") { process.exit(0); } -const cwd = process.cwd(); - // The CLI embeds the kernel, but only across the protocol's in-proc // transport — the same messages a Tauri sidecar or remote kernel would see. const [clientTransport, kernelTransport] = createInProcTransportPair(); diff --git a/packages/cli/test/args.test.ts b/packages/cli/test/args.test.ts index 932b28d..2585b1d 100644 --- a/packages/cli/test/args.test.ts +++ b/packages/cli/test/args.test.ts @@ -1,66 +1,69 @@ import { describe, expect, test } from "bun:test"; import { parseCliArgs, usage } from "../src/args"; -const DEFAULT = "claude-opus-4-8"; - describe("parseCliArgs", () => { - test("no arguments runs the TUI with defaults", () => { - expect(parseCliArgs([], DEFAULT)).toEqual({ + test("no arguments runs the TUI with no model override", () => { + expect(parseCliArgs([])).toEqual({ kind: "run", - args: { command: "tui", model: DEFAULT, resume: null }, + args: { command: "tui", model: null, resume: null }, }); }); test("acp command and flags compose in any order", () => { - expect(parseCliArgs(["acp", "-m", "openai/gpt-5.2"], DEFAULT)).toEqual({ + expect(parseCliArgs(["acp", "-m", "openai/gpt-5.2"])).toEqual({ kind: "run", args: { command: "acp", model: "openai/gpt-5.2", resume: null }, }); - expect(parseCliArgs(["-m", "openai/gpt-5.2", "acp"], DEFAULT)).toEqual({ + expect(parseCliArgs(["-m", "openai/gpt-5.2", "acp"])).toEqual({ kind: "run", args: { command: "acp", model: "openai/gpt-5.2", resume: null }, }); }); test("--continue and --resume set the resume target", () => { - expect(parseCliArgs(["--continue"], DEFAULT)).toMatchObject({ + expect(parseCliArgs(["--continue"])).toMatchObject({ args: { resume: "latest" }, }); - expect(parseCliArgs(["-r", "ses_abc"], DEFAULT)).toMatchObject({ + expect(parseCliArgs(["-r", "ses_abc"])).toMatchObject({ args: { resume: "ses_abc" }, }); }); test("missing or flag-shaped values are errors, not silent misparses", () => { - expect(parseCliArgs(["--resume"], DEFAULT)).toEqual({ + expect(parseCliArgs(["--resume"])).toEqual({ kind: "error", message: "--resume requires a session id", }); - expect(parseCliArgs(["-r", "--model", "x"], DEFAULT)).toMatchObject({ kind: "error" }); - expect(parseCliArgs(["--model"], DEFAULT)).toEqual({ + expect(parseCliArgs(["-r", "--model", "x"])).toMatchObject({ kind: "error" }); + expect(parseCliArgs(["--model"])).toEqual({ kind: "error", message: "--model requires a model id", }); - expect(parseCliArgs(["-m", "-c"], DEFAULT)).toMatchObject({ kind: "error" }); + expect(parseCliArgs(["-m", "-c"])).toMatchObject({ kind: "error" }); }); test("help and unknown options", () => { - expect(parseCliArgs(["--help"], DEFAULT)).toEqual({ kind: "help" }); - expect(parseCliArgs(["-h"], DEFAULT)).toEqual({ kind: "help" }); - expect(parseCliArgs(["--wat"], DEFAULT)).toEqual({ + expect(parseCliArgs(["--help"])).toEqual({ kind: "help" }); + expect(parseCliArgs(["-h"])).toEqual({ kind: "help" }); + expect(parseCliArgs(["--wat"])).toEqual({ kind: "error", message: "unknown option: --wat", }); // A second bare "acp" is not a valid option once the command is set. - expect(parseCliArgs(["acp", "acp"], DEFAULT)).toMatchObject({ kind: "error" }); + expect(parseCliArgs(["acp", "acp"])).toMatchObject({ kind: "error" }); }); test("usage names the default model and every documented flag", () => { + const DEFAULT = "claude-opus-4-8"; const text = usage(DEFAULT); expect(text).toContain("Usage: minerva"); expect(text).toContain(DEFAULT); for (const flag of ["--continue", "--resume", "--model", "--help", "acp"]) { expect(text).toContain(flag); } + for (const keyVar of ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "DASHSCOPE_API_KEY"]) { + expect(text).toContain(keyVar); + } + expect(text).toContain("/config"); }); }); From d9eba0aef624c41054a254a9165f61600759e02f Mon Sep 17 00:00:00 2001 From: John Hu Date: Sat, 11 Jul 2026 07:39:02 +0800 Subject: [PATCH 05/12] feat(cli): /config panel and first-run provider setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missing API keys used to kill the process before the TUI even mounted. Now the TUI opens an interactive config panel instead: pick a provider (built-ins, the bailian preset, or a custom OpenAI-compatible endpoint with name + baseUrl), enter a key (masked input), confirm a model. The panel persists via minerva/config/set_model and the swap applies to the very next prompt — no restart. /config reopens it any time, and the header reflects the new model live. The acp path keeps its hard exit since stdout there belongs to the protocol. The panel follows PermissionPrompt's inline-replacement pattern and sits below `pending` in the render ternary, so it can never hide a running turn's permission request. Provider selection tracks the highlight in a ref as well as state: a rapid ↓+enter reaches React as one batch, and selecting via the state closure picked the previously highlighted row — caught by a PTY e2e run, regression-tested with a single-chunk write. --- packages/cli/src/app.tsx | 54 +++- packages/cli/src/config-panel.tsx | 269 ++++++++++++++++++++ packages/cli/src/index.tsx | 48 +++- packages/cli/test/app.test.tsx | 143 ++++++++++- packages/cli/test/fixtures/tui-scripted.tsx | 10 +- 5 files changed, 504 insertions(+), 20 deletions(-) create mode 100644 packages/cli/src/config-panel.tsx diff --git a/packages/cli/src/app.tsx b/packages/cli/src/app.tsx index 53f5fc0..9f515c0 100644 --- a/packages/cli/src/app.tsx +++ b/packages/cli/src/app.tsx @@ -2,6 +2,7 @@ import type { MinervaClient, SessionStore, ViewItem } from "@minerva/client"; import { Box, Text, useApp, useInput } from "ink"; import TextInput from "ink-text-input"; import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import { ConfigPanel, type ConfigResult, type ProviderChoice } from "./config-panel"; import type { PendingPermission, PermissionBridge } from "./permission-bridge"; interface AppProps { @@ -11,12 +12,18 @@ interface AppProps { cwd: string; /** null = new session; "latest" = most recent for cwd; else a session id. */ resume: string | null; + /** Rows for the /config panel's provider selector. */ + providers: ProviderChoice[]; + /** No usable API key at startup — open the config panel instead of exiting. */ + needsConfig: boolean; } -export function App({ client, bridge, model, cwd, resume }: AppProps) { +export function App({ client, bridge, model, cwd, resume, providers, needsConfig }: AppProps) { const [session, setSession] = useState<{ id: string; store: SessionStore } | null>(null); const [pending, setPending] = useState(null); const [error, setError] = useState(null); + // Header model ref; updated live when /config swaps the provider. + const [modelRef, setModelRef] = useState(model); useEffect(() => { bridge.handler = (request) => @@ -63,7 +70,7 @@ export function App({ client, bridge, model, cwd, resume }: AppProps) { return ( - Minerva · {model} · {cwd} · /help for commands, esc to cancel + Minerva · {modelRef} · {cwd} · /help for commands, esc to cancel ); @@ -78,6 +89,7 @@ export function App({ client, bridge, model, cwd, resume }: AppProps) { const HELP_TEXT = [ "/help show this help", + "/config choose provider, API key, and model", "/mode [id] show or set the session mode (plan | default | acceptEdits | auto)", "/compact summarize the conversation and reset the model context", "/sessions list recent sessions for this directory", @@ -91,12 +103,20 @@ function Chat({ pending, cwd, onNewSession, + providers, + model, + onModelChanged, + initialConfigOpen, }: { client: MinervaClient; session: { id: string; store: SessionStore }; pending: PendingPermission | null; cwd: string; onNewSession: () => void; + providers: ProviderChoice[]; + model: string; + onModelChanged: (providerId: string) => void; + initialConfigOpen: boolean; }) { const subscribe = useCallback( (listener: () => void) => session.store.subscribe(listener), @@ -104,6 +124,7 @@ function Chat({ ); const viewModel = useSyncExternalStore(subscribe, () => session.store.snapshot); const [draft, setDraft] = useState(""); + const [configOpen, setConfigOpen] = useState(initialConfigOpen); const { exit } = useApp(); useInput((_input, key) => { @@ -125,6 +146,9 @@ function Chat({ case "help": info(HELP_TEXT); break; + case "config": + setConfigOpen(true); + break; case "mode": if (!argument) { info( @@ -175,6 +199,24 @@ function Chat({ client.prompt(session.id, text).catch(reportError); }; + const applyConfig = async (result: ConfigResult) => { + const providerId = await client.setModel({ + modelRef: result.modelRef, + ...(result.provider ? { provider: result.provider } : {}), + ...(result.apiKey ? { apiKey: result.apiKey } : {}), + }); + onModelChanged(providerId); + setConfigOpen(false); + info(`model set to ${providerId} (saved to global settings)`); + }; + + const cancelConfig = () => { + setConfigOpen(false); + if (initialConfigOpen) { + info("no API key configured — prompts will fail until you run /config"); + } + }; + return ( {viewModel.items.map((item, index) => ( @@ -182,6 +224,14 @@ function Chat({ ))} {pending ? ( + ) : configOpen ? ( + ) : viewModel.busy ? ( ✳ working… (esc to cancel) ) : ( diff --git a/packages/cli/src/config-panel.tsx b/packages/cli/src/config-panel.tsx new file mode 100644 index 0000000..99e579a --- /dev/null +++ b/packages/cli/src/config-panel.tsx @@ -0,0 +1,269 @@ +import { Box, Text, useInput } from "ink"; +import TextInput from "ink-text-input"; +import { useRef, useState } from "react"; + +/** + * Interactive provider/model/key setup — opened by /config and on first run + * when the selected model has no API key. Same inline-replacement pattern as + * PermissionPrompt: it occupies the input slot, so it can never hide a + * running turn's permission request. + */ + +/** One selectable provider row, precomputed by the entrypoint. */ +export interface ProviderChoice { + name: string; + defaultModel?: string | undefined; + /** Env var the provider reads (e.g. DASHSCOPE_API_KEY). */ + keyVar: string; + /** Where a usable key was found, if anywhere. */ + keySource: "env" | "settings" | "none"; + baseUrl?: string | undefined; +} + +export interface ConfigResult { + modelRef: string; + /** Present for newly defined custom providers. */ + provider?: { name: string; baseUrl?: string | undefined }; + /** Omitted when the user kept the existing env/stored key. */ + apiKey?: string; +} + +type Step = "provider" | "name" | "baseUrl" | "apiKey" | "model"; + +const NAME_PATTERN = /^[a-z][a-z0-9-]*$/; + +export function ConfigPanel({ + providers, + currentModel, + firstRun, + onSubmit, + onCancel, +}: { + providers: ProviderChoice[]; + currentModel: string; + firstRun: boolean; + onSubmit: (result: ConfigResult) => Promise; + onCancel: () => void; +}) { + const [step, setStep] = useState("provider"); + const [index, setIndex] = useState(0); + // Mirror of `index` that is current within a single input batch: rapid + // ↓+enter can reach React as one batch, and selecting via the state + // closure would then pick the previously highlighted row. + const indexRef = useRef(0); + const [custom, setCustom] = useState(false); + const [name, setName] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [model, setModel] = useState(""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + const rows = providers.length + 1; // trailing "custom…" row + const selected = custom ? undefined : providers[index]; + + const chooseProvider = (i: number) => { + setError(null); + if (i === providers.length) { + setCustom(true); + setStep("name"); + return; + } + setCustom(false); + const choice = providers[i]; + if (!choice) return; + // Re-configuring the current provider keeps its model; otherwise prefill + // the provider's default. + const slash = currentModel.indexOf("/"); + const currentProvider = slash === -1 ? "anthropic" : currentModel.slice(0, slash); + const currentModelId = slash === -1 ? currentModel : currentModel.slice(slash + 1); + setModel(currentProvider === choice.name ? currentModelId : (choice.defaultModel ?? "")); + setStep("apiKey"); + }; + + const stepBack = () => { + setError(null); + if (step === "model") setStep("apiKey"); + else if (step === "apiKey") setStep(custom ? "baseUrl" : "provider"); + else if (step === "baseUrl") setStep("name"); + else if (step === "name") setStep("provider"); + }; + + const moveIndex = (delta: number) => { + indexRef.current = (indexRef.current + rows + delta) % rows; + setIndex(indexRef.current); + }; + + useInput((input, key) => { + if (saving) return; + if (step === "provider") { + if (key.upArrow || input === "k") moveIndex(-1); + else if (key.downArrow || input === "j") moveIndex(1); + else if (key.return) chooseProvider(indexRef.current); + else if (key.escape) onCancel(); + return; + } + if (key.escape) stepBack(); + }); + + const submitName = (value: string) => { + const trimmed = value.trim(); + if (!NAME_PATTERN.test(trimmed)) { + setError("provider name must be lowercase letters, digits, and dashes (e.g. deepseek)"); + return; + } + if (providers.some((choice) => choice.name === trimmed)) { + setError(`"${trimmed}" already exists — select it from the list instead`); + return; + } + setError(null); + setName(trimmed); + setStep("baseUrl"); + }; + + const submitBaseUrl = (value: string) => { + const trimmed = value.trim(); + if (!trimmed.startsWith("http")) { + setError("baseUrl must be an http(s) endpoint, e.g. https://api.example.com/v1"); + return; + } + setError(null); + setBaseUrl(trimmed); + setStep("apiKey"); + }; + + const submitApiKey = (value: string) => { + const trimmed = value.trim(); + // Custom endpoints may be keyless (local servers); known providers need + // a key from somewhere. + if (!trimmed && !custom && selected?.keySource === "none") { + setError(`no existing key for ${selected.name} — enter one`); + return; + } + setError(null); + setApiKey(trimmed); + setStep("model"); + }; + + const submitModel = async (value: string) => { + const trimmed = value.trim(); + if (!trimmed) { + setError("model id is required (e.g. qwen-plus)"); + return; + } + const providerName = custom ? name : (selected?.name ?? "anthropic"); + const result: ConfigResult = { + modelRef: `${providerName}/${trimmed}`, + ...(custom ? { provider: { name: providerName, baseUrl } } : {}), + ...(apiKey ? { apiKey } : {}), + }; + setSaving(true); + setError(null); + try { + await onSubmit(result); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + setSaving(false); + } + }; + + return ( + + + Configure model{custom && name ? ` — ${name}` : selected ? ` — ${selected.name}` : ""} + + {firstRun && step === "provider" ? ( + + No API key found for {currentModel} — pick a provider to get started. + + ) : null} + + {step === "provider" ? ( + + {providers.map((choice, i) => ( + + {i === index ? "❯ " : " "} + {choice.name.padEnd(12)} + {(choice.defaultModel ?? "").padEnd(22)} + {keyStatus(choice)} + + ))} + + {index === providers.length ? "❯ " : " "}custom…{" "} + add an OpenAI-compatible provider + + ↑/↓ select · enter confirm · esc cancel + + ) : null} + + {step === "name" ? ( + + Provider name: + + + ) : null} + + {step === "baseUrl" ? ( + + Base URL: + + + ) : null} + + {step === "apiKey" ? ( + + API key{selected ? ` ($${selected.keyVar})` : ""}: + + + ) : null} + + {step === "model" ? ( + + Model id: + + + ) : null} + + {saving ? saving… : null} + {error ? {error} : null} + {step !== "provider" && !saving ? enter confirm · esc back : null} + + ); +} + +function keyStatus(choice: ProviderChoice): string { + switch (choice.keySource) { + case "env": + return `key: env $${choice.keyVar}`; + case "settings": + return "key: saved in settings"; + case "none": + return "no key"; + } +} + +function apiKeyPlaceholder(custom: boolean, selected: ProviderChoice | undefined): string { + if (custom) return "leave empty if the endpoint needs no key"; + if (selected && selected.keySource !== "none") { + return selected.keySource === "env" + ? `leave empty to keep using $${selected.keyVar}` + : "leave empty to keep the saved key"; + } + return "sk-…"; +} diff --git a/packages/cli/src/index.tsx b/packages/cli/src/index.tsx index c3f29a2..b49af62 100644 --- a/packages/cli/src/index.tsx +++ b/packages/cli/src/index.tsx @@ -21,6 +21,7 @@ import { render } from "ink"; import { runAcpHost } from "./acp"; import { App } from "./app"; import { parseCliArgs, usage } from "./args"; +import type { ProviderChoice } from "./config-panel"; import { createPermissionBridge } from "./permission-bridge"; const usageDefault = process.env.MINERVA_MODEL ?? DEFAULT_ANTHROPIC_MODEL; @@ -73,13 +74,6 @@ const apiKey = resolveApiKey(providerName, registry, { env: process.env, storedKeys: storedKeys(settings), }); -if (!apiKey) { - const keyVar = apiKeyEnvVar(providerName, registry); - console.error(`${keyVar} is not set (required for ${model}). Export it and try again:`); - console.error(` export ${keyVar}="..."`); - console.error("or run `minerva` and use /config to store a key in settings."); - process.exit(1); -} /** * Host-injected factory for minerva/config/set_model. Re-reads settings so @@ -97,17 +91,41 @@ const resolveProvider = async (modelRef: string): Promise => { }; const kernelOptions = { - provider: createProviderFromRef(model, { providers: registry, apiKey }), + provider: createProviderFromRef(model, { + providers: registry, + ...(apiKey ? { apiKey } : {}), + }), resolveProvider, dataDir, }; if (command === "acp") { - // stdout carries the protocol; the kernel must never render UI here. + // stdout carries the protocol — no UI, so a missing key stays a hard exit. + if (!apiKey) { + const keyVar = apiKeyEnvVar(providerName, registry); + console.error(`${keyVar} is not set (required for ${model}). Export it and try again:`); + console.error(` export ${keyVar}="..."`); + console.error("or run `minerva` and use /config to store a key in settings."); + process.exit(1); + } await runAcpHost(kernelOptions); process.exit(0); } +// Rows for the /config panel: every registry provider plus where (if +// anywhere) a usable key was found for it. +const providerChoices: ProviderChoice[] = Object.entries(registry).map(([name, def]) => ({ + name, + defaultModel: def.defaultModel, + keyVar: def.apiKeyEnv, + keySource: process.env[def.apiKeyEnv] + ? ("env" as const) + : settings.providers[name]?.apiKey + ? ("settings" as const) + : ("none" as const), + baseUrl: def.baseURL, +})); + // The CLI embeds the kernel, but only across the protocol's in-proc // transport — the same messages a Tauri sidecar or remote kernel would see. const [clientTransport, kernelTransport] = createInProcTransportPair(); @@ -118,7 +136,17 @@ const client = new MinervaClient(clientTransport, { onPermissionRequest: bridge.onPermissionRequest, }); -const app = render(); +const app = render( + , +); await app.waitUntilExit(); // Ink unmounts but stdin's raw-mode listener keeps the runtime alive. process.exit(0); diff --git a/packages/cli/test/app.test.tsx b/packages/cli/test/app.test.tsx index cf2dec9..8d6e4f4 100644 --- a/packages/cli/test/app.test.tsx +++ b/packages/cli/test/app.test.tsx @@ -1,9 +1,9 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, readFileSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { MinervaClient } from "@minerva/client"; -import { createKernel, type MinervaKernel } from "@minerva/kernel"; +import { createKernel, type KernelOptions, type MinervaKernel } from "@minerva/kernel"; import { createInProcTransportPair } from "@minerva/protocol"; import { createScriptedProvider, type TurnEvent } from "@minerva/providers"; // ink-testing-library is pinned exactly: 4.0.0 predates ink 6 / React 19 and @@ -11,6 +11,7 @@ import { createScriptedProvider, type TurnEvent } from "@minerva/providers"; // so an unreviewed 4.x bump must not slip in via install. import { render } from "ink-testing-library"; import { App } from "../src/app"; +import type { ProviderChoice } from "../src/config-panel"; import { createPermissionBridge } from "../src/permission-bridge"; const FINISH_STOP: TurnEvent = { type: "finish", finishReason: "stop", usage: {} }; @@ -22,19 +23,40 @@ afterEach(() => { }); /** Full-stack TUI harness: real App + client + kernel, scripted provider. */ -function renderTui(turns: TurnEvent[][]) { +function renderTui( + turns: TurnEvent[][], + options: { + resolveProvider?: KernelOptions["resolveProvider"]; + providers?: ProviderChoice[]; + needsConfig?: boolean; + } = {}, +) { const cwd = mkdtempSync(join(tmpdir(), "minerva-ui-proj-")); const dataDir = mkdtempSync(join(tmpdir(), "minerva-ui-data-")); const [clientTransport, kernelTransport] = createInProcTransportPair(); - kernels.push(createKernel(kernelTransport, { dataDir, provider: createScriptedProvider(turns) })); + kernels.push( + createKernel(kernelTransport, { + dataDir, + provider: createScriptedProvider(turns), + ...(options.resolveProvider ? { resolveProvider: options.resolveProvider } : {}), + }), + ); const bridge = createPermissionBridge(); const client = new MinervaClient(clientTransport, { onPermissionRequest: bridge.onPermissionRequest, }); const ui = render( - , + , ); - return { ...ui, cwd }; + return { ...ui, cwd, dataDir }; } async function waitFor(predicate: () => boolean, what: string, timeoutMs = 8000): Promise { @@ -65,6 +87,41 @@ async function type(ui: { stdin: { write: (s: string) => void } }, text: string) ui.stdin.write("\r"); } +async function pressEnter(ui: { stdin: { write: (s: string) => void } }) { + await Bun.sleep(50); + ui.stdin.write("\r"); +} + +const BAILIAN_CHOICE: ProviderChoice = { + name: "bailian", + defaultModel: "qwen-plus", + keyVar: "DASHSCOPE_API_KEY", + keySource: "none", +}; + +/** Harness stand-in for the entrypoint's resolveProvider factory. */ +const swappedProvider: KernelOptions["resolveProvider"] = (modelRef) => ({ + ...createScriptedProvider([[{ type: "text-delta", text: "hello from qwen" }, FINISH_STOP]]), + id: modelRef, +}); + +/** Drive the panel: select bailian → enter a key → accept the prefilled model. */ +async function completeConfigPanel(ui: { + stdin: { write: (s: string) => void }; + lastFrame: () => string | undefined; +}) { + await waitFor(() => (ui.lastFrame() ?? "").includes("Configure model"), "config panel"); + await pressEnter(ui); // bailian is the first (highlighted) row + await waitFor(() => (ui.lastFrame() ?? "").includes("API key"), "api key step"); + await type(ui, "sk-dashscope-test"); + await waitFor(() => (ui.lastFrame() ?? "").includes("Model id"), "model step"); + await pressEnter(ui); // accept the prefilled qwen-plus + await waitFor( + () => (ui.lastFrame() ?? "").includes("model set to bailian/qwen-plus"), + "config saved", + ); +} + describe("TUI (ink-testing-library, full stack)", () => { test("/help lists the command palette; unknown commands hint at it", async () => { const ui = renderTui([]); @@ -72,7 +129,7 @@ describe("TUI (ink-testing-library, full stack)", () => { await type(ui, "/help"); await waitFor(() => (ui.lastFrame() ?? "").includes("/compact"), "help text"); - for (const cmd of ["/mode", "/sessions", "/new", "/exit"]) { + for (const cmd of ["/config", "/mode", "/sessions", "/new", "/exit"]) { expect(ui.lastFrame()).toContain(cmd); } @@ -134,6 +191,78 @@ describe("TUI (ink-testing-library, full stack)", () => { ui.unmount(); }, 20_000); + test("/config switches the provider live and persists to global settings", async () => { + const ui = renderTui([], { + providers: [BAILIAN_CHOICE], + resolveProvider: swappedProvider, + }); + await ready(ui); + + await type(ui, "/config"); + await completeConfigPanel(ui); + expect(ui.lastFrame()).toContain("bailian/qwen-plus"); // header live-updated + + await type(ui, "say hi"); + await waitFor(() => (ui.lastFrame() ?? "").includes("hello from qwen"), "swapped provider"); + + const path = join(ui.dataDir, "settings.json"); + expect(statSync(path).mode & 0o777).toBe(0o600); + const settings = JSON.parse(readFileSync(path, "utf8")) as { + model?: string; + providers?: Record; + }; + expect(settings.model).toBe("bailian/qwen-plus"); + expect(settings.providers?.bailian?.apiKey).toBe("sk-dashscope-test"); + ui.unmount(); + }, 20_000); + + test("arrow+enter arriving as one input batch still selects the highlighted row", async () => { + const ui = renderTui([], { + providers: [ + { + name: "anthropic", + defaultModel: "claude-opus-4-8", + keyVar: "ANTHROPIC_API_KEY", + keySource: "none", + }, + { name: "openai", defaultModel: "gpt-5.2", keyVar: "OPENAI_API_KEY", keySource: "none" }, + BAILIAN_CHOICE, + ], + resolveProvider: swappedProvider, + }); + await ready(ui); + await type(ui, "/config"); + await waitFor(() => (ui.lastFrame() ?? "").includes("Configure model"), "config panel"); + await Bun.sleep(50); + // One chunk: ↓ ↓ enter — processed in a single React batch, which used + // to select the stale (previously highlighted) row. + ui.stdin.write("\r"); + await waitFor(() => (ui.lastFrame() ?? "").includes("DASHSCOPE_API_KEY"), "bailian selected"); + await type(ui, "sk-batched"); + await waitFor(() => (ui.lastFrame() ?? "").includes("Model id"), "model step"); + expect(ui.lastFrame()).toContain("qwen-plus"); // bailian's prefill, not a stale row's + await pressEnter(ui); + await waitFor( + () => (ui.lastFrame() ?? "").includes("model set to bailian/qwen-plus"), + "config saved", + ); + ui.unmount(); + }, 20_000); + + test("first run without a key opens the panel; finishing it reveals the composer", async () => { + const ui = renderTui([], { + providers: [BAILIAN_CHOICE], + needsConfig: true, + resolveProvider: swappedProvider, + }); + await waitFor(() => (ui.lastFrame() ?? "").includes("No API key found"), "first-run panel"); + + await completeConfigPanel(ui); + await waitFor(() => (ui.lastFrame() ?? "").includes(">"), "composer after config"); + expect(statSync(join(ui.dataDir, "settings.json")).mode & 0o777).toBe(0o600); + ui.unmount(); + }, 20_000); + test("todo_write renders the plan checklist and /mode shows the indicator", async () => { const ui = renderTui([ [ diff --git a/packages/cli/test/fixtures/tui-scripted.tsx b/packages/cli/test/fixtures/tui-scripted.tsx index a058c87..28516da 100644 --- a/packages/cli/test/fixtures/tui-scripted.tsx +++ b/packages/cli/test/fixtures/tui-scripted.tsx @@ -24,7 +24,15 @@ const client = new MinervaClient(clientTransport, { }); const app = render( - , + , ); await app.waitUntilExit(); process.exit(0); From 80b8b94ead3bc4d11a3783321cdec5e188ff8bd4 Mon Sep 17 00:00:00 2001 From: John Hu Date: Sat, 11 Jul 2026 07:40:58 +0800 Subject: [PATCH 06/12] docs: bailian provider, custom providers, and /config Document the new provider surface: the bailian preset (with the intl baseUrl override), custom OpenAI-compatible providers in settings, key precedence (env over stored, global-only, 0600), the /config panel and first-run flow, and the minerva/config/set_model extension method with its host-injected resolveProvider design. --- CHANGELOG.md | 9 +++++++++ README.md | 34 ++++++++++++++++++++++++++++++++-- docs/DESIGN.md | 6 +++++- docs/PROTOCOL.md | 10 ++++++++++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2573b3a..bdac1a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ All notable changes to Minerva are documented here. The format follows ## [Unreleased] ### Added +- Alibaba Bailian (DashScope) provider preset (`bailian/qwen-plus`, + `DASHSCOPE_API_KEY`) and settings-defined custom OpenAI-compatible + providers (any name + `baseUrl`), via `@ai-sdk/openai-compatible`. +- Interactive `/config` panel in the TUI: pick a provider, enter an API key + (masked), choose a model. Runs automatically on first launch when no key is + found — replacing the old print-error-and-exit behavior — and applies live + to the next prompt via the new `minerva/config/set_model` extension method. +- Settings: `model` (default model ref) and per-provider `providers` entries; + API keys are honored from the global file only, always written mode `0600`. - CI (GitHub Actions): verify matrix on ubuntu/macOS, per-file coverage thresholds, knip, PTY smoke + e2e for the TUI, compiled-binary smoke, and a secret-gated live-model smoke on main pushes; Dependabot. diff --git a/README.md b/README.md index 967409d..72d4fa6 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,14 @@ Docs: [design record](docs/DESIGN.md) · [wire protocol](docs/PROTOCOL.md) · ```sh bun install -export ANTHROPIC_API_KEY="sk-ant-..." bun run --cwd packages/cli dev ``` +On first run with no API key configured, the TUI opens an interactive setup +panel: pick a provider, paste a key, confirm a model. Keys can also come from +the environment (`export ANTHROPIC_API_KEY="sk-ant-..."`), which always takes +precedence over stored ones. + ## Usage ``` @@ -43,6 +47,7 @@ Inside the TUI: | Command | Effect | |---|---| | `/help` | List commands | +| `/config` | Choose provider, API key, and model — applies to the next prompt, no restart | | `/mode [id]` | Show or set the session mode (`plan` \| `default` \| `acceptEdits` \| `auto`) | | `/compact` | Summarize the conversation and reset the model context | | `/sessions` | List recent sessions for this directory | @@ -55,12 +60,19 @@ project rule), `n` (reject), `esc` (cancel the turn). ## Providers -Model references are `provider/model`; the matching key must be exported: +Model references are `provider/model`. Keys resolve as **env var → key stored +in global settings** (set either via `/config`): | Provider | Example ref | Key | |---|---|---| | Anthropic (default) | `claude-opus-4-8` or `anthropic/claude-opus-4-8` | `ANTHROPIC_API_KEY` | | OpenAI | `openai/gpt-5.2` | `OPENAI_API_KEY` | +| Alibaba Bailian (DashScope) | `bailian/qwen-plus` | `DASHSCOPE_API_KEY` | + +Bailian uses the China endpoint by default; for the international one, +override its `baseUrl` in settings (see below). Any other OpenAI-compatible +endpoint (DeepSeek, Ollama, a proxy…) can be added as a custom provider — +via `/config` → `custom…`, or directly in settings. ## Configuration @@ -69,6 +81,17 @@ Settings merge from `~/.minerva/settings.json` (global; override the root with ```json { + "model": "bailian/qwen-plus", + "providers": { + "bailian": { + "apiKey": "sk-...", + "baseUrl": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" + }, + "deepseek": { + "baseUrl": "https://api.deepseek.com/v1", + "defaultModel": "deepseek-chat" + } + }, "defaultMode": "default", "permissions": { "allow": ["bash(git status)", "bash(bun test*)"], @@ -81,6 +104,13 @@ Settings merge from `~/.minerva/settings.json` (global; override the root with } ``` +`model` picks the default model ref (`--model` and `MINERVA_MODEL` override +it). `providers` overrides a built-in (e.g. Bailian's endpoint) or defines a +new OpenAI-compatible one — a new name needs `baseUrl`, and its key env var +defaults to `_API_KEY`. **API keys are honored from the global file +only** (never the shareable project file), and any file that may hold keys is +written with mode `0600`. + Permission rules are `tool` or `tool(pattern)` where `*` matches any run of characters, `?` one character, and `\*` a literal asterisk. Precedence: **deny** → **ask** → read-only auto-allow → **plan mode deny** → **allow** → diff --git a/docs/DESIGN.md b/docs/DESIGN.md index de4aa4a..b5415c2 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -47,7 +47,11 @@ minerva/ ``` Config: `~/.minerva/settings.json` (global) + `.minerva/settings.json` -(project) — provider/model selection, permission rules, MCP server definitions. +(project) — provider/model selection (`model`, `providers`; API keys +global-only, file written `0600`), permission rules, MCP server definitions. +The config extension method is `minerva/config/set_model` (persist + live +provider swap); provider construction is host-injected via +`KernelOptions.resolveProvider` so the kernel never imports the AI SDK. ## v0.1 internal slices (checkpoints, not releases) diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 6974ec5..ee6a4e8 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -103,6 +103,16 @@ Params `{ sessionId }` → `{ summary }`. Runs one summarization turn, appends a `session.compacted` event, and resets the model context to the summary. The event log — and therefore the replayed transcript — keeps the full history. +### `minerva/config/set_model` +Params `{ modelRef, provider?, apiKey? }` → `{ providerId }`. Persists the +model ref — and optionally a provider definition (`{ name, baseUrl?, +apiKeyEnv?, defaultModel? }`) plus API key — to global settings (written +`0600`), then swaps the kernel's live provider; the next prompt in any session +uses it. Provider construction is host-injected (`KernelOptions. +resolveProvider`), keeping the kernel free of AI SDK knowledge; hosts without +a resolver reject the method. Open sessions log a `session.model_changed` +audit event, which replay ignores. + ## Versioning `PROTOCOL_VERSION` is exchanged in `initialize`. Additive changes (new From dcb1e5e31d417835653339dc0f2c63dd65be4abe Mon Sep 17 00:00:00 2001 From: John Hu Date: Sat, 11 Jul 2026 07:43:51 +0800 Subject: [PATCH 07/12] test(cli): cover the config panel's custom-provider and error paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-stack TUI tests only drive the happy preset flow, leaving the custom-provider steps, validation errors, esc navigation, and rejected saves untested — below CI's per-file coverage threshold. Exercise them against the component directly, without the kernel plumbing. --- packages/cli/test/config-panel.test.tsx | 174 ++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 packages/cli/test/config-panel.test.tsx diff --git a/packages/cli/test/config-panel.test.tsx b/packages/cli/test/config-panel.test.tsx new file mode 100644 index 0000000..c2c6ff7 --- /dev/null +++ b/packages/cli/test/config-panel.test.tsx @@ -0,0 +1,174 @@ +import { describe, expect, test } from "bun:test"; +import { render } from "ink-testing-library"; +import { ConfigPanel, type ConfigResult, type ProviderChoice } from "../src/config-panel"; + +const CHOICES: ProviderChoice[] = [ + { + name: "anthropic", + defaultModel: "claude-opus-4-8", + keyVar: "ANTHROPIC_API_KEY", + keySource: "env", + }, + { + name: "bailian", + defaultModel: "qwen-plus", + keyVar: "DASHSCOPE_API_KEY", + keySource: "settings", + }, +]; + +function renderPanel( + options: { + choices?: ProviderChoice[]; + currentModel?: string; + onSubmit?: (result: ConfigResult) => Promise; + onCancel?: () => void; + } = {}, +) { + return render( + {})} + onCancel={options.onCancel ?? (() => {})} + />, + ); +} + +async function waitFor(predicate: () => boolean, what: string, timeoutMs = 5000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error(`timed out waiting for ${what}`); + await Bun.sleep(10); + } +} + +async function press(ui: { stdin: { write: (s: string) => void } }, keys: string) { + await Bun.sleep(30); + ui.stdin.write(keys); +} + +async function submitText(ui: { stdin: { write: (s: string) => void } }, text: string) { + await Bun.sleep(30); + if (text) ui.stdin.write(text); + await Bun.sleep(30); + ui.stdin.write("\r"); +} + +describe("ConfigPanel", () => { + test("key status and defaults render for every source", async () => { + const ui = renderPanel(); + await waitFor(() => (ui.lastFrame() ?? "").includes("Configure model"), "panel"); + const frame = ui.lastFrame() ?? ""; + expect(frame).toContain("key: env $ANTHROPIC_API_KEY"); + expect(frame).toContain("key: saved in settings"); + expect(frame).toContain("custom…"); + ui.unmount(); + }); + + test("custom provider flow: k wraps up to custom, keyless endpoint allowed", async () => { + const results: ConfigResult[] = []; + const ui = renderPanel({ + onSubmit: async (result) => { + results.push(result); + }, + }); + await waitFor(() => (ui.lastFrame() ?? "").includes("custom…"), "panel"); + + await press(ui, "k"); // wraps from the first row up to the custom row + await press(ui, "\r"); + await waitFor(() => (ui.lastFrame() ?? "").includes("Provider name"), "name step"); + await submitText(ui, "deepseek"); + await waitFor(() => (ui.lastFrame() ?? "").includes("Base URL"), "baseUrl step"); + await submitText(ui, "https://api.deepseek.com/v1"); + await waitFor(() => (ui.lastFrame() ?? "").includes("API key"), "key step"); + expect(ui.lastFrame()).toContain("leave empty if the endpoint needs no key"); + await submitText(ui, ""); // keyless custom endpoint is fine + await waitFor(() => (ui.lastFrame() ?? "").includes("Model id"), "model step"); + await submitText(ui, "deepseek-chat"); + + await waitFor(() => results.length === 1, "submit"); + expect(results[0]).toEqual({ + modelRef: "deepseek/deepseek-chat", + provider: { name: "deepseek", baseUrl: "https://api.deepseek.com/v1" }, + }); + ui.unmount(); + }); + + test("invalid and colliding names are rejected inline", async () => { + const ui = renderPanel(); + await waitFor(() => (ui.lastFrame() ?? "").includes("custom…"), "panel"); + await press(ui, "j"); + await press(ui, "j"); // down twice: anthropic → bailian → custom + await press(ui, "\r"); + await waitFor(() => (ui.lastFrame() ?? "").includes("Provider name"), "name step"); + + await submitText(ui, "bailian"); + await waitFor(() => (ui.lastFrame() ?? "").includes("already exists"), "collision error"); + ui.unmount(); + }); + + test("a non-http baseUrl is rejected inline", async () => { + const ui = renderPanel(); + await waitFor(() => (ui.lastFrame() ?? "").includes("custom…"), "panel"); + await press(ui, "k"); + await press(ui, "\r"); + await waitFor(() => (ui.lastFrame() ?? "").includes("Provider name"), "name step"); + await submitText(ui, "myproxy"); + await waitFor(() => (ui.lastFrame() ?? "").includes("Base URL"), "baseUrl step"); + await submitText(ui, "not-a-url"); + await waitFor(() => (ui.lastFrame() ?? "").includes("http(s) endpoint"), "baseUrl error"); + ui.unmount(); + }); + + test("esc steps back and cancels from the provider list", async () => { + let cancelled = false; + const ui = renderPanel({ + onCancel: () => { + cancelled = true; + }, + }); + await waitFor(() => (ui.lastFrame() ?? "").includes("custom…"), "panel"); + await press(ui, "\r"); // select anthropic + await waitFor(() => (ui.lastFrame() ?? "").includes("API key"), "key step"); + expect(ui.lastFrame()).toContain("leave empty to keep using $ANTHROPIC_API_KEY"); + await press(ui, ""); // esc → back to the provider list + await waitFor(() => (ui.lastFrame() ?? "").includes("custom…"), "back to list"); + await press(ui, ""); // esc → cancel + await waitFor(() => cancelled, "cancel"); + ui.unmount(); + }); + + test("an empty key is rejected when the provider has none anywhere", async () => { + const ui = renderPanel({ + choices: [ + { name: "openai", defaultModel: "gpt-5.2", keyVar: "OPENAI_API_KEY", keySource: "none" }, + ], + }); + await waitFor(() => (ui.lastFrame() ?? "").includes("no key"), "panel"); + await press(ui, "\r"); + await waitFor(() => (ui.lastFrame() ?? "").includes("API key"), "key step"); + await submitText(ui, ""); + await waitFor(() => (ui.lastFrame() ?? "").includes("no existing key"), "key required error"); + ui.unmount(); + }); + + test("a rejected save shows the error and keeps the panel open", async () => { + const ui = renderPanel({ + currentModel: "anthropic/claude-opus-4-8", + onSubmit: async () => { + throw new Error("kernel said no"); + }, + }); + await waitFor(() => (ui.lastFrame() ?? "").includes("custom…"), "panel"); + await press(ui, "\r"); // anthropic; current model keeps its id as the prefill + await waitFor(() => (ui.lastFrame() ?? "").includes("API key"), "key step"); + await submitText(ui, ""); // env key exists — keep it + await waitFor(() => (ui.lastFrame() ?? "").includes("Model id"), "model step"); + await submitText(ui, ""); + await waitFor(() => (ui.lastFrame() ?? "").includes("kernel said no"), "submit error"); + expect(ui.lastFrame()).toContain("Model id"); // still on the step, input preserved + ui.unmount(); + }); +}); From 52bcbc3c186e2d6145f7ea3b62e10e828fa1c062 Mon Sep 17 00:00:00 2001 From: John Hu Date: Sat, 11 Jul 2026 08:03:33 +0800 Subject: [PATCH 08/12] feat(providers): known-models lists with GLM-5.2 on the bailian preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bailian hosts third-party models alongside qwen (e.g. Zhipu's GLM), but nothing surfaced them: the config panel prefilled only qwen-plus and any other id had to be known and typed blind. Providers now carry an optional models list — suggestions, never a restriction — which the panel cycles with up/down at the model step while free text keeps working. The bailian preset ships qwen-plus/-max/-turbo and glm-5.2; settings can replace the list per provider. The cycle position lives in a ref for the same reason as the provider selector's highlight: rapid key events can reach React as one batch. --- CHANGELOG.md | 3 ++ README.md | 10 +++-- packages/cli/src/config-panel.tsx | 47 +++++++++++++++++++----- packages/cli/src/index.tsx | 1 + packages/cli/test/config-panel.test.tsx | 35 ++++++++++++++++++ packages/providers/src/registry.ts | 9 ++++- packages/providers/test/registry.test.ts | 14 +++++++ 7 files changed, 105 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdac1a7..ec24f0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ All notable changes to Minerva are documented here. The format follows - Alibaba Bailian (DashScope) provider preset (`bailian/qwen-plus`, `DASHSCOPE_API_KEY`) and settings-defined custom OpenAI-compatible providers (any name + `baseUrl`), via `@ai-sdk/openai-compatible`. +- Known-models lists on providers (`models` in the registry and settings): + the bailian preset ships qwen-plus/-max/-turbo and GLM-5.2, cyclable with + ↑/↓ at the `/config` panel's model step; free-text ids still work. - Interactive `/config` panel in the TUI: pick a provider, enter an API key (masked), choose a model. Runs automatically on first launch when no key is found — replacing the old print-error-and-exit behavior — and applies live diff --git a/README.md b/README.md index 72d4fa6..0748a8f 100644 --- a/README.md +++ b/README.md @@ -67,12 +67,14 @@ in global settings** (set either via `/config`): |---|---|---| | Anthropic (default) | `claude-opus-4-8` or `anthropic/claude-opus-4-8` | `ANTHROPIC_API_KEY` | | OpenAI | `openai/gpt-5.2` | `OPENAI_API_KEY` | -| Alibaba Bailian (DashScope) | `bailian/qwen-plus` | `DASHSCOPE_API_KEY` | +| Alibaba Bailian (DashScope) | `bailian/qwen-plus`, `bailian/glm-5.2` | `DASHSCOPE_API_KEY` | Bailian uses the China endpoint by default; for the international one, -override its `baseUrl` in settings (see below). Any other OpenAI-compatible -endpoint (DeepSeek, Ollama, a proxy…) can be added as a custom provider — -via `/config` → `custom…`, or directly in settings. +override its `baseUrl` in settings (see below). Bailian hosts third-party +models too (e.g. Zhipu's GLM) — the `/config` panel cycles the known ids with +↑/↓ at the model step, and any id it doesn't know can simply be typed. Any +other OpenAI-compatible endpoint (DeepSeek, Ollama, a proxy…) can be added as +a custom provider — via `/config` → `custom…`, or directly in settings. ## Configuration diff --git a/packages/cli/src/config-panel.tsx b/packages/cli/src/config-panel.tsx index 99e579a..977daf4 100644 --- a/packages/cli/src/config-panel.tsx +++ b/packages/cli/src/config-panel.tsx @@ -18,6 +18,8 @@ export interface ProviderChoice { /** Where a usable key was found, if anywhere. */ keySource: "env" | "settings" | "none"; baseUrl?: string | undefined; + /** Known model ids — cycled with ↑/↓ at the model step; free text still wins. */ + models?: string[] | undefined; } export interface ConfigResult { @@ -61,6 +63,10 @@ export function ConfigPanel({ const rows = providers.length + 1; // trailing "custom…" row const selected = custom ? undefined : providers[index]; + const suggestions = selected?.models ?? []; + // Position within `suggestions` while cycling; -1 = free text. A ref for + // the same batching reason as indexRef. + const suggestionRef = useRef(-1); const chooseProvider = (i: number) => { setError(null); @@ -77,10 +83,24 @@ export function ConfigPanel({ const slash = currentModel.indexOf("/"); const currentProvider = slash === -1 ? "anthropic" : currentModel.slice(0, slash); const currentModelId = slash === -1 ? currentModel : currentModel.slice(slash + 1); - setModel(currentProvider === choice.name ? currentModelId : (choice.defaultModel ?? "")); + const prefill = currentProvider === choice.name ? currentModelId : (choice.defaultModel ?? ""); + setModel(prefill); + suggestionRef.current = (choice.models ?? []).indexOf(prefill); setStep("apiKey"); }; + const cycleSuggestion = (delta: number) => { + if (suggestions.length === 0) return; + suggestionRef.current = + suggestionRef.current === -1 + ? delta > 0 + ? 0 + : suggestions.length - 1 + : (suggestionRef.current + suggestions.length + delta) % suggestions.length; + const next = suggestions[suggestionRef.current]; + if (next !== undefined) setModel(next); + }; + const stepBack = () => { setError(null); if (step === "model") setStep("apiKey"); @@ -103,6 +123,10 @@ export function ConfigPanel({ else if (key.escape) onCancel(); return; } + if (step === "model") { + if (key.upArrow) cycleSuggestion(-1); + else if (key.downArrow) cycleSuggestion(1); + } if (key.escape) stepBack(); }); @@ -229,14 +253,19 @@ export function ConfigPanel({ ) : null} {step === "model" ? ( - - Model id: - + + + Model id: + + + {suggestions.length > 0 ? ( + known: {suggestions.join(" · ")} (↑/↓ cycle, or type any id) + ) : null} ) : null} diff --git a/packages/cli/src/index.tsx b/packages/cli/src/index.tsx index b49af62..fc13d20 100644 --- a/packages/cli/src/index.tsx +++ b/packages/cli/src/index.tsx @@ -124,6 +124,7 @@ const providerChoices: ProviderChoice[] = Object.entries(registry).map(([name, d ? ("settings" as const) : ("none" as const), baseUrl: def.baseURL, + models: def.models, })); // The CLI embeds the kernel, but only across the protocol's in-proc diff --git a/packages/cli/test/config-panel.test.tsx b/packages/cli/test/config-panel.test.tsx index c2c6ff7..2fc076e 100644 --- a/packages/cli/test/config-panel.test.tsx +++ b/packages/cli/test/config-panel.test.tsx @@ -140,6 +140,41 @@ describe("ConfigPanel", () => { ui.unmount(); }); + test("↑/↓ cycles the provider's known models at the model step", async () => { + const results: ConfigResult[] = []; + const ui = renderPanel({ + choices: [ + { + name: "bailian", + defaultModel: "qwen-plus", + keyVar: "DASHSCOPE_API_KEY", + keySource: "none", + models: ["qwen-plus", "qwen-max", "qwen-turbo", "glm-5.2"], + }, + ], + onSubmit: async (result) => { + results.push(result); + }, + }); + await waitFor(() => (ui.lastFrame() ?? "").includes("bailian"), "panel"); + await press(ui, "\r"); + await waitFor(() => (ui.lastFrame() ?? "").includes("API key"), "key step"); + await submitText(ui, "sk-x"); + await waitFor(() => (ui.lastFrame() ?? "").includes("Model id"), "model step"); + expect(ui.lastFrame()).toContain("known: qwen-plus · qwen-max · qwen-turbo · glm-5.2"); + + await press(ui, ""); // ↑ from the qwen-plus prefill wraps to glm-5.2 + // Twice in the frame: once in the hint line, once as the input value. + await waitFor( + () => ((ui.lastFrame() ?? "").match(/glm-5\.2/g) ?? []).length >= 2, + "cycled model", + ); + await press(ui, "\r"); + await waitFor(() => results.length === 1, "submit"); + expect(results[0]?.modelRef).toBe("bailian/glm-5.2"); + ui.unmount(); + }); + test("an empty key is rejected when the provider has none anywhere", async () => { const ui = renderPanel({ choices: [ diff --git a/packages/providers/src/registry.ts b/packages/providers/src/registry.ts index a0fc3c9..fbffcf2 100644 --- a/packages/providers/src/registry.ts +++ b/packages/providers/src/registry.ts @@ -24,6 +24,8 @@ export interface ProviderDef { baseURL?: string | undefined; /** Prefill for the config panel; never used to complete bare refs. */ defaultModel?: string | undefined; + /** Known model ids — config-panel suggestions, never a restriction. */ + models?: string[] | undefined; } export type ProviderRegistry = Record; @@ -40,12 +42,14 @@ export const BUILTIN_PROVIDERS: ProviderRegistry = { defaultModel: DEFAULT_OPENAI_MODEL, }, // Alibaba Bailian (DashScope), OpenAI-compatible mode. China endpoint; - // override baseUrl in settings for dashscope-intl.aliyuncs.com. + // override baseUrl in settings for dashscope-intl.aliyuncs.com. Bailian + // hosts third-party models too (e.g. Zhipu's GLM), hence the mixed list. bailian: { kind: "openai-compatible", apiKeyEnv: "DASHSCOPE_API_KEY", baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", defaultModel: "qwen-plus", + models: ["qwen-plus", "qwen-max", "qwen-turbo", "glm-5.2"], }, }; @@ -54,6 +58,7 @@ export interface CustomProviderConfig { baseUrl?: string | undefined; apiKeyEnv?: string | undefined; defaultModel?: string | undefined; + models?: string[] | undefined; } // Names must survive ref parsing ("name/model") and env-var derivation. @@ -76,6 +81,7 @@ export function buildProviderRegistry( ...(config.baseUrl !== undefined ? { baseURL: config.baseUrl } : {}), ...(config.apiKeyEnv !== undefined ? { apiKeyEnv: config.apiKeyEnv } : {}), ...(config.defaultModel !== undefined ? { defaultModel: config.defaultModel } : {}), + ...(config.models !== undefined ? { models: config.models } : {}), }; continue; } @@ -92,6 +98,7 @@ export function buildProviderRegistry( apiKeyEnv: config.apiKeyEnv ?? `${name.toUpperCase().replaceAll("-", "_")}_API_KEY`, baseURL: config.baseUrl, ...(config.defaultModel !== undefined ? { defaultModel: config.defaultModel } : {}), + ...(config.models !== undefined ? { models: config.models } : {}), }; } return registry; diff --git a/packages/providers/test/registry.test.ts b/packages/providers/test/registry.test.ts index a10960d..d4bc7e3 100644 --- a/packages/providers/test/registry.test.ts +++ b/packages/providers/test/registry.test.ts @@ -91,9 +91,23 @@ describe("provider registry", () => { apiKeyEnv: "DASHSCOPE_API_KEY", baseURL: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", defaultModel: "qwen-plus", + models: ["qwen-plus", "qwen-max", "qwen-turbo", "glm-5.2"], }); }); + test("bailian lists glm-5.2 among known models, and refs to it resolve", () => { + const providers = buildProviderRegistry(); + expect(providers.bailian?.models).toContain("glm-5.2"); + expect(createProviderFromRef("bailian/glm-5.2", { providers, apiKey: "sk-test" }).id).toBe( + "bailian/glm-5.2", + ); + }); + + test("settings can replace a builtin's known-models list", () => { + const providers = buildProviderRegistry({ bailian: { models: ["glm-5.2"] } }); + expect(providers.bailian?.models).toEqual(["glm-5.2"]); + }); + test("a custom provider without a baseUrl is rejected", () => { expect(() => buildProviderRegistry({ deepseek: {} })).toThrow("needs a baseUrl"); }); From ebbdf042a40890e08538f7ae1fcf5f9046be763f Mon Sep 17 00:00:00 2001 From: John Hu Date: Sat, 11 Jul 2026 08:12:56 +0800 Subject: [PATCH 09/12] fix(cli): offer known models as a select list in the config panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model step showed a free-text input with the known models buried in a dim wrapped hint line ("known: … (↑/↓ cycle)") — users looking to pick glm-5.2 on bailian couldn't see anything selectable. Render the list as highlighted rows like the provider step instead: ↑/↓ moves, enter saves, the provider's default is annotated, and a trailing "other…" row drops into free-text entry for ids the preset doesn't know (esc returns to the list). Providers without a models list keep the plain text input. Verified against the real binary over a PTY: first run → bailian → key → arrow down to glm-5.2 → enter persists bailian/glm-5.2. --- CHANGELOG.md | 5 +- README.md | 9 +- packages/cli/src/config-panel.tsx | 111 ++++++++++++++++-------- packages/cli/test/config-panel.test.tsx | 63 ++++++++++---- 4 files changed, 128 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec24f0e..0378cb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,9 @@ All notable changes to Minerva are documented here. The format follows `DASHSCOPE_API_KEY`) and settings-defined custom OpenAI-compatible providers (any name + `baseUrl`), via `@ai-sdk/openai-compatible`. - Known-models lists on providers (`models` in the registry and settings): - the bailian preset ships qwen-plus/-max/-turbo and GLM-5.2, cyclable with - ↑/↓ at the `/config` panel's model step; free-text ids still work. + the bailian preset ships qwen-plus/-max/-turbo and GLM-5.2, offered as a + select list at the `/config` panel's model step, with an `other…` row for + free-text ids. - Interactive `/config` panel in the TUI: pick a provider, enter an API key (masked), choose a model. Runs automatically on first launch when no key is found — replacing the old print-error-and-exit behavior — and applies live diff --git a/README.md b/README.md index 0748a8f..a8c1ac5 100644 --- a/README.md +++ b/README.md @@ -71,10 +71,11 @@ in global settings** (set either via `/config`): Bailian uses the China endpoint by default; for the international one, override its `baseUrl` in settings (see below). Bailian hosts third-party -models too (e.g. Zhipu's GLM) — the `/config` panel cycles the known ids with -↑/↓ at the model step, and any id it doesn't know can simply be typed. Any -other OpenAI-compatible endpoint (DeepSeek, Ollama, a proxy…) can be added as -a custom provider — via `/config` → `custom…`, or directly in settings. +models too (e.g. Zhipu's GLM) — the `/config` panel lists the known ids to +pick from at the model step, with an `other…` row for any id it doesn't know. +Any other OpenAI-compatible endpoint (DeepSeek, Ollama, a proxy…) can be +added as a custom provider — via `/config` → `custom…`, or directly in +settings. ## Configuration diff --git a/packages/cli/src/config-panel.tsx b/packages/cli/src/config-panel.tsx index 977daf4..bce48f1 100644 --- a/packages/cli/src/config-panel.tsx +++ b/packages/cli/src/config-panel.tsx @@ -18,7 +18,8 @@ export interface ProviderChoice { /** Where a usable key was found, if anywhere. */ keySource: "env" | "settings" | "none"; baseUrl?: string | undefined; - /** Known model ids — cycled with ↑/↓ at the model step; free text still wins. */ + /** Known model ids — offered as a select list at the model step; the + * trailing "other…" row still accepts any id as free text. */ models?: string[] | undefined; } @@ -64,9 +65,14 @@ export function ConfigPanel({ const rows = providers.length + 1; // trailing "custom…" row const selected = custom ? undefined : providers[index]; const suggestions = selected?.models ?? []; - // Position within `suggestions` while cycling; -1 = free text. A ref for - // the same batching reason as indexRef. - const suggestionRef = useRef(-1); + // Model-step list highlight (same ref-mirror pattern as indexRef). + const [modelIndex, setModelIndex] = useState(0); + const modelIndexRef = useRef(0); + const modelRows = suggestions.length + 1; // trailing "other…" row + // Free-text entry: always for providers without a models list; opt-in via + // the "other…" row when there is one. + const [modelFreeText, setModelFreeText] = useState(false); + const modelList = step === "model" && suggestions.length > 0 && !modelFreeText; const chooseProvider = (i: number) => { setError(null); @@ -85,22 +91,13 @@ export function ConfigPanel({ const currentModelId = slash === -1 ? currentModel : currentModel.slice(slash + 1); const prefill = currentProvider === choice.name ? currentModelId : (choice.defaultModel ?? ""); setModel(prefill); - suggestionRef.current = (choice.models ?? []).indexOf(prefill); + const known = (choice.models ?? []).indexOf(prefill); + modelIndexRef.current = known === -1 ? 0 : known; + setModelIndex(modelIndexRef.current); + setModelFreeText(false); setStep("apiKey"); }; - const cycleSuggestion = (delta: number) => { - if (suggestions.length === 0) return; - suggestionRef.current = - suggestionRef.current === -1 - ? delta > 0 - ? 0 - : suggestions.length - 1 - : (suggestionRef.current + suggestions.length + delta) % suggestions.length; - const next = suggestions[suggestionRef.current]; - if (next !== undefined) setModel(next); - }; - const stepBack = () => { setError(null); if (step === "model") setStep("apiKey"); @@ -114,6 +111,11 @@ export function ConfigPanel({ setIndex(indexRef.current); }; + const moveModelIndex = (delta: number) => { + modelIndexRef.current = (modelIndexRef.current + modelRows + delta) % modelRows; + setModelIndex(modelIndexRef.current); + }; + useInput((input, key) => { if (saving) return; if (step === "provider") { @@ -123,11 +125,35 @@ export function ConfigPanel({ else if (key.escape) onCancel(); return; } - if (step === "model") { - if (key.upArrow) cycleSuggestion(-1); - else if (key.downArrow) cycleSuggestion(1); + if (modelList) { + if (key.upArrow || input === "k") moveModelIndex(-1); + else if (key.downArrow || input === "j") moveModelIndex(1); + else if (key.return) { + const i = modelIndexRef.current; + if (i === suggestions.length) { + // "other…" — hand over to free text; keep the draft only when it + // isn't just the highlighted suggestion. + if (suggestions.includes(model)) setModel(""); + setModelFreeText(true); + } else { + const value = suggestions[i]; + if (value !== undefined) { + setModel(value); + void submitModel(value); + } + } + } else if (key.escape) stepBack(); + return; + } + if (key.escape) { + // esc from free text returns to the model list when there is one. + if (step === "model" && modelFreeText && suggestions.length > 0) { + setError(null); + setModelFreeText(false); + } else { + stepBack(); + } } - if (key.escape) stepBack(); }); const submitName = (value: string) => { @@ -252,26 +278,41 @@ export function ConfigPanel({ ) : null} - {step === "model" ? ( + {modelList ? ( - - Model id: - - - {suggestions.length > 0 ? ( - known: {suggestions.join(" · ")} (↑/↓ cycle, or type any id) - ) : null} + {suggestions.map((id, i) => ( + + {i === modelIndex ? "❯ " : " "} + {id} + {id === selected?.defaultModel ? (default) : null} + + ))} + + {modelIndex === suggestions.length ? "❯ " : " "}other…{" "} + type a model id + + + ) : null} + + {step === "model" && !modelList ? ( + + Model id: + ) : null} {saving ? saving… : null} {error ? {error} : null} - {step !== "provider" && !saving ? enter confirm · esc back : null} + {step !== "provider" && !saving ? ( + + {modelList ? "↑/↓ select · enter save · esc back" : "enter confirm · esc back"} + + ) : null} ); } diff --git a/packages/cli/test/config-panel.test.tsx b/packages/cli/test/config-panel.test.tsx index 2fc076e..7bcc79a 100644 --- a/packages/cli/test/config-panel.test.tsx +++ b/packages/cli/test/config-panel.test.tsx @@ -17,6 +17,14 @@ const CHOICES: ProviderChoice[] = [ }, ]; +const BAILIAN_WITH_MODELS: ProviderChoice = { + name: "bailian", + defaultModel: "qwen-plus", + keyVar: "DASHSCOPE_API_KEY", + keySource: "none", + models: ["qwen-plus", "qwen-max", "qwen-turbo", "glm-5.2"], +}; + function renderPanel( options: { choices?: ProviderChoice[]; @@ -140,18 +148,10 @@ describe("ConfigPanel", () => { ui.unmount(); }); - test("↑/↓ cycles the provider's known models at the model step", async () => { + test("known models render as a select list; picking glm-5.2 saves it", async () => { const results: ConfigResult[] = []; const ui = renderPanel({ - choices: [ - { - name: "bailian", - defaultModel: "qwen-plus", - keyVar: "DASHSCOPE_API_KEY", - keySource: "none", - models: ["qwen-plus", "qwen-max", "qwen-turbo", "glm-5.2"], - }, - ], + choices: [BAILIAN_WITH_MODELS], onSubmit: async (result) => { results.push(result); }, @@ -160,21 +160,46 @@ describe("ConfigPanel", () => { await press(ui, "\r"); await waitFor(() => (ui.lastFrame() ?? "").includes("API key"), "key step"); await submitText(ui, "sk-x"); - await waitFor(() => (ui.lastFrame() ?? "").includes("Model id"), "model step"); - expect(ui.lastFrame()).toContain("known: qwen-plus · qwen-max · qwen-turbo · glm-5.2"); - - await press(ui, ""); // ↑ from the qwen-plus prefill wraps to glm-5.2 - // Twice in the frame: once in the hint line, once as the input value. - await waitFor( - () => ((ui.lastFrame() ?? "").match(/glm-5\.2/g) ?? []).length >= 2, - "cycled model", - ); + await waitFor(() => (ui.lastFrame() ?? "").includes("other…"), "model list"); + const frame = ui.lastFrame() ?? ""; + for (const id of BAILIAN_WITH_MODELS.models ?? []) expect(frame).toContain(id); + expect(frame).toContain("(default)"); + + await press(ui, "k"); // ↑ from the qwen-plus prefill wraps to other… + await press(ui, "k"); // …and once more up to glm-5.2 await press(ui, "\r"); await waitFor(() => results.length === 1, "submit"); expect(results[0]?.modelRef).toBe("bailian/glm-5.2"); ui.unmount(); }); + test("the other… row switches to free text; esc returns to the list", async () => { + const results: ConfigResult[] = []; + const ui = renderPanel({ + choices: [BAILIAN_WITH_MODELS], + onSubmit: async (result) => { + results.push(result); + }, + }); + await waitFor(() => (ui.lastFrame() ?? "").includes("bailian"), "panel"); + await press(ui, "\r"); + await waitFor(() => (ui.lastFrame() ?? "").includes("API key"), "key step"); + await submitText(ui, "sk-x"); + await waitFor(() => (ui.lastFrame() ?? "").includes("other…"), "model list"); + + await press(ui, "k"); // wrap up to other… + await press(ui, "\r"); + await waitFor(() => (ui.lastFrame() ?? "").includes("Model id"), "free text"); + await press(ui, ""); // esc → back to the list + await waitFor(() => (ui.lastFrame() ?? "").includes("other…"), "list again"); + await press(ui, "\r"); // other… is still the highlighted row + await waitFor(() => (ui.lastFrame() ?? "").includes("Model id"), "free text again"); + await submitText(ui, "qwen-long"); // draft was cleared, not prefilled + await waitFor(() => results.length === 1, "submit"); + expect(results[0]?.modelRef).toBe("bailian/qwen-long"); + ui.unmount(); + }); + test("an empty key is rejected when the provider has none anywhere", async () => { const ui = renderPanel({ choices: [ From 2a860ef954726b3abc241606d3358a7d521cd9db Mon Sep 17 00:00:00 2001 From: John Hu Date: Sat, 11 Jul 2026 09:22:22 +0800 Subject: [PATCH 10/12] fix(cli): list each provider's known models on the panel's start screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider list showed exactly one model id per row (the default), so the opening screen read as a three-model picker — someone looking for glm-5.2 on bailian saw only qwen-plus and concluded it wasn't there. Each provider row now carries a dim second line with its full known- models list (falling back to the default model when no list exists), so what's selectable is visible before committing to a provider. Also drop the "— " title suffix on this step: it echoed whichever row was highlighted and read as an already-made choice. --- packages/cli/src/config-panel.tsx | 32 +++++++++++++++++++------ packages/cli/test/config-panel.test.tsx | 11 +++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/config-panel.tsx b/packages/cli/src/config-panel.tsx index bce48f1..62e96d8 100644 --- a/packages/cli/src/config-panel.tsx +++ b/packages/cli/src/config-panel.tsx @@ -220,7 +220,14 @@ export function ConfigPanel({ return ( - Configure model{custom && name ? ` — ${name}` : selected ? ` — ${selected.name}` : ""} + Configure model + {step === "provider" + ? "" + : custom && name + ? ` — ${name}` + : selected + ? ` — ${selected.name}` + : ""} {firstRun && step === "provider" ? ( @@ -231,12 +238,17 @@ export function ConfigPanel({ {step === "provider" ? ( {providers.map((choice, i) => ( - - {i === index ? "❯ " : " "} - {choice.name.padEnd(12)} - {(choice.defaultModel ?? "").padEnd(22)} - {keyStatus(choice)} - + + + {i === index ? "❯ " : " "} + {choice.name.padEnd(12)} + {keyStatus(choice)} + + + {" "} + {modelsLine(choice)} + + ))} {index === providers.length ? "❯ " : " "}custom…{" "} @@ -317,6 +329,12 @@ export function ConfigPanel({ ); } +/** All of a provider's known models, so the list doesn't read as one-model-per-provider. */ +function modelsLine(choice: ProviderChoice): string { + if (choice.models && choice.models.length > 0) return choice.models.join(" · "); + return choice.defaultModel ?? "any model id"; +} + function keyStatus(choice: ProviderChoice): string { switch (choice.keySource) { case "env": diff --git a/packages/cli/test/config-panel.test.tsx b/packages/cli/test/config-panel.test.tsx index 7bcc79a..4424eb4 100644 --- a/packages/cli/test/config-panel.test.tsx +++ b/packages/cli/test/config-panel.test.tsx @@ -75,6 +75,17 @@ describe("ConfigPanel", () => { ui.unmount(); }); + test("the provider list shows every known model up front, not just defaults", async () => { + const ui = renderPanel({ choices: [CHOICES[0] as ProviderChoice, BAILIAN_WITH_MODELS] }); + await waitFor(() => (ui.lastFrame() ?? "").includes("Configure model"), "panel"); + const frame = ui.lastFrame() ?? ""; + // A provider without a models list falls back to its default model… + expect(frame).toContain("claude-opus-4-8"); + // …and one with a list shows all of it on the start screen. + for (const id of BAILIAN_WITH_MODELS.models ?? []) expect(frame).toContain(id); + ui.unmount(); + }); + test("custom provider flow: k wraps up to custom, keyless endpoint allowed", async () => { const results: ConfigResult[] = []; const ui = renderPanel({ From 96f5927bd9f76496f075a7bd68b06980f0b14874 Mon Sep 17 00:00:00 2001 From: John Hu Date: Sat, 11 Jul 2026 09:33:22 +0800 Subject: [PATCH 11/12] chore: gitignore .minerva project-settings dirs Running the TUI inside this repo persists "always allow" permission rules to /.minerva/settings.json, which then shows up as untracked noise (with machine-specific paths) after every dev or e2e session. Ignore it; a team that wants deliberately shared project settings can force-add the file. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b754db1..8f08ac8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ coverage/ .env .env.* .DS_Store +# Project-layer settings written by running the minerva TUI inside this repo +# (persisted "always allow" rules) — local dev state, not shared config. +.minerva/ From bc2cf37f12559250fa470f193092320470949e6d Mon Sep 17 00:00:00 2001 From: John Hu Date: Sat, 11 Jul 2026 09:45:50 +0800 Subject: [PATCH 12/12] fix: address review findings on API-key handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round on PR #7 surfaced two real gaps and two nitpicks: - Blank keys count as absent: an exported-but-empty env var used to win the ?? chain in resolveApiKey, masking a usable stored key while the provider row claimed "key: saved in settings". resolveApiKey and the panel's keySource computation now ignore empty/whitespace values. - Keyless endpoints are first-class: the panel allowed saving a custom provider without a key, but startup still gated on !apiKey, forcing local endpoints (e.g. Ollama) back into setup every launch and blocking `minerva acp` outright. Providers now carry requiresApiKey: false — persisted automatically when the panel saves a keyless custom provider, settable in settings — and both the first-run gate and the acp hard exit honor it. - The model-ref rollback after a rejected switch no longer swallows write failures; they surface on stderr, since settings would be left pointing at the rejected model with no other trace of why. - Align the anthropic branch's option spreads in createProviderFromRef with the other branches (truthy checks). --- CHANGELOG.md | 4 ++- README.md | 8 ++++-- docs/PROTOCOL.md | 7 +++-- packages/cli/src/config-panel.tsx | 26 ++++++++++++++---- packages/cli/src/index.tsx | 14 +++++++--- packages/cli/test/config-panel.test.tsx | 35 +++++++++++++++++++++++- packages/kernel/src/kernel.ts | 13 ++++++++- packages/kernel/src/settings.ts | 2 ++ packages/kernel/test/config.test.ts | 18 ++++++++++++ packages/protocol/src/types.ts | 2 ++ packages/providers/src/registry.ts | 25 ++++++++++++++--- packages/providers/test/registry.test.ts | 26 ++++++++++++++++++ 12 files changed, 157 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0378cb5..b54604d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,9 @@ All notable changes to Minerva are documented here. The format follows ### Added - Alibaba Bailian (DashScope) provider preset (`bailian/qwen-plus`, `DASHSCOPE_API_KEY`) and settings-defined custom OpenAI-compatible - providers (any name + `baseUrl`), via `@ai-sdk/openai-compatible`. + providers (any name + `baseUrl`), via `@ai-sdk/openai-compatible`. Keyless + endpoints (e.g. local servers) persist `requiresApiKey: false`, so startup + and `minerva acp` don't demand a key they don't need. - Known-models lists on providers (`models` in the registry and settings): the bailian preset ships qwen-plus/-max/-turbo and GLM-5.2, offered as a select list at the `/config` panel's model step, with an `other…` row for diff --git a/README.md b/README.md index a8c1ac5..8e76a6a 100644 --- a/README.md +++ b/README.md @@ -110,9 +110,11 @@ Settings merge from `~/.minerva/settings.json` (global; override the root with `model` picks the default model ref (`--model` and `MINERVA_MODEL` override it). `providers` overrides a built-in (e.g. Bailian's endpoint) or defines a new OpenAI-compatible one — a new name needs `baseUrl`, and its key env var -defaults to `_API_KEY`. **API keys are honored from the global file -only** (never the shareable project file), and any file that may hold keys is -written with mode `0600`. +defaults to `_API_KEY`. Keyless endpoints (e.g. a local Ollama) set +`"requiresApiKey": false` so startup doesn't demand a key — the `/config` +panel writes this automatically when you save a custom provider without one. +**API keys are honored from the global file only** (never the shareable +project file), and any file that may hold keys is written with mode `0600`. Permission rules are `tool` or `tool(pattern)` where `*` matches any run of characters, `?` one character, and `\*` a literal asterisk. Precedence: diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index ee6a4e8..5052764 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -106,9 +106,10 @@ event log — and therefore the replayed transcript — keeps the full history. ### `minerva/config/set_model` Params `{ modelRef, provider?, apiKey? }` → `{ providerId }`. Persists the model ref — and optionally a provider definition (`{ name, baseUrl?, -apiKeyEnv?, defaultModel? }`) plus API key — to global settings (written -`0600`), then swaps the kernel's live provider; the next prompt in any session -uses it. Provider construction is host-injected (`KernelOptions. +apiKeyEnv?, defaultModel?, requiresApiKey? }`) plus API key — to global +settings (written `0600`), then swaps the kernel's live provider; the next +prompt in any session uses it. `requiresApiKey: false` marks a keyless +endpoint so hosts don't demand a key at startup. Provider construction is host-injected (`KernelOptions. resolveProvider`), keeping the kernel free of AI SDK knowledge; hosts without a resolver reject the method. Open sessions log a `session.model_changed` audit event, which replay ignores. diff --git a/packages/cli/src/config-panel.tsx b/packages/cli/src/config-panel.tsx index 62e96d8..c8117c9 100644 --- a/packages/cli/src/config-panel.tsx +++ b/packages/cli/src/config-panel.tsx @@ -21,12 +21,14 @@ export interface ProviderChoice { /** Known model ids — offered as a select list at the model step; the * trailing "other…" row still accepts any id as free text. */ models?: string[] | undefined; + /** false = keyless endpoint; an empty key entry is fine, not a gap. */ + requiresApiKey?: boolean | undefined; } export interface ConfigResult { modelRef: string; /** Present for newly defined custom providers. */ - provider?: { name: string; baseUrl?: string | undefined }; + provider?: { name: string; baseUrl?: string | undefined; requiresApiKey?: boolean }; /** Omitted when the user kept the existing env/stored key. */ apiKey?: string; } @@ -184,9 +186,14 @@ export function ConfigPanel({ const submitApiKey = (value: string) => { const trimmed = value.trim(); - // Custom endpoints may be keyless (local servers); known providers need - // a key from somewhere. - if (!trimmed && !custom && selected?.keySource === "none") { + // Custom and keyless endpoints may go without; other known providers + // need a key from somewhere. + if ( + !trimmed && + !custom && + selected?.keySource === "none" && + selected.requiresApiKey !== false + ) { setError(`no existing key for ${selected.name} — enter one`); return; } @@ -204,7 +211,11 @@ export function ConfigPanel({ const providerName = custom ? name : (selected?.name ?? "anthropic"); const result: ConfigResult = { modelRef: `${providerName}/${trimmed}`, - ...(custom ? { provider: { name: providerName, baseUrl } } : {}), + // Persist keylessness with the definition, or the next startup would + // demand the key this endpoint doesn't have. + ...(custom + ? { provider: { name: providerName, baseUrl, requiresApiKey: apiKey !== "" } } + : {}), ...(apiKey ? { apiKey } : {}), }; setSaving(true); @@ -342,12 +353,15 @@ function keyStatus(choice: ProviderChoice): string { case "settings": return "key: saved in settings"; case "none": - return "no key"; + return choice.requiresApiKey === false ? "no key required" : "no key"; } } function apiKeyPlaceholder(custom: boolean, selected: ProviderChoice | undefined): string { if (custom) return "leave empty if the endpoint needs no key"; + if (selected?.requiresApiKey === false && selected.keySource === "none") { + return "leave empty — this endpoint needs no key"; + } if (selected && selected.keySource !== "none") { return selected.keySource === "env" ? `leave empty to keep using $${selected.keyVar}` diff --git a/packages/cli/src/index.tsx b/packages/cli/src/index.tsx index fc13d20..619690a 100644 --- a/packages/cli/src/index.tsx +++ b/packages/cli/src/index.tsx @@ -74,6 +74,9 @@ const apiKey = resolveApiKey(providerName, registry, { env: process.env, storedKeys: storedKeys(settings), }); +// Keyless endpoints (requiresApiKey: false, e.g. local servers) must not be +// forced into setup — only gate on a missing key when one is actually needed. +const missingRequiredKey = !apiKey && registry[providerName]?.requiresApiKey !== false; /** * Host-injected factory for minerva/config/set_model. Re-reads settings so @@ -101,7 +104,7 @@ const kernelOptions = { if (command === "acp") { // stdout carries the protocol — no UI, so a missing key stays a hard exit. - if (!apiKey) { + if (missingRequiredKey) { const keyVar = apiKeyEnvVar(providerName, registry); console.error(`${keyVar} is not set (required for ${model}). Export it and try again:`); console.error(` export ${keyVar}="..."`); @@ -118,13 +121,16 @@ const providerChoices: ProviderChoice[] = Object.entries(registry).map(([name, d name, defaultModel: def.defaultModel, keyVar: def.apiKeyEnv, - keySource: process.env[def.apiKeyEnv] + // Blank-aware, matching resolveApiKey — an exported-but-empty env var + // must not display as a usable key. + keySource: process.env[def.apiKeyEnv]?.trim() ? ("env" as const) - : settings.providers[name]?.apiKey + : settings.providers[name]?.apiKey?.trim() ? ("settings" as const) : ("none" as const), baseUrl: def.baseURL, models: def.models, + requiresApiKey: def.requiresApiKey, })); // The CLI embeds the kernel, but only across the protocol's in-proc @@ -145,7 +151,7 @@ const app = render( cwd={cwd} resume={resume} providers={providerChoices} - needsConfig={!apiKey} + needsConfig={missingRequiredKey} />, ); await app.waitUntilExit(); diff --git a/packages/cli/test/config-panel.test.tsx b/packages/cli/test/config-panel.test.tsx index 4424eb4..00ff834 100644 --- a/packages/cli/test/config-panel.test.tsx +++ b/packages/cli/test/config-panel.test.tsx @@ -110,11 +110,44 @@ describe("ConfigPanel", () => { await waitFor(() => results.length === 1, "submit"); expect(results[0]).toEqual({ modelRef: "deepseek/deepseek-chat", - provider: { name: "deepseek", baseUrl: "https://api.deepseek.com/v1" }, + // No key entered → keylessness persists so startup won't demand one. + provider: { + name: "deepseek", + baseUrl: "https://api.deepseek.com/v1", + requiresApiKey: false, + }, }); ui.unmount(); }); + test("a keyless known provider accepts an empty key and says so", async () => { + const results: ConfigResult[] = []; + const ui = renderPanel({ + choices: [ + { + name: "ollama", + defaultModel: "llama4", + keyVar: "OLLAMA_API_KEY", + keySource: "none", + requiresApiKey: false, + }, + ], + onSubmit: async (result) => { + results.push(result); + }, + }); + await waitFor(() => (ui.lastFrame() ?? "").includes("no key required"), "panel"); + await press(ui, "\r"); + await waitFor(() => (ui.lastFrame() ?? "").includes("API key"), "key step"); + expect(ui.lastFrame()).toContain("this endpoint needs no key"); + await submitText(ui, ""); // empty is fine here, not an error + await waitFor(() => (ui.lastFrame() ?? "").includes("Model id"), "model step"); + await submitText(ui, ""); + await waitFor(() => results.length === 1, "submit"); + expect(results[0]?.modelRef).toBe("ollama/llama4"); + ui.unmount(); + }); + test("invalid and colliding names are rejected inline", async () => { const ui = renderPanel(); await waitFor(() => (ui.lastFrame() ?? "").includes("custom…"), "panel"); diff --git a/packages/kernel/src/kernel.ts b/packages/kernel/src/kernel.ts index 1fc0315..6886311 100644 --- a/packages/kernel/src/kernel.ts +++ b/packages/kernel/src/kernel.ts @@ -348,6 +348,9 @@ export class MinervaKernel { ...(provider?.baseUrl !== undefined ? { baseUrl: provider.baseUrl } : {}), ...(provider?.apiKeyEnv !== undefined ? { apiKeyEnv: provider.apiKeyEnv } : {}), ...(provider?.defaultModel !== undefined ? { defaultModel: provider.defaultModel } : {}), + ...(provider?.requiresApiKey !== undefined + ? { requiresApiKey: provider.requiresApiKey } + : {}), ...(apiKey !== undefined ? { apiKey } : {}), }; const touched = provider !== undefined || apiKey !== undefined; @@ -367,7 +370,15 @@ export class MinervaKernel { await updateGlobalSettings(this.#runtime, this.#dataDir, (current) => ({ ...current, model: previousModel, - })).catch(() => {}); + })).catch((rollbackError) => { + // Settings now point at the rejected model — surface it, since the + // next startup will hit it with no other trace of why. + process.stderr.write( + `minerva: failed to roll back model after rejected switch: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }\n`, + ); + }); throw new RpcError( JSON_RPC_ERROR_CODES.INVALID_PARAMS, error instanceof Error ? error.message : String(error), diff --git a/packages/kernel/src/settings.ts b/packages/kernel/src/settings.ts index 9c2d2bc..dbe3313 100644 --- a/packages/kernel/src/settings.ts +++ b/packages/kernel/src/settings.ts @@ -26,6 +26,8 @@ export interface ProviderSettings { baseUrl?: string | undefined; apiKeyEnv?: string | undefined; defaultModel?: string | undefined; + /** false = keyless endpoint (e.g. a local server); startup won't demand a key. */ + requiresApiKey?: boolean | undefined; /** Only honored in the GLOBAL layer; project-layer keys are ignored. */ apiKey?: string | undefined; } diff --git a/packages/kernel/test/config.test.ts b/packages/kernel/test/config.test.ts index 976a4b8..4a90ff2 100644 --- a/packages/kernel/test/config.test.ts +++ b/packages/kernel/test/config.test.ts @@ -123,6 +123,24 @@ describe("minerva/config/set_model", () => { expect(settings.model).toBeUndefined(); }); + test("a keyless custom provider definition persists requiresApiKey", async () => { + const h = await setup({ + provider: scripted("scripted", [textTurn("hello")]), + resolveProvider: (ref) => scripted(ref, []), + }); + await h.client.request(MINERVA_METHODS.configSetModel, { + modelRef: "ollama/llama4", + provider: { name: "ollama", baseUrl: "http://localhost:11434/v1", requiresApiKey: false }, + }); + const settings = JSON.parse( + readFileSync(globalSettingsPath(h.dataDir), "utf8"), + ) as MinervaSettings; + expect(settings.providers?.ollama).toEqual({ + baseUrl: "http://localhost:11434/v1", + requiresApiKey: false, + }); + }); + test("modelRef is required", async () => { const h = await setup({ provider: scripted("scripted", [textTurn("hello")]), diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 8024e56..bca5c88 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -132,6 +132,8 @@ export interface ConfigSetModelParams { baseUrl?: string | undefined; apiKeyEnv?: string | undefined; defaultModel?: string | undefined; + /** false = keyless endpoint; hosts won't demand a key at startup. */ + requiresApiKey?: boolean | undefined; }; /** Stored in global settings only (file mode 0600). Omit to keep the env/stored key. */ apiKey?: string; diff --git a/packages/providers/src/registry.ts b/packages/providers/src/registry.ts index fbffcf2..9350db1 100644 --- a/packages/providers/src/registry.ts +++ b/packages/providers/src/registry.ts @@ -26,6 +26,8 @@ export interface ProviderDef { defaultModel?: string | undefined; /** Known model ids — config-panel suggestions, never a restriction. */ models?: string[] | undefined; + /** false = the endpoint needs no key (e.g. a local server). Default true. */ + requiresApiKey?: boolean | undefined; } export type ProviderRegistry = Record; @@ -59,6 +61,7 @@ export interface CustomProviderConfig { apiKeyEnv?: string | undefined; defaultModel?: string | undefined; models?: string[] | undefined; + requiresApiKey?: boolean | undefined; } // Names must survive ref parsing ("name/model") and env-var derivation. @@ -82,6 +85,7 @@ export function buildProviderRegistry( ...(config.apiKeyEnv !== undefined ? { apiKeyEnv: config.apiKeyEnv } : {}), ...(config.defaultModel !== undefined ? { defaultModel: config.defaultModel } : {}), ...(config.models !== undefined ? { models: config.models } : {}), + ...(config.requiresApiKey !== undefined ? { requiresApiKey: config.requiresApiKey } : {}), }; continue; } @@ -99,6 +103,7 @@ export function buildProviderRegistry( baseURL: config.baseUrl, ...(config.defaultModel !== undefined ? { defaultModel: config.defaultModel } : {}), ...(config.models !== undefined ? { models: config.models } : {}), + ...(config.requiresApiKey !== undefined ? { requiresApiKey: config.requiresApiKey } : {}), }; } return registry; @@ -161,7 +166,11 @@ export interface ResolveApiKeyOptions { storedKeys?: Record; } -/** Precedence: explicit > provider's env var > key stored in settings. */ +/** + * Precedence: explicit > provider's env var > key stored in settings. + * Blank (empty/whitespace) values count as absent at every level — an + * exported-but-empty env var must not mask a stored key. + */ export function resolveApiKey( provider: string, providers: ProviderRegistry, @@ -169,7 +178,15 @@ export function resolveApiKey( ): string | undefined { const def = providerDef(provider, providers); const env = options.env ?? process.env; - return options.explicit ?? env[def.apiKeyEnv] ?? options.storedKeys?.[provider]; + return ( + nonBlank(options.explicit) ?? + nonBlank(env[def.apiKeyEnv]) ?? + nonBlank(options.storedKeys?.[provider]) + ); +} + +function nonBlank(value: string | undefined): string | undefined { + return value && value.trim() !== "" ? value : undefined; } export interface ProviderFromRefOptions { @@ -211,8 +228,8 @@ export function createProviderFromRef( const modelId = model || DEFAULT_ANTHROPIC_MODEL; return createAnthropicProvider({ model: modelId, - ...(options.apiKey !== undefined ? { apiKey: options.apiKey } : {}), - ...(def.baseURL !== undefined ? { baseURL: def.baseURL } : {}), + ...(options.apiKey ? { apiKey: options.apiKey } : {}), + ...(def.baseURL ? { baseURL: def.baseURL } : {}), }); } } diff --git a/packages/providers/test/registry.test.ts b/packages/providers/test/registry.test.ts index d4bc7e3..2401bbb 100644 --- a/packages/providers/test/registry.test.ts +++ b/packages/providers/test/registry.test.ts @@ -121,6 +121,32 @@ describe("provider registry", () => { ); }); + test("blank keys count as absent — an empty env var must not mask a stored key", () => { + const providers = buildProviderRegistry(); + const storedKeys = { bailian: "from-settings" }; + expect( + resolveApiKey("bailian", providers, { env: { DASHSCOPE_API_KEY: "" }, storedKeys }), + ).toBe("from-settings"); + expect( + resolveApiKey("bailian", providers, { env: { DASHSCOPE_API_KEY: " " }, storedKeys }), + ).toBe("from-settings"); + expect( + resolveApiKey("bailian", providers, { explicit: "", env: { DASHSCOPE_API_KEY: "from-env" } }), + ).toBe("from-env"); + expect( + resolveApiKey("bailian", providers, { env: {}, storedKeys: { bailian: "" } }), + ).toBeUndefined(); + }); + + test("custom providers can declare keyless endpoints", () => { + const providers = buildProviderRegistry({ + ollama: { baseUrl: "http://localhost:11434/v1", requiresApiKey: false }, + }); + expect(providers.ollama?.requiresApiKey).toBe(false); + // Unset means required — the safe default. + expect(buildProviderRegistry().bailian?.requiresApiKey).toBeUndefined(); + }); + test("resolveApiKey precedence: explicit > env > stored", () => { const providers = buildProviderRegistry(); const env = { DASHSCOPE_API_KEY: "from-env" };