1package provider
2
3import (
4 "context"
5 "math"
6 "net/http"
7 "net/http/httptest"
8 "testing"
9)
10
11func preserveActiveCatalog(t *testing.T) {
12 t.Helper()
13 snapshot := SnapshotCatalog()
14 t.Cleanup(func() { RestoreCatalog(snapshot) })
15}
16
17func TestOpenCodeGoCatalogIsDiscoveredAtRuntime(t *testing.T) {
18 for _, model := range Catalog {
19 if model.Provider == "opencode-go" {
20 t.Fatalf("OpenCode Go model %q is still manually cataloged", model.ID)
21 }
22 }
23}
24
25func TestDiscoverOpenCodeGoCombinesLiveIDsWithModelsDevMetadata(t *testing.T) {
26 const apiKey = "synthetic-opencode-key"
27 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28 switch r.URL.Path {
29 case "/api.json":
30 if got := r.Header.Get("authorization"); got != "" {
31 t.Errorf("models.dev authorization = %q, want empty", got)
32 }
33 _, _ = w.Write([]byte(`{
34 "opencode-go": {
35 "id": "opencode-go",
36 "api": "https://opencode.ai/zen/go/v1",
37 "npm": "@ai-sdk/openai-compatible",
38 "models": {
39 "muse-spark-1.2-contributor": {
40 "id": "muse-spark-1.2-contributor",
41 "name": "Muse Spark 1.2 Contributor",
42 "reasoning": true,
43 "reasoning_options": [{"type":"effort","values":["minimal","low","medium","high","xhigh"]}],
44 "limit": {"context": 1048576, "output": 131072},
45 "cost": {"input": 0.1, "output": 0.2, "cache_read": 0.002, "cache_write": 0.003,
46 "tiers": [
47 {"input": 0.9, "output": 1.8, "cache_read": 0.009, "cache_write": 0.01,
48 "tier": {"type": "context", "size": 500000}},
49 {"input": 0.4, "output": 0.8, "cache_read": 0.004, "cache_write": 0.005,
50 "tier": {"type": "context", "size": 272000}}]}
51 },
52 "not-served": {
53 "id": "not-served",
54 "name": "Not Served",
55 "limit": {"context": 999, "output": 99}
56 }
57 }
58 }
59 }`))
60 case "/v1/models":
61 if got := r.Header.Get("authorization"); got != "Bearer "+apiKey {
62 t.Errorf("OpenCode Go authorization = %q, want bearer key", got)
63 }
64 _, _ = w.Write([]byte(`{"object":"list","data":[
65 {"id":"muse-spark-1.2-contributor","object":"model"},
66 {"id":"future-model","object":"model"},
67 {"id":" padded-model ","object":"model"},
68 {"id":" \t ","object":"model"}
69 ]}`))
70 default:
71 http.NotFound(w, r)
72 }
73 }))
74 defer server.Close()
75
76 models, err := discoverOpenCodeGo(context.Background(), apiKey, server.URL+"/v1", server.URL+"/api.json")
77 if err != nil {
78 t.Fatal(err)
79 }
80 if len(models) != 3 {
81 t.Fatalf("models = %+v, want three nonblank served models", models)
82 }
83
84 if got := models[0]; got.Provider != "opencode-go" || got.ID != "muse-spark-1.2-contributor" || got.DisplayName != "Muse Spark 1.2 Contributor" || got.ContextWindow != 1048576 || got.MaxOutput != 131072 || !got.Reasoning || got.API != APICompletions || got.PriceInput != 0.1 || got.PriceOutput != 0.2 || got.PriceCacheRead != 0.002 || got.PriceCacheWrite != 0.003 || got.PriceTierInputTokens != 272000 || got.PriceInputAbove != 0.4 || got.PriceOutputAbove != 0.8 || got.PriceCacheReadAbove != 0.004 || got.PriceCacheWriteAbove != 0.005 || got.BaseURL != server.URL+"/v1" || got.Source != "live" {
85 t.Fatalf("metadata model = %+v", got)
86 }
87 if got := models[0].ReasoningLevelMap; got["minimum"] != "minimum" || got["xhigh"] != "xhigh" {
88 t.Fatalf("reasoning level map = %#v, want minimum/xhigh identity mappings", got)
89 }
90 if got := models[0].ReasoningEffortMap; got["minimum"] != "minimal" || got["xhigh"] != "xhigh" {
91 t.Fatalf("reasoning effort map = %#v, want minimal/xhigh wire values", got)
92 }
93
94 if got := models[0].PriceTiers; len(got) != 2 || got[0].InputTokens != 272000 || got[0].PriceInput != 0.4 || got[1].InputTokens != 500000 || got[1].PriceInput != 0.9 {
95 t.Fatalf("price tiers = %+v, want sorted 272k/500k tiers", got)
96 }
97 for _, tc := range []struct {
98 name string
99 input int
100 want float64
101 }{
102 {"below_first", 271999, 271999 * 0.1 / 1_000_000},
103 {"at_272k", 272000, 272000 * 0.4 / 1_000_000},
104 {"at_500k", 500000, 500000 * 0.9 / 1_000_000},
105 {"above_500k", 600000, 600000 * 0.9 / 1_000_000},
106 } {
107 t.Run(tc.name, func(t *testing.T) {
108 if got := ComputeCost(models[0], Usage{InputTokens: tc.input}); math.Abs(got-tc.want) > 1e-12 {
109 t.Fatalf("cost = %v, want %v", got, tc.want)
110 }
111 })
112 }
113
114 if got := models[1]; got.Provider != "opencode-go" || got.ID != "future-model" || got.DisplayName != "future-model" || got.BaseURL != server.URL+"/v1" || got.Source != "live" || got.ContextWindow != 0 || got.MaxOutput != 0 || got.Reasoning {
115 t.Fatalf("unmapped live model = %+v", got)
116 }
117 if got := models[2]; got.ID != "padded-model" || got.DisplayName != "padded-model" {
118 t.Fatalf("padded live model = %+v, want trimmed id", got)
119 }
120}
121
122func TestDiscoverOpenCodeGoKeepsLiveIDsWithoutProviderMetadata(t *testing.T) {
123 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
124 switch r.URL.Path {
125 case "/api.json":
126 _, _ = w.Write([]byte(`{"other-provider":{"models":{}}}`))
127 case "/models":
128 _, _ = w.Write([]byte(`{"data":[{"id":"served"}]}`))
129 default:
130 http.NotFound(w, r)
131 }
132 }))
133 defer server.Close()
134
135 models, err := discoverOpenCodeGo(context.Background(), "key", server.URL, server.URL+"/api.json")
136 if err != nil {
137 t.Fatal(err)
138 }
139 if len(models) != 1 || models[0].ID != "served" || models[0].DisplayName != "served" || models[0].API != "" {
140 t.Fatalf("models = %+v, want the live id with fallback metadata and no explicit adapter", models)
141 }
142}
143
144func TestDiscoverOpenCodeGoUsesModelsDevAdaptersAndEmptyReasoningMetadata(t *testing.T) {
145 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
146 switch r.URL.Path {
147 case "/api.json":
148 _, _ = w.Write([]byte(`{
149 "opencode-go": {
150 "npm": "@ai-sdk/openai-compatible",
151 "api": "https://metadata.example/v1",
152 "models": {
153 "muse-spark-1.3-contributor": {
154 "name": "Muse Spark 1.3 Contributor",
155 "reasoning": true,
156 "reasoning_options": [{"type":"effort","values":["low","high"]}],
157 "provider": {"npm":"@ai-sdk/openai", "api":"https://untrusted.example/v1"}
158 },
159 "minimax-m2": {
160 "name": "MiniMax M2",
161 "reasoning": true,
162 "provider": {"npm":"@ai-sdk/anthropic"}
163 },
164 "gpt-5.6-completions": {
165 "name": "GPT Completions Override",
166 "reasoning": true,
167 "reasoning_options": []
168 }
169 }
170 }
171 }`))
172 case "/v1/models":
173 _, _ = w.Write([]byte(`{"data":[{"id":"muse-spark-1.3-contributor"},{"id":"minimax-m2"},{"id":"gpt-5.6-completions"}]}`))
174 default:
175 http.NotFound(w, r)
176 }
177 }))
178 defer server.Close()
179
180 models, err := discoverOpenCodeGo(context.Background(), "key", server.URL+"/v1", server.URL+"/api.json")
181 if err != nil {
182 t.Fatal(err)
183 }
184 if len(models) != 3 {
185 t.Fatalf("models = %+v, want three models", models)
186 }
187 if models[0].API != APIResponses {
188 t.Fatalf("Responses model API = %q, want %q", models[0].API, APIResponses)
189 }
190 if models[0].BaseURL != server.URL+"/v1" {
191 t.Fatalf("model-level metadata redirected base URL to %q", models[0].BaseURL)
192 }
193 if models[1].API != APIAnthropicMessages {
194 t.Fatalf("Anthropic model API = %q, want %q", models[1].API, APIAnthropicMessages)
195 }
196 if models[2].API != APICompletions {
197 t.Fatalf("provider-default model API = %q, want %q", models[2].API, APICompletions)
198 }
199 if got := AvailableReasoningLevels(models[2]); len(got) != 1 || got[0] != "" {
200 t.Fatalf("empty reasoning options produced levels %q, want only off", got)
201 }
202}
203
204func TestDiscoverOpenCodeGoRetainsUnsupportedExplicitAdapterForRoutingError(t *testing.T) {
205 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
206 switch r.URL.Path {
207 case "/api.json":
208 _, _ = w.Write([]byte(`{"opencode-go":{"npm":"@ai-sdk/google","models":{"unknown-adapter":{"name":"Unknown Adapter"}}}}`))
209 case "/models":
210 _, _ = w.Write([]byte(`{"data":[{"id":"unknown-adapter"}]}`))
211 default:
212 http.NotFound(w, r)
213 }
214 }))
215 defer server.Close()
216
217 models, err := discoverOpenCodeGo(context.Background(), "key", server.URL, server.URL+"/api.json")
218 if err != nil {
219 t.Fatal(err)
220 }
221 if len(models) != 1 || models[0].API != "@ai-sdk/google" {
222 t.Fatalf("unsupported adapter model = %+v, want preserved adapter", models)
223 }
224}