| 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 |