返回 DeepSeek-Reasonix
provider_isolation_test.go
根目录 / internal / config / provider_isolation_test.go
1 package config
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8 )
9
10 // customProviderTOML declares a single self-hosted OpenAI-compatible provider
11 // and none of the vendor-specific fields an official DeepSeek entry carries.
12 const customProviderTOML = `config_version = 1
13 default_model = "gateway/my-model"
14
15 [[providers]]
16 name = "gateway"
17 kind = "openai"
18 base_url = "http://localhost:8021/v1"
19 models = ["my-model"]
20 api_key_env = "GATEWAY_API_KEY"
21 `
22
23 func assertNoOfficialDeepSeekFields(t *testing.T, tag string, p *ProviderEntry) {
24 t.Helper()
25 if p.BalanceURL != "" {
26 t.Errorf("%s: custom provider gained balance_url %q; a self-hosted endpoint must not be pointed at another vendor's wallet API", tag, p.BalanceURL)
27 }
28 if p.ContextWindow != 0 {
29 t.Errorf("%s: custom provider gained context_window %d; an undeclared window must stay unset so compaction is not sized against a foreign default", tag, p.ContextWindow)
30 }
31 if p.Price != nil {
32 t.Errorf("%s: custom provider gained price %+v; another vendor's price table must not be applied to it", tag, *p.Price)
33 }
34 if p.Model != "" {
35 t.Errorf("%s: custom provider gained model %q from a built-in default", tag, p.Model)
36 }
37 }
38
39 // TestLoadForEditKeepsCustomProviderFreeOfOfficialDefaults covers #7357/#7358.
40 // Config loads seed from Default(), which ships two official DeepSeek entries.
41 // TOML array-of-tables decoding is positional, so the first [[providers]] in a
42 // user file used to be unified onto the DeepSeek entry already occupying index
43 // 0 and silently inherit every field the user had not set.
44 func TestLoadForEditKeepsCustomProviderFreeOfOfficialDefaults(t *testing.T) {
45 dir := t.TempDir()
46 path := filepath.Join(dir, "config.toml")
47 if err := os.WriteFile(path, []byte(customProviderTOML), 0o600); err != nil {
48 t.Fatal(err)
49 }
50
51 cfg := LoadForEditWithoutCredentials(path)
52 if len(cfg.Providers) != 1 {
53 t.Fatalf("providers = %d, want 1", len(cfg.Providers))
54 }
55 p := &cfg.Providers[0]
56 if p.Name != "gateway" || p.BaseURL != "http://localhost:8021/v1" {
57 t.Fatalf("unexpected provider identity: name=%q base_url=%q", p.Name, p.BaseURL)
58 }
59 assertNoOfficialDeepSeekFields(t, "LoadForEdit", p)
60 }
61
62 // TestSaveAfterLoadForEditDoesNotWriteForeignProviderFields is the on-disk half
63 // of #7357: the leaked fields were persisted into the user's own file on the
64 // next rewrite, so they outlived the process that invented them.
65 func TestSaveAfterLoadForEditDoesNotWriteForeignProviderFields(t *testing.T) {
66 dir := t.TempDir()
67 path := filepath.Join(dir, "config.toml")
68 if err := os.WriteFile(path, []byte(customProviderTOML), 0o600); err != nil {
69 t.Fatal(err)
70 }
71
72 cfg := LoadForEditWithoutCredentials(path)
73 if err := cfg.SaveTo(path); err != nil {
74 t.Fatal(err)
75 }
76 raw, err := os.ReadFile(path)
77 if err != nil {
78 t.Fatal(err)
79 }
80 for _, unwanted := range []string{"balance_url", "context_window", "price"} {
81 if strings.Contains(string(raw), unwanted) {
82 t.Errorf("rewritten config contains %q for a custom provider:\n%s", unwanted, raw)
83 }
84 }
85 }
86
87 // TestLoadForRootKeepsCustomProviderFreeOfOfficialDefaults exercises the same
88 // leak through the runtime loader, which is what the agent and compaction read.
89 func TestLoadForRootKeepsCustomProviderFreeOfOfficialDefaults(t *testing.T) {
90 home := t.TempDir()
91 ws := t.TempDir()
92 t.Setenv("REASONIX_HOME", home)
93 if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(customProviderTOML), 0o600); err != nil {
94 t.Fatal(err)
95 }
96
97 cfg, err := LoadForRootReadOnly(ws)
98 if err != nil {
99 t.Fatal(err)
100 }
101 p, ok := cfg.Provider("gateway")
102 if !ok {
103 t.Fatal("gateway provider missing after load")
104 }
105 assertNoOfficialDeepSeekFields(t, "LoadForRoot", p)
106 }
107
108 // TestLastKnownGoodRecoveryKeepsCustomProviderFreeOfOfficialDefaults covers the
109 // recovery path, which decodes a snapshot onto a freshly seeded Config and so
110 // leaked through the same positional overlay.
111 func TestLastKnownGoodRecoveryKeepsCustomProviderFreeOfOfficialDefaults(t *testing.T) {
112 home := t.TempDir()
113 ws := t.TempDir()
114 t.Setenv("REASONIX_HOME", home)
115
116 // Malformed live config forces the last-known-good branch.
117 if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte("config_version = ["), 0o600); err != nil {
118 t.Fatal(err)
119 }
120 lkg := LastKnownGoodConfigPath()
121 if lkg == "" {
122 t.Skip("last-known-good path unavailable in this environment")
123 }
124 if err := os.MkdirAll(filepath.Dir(lkg), 0o755); err != nil {
125 t.Fatal(err)
126 }
127 if err := os.WriteFile(lkg, []byte(customProviderTOML), 0o600); err != nil {
128 t.Fatal(err)
129 }
130
131 cfg, err := LoadForRootReadOnly(ws)
132 if err != nil {
133 t.Fatal(err)
134 }
135 p, ok := cfg.Provider("gateway")
136 if !ok {
137 t.Fatal("gateway provider missing after last-known-good recovery")
138 }
139 assertNoOfficialDeepSeekFields(t, "last-known-good", p)
140 }
141
142 // TestSecondCustomProviderKeepsNoOfficialDefaults guards the index-1 overlay:
143 // Default() ships two DeepSeek entries, so the second declared provider used to
144 // inherit the Pro SKU's price table and model.
145 func TestSecondCustomProviderKeepsNoOfficialDefaults(t *testing.T) {
146 dir := t.TempDir()
147 path := filepath.Join(dir, "config.toml")
148 raw := customProviderTOML + `
149 [[providers]]
150 name = "second"
151 kind = "openai"
152 base_url = "http://localhost:9000/v1"
153 models = ["m2"]
154 api_key_env = "SECOND_KEY"
155 `
156 if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
157 t.Fatal(err)
158 }
159
160 cfg := LoadForEditWithoutCredentials(path)
161 if len(cfg.Providers) != 2 {
162 t.Fatalf("providers = %d, want 2", len(cfg.Providers))
163 }
164 for i := range cfg.Providers {
165 assertNoOfficialDeepSeekFields(t, cfg.Providers[i].Name, &cfg.Providers[i])
166 }
167 }
168
169 // officialDeepSeekTOML declares the official endpoint under name without
170 // context_window, balance_url or price, which a documented config may omit.
171 func officialDeepSeekTOML(name string) string {
172 return `config_version = 1
173 default_model = "` + name + `/deepseek-v4-flash"
174
175 [[providers]]
176 name = "` + name + `"
177 kind = "openai"
178 base_url = "https://api.deepseek.com"
179 model = "deepseek-v4-flash"
180 api_key_env = "DEEPSEEK_API_KEY"
181 `
182 }
183
184 func assertOfficialDeepSeekDefaults(t *testing.T, tag string, p *ProviderEntry) {
185 t.Helper()
186 if p.ContextWindow != 1_000_000 {
187 t.Errorf("%s: official DeepSeek provider has context_window %d, want 1000000; a zero window disables compaction", tag, p.ContextWindow)
188 }
189 if p.BalanceURL != "https://api.deepseek.com/user/balance" {
190 t.Errorf("%s: official DeepSeek provider has balance_url %q, want the vendor wallet endpoint; empty removes the balance readout", tag, p.BalanceURL)
191 }
192 if p.Prices["deepseek-v4-flash"] == nil {
193 t.Errorf("%s: official DeepSeek provider lost its per-model price backfill: prices=%v", tag, p.Prices)
194 }
195 }
196
197 // TestOfficialDeepSeekProviderStillGetsItsDefaults pins the other half of the
198 // contract. Isolating the decoded provider list must not strip the defaults a
199 // genuinely official endpoint may omit, so they are reapplied by an
200 // endpoint-keyed backfill that a custom provider can never match.
201 //
202 // Both loaders are checked because they normalize through different entry
203 // points: the runtime loader feeds the agent and compaction, while the edit
204 // loader is what desktop Settings reads and writes back.
205 func TestOfficialDeepSeekProviderStillGetsItsDefaults(t *testing.T) {
206 for _, name := range []string{"deepseek", "deepseek-flash"} {
207 t.Run(name, func(t *testing.T) {
208 home := t.TempDir()
209 ws := t.TempDir()
210 t.Setenv("REASONIX_HOME", home)
211 path := filepath.Join(home, "config.toml")
212 if err := os.WriteFile(path, []byte(officialDeepSeekTOML(name)), 0o600); err != nil {
213 t.Fatal(err)
214 }
215
216 cfg, err := LoadForRootReadOnly(ws)
217 if err != nil {
218 t.Fatal(err)
219 }
220 p, ok := cfg.Provider(name)
221 if !ok {
222 t.Fatalf("official %q provider missing after runtime load", name)
223 }
224 assertOfficialDeepSeekDefaults(t, "LoadForRoot/"+name, p)
225
226 edit := LoadForEditWithoutCredentials(path)
227 ep, ok := edit.Provider(name)
228 if !ok {
229 t.Fatalf("official %q provider missing after edit load", name)
230 }
231 assertOfficialDeepSeekDefaults(t, "LoadForEdit/"+name, ep)
232 })
233 }
234 }
235
236 // TestOfficialDeepSeekBackfillRespectsDeclaredValues keeps the backfill from
237 // overriding a user who deliberately narrowed the window or disabled the
238 // balance readout for the official endpoint.
239 func TestOfficialDeepSeekBackfillRespectsDeclaredValues(t *testing.T) {
240 home := t.TempDir()
241 ws := t.TempDir()
242 t.Setenv("REASONIX_HOME", home)
243 declared := `config_version = 1
244 default_model = "deepseek-flash/deepseek-v4-flash"
245
246 [[providers]]
247 name = "deepseek-flash"
248 kind = "openai"
249 base_url = "https://api.deepseek.com"
250 model = "deepseek-v4-flash"
251 api_key_env = "DEEPSEEK_API_KEY"
252 context_window = 65536
253 `
254 if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(declared), 0o600); err != nil {
255 t.Fatal(err)
256 }
257
258 cfg, err := LoadForRootReadOnly(ws)
259 if err != nil {
260 t.Fatal(err)
261 }
262 p, ok := cfg.Provider("deepseek-flash")
263 if !ok {
264 t.Fatal("official deepseek-flash provider missing after load")
265 }
266 if p.ContextWindow != 65536 {
267 t.Errorf("declared context_window was overwritten: got %d, want 65536", p.ContextWindow)
268 }
269 }
270
270 lines GO