From 42ed9b0180880001229d25f013b232869e40c13d Mon Sep 17 00:00:00 2001 From: sfhgknsdfksdlf Date: Wed, 14 Oct 2026 23:34:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=BB=98=E8=AE=A4=E4=BC=AA=E9=80=A0=20?= =?UTF-8?q?OpenCode=20=E5=8E=9F=E7=94=9F=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=EF=BC=8CUA=20=E4=B8=8E=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E5=A4=B4=E5=8F=AF=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - opencode_go/opencode_zen 新增 HeadersConfig:user_agent 与任意请求头注入,配置优先 - 默认 UA 改为 opencode/1.19.0(最新版),通过上游版本门控 - 默认注入 x-opencode-client/project/request 三个身份头,session 头不变 - Docker 构建器升至 golang:1.26,新增推送 ghcr 的 workflow - 补充默认指纹与配置覆盖的测试 --- .github/workflows/ghcr-build.yml | 46 ++++++++++ Dockerfile | 2 +- configs/config.example.json | 8 +- internal/config/config.go | 20 +++++ internal/provider/opencode_go.go | 10 ++- internal/provider/opencode_useragent_test.go | 93 ++++++++++++++++++++ internal/provider/opencode_zen.go | 16 ++-- internal/provider/provider.go | 55 ++++++++++++ 8 files changed, 239 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/ghcr-build.yml diff --git a/.github/workflows/ghcr-build.yml b/.github/workflows/ghcr-build.yml new file mode 100644 index 00000000..0c4f0ce5 --- /dev/null +++ b/.github/workflows/ghcr-build.yml @@ -0,0 +1,46 @@ +name: Build and Push to GHCR + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build: + name: Build and Push Image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 + + - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + id: meta + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=raw,value=latest + type=sha,format=short + + - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: VERSION=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 3c3b580f..fafaa96d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25-alpine AS builder +FROM golang:1.26-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download diff --git a/configs/config.example.json b/configs/config.example.json index 8e9a0dc3..84cde27b 100644 --- a/configs/config.example.json +++ b/configs/config.example.json @@ -358,7 +358,9 @@ "api_key": "", "api_keys": [], "timeout_ms": 300000, - "streaming_timeout_ms": 600000 + "streaming_timeout_ms": 600000, + "user_agent": "opencode/1.19.0", + "headers": {} }, "openrouter": { @@ -396,7 +398,9 @@ "api_key": "", "api_keys": [], "timeout_ms": 300000, - "streaming_timeout_ms": 600000 + "streaming_timeout_ms": 600000, + "user_agent": "opencode/1.19.0", + "headers": {} }, "logging": { diff --git a/internal/config/config.go b/internal/config/config.go index a3d6c9ed..bd55c38c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -135,6 +135,24 @@ func (c *AWSBedrockConfig) EffectiveAPIKeys() []string { return nil } +// HeadersConfig allows overriding the outgoing User-Agent and injecting +// arbitrary request headers on upstream calls. Every field is optional; when +// unset the built-in defaults are preserved. +// +// Example (config.json): +// +// "opencode_go": { +// "user_agent": "opencode/1.19.0", +// "headers": { +// "x-opencode-client": "desktop", +// "x-opencode-project": "global" +// } +// } +type HeadersConfig struct { + UserAgent string `json:"user_agent,omitempty"` + Headers map[string]string `json:"headers,omitempty"` +} + // OpenCodeGoConfig holds the upstream OpenCode Go API settings. type OpenCodeGoConfig struct { BaseURL string `json:"base_url"` @@ -145,6 +163,7 @@ type OpenCodeGoConfig struct { TimeoutMs int `json:"timeout_ms"` StreamTimeoutMs int `json:"stream_timeout_ms"` StreamingTimeoutMs int `json:"streaming_timeout_ms,omitempty"` + HeadersConfig } // EffectiveAPIKeys returns the pool of API keys for OpenCode Go. The APIKeys @@ -194,6 +213,7 @@ type OpenCodeZenConfig struct { TimeoutMs int `json:"timeout_ms"` StreamTimeoutMs int `json:"stream_timeout_ms"` StreamingTimeoutMs int `json:"streaming_timeout_ms,omitempty"` + HeadersConfig } // EffectiveAPIKeys returns the pool of API keys for OpenCode Zen. The APIKeys diff --git a/internal/provider/opencode_go.go b/internal/provider/opencode_go.go index ae48e7e7..9a97b8b6 100644 --- a/internal/provider/opencode_go.go +++ b/internal/provider/opencode_go.go @@ -255,7 +255,8 @@ func (p *OpenCodeGoProvider) executeAnthropic(ctx context.Context, req *core.Nor } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+apiKey) - httpReq.Header.Set("User-Agent", upstreamUserAgent) + setOpenCodeFingerprint(httpReq.Header) + applyHeaders(httpReq, cfg.OpenCodeGo.HeadersConfig, upstreamUserAgent) httpReq.Header.Set("x-api-key", apiKey) core.SetOpenCodeSessionHeader(httpReq.Header, ctx) @@ -300,7 +301,8 @@ func (p *OpenCodeGoProvider) streamAnthropic(ctx context.Context, req *core.Norm } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+apiKey) - httpReq.Header.Set("User-Agent", upstreamUserAgent) + setOpenCodeFingerprint(httpReq.Header) + applyHeaders(httpReq, cfg.OpenCodeGo.HeadersConfig, upstreamUserAgent) httpReq.Header.Set("x-api-key", apiKey) httpReq.Header.Set("Accept", "text/event-stream") core.SetOpenCodeSessionHeader(httpReq.Header, ctx) @@ -322,6 +324,7 @@ func (p *OpenCodeGoProvider) streamAnthropic(ctx context.Context, req *core.Norm // ── HTTP helpers ────────────────────────────────────────────────────── func (p *OpenCodeGoProvider) doRequest(ctx context.Context, endpoint, apiKey string, req any, stream bool) (*http.Response, error) { + cfg := p.atomic.Get() body, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) @@ -333,7 +336,8 @@ func (p *OpenCodeGoProvider) doRequest(ctx context.Context, endpoint, apiKey str } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+apiKey) - httpReq.Header.Set("User-Agent", upstreamUserAgent) + setOpenCodeFingerprint(httpReq.Header) + applyHeaders(httpReq, cfg.OpenCodeGo.HeadersConfig, upstreamUserAgent) core.SetOpenCodeSessionHeader(httpReq.Header, ctx) if stream { httpReq.Header.Set("Accept", "text/event-stream") diff --git a/internal/provider/opencode_useragent_test.go b/internal/provider/opencode_useragent_test.go index 154bc0d0..7dce6e96 100644 --- a/internal/provider/opencode_useragent_test.go +++ b/internal/provider/opencode_useragent_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "regexp" "strings" "testing" @@ -262,3 +263,95 @@ func TestOpenCodeGoProvider_StreamAnthropic_OpencodeUserAgent(t *testing.T) { t.Error("Stream() returned empty body") } } + +func TestOpenCodeZenProvider_ConfigUserAgentAndHeadersOverride(t *testing.T) { + var gotUA string + var gotClient string + var gotProject string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUA = r.Header.Get("User-Agent") + gotClient = r.Header.Get("x-opencode-client") + gotProject = r.Header.Get("x-opencode-project") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(types.ChatCompletionResponse{ + ID: "cmpl-test", + Model: "test-model", + Choices: []types.Choice{ + {Index: 0, Message: types.ChatMessage{Role: "assistant", Content: json.RawMessage(`"hi"`)}, FinishReason: "stop"}, + }, + Usage: types.UsageInfo{PromptTokens: 1, CompletionTokens: 1, TotalTokens: 2}, + }) + })) + defer server.Close() + + cfg := &config.Config{ + APIKey: "test-key", + OpenCodeZen: config.OpenCodeZenConfig{ + BaseURL: server.URL, + HeadersConfig: config.HeadersConfig{ + UserAgent: "opencode/1.19.0", + Headers: map[string]string{ + "x-opencode-client": "desktop", + "x-opencode-project": "global", + }, + }, + }, + } + atomic := config.NewAtomicConfig(cfg, "") + p := NewOpenCodeZenProvider(atomic) + + req := &core.NormalizedRequest{Model: "deepseek-v4-flash-free", Messages: []core.NormalizedMessage{{Role: "user", Blocks: []core.NormalizedContentBlock{{Type: "text", Text: "Hi"}}}}} + model := config.ModelConfig{ModelID: "deepseek-v4-flash-free"} + if _, err := p.Execute(context.Background(), req, model); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + if gotUA != "opencode/1.19.0" { + t.Errorf("User-Agent = %q, want %q", gotUA, "opencode/1.19.0") + } + if gotClient != "desktop" { + t.Errorf("x-opencode-client = %q, want %q", gotClient, "desktop") + } + if gotProject != "global" { + t.Errorf("x-opencode-project = %q, want %q", gotProject, "global") + } +} + +func TestOpenCodeGoProvider_DefaultFingerprintHeaders(t *testing.T) { + var client, project, request string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + client = r.Header.Get("x-opencode-client") + project = r.Header.Get("x-opencode-project") + request = r.Header.Get("x-opencode-request") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(types.ChatCompletionResponse{ + ID: "cmpl-test", + Model: "test-model", + Choices: []types.Choice{ + {Index: 0, Message: types.ChatMessage{Role: "assistant", Content: json.RawMessage(`"hi"`)}, FinishReason: "stop"}, + }, + Usage: types.UsageInfo{PromptTokens: 1, CompletionTokens: 1, TotalTokens: 2}, + }) + })) + defer server.Close() + + cfg := &config.Config{APIKey: "test-key", OpenCodeGo: config.OpenCodeGoConfig{BaseURL: server.URL}} + atomic := config.NewAtomicConfig(cfg, "") + p := NewOpenCodeGoProvider(atomic) + + req := &core.NormalizedRequest{Model: "deepseek-v4-flash", Messages: []core.NormalizedMessage{{Role: "user", Blocks: []core.NormalizedContentBlock{{Type: "text", Text: "Hi"}}}}} + model := config.ModelConfig{ModelID: "deepseek-v4-flash"} + if _, err := p.Execute(context.Background(), req, model); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + if client != "desktop" { + t.Errorf("x-opencode-client = %q, want %q", client, "desktop") + } + if project != "global" { + t.Errorf("x-opencode-project = %q, want %q", project, "global") + } + if !regexp.MustCompile(`^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$`).MatchString(request) { + t.Errorf("x-opencode-request = %q, want msg_<12hex><14base62>", request) + } +} diff --git a/internal/provider/opencode_zen.go b/internal/provider/opencode_zen.go index 7475e1e4..794430c0 100644 --- a/internal/provider/opencode_zen.go +++ b/internal/provider/opencode_zen.go @@ -20,7 +20,7 @@ import ( // upstreamUserAgent mirrors the opencode client User-Agent so Zen free-tier // rate limiting treats routatic-proxy traffic like the native client. -const upstreamUserAgent = "opencode/routatic-proxy" +const upstreamUserAgent = "opencode/1.19.0" // OpenCodeZenProvider implements core.Provider for the OpenCode Zen backend. // Zen supports four wire formats determined by model ID: Anthropic (Claude, @@ -217,7 +217,8 @@ func (p *OpenCodeZenProvider) executeAnthropic(ctx context.Context, req *core.No } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+apiKey) - httpReq.Header.Set("User-Agent", upstreamUserAgent) + setOpenCodeFingerprint(httpReq.Header) + applyHeaders(httpReq, cfg.OpenCodeZen.HeadersConfig, upstreamUserAgent) httpReq.Header.Set("x-api-key", apiKey) start := time.Now() @@ -261,7 +262,8 @@ func (p *OpenCodeZenProvider) streamAnthropic(ctx context.Context, req *core.Nor } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+apiKey) - httpReq.Header.Set("User-Agent", upstreamUserAgent) + setOpenCodeFingerprint(httpReq.Header) + applyHeaders(httpReq, cfg.OpenCodeZen.HeadersConfig, upstreamUserAgent) httpReq.Header.Set("x-api-key", apiKey) httpReq.Header.Set("Accept", "text/event-stream") @@ -396,6 +398,7 @@ func (p *OpenCodeZenProvider) streamGemini(ctx context.Context, req *core.Normal // ── HTTP helpers ────────────────────────────────────────────────────── func (p *OpenCodeZenProvider) doRequest(ctx context.Context, endpoint, apiKey string, req any, stream bool) (*http.Response, error) { + cfg := p.atomic.Get() body, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) @@ -407,7 +410,8 @@ func (p *OpenCodeZenProvider) doRequest(ctx context.Context, endpoint, apiKey st } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+apiKey) - httpReq.Header.Set("User-Agent", upstreamUserAgent) + setOpenCodeFingerprint(httpReq.Header) + applyHeaders(httpReq, cfg.OpenCodeZen.HeadersConfig, upstreamUserAgent) if stream { httpReq.Header.Set("Accept", "text/event-stream") } @@ -427,6 +431,7 @@ func (p *OpenCodeZenProvider) doRequest(ctx context.Context, endpoint, apiKey st } func (p *OpenCodeZenProvider) doJSONRequest(ctx context.Context, endpoint, apiKey string, req any) (*http.Response, error) { + cfg := p.atomic.Get() body, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) @@ -438,7 +443,8 @@ func (p *OpenCodeZenProvider) doJSONRequest(ctx context.Context, endpoint, apiKe } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+apiKey) - httpReq.Header.Set("User-Agent", upstreamUserAgent) + setOpenCodeFingerprint(httpReq.Header) + applyHeaders(httpReq, cfg.OpenCodeZen.HeadersConfig, upstreamUserAgent) resp, err := p.httpClient.Do(httpReq) if err != nil { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 307e3e74..e0fbbd97 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -3,7 +3,10 @@ package provider import ( + "crypto/rand" + "encoding/hex" "net/http" + "strings" "sync/atomic" "time" @@ -48,6 +51,58 @@ func newBaseProvider(atomic *config.AtomicConfig) baseProvider { } } +// applyHeaders applies the configurable User-Agent override and any custom +// request headers to an outgoing upstream request. An empty UserAgent keeps +// the provider's default (defaultUserAgent); a nil/empty Headers map is a +// no-op. Custom headers are applied last so they can override UA and any +// previously set header. +func applyHeaders(httpReq *http.Request, hc config.HeadersConfig, defaultUserAgent string) { + ua := hc.UserAgent + if ua == "" { + ua = defaultUserAgent + } + httpReq.Header.Set("User-Agent", ua) + for name, value := range hc.Headers { + httpReq.Header.Set(name, value) + } +} + +const ( + openCodeClientHeader = "x-opencode-client" + openCodeProjectHeader = "x-opencode-project" + openCodeRequestHeader = "x-opencode-request" + openCodeClientValue = "desktop" + openCodeProjectValue = "global" + base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +) + +// setOpenCodeFingerprint stamps the identity headers the native OpenCode CLI +// sends, so upstream gating treats proxied traffic as a first-party client. +// A per-request x-opencode-request id is generated; config headers applied +// afterwards can override any of these. +func setOpenCodeFingerprint(h http.Header) { + h.Set(openCodeClientHeader, openCodeClientValue) + h.Set(openCodeProjectHeader, openCodeProjectValue) + h.Set(openCodeRequestHeader, newOpenCodeRequestID()) +} + +// newOpenCodeRequestID returns a "msg_" id matching the native client's +// shape: the prefix, 12 lowercase hex chars, then 14 base62 chars. +func newOpenCodeRequestID() string { + raw := make([]byte, 20) + if _, err := rand.Read(raw); err != nil { + return "msg_00000000000000000000000000" + } + var b strings.Builder + b.Grow(len("msg_") + 26) + b.WriteString("msg_") + b.WriteString(hex.EncodeToString(raw[:6])) + for _, x := range raw[6:] { + b.WriteByte(base62Alphabet[int(x)%len(base62Alphabet)]) + } + return b.String() +} + // nextAPIKey returns the next API key in round-robin order from the given pool. func (b *baseProvider) nextAPIKey(keys []string) string { if len(keys) == 0 {