8000 fix(opencode-go): adapt completions routes to the live service · felinics/twilight@27f08ff · GitHub
[go: up one dir, main page]

Skip to content

Commit 27f08ff

Browse files
committed
fix(opencode-go): adapt completions routes to the live service
Verified against the live service on 2026-09-20 across all catalog models: - Several Completions models (GLM, DeepSeek, LongCat) accept the developer role but silently ignore its content. Developer messages are now sent as system messages, which every model honors. - Some Completions routes reject a replayed assistant tool call that carries no reasoning_content. It is now always sent, empty when the history has none; every route accepts the empty value. Both rules apply to every Completions route and come from the service's observed behavior. Nothing is inferred from the model name: the same model family is served by different upstreams with different behavior. Also build the protocol delegates once in New instead of per call, omit the Authorization header when no key is configured, carry catalog display names on returned models, and add deepseek-flash, which the endpoint table omits but the live service lists and serves. Tests: an offline test routes every catalog model to its endpoint, and env-gated TestIntegration_* tests (OPENCODE_GO_API_KEY) exercise a streamed tool loop, a replay without reasoning and a developer instruction on every catalog model.
1 parent 8fccbe2 commit 27f08ff

6 files changed

Lines changed: 388 additions & 25 deletions

File tree

‎.env.example‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,10 @@ ANTHROPIC_AUTH_MODE=
1717
GOOGLE_GENERATIVE_AI_API_KEY=AIzaxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
1818
GOOGLE_GENERATIVE_AI_BASE_URL=https://generativelanguage.googleapis.com/v1beta
1919
GOOGLE_GENERATIVE_AI_MODEL=gemini-2.5-flash
20+
21+
# OpenCode Go
22+
# Integration tests exercise every catalog model and incur usage charges.
23+
OPENCODE_GO_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
24+
OPENCODE_GO_BASE_URL=https://opencode.ai/zen/go/v1
25+
# Optional comma-separated model IDs to narrow the run, e.g. glm-5.2,kimi-k3
26+
OPENCODE_GO_MODELS=

‎docs/providers.md‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -646,12 +646,31 @@ transports may also supply headers. See the [official client requirements](https
646646
| `ProtocolMessages` | `/messages` | `minimax-m2.7` |
647647

648648
`Catalog()` returns the explicit model/protocol routes from the [official
649-
endpoint table](https://opencode.ai/docs/go/#endpoints), checked on 2026-09-18.
649+
endpoint table](https://opencode.ai/docs/go/#endpoints), checked on 2026-09-18,
650+
plus `deepseek-flash`, which the table omits but the live service lists and
651+
serves.
650652
`provider.ProtocolForModel(id)` exposes the routing decision for applications
651653
that need protocol-specific reasoning or cache settings. Generic generation
652654
parameters, tools, reasoning metadata and stream events retain the selected
653-
protocol provider's behavior; the Go wrapper does not infer model-family
654-
compatibility flags or thinking modes.
655+
protocol provider's behavior. The Go wrapper does not infer model-family
656+
compatibility flags or thinking modes: OpenCode Go is its own service, and a
657+
model's name says nothing about which upstream serves it.
658+
659+
Two adjustments apply to every Completions route. Both come from the service's
660+
observed behavior, verified on 2026-09-20, not from the model vendor's API:
661+
662+
- Developer messages are sent as system messages. The routes accept the
663+
developer role, but several models silently ignore its content, while every
664+
model honors system messages.
665+
- A replayed assistant tool call always carries `reasoning_content`, empty when
666+
the history has none. Some routes reject the request otherwise, and every
667+
route accepts the empty value.
668+
669+
Other upstream behavior passes through unchanged. `ReasoningEffort` is sent as
670+
`reasoning_effort`; the service maps `"none"` to disabled thinking on most
671+
routes, while `deepseek-v4-flash` ignores every thinking control and always
672+
reasons. `glm-5.3-flash` rejects a request that sets a reasoning effort with
673+
`MaxTokens` of 1024 or less.
655674

656675
`ListModels(ctx)` fetches the live `/models` list, including new models. The
657676
upstream list does not include protocol metadata. A listed model can therefore

‎provider/opencode/go/catalog.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ func Catalog() []ModelDescriptor {
3333
{ID: "kimi-k2.7-code", DisplayName: "Kimi K2.7 Code", Protocol: ProtocolCompletions},
3434
{ID: "kimi-k2.6", DisplayName: "Kimi K2.6", Protocol: ProtocolCompletions},
3535
{ID: "longcat-2.0", DisplayName: "LongCat-2.0", Protocol: ProtocolCompletions},
36+
// Not in the endpoint table: listed by the live /models endpoint and
37+
// verified against the service on 2026-09-20.
38+
{ID: "deepseek-flash", DisplayName: "DeepSeek Flash", Protocol: ProtocolCompletions},
3639
{ID: "deepseek-v4.1-flash", DisplayName: "DeepSeek V4.1 Flash", Protocol: ProtocolCompletions},
3740
{ID: "deepseek-v4-pro", DisplayName: "DeepSeek V4 Pro", Protocol: ProtocolCompletions},
3841
{ID: "deepseek-v4-flash", DisplayName: "DeepSeek V4 Flash", Protocol: ProtocolCompletions},
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package opencodego_test
2+
3+
import (
4+
"context"
5+
"os"
6+
"slices"
7+
"strings"
8+
"testing"
9+
"time"
10+
11+
"github.com/felinics/twilight/internal/testutil"
12+
opencodego "github.com/felinics/twilight/provider/opencode/go"
13+
"github.com/felinics/twilight/sdk"
14+
"github.com/google/jsonschema-go/jsonschema"
15+
)
16+
17+
func TestMain(m *testing.M) {
18+
testutil.LoadEnv()
19+
os.Exit(m.Run())
20+
}
21+
22+
// ---------- integration tests (real API, skipped without env) ----------
23+
//
24+
// OPENCODE_GO_API_KEY enables them. OPENCODE_GO_MODELS narrows the run to a
25+
// comma-separated list of model IDs; by default every catalog model is
26+
// exercised. These requests incur upstream usage charges.
27+
28+
func newIntegrationProvider(t *testing.T) *opencodego.Provider {
29+
t.Helper()
30+
apiKey := os.Getenv("OPENCODE_GO_API_KEY")
31+
if apiKey == "" {
32+
t.Skip("skipping: OPENCODE_GO_API_KEY not set")
33+
}
34+
options := []opencodego.Option{
35+
opencodego.WithAPIKey(apiKey),
36+
opencodego.WithHeaders(map[string]string{"User-Agent": "twilight-integration-test/1.0"}),
37+
}
38+
if base := os.Getenv("OPENCODE_GO_BASE_URL"); base != "" {
39+
options = append(options, opencodego.WithBaseURL(base))
40+
}
41+
return opencodego.New(options...)
42+
}
43+
44+
func integrationModels() []opencodego.ModelDescriptor {
45+
var only []string
46+
for id := range strings.SplitSeq(os.Getenv("OPENCODE_GO_MODELS"), ",") {
47+
if id = strings.TrimSpace(id); id != "" {
48+
only = append(only, id)
49+
}
50+
}
51+
models := opencodego.Catalog()
52+
if len(only) == 0 {
53+
return models
54+
}
55+
return slices.DeleteFunc(models, func(m opencodego.ModelDescriptor) bool { return !slices.Contains(only, m.ID) })
56+
}
57+
58+
func integrationContext(t *testing.T) context.Context {
59+
t.Helper()
60+
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
61+
t.Cleanup(cancel)
62+
return sdk.WithRequestHeaders(ctx, map[string]string{opencodego.SessionHeader: "twilight-integration-" + t.Name()})
63+
}
64+
65+
func integrationLookupTool() sdk.Tool {
66+
return sdk.Tool{
67+
Name: "lookup", Description: "Look up a value by key",
68+
Parameters: &jsonschema.Schema{
69+
Type: "object", Properties: map[string]*jsonschema.Schema{"key": {Type: "string"}}, Required: []string{"key"},
70+
},
71+
Execute: func(_ *sdk.ToolExecContext, _ any) (any, error) { return "blue", nil },
72+
}
73+
}
74+
75+
// The live catalog must still list every model that has a local route.
76+
func TestIntegration_CatalogIsLive(t *testing.T) {
77+
p := newIntegrationProvider(t)
78+
models, err := p.ListModels(integrationContext(t))
79+
if err != nil {
80+
t.Fatal(err)
81+
}
82+
live := make(map[string]bool, len(models))
83+
for _, model := range models {
84+
live[model.ID] = true
85+
if _, err := p.ProtocolForModel(model.ID); err != nil {
86+
t.Logf("live model without a local route: %s", model.ID)
87+
}
88+
}
89+
for _, entry := range opencodego.Catalog() {
90+
if !live[entry.ID] {
91+
t.Errorf("catalog model %q is no longer listed upstream", entry.ID)
92+
}
93+
}
94+
}
95+
96+
// A streamed multi-step tool loop replays each model's own reasoning. Whether
97+
// the model chooses to call the tool is its decision; the request must succeed.
98+
func TestIntegration_StreamToolLoop(t *testing.T) {
99+
p := newIntegrationProvider(t)
100+
for _, entry := range integrationModels() {
101+
t.Run(entry.ID, func(t *testing.T) {
102+
t.Parallel()
103+
stream, err := sdk.StreamText(integrationContext(t),
104+
sdk.WithModel(p.ChatModel(entry.ID)), sdk.WithMaxSteps(4), sdk.WithTools([]sdk.Tool{integrationLookupTool()}),
105+
sdk.WithMessages([]sdk.Message{sdk.UserMessage("Call the lookup tool with key 'color', then reply with only the value it returned.")}),
106+
)
107+
if err != nil {
108+
t.Fatal(err)
109+
}
110+
result, err := stream.ToResult()
111+
if err != nil {
112+
t.Fatal(err)
113+
}
114+
if strings.TrimSpace(result.Text) == "" {
115+
t.Errorf("empty text: %+v", result)
116+
}
117+
t.Logf("[%s] steps=%d text=%q", entry.Protocol, len(result.Steps), result.Text)
118+
})
119+
}
120+
}
121+
122+
// Persisted history whose assistant tool call carries no reasoning, sent with
123+
// thinking enabled. Some Completions routes reject it unless it is padded.
124+
func TestIntegration_ReplayWithoutReasoning(t *testing.T) {
125+
p := newIntegrationProvider(t)
126+
maxTokens := 2048
127+
effort := "high"
128+
history := []sdk.Message{
129+
sdk.UserMessage("Use lookup for key 'color', then tell me the result."),
130+
{Role: sdk.MessageRoleAssistant, Content: []sdk.MessagePart{sdk.ToolCallPart{ToolCallID: "call_1", ToolName: "lookup", Input: map[string]any{"key": "color"}}}},
131+
sdk.ToolMessage(sdk.ToolResultPart{ToolCallID: "call_1", ToolName: "lookup", Result: "blue"}),
132+
}
133+
for _, entry := range integrationModels() {
134+
t.Run(entry.ID, func(t *testing.T) {
135+
t.Parallel()
136+
result, err := p.DoGenerate(integrationContext(t), sdk.GenerateParams{
137+
Model: p.ChatModel(entry.ID), MaxTokens: &maxTokens, ReasoningEffort: &effort,
138+
Tools: []sdk.Tool{integrationLookupTool()}, Messages: history,
139+
})
140+
if err != nil {
141+
t.Fatal(err)
142+
}
143+
if !strings.Contains(strings.ToLower(result.Text), "blue") {
144+
t.Errorf("text = %q", result.Text)
145+
}
146+
})
147+
}
148+
}
149+
150+
// Several Completions models ignore the developer role on the wire, so a
151+
// developer instruction must still take effect on every model.
152+
func TestIntegration_DeveloperInstruction(t *testing.T) {
153+
p := newIntegrationProvider(t)
154+
maxTokens := 2048
155+
for _, entry := range integrationModels() {
156+
t.Run(entry.ID, func(t *testing.T) {
157+
t.Parallel()
158+
result, err := p.DoGenerate(integrationContext(t), sdk.GenerateParams{
159+
Model: p.ChatModel(entry.ID), MaxTokens: &maxTokens,
160+
Messages: []sdk.Message{sdk.DeveloperMessage("Always answer in uppercase."), sdk.UserMessage("Say: pong")},
161+
})
162+
if err != nil {
163+
t.Fatal(err)
164+
}
165+
if !strings.Contains(result.Text, "PONG") {
166+
t.Errorf("developer instruction ignored: %q", result.Text)
167+
}
168+
})
169+
}
170+
}

‎provider/opencode/go/provider.go‎

Lines changed: 81 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"maps"
1010
"net/http"
11+
"slices"
1112

1213
"github.com/felinics/twilight/internal/utils"
1314
"github.com/felinics/twilight/provider/anthropic/messages"
@@ -33,6 +34,8 @@ type Provider struct {
3334
httpClient *http.Client
3435
headers map[string]string
3536
modelProtocols map[string]Protocol
37+
displayNames map[string]string
38+
delegates map[Protocol]sdk.Provider
3639
}
3740

3841
var _ sdk.Provider = (*Provider)(nil)
@@ -72,20 +75,36 @@ func New(options ...Option) *Provider {
7275
baseURL: defaultBaseURL,
7376
httpClient: &http.Client{},
7477
modelProtocols: make(map[string]Protocol),
78+
displayNames: make(map[string]string),
7579
}
7680
for _, model := range Catalog() {
7781
p.modelProtocols[model.ID] = model.Protocol
82+
p.displayNames[model.ID] = model.DisplayName
7883
}
7984
for _, option := range options {
8085
option(p)
8186
}
87+
p.delegates = map[Protocol]sdk.Provider{
88+
ProtocolCompletions: completions.New(
89+
completions.WithAPIKey(p.apiKey), completions.WithBaseURL(p.baseURL),
90+
completions.WithHTTPClient(p.httpClient), completions.WithHeaders(p.headers),
91+
),
92+
ProtocolResponses: responses.New(
93+
responses.WithAPIKey(p.apiKey), responses.WithBaseURL(p.baseURL),
94+
responses.WithHTTPClient(p.httpClient), responses.WithHeaders(p.headers),
95+
),
96+
ProtocolMessages: messages.New(
97+
messages.WithAPIKey(p.apiKey), messages.WithBaseURL(p.baseURL),
98+
messages.WithHTTPClient(p.httpClient), messages.WithHeaders(p.headers),
99+
),
100+
}
82101
return p
83102
}
84103

85104
func (p *Provider) Name() string { return "opencode-go" }
86105

87106
func (p *Provider) ChatModel(id string) *sdk.Model {
88-
return &sdk.Model{ID: id, Provider: p, Type: sdk.ModelTypeChat}
107+
return &sdk.Model{ID: id, DisplayName: p.displayNames[id], Provider: p, Type: sdk.ModelTypeChat}
89108
}
90109

91110
// ProtocolForModel exposes the same routing decision used by generation and
@@ -115,7 +134,7 @@ func (p *Provider) ListModels(ctx context.Context) ([]sdk.Model, error) {
115134
Method: http.MethodGet,
116135
BaseURL: p.baseURL,
117136
Path: "/models",
118-
Headers: utils.RequestHeaders(ctx, utils.AuthHeader(p.apiKey), p.headers),
137+
Headers: p.requestHeaders(ctx),
119138
})
120139
if err != nil {
121140
return nil, fmt.Errorf("opencode-go: list models: %w", err)
@@ -169,7 +188,7 @@ func (p *Provider) DoGenerate(ctx context.Context, params sdk.GenerateParams) (*
169188
if err != nil {
170189
return nil, err
171190
}
172-
return delegate.DoGenerate(ctx, params)
191+
return delegate.DoGenerate(ctx, p.paramsForModel(params))
173192
}
174193

175194
func (p *Provider) DoStream(ctx context.Context, params sdk.GenerateParams) (*sdk.StreamResult, error) { //nolint:gocritic // interface method
@@ -180,31 +199,71 @@ func (p *Provider) DoStream(ctx context.Context, params sdk.GenerateParams) (*sd
180199
if err != nil {
181200
return nil, err
182201
}
183-
return delegate.DoStream(ctx, params)
202+
return delegate.DoStream(ctx, p.paramsForModel(params))
184203
}
185204

186205
func (p *Provider) providerForModel(id string) (sdk.Provider, error) {
187206
protocol, err := p.ProtocolForModel(id)
188207
if err != nil {
189208
return nil, err
190209
}
191-
switch protocol {
192-
case ProtocolCompletions:
193-
return completions.New(
194-
completions.WithAPIKey(p.apiKey), completions.WithBaseURL(p.baseURL),
195-
completions.WithHTTPClient(p.httpClient), completions.WithHeaders(p.headers),
196-
), nil
197-
case ProtocolResponses:
198-
return responses.New(
199-
responses.WithAPIKey(p.apiKey), responses.WithBaseURL(p.baseURL),
200-
responses.WithHTTPClient(p.httpClient), responses.WithHeaders(p.headers),
201-
), nil
202-
case ProtocolMessages:
203-
return messages.New(
204-
messages.WithAPIKey(p.apiKey), messages.WithBaseURL(p.baseURL),
205-
messages.WithHTTPClient(p.httpClient), messages.WithHeaders(p.headers),
206-
), nil
207-
default:
208-
return nil, fmt.Errorf("opencode-go: unsupported protocol %q", protocol)
210+
return p.delegates[protocol], nil
211+
}
212+
213+
// paramsForModel adapts a request to how the service's Completions routes
214+
// behave, as observed against the live service rather than inferred from the
215+
// model family:
216+
//
217+
// - Developer messages are sent as system messages. The routes accept the
218+
// developer role, but several models silently ignore its content, while
219+
// every model honors system messages, including mid-conversation ones.
220+
// - A replayed assistant tool call always carries reasoning_content. Some
221+
// routes reject the request otherwise, which happens when persisted history
222+
// dropped the reasoning or the model emitted none; an empty value is
223+
// accepted by every route.
224+
func (p *Provider) paramsForModel(params sdk.GenerateParams) sdk.GenerateParams { //nolint:gocritic // mirrors interface methods
225+
if p.modelProtocols[params.Model.ID] != ProtocolCompletions {
226+
return params
227+
}
228+
converted := slices.Clone(params.Messages)
229+
for i := range converted {
230+
switch converted[i].Role {
231+
case sdk.MessageRoleDeveloper:
232+
converted[i].Role = sdk.MessageRoleSystem
233+
case sdk.MessageRoleAssistant:
234+
converted[i].Content = padToolCallReasoning(converted[i].Content)
235+
}
236+
}
237+
params.Messages = converted
238+
return params
239+
}
240+
241+
// padToolCallReasoning appends an empty Chat Completions reasoning block to a
242+
// tool-call message that has none, without modifying the caller's parts.
243+
func padToolCallReasoning(parts []sdk.MessagePart) []sdk.MessagePart {
244+
hasToolCall := false
245+
for _, part := range parts {
246+
switch part := part.(type) {
247+
case sdk.ToolCallPart:
248+
hasToolCall = true
249+
case sdk.ReasoningPart:
250+
if part.Format == sdk.ReasoningFormatOpenAIChat {
251+
return parts
252+
}
253+
}
254+
}
255+
if !hasToolCall {
256+
return parts
257+
}
258+
return append(slices.Clone(parts), sdk.ReasoningPart{Format: sdk.ReasoningFormatOpenAIChat})
259+
}
260+
261+
// requestHeaders omits Authorization when no key is configured, so the public
262+
// models endpoint is not sent an empty bearer token.
263+
func (p *Provider) requestHeaders(ctx context.Context) map[string]string {
264+
var defaults map[string]string
265+
if p.apiKey != "" {
266+
defaults = utils.AuthHeader(p.apiKey)
209267
}
268+
return utils.RequestHeaders(ctx, defaults, p.headers)
210269
}

0 commit comments

Comments
 (0)
0