diff --git a/docs/CLI-REFERENCE.md b/docs/CLI-REFERENCE.md index a063749..f60b4f8 100644 --- a/docs/CLI-REFERENCE.md +++ b/docs/CLI-REFERENCE.md @@ -93,6 +93,7 @@ Type `/` mid-chat to open the picker. Aliases shown in parentheses. Code-mode-on |---|---| | `/preset ` | Switch model bundle. Bare opens picker | | `/model ` | Switch DeepSeek model id. Bare opens picker | +| `/provider [name]` | List or switch configured OpenAI-compatible chat providers | | `/language ` (`/lang`) | Switch the runtime language | | `/theme ` | Show or persist terminal theme. Bare opens picker | | `/config` | Open configuration guidance and current config paths | diff --git a/docs/cli-reference.html b/docs/cli-reference.html index 32afa3b..399c0f0 100644 --- a/docs/cli-reference.html +++ b/docs/cli-reference.html @@ -367,6 +367,10 @@

Setup

/model <id> Switch DeepSeek model id. Bare opens picker + + /provider [name] + List or switch configured OpenAI-compatible chat providers + /language <EN|zh-CN> (/lang) Switch the runtime language diff --git a/src/cli/ui/App.tsx b/src/cli/ui/App.tsx index b90429e..94323db 100644 --- a/src/cli/ui/App.tsx +++ b/src/cli/ui/App.tsx @@ -35,6 +35,7 @@ import { type PresetName, defaultConfigPath, editModeHintShown, + listChatProviders, loadBaseUrl, loadReasoningEffort, loadTheme, @@ -44,7 +45,9 @@ import { markMouseClipboardHintShown, mouseClipboardHintShown, readConfig, + resolveChatProviderConfig, resolveThemePreference, + saveActiveChatProvider, saveEditMode, saveModel, savePreset, @@ -1271,6 +1274,7 @@ function AppInner({ useEffect(() => { let applied = initialRuntimeConfigRef.current ?? { + providerId: resolveChatProviderConfig().id, apiKey: loop.client.apiKey, baseUrl: loop.client.baseUrl, }; @@ -1285,17 +1289,22 @@ function AppInner({ } try { loop.replaceClient(new DeepSeekClient({ apiKey: next.apiKey, baseUrl: next.baseUrl })); + if (next.model) { + loop.configure({ model: next.model, autoEscalate: false }); + agentStore.dispatch({ type: "session.model.change", model: next.model }); + agentStore.dispatch({ type: "session.preset.change", preset: null }); + } applied = next; refreshBalance(); refreshModels(); - log.pushInfo("config: DeepSeek connection reloaded"); + log.pushInfo(`config: provider ${next.providerId} connection reloaded`); } catch (err) { log.pushWarning("config reload failed", (err as Error).message); } }, 1000); timer.unref(); return () => clearInterval(timer); - }, [log, loop, refreshBalance, refreshModels, runtimeConfigSource]); + }, [agentStore, log, loop, refreshBalance, refreshModels, runtimeConfigSource]); // Keep the dashboard-server ref-mirrors in sync with their state. // These four are the load-bearing live reads for the attached @@ -2987,6 +2996,27 @@ function AppInner({ refreshLatestVersion, models, refreshModels, + chatProviders: () => listChatProviders(), + activeChatProvider: () => resolveChatProviderConfig(), + switchChatProvider: (id: string) => { + try { + const provider = saveActiveChatProvider(id); + const apiKey = process.env.DEEPSEEK_API_KEY ?? provider.apiKey; + const baseUrl = process.env.DEEPSEEK_BASE_URL ?? provider.baseUrl; + if (!apiKey) return { ok: false, error: `provider ${provider.id} has no API key` }; + loop.replaceClient(new DeepSeekClient({ apiKey, baseUrl })); + if (provider.model) { + loop.configure({ model: provider.model, autoEscalate: false }); + agentStore.dispatch({ type: "session.model.change", model: provider.model }); + agentStore.dispatch({ type: "session.preset.change", preset: null }); + } + refreshBalance(); + refreshModels(); + return { ok: true, provider }; + } catch (err) { + return { ok: false, error: (err as Error).message }; + } + }, generateSessionTitle: generateCurrentSessionTitle, }); if ( diff --git a/src/cli/ui/slash/commands.ts b/src/cli/ui/slash/commands.ts index 217d6ad..914e235 100644 --- a/src/cli/ui/slash/commands.ts +++ b/src/cli/ui/slash/commands.ts @@ -94,6 +94,12 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [ summary: "switch DeepSeek model id. Bare opens picker.", argCompleter: "models", }, + { + cmd: "provider", + group: "setup", + argsHint: "[name]", + summary: "list or switch configured chat providers", + }, { cmd: "language", group: "setup", diff --git a/src/cli/ui/slash/handlers/model.ts b/src/cli/ui/slash/handlers/model.ts index 10d29a6..e494500 100644 --- a/src/cli/ui/slash/handlers/model.ts +++ b/src/cli/ui/slash/handlers/model.ts @@ -144,9 +144,35 @@ const budget: SlashHandler = (args, loop) => { }; }; +const provider: SlashHandler = (args, _loop, ctx) => { + const providers = ctx.chatProviders?.() ?? []; + const active = ctx.activeChatProvider?.(); + const id = args[0]?.trim(); + if (!id) { + if (providers.length === 0) + return { info: "provider: deepseek (legacy single-provider config)" }; + const rows = providers.map((p) => { + const marker = p.id === active?.id ? "*" : " "; + const endpoint = p.baseUrl ?? "https://api.deepseek.com"; + const model = p.model ? ` · model ${p.model}` : ""; + return `${marker} ${p.id} · ${endpoint}${model}`; + }); + return { info: `providers:\n${rows.join("\n")}\n\nUse /provider to switch.` }; + } + + if (!ctx.switchChatProvider) { + return { info: "provider switching is not available in this session." }; + } + const result = ctx.switchChatProvider(id); + if (!result.ok) return { info: `provider: ${result.error}` }; + const model = result.provider.model ? ` · model ${result.provider.model}` : ""; + return { info: `provider: switched to ${result.provider.id}${model}` }; +}; + export const handlers: Record = { model, preset, pro, budget, + provider, }; diff --git a/src/cli/ui/slash/types.ts b/src/cli/ui/slash/types.ts index cc17787..1b7727d 100644 --- a/src/cli/ui/slash/types.ts +++ b/src/cli/ui/slash/types.ts @@ -1,4 +1,4 @@ -import type { EditMode } from "../../../config.js"; +import type { EditMode, ResolvedChatProviderConfig } from "../../../config.js"; import type { McpServerSummary } from "../../../mcp/summary.js"; import type { JobRegistry } from "../../../tools/jobs.js"; import type { PlanStep } from "../../../tools/plan.js"; @@ -152,6 +152,11 @@ export interface SlashContext { /** `null` → in flight / failed; `[]` → API answered empty. `/model ` warn-only since list can lag. */ models?: string[] | null; refreshModels?: () => void; + chatProviders?: () => ResolvedChatProviderConfig[]; + activeChatProvider?: () => ResolvedChatProviderConfig; + switchChatProvider?: ( + id: string, + ) => { ok: true; provider: ResolvedChatProviderConfig } | { ok: false; error: string }; /** Ask the current model to summarize the active session into a short title and rename it. */ generateSessionTitle?: () => Promise; armPro?: () => void; diff --git a/src/config.ts b/src/config.ts index 8a2bc4e..974a635 100644 --- a/src/config.ts +++ b/src/config.ts @@ -108,7 +108,27 @@ export interface RateLimitConfig { rpm?: number; } +export interface ChatProviderConfig { + /** OpenAI-compatible chat API key for this provider. */ + apiKey?: string; + /** OpenAI-compatible API base URL, e.g. https://api.deepseek.com or .../compatible-mode/v1. */ + baseUrl?: string; + /** Optional default model used when switching to this provider. */ + model?: string; +} + +export interface ResolvedChatProviderConfig { + id: string; + apiKey?: string; + baseUrl?: string; + model?: string; +} + export interface ReasonixConfig { + /** Active chat provider id. Defaults to `deepseek` for legacy single-provider configs. */ + provider?: string; + /** Named OpenAI-compatible chat providers. Legacy apiKey/baseUrl remain the default DeepSeek provider. */ + providers?: Record; apiKey?: string; baseUrl?: string; /** Explicit chat model pin. When absent, the selected preset supplies the model. */ @@ -448,16 +468,106 @@ export function saveLanguage(lang: LanguageCode, path: string = defaultConfigPat writeConfig(cfg, path); } -/** Resolve the API key from env var first, then the config file. */ +function normalizeProviderId(id: unknown): string | undefined { + if (typeof id !== "string") return undefined; + const trimmed = id.trim(); + if (!/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/.test(trimmed)) return undefined; + return trimmed; +} + +function normalizeChatProviderConfig(value: unknown): ChatProviderConfig { + if (!isPlainObject(value)) return {}; + const cfg: ChatProviderConfig = {}; + if (typeof value.apiKey === "string" && value.apiKey.trim()) cfg.apiKey = value.apiKey.trim(); + if (typeof value.baseUrl === "string" && value.baseUrl.trim()) { + cfg.baseUrl = value.baseUrl.trim(); + } + if (typeof value.model === "string" && value.model.trim()) cfg.model = value.model.trim(); + return cfg; +} + +export function listChatProviders( + path: string = defaultConfigPath(), +): ResolvedChatProviderConfig[] { + const cfg = readConfig(path); + const providers = cfg.providers && isPlainObject(cfg.providers) ? cfg.providers : {}; + const out: ResolvedChatProviderConfig[] = []; + const seen = new Set(); + + const push = (id: string, value: unknown, legacyFallback = false) => { + const provider = normalizeChatProviderConfig(value); + if (legacyFallback) { + provider.apiKey ??= + typeof cfg.apiKey === "string" && cfg.apiKey.trim() ? cfg.apiKey : undefined; + provider.baseUrl ??= + typeof cfg.baseUrl === "string" && cfg.baseUrl.trim() ? cfg.baseUrl : undefined; + provider.model ??= typeof cfg.model === "string" && cfg.model.trim() ? cfg.model : undefined; + } + if (seen.has(id)) return; + seen.add(id); + out.push({ id, ...provider }); + }; + + for (const [rawId, value] of Object.entries(providers)) { + const id = normalizeProviderId(rawId); + if (!id) continue; + push(id, value, id === "deepseek"); + } + + if (!seen.has("deepseek")) { + push( + "deepseek", + { + apiKey: cfg.apiKey, + baseUrl: cfg.baseUrl, + model: cfg.model, + }, + false, + ); + } + + return out; +} + +export function resolveChatProviderConfig( + path: string = defaultConfigPath(), + providerId?: string, +): ResolvedChatProviderConfig { + const cfg = readConfig(path); + const providers = listChatProviders(path); + const requested = normalizeProviderId(providerId) ?? normalizeProviderId(cfg.provider); + return providers.find((p) => p.id === requested) ?? providers[0] ?? { id: "deepseek" }; +} + +export function saveActiveChatProvider( + providerId: string, + path: string = defaultConfigPath(), +): ResolvedChatProviderConfig { + const id = normalizeProviderId(providerId); + if (!id) + throw new Error( + "provider id must start with a letter and contain only letters, numbers, _ or -", + ); + const providers = listChatProviders(path); + const provider = providers.find((p) => p.id === id); + if (!provider) throw new Error(`unknown provider: ${providerId}`); + const cfg = readConfig(path); + cfg.provider = provider.id; + if (provider.model) cfg.model = provider.model; + writeConfig(cfg, path); + return provider; +} + +/** Resolve the API key from env var first, then the active chat provider. */ export function loadApiKey(path: string = defaultConfigPath()): string | undefined { if (process.env.DEEPSEEK_API_KEY) return process.env.DEEPSEEK_API_KEY; - return readConfig(path).apiKey; + return resolveChatProviderConfig(path).apiKey; } -/** env > config > undefined. Client falls back to api.deepseek.com when undefined. */ +/** env > active provider > undefined. Client falls back to api.deepseek.com when undefined. */ export function loadBaseUrl(path: string = defaultConfigPath()): string | undefined { if (process.env.DEEPSEEK_BASE_URL) return process.env.DEEPSEEK_BASE_URL; - return readConfig(path).baseUrl; + return resolveChatProviderConfig(path).baseUrl; } function isNonNegativeNumber(value: unknown): value is number { @@ -491,7 +601,13 @@ export function loadRateLimit(path: string = defaultConfigPath()): RateLimitConf export function saveBaseUrl(url: string, path: string = defaultConfigPath()): void { const cfg = readConfig(path); const trimmed = url.trim(); - if (trimmed) { + const active = normalizeProviderId(cfg.provider); + if (active && cfg.providers && isPlainObject(cfg.providers)) { + cfg.providers[active] = { + ...normalizeChatProviderConfig(cfg.providers[active]), + baseUrl: trimmed || undefined, + }; + } else if (trimmed) { cfg.baseUrl = trimmed; } else { cfg.baseUrl = undefined; @@ -650,7 +766,16 @@ export function webSearchEndpoint(path: string = defaultConfigPath()): string { export function saveApiKey(key: string, path: string = defaultConfigPath()): void { const cfg = readConfig(path); - cfg.apiKey = key.trim(); + const trimmed = key.trim(); + const active = normalizeProviderId(cfg.provider); + if (active && cfg.providers && isPlainObject(cfg.providers)) { + cfg.providers[active] = { + ...normalizeChatProviderConfig(cfg.providers[active]), + apiKey: trimmed, + }; + } else { + cfg.apiKey = trimmed; + } writeConfig(cfg, path); } diff --git a/src/i18n/EN.ts b/src/i18n/EN.ts index 370ffca..05f73ed 100644 --- a/src/i18n/EN.ts +++ b/src/i18n/EN.ts @@ -271,6 +271,7 @@ export const EN: TranslationSchema = { argsHint: "", }, model: { description: "switch DeepSeek model id", argsHint: "" }, + provider: { description: "list or switch configured chat providers", argsHint: "[name]" }, models: { description: "list available models fetched from DeepSeek /models" }, theme: { description: "show or persist the terminal theme preference. Bare opens picker.", diff --git a/src/i18n/zh-CN.ts b/src/i18n/zh-CN.ts index b04b220..40ec7b5 100644 --- a/src/i18n/zh-CN.ts +++ b/src/i18n/zh-CN.ts @@ -267,6 +267,7 @@ export const zhCN: TranslationSchema = { }, model: { description: "切换 DeepSeek 模型 ID", argsHint: "" }, models: { description: "列出从 DeepSeek /models 获取的可用模型" }, + provider: { description: "列出或切换已配置的模型供应商", argsHint: "[name]" }, theme: { description: "显示或持久化终端主题偏好。无参数时打开选择器。", argsHint: "[auto|default|dark|light|tokyo-night|github-dark|github-light|high-contrast]", diff --git a/src/runtime-config.ts b/src/runtime-config.ts index 9813ffd..ea83ff7 100644 --- a/src/runtime-config.ts +++ b/src/runtime-config.ts @@ -1,9 +1,11 @@ import { readFileSync } from "node:fs"; -import { type ReasonixConfig, defaultConfigPath } from "./config.js"; +import { type ReasonixConfig, defaultConfigPath, resolveChatProviderConfig } from "./config.js"; export interface RuntimeConnectionConfig { + providerId: string; apiKey?: string; baseUrl?: string; + model?: string; } function readConfigStrict(path: string): ReasonixConfig | null { @@ -27,20 +29,24 @@ export class RuntimeConnectionConfigSource { private readonly env: NodeJS.ProcessEnv = process.env, ) { const initial = readConfigStrict(path) ?? {}; + const initialProvider = initial ? resolveChatProviderConfig(path) : { id: "deepseek" }; this.apiKeyPinnedByEnv = Boolean( - env.DEEPSEEK_API_KEY && env.DEEPSEEK_API_KEY !== initial.apiKey, + env.DEEPSEEK_API_KEY && env.DEEPSEEK_API_KEY !== initialProvider.apiKey, ); this.baseUrlPinnedByEnv = Boolean( - env.DEEPSEEK_BASE_URL && env.DEEPSEEK_BASE_URL !== initial.baseUrl, + env.DEEPSEEK_BASE_URL && env.DEEPSEEK_BASE_URL !== initialProvider.baseUrl, ); } read(): RuntimeConnectionConfig | null { const config = readConfigStrict(this.path); if (!config) return null; + const provider = resolveChatProviderConfig(this.path); return { - apiKey: this.apiKeyPinnedByEnv ? this.env.DEEPSEEK_API_KEY : config.apiKey, - baseUrl: this.baseUrlPinnedByEnv ? this.env.DEEPSEEK_BASE_URL : config.baseUrl, + providerId: provider.id, + apiKey: this.apiKeyPinnedByEnv ? this.env.DEEPSEEK_API_KEY : provider.apiKey, + baseUrl: this.baseUrlPinnedByEnv ? this.env.DEEPSEEK_BASE_URL : provider.baseUrl, + model: provider.model, }; } } @@ -49,5 +55,10 @@ export function sameRuntimeConnectionConfig( left: RuntimeConnectionConfig, right: RuntimeConnectionConfig, ): boolean { - return left.apiKey === right.apiKey && left.baseUrl === right.baseUrl; + return ( + left.providerId === right.providerId && + left.apiKey === right.apiKey && + left.baseUrl === right.baseUrl && + left.model === right.model + ); } diff --git a/tests/config.test.ts b/tests/config.test.ts index 973ea60..b077141 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -10,6 +10,7 @@ import { defaultConfigPath, editModeHintShown, isPlausibleKey, + listChatProviders, loadApiKey, loadBaseUrl, loadDesktopOpenTabs, @@ -31,8 +32,10 @@ import { redactSemanticEmbeddingConfig, removeProjectPathAllowed, removeProjectShellAllowed, + resolveChatProviderConfig, resolveSemanticEmbeddingConfig, resolveThemePreference, + saveActiveChatProvider, saveApiKey, saveBaseUrl, saveDesktopOpenTabs, @@ -205,6 +208,89 @@ describe("config", () => { expect(loadBaseUrl(path)).toBeUndefined(); }); + it("resolves the active chat provider from providers config", () => { + writeConfig( + { + provider: "dashscope", + apiKey: "sk-legacy1234567890", + baseUrl: "https://api.deepseek.com", + providers: { + dashscope: { + apiKey: "dash-key-1234567890", + baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + model: "qwen3-coder-plus", + }, + openrouter: { + apiKey: "or-key-1234567890", + baseUrl: "https://openrouter.ai/api/v1", + }, + }, + }, + path, + ); + + expect(resolveChatProviderConfig(path)).toEqual({ + id: "dashscope", + apiKey: "dash-key-1234567890", + baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + model: "qwen3-coder-plus", + }); + expect(loadApiKey(path)).toBe("dash-key-1234567890"); + expect(loadBaseUrl(path)).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1"); + expect(listChatProviders(path).map((p) => p.id)).toEqual([ + "dashscope", + "openrouter", + "deepseek", + ]); + }); + + it("switches the active chat provider and applies its default model", () => { + writeConfig( + { + provider: "deepseek", + providers: { + deepseek: { apiKey: "sk-deepseek1234567890", model: "deepseek-v4-flash" }, + openrouter: { + apiKey: "sk-openrouter1234567890", + baseUrl: "https://openrouter.ai/api/v1", + model: "anthropic/claude-sonnet-4", + }, + }, + }, + path, + ); + + expect(saveActiveChatProvider("openrouter", path)).toMatchObject({ + id: "openrouter", + model: "anthropic/claude-sonnet-4", + }); + expect(readConfig(path).provider).toBe("openrouter"); + expect(readConfig(path).model).toBe("anthropic/claude-sonnet-4"); + }); + + it("saves API key and base URL into the active provider when providers are configured", () => { + writeConfig( + { + provider: "openrouter", + providers: { + openrouter: { apiKey: "old-key-1234567890", baseUrl: "https://old.example.com" }, + }, + }, + path, + ); + + saveApiKey(" new-key-1234567890 ", path); + saveBaseUrl("https://new.example.com", path); + + expect(readConfig(path).apiKey).toBeUndefined(); + expect(readConfig(path).baseUrl).toBeUndefined(); + expect(resolveChatProviderConfig(path)).toMatchObject({ + id: "openrouter", + apiKey: "new-key-1234567890", + baseUrl: "https://new.example.com", + }); + }); + it("loads pricingOverride with valid non-negative fields", () => { writeConfig( { diff --git a/tests/runtime-config.test.ts b/tests/runtime-config.test.ts index 9d7c47d..2aa43ac 100644 --- a/tests/runtime-config.test.ts +++ b/tests/runtime-config.test.ts @@ -27,12 +27,14 @@ describe("runtime connection config", () => { const source = new RuntimeConnectionConfigSource(path, {}); expect(source.read()).toEqual({ + providerId: "deepseek", apiKey: "sk-old", baseUrl: "https://old.example.com", }); writeConfig({ apiKey: "sk-new", baseUrl: "https://new.example.com" }, path); expect(source.read()).toEqual({ + providerId: "deepseek", apiKey: "sk-new", baseUrl: "https://new.example.com", }); @@ -59,11 +61,59 @@ describe("runtime connection config", () => { writeConfig({ apiKey: "sk-new", baseUrl: "https://new.example.com" }, path); expect(source.read()).toEqual({ + providerId: "deepseek", apiKey: "sk-env", baseUrl: "https://env.example.com", }); }); + it("re-reads active provider changes from the config file", () => { + const path = configPath(); + writeConfig( + { + provider: "deepseek", + providers: { + deepseek: { apiKey: "sk-deepseek", baseUrl: "https://api.deepseek.com" }, + openrouter: { + apiKey: "sk-openrouter", + baseUrl: "https://openrouter.ai/api/v1", + model: "openai/gpt-4.1", + }, + }, + }, + path, + ); + const source = new RuntimeConnectionConfigSource(path, {}); + + expect(source.read()).toEqual({ + providerId: "deepseek", + apiKey: "sk-deepseek", + baseUrl: "https://api.deepseek.com", + model: undefined, + }); + + writeConfig( + { + provider: "openrouter", + providers: { + deepseek: { apiKey: "sk-deepseek", baseUrl: "https://api.deepseek.com" }, + openrouter: { + apiKey: "sk-openrouter", + baseUrl: "https://openrouter.ai/api/v1", + model: "openai/gpt-4.1", + }, + }, + }, + path, + ); + expect(source.read()).toEqual({ + providerId: "openrouter", + apiKey: "sk-openrouter", + baseUrl: "https://openrouter.ai/api/v1", + model: "openai/gpt-4.1", + }); + }); + it("ignores a partially written config until valid JSON is available", () => { const path = configPath(); writeConfig({ apiKey: "sk-old" }, path); @@ -77,7 +127,17 @@ describe("runtime connection config", () => { }); it("compares connection snapshots", () => { - expect(sameRuntimeConnectionConfig({ apiKey: "a" }, { apiKey: "a" })).toBe(true); - expect(sameRuntimeConnectionConfig({ apiKey: "a" }, { apiKey: "b" })).toBe(false); + expect( + sameRuntimeConnectionConfig( + { providerId: "a", apiKey: "a" }, + { providerId: "a", apiKey: "a" }, + ), + ).toBe(true); + expect( + sameRuntimeConnectionConfig( + { providerId: "a", apiKey: "a" }, + { providerId: "b", apiKey: "a" }, + ), + ).toBe(false); }); }); diff --git a/tests/slash.test.ts b/tests/slash.test.ts index 299c337..f63d27e 100644 --- a/tests/slash.test.ts +++ b/tests/slash.test.ts @@ -165,6 +165,29 @@ describe("handleSlash", () => { expect(r.openModelPicker).toBe(true); }); + it("/provider lists configured chat providers", () => { + const r = handleSlash("provider", [], makeLoop(), { + activeChatProvider: () => ({ id: "openrouter", baseUrl: "https://openrouter.ai/api/v1" }), + chatProviders: () => [ + { id: "deepseek", baseUrl: "https://api.deepseek.com" }, + { id: "openrouter", baseUrl: "https://openrouter.ai/api/v1", model: "openai/gpt-4.1" }, + ], + }); + expect(r.info).toContain("* openrouter"); + expect(r.info).toContain("openai/gpt-4.1"); + }); + + it("/provider switches through the live provider callback", () => { + const r = handleSlash("provider", ["openrouter"], makeLoop(), { + switchChatProvider: (id) => ({ + ok: true, + provider: { id, baseUrl: "https://openrouter.ai/api/v1", model: "openai/gpt-4.1" }, + }), + }); + expect(r.info).toContain("switched to openrouter"); + expect(r.info).toContain("openai/gpt-4.1"); + }); + it("/preset with no arg opens the unified picker", () => { const r = handleSlash("preset", [], makeLoop()); expect(r.openModelPicker).toBe(true); @@ -565,7 +588,7 @@ describe("handleSlash", () => { // Case-insensitive. expect(suggestSlashCommands("HE").map((s) => s.cmd)).toEqual(["help"]); // Empty prefix returns the full non-advanced release list, including code commands. - expect(suggestSlashCommands("", true)).toHaveLength(56); + expect(suggestSlashCommands("", true)).toHaveLength(57); expect(suggestSlashCommands("", true).map((s) => s.cmd)).toContain("add-dir"); expect(suggestSlashCommands("", true).map((s) => s.cmd)).toContain("vim"); expect(suggestSlashCommands("", true).map((s) => s.cmd)).toContain("agents"); @@ -578,6 +601,7 @@ describe("handleSlash", () => { expect(suggestSlashCommands("", true).map((s) => s.cmd)).toContain("export"); expect(suggestSlashCommands("", true).map((s) => s.cmd)).toContain("logs"); expect(suggestSlashCommands("", true).map((s) => s.cmd)).toContain("language"); + expect(suggestSlashCommands("", true).map((s) => s.cmd)).toContain("provider"); expect(suggestSlashCommands("lan").map((s) => s.cmd)).toContain("language"); }); diff --git a/tests/ui-slash-suggestions.test.tsx b/tests/ui-slash-suggestions.test.tsx index fba52c1..55857ed 100644 --- a/tests/ui-slash-suggestions.test.tsx +++ b/tests/ui-slash-suggestions.test.tsx @@ -109,7 +109,7 @@ describe("SlashSuggestions", () => { ); }); - it("renders the bare slash release command surface as 56 total commands", () => { + it("renders the bare slash release command surface as 57 total commands", () => { const matches = suggestSlashCommands("", true); const names = matches.map((spec) => spec.cmd); const { lastFrame, unmount } = render( @@ -118,7 +118,7 @@ describe("SlashSuggestions", () => { const frame = lastFrame() ?? ""; unmount(); - expect(matches).toHaveLength(56); + expect(matches).toHaveLength(57); expect(names).toContain("add-dir"); expect(names).toContain("vim"); expect(names).toContain("agents"); @@ -134,9 +134,10 @@ describe("SlashSuggestions", () => { expect(names).toContain("terminal-setup"); expect(names).toContain("output-style"); expect(names).toContain("language"); + expect(names).toContain("provider"); expect(names).toContain("btw"); expect(countAdvancedCommands(true)).toBe(11); - expect(frame).toContain("56 commands"); + expect(frame).toContain("57 commands"); expect(frame).toContain("+ 11 advanced"); });