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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CLI-REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ Type `/` mid-chat to open the picker. Aliases shown in parentheses. Code-mode-on
|---|---|
| `/preset <auto\|flash\|pro>` | Switch model bundle. Bare opens picker |
| `/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 |
| `/theme <name>` | Show or persist terminal theme. Bare opens picker |
| `/config` | Open configuration guidance and current config paths |
Expand Down
4 changes: 4 additions & 0 deletions docs/cli-reference.html
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,10 @@ <h3 data-i18n="sl.h.setup">Setup</h3>
<td><code>/model &lt;id&gt;</code></td>
<td>Switch DeepSeek model id. Bare opens picker</td>
</tr>
<tr>
<td><code>/provider [name]</code></td>
<td>List or switch configured OpenAI-compatible chat providers</td>
</tr>
<tr>
<td><code>/language &lt;EN|zh-CN&gt;</code> (<code>/lang</code>)</td>
<td>Switch the runtime language</td>
Expand Down
34 changes: 32 additions & 2 deletions src/cli/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
type PresetName,
defaultConfigPath,
editModeHintShown,
listChatProviders,
loadBaseUrl,
loadReasoningEffort,
loadTheme,
Expand All @@ -44,7 +45,9 @@ import {
markMouseClipboardHintShown,
mouseClipboardHintShown,
readConfig,
resolveChatProviderConfig,
resolveThemePreference,
saveActiveChatProvider,
saveEditMode,
saveModel,
savePreset,
Expand Down Expand Up @@ -1271,6 +1274,7 @@ function AppInner({

useEffect(() => {
let applied = initialRuntimeConfigRef.current ?? {
providerId: resolveChatProviderConfig().id,
apiKey: loop.client.apiKey,
baseUrl: loop.client.baseUrl,
};
Expand All @@ -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
Expand Down Expand Up @@ -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 (
Expand Down
6 changes: 6 additions & 0 deletions src/cli/ui/slash/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions src/cli/ui/slash/handlers/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> 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<string, SlashHandler> = {
model,
preset,
pro,
budget,
provider,
};
7 changes: 6 additions & 1 deletion src/cli/ui/slash/types.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -152,6 +152,11 @@ export interface SlashContext {
/** `null` → in flight / failed; `[]` → API answered empty. `/model <id>` 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<string>;
armPro?: () => void;
Expand Down
137 changes: 131 additions & 6 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ChatProviderConfig>;
apiKey?: string;
baseUrl?: string;
/** Explicit chat model pin. When absent, the selected preset supplies the model. */
Expand Down Expand Up @@ -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<string>();

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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down
1 change: 1 addition & 0 deletions src/i18n/EN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ export const EN: TranslationSchema = {
argsHint: "<auto|flash|pro>",
},
model: { description: "switch DeepSeek model id", argsHint: "<id>" },
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.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ export const zhCN: TranslationSchema = {
},
model: { description: "切换 DeepSeek 模型 ID", argsHint: "<id>" },
models: { description: "列出从 DeepSeek /models 获取的可用模型" },
provider: { description: "列出或切换已配置的模型供应商", argsHint: "[name]" },
theme: {
description: "显示或持久化终端主题偏好。无参数时打开选择器。",
argsHint: "[auto|default|dark|light|tokyo-night|github-dark|github-light|high-contrast]",
Expand Down
Loading