provider: OpenCode Go and Zen presets, with the session header OpenCode requires (#2)

OpenCode's gateway turns away requests that do not name their conversation
in x-opencode-session ('Request is missing x-opencode-session and cannot be
routed efficiently'), and magpie never sent it. Requests to opencode.ai now
carry the agent's own session id (OpenCode's x-session-affinity, Pi's,
Codex's session_id, Claude Code's) or, when the agent sends none, an id
derived from the conversation's first user message, stable across turns;
the provider test sends one too. OpenCode's docs ask exactly this of
third-party clients, along with their own User-Agent, which magpie already
sends.
This commit is contained in:
yetone committed 2026-09-24 11:49:06 +08:00
1 parent 1786bc3c3d
commit 084480beec
6 files changed
+142

No files matched your search

+52
View File
@@ -4,6 +4,8 @@ import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -404,6 +406,9 @@ func (s *Server) forward(ctx context.Context, p provider.Provider, to provider.P
}
}
}
if p.IsOpenCode() {
req.Header.Set("x-opencode-session", conversationID(in, body))
}
if err := p.Sign(ctx, req, to, body); err != nil {
return nil, err
}
@@ -742,6 +747,53 @@ func developerAsSystem(body []byte) []byte {
return out
}
// sessionHeaders are where agents already name their conversation: OpenCode
// (to its own gateway, and to everyone else), Pi, Codex and Claude Code.
var sessionHeaders = []string{
"x-opencode-session", "x-session-affinity", "x-session-id",
"session_id", "session-id", "x-claude-code-session-id",
}
// conversationID is a stable id for the conversation a request belongs to.
// It is the agent's own session id when it sends one; otherwise it is derived
// from the conversation's first user message, which every later turn repeats.
func conversationID(in http.Header, body []byte) string {
for _, h := range sessionHeaders {
if v := strings.TrimSpace(in.Get(h)); v != "" {
return v
}
}
var m struct {
Messages []json.RawMessage `json:"messages"`
Input json.RawMessage `json:"input"`
}
// An undecodable body still gets an id: the hash of the whole body.
_ = json.Unmarshal(body, &m)
items := m.Messages
if len(items) == 0 && len(m.Input) > 0 && m.Input[0] == '[' {
// A malformed input array leaves items empty, and the whole body is hashed.
_ = json.Unmarshal(m.Input, &items)
}
first := []byte(m.Input)
if len(items) > 0 {
first = items[0]
}
for _, it := range items {
var r struct {
Role string `json:"role"`
}
if json.Unmarshal(it, &r) == nil && r.Role == "user" {
first = it
break
}
}
if len(first) == 0 {
first = body
}
sum := sha256.Sum256(first)
return "magpie-" + hex.EncodeToString(sum[:12])
}
// withFields sets top-level fields, keeping every other field as it was.
func withFields(body []byte, fields map[string]any) []byte {
dec := json.NewDecoder(bytes.NewReader(body))
+59
View File
@@ -2,10 +2,12 @@ package gateway
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
@@ -541,3 +543,60 @@ func TestCodexAccountUpstream(t *testing.T) {
t.Fatalf("relayed input not a list: %s", f.got)
}
}
func TestConversationID(t *testing.T) {
h := http.Header{}
h.Set("X-Session-Affinity", "ses_1")
if got := conversationID(h, []byte(`{}`)); got != "ses_1" {
t.Errorf("agent session header: %q", got)
}
turn1 := `{"messages":[{"role":"system","content":"s"},{"role":"user","content":"fix the bug"}]}`
turn2 := `{"messages":[{"role":"system","content":"s"},{"role":"user","content":"fix the bug"},{"role":"assistant","content":"done"},{"role":"user","content":"thanks"}]}`
other := `{"messages":[{"role":"system","content":"s"},{"role":"user","content":"write docs"}]}`
a, b, c := conversationID(http.Header{}, []byte(turn1)), conversationID(http.Header{}, []byte(turn2)), conversationID(http.Header{}, []byte(other))
if a != b || a == c || !strings.HasPrefix(a, "magpie-") {
t.Errorf("derived ids: %q %q %q", a, b, c)
}
r1 := conversationID(http.Header{}, []byte(`{"input":[{"role":"user","content":"hi"}]}`))
r2 := conversationID(http.Header{}, []byte(`{"input":[{"role":"user","content":"hi"},{"type":"function_call_output","output":"x"}]}`))
if r1 != r2 {
t.Errorf("responses ids: %q %q", r1, r2)
}
}
func TestOpenCodeGetsConversationSession(t *testing.T) {
f := &fake{t: t, ctype: "application/json", reply: `{"id":"c1","choices":[]}`}
up := setup(t, provider.Chat, f)
body := `{"model":"m1","messages":[{"role":"user","content":"hi"}]}`
if code, out := post(t, "/v1/chat/completions", body); code != 200 || f.head.Get("x-opencode-session") != "" {
t.Fatalf("other vendors get no OpenCode header: %d %s %v", code, out, f.head)
}
// The same upstream, reached under OpenCode's host name.
if err := provider.Save(provider.Provider{ID: "fake", Name: "Fake", Key: "k", Models: []string{"m1"}, Chat: "http://opencode.ai/zen/go/v1"}); err != nil {
t.Fatal(err)
}
s := New()
addr := strings.TrimPrefix(up.URL, "http://")
s.client = &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, addr)
}}}
send := func(h http.Header) string {
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body))
for k, v := range h {
req.Header[k] = v
}
s.Handler().ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("%d %s", rec.Code, rec.Body.String())
}
return f.head.Get("x-opencode-session")
}
if got := send(http.Header{"Session_id": {"codex-ses"}}); got != "codex-ses" {
t.Errorf("agent's session: %q", got)
}
if a, b := send(nil), send(nil); a == "" || a != b {
t.Errorf("derived session: %q %q", a, b)
}
}
+13
View File
@@ -26,3 +26,16 @@ func TestRejectsTemperatureFromFetchedList(t *testing.T) {
}
}
}
func TestIsOpenCode(t *testing.T) {
for base, want := range map[string]bool{
"https://opencode.ai/zen/go/v1": true,
"https://api.opencode.ai/v1": true,
"https://notopencode.ai/v1": false,
"https://api.deepseek.com/v1": false,
} {
if got := (Provider{Chat: base}).IsOpenCode(); got != want {
t.Errorf("%s: %v", base, got)
}
}
}
+7
View File
@@ -93,6 +93,13 @@ var presets = []PresetDef{
{ID: "openrouter", Name: "OpenRouter", Icon: "openrouter", Kind: KindRelay, Catalog: "openrouter",
Chat: "https://openrouter.ai/api/v1", Anthropic: "https://openrouter.ai/api",
Website: "https://openrouter.ai", KeysURL: "https://openrouter.ai/keys"},
{ID: "opencode-go", Name: "OpenCode Go", Icon: "opencode", Kind: KindRelay, Catalog: "opencode-go",
Chat: "https://opencode.ai/zen/go/v1", Responses: "https://opencode.ai/zen/go/v1", Anthropic: "https://opencode.ai/zen/go",
Note: "open coding models, $10/month",
Website: "https://opencode.ai/docs/go", KeysURL: "https://opencode.ai/auth"},
{ID: "opencode-zen", Name: "OpenCode Zen", Icon: "opencode", Kind: KindRelay, Catalog: "opencode",
Chat: "https://opencode.ai/zen/v1", Responses: "https://opencode.ai/zen/v1", Anthropic: "https://opencode.ai/zen",
Website: "https://opencode.ai/docs/zen", KeysURL: "https://opencode.ai/auth"},
{ID: "together", Name: "Together AI", Icon: "together-color", Kind: KindRelay, Catalog: "togetherai",
Chat: "https://api.together.xyz/v1",
Website: "https://api.together.ai", KeysURL: "https://api.together.ai/settings/api-keys"},
+8
View File
@@ -304,6 +304,14 @@ func (p Provider) Host() string {
return ""
}
// IsOpenCode reports whether the provider is OpenCode's gateway (Zen or Go),
// which routes and caches by conversation and turns away requests that do
// not name one in x-opencode-session.
func (p Provider) IsOpenCode() bool {
h := p.Host()
return h == "opencode.ai" || strings.HasSuffix(h, ".opencode.ai")
}
// HostOf pulls the host out of a URL, for display.
func HostOf(u string) string {
u = strings.TrimSpace(u)
+3
View File
@@ -80,6 +80,9 @@ func probe(ctx context.Context, p Provider, proto Protocol, url string, body []b
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("anthropic-version", "2023-06-01")
if p.IsOpenCode() {
req.Header.Set("x-opencode-session", "magpie-test-"+randomUUID())
}
if err := p.Sign(ctx, req, proto, body); err != nil {
r.Error = err.Error()
return r