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
46 changes: 46 additions & 0 deletions .github/workflows/ghcr-build.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 6 additions & 2 deletions configs/config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
20 changes: 20 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions internal/provider/opencode_go.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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")
Expand Down
93 changes: 93 additions & 0 deletions internal/provider/opencode_useragent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"

Expand Down Expand Up @@ -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)
}
}
16 changes: 11 additions & 5 deletions internal/provider/opencode_zen.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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)
Expand All @@ -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")
}
Expand All @@ -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)
Expand All @@ -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 {
Expand Down
55 changes: 55 additions & 0 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
package provider

import (
"crypto/rand"
"encoding/hex"
"net/http"
"strings"
"sync/atomic"
"time"

Expand Down Expand Up @@ -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 {
Expand Down