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