返回 DeepSeek-Reasonix
opencode_contract_wire_test.go
根目录 / internal / provider / openai / opencode_contract_wire_test.go
1 package openai
2
3 import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "net/http/httptest"
10 "net/url"
11 "strings"
12 "sync/atomic"
13 "testing"
14
15 "reasonix/internal/provider"
16 )
17
18 type localOpenCodeGoTransport struct {
19 target *url.URL
20 }
21
22 func (t localOpenCodeGoTransport) RoundTrip(req *http.Request) (*http.Response, error) {
23 req = req.Clone(req.Context())
24 req.URL.Scheme, req.URL.Host = t.target.Scheme, t.target.Host
25 req.Host = t.target.Host
26 return http.DefaultTransport.RoundTrip(req)
27 }
28
29 func TestOpenCodeGoChatToolReplayOmitsToolName(t *testing.T) {
30 var requests atomic.Int32
31 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
32 requestNumber := requests.Add(1)
33 if r.URL.Path != "/zen/go/v1/chat/completions" {
34 t.Errorf("request path = %q", r.URL.Path)
35 }
36 var body struct {
37 Messages []map[string]json.RawMessage `json:"messages"`
38 }
39 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
40 t.Errorf("decode request: %v", err)
41 w.WriteHeader(http.StatusBadRequest)
42 return
43 }
44 if requestNumber == 1 {
45 w.Header().Set("Content-Type", "text/event-stream")
46 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"bash\",\"arguments\":\"{}\"}}]}}]}\n\n")
47 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n")
48 return
49 }
50 for i, message := range body.Messages {
51 if string(message["role"]) != `"tool"` {
52 continue
53 }
54 if _, ok := message["name"]; ok {
55 w.WriteHeader(http.StatusBadRequest)
56 _, _ = fmt.Fprintf(w, `{"error":{"message":"messages[%d]: name is not supported by this endpoint"}}`, i)
57 return
58 }
59 if string(message["tool_call_id"]) != `"call_1"` || string(message["content"]) != `"ok"` {
60 t.Errorf("tool result = %v", message)
61 }
62 w.Header().Set("Content-Type", "text/event-stream")
63 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"done\"}}]}\n\ndata: [DONE]\n\n")
64 return
65 }
66 t.Error("second request missing tool result")
67 w.WriteHeader(http.StatusBadRequest)
68 }))
69 defer srv.Close()
70 target, err := url.Parse(srv.URL)
71 if err != nil {
72 t.Fatal(err)
73 }
74 p, err := New(provider.Config{
75 Name: "opencode-go", BaseURL: "https://opencode.ai/zen/go/v1", Model: "deepseek-v4-flash", APIKey: "test",
76 HTTPClient: &http.Client{Transport: localOpenCodeGoTransport{target: target}},
77 })
78 if err != nil {
79 t.Fatal(err)
80 }
81 messages := []provider.Message{{Role: provider.RoleUser, Content: "run bash"}}
82 first, err := p.Stream(t.Context(), provider.Request{Messages: messages})
83 if err != nil {
84 t.Fatal(err)
85 }
86 var call *provider.ToolCall
87 for chunk := range first {
88 if chunk.Type == provider.ChunkError {
89 t.Fatal(chunk.Err)
90 }
91 if chunk.Type == provider.ChunkToolCall {
92 call = chunk.ToolCall
93 }
94 }
95 if call == nil || call.ID != "call_1" || call.Name != "bash" {
96 t.Fatalf("first turn tool call = %+v", call)
97 }
98 messages = append(messages,
99 provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{*call}},
100 provider.Message{Role: provider.RoleTool, ToolCallID: call.ID, Name: call.Name, Content: "ok"},
101 )
102 second, err := p.Stream(t.Context(), provider.Request{Messages: messages})
103 if err != nil {
104 t.Fatal(err)
105 }
106 var text strings.Builder
107 for chunk := range second {
108 if chunk.Type == provider.ChunkError {
109 t.Fatal(chunk.Err)
110 }
111 if chunk.Type == provider.ChunkText {
112 text.WriteString(chunk.Text)
113 }
114 }
115 if requests.Load() != 2 || text.String() != "done" {
116 t.Fatalf("requests = %d, second response = %q", requests.Load(), text.String())
117 }
118 }
119
120 func TestOpenCodeGoToolNameOnlyOmittedOnOfficialChatRoute(t *testing.T) {
121 for _, tc := range []struct {
122 name, baseURL, requestURL string
123 wantName bool
124 }{
125 {name: "official chat", baseURL: "https://opencode.ai/zen/go/v1", wantName: false},
126 {name: "official request override", baseURL: "https://gateway.example/v1", requestURL: "https://opencode.ai/zen/go/v1/chat/completions", wantName: false},
127 {name: "custom request override", baseURL: "https://opencode.ai/zen/go/v1", requestURL: "https://gateway.example/v1/chat/completions", wantName: true},
128 {name: "other endpoint", baseURL: "https://api.xiaomimimo.com/v1", wantName: true},
129 } {
130 t.Run(tc.name, func(t *testing.T) {
131 extra := map[string]any{}
132 if tc.requestURL != "" {
133 extra["request_url"] = tc.requestURL
134 }
135 p, err := New(provider.Config{BaseURL: tc.baseURL, Model: "deepseek-v4-flash", Extra: extra})
136 if err != nil {
137 t.Fatal(err)
138 }
139 body, err := json.Marshal(p.(*client).buildRequest(provider.Request{Messages: []provider.Message{
140 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: "{}"}}},
141 {Role: provider.RoleTool, ToolCallID: "call_1", Name: "bash", Content: "ok"},
142 }}))
143 if err != nil {
144 t.Fatal(err)
145 }
146 var wire struct {
147 Messages []map[string]json.RawMessage `json:"messages"`
148 }
149 if err := json.Unmarshal(body, &wire); err != nil {
150 t.Fatal(err)
151 }
152 _, hasName := wire.Messages[1]["name"]
153 if hasName != tc.wantName {
154 t.Fatalf("tool name present = %v, want %v: %s", hasName, tc.wantName, body)
155 }
156 })
157 }
158 }
159
160 func TestOpenCodeGoDeepSeekWireKeepsReasoningAndStableHistory(t *testing.T) {
161 for _, model := range []string{"deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-flash-vision-exp"} {
162 p, err := New(provider.Config{BaseURL: "https://opencode.ai/zen/go/v1", Model: model, Extra: map[string]any{"request_url": "https://opencode.ai/zen/go/v1/chat/completions", "thinking": "enabled", "effort": "max"}})
163 if err != nil {
164 t.Fatal(err)
165 }
166 c := p.(*client)
167 req := provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "fixture"}, {Role: provider.RoleAssistant, ReasoningContent: "provider-reasoning", ToolCalls: []provider.ToolCall{{ID: "call-1", Name: "read_file", Arguments: "{}"}}}, {Role: provider.RoleTool, ToolCallID: "call-1", Content: "tool-result"}}, MaxTokens: 128}
168 body := c.buildRequest(req)
169 first, _ := json.Marshal(body)
170 second, _ := json.Marshal(c.buildRequest(req))
171 if !bytes.Equal(first, second) || !bytes.Contains(first, []byte(`"reasoning_effort":"max"`)) || !bytes.Contains(first, []byte(`"reasoning_content":"provider-reasoning"`)) {
172 t.Fatalf("%s lost effort/replay/cache stability: %s", model, first)
173 }
174 }
175 }
176
176 lines GO