Skip to content

Commit 878acef

Browse files
authored
Merge pull request #127 from itmisx/feat/provider-switch
✨ provider switch
2 parents 78a3c56 + 6088b80 commit 878acef

11 files changed

Lines changed: 379 additions & 5 deletions

File tree

README.en.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ deepx # enter the interactive TUI
102102
| :----------- | :----------------------------------------------------------- |
103103
| Provider & key | A wizard prompts on first run: **use ←/→ to pick a provider (DeepSeek / Xiaomi MiMo), then enter its API key**, persisted to `~/.deepx/model.yaml`. Each provider ships default flash/pro models and 1M context (DeepSeek `deepseek-v4-flash` / `-pro`, MiMo `mimo-v2.5` / `-pro`). Reconfigure with `/config`. |
104104
| Manual override | Edit `~/.deepx/model.yaml` directly to override `base_url` / `model` / `api_key` / `max_tokens` / `context_window` per role (flash/pro); flash and pro may even point at different providers. |
105+
| Multi-provider switch | Each `/config` archives the config by provider name (deepseek/mimo/kimi/qwen/custom) to `~/.deepx/provider.yaml`. Use `/provider` to one-tap switch between configured providers (writes that provider's flash/pro back into `model.yaml`) without re-entering keys. |
105106
| Skills | Drop into `<workspace>/.deepx/skills/`, or reuse `~/.claude/skills/` etc. |
106107
| MCP | Add via `/mcp-add` inside the TUI; list with `/mcp-list`. |
107108

@@ -233,6 +234,7 @@ A built-in symbol-graph engine lets the model do symbol-level navigation + call-
233234
| :----------------------------------- | :---------------------------------- |
234235
| `/plan` `/auto` `/review` | switch mode (read-only / auto / review) |
235236
| `/model` | popup to pick the model (auto routes by task / flash / pro lock); `/model flash` also works directly |
237+
| `/provider` | quick-switch between configured providers: popup to pick (or `/provider <name>` directly). Each `/config` archives its config by provider name to `~/.deepx/provider.yaml`; switching writes that provider's flash/pro back into `model.yaml` |
236238
| `/reasoning` | popup to set `thinking` / `reasoning_effort` per role (flash/pro); empty = don't send the field (safe for MiMo and other models that don't support it) |
237239
| `/compact` | manually compact the session |
238240
| `/new` `/sessions` | start a new conversation / browse history (↑↓ select, Enter switch) |

README.ja.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ deepx # 対話型 TUI に入る
102102
| :------------ | :----------------------------------------------------------- |
103103
| プロバイダ & Key | 初回起動時のウィザードで:**←/→ でプロバイダ(DeepSeek / Xiaomi MiMo)を選び、対応する API Key を入力**`~/.deepx/model.yaml` に保存。各プロバイダに flash/pro の既定モデルと 1M コンテキストを用意(DeepSeek `deepseek-v4-flash` / `-pro`、MiMo `mimo-v2.5` / `-pro`)。`/config` で再設定。 |
104104
| 手動上書き | `~/.deepx/model.yaml` を直接編集し、role(flash/pro)ごとに `base_url` / `model` / `api_key` / `max_tokens` / `context_window` を上書き可能。flash と pro で別プロバイダも指定できる。 |
105+
| 複数プロバイダ切替 | `/config` のたびに設定がプロバイダ名(deepseek/mimo/kimi/qwen/custom)で `~/.deepx/provider.yaml` に保存される。`/provider` で設定済みプロバイダ間をワンタップ切替(そのプロバイダの flash/pro を `model.yaml` に書き戻す)、Key の再入力不要。 |
105106
| Skill | `<ワークスペース>/.deepx/skills/` に配置、または `~/.claude/skills/` などを再利用。 |
106107
| MCP | TUI 内で `/mcp-add` で追加、`/mcp-list` で一覧。 |
107108

@@ -233,6 +234,7 @@ CreatePlan
233234
| :----------------------------------- | :---------------------------------- |
234235
| `/plan` `/auto` `/review` | モード切替(読み取り専用 / 自動 / レビュー) |
235236
| `/model` | モデル選択ポップアップ(auto=タスク振り分け / flash / pro 固定);`/model flash` で直接指定も可 |
237+
| `/provider` | 設定済みプロバイダ間をすばやく切替:ポップアップで選択(`/provider <名前>` で直接指定も可)。`/config` のたびに設定がプロバイダ名で `~/.deepx/provider.yaml` に保存され、切替時にそのプロバイダの flash/pro を `model.yaml` に書き戻す |
236238
| `/reasoning` | `thinking` / `reasoning_effort` をロール毎(flash/pro)に設定するポップアップ;空 = 該当フィールドを送信しない(MiMo など非対応モデルに無影響) |
237239
| `/compact` | セッションを手動圧縮 |
238240
| `/new` `/sessions` | 新しい会話を開始 / 履歴一覧(↑↓ 選択、Enter で切替) |

README.ko.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ deepx # 대화형 TUI 진입
102102
| :------------ | :----------------------------------------------------------- |
103103
| 공급자 & Key | 첫 실행 마법사에서 **←/→로 공급자(DeepSeek / Xiaomi MiMo)를 선택하고 해당 API Key를 입력**`~/.deepx/model.yaml`에 저장. 각 공급자에 flash/pro 기본 모델과 1M 컨텍스트 제공(DeepSeek `deepseek-v4-flash` / `-pro`, MiMo `mimo-v2.5` / `-pro`). `/config`로 재설정. |
104104
| 수동 재정의 | `~/.deepx/model.yaml`을 직접 편집해 role(flash/pro)별로 `base_url` / `model` / `api_key` / `max_tokens` / `context_window`를 재정의 가능. flash와 pro가 서로 다른 공급자를 가리킬 수도 있음. |
105+
| 다중 공급자 전환 | `/config` 할 때마다 설정이 공급자 이름(deepseek/mimo/kimi/qwen/custom)으로 `~/.deepx/provider.yaml`에 보관됨. `/provider`로 설정된 공급자 간 원탭 전환(해당 공급자의 flash/pro를 `model.yaml`에 다시 기록), Key 재입력 불필요. |
105106
| Skill | `<워크스페이스>/.deepx/skills/`에 두거나 `~/.claude/skills/` 등 재사용. |
106107
| MCP | TUI에서 `/mcp-add`로 추가, `/mcp-list`로 목록 확인. |
107108

@@ -233,6 +234,7 @@ CreatePlan
233234
| :----------------------------------- | :---------------------------------- |
234235
| `/plan` `/auto` `/review` | 모드 전환(읽기 전용 / 자동 / 검토) |
235236
| `/model` | 모델 선택 팝업(auto=작업별 라우팅 / flash / pro 고정); `/model flash` 직접 지정도 가능 |
237+
| `/provider` | 설정된 공급자 간 빠른 전환: 팝업에서 선택(`/provider <이름>` 직접 지정도 가능). `/config` 할 때마다 설정이 공급자 이름으로 `~/.deepx/provider.yaml`에 보관되며, 전환 시 해당 공급자의 flash/pro를 `model.yaml`에 다시 기록 |
236238
| `/reasoning` | role(flash/pro)별로 `thinking` / `reasoning_effort` 설정 팝업; 빈 값 = 해당 필드 미전송(MiMo 등 미지원 모델에 영향 없음) |
237239
| `/compact` | 세션 수동 압축 |
238240
| `/new` `/sessions` | 새 대화 시작 / 기록 목록(↑↓ 선택, Enter 전환) |

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ deepx # 进入交互式 TUI
102102
| :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
103103
| 供应商 & Key | 首次启动弹出向导:**用 ←/→ 选模型供应商(DeepSeek / 小米 MiMo),再填对应 API Key**,持久化到 `~/.deepx/model.yaml`。各供应商已预置 flash/pro 默认模型与 1M 上下文(DeepSeek `deepseek-v4-flash` / `-pro`,MiMo `mimo-v2.5` / `-pro`)。也可 `/config` 重配。 |
104104
| 手动覆盖 | 可直接编辑 `~/.deepx/model.yaml`,按 role(flash/pro)覆盖 `base_url` / `model` / `api_key` / `max_tokens` / `context_window`;flash 与 pro 也可指向不同供应商。 |
105+
| 多供应商切换 | 每次 `/config` 会把配置按供应商名(deepseek/mimo/kimi/qwen/custom)存档到 `~/.deepx/provider.yaml`。之后用 `/provider` 在已配置的供应商间一键切换(切换即把对应 flash/pro 写回 `model.yaml`),无需重填 key。 |
105106
| Skill | 放到 `<工作区>/.deepx/skills/`,或复用 `~/.claude/skills/` 等已有目录。 |
106107
| MCP | TUI 内 `/mcp-add` 添加,`/mcp-list` 查看。 |
107108

@@ -239,6 +240,7 @@ CreatePlan
239240
| :----------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
240241
| `/plan` `/auto` `/review` | 切换模式(只读 / 全自动 / 审核) |
241242
| `/model` | 弹窗选择模型(auto 按任务路由 / flash / pro 定死);也可 `/model flash` 直接指定 |
243+
| `/provider` | 在已配置的供应商间快捷切换:弹窗选(也可 `/provider <名字>` 直切)。每次 `/config` 会把配置按供应商名存档到 `~/.deepx/provider.yaml`,切换即把对应 flash/pro 写回 `model.yaml` |
242244
| `/reasoning` | 弹窗设置 `thinking` / `reasoning_effort`(flash/pro 各自独立;空值=不发该字段,对 MiMo 等不支持的模型零侵入) |
243245
| `/compact` | 手动压缩会话以节省上下文 |
244246
| `/new` `/sessions` | 开启全新对话 / 历史对话列表(↑↓ 选,Enter 切换) |

config/provider.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package config
2+
3+
// provider.yaml 是「已配置供应商」的存档,供 /provider 快捷切换。
4+
//
5+
// 每次 /config 完成会把当前配置按供应商名(deepseek / mimo / kimi / qwen / custom)
6+
// upsert 进来;/provider 从这里读名字列表、把选中供应商的 flash/pro 写回 model.yaml。
7+
//
8+
// YAML 结构(供应商名 → 该供应商的 {flash, pro},与 model.yaml 的 Config 同构):
9+
//
10+
// deepseek:
11+
// flash: {base_url, model, api_key, context_window, max_tokens}
12+
// pro: {base_url, model, api_key, context_window, max_tokens}
13+
// mimo:
14+
// flash: {...}
15+
// pro: {...}
16+
17+
import (
18+
"fmt"
19+
"os"
20+
"path/filepath"
21+
"sort"
22+
23+
"gopkg.in/yaml.v3"
24+
)
25+
26+
const providerFileName = "provider.yaml"
27+
28+
// Providers 是 provider.yaml 的反序列化目标:供应商名 → 该供应商的 {flash, pro} 配置。
29+
type Providers map[string]Config
30+
31+
// ProviderPath 返回 ~/.deepx/provider.yaml 绝对路径。
32+
func ProviderPath() (string, error) {
33+
home, err := os.UserHomeDir()
34+
if err != nil {
35+
return "", fmt.Errorf("无法获取用户目录: %w", err)
36+
}
37+
return filepath.Join(home, dirName, providerFileName), nil
38+
}
39+
40+
// LoadProviders 读 provider.yaml。文件不存在视为空(返回空 map,非错误);解析失败返回 err。
41+
func LoadProviders() (Providers, error) {
42+
p, err := ProviderPath()
43+
if err != nil {
44+
return nil, err
45+
}
46+
data, err := os.ReadFile(p)
47+
if os.IsNotExist(err) {
48+
return Providers{}, nil
49+
}
50+
if err != nil {
51+
return nil, err
52+
}
53+
var ps Providers
54+
if err := yaml.Unmarshal(data, &ps); err != nil {
55+
return nil, fmt.Errorf("解析 %s: %w", p, err)
56+
}
57+
if ps == nil {
58+
ps = Providers{}
59+
}
60+
return ps, nil
61+
}
62+
63+
// SaveProvider 把一份配置按供应商名 upsert 进 provider.yaml(其余供应商原样保留)。
64+
// custom 统一占 "custom" 一个槽,后配置的覆盖旧的。文件权限 0600(含 api key)。
65+
func SaveProvider(name string, c *Config) error {
66+
if name == "" || c == nil {
67+
return nil
68+
}
69+
ps, err := LoadProviders()
70+
if err != nil {
71+
// 读失败(文件损坏)就从空开始,免得一份坏文件永久挡住存档。
72+
ps = Providers{}
73+
}
74+
ps[name] = *c
75+
return saveProviders(ps)
76+
}
77+
78+
func saveProviders(ps Providers) error {
79+
p, err := ProviderPath()
80+
if err != nil {
81+
return err
82+
}
83+
if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil {
84+
return err
85+
}
86+
data, err := yaml.Marshal(ps)
87+
if err != nil {
88+
return err
89+
}
90+
return os.WriteFile(p, data, 0600)
91+
}
92+
93+
// LoadProvider 取单个供应商的存档配置;不存在返回 (nil, false, nil)。
94+
func LoadProvider(name string) (*Config, bool, error) {
95+
ps, err := LoadProviders()
96+
if err != nil {
97+
return nil, false, err
98+
}
99+
c, ok := ps[name]
100+
if !ok {
101+
return nil, false, nil
102+
}
103+
return &c, true, nil
104+
}
105+
106+
// ProviderNames 返回 provider.yaml 中已存的供应商名:预设供应商(ProviderOptions)按其顺序
107+
// 排在前,其余未知名按字母序排在后,便于 /provider 选择器稳定展示。文件为空时返回空切片。
108+
func ProviderNames() ([]string, error) {
109+
ps, err := LoadProviders()
110+
if err != nil {
111+
return nil, err
112+
}
113+
names := make([]string, 0, len(ps))
114+
seen := make(map[string]bool, len(ps))
115+
for _, p := range ProviderOptions {
116+
if _, ok := ps[p]; ok {
117+
names = append(names, p)
118+
seen[p] = true
119+
}
120+
}
121+
rest := make([]string, 0)
122+
for k := range ps {
123+
if !seen[k] {
124+
rest = append(rest, k)
125+
}
126+
}
127+
sort.Strings(rest)
128+
return append(names, rest...), nil
129+
}

config/provider_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package config
2+
3+
import (
4+
"testing"
5+
)
6+
7+
// TestProviderRoundTrip 验证 provider.yaml 的存档/读取/列名闭环:
8+
// SaveProvider upsert、LoadProvider 取回原值、ProviderNames 按预设顺序排列。
9+
func TestProviderRoundTrip(t *testing.T) {
10+
t.Setenv("HOME", t.TempDir()) // ProviderPath 走 os.UserHomeDir() → $HOME
11+
t.Setenv("USERPROFILE", t.TempDir()) // Windows 兜底,避免在该平台读到真实 home
12+
13+
// 初始为空。
14+
if names, err := ProviderNames(); err != nil || len(names) != 0 {
15+
t.Fatalf("空 provider.yaml 应返回空列表, got %v err=%v", names, err)
16+
}
17+
18+
ds := &Config{
19+
Flash: ModelEntry{BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", APIKey: "sk-ds"},
20+
Pro: ModelEntry{BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro", APIKey: "sk-ds"},
21+
}
22+
cu := &Config{
23+
Flash: ModelEntry{BaseURL: "https://x.example/v1", Model: "x-small", APIKey: "sk-x", MaxTokens: 4096},
24+
Pro: ModelEntry{BaseURL: "https://x.example/v1", Model: "x-large", APIKey: "sk-x", MaxTokens: 8192},
25+
}
26+
if err := SaveProvider("deepseek", ds); err != nil {
27+
t.Fatal(err)
28+
}
29+
if err := SaveProvider("custom", cu); err != nil {
30+
t.Fatal(err)
31+
}
32+
33+
// ProviderNames:预设顺序优先(deepseek 在 custom 前)。
34+
names, err := ProviderNames()
35+
if err != nil {
36+
t.Fatal(err)
37+
}
38+
if len(names) != 2 || names[0] != "deepseek" || names[1] != "custom" {
39+
t.Fatalf("期望 [deepseek custom], got %v", names)
40+
}
41+
42+
// LoadProvider 取回原值。
43+
got, ok, err := LoadProvider("custom")
44+
if err != nil || !ok {
45+
t.Fatalf("custom 应存在, ok=%v err=%v", ok, err)
46+
}
47+
if got.Flash.Model != "x-small" || got.Pro.Model != "x-large" || got.Pro.MaxTokens != 8192 {
48+
t.Fatalf("custom 配置读回不一致: %+v", got)
49+
}
50+
51+
// 不存在的供应商。
52+
if _, ok, _ := LoadProvider("nope"); ok {
53+
t.Fatal("不存在的供应商应返回 ok=false")
54+
}
55+
56+
// upsert 覆盖同名,且不影响其它供应商。
57+
ds2 := &Config{Flash: ModelEntry{Model: "deepseek-v5-flash"}, Pro: ModelEntry{Model: "deepseek-v5-pro"}}
58+
if err := SaveProvider("deepseek", ds2); err != nil {
59+
t.Fatal(err)
60+
}
61+
got2, _, _ := LoadProvider("deepseek")
62+
if got2.Flash.Model != "deepseek-v5-flash" {
63+
t.Fatalf("deepseek 应被覆盖为 v5, got %q", got2.Flash.Model)
64+
}
65+
if cuStill, ok, _ := LoadProvider("custom"); !ok || cuStill.Flash.Model != "x-small" {
66+
t.Fatal("覆盖 deepseek 不应影响 custom")
67+
}
68+
}

tui/i18n.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ var translations = map[string]map[Lang]string{
8585
LangZH: "重新配置 API key",
8686
LangEN: "Reconfigure API key",
8787
},
88+
"cmd.provider.desc": {
89+
LangZH: "在已配置的供应商间快捷切换:/provider [名字]",
90+
LangEN: "Quick-switch between configured providers: /provider [name]",
91+
},
8892
"cmd.skills.desc": {
8993
LangZH: "列出可用 skill",
9094
LangEN: "List available skills",
@@ -310,6 +314,7 @@ var translations = map[string]map[Lang]string{
310314
"- `/review` — 切到审核模式(Write/Update/Bash 需人工确认)\n" +
311315
"- `/mode` — 显示当前模式\n" +
312316
"- `/config` — 重新配置 API key (覆盖 `~/.deepx/model.yaml`)\n" +
317+
"- `/provider` — 在已配置的供应商间快捷切换(`/provider [名字]`;配置存于 `~/.deepx/provider.yaml`)\n" +
313318
"- `/skills` — 列出可用 skill\n" +
314319
"- `/skill-add` `/skill-delete` — 搜索安装 / 删除 skill\n" +
315320
"- `/mcp-list` `/mcp-add` `/mcp-delete` — 管理 MCP server\n" +
@@ -343,6 +348,7 @@ var translations = map[string]map[Lang]string{
343348
"- `/review` — Switch to review mode (Write/Update/Bash require confirmation)\n" +
344349
"- `/mode` — Show current mode\n" +
345350
"- `/config` — Reconfigure API key (overwrites `~/.deepx/model.yaml`)\n" +
351+
"- `/provider` — Quick-switch between configured providers (`/provider [name]`; saved in `~/.deepx/provider.yaml`)\n" +
346352
"- `/skills` — List available skills\n" +
347353
"- `/skill-add` `/skill-delete` — Search-install / delete skills\n" +
348354
"- `/mcp-list` `/mcp-add` `/mcp-delete` — Manage MCP servers\n" +
@@ -583,6 +589,30 @@ var translations = map[string]map[Lang]string{
583589
LangZH: "↑/↓ 选择 · Enter 确认 · Esc 取消",
584590
LangEN: "↑/↓ select · Enter confirm · Esc cancel",
585591
},
592+
"provider.title": {
593+
LangZH: "切换模型供应商",
594+
LangEN: "Switch Model Provider",
595+
},
596+
"provider.empty": {
597+
LangZH: "还没有已保存的供应商配置。先用 /config 配置一个,会自动存档到 provider.yaml。",
598+
LangEN: "No saved provider configs yet. Configure one with /config first — it's archived to provider.yaml automatically.",
599+
},
600+
"provider.unknown": {
601+
LangZH: "未找到供应商「%s」。/provider 看可选列表。",
602+
LangEN: "Provider \"%s\" not found. Run /provider to see the list.",
603+
},
604+
"provider.switched": {
605+
LangZH: "↩ 已切换到供应商「%s」(flash: %s · pro: %s)",
606+
LangEN: "↩ Switched to provider \"%s\" (flash: %s · pro: %s)",
607+
},
608+
"provider.error.load": {
609+
LangZH: "读取 provider.yaml 失败:%v",
610+
LangEN: "Failed to read provider.yaml: %v",
611+
},
612+
"provider.error.save": {
613+
LangZH: "切换供应商失败:%v",
614+
LangEN: "Failed to switch provider: %v",
615+
},
586616
"workingmode.title": {
587617
LangZH: "选择工作模式",
588618
LangEN: "Choose Working Mode",

0 commit comments

Comments
 (0)