Skip to content
Closed
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
4 changes: 4 additions & 0 deletions docs/providers/opencode-go/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 requires an `x-opencode-session` header carrying one stable ID per conversation; it is the key used for prompt-cache routing, and requests without it may be rejected. 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
Expand Down
4 changes: 4 additions & 0 deletions docs/providers/opencode-zen/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 requires an `x-opencode-session` header carrying one stable ID per conversation; it is the key used for prompt-cache routing, and requests without it may be rejected. 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 |
Expand Down
7 changes: 7 additions & 0 deletions pkg/model/provider/openai/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,13 @@ 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)...)

// OpenCode requires a per-conversation x-opencode-session header; the
// value comes from the request context, so it must be a middleware
// rather than 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.
Expand Down
72 changes: 72 additions & 0 deletions pkg/model/provider/openai/opencode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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 Go/Zen require an x-opencode-session header carrying one stable ID
// per conversation; it is the key their edge uses for prompt-cache routing and
// requests without it may be rejected. See
// https://github.com/docker/docker-agent/issues/4164
const (
opencodeSessionHeader = "x-opencode-session"
opencodeHost = "opencode.ai"
)

// opencodeSessionNamespace salts the session hash so the header value cannot
// be mapped back to the local session store ID or to the X-Cagent-Session-Id
// sent to the Docker gateway.
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 value from the docker-agent session so
// every request of one conversation shares it, including across processes
// when a persisted session is resumed. Multiplexed deployments (serve api,
// serve chat) therefore get one ID per conversation rather than per client.
func opencodeSessionID(sessionID string) string {
return uuid.NewSHA1(opencodeSessionNamespace, []byte(sessionID)).String()
}

// opencodeSessionMiddleware sets x-opencode-session on every request unless
// the user pinned one via provider_opts.http_headers. Requests without a
// session on the context (embeddings, one-off calls) share a per-client
// fallback ID so they still satisfy the requirement.
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)
}
}
204 changes: 204 additions & 0 deletions pkg/model/provider/openai/opencode_test.go
Original file line number Diff line number Diff line change
@@ -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")
}