返回 DeepSeek-Reasonix
model_fallback_test.go
根目录 / internal / config / model_fallback_test.go
1 package config
2
3 import (
4 "strings"
5 "testing"
6 )
7
8 func testModelFallbackConfig(t *testing.T) *Config {
9 t.Helper()
10
11 c := Default()
12 c.DefaultModel = "prov-a"
13 c.Providers = []ProviderEntry{
14 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", Models: []string{"model-a1", "model-a2"}, APIKeyEnv: "REASONIX_TEST_KEY", resolvedAPIKey: "sk-test"},
15 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", Models: []string{"model-b1", "model-b2"}, APIKeyEnv: "REASONIX_TEST_KEY", resolvedAPIKey: "sk-test"},
16 {Name: "prov-nokey", Kind: "openai", BaseURL: "https://nk.example.com", Model: "model-nk", APIKeyEnv: "REASONIX_TEST_EMPTY"},
17 }
18 return c
19 }
20
21 func TestResolveModelWithFallback(t *testing.T) {
22 c := testModelFallbackConfig(t)
23
24 cases := []struct {
25 name string
26 ref string
27 wantResolved string
28 wantFallback bool
29 wantOK bool
30 }{
31 {"direct provider model", "prov-a/model-a2", "prov-a/model-a2", false, true},
32 {"provider name", "prov-b", "prov-b/model-b1", false, true},
33 {"bare model", "model-a1", "prov-a/model-a1", false, true},
34 {"empty falls back", "", "prov-a/model-a1", true, true},
35 {"stale falls back", "deleted/model", "prov-a/model-a1", true, true},
36 }
37
38 for _, tt := range cases {
39 t.Run(tt.name, func(t *testing.T) {
40 got, fallback, ok := c.ResolveModelWithFallback(tt.ref)
41 if got != tt.wantResolved || fallback != tt.wantFallback || ok != tt.wantOK {
42 t.Fatalf("ResolveModelWithFallback(%q) = (%q, %v, %v), want (%q, %v, %v)", tt.ref, got, fallback, ok, tt.wantResolved, tt.wantFallback, tt.wantOK)
43 }
44 })
45 }
46
47 c.Providers = nil
48 if got, fallback, ok := c.ResolveModelWithFallback("deleted/model"); got != "" || fallback || ok {
49 t.Fatalf("no providers fallback = (%q, %v, %v), want empty false false", got, fallback, ok)
50 }
51 }
52
53 func TestResolveModelWithFallbackSkipsKeylessProvider(t *testing.T) {
54 c := testModelFallbackConfig(t)
55 // Make the first provider keyless. A fallback must skip it and pick the next
56 // configured provider, rather than booting a tab onto a provider with no API
57 // key (which just fails on first use).
58 c.Providers[0].APIKeyEnv = "REASONIX_TEST_EMPTY"
59 c.Providers[0].resolvedAPIKey = ""
60
61 got, fallback, ok := c.ResolveModelWithFallback("")
62 if !ok || !fallback {
63 t.Fatalf("ResolveModelWithFallback(\"\") = (%q, %v, %v), want a fallback", got, fallback, ok)
64 }
65 if got != "prov-b/model-b1" {
66 t.Errorf("fallback = %q, want prov-b/model-b1 (prov-a is keyless and must be skipped)", got)
67 }
68 }
69
70 // TestResolveModelWithFallbackHonorsDefaultModel verifies that when the ref is
71 // stale or empty, the function tries c.DefaultModel before iterating providers
72 // in order. Without this, the first provider (deepseek, by default) always wins
73 // even when the user has configured a different default_model (#3801).
74 func TestResolveModelWithFallbackHonorsDefaultModel(t *testing.T) {
75 c := testModelFallbackConfig(t)
76 // Set DefaultModel to prov-b (not the first provider). An empty/stale ref
77 // should fall back to prov-b, not prov-a.
78 c.DefaultModel = "prov-b"
79
80 // Empty ref → must use DefaultModel = prov-b
81 got, fallback, ok := c.ResolveModelWithFallback("")
82 if !ok || !fallback {
83 t.Fatalf("ResolveModelWithFallback(\"\") = (%q, %v, %v), want fallback to prov-b", got, fallback, ok)
84 }
85 if got != "prov-b/model-b1" {
86 t.Errorf("empty ref fallback = %q, want prov-b/model-b1 (DefaultModel)", got)
87 }
88
89 // Stale ref → same: must use DefaultModel
90 got, fallback, ok = c.ResolveModelWithFallback("deleted/model")
91 if !ok || !fallback {
92 t.Fatalf("ResolveModelWithFallback(\"deleted/model\") = (%q, %v, %v), want fallback", got, fallback, ok)
93 }
94 if got != "prov-b/model-b1" {
95 t.Errorf("stale ref fallback = %q, want prov-b/model-b1 (DefaultModel)", got)
96 }
97
98 // DefaultModel pointing to a keyless provider must be skipped
99 c.Providers[1].APIKeyEnv = "REASONIX_TEST_EMPTY" // prov-b, the DefaultModel
100 c.Providers[1].resolvedAPIKey = ""
101 got, fallback, ok = c.ResolveModelWithFallback("stale/ref")
102 if !ok || !fallback {
103 t.Fatalf("keyless DefaultModel fallback = (%q, %v, %v), want next configured", got, fallback, ok)
104 }
105 if got != "prov-a/model-a1" {
106 t.Errorf("keyless DefaultModel fallback = %q, want prov-a/model-a1 (only configured left)", got)
107 }
108 }
109
110 func TestResolveNewSessionChatModelSkipsNonChatProviders(t *testing.T) {
111 c := &Config{
112 DefaultModel: "keyless/chat",
113 Providers: []ProviderEntry{
114 {Name: "keyless", BaseURL: "https://keyless.example.com", Model: "chat", APIKeyEnv: "MISSING_KEY"},
115 {Name: "audio", BaseURL: "https://audio.example.com", Model: "tts-1", resolvedAPIKey: "sk-test"},
116 {Name: "embedding", BaseURL: "https://embedding.example.com", Model: "text-embedding-3-small", resolvedAPIKey: "sk-test"},
117 {Name: "visible", BaseURL: "https://visible.example.com", Models: []string{"text-embedding-3-small", "chat-model"}, Default: "text-embedding-3-small", resolvedAPIKey: "sk-test"},
118 },
119 }
120
121 got, fallback, ok := c.ResolveNewSessionChatModel()
122 if !ok || !fallback || got != "visible/chat-model" {
123 t.Fatalf("ResolveNewSessionChatModel() = (%q, %v, %v), want (visible/chat-model, true, true)", got, fallback, ok)
124 }
125 }
126
127 func TestResolveNewSessionChatModelIgnoresDesktopProviderAccess(t *testing.T) {
128 c := &Config{
129 DefaultModel: "visible/chat",
130 Desktop: DesktopConfig{ProviderAccess: []string{}},
131 Providers: []ProviderEntry{
132 {Name: "visible", BaseURL: "https://visible.example.com", Model: "chat", resolvedAPIKey: "sk-test"},
133 },
134 }
135
136 got, fallback, ok := c.ResolveNewSessionChatModel()
137 if !ok || fallback || got != "visible/chat" {
138 t.Fatalf("ResolveNewSessionChatModel() = (%q, %v, %v), want (visible/chat, false, true)", got, fallback, ok)
139 }
140 }
141
142 func TestResolveDesktopNewSessionModelFiltersUnavailableAndNonChatProviders(t *testing.T) {
143 c := &Config{
144 DefaultModel: "hidden/chat",
145 Desktop: DesktopConfig{ProviderAccess: []string{"audio", "visible"}},
146 Providers: []ProviderEntry{
147 {Name: "hidden", BaseURL: "https://hidden.example.com", Model: "chat", APIKeyEnv: "KEY", resolvedAPIKey: "sk-test"},
148 {Name: "audio", BaseURL: "https://audio.example.com", Model: "tts-1", APIKeyEnv: "KEY", resolvedAPIKey: "sk-test"},
149 {Name: "visible", BaseURL: "https://visible.example.com", Models: []string{"text-embedding-3-small", "chat-model"}, Default: "text-embedding-3-small", APIKeyEnv: "KEY", resolvedAPIKey: "sk-test"},
150 },
151 }
152
153 got, fallback, ok := c.ResolveDesktopNewSessionModel()
154 if !ok || !fallback || got != "visible/chat-model" {
155 t.Fatalf("ResolveDesktopNewSessionModel() = (%q, %v, %v), want (visible/chat-model, true, true)", got, fallback, ok)
156 }
157 }
158
159 func TestResolveDesktopNewSessionModelDoesNotReadmitIneligibleDefaultWithoutFallback(t *testing.T) {
160 tests := []struct {
161 name string
162 cfg *Config
163 }{
164 {
165 name: "provider is outside desktop access",
166 cfg: &Config{
167 DefaultModel: "hidden/chat",
168 Desktop: DesktopConfig{ProviderAccess: []string{"audio"}},
169 Providers: []ProviderEntry{
170 {Name: "hidden", BaseURL: "https://hidden.example.com", Model: "chat", APIKeyEnv: "KEY", resolvedAPIKey: "sk-test"},
171 {Name: "audio", BaseURL: "https://audio.example.com", Model: "tts-1", APIKeyEnv: "KEY", resolvedAPIKey: "sk-test"},
172 },
173 },
174 },
175 {
176 name: "default is not a chat model",
177 cfg: &Config{
178 DefaultModel: "audio/tts-1",
179 Desktop: DesktopConfig{ProviderAccess: []string{"audio"}},
180 Providers: []ProviderEntry{
181 {Name: "audio", BaseURL: "https://audio.example.com", Model: "tts-1", APIKeyEnv: "KEY", resolvedAPIKey: "sk-test"},
182 },
183 },
184 },
185 }
186
187 for _, tt := range tests {
188 t.Run(tt.name, func(t *testing.T) {
189 if got, fallback, ok := tt.cfg.ResolveDesktopNewSessionModel(); got != "" || fallback || ok {
190 t.Fatalf("ResolveDesktopNewSessionModel() = (%q, %v, %v), want no eligible model", got, fallback, ok)
191 }
192 })
193 }
194 }
195
196 func TestResolveDesktopNewSessionModelHonorsExplicitEmptyAccess(t *testing.T) {
197 c := testModelFallbackConfig(t)
198 c.Desktop.ProviderAccess = []string{}
199
200 if got, fallback, ok := c.ResolveDesktopNewSessionModel(); got != "" || fallback || ok {
201 t.Fatalf("ResolveDesktopNewSessionModel() = (%q, %v, %v), want no model when provider access is explicitly empty", got, fallback, ok)
202 }
203 }
204
205 func TestResolveDesktopNewSessionModelKeepsAllowedKeylessDefaultWithoutFallback(t *testing.T) {
206 c := &Config{
207 DefaultModel: "keyless/chat",
208 Desktop: DesktopConfig{ProviderAccess: []string{"keyless"}},
209 Providers: []ProviderEntry{
210 {Name: "keyless", BaseURL: "https://keyless.example.com", Model: "chat", APIKeyEnv: "MISSING_KEY"},
211 },
212 }
213
214 got, fallback, ok := c.ResolveDesktopNewSessionModel()
215 if !ok || fallback || got != "keyless/chat" {
216 t.Fatalf("ResolveDesktopNewSessionModel() = (%q, %v, %v), want raw allowed keyless default", got, fallback, ok)
217 }
218 }
219
220 func TestResolveDesktopNewSessionModelKeepsUsableDefaultVerbatim(t *testing.T) {
221 c := testModelFallbackConfig(t)
222 c.DefaultModel = "prov-b"
223 c.Desktop.ProviderAccess = []string{"prov-b"}
224
225 got, fallback, ok := c.ResolveDesktopNewSessionModel()
226 if !ok || fallback || got != "prov-b" {
227 t.Fatalf("ResolveDesktopNewSessionModel() = (%q, %v, %v), want (prov-b, false, true)", got, fallback, ok)
228 }
229 }
230
231 func TestModelRefsProvider(t *testing.T) {
232 if !ModelRefsProvider("deepseek-flash", "deepseek-flash") {
233 t.Fatal("bare provider ref should match provider")
234 }
235 if !ModelRefsProvider("deepseek-flash/deepseek-v4-flash", "deepseek-flash") {
236 t.Fatal("provider/model ref should match provider")
237 }
238 if ModelRefsProvider("other/model", "deepseek-flash") {
239 t.Fatal("different provider should not match")
240 }
241 if ModelRefsProvider("", "deepseek-flash") {
242 t.Fatal("empty ref should not match")
243 }
244 }
245
246 func TestRemoveProviderMigratesDanglingRefs(t *testing.T) {
247 c := testModelFallbackConfig(t)
248 c.DefaultModel = "model-a2"
249 c.Agent.PlannerModel = "prov-a"
250 c.Agent.SubagentModel = "prov-a/model-a1"
251 c.Agent.SubagentModels = map[string]string{
252 "review": "prov-a/model-a2",
253 "bare": "model-a1",
254 "explore": "prov-b/model-b1",
255 }
256
257 if err := c.RemoveProvider("prov-a"); err != nil {
258 t.Fatalf("RemoveProvider: %v", err)
259 }
260 if _, ok := c.Provider("prov-a"); ok {
261 t.Fatal("provider should be removed")
262 }
263 if c.DefaultModel != "prov-b" {
264 t.Fatalf("default_model = %q, want prov-b", c.DefaultModel)
265 }
266 if c.Agent.PlannerModel != "prov-b" {
267 t.Fatalf("planner_model = %q, want prov-b", c.Agent.PlannerModel)
268 }
269 if c.Agent.SubagentModel != "prov-b" {
270 t.Fatalf("subagent_model = %q, want prov-b", c.Agent.SubagentModel)
271 }
272 if c.Agent.SubagentModels["review"] != "prov-b" {
273 t.Fatalf("subagent_models.review = %q, want prov-b", c.Agent.SubagentModels["review"])
274 }
275 if c.Agent.SubagentModels["bare"] != "prov-b" {
276 t.Fatalf("subagent_models.bare = %q, want prov-b", c.Agent.SubagentModels["bare"])
277 }
278 if c.Agent.SubagentModels["explore"] != "prov-b/model-b1" {
279 t.Fatalf("unaffected subagent model changed to %q", c.Agent.SubagentModels["explore"])
280 }
281 }
282
283 func TestRemoveProviderBlocksDefaultWithoutFallback(t *testing.T) {
284 c := testModelFallbackConfig(t)
285 c.DefaultModel = "prov-a/model-a1"
286 c.Providers[1].APIKeyEnv = "REASONIX_TEST_EMPTY"
287 c.Providers[1].resolvedAPIKey = ""
288
289 err := c.RemoveProvider("prov-a")
290 if err == nil {
291 t.Fatal("expected removing default provider without fallback to fail")
292 }
293 if !strings.Contains(err.Error(), "default_model") {
294 t.Fatalf("error = %q, want default_model mention", err)
295 }
296 if _, ok := c.Provider("prov-a"); !ok {
297 t.Fatal("provider should remain after failed removal")
298 }
299 }
300
301 func TestRemoveProviderClearsOptionalRefsWithoutFallback(t *testing.T) {
302 c := testModelFallbackConfig(t)
303 c.DefaultModel = "prov-b"
304 c.Agent.PlannerModel = "prov-a/model-a1"
305 c.Agent.SubagentModel = "prov-a"
306 c.Agent.SubagentModels = map[string]string{"review": "prov-a/model-a2"}
307 c.Providers[1].APIKeyEnv = "REASONIX_TEST_EMPTY"
308 c.Providers[1].resolvedAPIKey = ""
309
310 if err := c.RemoveProvider("prov-a"); err != nil {
311 t.Fatalf("RemoveProvider: %v", err)
312 }
313 if c.Agent.PlannerModel != "" {
314 t.Fatalf("planner_model = %q, want cleared", c.Agent.PlannerModel)
315 }
316 if c.Agent.SubagentModel != "" {
317 t.Fatalf("subagent_model = %q, want cleared", c.Agent.SubagentModel)
318 }
319 if _, ok := c.Agent.SubagentModels["review"]; ok {
320 t.Fatal("subagent_models.review should be removed")
321 }
322 }
323
323 lines GO