| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "reasonix/internal/provider" |
| 11 | ) |
| 12 | |
| 13 | func TestModelCapabilityResolverHonorsExplicitConfigBeforeCache(t *testing.T) { |
| 14 | r := &ModelCapabilityResolver{entries: map[string]ModelCapabilityCacheEntry{}} |
| 15 | entry := ProviderEntry{Name: "custom", Kind: "openai", BaseURL: "https://example.test", Model: "mixed"} |
| 16 | r.PutCatalog(entry, []provider.ModelInfo{{ID: "mixed", InputModalities: []provider.ModelModality{provider.ModalityText, provider.ModalityImage}}}) |
| 17 | if got := r.Resolve(&entry); got.State != CapabilitySupported || got.Source != CapabilitySourceAdapter { |
| 18 | t.Fatalf("adapter capability = %+v", got) |
| 19 | } |
| 20 | entry.VisionModels = []string{"mixed"} |
| 21 | if got := r.Resolve(&entry); got.State != CapabilitySupported || got.Source != CapabilitySourceLegacy { |
| 22 | t.Fatalf("legacy capability = %+v", got) |
| 23 | } |
| 24 | entry.visionOverride = capabilityBoolPtr(false) |
| 25 | if got := r.Resolve(&entry); got.State != CapabilityUnsupported || got.Source != CapabilitySourceOverride { |
| 26 | t.Fatalf("override capability = %+v", got) |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | func TestModelCapabilityResolverDefaultsExactModelsToUnknown(t *testing.T) { |
| 31 | r := &ModelCapabilityResolver{entries: map[string]ModelCapabilityCacheEntry{}} |
| 32 | entry := ProviderEntry{Name: "custom", Kind: "openai", BaseURL: "https://example.test", Model: "new-model"} |
| 33 | got := r.Resolve(&entry) |
| 34 | if got.State != CapabilityUnknown || got.Source != CapabilitySourceUnknown || got.InputModalities != nil { |
| 35 | t.Fatalf("default capability = %+v", got) |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | func TestModelCapabilityResolverUsesBuiltinAdapterCatalog(t *testing.T) { |
| 40 | r := &ModelCapabilityResolver{entries: map[string]ModelCapabilityCacheEntry{}} |
| 41 | vision := ProviderEntry{Name: "opencode-go", Kind: "openai", BaseURL: "https://opencode.ai/zen/go/v1", Model: "kimi-k3"} |
| 42 | if got := r.Resolve(&vision); got.State != CapabilitySupported || got.Source != CapabilitySourceAdapter { |
| 43 | t.Fatalf("OpenCode Go vision capability = %+v", got) |
| 44 | } |
| 45 | if got := r.Resolve(&vision); got.ModelInfo.ContextWindow == 0 || got.ModelInfo.MaxOutputTokens == 0 || got.ModelInfo.API == "" { |
| 46 | t.Fatalf("OpenCode Go catalog facts missing = %+v", got.ModelInfo) |
| 47 | } |
| 48 | text := vision |
| 49 | text.Model = "glm-5.2" |
| 50 | if got := r.Resolve(&text); got.State != CapabilityUnsupported || got.Source != CapabilitySourceAdapter { |
| 51 | t.Fatalf("OpenCode Go text capability = %+v", got) |
| 52 | } |
| 53 | unknown := vision |
| 54 | unknown.Model = "omen-alpha" |
| 55 | if got := r.Resolve(&unknown); got.State != CapabilityUnknown || got.Source != CapabilitySourceUnknown { |
| 56 | t.Fatalf("OpenCode Go unknown capability = %+v", got) |
| 57 | } |
| 58 | modelScope := ProviderEntry{Name: "modelscope", Kind: "openai", BaseURL: "https://api-inference.modelscope.cn/v1", Model: "Qwen/Qwen3.5-27B"} |
| 59 | if got := r.Resolve(&modelScope); got.State != CapabilitySupported || got.Source != CapabilitySourceAdapter { |
| 60 | t.Fatalf("ModelScope capability = %+v", got) |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | func TestModelCapabilityResolverUsesOtherCuratedPresetCatalogs(t *testing.T) { |
| 65 | r := &ModelCapabilityResolver{entries: map[string]ModelCapabilityCacheEntry{}} |
| 66 | for _, id := range []string{"kimi-cn", "mimo-api", "minimax-cn-api", "glm-cn", "stepfun-api", "scnet", "ollama-cloud"} { |
| 67 | preset, ok := CuratedProviderPreset(id) |
| 68 | if !ok || len(preset.Entries) == 0 { |
| 69 | t.Fatalf("missing preset %q", id) |
| 70 | } |
| 71 | entry := preset.Entries[0] |
| 72 | var visionModel string |
| 73 | for _, candidate := range entry.VisionModels { |
| 74 | visionModel = candidate |
| 75 | break |
| 76 | } |
| 77 | if visionModel == "" { |
| 78 | continue |
| 79 | } |
| 80 | entry.Model = visionModel |
| 81 | got := r.Resolve(&entry) |
| 82 | if got.State != CapabilitySupported || got.Source != CapabilitySourcePreset { |
| 83 | t.Fatalf("preset %q model %q capability = %+v", id, visionModel, got) |
| 84 | } |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | func TestModelCapabilityResolverLoadsIndependentCache(t *testing.T) { |
| 89 | dir := t.TempDir() |
| 90 | oldCache := os.Getenv("REASONIX_CACHE_HOME") |
| 91 | if err := os.Setenv("REASONIX_CACHE_HOME", dir); err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | t.Cleanup(func() { _ = os.Setenv("REASONIX_CACHE_HOME", oldCache) }) |
| 95 | |
| 96 | entry := ProviderEntry{Name: "custom", Kind: "openai", BaseURL: "https://example.test", Model: "vision"} |
| 97 | writer := NewModelCapabilityResolver() |
| 98 | writer.PutCatalog(entry, []provider.ModelInfo{{ID: "vision", InputModalities: []provider.ModelModality{provider.ModalityText, provider.ModalityImage}}}) |
| 99 | reader := NewModelCapabilityResolver() |
| 100 | got := reader.Resolve(&entry) |
| 101 | if got.State != CapabilitySupported || got.Source != CapabilitySourceCache { |
| 102 | t.Fatalf("reloaded capability = %+v", got) |
| 103 | } |
| 104 | if _, err := os.Stat(filepath.Join(dir, "model-capabilities-v2.json")); err != nil { |
| 105 | t.Fatalf("cache file missing: %v", err) |
| 106 | } |
| 107 | data, err := os.ReadFile(filepath.Join(dir, "model-capabilities-v2.json")) |
| 108 | if err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | if string(data) == "" || string(data) == "\n" { |
| 112 | t.Fatal("cache file is unexpectedly empty") |
| 113 | } |
| 114 | if string(data) != "" && containsAny(string(data), "super-secret-api-key", "Authorization") { |
| 115 | t.Fatal("cache must not contain credential material") |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | func TestModelCapabilityResolverIgnoresExpiredCache(t *testing.T) { |
| 120 | r := &ModelCapabilityResolver{entries: map[string]ModelCapabilityCacheEntry{}} |
| 121 | entry := ProviderEntry{Name: "custom", Kind: "openai", BaseURL: "https://example.test", Model: "vision"} |
| 122 | key := r.entryKey(&entry, entry.Model) |
| 123 | r.entries[key] = ModelCapabilityCacheEntry{ |
| 124 | ProviderFingerprint: r.providerFingerprint(entry), ModelID: entry.Model, |
| 125 | InputModalities: []provider.ModelModality{provider.ModalityText, provider.ModalityImage}, |
| 126 | ExpiresAt: time.Now().Add(-time.Minute), Source: CapabilitySourceCache, |
| 127 | } |
| 128 | got := r.Resolve(&entry) |
| 129 | if got.Source != CapabilitySourceUnknown || got.State != CapabilityUnknown { |
| 130 | t.Fatalf("expired cache capability = %+v", got) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | func TestModelCapabilityResolverFingerprintIncludesCatalogHeaders(t *testing.T) { |
| 135 | r := &ModelCapabilityResolver{} |
| 136 | base := ProviderEntry{Name: "custom", Kind: "openai", BaseURL: "https://example.test", Model: "m"} |
| 137 | withHeader := base |
| 138 | withHeader.Headers = map[string]string{"X-Tenant": "tenant-a"} |
| 139 | if r.providerFingerprint(base) == r.providerFingerprint(withHeader) { |
| 140 | t.Fatal("catalog-affecting headers must invalidate capability cache identity") |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | func TestReadModelCapabilityCacheFileRejectsOversizedData(t *testing.T) { |
| 145 | path := filepath.Join(t.TempDir(), "model-capabilities-v1.json") |
| 146 | data := []byte(strings.Repeat("x", modelCapabilityCacheMaxSize+1)) |
| 147 | if err := os.WriteFile(path, data, 0o600); err != nil { |
| 148 | t.Fatal(err) |
| 149 | } |
| 150 | if _, ok := readModelCapabilityCacheFile(path); ok { |
| 151 | t.Fatal("oversized capability cache must be ignored") |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | func capabilityBoolPtr(value bool) *bool { return &value } |
| 156 | |
| 157 | func containsAny(value string, needles ...string) bool { |
| 158 | for _, needle := range needles { |
| 159 | if strings.Contains(value, needle) { |
| 160 | return true |
| 161 | } |
| 162 | } |
| 163 | return false |
| 164 | } |
| 165 |