1package provider
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "io"
8 "net/http"
9 "net/http/httptest"
10 "strings"
11 "sync"
12 "testing"
13)
14
15func TestAnthropicMessagesURLKeepsVersionedBasePath(t *testing.T) {
16 server := httptest.NewServer(nil)
17 defer server.Close()
18
19 for _, test := range []struct {
20 name string
21 base string
22 want string
23 }{
24 {name: "host", base: server.URL, want: server.URL + "/v1/messages"},
25 {name: "v1", base: server.URL + "/v1", want: server.URL + "/v1/messages"},
26 {name: "v2", base: server.URL + "/v2", want: server.URL + "/v2/messages"},
27 } {
28 t.Run(test.name, func(t *testing.T) {
29 if got := anthropicMessagesURL(test.base); got != test.want {
30 t.Fatalf("anthropicMessagesURL(%q) = %q, want %q", test.base, got, test.want)
31 }
32 })
33 }
34}
35
36func TestAnthropicCompatUsesScopedModelMetadata(t *testing.T) {
37 const modelID = "scoped-anthropic-model"
38 client := NewAnthropicCompat(ProviderOpenCodeGo, "key", "https://example.test/v1").(*anthropicClient)
39 client.SetModelMetadata(Model{
40 Provider: ProviderOpenCodeGo,
41 ID: modelID,
42 Reasoning: true,
43 MaxOutput: 4096,
44 ContextWindow: 32768,
45 ReasoningLevelMap: map[string]string{
46 "high": "high",
47 },
48 })
49
50 wire, err := client.buildRequest(Request{
51 Model: modelID,
52 Reasoning: "high",
53 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}}},
54 })
55 if err != nil {
56 t.Fatal(err)
57 }
58 if wire.MaxTokens != 4096 || wire.Thinking == nil || wire.Thinking.Type != "enabled" {
59 t.Fatalf("wire = %+v, want scoped output limit and reasoning", wire)
60 }
61}
62
63func TestOpenCodeGoAdaptersUsePublishedProtocolsAndIdentity(t *testing.T) {
64 const apiKey = "synthetic-opencode-key"
65 const (
66 responsesModel = "muse-spark-1.3-contributor"
67 messagesModel = "minimax-m2"
68 qwenModel = "qwen3-coder"
69 chatModel = "kimi-k2"
70 glmModel = "glm-5"
71 )
72
73 type requestRecord struct {
74 path string
75 method string
76 headers http.Header
77 body map[string]any
78 }
79 var (
80 mu sync.Mutex
81 calls = make(map[string]int)
82 records []requestRecord
83 )
84 expectedPathByModel := map[string]string{
85 responsesModel: "/v1/responses",
86 messagesModel: "/v1/messages",
87 qwenModel: "/v1/messages",
88 chatModel: "/v1/chat/completions",
89 glmModel: "/v1/chat/completions",
90 }
91
92 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93 switch r.URL.Path {
94 case "/api.json":
95 _, _ = io.WriteString(w, `{
96 "opencode-go": {
97 "npm": "@ai-sdk/openai-compatible",
98 "models": {
99 "muse-spark-1.3-contributor": {
100 "name": "Muse Spark 1.3 Contributor",
101 "reasoning": true,
102 "reasoning_options": [{"type":"effort","values":["low","medium","high"]}],
103 "limit": {"context":128000,"output":8192},
104 "provider": {"npm":"@ai-sdk/openai"}
105 },
106 "minimax-m2": {
107 "name": "MiniMax M2",
108 "reasoning": true,
109 "reasoning_options": [{"type":"effort","values":["low","medium","high"]}],
110 "limit": {"context":128000,"output":8192},
111 "provider": {"npm":"@ai-sdk/anthropic"}
112 },
113 "qwen3-coder": {
114 "name": "Qwen 3 Coder",
115 "reasoning": true,
116 "reasoning_options": [{"type":"effort","values":["low","medium","high"]}],
117 "limit": {"context":128000,"output":8192},
118 "provider": {"npm":"@ai-sdk/anthropic"}
119 },
120 "kimi-k2": {
121 "name": "Kimi K2",
122 "reasoning": false
123 },
124 "glm-5": {
125 "name": "GLM 5",
126 "reasoning": false
127 }
128 }
129 }
130 }`)
131 return
132 case "/v1/models":
133 if got := r.Header.Get("authorization"); got != "Bearer "+apiKey {
134 t.Errorf("models authorization = %q, want bearer key", got)
135 }
136 _, _ = io.WriteString(w, `{"data":[{"id":"muse-spark-1.3-contributor"},{"id":"minimax-m2"},{"id":"qwen3-coder"},{"id":"kimi-k2"},{"id":"glm-5"}]}`)
137 return
138 case "/v1/responses", "/v1/messages", "/v1/chat/completions":
139 default:
140 http.NotFound(w, r)
141 return
142 }
143
144 body, err := io.ReadAll(r.Body)
145 if err != nil {
146 t.Errorf("read %s request: %v", r.URL.Path, err)
147 return
148 }
149 var decoded map[string]any
150 if err := json.Unmarshal(body, &decoded); err != nil {
151 t.Errorf("decode %s request: %v", r.URL.Path, err)
152 return
153 }
154 model, _ := decoded["model"].(string)
155 if expectedPathByModel[model] != r.URL.Path {
156 http.Error(w, "model routed to the wrong protocol", http.StatusBadRequest)
157 return
158 }
159 if r.URL.Path == "/v1/responses" {
160 if decoded["input"] == nil || decoded["messages"] != nil {
161 http.Error(w, "Responses body has the wrong shape", http.StatusBadRequest)
162 return
163 }
164 } else if decoded["messages"] == nil || decoded["input"] != nil {
165 http.Error(w, "non-Responses body has the wrong shape", http.StatusBadRequest)
166 return
167 }
168 mu.Lock()
169 callKey := r.URL.Path + "\x00" + model
170 calls[callKey]++
171 call := calls[callKey]
172 records = append(records, requestRecord{path: r.URL.Path, method: r.Method, headers: r.Header.Clone(), body: decoded})
173 mu.Unlock()
174
175 if r.Header.Get("x-opencode-client") != "zut" || r.Header.Get("user-agent") != "zut" {
176 t.Errorf("%s identity headers = client %q user-agent %q", r.URL.Path, r.Header.Get("x-opencode-client"), r.Header.Get("user-agent"))
177 }
178 if r.Header.Get("x-opencode-session") == "" || r.Header.Get("x-opencode-request") == "" {
179 t.Errorf("%s missing request identity: %v", r.URL.Path, r.Header)
180 }
181 switch r.URL.Path {
182 case "/v1/chat/completions", "/v1/responses":
183 if got := r.Header.Get("authorization"); got != "Bearer "+apiKey {
184 t.Errorf("%s authorization = %q, want bearer key", r.URL.Path, got)
185 }
186 if got := r.Header.Get("x-api-key"); got != "" {
187 t.Errorf("%s leaked x-api-key %q", r.URL.Path, got)
188 }
189 case "/v1/messages":
190 if got := r.Header.Get("x-api-key"); got != apiKey {
191 t.Errorf("messages x-api-key = %q, want %q", got, apiKey)
192 }
193 if got := r.Header.Get("authorization"); got != "" {
194 t.Errorf("messages leaked authorization %q", got)
195 }
196 }
197 if call == 1 {
198 writeToolResponse(w, r.URL.Path)
199 } else {
200 writeTextResponse(w, r.URL.Path)
201 }
202 }))
203 defer server.Close()
204
205 models, err := discoverOpenCodeGo(context.Background(), apiKey, server.URL+"/v1", server.URL+"/api.json")
206 if err != nil {
207 t.Fatal(err)
208 }
209 client := NewOpenCodeGoWithModels(apiKey, server.URL+"/v1", models)
210
211 for _, test := range []struct {
212 name string
213 model string
214 thread string
215 firstWant string
216 }{
217 {name: "responses", model: responsesModel, thread: "thread-responses", firstWant: "/v1/responses"},
218 {name: "messages minimax", model: messagesModel, thread: "thread-messages-minimax", firstWant: "/v1/messages"},
219 {name: "messages qwen", model: qwenModel, thread: "thread-messages-qwen", firstWant: "/v1/messages"},
220 {name: "chat completions kimi", model: chatModel, thread: "thread-chat-kimi", firstWant: "/v1/chat/completions"},
221 {name: "chat completions glm", model: glmModel, thread: "thread-chat-glm", firstWant: "/v1/chat/completions"},
222 } {
223 t.Run(test.name, func(t *testing.T) {
224 first := Request{
225 Model: test.model,
226 Context: RequestContext{ThreadID: test.thread, CacheSessionID: "cache-" + test.name, TurnID: "turn-1"},
227 Tools: []Tool{{Name: "lookup", Schema: json.RawMessage(`{"type":"object","properties":{"q":{"type":"string"}}}`)}},
228 Reasoning: "high",
229 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "find x"}}}},
230 }
231 assistant, err := consumeToolStream(client, first)
232 if err != nil {
233 t.Fatal(err)
234 }
235 second := first
236 second.Context.TurnID = "turn-2"
237 second.Messages = append(second.Messages,
238 Message{Role: RoleAssistant, Content: []Content{assistant}},
239 Message{Role: RoleTool, Content: []Content{ToolResultBlock{CallID: assistant.ID, Content: []Content{TextBlock{Text: "result"}}}}},
240 )
241 if _, err := consumeTextStream(client, second); err != nil {
242 t.Fatal(err)
243 }
244
245 mu.Lock()
246 var got []requestRecord
247 for _, record := range records {
248 if record.path == test.firstWant && record.body["model"] == test.model {
249 got = append(got, record)
250 }
251 }
252 mu.Unlock()
253 if len(got) != 2 {
254 t.Fatalf("%s requests = %d, want initial and continuation", test.firstWant, len(got))
255 }
256 if got[0].method != http.MethodPost || got[1].method != http.MethodPost {
257 t.Fatalf("methods = %q, %q", got[0].method, got[1].method)
258 }
259 if got[0].headers.Get("x-opencode-session") != test.thread || got[1].headers.Get("x-opencode-session") != test.thread {
260 t.Fatalf("session headers = %q, %q", got[0].headers.Get("x-opencode-session"), got[1].headers.Get("x-opencode-session"))
261 }
262 if got[0].headers.Get("x-opencode-request") != "turn-1" || got[1].headers.Get("x-opencode-request") != "turn-2" {
263 t.Fatalf("request headers = %q, %q", got[0].headers.Get("x-opencode-request"), got[1].headers.Get("x-opencode-request"))
264 }
265 assertOpenCodeGoBodyShape(t, test.firstWant, got[0].body, false)
266 assertOpenCodeGoBodyShape(t, test.firstWant, got[1].body, true)
267 })
268 }
269}
270
271func writeToolResponse(w http.ResponseWriter, path string) {
272 w.Header().Set("content-type", "text/event-stream")
273 switch path {
274 case "/v1/chat/completions":
275 _, _ = io.WriteString(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-1\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"q\\\":\\\"x\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n")
276 case "/v1/responses":
277 _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"lookup\"}}\n\ndata: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":\"{\\\"q\\\":\\\"x\\\"}\"}\n\ndata: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"function_call\"}}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
278 case "/v1/messages":
279 _, _ = io.WriteString(w, "event: message_start\ndata: {\"message\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\nevent: content_block_start\ndata: {\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call-1\",\"name\":\"lookup\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"q\\\":\\\"x\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"index\":0}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":1}}\n\n\nevent: message_stop\ndata: {}\n\n")
280 }
281}
282
283func writeTextResponse(w http.ResponseWriter, path string) {
284 w.Header().Set("content-type", "text/event-stream")
285 switch path {
286 case "/v1/chat/completions":
287 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
288 case "/v1/responses":
289 _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"message\"}}\n\ndata: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"delta\":\"ok\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-2\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
290 case "/v1/messages":
291 _, _ = io.WriteString(w, "event: message_start\ndata: {\"message\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\nevent: content_block_start\ndata: {\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\nevent: content_block_stop\ndata: {\"index\":0}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\nevent: message_stop\ndata: {}\n\n")
292 }
293}
294
295func consumeToolStream(client Client, req Request) (ToolCallBlock, error) {
296 stream, err := client.Stream(context.Background(), req)
297 if err != nil {
298 return ToolCallBlock{}, err
299 }
300 var call ToolCallBlock
301 for event := range stream {
302 if done, ok := event.(EventDone); ok {
303 if done.Err != nil {
304 return ToolCallBlock{}, done.Err
305 }
306 for _, content := range done.Message.Content {
307 if candidate, ok := content.(ToolCallBlock); ok {
308 call = candidate
309 }
310 }
311 }
312 }
313 if call.ID == "" {
314 return ToolCallBlock{}, errors.New("stream did not produce a tool call")
315 }
316 return call, nil
317}
318
319func consumeTextStream(client Client, req Request) (string, error) {
320 stream, err := client.Stream(context.Background(), req)
321 if err != nil {
322 return "", err
323 }
324 var text strings.Builder
325 for event := range stream {
326 switch event := event.(type) {
327 case EventTextDelta:
328 text.WriteString(event.Delta)
329 case EventDone:
330 if event.Err != nil {
331 return "", event.Err
332 }
333 }
334 }
335 return text.String(), nil
336}
337
338func assertOpenCodeGoBodyShape(t *testing.T, path string, body map[string]any, continuation bool) {
339 t.Helper()
340 if body["model"] == nil || body["stream"] != true {
341 t.Fatalf("%s body = %#v, want model and stream", path, body)
342 }
343 switch path {
344 case "/v1/responses":
345 if body["input"] == nil || body["messages"] != nil {
346 t.Fatalf("Responses body = %#v, want input without messages", body)
347 }
348 if reasoning, ok := body["reasoning"].(map[string]any); !ok || reasoning["effort"] != "high" {
349 t.Fatalf("Responses reasoning = %#v, want effort=high", body["reasoning"])
350 }
351 if continuation && !containsResponseItemType(body["input"], "function_call_output") {
352 t.Fatalf("Responses continuation = %#v, want function_call_output", body["input"])
353 }
354 case "/v1/messages":
355 if body["messages"] == nil || body["input"] != nil {
356 t.Fatalf("Messages body = %#v, want messages without input", body)
357 }
358 if thinking, ok := body["thinking"].(map[string]any); !ok || thinking["type"] != "enabled" {
359 t.Fatalf("Messages thinking = %#v, want enabled thinking", body["thinking"])
360 }
361 if continuation && !containsAnthropicBlockType(body["messages"], "tool_result") {
362 t.Fatalf("Messages continuation = %#v, want tool_result", body["messages"])
363 }
364 case "/v1/chat/completions":
365 if body["messages"] == nil || body["input"] != nil {
366 t.Fatalf("Chat Completions body = %#v, want messages without input", body)
367 }
368 if continuation && !containsChatRole(body["messages"], "tool") {
369 t.Fatalf("Chat continuation = %#v, want tool message", body["messages"])
370 }
371 }
372}
373
374func containsResponseItemType(value any, want string) bool {
375 items, ok := value.([]any)
376 if !ok {
377 return false
378 }
379 for _, item := range items {
380 if object, ok := item.(map[string]any); ok && object["type"] == want {
381 return true
382 }
383 }
384 return false
385}
386
387func containsAnthropicBlockType(value any, want string) bool {
388 messages, ok := value.([]any)
389 if !ok {
390 return false
391 }
392 for _, message := range messages {
393 object, ok := message.(map[string]any)
394 if !ok {
395 continue
396 }
397 blocks, ok := object["content"].([]any)
398 if !ok {
399 continue
400 }
401 for _, block := range blocks {
402 if object, ok := block.(map[string]any); ok && object["type"] == want {
403 return true
404 }
405 }
406 }
407 return false
408}
409
410func containsChatRole(value any, want string) bool {
411 messages, ok := value.([]any)
412 if !ok {
413 return false
414 }
415 for _, message := range messages {
416 if object, ok := message.(map[string]any); ok && object["role"] == want {
417 return true
418 }
419 }
420 return false
421}