diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 369484ea..2a04569f 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -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)) diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 8265fc63..23218515 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -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) + } +} diff --git a/internal/provider/models_test.go b/internal/provider/models_test.go index 1daa580c..463fad8d 100644 --- a/internal/provider/models_test.go +++ b/internal/provider/models_test.go @@ -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) + } + } +} diff --git a/internal/provider/presets.go b/internal/provider/presets.go index d7a79b86..93979af2 100644 --- a/internal/provider/presets.go +++ b/internal/provider/presets.go @@ -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"}, diff --git a/internal/provider/provider.go b/internal/provider/provider.go index e95f9918..5aefb913 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -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) diff --git a/internal/provider/test.go b/internal/provider/test.go index 63fae71f..d692b75b 100644 --- a/internal/provider/test.go +++ b/internal/provider/test.go @@ -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