| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "reflect" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "github.com/BurntSushi/toml" |
| 12 | |
| 13 | "reasonix/internal/provider" |
| 14 | ) |
| 15 | |
| 16 | func TestLegacyReasoningDefaultsStaySeparateFromSavedOverrides(t *testing.T) { |
| 17 | path := filepath.Join(t.TempDir(), "config.toml") |
| 18 | raw := `[[providers]] |
| 19 | name = "renamed-gateway" |
| 20 | kind = "openai" |
| 21 | base_url = "https://tokenrhythm.studio/v1" |
| 22 | models = ["deepseek-flash", "glm-5.1"] |
| 23 | future_field = "preserve-me" |
| 24 | ` |
| 25 | if err := os.WriteFile(path, []byte(raw), 0600); err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | cfg := LoadForEdit(path) |
| 29 | baseline := cfg.ModelSettingsBaseline() |
| 30 | for _, tc := range []struct{ model, protocol, effort string }{ |
| 31 | {"deepseek-flash", "deepseek", "high"}, {"glm-5.1", "glm", "enabled"}, |
| 32 | } { |
| 33 | e, ok := cfg.ResolveModel("renamed-gateway/" + tc.model) |
| 34 | if !ok { |
| 35 | t.Fatal(tc.model) |
| 36 | } |
| 37 | if got := ReasoningProtocolForEntry(e); got != tc.protocol { |
| 38 | t.Fatalf("protocol = %q", got) |
| 39 | } |
| 40 | if err := ReasoningCapabilityForEntry(e).Validate(e.Model, tc.effort); err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | // Catalog/settings callers that have not used ResolveModel must agree. |
| 44 | unresolved := cfg.Providers[0] |
| 45 | unresolved.Model = tc.model |
| 46 | if !reflect.DeepEqual(ResolveReasoningView(&unresolved), ResolveReasoningView(e)) { |
| 47 | t.Fatalf("UI/runtime disagree for %s", tc.model) |
| 48 | } |
| 49 | } |
| 50 | cfg.Providers[0].DisplayName = "My gateway" |
| 51 | if err := cfg.SaveModelSettingsTo(path, baseline); err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | saved, err := os.ReadFile(path) |
| 55 | if err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | if strings.Contains(string(saved), "supported_efforts") || strings.Contains(string(saved), "reasoning_protocol") || !strings.Contains(string(saved), "preserve-me") { |
| 59 | t.Fatalf("automatic capability leaked into saved user overrides: %s", saved) |
| 60 | } |
| 61 | // A subsequent catalog correction is visible to the same old connection. |
| 62 | entry := cfg.Providers[0] |
| 63 | entry.Model = "deepseek-flash" |
| 64 | entry.ModelOverrides = map[string]ProviderModelOverride{"deepseek-flash": {SupportedEfforts: []string{"high"}, DefaultEffort: "high"}} |
| 65 | cap := ReasoningCapabilityForEntry(&entry) |
| 66 | if !reflect.DeepEqual(cap.IDs(), []string{"high"}) || cap.Validate(entry.Model, "max") == nil { |
| 67 | t.Fatalf("explicit model vocabulary lost: %+v", cap) |
| 68 | } |
| 69 | entry.ModelOverrides = nil |
| 70 | entry.SupportedEfforts = []string{"custom"} |
| 71 | entry.DefaultEffort = "custom" |
| 72 | if cap := ReasoningCapabilityForEntry(&entry); !reflect.DeepEqual(cap.IDs(), []string{"custom"}) { |
| 73 | t.Fatal(cap) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | func TestReasoningDefaultsRespectEndpointProtocolAndExactModel(t *testing.T) { |
| 78 | base := ProviderEntry{Name: "token-rhythm", Kind: "openai", BaseURL: "https://tokenrhythm.studio/v1", Model: "deepseek-flash"} |
| 79 | for _, tc := range []struct { |
| 80 | name string |
| 81 | edit func(*ProviderEntry) |
| 82 | state string |
| 83 | }{ |
| 84 | {"renamed", func(e *ProviderEntry) { e.Name = "my-account" }, "supported"}, |
| 85 | {"unknown model", func(e *ProviderEntry) { e.Model = "DeepSeek-Flash" }, "unknown"}, |
| 86 | {"custom endpoint", func(e *ProviderEntry) { e.BaseURL = "https://relay.invalid/v1" }, "unknown"}, |
| 87 | {"request override", func(e *ProviderEntry) { e.RequestURL = "https://relay.invalid/chat/completions" }, "unknown"}, |
| 88 | {"disabled", func(e *ProviderEntry) { e.ReasoningProtocol = "none" }, "unsupported"}, |
| 89 | } { |
| 90 | t.Run(tc.name, func(t *testing.T) { |
| 91 | e := base |
| 92 | tc.edit(&e) |
| 93 | cap := ReasoningCapabilityForEntry(&e) |
| 94 | if cap.State() != tc.state { |
| 95 | t.Fatalf("cap = %+v", cap) |
| 96 | } |
| 97 | if err := cap.Validate(e.Model, ""); err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | if tc.state == "unknown" { |
| 101 | var diagnostic *provider.UnsupportedReasoningEffort |
| 102 | err := cap.Validate(e.Model, "high") |
| 103 | if !errors.As(err, &diagnostic) || !diagnostic.Unknown || !strings.Contains(err.Error(), "Select auto") { |
| 104 | t.Fatalf("diagnostic = %v", err) |
| 105 | } |
| 106 | } |
| 107 | }) |
| 108 | } |
| 109 | base.ReasoningProtocol = "glm" |
| 110 | if cap := ReasoningCapabilityForEntry(&base); !reflect.DeepEqual(cap.IDs(), []string{"enabled", "disabled"}) { |
| 111 | t.Fatal(cap) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | func TestPresetReasoningIsInheritedAndExplicitDeclarationsRoundTrip(t *testing.T) { |
| 116 | preset, _ := CuratedProviderPreset("token-rhythm") |
| 117 | e := preset.Entries[0] |
| 118 | if len(e.ModelOverrides["deepseek-flash"].SupportedEfforts) != 0 { |
| 119 | t.Fatal("preset froze generated levels") |
| 120 | } |
| 121 | path := filepath.Join(t.TempDir(), "config.toml") |
| 122 | cfg := &Config{} |
| 123 | if err := cfg.UpsertProvider(e); err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | if err := cfg.SaveTo(path); err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | loaded := LoadForEdit(path) |
| 130 | resolved, _ := loaded.ResolveModel("token-rhythm/deepseek-flash") |
| 131 | if err := ReasoningCapabilityForEntry(resolved).Validate("deepseek-flash", "high"); err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | e.ModelOverrides["deepseek-flash"] = ProviderModelOverride{ReasoningProtocol: "none"} |
| 135 | if err := cfg.UpsertProvider(e); err != nil { |
| 136 | t.Fatal(err) |
| 137 | } |
| 138 | if err := cfg.SaveTo(path); err != nil { |
| 139 | t.Fatal(err) |
| 140 | } |
| 141 | resolved, _ = LoadForEdit(path).ResolveModel("token-rhythm/deepseek-flash") |
| 142 | if cap := ReasoningCapabilityForEntry(resolved); cap.State() != "unsupported" { |
| 143 | t.Fatal(cap) |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | func TestSessionEffortIsBoundToExactModelIdentity(t *testing.T) { |
| 148 | cfg := &Config{Providers: []ProviderEntry{{Name: "a", Models: []string{"one", "two"}, Default: "one"}, {Name: "b", Model: "one"}}} |
| 149 | for _, tc := range []struct { |
| 150 | from, to string |
| 151 | keep bool |
| 152 | }{ |
| 153 | {"a/one", "a/one", true}, {"a", "a/one", true}, {"a/one", "a/two", false}, |
| 154 | {"a/one", "b/one", false}, {"plugin/foo/model", "plugin/bar/model", false}, |
| 155 | } { |
| 156 | value := "invalid-explicit-level" |
| 157 | got := RebindSessionEffort(cfg, tc.from, tc.to, &value) |
| 158 | if (got != nil) != tc.keep { |
| 159 | t.Fatalf("%s -> %s: %v", tc.from, tc.to, got) |
| 160 | } |
| 161 | if got != nil && (*got != value || got == &value) { |
| 162 | t.Fatal("same-route explicit intent must be independently retained") |
| 163 | } |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | func TestReasoningSnapshotSupportsOlderReadersAndWriters(t *testing.T) { |
| 168 | preset, _ := CuratedProviderPreset("token-rhythm") |
| 169 | e := preset.Entries[0] |
| 170 | e.ModelOverrides["deepseek-flash"] = ProviderModelOverride{DefaultEffort: "max", ContextWindow: 123456} |
| 171 | cfg := &Config{Providers: []ProviderEntry{e}} |
| 172 | body := RenderTOML(cfg) |
| 173 | // An older reader knows the original fields and ignores the optional marker. |
| 174 | type oldOverride struct { |
| 175 | ReasoningProtocol string `toml:"reasoning_protocol"` |
| 176 | SupportedEfforts []string `toml:"supported_efforts"` |
| 177 | DefaultEffort string `toml:"default_effort"` |
| 178 | ContextWindow int `toml:"context_window"` |
| 179 | } |
| 180 | var old struct { |
| 181 | Providers []struct { |
| 182 | Name string `toml:"name"` |
| 183 | Kind string `toml:"kind"` |
| 184 | BaseURL string `toml:"base_url"` |
| 185 | Models []string `toml:"models"` |
| 186 | ModelOverrides map[string]oldOverride `toml:"model_overrides"` |
| 187 | } `toml:"providers"` |
| 188 | } |
| 189 | if _, err := toml.Decode(body, &old); err != nil { |
| 190 | t.Fatal(err) |
| 191 | } |
| 192 | ov := old.Providers[0].ModelOverrides["deepseek-flash"] |
| 193 | if ov.ReasoningProtocol != "deepseek" || len(ov.SupportedEfforts) != 4 || ov.DefaultEffort != "max" { |
| 194 | t.Fatal(ov) |
| 195 | } |
| 196 | path := filepath.Join(t.TempDir(), "config.toml") |
| 197 | if err := os.WriteFile(path, []byte(body), 0600); err != nil { |
| 198 | t.Fatal(err) |
| 199 | } |
| 200 | current := LoadForEdit(path) |
| 201 | explicit := current.Providers[0].ModelOverrides["deepseek-flash"] |
| 202 | if explicit.DefaultEffort != "max" || explicit.ContextWindow != 123456 || len(explicit.SupportedEfforts) != 0 || explicit.ReasoningProtocol != "" { |
| 203 | t.Fatalf("generated fields froze or user fields lost: %+v", explicit) |
| 204 | } |
| 205 | // A legacy editor changes the vocabulary and drops the unknown marker. |
| 206 | ov.SupportedEfforts, ov.DefaultEffort = []string{"high"}, "high" |
| 207 | old.Providers[0].ModelOverrides["deepseek-flash"] = ov |
| 208 | var edited strings.Builder |
| 209 | if err := toml.NewEncoder(&edited).Encode(old); err != nil { |
| 210 | t.Fatal(err) |
| 211 | } |
| 212 | if err := os.WriteFile(path, []byte(edited.String()), 0600); err != nil { |
| 213 | t.Fatal(err) |
| 214 | } |
| 215 | resolved, _ := LoadForEdit(path).ResolveModel("token-rhythm/deepseek-flash") |
| 216 | if cap := ReasoningCapabilityForEntry(resolved); !reflect.DeepEqual(cap.IDs(), []string{"high"}) { |
| 217 | t.Fatalf("legacy edit lost: %+v", cap) |
| 218 | } |
| 219 | // Editors retaining unknown fields also retain manual reasoning changes. |
| 220 | snapshot := reasoningCompatibilitySnapshot(e).ModelOverrides["deepseek-flash"] |
| 221 | snapshot.SupportedEfforts = []string{"custom"} |
| 222 | if got := explicitModelReasoning(snapshot); !reflect.DeepEqual(got.SupportedEfforts, []string{"custom"}) || got.ReasoningProtocol != "deepseek" { |
| 223 | t.Fatalf("changed snapshot erased: %+v", got) |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | func TestReasoningSnapshotDoesNotShadowConnectionEditsInOlderReaders(t *testing.T) { |
| 228 | e := ProviderEntry{Name: "gateway", Kind: "openai", BaseURL: "https://tokenrhythm.studio/v1", Models: []string{"deepseek-flash"}, ReasoningProtocol: "deepseek", SupportedEfforts: []string{"high"}, DefaultEffort: "high"} |
| 229 | var previousReader Config |
| 230 | if _, err := toml.Decode(RenderTOML(&Config{Providers: []ProviderEntry{e}}), &previousReader); err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | ov := previousReader.Providers[0].ModelOverrides["deepseek-flash"] |
| 234 | if ov.ReasoningProtocol != "" || len(ov.SupportedEfforts) != 0 || ov.DefaultEffort != "" { |
| 235 | t.Fatalf("snapshot would shadow a legacy editor's provider-level changes: %+v", ov) |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | func TestSavingResolvedRuntimePreservesReasoningInheritance(t *testing.T) { |
| 240 | raw := ProviderEntry{Name: "gateway", Kind: "openai", BaseURL: "https://tokenrhythm.studio/v1", Model: "deepseek-flash", DefaultEffort: "max"} |
| 241 | runtime := ResolveReasoningEntry(&raw) |
| 242 | runtime.Effort = "low" |
| 243 | cfg := &Config{} |
| 244 | if err := cfg.UpsertProvider(*runtime); err != nil { |
| 245 | t.Fatal(err) |
| 246 | } |
| 247 | saved := cfg.Providers[0] |
| 248 | if saved.ReasoningProtocol != "" || len(saved.SupportedEfforts) != 0 || saved.DefaultEffort != "max" || saved.Effort != "low" { |
| 249 | t.Fatalf("runtime defaults froze or explicit values lost: %+v", saved) |
| 250 | } |
| 251 | runtime.SupportedEfforts = []string{"low"} |
| 252 | if err := cfg.UpsertProvider(*runtime); err != nil { |
| 253 | t.Fatal(err) |
| 254 | } |
| 255 | if !reflect.DeepEqual(cfg.Providers[0].SupportedEfforts, []string{"low"}) { |
| 256 | t.Fatal("post-resolution edit lost") |
| 257 | } |
| 258 | } |
| 259 |