| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "reasonix/internal/provider" |
| 8 | "reflect" |
| 9 | "runtime" |
| 10 | "sync" |
| 11 | "testing" |
| 12 | "time" |
| 13 | ) |
| 14 | |
| 15 | func TestCapabilityOverrideDirectCatalogResolution(t *testing.T) { |
| 16 | r := &ModelCapabilityResolver{entries: map[string]ModelCapabilityCacheEntry{}} |
| 17 | e := ProviderEntry{Name: "opencode-go", Kind: "openai", BaseURL: "https://opencode.ai/zen/go/v1", Model: "kimi-k3"} |
| 18 | auto := r.Resolve(&e) |
| 19 | e.ModelOverrides = map[string]ProviderModelOverride{"KIMI-K3": {Vision: capabilityBoolPtr(false), ContextWindow: 123456}} |
| 20 | if got := r.Resolve(&e); got.Source == CapabilitySourceOverride { |
| 21 | t.Fatal("differently cased override must not apply") |
| 22 | } |
| 23 | e.ModelOverrides["kimi-k3"] = e.ModelOverrides["KIMI-K3"] |
| 24 | got := r.Resolve(&e) |
| 25 | if got.State != CapabilityUnsupported || got.AutomaticState != CapabilitySupported || got.Source != CapabilitySourceOverride { |
| 26 | t.Fatalf("override = %+v", got) |
| 27 | } |
| 28 | facts := got.ModelInfo |
| 29 | facts.InputModalities = auto.ModelInfo.InputModalities |
| 30 | if !reflect.DeepEqual(facts, auto.ModelInfo) { |
| 31 | t.Fatalf("override erased catalog facts: %+v vs %+v", got, auto) |
| 32 | } |
| 33 | if e.Model != "kimi-k3" || e.visionOverride != nil { |
| 34 | t.Fatal("shared entry was mutated") |
| 35 | } |
| 36 | e.Model = "uncatalogued" |
| 37 | if got := r.Resolve(&e); got.State != CapabilityUnknown { |
| 38 | t.Fatalf("other model inherited override: %+v", got) |
| 39 | } |
| 40 | e.ModelOverrides["uncatalogued"] = ProviderModelOverride{Vision: capabilityBoolPtr(true)} |
| 41 | if got := r.Resolve(&e); got.State != CapabilitySupported || got.AutomaticState != CapabilityUnknown { |
| 42 | t.Fatalf("manual enable: %+v", got) |
| 43 | } |
| 44 | delete(e.ModelOverrides, "uncatalogued") |
| 45 | if got := r.Resolve(&e); got.State != CapabilityUnknown { |
| 46 | t.Fatalf("auto: %+v", got) |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | func TestCapabilityOfficialHardLimitAndExplicitOff(t *testing.T) { |
| 51 | r := &ModelCapabilityResolver{} |
| 52 | for _, kind := range []string{"openai", "anthropic", "responses"} { |
| 53 | e := ProviderEntry{Name: "deepseek", Kind: kind, BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro", Vision: true, ModelOverrides: map[string]ProviderModelOverride{"deepseek-v4-pro": {Vision: capabilityBoolPtr(true)}}} |
| 54 | if got := r.Resolve(&e); got.State != CapabilityUnsupported || got.ImageInputEnableAllowed || got.ImageInputBlockReason == "" { |
| 55 | t.Fatalf("%s hard limit: %+v", kind, got) |
| 56 | } |
| 57 | e.BaseURL, e.RequestURL = "https://relay.test", "https://api.deepseek.com/v1/messages" |
| 58 | if got := r.Resolve(&e); got.State != CapabilityUnsupported || got.ImageInputEnableAllowed { |
| 59 | t.Fatalf("%s exact request URL bypassed hard limit: %+v", kind, got) |
| 60 | } |
| 61 | e.Model = "deepseek-v4-flash-vision-exp" |
| 62 | e.ModelOverrides[e.Model] = ProviderModelOverride{Vision: capabilityBoolPtr(false)} |
| 63 | if got := r.Resolve(&e); got.State != CapabilityUnsupported || !got.ImageInputEnableAllowed || got.Source != CapabilitySourceOverride { |
| 64 | t.Fatalf("%s vision off: %+v", kind, got) |
| 65 | } |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | func TestCapabilityV2IgnoresV1AndPersistsUnknown(t *testing.T) { |
| 70 | t.Setenv("REASONIX_CACHE_HOME", t.TempDir()) |
| 71 | v1 := filepath.Join(CacheDir(), "model-capabilities-v1.json") |
| 72 | old := []byte(`{"version":1,"entries":[]}`) |
| 73 | if err := os.WriteFile(v1, old, 0600); err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | e := ProviderEntry{Name: "relay", Kind: "openai", BaseURL: "https://relay.test", Model: "x"} |
| 77 | r := NewModelCapabilityResolver() |
| 78 | now := time.Now() |
| 79 | r.PutCatalogAt(e, []provider.ModelInfo{{ID: "x", InputModalities: []provider.ModelModality{provider.ModalityText, provider.ModalityImage}}}, now) |
| 80 | r.PutCatalogAt(e, []provider.ModelInfo{{ID: "x"}}, now.Add(time.Second)) |
| 81 | if got := NewModelCapabilityResolver().Resolve(&e); got.State != CapabilityUnknown || got.InputModalities != nil { |
| 82 | t.Fatalf("unknown roundtrip: %+v", got) |
| 83 | } |
| 84 | if data, _ := os.ReadFile(v1); string(data) != string(old) { |
| 85 | t.Fatal("v1 was modified") |
| 86 | } |
| 87 | info, err := os.Stat(r.path) |
| 88 | if err != nil { |
| 89 | t.Fatal(err) |
| 90 | } else if runtime.GOOS != "windows" && info.Mode().Perm() != 0600 { |
| 91 | t.Fatalf("cache permissions: %o, want 600", info.Mode().Perm()) |
| 92 | } |
| 93 | for _, content := range []string{"broken", `{"version":999}`, `{"version":1}`} { |
| 94 | if err := os.WriteFile(r.path, []byte(content), 0600); err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | if got := NewModelCapabilityResolver().Resolve(&e); got.State != CapabilityUnknown { |
| 98 | t.Fatalf("invalid cache: %+v", got) |
| 99 | } |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | func TestCapabilityCacheNewestSuccessWinsAcrossResolvers(t *testing.T) { |
| 104 | t.Setenv("REASONIX_CACHE_HOME", t.TempDir()) |
| 105 | e := ProviderEntry{Name: "relay", Kind: "openai", BaseURL: "https://relay.test", Model: "x"} |
| 106 | old, newer := NewModelCapabilityResolver(), NewModelCapabilityResolver() |
| 107 | now := time.Now() |
| 108 | newer.PutCatalogAt(e, []provider.ModelInfo{{ID: "x"}}, now.Add(time.Second)) |
| 109 | old.PutCatalogAt(e, []provider.ModelInfo{{ID: "x", InputModalities: []provider.ModelModality{provider.ModalityImage}}}, now) |
| 110 | if got := old.Resolve(&e); got.State != CapabilityUnknown { |
| 111 | t.Fatalf("late response resurrected images: %+v", got) |
| 112 | } |
| 113 | if got := NewModelCapabilityResolver().Resolve(&e); got.State != CapabilityUnknown { |
| 114 | t.Fatalf("disk lost newer success: %+v", got) |
| 115 | } |
| 116 | // Concurrent cache reads and writes share no mutable Provider state. |
| 117 | var wg sync.WaitGroup |
| 118 | for range 8 { |
| 119 | wg.Go(func() { |
| 120 | old.PutCatalogAt(e, []provider.ModelInfo{{ID: "x"}}, now) |
| 121 | _ = old.Resolve(&e) |
| 122 | }) |
| 123 | } |
| 124 | wg.Wait() |
| 125 | } |
| 126 | |
| 127 | func TestCapabilityCacheRouteIdentity(t *testing.T) { |
| 128 | r := &ModelCapabilityResolver{} |
| 129 | e := ProviderEntry{Name: "relay", Kind: "openai", BaseURL: "https://relay.test", Model: "x"} |
| 130 | for _, mutate := range []func(*ProviderEntry){func(p *ProviderEntry) { p.NoProxy = true }, func(p *ProviderEntry) { p.ChatURL = "https://a.test/chat" }, func(p *ProviderEntry) { p.RequestURL = "https://b.test/responses" }} { |
| 131 | other := e |
| 132 | mutate(&other) |
| 133 | if r.providerFingerprint(e) == r.providerFingerprint(other) { |
| 134 | t.Fatal("route omitted from cache identity") |
| 135 | } |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | func TestCapabilityCacheMergeValidatesDiskModalities(t *testing.T) { |
| 140 | t.Setenv("REASONIX_CACHE_HOME", t.TempDir()) |
| 141 | r := NewModelCapabilityResolver() |
| 142 | e := ProviderEntry{Name: "relay", Kind: "openai", BaseURL: "https://relay.test", Model: "malformed"} |
| 143 | file := ModelCapabilityCacheFile{Version: 2, Entries: []ModelCapabilityCacheEntry{{ProviderFingerprint: r.providerFingerprint(e), ModelID: e.Model, InputModalities: []provider.ModelModality{provider.ModalityImage, "invalid"}, FetchedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)}}} |
| 144 | data, err := json.Marshal(file) |
| 145 | if err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | if err := os.WriteFile(r.path, data, 0600); err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | r.PutCatalog(e, []provider.ModelInfo{{ID: "other"}}) |
| 152 | if got := r.Resolve(&e); got.State != CapabilityUnknown { |
| 153 | t.Fatalf("merge trusted invalid disk metadata: %+v", got) |
| 154 | } |
| 155 | } |
| 156 |