v0.48.1 deaab59

opencode_go_discovery_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close