From d4fa1553528e18cbdac802fa7f0cbb0cbd9eb01f Mon Sep 17 00:00:00 2001 From: Ismael Martinez Ramos Date: Mon, 7 Sep 2026 19:52:27 +0100 Subject: [PATCH] fix: send x-opencode-session header to OpenCode providers OpenCode asks clients to send a stable session ID per conversation, which it uses to optimise routing and prompt caching. A middleware derives the value from the session already on the request context, so multiplexed deployments get one ID per conversation rather than one per client. Fixes #4164 Co-Authored-By: Md Yunus Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qyjba1txhq3QsrpcGUt3FM --- docs/providers/opencode-go/index.md | 4 + docs/providers/opencode-zen/index.md | 4 + pkg/model/provider/openai/client.go | 6 + pkg/model/provider/openai/opencode.go | 66 +++++++ pkg/model/provider/openai/opencode_test.go | 204 +++++++++++++++++++++ 5 files changed, 284 insertions(+) create mode 100644 pkg/model/provider/openai/opencode.go create mode 100644 pkg/model/provider/openai/opencode_test.go diff --git a/docs/providers/opencode-go/index.md b/docs/providers/opencode-go/index.md index 69199c31f5..2e9c554df6 100644 --- a/docs/providers/opencode-go/index.md +++ b/docs/providers/opencode-go/index.md @@ -132,6 +132,10 @@ This means OpenCode Go uses the same client as OpenAI, making it fully compatibl For Anthropic-compatible models (MiniMax, Qwen), Docker Agent uses a custom provider pointing to the Anthropic client at `https://opencode.ai/zen/go` with the same token. +### Session Header + +OpenCode asks clients to send an `x-opencode-session` header carrying one stable ID per conversation, which it uses to optimise routing and prompt caching. Docker Agent sends it automatically on every request to `opencode.ai` (built-in aliases and custom providers alike), deriving an opaque value from the agent session so each conversation keeps the same ID, including in `serve api` / `serve chat` deployments that multiplex many conversations. To pin your own value, set `provider_opts.http_headers.x-opencode-session`. + ## Example: Code Assistant ```yaml diff --git a/docs/providers/opencode-zen/index.md b/docs/providers/opencode-zen/index.md index 4e4d3d0af6..704651fd98 100644 --- a/docs/providers/opencode-zen/index.md +++ b/docs/providers/opencode-zen/index.md @@ -204,6 +204,10 @@ The same API key works for both OpenCode Go and OpenCode Zen — they are part o For Anthropic-compatible models, Docker Agent uses a custom provider pointing to the Anthropic client at `https://opencode.ai/zen` with the same token. For Google models, a custom provider points to the Google client at `https://opencode.ai/zen` (the Google SDK appends its own `/v1beta/models/...` path segment). +### Session Header + +OpenCode asks clients to send an `x-opencode-session` header carrying one stable ID per conversation, which it uses to optimise routing and prompt caching. Docker Agent sends it automatically on every request to `opencode.ai` (built-in aliases and custom providers alike), deriving an opaque value from the agent session so each conversation keeps the same ID, including in `serve api` / `serve chat` deployments that multiplex many conversations. To pin your own value, set `provider_opts.http_headers.x-opencode-session`. + ### Differences from OpenCode Go | Aspect | OpenCode Zen | OpenCode Go | diff --git a/pkg/model/provider/openai/client.go b/pkg/model/provider/openai/client.go index dd64c8b9b1..9a774ce1e7 100644 --- a/pkg/model/provider/openai/client.go +++ b/pkg/model/provider/openai/client.go @@ -133,6 +133,12 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro // required Copilot-Integration-Id) and any provider-specific defaults. clientOptions = append(clientOptions, buildHeaderOptions(cfg)...) + // The session comes off the request context, so this cannot be a + // static header fixed at construction time. + if isOpenCodeProvider(cfg) { + clientOptions = append(clientOptions, option.WithMiddleware(opencodeSessionMiddleware())) + } + // Preserve full error details from non-OpenAI providers (e.g. GitHub // Copilot returns a bare "400 Bad Request" whose body explains the // actual cause); without this the SDK discards it. diff --git a/pkg/model/provider/openai/opencode.go b/pkg/model/provider/openai/opencode.go new file mode 100644 index 0000000000..f0d7e87f65 --- /dev/null +++ b/pkg/model/provider/openai/opencode.go @@ -0,0 +1,66 @@ +package openai + +import ( + "net/http" + "net/url" + "strings" + + "github.com/google/uuid" + "github.com/openai/openai-go/v3/option" + + "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/httpclient" +) + +// OpenCode asks clients to send one stable ID per conversation; it keys their +// routing and prompt caching. +const ( + opencodeSessionHeader = "x-opencode-session" + opencodeHost = "opencode.ai" +) + +// opencodeSessionNamespace salts the hash so the header cannot be mapped back +// to the local session ID or the gateway's X-Cagent-Session-Id. +var opencodeSessionNamespace = uuid.MustParse("6f0c2a1e-8d4b-4c7f-9a3e-2b5d7e9f1c03") + +// isOpenCodeProvider reports whether requests target OpenCode, either through +// the built-in aliases or a custom provider pointed at opencode.ai. +func isOpenCodeProvider(cfg *latest.ModelConfig) bool { + if cfg == nil { + return false + } + switch cfg.Provider { + case "opencode-go", "opencode-zen": + return true + } + u, err := url.Parse(cfg.BaseURL) + if err != nil { + return false + } + host := strings.ToLower(u.Hostname()) + return host == opencodeHost || strings.HasSuffix(host, "."+opencodeHost) +} + +// opencodeSessionID derives the header from the agent session, so multiplexed +// deployments get one ID per conversation rather than per client, and a +// resumed session keeps its ID across processes. +func opencodeSessionID(sessionID string) string { + return uuid.NewSHA1(opencodeSessionNamespace, []byte(sessionID)).String() +} + +// opencodeSessionMiddleware sets the header per request, leaving a user-pinned +// value alone. Requests with no session on the context share a per-client ID. +func opencodeSessionMiddleware() option.Middleware { + fallback := uuid.NewString() + + return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { + if req.Header.Get(opencodeSessionHeader) == "" { + id := fallback + if sid := httpclient.SessionIDFromContext(req.Context()); sid != "" { + id = opencodeSessionID(sid) + } + req.Header.Set(opencodeSessionHeader, id) + } + return next(req) + } +} diff --git a/pkg/model/provider/openai/opencode_test.go b/pkg/model/provider/openai/opencode_test.go new file mode 100644 index 0000000000..bbd308685a --- /dev/null +++ b/pkg/model/provider/openai/opencode_test.go @@ -0,0 +1,204 @@ +package openai + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/chat" + "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/environment" + "github.com/docker/docker-agent/pkg/httpclient" +) + +func TestIsOpenCodeProvider(t *testing.T) { + t.Parallel() + tests := []struct { + name string + cfg *latest.ModelConfig + want bool + }{ + {name: "nil config", cfg: nil, want: false}, + {name: "opencode-go alias", cfg: &latest.ModelConfig{Provider: "opencode-go"}, want: true}, + {name: "opencode-zen alias", cfg: &latest.ModelConfig{Provider: "opencode-zen"}, want: true}, + { + name: "custom provider on opencode.ai", + cfg: &latest.ModelConfig{Provider: "custom", BaseURL: "https://opencode.ai/zen/go/v1"}, + want: true, + }, + { + name: "custom provider on opencode.ai subdomain", + cfg: &latest.ModelConfig{Provider: "custom", BaseURL: "https://eu.opencode.ai/zen/v1"}, + want: true, + }, + { + name: "lookalike host is not opencode", + cfg: &latest.ModelConfig{Provider: "custom", BaseURL: "https://notopencode.ai/v1"}, + want: false, + }, + { + name: "opencode.ai in path only", + cfg: &latest.ModelConfig{Provider: "custom", BaseURL: "https://evil.example/opencode.ai/v1"}, + want: false, + }, + {name: "openai", cfg: &latest.ModelConfig{Provider: "openai"}, want: false}, + {name: "github-copilot", cfg: &latest.ModelConfig{Provider: "github-copilot", BaseURL: "https://api.githubcopilot.com"}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, isOpenCodeProvider(tt.cfg)) + }) + } +} + +func TestOpenCodeSessionIDIsStableAndOpaque(t *testing.T) { + t.Parallel() + a := opencodeSessionID("session-a") + b := opencodeSessionID("session-b") + + assert.Equal(t, a, opencodeSessionID("session-a"), "same session must map to the same header value") + assert.NotEqual(t, a, b, "different sessions must map to different header values") + assert.NotEqual(t, "session-a", a, "raw session ID must not leak") + _, err := uuid.Parse(a) + require.NoError(t, err, "header value must be a UUID") +} + +// startOpenCodeCapture returns a fake OpenAI-compatible endpoint that records +// the x-opencode-session header of every request. +func startOpenCodeCapture(t *testing.T) (*httptest.Server, func() []string) { + t.Helper() + var mu sync.Mutex + var seen []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + seen = append(seen, r.Header.Get(opencodeSessionHeader)) + mu.Unlock() + writeSSEResponse(w) + })) + t.Cleanup(server.Close) + return server, func() []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), seen...) + } +} + +func newOpenCodeTestClient(t *testing.T, cfg *latest.ModelConfig) *Client { + t.Helper() + env := environment.NewMapEnvProvider(map[string]string{"OPENCODE_API_KEY": "test-key"}) + client, err := NewClient(t.Context(), cfg, env) + require.NoError(t, err) + return client +} + +func streamOnce(t *testing.T, client *Client, ctx context.Context) { + t.Helper() + stream, err := client.CreateChatCompletionStream(ctx, []chat.Message{ + {Role: chat.MessageRoleUser, Content: "hello"}, + }, nil) + require.NoError(t, err) + defer stream.Close() + for { + if _, err := stream.Recv(); err != nil { + break + } + } +} + +func TestOpenCodeSessionHeaderDerivedFromContext(t *testing.T) { + server, seen := startOpenCodeCapture(t) + client := newOpenCodeTestClient(t, &latest.ModelConfig{ + Provider: "opencode-go", + Model: "deepseek-v4-flash", + BaseURL: server.URL, + TokenKey: "OPENCODE_API_KEY", + ProviderOpts: map[string]any{ + "api_type": "openai_chatcompletions", + }, + }) + + ctxA := httpclient.ContextWithSessionID(t.Context(), "conversation-a") + ctxB := httpclient.ContextWithSessionID(t.Context(), "conversation-b") + streamOnce(t, client, ctxA) + streamOnce(t, client, ctxA) + streamOnce(t, client, ctxB) + + got := seen() + require.Len(t, got, 3) + assert.Equal(t, opencodeSessionID("conversation-a"), got[0]) + assert.Equal(t, got[0], got[1], "one conversation must keep one stable ID across requests") + assert.Equal(t, opencodeSessionID("conversation-b"), got[2]) + assert.NotEqual(t, got[0], got[2], "distinct conversations on a shared client must not share an ID") +} + +func TestOpenCodeSessionHeaderFallsBackWithoutSession(t *testing.T) { + server, seen := startOpenCodeCapture(t) + client := newOpenCodeTestClient(t, &latest.ModelConfig{ + Provider: "opencode-zen", + Model: "gpt-5", + BaseURL: server.URL, + TokenKey: "OPENCODE_API_KEY", + ProviderOpts: map[string]any{ + "api_type": "openai_chatcompletions", + }, + }) + + streamOnce(t, client, t.Context()) + streamOnce(t, client, t.Context()) + + got := seen() + require.Len(t, got, 2) + require.NotEmpty(t, got[0], "header must still be sent when no session is on the context") + _, err := uuid.Parse(got[0]) + require.NoError(t, err) + assert.Equal(t, got[0], got[1], "fallback ID must be stable for the client's lifetime") +} + +func TestOpenCodeSessionHeaderUserOverrideWins(t *testing.T) { + server, seen := startOpenCodeCapture(t) + client := newOpenCodeTestClient(t, &latest.ModelConfig{ + Provider: "opencode-go", + Model: "deepseek-v4-flash", + BaseURL: server.URL, + TokenKey: "OPENCODE_API_KEY", + ProviderOpts: map[string]any{ + "api_type": "openai_chatcompletions", + "http_headers": map[string]any{ + "X-OpenCode-Session": "pinned-by-user", + }, + }, + }) + + streamOnce(t, client, httpclient.ContextWithSessionID(t.Context(), "conversation-a")) + + got := seen() + require.Len(t, got, 1) + assert.Equal(t, "pinned-by-user", got[0]) +} + +func TestOpenCodeSessionHeaderNotSentToOtherProviders(t *testing.T) { + server, seen := startOpenCodeCapture(t) + env := environment.NewMapEnvProvider(map[string]string{"OPENAI_API_KEY": "test-key"}) + client, err := NewClient(t.Context(), &latest.ModelConfig{ + Provider: "openai", + Model: "gpt-4o", + BaseURL: server.URL, + ProviderOpts: map[string]any{ + "api_type": "openai_chatcompletions", + }, + }, env) + require.NoError(t, err) + + streamOnce(t, client, httpclient.ContextWithSessionID(t.Context(), "conversation-a")) + + got := seen() + require.Len(t, got, 1) + assert.Empty(t, got[0], "session identifiers must not leak to unrelated providers") +}