Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.25.0
require (
github.com/fsnotify/fsnotify v1.10.1
github.com/getlantern/systray v1.2.2
github.com/google/uuid v1.6.0
github.com/pkoukk/tiktoken-go v0.1.8
github.com/spf13/cobra v1.10.2
golang.org/x/mod v0.40.0
Expand All @@ -22,7 +23,6 @@ require (
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
Expand Down
14 changes: 14 additions & 0 deletions internal/client/opencode.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,16 @@ func setOpenRouterHeaders(req *http.Request) {
req.Header.Set("X-OpenRouter-Categories", openRouterCategories)
}

// setOpenCodeSessionHeader forwards the OpenCode session ID from ctx to the
// upstream request, but only for OpenCode Go models. Zen, Bedrock, and
// OpenRouter do not receive it.
func setOpenCodeSessionHeader(h http.Header, ctx context.Context, modelConfig config.ModelConfig) {
if config.NormalizeProvider(modelConfig.Provider) != config.ProviderOpenCodeGo {
return
}
core.SetOpenCodeSessionHeader(h, ctx)
}

// EndpointType determines which Zen endpoint format to use.
type EndpointType int

Expand Down Expand Up @@ -473,6 +483,7 @@ func (c *OpenCodeClient) ChatCompletion(
if IsOpenRouter(modelConfig) {
setOpenRouterHeaders(httpReq)
}
setOpenCodeSessionHeader(httpReq.Header, ctx, modelConfig)

if req.Stream != nil && *req.Stream {
httpReq.Header.Set("Accept", "text/event-stream")
Expand Down Expand Up @@ -575,6 +586,7 @@ func (c *OpenCodeClient) SendAnthropicRequest(
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
httpReq.Header.Set("x-api-key", apiKey)
setOpenCodeSessionHeader(httpReq.Header, ctx, modelConfig)

if stream {
httpReq.Header.Set("Accept", "text/event-stream")
Expand Down Expand Up @@ -623,6 +635,7 @@ func (c *OpenCodeClient) ResponsesCompletion(
if IsOpenRouter(modelConfig) {
setOpenRouterHeaders(httpReq)
}
setOpenCodeSessionHeader(httpReq.Header, ctx, modelConfig)

resp, err := c.httpClient.Do(httpReq)
if err != nil {
Expand Down Expand Up @@ -723,6 +736,7 @@ func (c *OpenCodeClient) GeminiCompletion(
if IsOpenRouter(modelConfig) {
setOpenRouterHeaders(httpReq)
}
setOpenCodeSessionHeader(httpReq.Header, ctx, modelConfig)

resp, err := c.httpClient.Do(httpReq)
if err != nil {
Expand Down
68 changes: 68 additions & 0 deletions internal/client/opencode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"time"

"github.com/routatic/proxy/internal/config"
"github.com/routatic/proxy/internal/core"
"github.com/routatic/proxy/pkg/types"
)

Expand Down Expand Up @@ -1000,6 +1001,7 @@ func TestOpenRouterChatCompletion_UsesAttributionHeaders(t *testing.T) {
{name: "referer", header: "HTTP-Referer", want: "https://github.com/routatic/proxy"},
{name: "title", header: "X-OpenRouter-Title", want: "routatic-proxy"},
{name: "category", header: "X-OpenRouter-Categories", want: "cli-agent"},
{name: "no opencode session", header: "x-opencode-session", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down Expand Up @@ -1082,3 +1084,69 @@ func TestGetProviderAPIKeys_EmptyReturnsGlobal(t *testing.T) {
t.Errorf("getProviderAPIKeys() = %v, want %v (should fallback to global)", got, want)
}
}

func TestOpenCodeClient_SetsOpenCodeSessionHeader(t *testing.T) {
var gotSession string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotSession = r.Header.Get(core.OpenCodeSessionHeader)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"resp-1","object":"chat.completion","created":1,"model":"deepseek-v4-pro","choices":[],"usage":{}}`))
}))
defer ts.Close()

cfg := &config.Config{
APIKey: "test-key",
OpenCodeGo: config.OpenCodeGoConfig{
BaseURL: ts.URL,
},
}
atomicCfg := config.NewAtomicConfig(cfg, "")
c := NewOpenCodeClient(atomicCfg, nil)

model := config.ModelConfig{Provider: ProviderOpenCodeGo, ModelID: "deepseek-v4-pro"}
req := &types.ChatCompletionRequest{
Model: "deepseek-v4-pro",
Messages: []types.ChatMessage{{Role: "user", Content: json.RawMessage(`"hello"`)}},
}
ctx := core.WithSessionID(context.Background(), "client-session-1")
if _, err := c.ChatCompletionNonStreaming(ctx, "deepseek-v4-pro", req, model); err != nil {
t.Fatalf("ChatCompletionNonStreaming() error = %v", err)
}

if gotSession != "client-session-1" {
t.Errorf("%s = %q, want %q", core.OpenCodeSessionHeader, gotSession, "client-session-1")
}
}

func TestOpenCodeClient_ZenOmitsOpenCodeSessionHeader(t *testing.T) {
var gotSession string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotSession = r.Header.Get(core.OpenCodeSessionHeader)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"resp-1","object":"chat.completion","created":1,"model":"deepseek-v4-flash-free","choices":[],"usage":{}}`))
}))
defer ts.Close()

cfg := &config.Config{
APIKey: "test-key",
OpenCodeZen: config.OpenCodeZenConfig{
BaseURL: ts.URL,
},
}
atomicCfg := config.NewAtomicConfig(cfg, "")
c := NewOpenCodeClient(atomicCfg, nil)

model := config.ModelConfig{Provider: ProviderOpenCodeZen, ModelID: "deepseek-v4-flash-free"}
req := &types.ChatCompletionRequest{
Model: "deepseek-v4-flash-free",
Messages: []types.ChatMessage{{Role: "user", Content: json.RawMessage(`"hello"`)}},
}
ctx := core.WithSessionID(context.Background(), "client-session-1")
if _, err := c.ChatCompletionNonStreaming(ctx, "deepseek-v4-flash-free", req, model); err != nil {
t.Fatalf("ChatCompletionNonStreaming() error = %v", err)
}

if gotSession != "" {
t.Errorf("OpenCode Zen must not receive %s, got %q", core.OpenCodeSessionHeader, gotSession)
}
}
33 changes: 33 additions & 0 deletions internal/core/session.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package core

import (
"context"
"net/http"
)

// OpenCodeSessionHeader is the header sent to OpenCode Go upstreams identifying
// the conversation. Its value is the Claude Code conversation UUID verbatim.
const OpenCodeSessionHeader = "x-opencode-session"

type sessionIDKey struct{}

// WithSessionID returns a context carrying the OpenCode session ID.
func WithSessionID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, sessionIDKey{}, id)
}

// SessionIDFromContext returns the OpenCode session ID carried by ctx, or ""
// when the context has none.
func SessionIDFromContext(ctx context.Context) string {
id, _ := ctx.Value(sessionIDKey{}).(string)
return id
}

// SetOpenCodeSessionHeader sets the OpenCode session header on h when ctx
// carries a session ID. Uses Set (never Add) so a reused header map cannot
// accumulate values.
func SetOpenCodeSessionHeader(h http.Header, ctx context.Context) {
if id := SessionIDFromContext(ctx); id != "" {
h.Set(OpenCodeSessionHeader, id)
}
}
18 changes: 18 additions & 0 deletions internal/handlers/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import (
"sync/atomic"
"time"

"github.com/google/uuid"

"github.com/routatic/proxy/internal/client"
"github.com/routatic/proxy/internal/config"
"github.com/routatic/proxy/internal/core"
Expand Down Expand Up @@ -265,6 +267,10 @@ func (w *responseWriter) Flush() {
const (
defaultKeepaliveInterval = 3 * time.Second
keepaliveWriteTimeout = 5 * time.Second

// claudeCodeSessionHeader carries the Claude Code conversation UUID. Its
// value is forwarded verbatim to OpenCode Go as x-opencode-session.
claudeCodeSessionHeader = "x-claude-code-session-id"
)

// WriteKeepalive writes a keepalive comment frame (":keepalive\n\n") to the
Expand Down Expand Up @@ -390,6 +396,18 @@ func (h *MessagesHandler) HandleMessages(w http.ResponseWriter, r *http.Request)
}
w.Header().Set("X-Request-ID", requestID)

// Resolve the OpenCode session ID from the Claude Code conversation header
// before any dispatch: this context reaches every provider call and every
// fallback attempt. Header.Get returns the first value when duplicates are
// present; the first value wins. Clients that do not send the header (curl,
// older Claude Code) get a per-request UUID so the header is always present
// on OpenCode Go.
sessionID := r.Header.Get(claudeCodeSessionHeader)
if sessionID == "" {
sessionID = uuid.NewString()
}
r = r.WithContext(core.WithSessionID(r.Context(), sessionID))

// Rate limiting
clientIP := middleware.GetClientIP(r)
if !h.rateLimiter.Allow(clientIP) {
Expand Down
Loading
Loading