返回 DeepSeek-Reasonix
model_error_test.go
根目录 / internal / boot / model_error_test.go
1 package boot
2
3 import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "reasonix/internal/config"
12 "reasonix/internal/control"
13 "reasonix/internal/event"
14
15 _ "reasonix/internal/provider/openai"
16 _ "reasonix/internal/tool/builtin"
17 )
18
19 // TestBuildUnknownModelErrorIsActionable: a default_model that doesn't resolve
20 // (e.g. a stale preset name after [[providers]] replaced the built-in presets) must
21 // fail with a message that names the model, lists what IS configured, and hints
22 // at the [[providers]] trap — not a silent empty model. This contract holds when
23 // the project file is the only config, so isolate REASONIX_HOME: a user-global
24 // config with an explicit default_model would instead rescue the boot (#4218).
25 func TestBuildUnknownModelErrorIsActionable(t *testing.T) {
26 t.Setenv("REASONIX_HOME", t.TempDir())
27 dir := robustTempDir(t)
28 fenceBootTestHistoryCatalog(t)
29 t.Chdir(dir)
30 writeFile(t, dir, "reasonix.toml", `
31 default_model = "legacy-missing"
32
33 [[providers]]
34 name = "deepseek-flash"
35 kind = "openai"
36 base_url = "https://example.invalid"
37 model = "deepseek-v4-flash"
38 api_key_env = "REASONIX_TEST_KEY_UNSET"
39 `)
40 approveWorkspace(t, dir)
41
42 _, err := Build(context.Background(), Options{Sink: event.Discard})
43 if err == nil {
44 t.Fatal("expected an error for an unresolvable default_model")
45 }
46 msg := err.Error()
47 for _, want := range []string{`"legacy-missing"`, "deepseek-flash", "[[providers]]"} {
48 if !strings.Contains(msg, want) {
49 t.Fatalf("error %q should mention %q", msg, want)
50 }
51 }
52 }
53
54 func TestBuildNoticesProjectDefaultModelFallback(t *testing.T) {
55 home := t.TempDir()
56 t.Setenv("REASONIX_HOME", home)
57 writeFile(t, home, "config.toml", `
58 default_model = "deepseek-pro"
59
60 [[providers]]
61 name = "deepseek-pro"
62 kind = "openai"
63 base_url = "https://example.invalid"
64 model = "deepseek-v4-pro"
65 api_key_env = "REASONIX_TEST_KEY_UNSET"
66 `)
67
68 dir := robustTempDir(t)
69 fenceBootTestHistoryCatalog(t)
70 t.Chdir(dir)
71 writeFile(t, dir, "reasonix.toml", `
72 default_model = "deepseek-flash"
73 `)
74 approveWorkspace(t, dir)
75
76 var notices []event.Event
77 ctrl, err := Build(context.Background(), Options{
78 Sink: event.FuncSink(func(e event.Event) {
79 if e.Kind == event.Notice {
80 notices = append(notices, e)
81 }
82 }),
83 })
84 if err != nil {
85 t.Fatalf("Build should fall back to the user default model: %v", err)
86 }
87 defer ctrl.Close()
88
89 for _, notice := range notices {
90 if notice.Level == event.LevelWarn &&
91 notice.Text == "Ignored the project config's default_model." &&
92 strings.Contains(notice.Detail, `default_model = "deepseek-flash"`) &&
93 strings.Contains(notice.Detail, `using "deepseek-pro"`) {
94 return
95 }
96 }
97 t.Fatalf("expected a warning naming the ignored project model and user fallback; got %v", notices)
98 }
99
100 func TestBuildMigratesLegacyBareMimoModelOverride(t *testing.T) {
101 dir := robustTempDir(t)
102 fenceBootTestHistoryCatalog(t)
103 t.Chdir(dir)
104 writeFile(t, dir, "reasonix.toml", `
105 default_model = "deepseek-flash"
106
107 [[providers]]
108 name = "deepseek-flash"
109 kind = "openai"
110 base_url = "https://example.invalid"
111 model = "deepseek-v4-flash"
112 api_key_env = "REASONIX_TEST_KEY_UNSET"
113 `)
114 approveWorkspace(t, dir)
115
116 ctrl, err := Build(context.Background(), Options{Sink: event.Discard, Model: "mimo-v2.5-pro"})
117 if err != nil {
118 t.Fatalf("Build should migrate legacy bare MiMo model override: %v", err)
119 }
120 defer ctrl.Close()
121 if ctrl.Label() != "mimo-v2.5-pro" {
122 t.Fatalf("controller label = %q, want mimo-v2.5-pro", ctrl.Label())
123 }
124 }
125
126 // TestBuildNoticesMissingAPIKey: a resolvable model whose API key env is unset
127 // builds fine (RequireKey is false so the UI stays reachable) but must emit a
128 // notice naming the env var, instead of silently showing a dead/empty model.
129 func TestBuildNoticesMissingAPIKey(t *testing.T) {
130 const keyEnv = "REASONIX_MISSING_KEY_FOR_TEST"
131 dir := robustTempDir(t)
132 fenceBootTestHistoryCatalog(t)
133 t.Chdir(dir)
134 writeFile(t, dir, "reasonix.toml", `
135 default_model = "x"
136
137 [[providers]]
138 name = "x"
139 kind = "openai"
140 base_url = "https://example.invalid"
141 model = "m"
142 api_key_env = "`+keyEnv+`"
143 `)
144 approveWorkspace(t, dir)
145
146 var notices []event.Event
147 ctrl, err := Build(context.Background(), Options{
148 Sink: event.FuncSink(func(e event.Event) {
149 if e.Kind == event.Notice {
150 notices = append(notices, e)
151 }
152 }),
153 })
154 if err != nil {
155 t.Fatalf("Build should succeed with RequireKey=false even without a key: %v", err)
156 }
157 defer ctrl.Close()
158
159 found := false
160 for _, n := range notices {
161 if n.Text == "Selected model is missing its API key." && strings.Contains(n.Detail, keyEnv) {
162 found = true
163 }
164 }
165 if !found {
166 t.Fatalf("expected a notice naming the unset key env %q; got %v", keyEnv, notices)
167 }
168 }
169
170 func TestBuildClassifiesUnavailableCredentialStore(t *testing.T) {
171 home := t.TempDir()
172 t.Setenv("REASONIX_HOME", home)
173 if err := os.Mkdir(filepath.Join(home, ".env"), 0o700); err != nil {
174 t.Fatal(err)
175 }
176 writeFile(t, home, "config.toml", `
177 default_model = "relay/chat"
178
179 [[providers]]
180 name = "relay"
181 kind = "openai"
182 base_url = "https://example.invalid/v1"
183 model = "chat"
184 api_key_env = "RELAY_TEST_KEY"
185 `)
186 dir := robustTempDir(t)
187 fenceBootTestHistoryCatalog(t)
188 t.Chdir(dir)
189
190 ctrl, err := Build(context.Background(), Options{Sink: event.Discard})
191 if err != nil {
192 t.Fatal(err)
193 }
194 defer ctrl.Close()
195 if got := ctrl.AuthenticationState(); got.Status != control.AuthenticationCredentialStoreUnavailable || got.Code != "credential_store_unavailable" {
196 t.Fatalf("authentication state = %+v", got)
197 }
198
199 _, err = Build(context.Background(), Options{Sink: event.Discard, RequireKey: true, Model: "relay/chat"})
200 var authErr *control.AuthenticationError
201 if !errors.As(err, &authErr) || authErr.State.Status != control.AuthenticationCredentialStoreUnavailable {
202 t.Fatalf("headless build error = %v, want credential-store AuthenticationError", err)
203 }
204 }
205
206 func TestBuildDoesNotNoticeMissingAPIKeyForNoAuthLoopback(t *testing.T) {
207 const keyEnv = "REASONIX_LOCAL_GATEWAY_KEY_FOR_TEST"
208 dir := robustTempDir(t)
209 fenceBootTestHistoryCatalog(t)
210 t.Chdir(dir)
211 t.Setenv(keyEnv, "")
212 writeFile(t, dir, "reasonix.toml", `
213 default_model = "local/model-a"
214
215 [[providers]]
216 name = "local"
217 kind = "openai"
218 base_url = "http://127.0.0.1:23333/v1"
219 models = ["model-a"]
220 api_key_env = "`+keyEnv+`"
221 `)
222 approveWorkspace(t, dir)
223
224 var notices []string
225 ctrl, err := Build(context.Background(), Options{
226 Sink: event.FuncSink(func(e event.Event) {
227 if e.Kind == event.Notice {
228 notices = append(notices, e.Text)
229 }
230 }),
231 })
232 if err != nil {
233 t.Fatalf("Build should allow no-auth loopback provider without a key: %v", err)
234 }
235 defer ctrl.Close()
236
237 for _, n := range notices {
238 if strings.Contains(n, keyEnv) {
239 t.Fatalf("did not expect missing-key notice for loopback no-auth provider; got %v", notices)
240 }
241 }
242 }
243
244 // TestBuildKeylessDefaultFallsBackToConfiguredProvider: when the configured
245 // default_model is resolvable but its API key is missing, Build should fall
246 // through to the next provider that IS configured rather than fail with
247 // "missing env X_API_KEY" (issue #6996). The fallback only kicks in when
248 // the caller did not pass an explicit Options.Model — explicit choices
249 // still fail loudly so the user is not silently rerouted.
250 func TestBuildKeylessDefaultFallsBackToConfiguredProvider(t *testing.T) {
251 const keylessEnv = "REASONIX_KEYLESS_DEFAULT_FALLBACK_KEYLESS"
252 const configuredEnv = "REASONIX_KEYLESS_DEFAULT_FALLBACK_CONFIGURED"
253
254 home := t.TempDir()
255 t.Setenv("REASONIX_HOME", home)
256 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
257 if _, err := config.SetCredential(configuredEnv, "sk-test"); err != nil {
258 t.Fatalf("seed configured key: %v", err)
259 }
260
261 dir := robustTempDir(t)
262 fenceBootTestHistoryCatalog(t)
263 t.Chdir(dir)
264 writeFile(t, dir, "reasonix.toml", `
265 default_model = "deepseek/deepseek-v4-flash"
266
267 [[providers]]
268 name = "deepseek"
269 kind = "openai"
270 base_url = "https://api.deepseek.com"
271 model = "deepseek-v4-flash"
272 api_key_env = "`+keylessEnv+`"
273
274 [[providers]]
275 name = "audio"
276 kind = "openai"
277 base_url = "https://audio.example.com/v1"
278 model = "tts-1"
279 api_key_env = "`+configuredEnv+`"
280
281 [[providers]]
282 name = "minimax"
283 kind = "openai"
284 base_url = "https://api.MiniMax.chat/v1"
285 model = "MiniMax-M3"
286 api_key_env = "`+configuredEnv+`"
287 `)
288 approveWorkspace(t, dir)
289
290 ctrl, err := Build(context.Background(), Options{Sink: event.Discard})
291 if err != nil {
292 t.Fatalf("Build should fall back to a configured provider instead of failing on the keyless default: %v", err)
293 }
294 defer ctrl.Close()
295 if got, want := ctrl.Label(), "MiniMax-M3"; got != want {
296 t.Fatalf("controller label = %q, want %q (the next provider's model with a configured key)", got, want)
297 }
298 }
299
300 // TestBuildExplicitKeylessModelStillFails: an Options.Model that the user
301 // passed explicitly must keep its fail-fast behavior even when another
302 // provider is configured and could be used as a fallback. The user asked
303 // for that specific ref, so we must not silently reroute them.
304 func TestBuildExplicitKeylessModelStillFails(t *testing.T) {
305 const keylessEnv = "REASONIX_EXPLICIT_KEYLESS_KEY"
306 const configuredEnv = "REASONIX_EXPLICIT_KEYLESS_CONFIGURED"
307
308 home := t.TempDir()
309 t.Setenv("REASONIX_HOME", home)
310 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
311 if _, err := config.SetCredential(configuredEnv, "sk-test"); err != nil {
312 t.Fatalf("seed configured key: %v", err)
313 }
314
315 dir := robustTempDir(t)
316 fenceBootTestHistoryCatalog(t)
317 t.Chdir(dir)
318 writeFile(t, dir, "reasonix.toml", `
319 default_model = "minimax/MiniMax-M3"
320
321 [[providers]]
322 name = "deepseek"
323 kind = "openai"
324 base_url = "https://api.deepseek.com"
325 model = "deepseek-v4-flash"
326 api_key_env = "`+keylessEnv+`"
327
328 [[providers]]
329 name = "minimax"
330 kind = "openai"
331 base_url = "https://api.MiniMax.chat/v1"
332 model = "MiniMax-M3"
333 api_key_env = "`+configuredEnv+`"
334 `)
335 approveWorkspace(t, dir)
336
337 _, err := Build(context.Background(), Options{
338 Sink: event.Discard,
339 Model: "deepseek/deepseek-v4-flash",
340 RequireKey: true,
341 })
342 if err == nil {
343 t.Fatal("explicit keyless ref must fail loudly even with a configured fallback available")
344 }
345 if !strings.Contains(err.Error(), keylessEnv) {
346 t.Fatalf("error %q should mention %q", err.Error(), keylessEnv)
347 }
348 }
349
349 lines GO