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/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2573b3a..b54604d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ 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`. 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 + 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 + 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..8e76a6a 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,22 @@ 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`, `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). Bailian hosts third-party +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 @@ -69,6 +84,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 +107,15 @@ 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`. 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: **deny** → **ask** → read-only auto-allow → **plan mode deny** → **allow** → 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/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..5052764 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -103,6 +103,17 @@ 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?, 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. + ## Versioning `PROTOCOL_VERSION` is exchanged in `initialize`. Additive changes (new 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/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/config-panel.tsx b/packages/cli/src/config-panel.tsx new file mode 100644 index 0000000..c8117c9 --- /dev/null +++ b/packages/cli/src/config-panel.tsx @@ -0,0 +1,371 @@ +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; + /** 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; requiresApiKey?: boolean }; + /** 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 suggestions = selected?.models ?? []; + // 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); + 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); + const prefill = currentProvider === choice.name ? currentModelId : (choice.defaultModel ?? ""); + setModel(prefill); + const known = (choice.models ?? []).indexOf(prefill); + modelIndexRef.current = known === -1 ? 0 : known; + setModelIndex(modelIndexRef.current); + setModelFreeText(false); + 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); + }; + + const moveModelIndex = (delta: number) => { + modelIndexRef.current = (modelIndexRef.current + modelRows + delta) % modelRows; + setModelIndex(modelIndexRef.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 (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(); + } + } + }); + + 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 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; + } + 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}`, + // 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); + setError(null); + try { + await onSubmit(result); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + setSaving(false); + } + }; + + return ( + + + Configure model + {step === "provider" + ? "" + : 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)} + {keyStatus(choice)} + + + {" "} + {modelsLine(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} + + {modelList ? ( + + {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 ? ( + + {modelList ? "↑/↓ select · enter save · esc back" : "enter confirm · esc back"} + + ) : null} + + ); +} + +/** 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": + return `key: env $${choice.keyVar}`; + case "settings": + return "key: saved in settings"; + case "none": + 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}` + : "leave empty to keep the saved key"; + } + return "sk-…"; +} diff --git a/packages/cli/src/index.tsx b/packages/cli/src/index.tsx index 1a87349..619690a 100644 --- a/packages/cli/src/index.tsx +++ b/packages/cli/src/index.tsx @@ -1,57 +1,137 @@ #!/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"; import { App } from "./app"; import { parseCliArgs, usage } from "./args"; +import type { ProviderChoice } from "./config-panel"; 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]) { - console.error(`${keyVar} is not set (required for ${model}). Export it and try again:`); - console.error(` export ${keyVar}="..."`); - process.exit(1); + +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), +}); +// 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 + * 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 ? { 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 (missingRequiredKey) { + 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); } -const cwd = process.cwd(); +// 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, + // 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?.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 // transport — the same messages a Tauri sidecar or remote kernel would see. @@ -63,7 +143,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/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"); }); }); diff --git a/packages/cli/test/config-panel.test.tsx b/packages/cli/test/config-panel.test.tsx new file mode 100644 index 0000000..00ff834 --- /dev/null +++ b/packages/cli/test/config-panel.test.tsx @@ -0,0 +1,278 @@ +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", + }, +]; + +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[]; + 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("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({ + 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", + // 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"); + 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("known models render as a select list; picking glm-5.2 saves it", 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"); + 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: [ + { 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(); + }); +}); 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); 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 f22a772..6886311 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 { 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,8 +70,9 @@ 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 ?? join(this.#runtime.homedir(), ".minerva"); + this.#dataDir = options.dataDir ?? defaultDataDir(this.#runtime); this.#tools = options.tools ?? builtinTools(); this.#systemPrompt = options.systemPrompt ?? defaultSystemPrompt; @@ -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,76 @@ 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 } : {}), + ...(provider?.requiresApiKey !== undefined + ? { requiresApiKey: provider.requiresApiKey } + : {}), + ...(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((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), + ); + } + + 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/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..dbe3313 100644 --- a/packages/kernel/src/settings.ts +++ b/packages/kernel/src/settings.ts @@ -21,16 +21,32 @@ 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; + /** 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; +} + 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 +73,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 +109,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/config.test.ts b/packages/kernel/test/config.test.ts new file mode 100644 index 0000000..4a90ff2 --- /dev/null +++ b/packages/kernel/test/config.test.ts @@ -0,0 +1,153 @@ +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("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")]), + resolveProvider: (ref) => scripted(ref, []), + }); + await expect(h.client.request(MINERVA_METHODS.configSetModel, {})).rejects.toThrow( + "requires modelRef", + ); + }); +}); 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"); + }); +}); diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index bcee4b9..bca5c88 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,28 @@ 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; + /** 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; +} + +export interface ConfigSetModelResult { + providerId: string; +} + // --- minerva/sessions/list --------------------------------------------- export interface SessionsListParams { 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..9350db1 100644 --- a/packages/providers/src/registry.ts +++ b/packages/providers/src/registry.ts @@ -1,36 +1,138 @@ 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; + /** 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; + +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 + // 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"], + }, +}; + +/** A provider entry as written in settings.json. */ +export interface CustomProviderConfig { + baseUrl?: string | undefined; + apiKeyEnv?: string | undefined; + defaultModel?: string | undefined; + models?: string[] | undefined; + requiresApiKey?: boolean | 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 } : {}), + ...(config.models !== undefined ? { models: config.models } : {}), + ...(config.requiresApiKey !== undefined ? { requiresApiKey: config.requiresApiKey } : {}), + }; + 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 } : {}), + ...(config.models !== undefined ? { models: config.models } : {}), + ...(config.requiresApiKey !== undefined ? { requiresApiKey: config.requiresApiKey } : {}), + }; + } + 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 +140,97 @@ 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. + * 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, + options: ResolveApiKeyOptions = {}, +): string | undefined { + const def = providerDef(provider, providers); + const env = options.env ?? process.env; + 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 { 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 ? { 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 df79cfc..2401bbb 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,106 @@ 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", + 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"); + }); + + 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("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" }; + 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"); }); });