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:
6 files changed
+142
No files matched your search
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user