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