v0.45.0 cb619f7

opencode_go_protocol_test.go RawHistoryFolder

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}

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