1package provider
2
3import (
4 "context"
5 "strings"
6 "sync"
7 "testing"
8)
9
10type routeCaptureClient struct {
11 name string
12 models []string
13}
14
15func (c *routeCaptureClient) Name() string { return c.name }
16func (c *routeCaptureClient) Stream(_ context.Context, req Request) (<-chan Event, error) {
17 c.models = append(c.models, req.Model)
18 out := make(chan Event)
19 close(out)
20 return out, nil
21}
22
23type noOpClient struct{ name string }
24
25func (c *noOpClient) Name() string { return c.name }
26func (c *noOpClient) Stream(context.Context, Request) (<-chan Event, error) {
27 out := make(chan Event)
28 close(out)
29 return out, nil
30}
31
32func TestModelRouterDispatchesByModelAPI(t *testing.T) {
33 completions := &routeCaptureClient{name: "xai"}
34 responses := &routeCaptureClient{name: "xai"}
35 router := NewModelRouter("xai", completions, map[string]Client{
36 APIResponses: responses,
37 })
38
39 for _, model := range []string{"grok-4.5", "grok-build-0.1"} {
40 stream, err := router.Stream(context.Background(), Request{Model: model})
41 if err != nil {
42 t.Fatal(err)
43 }
44 for range stream {
45 }
46 }
47
48 if len(responses.models) != 1 || responses.models[0] != "grok-4.5" {
49 t.Fatalf("Responses models = %v", responses.models)
50 }
51 if len(completions.models) != 1 || completions.models[0] != "grok-build-0.1" {
52 t.Fatalf("Completions models = %v", completions.models)
53 }
54}
55
56func TestModelRouterRejectsMissingAPIClient(t *testing.T) {
57 preserveActiveCatalog(t)
58 SetLiveModels([]Model{{Provider: "xai", ID: "grok-4.5", API: APIResponses}})
59 router := NewModelRouter("xai", &routeCaptureClient{name: "xai"}, nil)
60 if _, err := router.Stream(context.Background(), Request{Model: "grok-4.5"}); err == nil {
61 t.Fatal("expected missing API client error")
62 }
63}
64
65func TestOpenCodeGoRoutesLunaToResponses(t *testing.T) {
66 preserveActiveCatalog(t)
67 SetLiveModels([]Model{
68 {Provider: "opencode-go", ID: "gpt-5.6-luna", API: APIResponses},
69 {Provider: "opencode-go", ID: "kimi-k3"},
70 {Provider: "opencode-go", ID: "explicit-completions", API: APICompletions},
71 })
72
73 router := NewOpenCodeGo("token", "https://example.com/go/v1").(*modelRouter)
74 if got := router.fallback.(*openaiClient).baseURL; got != "https://example.com/go/v1" {
75 t.Fatalf("Completions base URL = %q", got)
76 }
77 responses := router.byAPI[APIResponses].(*renamedClient).inner.(*codexClient)
78 if got := responses.baseURL; got != "https://example.com/go/v1/responses" {
79 t.Fatalf("Responses base URL = %q", got)
80 }
81
82 completionsCapture := &routeCaptureClient{name: "opencode-go"}
83 responsesCapture := &routeCaptureClient{name: "opencode-go"}
84 router.fallback = completionsCapture
85 router.byAPI[APIResponses] = responsesCapture
86 for _, model := range []string{"gpt-5.6-luna", "kimi-k3", "explicit-completions"} {
87 stream, err := router.Stream(context.Background(), Request{Model: model})
88 if err != nil {
89 t.Fatal(err)
90 }
91 for range stream {
92 }
93 }
94
95 if len(responsesCapture.models) != 1 || responsesCapture.models[0] != "gpt-5.6-luna" {
96 t.Fatalf("Responses models = %v", responsesCapture.models)
97 }
98 if len(completionsCapture.models) != 2 || completionsCapture.models[0] != "kimi-k3" || completionsCapture.models[1] != "explicit-completions" {
99 t.Fatalf("Completions models = %v", completionsCapture.models)
100 }
101}
102
103func TestOpenCodeGoScopedEmptyRouterIgnoresGlobalCatalog(t *testing.T) {
104 preserveActiveCatalog(t)
105 SetLiveModels([]Model{{Provider: ProviderOpenCodeGo, ID: "global-model", API: APIResponses}})
106
107 fallback := &routeCaptureClient{name: ProviderOpenCodeGo}
108 responses := &routeCaptureClient{name: ProviderOpenCodeGo}
109 router := NewOpenCodeGoWithModels("token", "https://example.com/go/v1", []Model{}).(*modelRouter)
110 router.fallback = fallback
111 router.byAPI[APIResponses] = responses
112 stream, err := router.Stream(context.Background(), Request{Model: "global-model"})
113 if err != nil {
114 t.Fatal(err)
115 }
116 for range stream {
117 }
118 if len(fallback.models) != 1 || len(responses.models) != 0 {
119 t.Fatalf("scoped empty router used global catalog: fallback=%v responses=%v", fallback.models, responses.models)
120 }
121}
122
123func TestModelRouterRejectsUnsupportedExplicitModelAPI(t *testing.T) {
124 router := NewOpenCodeGoWithModels("token", "https://example.com/go/v1", []Model{{
125 Provider: ProviderOpenCodeGo,
126 ID: "unsupported-model",
127 API: "@ai-sdk/google",
128 }})
129 if _, err := router.Stream(context.Background(), Request{Model: "unsupported-model"}); err == nil || !strings.Contains(err.Error(), "unsupported wire API") {
130 t.Fatalf("error = %v, want actionable unsupported wire API error", err)
131 }
132}
133
134func TestOpenCodeGoRoutesUncataloguedGPT56ToResponses(t *testing.T) {
135 preserveActiveCatalog(t)
136 SetLiveModels(nil)
137
138 responses := &routeCaptureClient{name: "opencode-go"}
139 completions := &routeCaptureClient{name: "opencode-go"}
140 router := NewModelRouter("opencode-go", completions, map[string]Client{
141 APIResponses: responses,
142 })
143 stream, err := router.Stream(context.Background(), Request{Model: "gpt-5.6-future"})
144 if err != nil {
145 t.Fatal(err)
146 }
147 for range stream {
148 }
149 if len(responses.models) != 1 || responses.models[0] != "gpt-5.6-future" {
150 t.Fatalf("Responses models = %v", responses.models)
151 }
152 if len(completions.models) != 0 {
153 t.Fatalf("Completions models = %v, want none", completions.models)
154 }
155}
156
157func TestOpenCodeGoDynamicRouterRefreshesMetadata(t *testing.T) {
158 preserveActiveCatalog(t)
159 SetLiveModels([]Model{{Provider: ProviderOpenCodeGo, ID: "refreshable-model", API: APIResponses}})
160
161 responses := &routeCaptureClient{name: ProviderOpenCodeGo}
162 completions := &routeCaptureClient{name: ProviderOpenCodeGo}
163 router := NewOpenCodeGo("token", "https://example.com/go/v1").(*modelRouter)
164 router.fallback = completions
165 router.byAPI[APIResponses] = responses
166 stream, err := router.Stream(context.Background(), Request{Model: "refreshable-model"})
167 if err != nil {
168 t.Fatal(err)
169 }
170 for range stream {
171 }
172 if router.modelOverrides["refreshable-model"].API != APIResponses {
173 t.Fatalf("initial router metadata = %+v", router.modelOverrides["refreshable-model"])
174 }
175
176 SetLiveModels([]Model{{Provider: ProviderOpenCodeGo, ID: "refreshable-model", API: APICompletions}})
177 stream, err = router.Stream(context.Background(), Request{Model: "refreshable-model"})
178 if err != nil {
179 t.Fatal(err)
180 }
181 for range stream {
182 }
183 if router.modelOverrides["refreshable-model"].API != APICompletions {
184 t.Fatalf("refreshed router metadata = %+v, want completions", router.modelOverrides["refreshable-model"])
185 }
186 if len(completions.models) != 1 || completions.models[0] != "refreshable-model" {
187 t.Fatalf("Completions models = %v, want refreshed model", completions.models)
188 }
189}
190
191func TestOpenCodeGoDynamicRouterRejectsUnknownAuthoritativeModel(t *testing.T) {
192 preserveActiveCatalog(t)
193 SetLiveModelsForProviders([]Model{{Provider: ProviderOpenCodeGo, ID: "served-model"}}, []string{ProviderOpenCodeGo})
194
195 router := NewOpenCodeGo("token", "https://example.com/go/v1")
196 if _, err := router.Stream(context.Background(), Request{Model: "removed-model"}); err == nil {
197 t.Fatal("authoritative OpenCode Go router accepted removed model")
198 }
199}
200
201func TestModelRouterConcurrentStreamAndMetadataUpdates(t *testing.T) {
202 router := NewModelRouter("test", &noOpClient{name: "test"}, map[string]Client{
203 APIResponses: &noOpClient{name: "test"},
204 }).(*modelRouter)
205 model := Model{Provider: "test", ID: "concurrent-model", API: APIResponses}
206 router.SetModelMetadata(model)
207
208 var wg sync.WaitGroup
209 for i := 0; i < 100; i++ {
210 wg.Add(2)
211 go func(i int) {
212 defer wg.Done()
213 updated := model
214 updated.API = APIResponses
215 if i%2 == 0 {
216 updated.API = APICompletions
217 }
218 router.SetModelMetadata(updated)
219 }(i)
220 go func() {
221 defer wg.Done()
222 stream, err := router.Stream(context.Background(), Request{Model: model.ID})
223 if err != nil {
224 t.Errorf("stream: %v", err)
225 return
226 }
227 for range stream {
228 }
229 }()
230 }
231 wg.Wait()
232}
233
234func TestModelMetadataConcurrentAccess(t *testing.T) {
235 model := Model{Provider: ProviderOpenAICodex, ID: "concurrent-model", ContextWindow: 128000, MaxOutput: 8192}
236 openai := &openaiClient{name: ProviderOpenAI}
237 codex := &codexClient{providerName: ProviderOpenAICodex}
238 openai.SetModelMetadata(model)
239 codex.SetModelMetadata(model)
240
241 var wg sync.WaitGroup
242 for i := 0; i < 100; i++ {
243 wg.Add(4)
244 go func() {
245 defer wg.Done()
246 openai.SetModelMetadata(model)
247 }()
248 go func() {
249 defer wg.Done()
250 _, _ = openai.buildRequest(Request{Model: model.ID})
251 }()
252 go func() {
253 defer wg.Done()
254 codex.SetModelMetadata(model)
255 }()
256 go func() {
257 defer wg.Done()
258 _, _ = codex.buildRequest(Request{Model: model.ID})
259 }()
260 }
261 wg.Wait()
262}