返回 DeepSeek-Reasonix
default_model_test.go
根目录 / internal / cli / default_model_test.go
1 package cli
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "reasonix/internal/config"
11 "reasonix/internal/event"
12 )
13
14 const (
15 defaultModelTestConfiguredEnv = "REASONIX_CLI_TEST_CONFIGURED_KEY"
16 defaultModelTestKeylessEnv = "REASONIX_CLI_TEST_KEYLESS_KEY"
17 )
18
19 func newDefaultModelTestConfig() *config.Config {
20 return &config.Config{
21 Providers: []config.ProviderEntry{
22 {Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", APIKeyEnv: defaultModelTestKeylessEnv},
23 {Name: "audio", Kind: "openai", BaseURL: "https://audio.example.com", Model: "tts-1", APIKeyEnv: defaultModelTestConfiguredEnv},
24 {Name: "embedding", Kind: "openai", BaseURL: "https://embedding.example.com", Model: "text-embedding-3-small", APIKeyEnv: defaultModelTestConfiguredEnv},
25 {Name: "minimax", Kind: "openai", BaseURL: "https://api.MiniMax.chat/v1", Model: "MiniMax-M3", APIKeyEnv: defaultModelTestConfiguredEnv},
26 },
27 }
28 }
29
30 func TestResolveModelForCLI(t *testing.T) {
31 cases := []struct {
32 name string
33 explicitRef string
34 defaultModel string
35 configured bool
36 keyless bool
37 wantRef string
38 wantFallback bool
39 wantErrSub string
40 }{
41 {
42 // The original bug: default_model points to a keyless provider
43 // while another provider has a configured key. CLI must fall
44 // through to that provider rather than fail on the keyless
45 // default. Issue #6996.
46 name: "keyless default falls back to configured provider",
47 explicitRef: "",
48 defaultModel: "deepseek-flash/deepseek-v4-flash",
49 configured: true,
50 keyless: false,
51 wantRef: "minimax/MiniMax-M3",
52 wantFallback: true,
53 },
54 {
55 name: "configured default is used verbatim",
56 explicitRef: "",
57 defaultModel: "minimax/MiniMax-M3",
58 configured: true,
59 keyless: false,
60 wantRef: "minimax/MiniMax-M3",
61 wantFallback: false,
62 },
63 {
64 // Nothing configured anywhere. The raw keyless default is
65 // returned so the boot-time missing-key banner can still tell
66 // the user which API key to set.
67 name: "all keyless preserves raw default for downstream banner",
68 explicitRef: "",
69 defaultModel: "deepseek-flash/deepseek-v4-flash",
70 configured: false,
71 keyless: false,
72 wantRef: "deepseek-flash/deepseek-v4-flash",
73 wantFallback: false,
74 },
75 {
76 name: "empty default falls back to first configured provider",
77 explicitRef: "",
78 defaultModel: "",
79 configured: true,
80 keyless: false,
81 wantRef: "minimax/MiniMax-M3",
82 wantFallback: true,
83 },
84 {
85 name: "empty default with all keyless providers keeps chat candidate",
86 explicitRef: "",
87 defaultModel: "",
88 configured: false,
89 keyless: false,
90 wantRef: "deepseek-flash/deepseek-v4-flash",
91 wantFallback: true,
92 },
93 {
94 name: "explicit unknown ref errors",
95 explicitRef: "does-not-exist/whatever",
96 defaultModel: "minimax/MiniMax-M3",
97 configured: true,
98 keyless: false,
99 wantErrSub: "unknown model",
100 },
101 {
102 // User passed --model with a keyless ref. Must fail loudly even
103 // when another provider is configured and could be used; the
104 // user asked for a specific model.
105 name: "explicit keyless ref errors even with configured fallback available",
106 explicitRef: "deepseek-flash/deepseek-v4-flash",
107 defaultModel: "minimax/MiniMax-M3",
108 configured: true,
109 keyless: false,
110 wantErrSub: "requires " + defaultModelTestKeylessEnv,
111 },
112 {
113 name: "explicit configured ref is used verbatim",
114 explicitRef: "minimax/MiniMax-M3",
115 defaultModel: "deepseek-flash/deepseek-v4-flash",
116 configured: true,
117 keyless: false,
118 wantRef: "minimax/MiniMax-M3",
119 wantFallback: false,
120 },
121 {
122 // An explicit plugin-namespaced ref passes through unresolved:
123 // extension sidecars own it, boot's merged resolver gates it.
124 name: "explicit plugin ref passes through",
125 explicitRef: "plugin/demo/fake/x",
126 defaultModel: "minimax/MiniMax-M3",
127 configured: true,
128 keyless: false,
129 wantRef: "plugin/demo/fake/x",
130 wantFallback: false,
131 },
132 {
133 // Two segments only is NOT a plugin ref — it stays an ordinary
134 // (unknown) config ref and keeps failing loudly.
135 name: "two-segment plugin shape is not a plugin ref",
136 explicitRef: "plugin/demo",
137 defaultModel: "minimax/MiniMax-M3",
138 configured: true,
139 keyless: false,
140 wantErrSub: "unknown model",
141 },
142 }
143
144 for _, tc := range cases {
145 t.Run(tc.name, func(t *testing.T) {
146 isolateCLIConfigHome(t)
147 setCredential(t, defaultModelTestConfiguredEnv, tc.configured)
148 setCredential(t, defaultModelTestKeylessEnv, tc.keyless)
149
150 cfg := newDefaultModelTestConfig()
151 cfg.DefaultModel = tc.defaultModel
152
153 got, gotFallback, err := resolveModelForCLI(tc.explicitRef, cfg)
154 if tc.wantErrSub != "" {
155 if err == nil {
156 t.Fatalf("expected error containing %q, got (ref=%q, fallback=%v, nil err)", tc.wantErrSub, got, gotFallback)
157 }
158 if !strings.Contains(err.Error(), tc.wantErrSub) {
159 t.Fatalf("error %q does not contain %q", err.Error(), tc.wantErrSub)
160 }
161 return
162 }
163 if err != nil {
164 t.Fatalf("unexpected error: %v", err)
165 }
166 if got != tc.wantRef {
167 t.Fatalf("ref = %q, want %q", got, tc.wantRef)
168 }
169 if gotFallback != tc.wantFallback {
170 t.Fatalf("fallback = %v, want %v", gotFallback, tc.wantFallback)
171 }
172 })
173 }
174 }
175
176 func TestResolveServeModelUsesGlobalChatFallback(t *testing.T) {
177 isolateCLIConfigHome(t)
178 setCredential(t, defaultModelTestConfiguredEnv, true)
179 setCredential(t, defaultModelTestKeylessEnv, false)
180
181 cfg := newDefaultModelTestConfig()
182 cfg.DefaultModel = "deepseek-flash/deepseek-v4-flash"
183 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
184 t.Fatal(err)
185 }
186 if err := os.WriteFile("reasonix.toml", []byte(`
187 default_model = "project/project-chat"
188
189 [[providers]]
190 name = "project"
191 kind = "openai"
192 base_url = "https://project.example.com"
193 model = "project-chat"
194 api_key_env = "`+defaultModelTestConfiguredEnv+`"
195 `), 0o644); err != nil {
196 t.Fatal(err)
197 }
198
199 if got := resolveServeModel(""); got != "minimax/MiniMax-M3" {
200 t.Fatalf("resolveServeModel(\"\") = %q, want global chat fallback", got)
201 }
202 if got := resolveServeModel("explicit/chat"); got != "explicit/chat" {
203 t.Fatalf("resolveServeModel(explicit) = %q, want explicit model preserved", got)
204 }
205 }
206
207 func TestNewChatTUIKeepsExplicitKeylessControllerModel(t *testing.T) {
208 isolateCLIConfigHome(t)
209 setCredential(t, defaultModelTestConfiguredEnv, true)
210 setCredential(t, defaultModelTestKeylessEnv, false)
211
212 cfg := newDefaultModelTestConfig()
213 cfg.DefaultModel = "minimax/MiniMax-M3"
214 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
215 t.Fatal(err)
216 }
217
218 const explicit = "deepseek-flash/deepseek-v4-flash"
219 ctrl, err := setupProfile(context.Background(), explicit, 0, false, event.Discard, "balanced", "")
220 if err != nil {
221 t.Fatalf("interactive build should remain reachable for missing-key recovery: %v", err)
222 }
223 defer ctrl.Close()
224
225 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
226 if got := m.modelRef; got != explicit {
227 t.Fatalf("TUI modelRef = %q, want explicit controller model %q", got, explicit)
228 }
229 }
230
231 // setCredential writes a "configured" sentinel key into Reasonix's user
232 // credentials store, or clears it. ProviderEntry.Configured() resolves keys
233 // only from that store (not from process env), so this is the only way to
234 // flip a test provider between configured and keyless.
235 func setCredential(t *testing.T, key string, configured bool) {
236 t.Helper()
237 if configured {
238 if _, err := config.SetCredential(key, "sk-test"); err != nil {
239 t.Fatalf("SetCredential(%s): %v", key, err)
240 }
241 return
242 }
243 if err := config.RemoveCredential(key); err != nil {
244 t.Fatalf("RemoveCredential(%s): %v", key, err)
245 }
246 // Make sure the credentials file exists even after a clear so a follow-up
247 // read on a different path does not synthesize a real one. Empty file is
248 // fine; storedCredentialValue returns ("", false) for missing keys.
249 if _, err := os.Stat(config.UserCredentialsPath()); os.IsNotExist(err) {
250 if err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o700); err != nil {
251 t.Fatalf("mkdir credentials dir: %v", err)
252 }
253 if err := os.WriteFile(config.UserCredentialsPath(), []byte{}, 0o600); err != nil {
254 t.Fatalf("write empty credentials: %v", err)
255 }
256 }
257 }
258
258 lines GO