From 0a4289cafce5797809afa6ea58883b8d7eea234c Mon Sep 17 00:00:00 2001 From: renato97 Date: Thu, 25 Jun 2026 11:49:11 -0300 Subject: [PATCH] Initial commit: OpenCode Zen Proxy Proxy translating OpenAI/Anthropic Messages API to OpenCode Zen's free models. Pure stdlib Go 1.22, no external dependencies. - OpenAI Chat Completions (/v1/chat/completions) with streaming - Anthropic Messages API (/v1/messages) with streaming - Model aliases for 5 free models - Auth middleware support - Session rotation (30min per user key) --- .gitignore | 6 + AGENTS.md | 72 +++ README.md | 169 +++++++ cmd/mcp-vision/main.go | 269 +++++++++++ cmd/vision/main.go | 143 ++++++ go.mod | 3 + internal/proxy/anthropic.go | 862 ++++++++++++++++++++++++++++++++++++ internal/proxy/models.go | 194 ++++++++ internal/proxy/proxy.go | 315 +++++++++++++ internal/server/server.go | 308 +++++++++++++ main.go | 55 +++ 11 files changed, 2396 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 cmd/mcp-vision/main.go create mode 100644 cmd/vision/main.go create mode 100644 go.mod create mode 100644 internal/proxy/anthropic.go create mode 100644 internal/proxy/models.go create mode 100644 internal/proxy/proxy.go create mode 100644 internal/server/server.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cec9d65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.exe +*.exe~ +opencode-proxy +deploy/ +vendor/ +*.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d02c5cb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,72 @@ +# OpenCode Zen Proxy + +Proxy translating OpenAI and Anthropic Messages API requests to OpenCode Zen's free model API. + +## Build & Run + +```bash +go build -o opencode-proxy . +./opencode-proxy # no auth +./opencode-proxy -api-key "secret" # with auth +``` + +Auth reads `OPENCODE_PROXY_KEY` env var; `-api-key` flag takes precedence. +Defaults: `127.0.0.1:6446`. Use `-host 0.0.0.0` for external access. + +## Architecture + +``` +Client → internal/server/ (HTTP, auth, routing) → internal/proxy/ (model resolve, session mgmt, forwarding, Anthropic↔OpenAI conversion) → opencode.ai/zen/v1 +``` + +- **Pure stdlib Go 1.22** — no router, no deps beyond stdlib +- **Entrypoint:** `main.go` wires `proxy.NewProxy(key)` into `server.NewServer(p)` +- **Auth middleware** in `server.go:88` checks `Authorization: Bearer` or `x-api-key` header +- **Model aliases** in `internal/proxy/models.go:59` — short names, company names, and Claude model names → full IDs +- **Session rotation:** session IDs rotate every 30 min per user key (`proxy.SessionID()` at `models.go:154`) +- **Pass-through:** unknown model names are forwarded as-is, not rejected + +## Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/health` | Health check (returns hardcoded `"1.0.0"`) | +| `GET` | `/v1/models` | List available free models (auth required if key set) | +| `GET` | `/v1/models/{id}` | Single model detail | +| `POST` | `/v1/chat/completions` | OpenAI Chat Completions (streaming + non-streaming) | +| `POST` | `/v1/messages` | Anthropic Messages API | +| `POST` | `/v1/v1/messages` | Same as above — workaround for Claude Code double-path bug | + +## Anthropic Messages API + +Fully implemented in `internal/proxy/anthropic.go`. Converts Anthropic → OpenAI format upstream, then converts responses back to Anthropic SSE (streaming) or JSON (non-streaming). + +**Claude Code quirk:** Claude Code appends `/v1/messages` to `ANTHROPIC_BASE_URL` automatically. Set `ANTHROPIC_BASE_URL` to `http://127.0.0.1:6446` (without `/v1` suffix) to avoid double-path. The proxy also handles `/v1/v1/messages` as a fallback (`server.go:62`). + +**Claude model name aliases** are defined in `models.go:77-88` — Claude Code validates model names client-side, so you must set `ANTHROPIC_MODEL` to a valid Claude name like `claude-sonnet-4-6` (maps to `deepseek-v4-flash-free`). + +## Available Models (from `models.go`) + +| Model ID | Aliases | Context | Max Output | +|----------|---------|---------|------------| +| `deepseek-v4-flash-free` | `deepseek`, `deepseek-v4`, `ds` | 1M | 384K | +| `big-pickle` | `pickle` | 200K | 32K | +| `mimo-v2.5-free` | `mimo`, `mimo-v2.5`, `xiaomi` | 1M | 32K | +| `north-mini-code-free` | `north`, `north-mini`, `cohere` | 256K | 64K | +| `nemotron-3-ultra-free` | `nemotron`, `nemotron-3`, `nvidia` | 1M | 16K | + +**Source of truth is `models.go`, not `README.md`.** The README may list models that differ from the code (e.g. `minimax-m2.5-free`, `kimi-k2.5-free`, `gpt-5-nano`, `nemotron-3-super-free`, `qwen3.6-plus-free` are NOT in the code). The code controls what works. + +## Quirks & Gotchas + +- **No tests exist.** Any change is untested unless you add them. +- **No CI/CD, no Makefile, no lint config.** All manual. +- **In-memory only.** Session state dies on restart. No database. +- **`deploy/start-proxy.bat`** and **`deploy/CREDENCIALES.md`** contain a hardcoded API key — do not commit them. +- **`opencode-proxy.exe~`** in root is a backup artifact; ignore. +- Upstream Zen API expects `x-opencode-request`, `x-opencode-session`, `x-opencode-client`, `x-opencode-project` headers (set in `proxy.go:107`). +- **Server logs request bodies to stdout** (truncated to 500 chars in `server.go:265-271`). Verbose by design. +- **Health endpoint** returns hardcoded version `"1.0.0"` in `server.go:126`. Update both `main.go:12` and `server.go:126` when bumping. +- **Upstream timeout:** `proxy.go:167` sets 10-minute HTTP client timeout — adjust if Zen models are slow on first call. +- **Unknown models are forwarded as-is** (`models.go:140`) — useful when new models appear upstream before code is updated. +- **Reasoning models** (DeepSeek, Nemotron) need `max_tokens` ≥ 500 — first tokens go to reasoning, not visible content. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f5acdb1 --- /dev/null +++ b/README.md @@ -0,0 +1,169 @@ +# OpenCode Zen Proxy + +OpenAI-compatible proxy for [OpenCode](https://opencode.ai)'s free Zen models. Use DeepSeek V4 Flash, MiniMax M2.5, Kimi K2.5, and more — for free — in any OpenAI-compatible tool (Cursor, Claude Code, Continue, etc.). + +## How it works + +``` +Your tool → opencode-proxy (localhost:6446) → opencode.ai/zen/v1 +``` + +The proxy injects the required `x-opencode-*` headers that OpenCode Zen's free API expects. Your tools talk standard OpenAI protocol to the proxy; the proxy handles the rest. + +## Quick Start + +```bash +# Build +go build -o opencode-proxy . + +# Run (no auth) +./opencode-proxy + +# Run with API key protection +./opencode-proxy -api-key "your-secret-key" + +# Or via env var +export OPENCODE_PROXY_KEY="your-secret-key" +./opencode-proxy +``` + +The server starts on `http://127.0.0.1:6446`. + +## CLI Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `-port` | `6446` | Server port | +| `-host` | `127.0.0.1` | Server host | +| `-api-key` | (none) | API key to protect the proxy | +| `-version` | — | Show version and exit | + +Also reads `OPENCODE_PROXY_KEY` environment variable if `-api-key` is not set. + +## Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/health` | Health check | +| `GET` | `/v1/models` | List available free models | +| `POST` | `/v1/chat/completions` | Chat completions (streaming + non-streaming) | + +### Authentication + +If `-api-key` is set, all requests require one of: +- `Authorization: Bearer ` +- `x-api-key: ` + +## Available Models + +| Model ID | Alias | Notes | +|----------|-------|-------| +| `deepseek-v4-flash-free` | `deepseek`, `deepseek-v4` | Solid, recommended | +| `big-pickle` | `pickle` | Stealth model (= DeepSeek V4 Flash) | +| `minimax-m2.5-free` | `minimax`, `m2.5` | Strong coding model | +| `kimi-k2.5-free` | `kimi`, `k2.5` | Best free model | +| `gpt-5-nano` | `nano`, `gpt5` | OpenAI-powered free | +| `nemotron-3-super-free` | `nemotron` | Hit or miss | +| `qwen3.6-plus-free` | `qwen` | Intermittent | + +All support streaming, tool calls, and system messages. + +## Tool Configuration + +### Cursor / Continue / Cline + +- **Base URL**: `http://127.0.0.1:6446/v1` +- **API Key**: your proxy key (or `public` if no auth) +- **Model**: `deepseek-v4-flash-free` (or any alias) + +### Claude Code + +Claude Code uses the Anthropic Messages API natively. For now, use an OpenAI-compatible bridge or configure via: + +```bash +# In Claude Code, use as custom provider +claude config set provider_base_url http://127.0.0.1:6446/v1 +``` + +### OpenCode CLI + +Add to `~/.config/opencode/opencode.json`: + +```json +{ + "provider": { + "free": { + "name": "free", + "type": "openai", + "apiKey": "public", + "baseURL": "http://127.0.0.1:6446/v1", + "models": { + "free/deepseek": { + "id": "deepseek-v4-flash-free", + "name": "free/deepseek" + } + } + } + } +} +``` + +### Any OpenAI SDK + +```python +from openai import OpenAI + +client = OpenAI( + base_url="http://127.0.0.1:6446/v1", + api_key="your-proxy-key" # or "public" if no auth +) + +response = client.chat.completions.create( + model="deepseek-v4-flash-free", + messages=[{"role": "user", "content": "Hello!"}] +) +``` + +## Deploy on VPS + +```bash +# Build for Linux +GOOS=linux GOARCH=amd64 go build -o opencode-proxy . + +# Copy and run +scp opencode-proxy user@vps:/home/user/ +ssh user@vps './opencode-proxy -api-key "secure-key" -host 0.0.0.0' + +# Or use systemd (create /etc/systemd/system/opencode-proxy.service) +``` + +systemd unit: + +```ini +[Unit] +Description=OpenCode Zen Proxy +After=network.target + +[Service] +ExecStart=/home/user/opencode-proxy -api-key "${PROXY_KEY}" -host 0.0.0.0 +Restart=always +User=user +EnvironmentFile=/etc/opencode-proxy.env + +[Install] +WantedBy=multi-user.target +``` + +## Local SSH tunnel + +If you don't want to expose the port publicly: + +```bash +ssh -L 6446:127.0.0.1:6446 user@your-vps +``` + +Then point your tools at `http://127.0.0.1:6446/v1`. + +## License + +MIT diff --git a/cmd/mcp-vision/main.go b/cmd/mcp-vision/main.go new file mode 100644 index 0000000..200207c --- /dev/null +++ b/cmd/mcp-vision/main.go @@ -0,0 +1,269 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +const ( + defaultAPIKey = "opx-57ffcb013651f7acae558cd9ab094dbd719fdec980a6ad53" + proxyBaseURL = "http://127.0.0.1:6446/v1" +) + +func main() { + debug := os.Getenv("MCP_DEBUG") != "" + logf := func(f string, a ...any) { + if debug { + fmt.Fprintf(os.Stderr, f+"\n", a...) + } + } + + reader := bufio.NewReader(os.Stdin) + buf := make([]byte, 0, 64*1024) + + for { + var msg map[string]any + + first, err := reader.ReadString('\n') + if err != nil { + logf("stdin EOF: %v", err) + return + } + logf("RAW stdin first line: %q", first) + + if strings.HasPrefix(first, "Content-Length: ") { + var contentLen int + fmt.Sscanf(first, "Content-Length: %d", &contentLen) + logf("mode=Content-Length len=%d", contentLen) + + emptyLine, err := reader.ReadString('\n') + if err != nil { + logf("err reading empty line: %v", err) + return + } + logf("empty line: %q", emptyLine) + + buf = buf[:contentLen] + if _, err := io.ReadFull(reader, buf); err != nil { + logf("err reading body: %v", err) + return + } + logf("body: %s", string(buf)) + json.Unmarshal(buf, &msg) + } else if len(first) > 0 && first[0] == '{' { + // Raw JSON line mode (like engram) + logf("mode=rawJSON") + json.Unmarshal([]byte(strings.TrimRight(first, "\r\n")), &msg) + } else { + logf("unrecognized input, skipping: %q", first) + continue + } + + if msg == nil { + logf("msg is nil, skipping") + continue + } + + method, _ := msg["method"].(string) + id, _ := msg["id"] + + switch method { + case "initialize": + respond(id, map[string]any{ + "protocolVersion": "2025-03-26", + "capabilities": map[string]any{ + "tools": map[string]any{}, + }, + "serverInfo": map[string]any{ + "name": "mcp-vision", + "version": "1.0.0", + }, + }) + + case "tools/list": + respond(id, map[string]any{ + "tools": []map[string]any{ + { + "name": "read_image", + "description": "Describe an image file using AI vision. Sends only the image + prompt, no conversation context.", + "inputSchema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Absolute path to the image file (jpg, png, webp)", + }, + "prompt": map[string]any{ + "type": "string", + "description": "Optional custom question about the image", + }, + }, + "required": []string{"path"}, + }, + }, + }, + }) + + case "tools/call": + params, _ := msg["params"].(map[string]any) + name, _ := params["name"].(string) + args, _ := params["arguments"].(map[string]any) + + if name != "read_image" { + respondError(id, -32601, fmt.Sprintf("Unknown tool: %s", name)) + continue + } + + path, _ := args["path"].(string) + prompt, _ := args["prompt"].(string) + if prompt == "" { + prompt = "Describe esta imagen en detalle." + } + + result, err := readImage(path, prompt) + if err != nil { + respondError(id, -32000, err.Error()) + continue + } + + respond(id, map[string]any{ + "content": []map[string]any{ + {"type": "text", "text": result}, + }, + }) + + case "notifications/initialized": + case "notifications/cancelled": + } + } +} + +func readImage(path, prompt string) (string, error) { + ext := strings.ToLower(filepath.Ext(path)) + var mediaType string + switch ext { + case ".jpg", ".jpeg": + mediaType = "image/jpeg" + case ".png": + mediaType = "image/png" + case ".webp": + mediaType = "image/webp" + default: + return "", fmt.Errorf("unsupported format: %s (use jpg, png, or webp)", ext) + } + + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("cannot read file: %w", err) + } + + b64 := base64.StdEncoding.EncodeToString(data) + + reqBody := map[string]any{ + "model": "mimo-v2.5-free", + "messages": []map[string]any{ + { + "role": "user", + "content": []map[string]any{ + {"type": "text", "text": prompt}, + {"type": "image_url", "image_url": map[string]string{ + "url": fmt.Sprintf("data:%s;base64,%s", mediaType, b64), + }}, + }, + }, + }, + "stream": false, + } + + body, _ := json.Marshal(reqBody) + + httpReq, err := http.NewRequest("POST", proxyBaseURL+"/chat/completions", bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("request error: %w", err) + } + + apiKey := os.Getenv("VISION_API_KEY") + if apiKey == "" { + apiKey = defaultAPIKey + } + // using proxy at proxyBaseURL + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+apiKey) + httpReq.Header.Set("User-Agent", "mcp-vision/1.0") + httpReq.Header.Set("x-opencode-client", "mcp-vision") + httpReq.Header.Set("x-opencode-project", "vision") + httpReq.Header.Set("x-opencode-request", fmt.Sprintf("req_%d", len(data))) + httpReq.Header.Set("x-opencode-session", fmt.Sprintf("ses_%d", len(data)%1000000)) + + client := &http.Client{Timeout: 2 * 60} + resp, err := client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("api error: %w", err) + } + defer resp.Body.Close() + + respData, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return "", fmt.Errorf("api returned %d: %s", resp.StatusCode, string(respData)) + } + + var result struct { + Choices []struct { + Message struct { + Content any `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(respData, &result); err != nil { + return "", fmt.Errorf("parse error: %w", err) + } + + if len(result.Choices) == 0 { + return "", fmt.Errorf("empty response") + } + + switch c := result.Choices[0].Message.Content.(type) { + case string: + return c, nil + default: + b, _ := json.MarshalIndent(c, "", " ") + return string(b), nil + } +} + +func respond(id any, result any) { + resp := map[string]any{ + "jsonrpc": "2.0", + "id": id, + "result": result, + } + writeJSON(resp) +} + +func respondError(id any, code int, message string) { + resp := map[string]any{ + "jsonrpc": "2.0", + "id": id, + "error": map[string]any{ + "code": code, + "message": message, + }, + } + writeJSON(resp) +} + +func writeJSON(v any) { + data, _ := json.Marshal(v) + // Claude Code accepts newline-delimited JSON (same format as engram) + os.Stdout.Write(data) + os.Stdout.Write([]byte{'\n'}) +} diff --git a/cmd/vision/main.go b/cmd/vision/main.go new file mode 100644 index 0000000..f06ed39 --- /dev/null +++ b/cmd/vision/main.go @@ -0,0 +1,143 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +const defaultAPIKey = "opx-57ffcb013651f7acae558cd9ab094dbd719fdec980a6ad53" + +type visionRequest struct { + Model string `json:"model"` + Messages []visionMessage `json:"messages"` + Stream bool `json:"stream"` +} + +type visionMessage struct { + Role string `json:"role"` + Content interface{} `json:"content"` +} + +type visionResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []visionChoice `json:"choices"` +} + +type visionChoice struct { + Index int `json:"index"` + Message visionContent `json:"message"` + FinishReason string `json:"finish_reason"` +} + +type visionContent struct { + Role string `json:"role"` + Content interface{} `json:"content"` +} + +func main() { + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "Usage: vision [prompt]\n") + fmt.Fprintf(os.Stderr, "Example: vision C:\\path\\to\\photo.jpg \"Describe this image\"\n") + os.Exit(1) + } + + imagePath := os.Args[1] + prompt := "Describe esta imagen en detalle." + if len(os.Args) > 2 { + prompt = strings.Join(os.Args[2:], " ") + } + + ext := strings.ToLower(filepath.Ext(imagePath)) + var mediaType string + switch ext { + case ".jpg", ".jpeg": + mediaType = "image/jpeg" + case ".png": + mediaType = "image/png" + case ".webp": + mediaType = "image/webp" + default: + fmt.Fprintf(os.Stderr, "Unsupported image format: %s (supported: jpg, png, webp)\n", ext) + os.Exit(1) + } + + data, err := os.ReadFile(imagePath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err) + os.Exit(1) + } + + b64 := base64.StdEncoding.EncodeToString(data) + dataURL := fmt.Sprintf("data:%s;base64,%s", mediaType, b64) + + reqBody := visionRequest{ + Model: "mimo-v2.5-free", + Messages: []visionMessage{ + { + Role: "user", + Content: []map[string]interface{}{ + {"type": "text", "text": prompt}, + {"type": "image_url", "image_url": map[string]string{"url": dataURL}}, + }, + }, + }, + } + + body, _ := json.Marshal(reqBody) + + httpReq, err := http.NewRequest("POST", "https://opencode.ai/zen/v1/chat/completions", bytes.NewReader(body)) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err) + os.Exit(1) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+defaultAPIKey) + httpReq.Header.Set("User-Agent", "vision-tool/1.0") + httpReq.Header.Set("x-opencode-client", "cli") + httpReq.Header.Set("x-opencode-project", "vision") + httpReq.Header.Set("x-opencode-request", fmt.Sprintf("req_%d", time.Now().UnixNano())) + httpReq.Header.Set("x-opencode-session", fmt.Sprintf("ses_%d", time.Now().UnixNano())) + + client := &http.Client{Timeout: 2 * time.Minute} + resp, err := client.Do(httpReq) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + defer resp.Body.Close() + + respData, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + fmt.Fprintf(os.Stderr, "API error %d: %s\n", resp.StatusCode, string(respData)) + os.Exit(1) + } + + var result visionResponse + if err := json.Unmarshal(respData, &result); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing response: %v\n%s\n", err, string(respData)) + os.Exit(1) + } + + if len(result.Choices) > 0 { + switch c := result.Choices[0].Message.Content.(type) { + case string: + fmt.Println(c) + default: + b, _ := json.MarshalIndent(c, "", " ") + fmt.Println(string(b)) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3ac91d3 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module opencode-proxy + +go 1.22 diff --git a/internal/proxy/anthropic.go b/internal/proxy/anthropic.go new file mode 100644 index 0000000..4206219 --- /dev/null +++ b/internal/proxy/anthropic.go @@ -0,0 +1,862 @@ +package proxy + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" +) + +// ---------- Anthropic request/response types ---------- + +// AnthropicRequest is the incoming Anthropic Messages API request. +type AnthropicRequest struct { + Model string `json:"model"` + Messages []AnthropicMessage `json:"messages"` + System any `json:"system,omitempty"` // string or []AnthropicTextBlock + MaxTokens int `json:"max_tokens"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK *int `json:"top_k,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + Stream bool `json:"stream,omitempty"` + Tools []AnthropicTool `json:"tools,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// AnthropicMessage is a message in Anthropic format. +// Content can be either a plain string or an array of content blocks. +type AnthropicMessage struct { + Role string `json:"role"` // "user" or "assistant" + Content AnthropicContentBlocks `json:"content"` +} + +// AnthropicContentBlocks handles both string and array content formats. +type AnthropicContentBlocks []AnthropicContent + +func (a *AnthropicContentBlocks) UnmarshalJSON(b []byte) error { + // Try string first: "content": "hello" + if len(b) > 0 && b[0] == '"' { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + *a = []AnthropicContent{{Type: "text", Text: s}} + return nil + } + // Otherwise it's an array: "content": [{"type":"text","text":"hello"}] + var blocks []AnthropicContent + if err := json.Unmarshal(b, &blocks); err != nil { + return err + } + *a = blocks + return nil +} + +// AnthropicContent is a content block (text, tool_use, tool_result, image). +type AnthropicContent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + Content any `json:"content,omitempty"` // for tool_result + IsError *bool `json:"is_error,omitempty"` + Source *AnthropicImage `json:"source,omitempty"` +} + +// AnthropicImage is an image source in Anthropic format. +type AnthropicImage struct { + Type string `json:"type"` + MediaType string `json:"media_type"` + Data string `json:"data"` +} + +// AnthropicTool defines a tool in Anthropic format. +type AnthropicTool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema map[string]any `json:"input_schema"` +} + +// AnthropicResponse is the non-streaming Anthropic response. +type AnthropicResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Model string `json:"model"` + Content []AnthropicContent `json:"content"` + StopReason string `json:"stop_reason"` + StopSeq string `json:"stop_sequence,omitempty"` + Usage AnthropicUsage `json:"usage"` +} + +// AnthropicUsage holds token usage in Anthropic format. +type AnthropicUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +// AnthropicError is an error response in Anthropic format. +type AnthropicError struct { + Type string `json:"type"` + Error AnthropicErrorBody `json:"error"` +} + +// AnthropicErrorBody is the inner error details. +type AnthropicErrorBody struct { + Type string `json:"type"` + Message string `json:"message"` +} + +// ---------- Anthropic SSE event types ---------- + +type anthropicSSE struct { + Type string `json:"type"` + Index *int `json:"index,omitempty"` + Delta *struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + PartialJSON string `json:"partial_json,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + StopSequence string `json:"stop_sequence,omitempty"` + } `json:"delta,omitempty"` + Message *anthropicSSEMessage `json:"message,omitempty"` + Usage *AnthropicUsage `json:"usage,omitempty"` + ContentBlock *struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + ID string `json:"id,omitempty"` + } `json:"content_block,omitempty"` +} + +type anthropicSSEMessage struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Model string `json:"model"` + Content []AnthropicContent `json:"content"` + StopReason string `json:"stop_reason"` + StopSeq string `json:"stop_sequence,omitempty"` + Usage AnthropicUsage `json:"usage"` +} + +// ---------- Anthropic → OpenAI conversion ---------- + +// AnthropicToOpenAI converts an Anthropic Messages request to OpenAI Chat Completions format. +func AnthropicToOpenAI(ar *AnthropicRequest) *OpenAIChatRequest { + var messages []OpenAIMessage + + // System prompt: Anthropic system → OpenAI system message + systemText := extractSystemText(ar.System) + if systemText != "" { + messages = append(messages, OpenAIMessage{ + Role: "system", + Content: systemText, + }) + } + + // Convert each message + for _, am := range ar.Messages { + msg := anthropicMessageToOpenAI(am) + messages = append(messages, msg) + } + + // Convert tools + var tools []OpenAITool + for _, at := range ar.Tools { + tools = append(tools, OpenAITool{ + Type: "function", + Function: OpenAIFunction{ + Name: at.Name, + Description: at.Description, + Parameters: at.InputSchema, + }, + }) + } + + temperature := 0.7 + if ar.Temperature != nil { + temperature = *ar.Temperature + } + + oaReq := &OpenAIChatRequest{ + Model: ar.Model, + Messages: messages, + MaxTokens: ar.MaxTokens, + Temperature: temperature, + Stream: ar.Stream, + Tools: tools, + } + + return oaReq +} + +func extractSystemText(system any) string { + switch s := system.(type) { + case string: + return s + case []any: + var parts []string + for _, block := range s { + if b, ok := block.(map[string]any); ok { + if b["type"] == "text" { + if text, ok := b["text"].(string); ok { + parts = append(parts, text) + } + } + } + } + return strings.Join(parts, "\n") + } + return "" +} + +func anthropicMessageToOpenAI(am AnthropicMessage) OpenAIMessage { + msg := OpenAIMessage{ + Role: am.Role, + } + + var textParts []string + var imageParts []map[string]any + var toolCalls []OpenAIToolCall + + for _, block := range am.Content { + switch block.Type { + case "text": + textParts = append(textParts, block.Text) + case "tool_use": + toolCalls = append(toolCalls, OpenAIToolCall{ + ID: block.ID, + Type: "function", + Function: OpenAIToolFunc{ + Name: block.Name, + Arguments: string(block.Input), + }, + }) + case "tool_result": + msg.Role = "tool" + msg.ToolCallID = block.ToolUseID + if block.Content != nil { + msg.Content = flattenContent(block.Content) + } + if block.IsError != nil && *block.IsError { + msg.Content = fmt.Sprintf("Error: %v", msg.Content) + } + return msg + case "image": + if block.Source != nil { + imageParts = append(imageParts, map[string]any{ + "type": "image_url", + "image_url": map[string]any{ + "url": fmt.Sprintf("data:%s;base64,%s", block.Source.MediaType, block.Source.Data), + }, + }) + } + } + } + + if len(imageParts) > 0 { + var content []map[string]any + for _, t := range textParts { + content = append(content, map[string]any{"type": "text", "text": t}) + } + content = append(content, imageParts...) + msg.Content = content + } else if len(toolCalls) > 0 { + msg.ToolCalls = toolCalls + msg.Content = strings.Join(textParts, "\n") + } else { + msg.Content = strings.Join(textParts, "\n") + } + + return msg +} + +// flattenContent converts tool_result content (string or array of content blocks) to a plain string. +func flattenContent(v any) string { + if s, ok := v.(string); ok { + return s + } + if arr, ok := v.([]any); ok { + var parts []string + for _, item := range arr { + if m, ok := item.(map[string]any); ok { + if t, ok := m["text"].(string); ok { + parts = append(parts, t) + } + } + } + return strings.Join(parts, "\n") + } + return fmt.Sprintf("%v", v) +} + +// requestHasImages checks whether any message in the Anthropic request contains an image block. +func requestHasImages(ar *AnthropicRequest) bool { + for _, m := range ar.Messages { + for _, block := range m.Content { + if block.Type == "image" { + return true + } + } + } + return false +} + +// ---------- OpenAI → Anthropic response conversion ---------- + +// OpenAIToAnthropicResponse converts a non-streaming OpenAI response to Anthropic format. +func OpenAIToAnthropicResponse(oaResp *OpenAIChatResponse, model string) *AnthropicResponse { + ar := &AnthropicResponse{ + ID: oaResp.ID, + Type: "message", + Role: "assistant", + Model: model, + Content: make([]AnthropicContent, 0), + } + + if len(oaResp.Choices) > 0 { + choice := oaResp.Choices[0] + msg := choice.Message + + // Extract text content + if text, ok := msg.Content.(string); ok && text != "" { + ar.Content = append(ar.Content, AnthropicContent{ + Type: "text", + Text: text, + }) + } + + // Extract tool calls + for _, tc := range msg.ToolCalls { + ar.Content = append(ar.Content, AnthropicContent{ + Type: "tool_use", + ID: tc.ID, + Name: tc.Function.Name, + Input: json.RawMessage(tc.Function.Arguments), + }) + } + + // Map finish reason + ar.StopReason = mapFinishReason(choice.FinishReason) + } + + // Map usage + if oaResp.Usage != nil { + ar.Usage = AnthropicUsage{ + InputTokens: oaResp.Usage.PromptTokens, + OutputTokens: oaResp.Usage.CompletionTokens, + } + } + + return ar +} + +func mapFinishReason(oaReason string) string { + switch oaReason { + case "stop": + return "end_turn" + case "length": + return "max_tokens" + case "tool_calls": + return "tool_use" + case "content_filter": + return "end_turn" + default: + return "end_turn" + } +} + +// ---------- OpenAI SSE → Anthropic SSE streaming ---------- + +// ---------- Anthropic SSE state machine ---------- + +type anthropicSSEState struct { + w http.ResponseWriter + flusher http.Flusher + model string + msgID string + + messageStarted bool + finished bool + inputTokens int + outputTokens int + + textBlockIdx int // -1 = not started + nextBlockIdx int // next sequential block index + + // toolCallIdx maps OpenAI tool call index → anthropic block state + toolByOpenAIIdx map[int]*anthropicToolBlock + // openBlocks tracks blocks in open order for sequential close + openBlocks []int // values are anthropic block indices +} + +type anthropicToolBlock struct { + anthropicIdx int + id string + name string +} + +func newAnthropicSSEState(w http.ResponseWriter, flusher http.Flusher, model, requestID string) *anthropicSSEState { + return &anthropicSSEState{ + w: w, + flusher: flusher, + model: model, + msgID: fmt.Sprintf("msg_%s", requestID), + textBlockIdx: -1, + toolByOpenAIIdx: make(map[int]*anthropicToolBlock), + } +} + +func (s *anthropicSSEState) startMessage() { + writeAnthropicSSE(s.w, anthropicSSE{ + Type: "message_start", + Message: &anthropicSSEMessage{ + ID: s.msgID, + Type: "message", + Role: "assistant", + Model: s.model, + Content: []AnthropicContent{}, + }, + }, s.flusher) + s.messageStarted = true +} + +func (s *anthropicSSEState) ensureTextBlock() { + if s.textBlockIdx >= 0 { + return + } + s.textBlockIdx = s.nextBlockIdx + s.nextBlockIdx++ + s.openBlocks = append(s.openBlocks, s.textBlockIdx) + + writeAnthropicSSE(s.w, anthropicSSE{ + Type: "content_block_start", + Index: &s.textBlockIdx, + ContentBlock: &struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + ID string `json:"id,omitempty"` + }{Type: "text"}, + }, s.flusher) +} + +func (s *anthropicSSEState) handleText(text string) { + s.ensureTextBlock() + writeAnthropicSSE(s.w, anthropicSSE{ + Type: "content_block_delta", + Index: &s.textBlockIdx, + Delta: &struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + PartialJSON string `json:"partial_json,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + StopSequence string `json:"stop_sequence,omitempty"` + }{Type: "text_delta", Text: text}, + }, s.flusher) +} + +func (s *anthropicSSEState) handleToolCall(openaiIdx int, tc map[string]any) { + id, _ := tc["id"].(string) + fn, _ := tc["function"].(map[string]any) + name, _ := fn["name"].(string) + args, _ := fn["arguments"].(string) + + block, exists := s.toolByOpenAIIdx[openaiIdx] + if !exists { + // Close text block if open — text must precede all tool blocks + if s.textBlockIdx >= 0 { + writeAnthropicSSE(s.w, anthropicSSE{ + Type: "content_block_stop", + Index: &s.textBlockIdx, + }, s.flusher) + // textBlockIdx stays set (don't reopen) + } + + block = &anthropicToolBlock{ + anthropicIdx: s.nextBlockIdx, + id: id, + name: name, + } + s.nextBlockIdx++ + s.toolByOpenAIIdx[openaiIdx] = block + s.openBlocks = append(s.openBlocks, block.anthropicIdx) + + writeAnthropicSSE(s.w, anthropicSSE{ + Type: "content_block_start", + Index: &block.anthropicIdx, + ContentBlock: &struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + ID string `json:"id,omitempty"` + }{Type: "tool_use", Name: block.name, ID: block.id}, + }, s.flusher) + } else { + // Update id/name if present (first chunk may have both, later only args) + if id != "" { + block.id = id + } + if name != "" { + block.name = name + } + } + + if args != "" { + writeAnthropicSSE(s.w, anthropicSSE{ + Type: "content_block_delta", + Index: &block.anthropicIdx, + Delta: &struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + PartialJSON string `json:"partial_json,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + StopSequence string `json:"stop_sequence,omitempty"` + }{Type: "input_json_delta", PartialJSON: args}, + }, s.flusher) + } +} + +func (s *anthropicSSEState) finish(stopReason string) { + // Close all open blocks in order + for _, blockIdx := range s.openBlocks { + writeAnthropicSSE(s.w, anthropicSSE{ + Type: "content_block_stop", + Index: &blockIdx, + }, s.flusher) + } + s.openBlocks = nil + + writeAnthropicSSE(s.w, anthropicSSE{ + Type: "message_delta", + Delta: &struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + PartialJSON string `json:"partial_json,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + StopSequence string `json:"stop_sequence,omitempty"` + }{StopReason: stopReason}, + Usage: &AnthropicUsage{ + InputTokens: s.inputTokens, + OutputTokens: s.outputTokens, + }, + }, s.flusher) + writeAnthropicSSE(s.w, anthropicSSE{Type: "message_stop"}, s.flusher) + s.finished = true +} + +// StreamOpenAIAsAnthropic reads OpenAI SSE chunks and converts them to Anthropic SSE events. +func StreamOpenAIAsAnthropic(w http.ResponseWriter, resp *http.Response, model string, requestID string) error { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("anthropic-version", "2023-06-01") + w.Header().Set("x-request-id", requestID) + w.WriteHeader(200) + + flusher, ok := w.(http.Flusher) + if !ok { + return fmt.Errorf("streaming not supported") + } + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + state := newAnthropicSSEState(w, flusher, model, requestID) + + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + if !state.finished { + state.finish("end_turn") + } + continue + } + + var chunk map[string]any + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue + } + + choices, _ := chunk["choices"].([]any) + if len(choices) == 0 { + if u, ok := chunk["usage"].(map[string]any); ok { + if it, ok := u["prompt_tokens"].(float64); ok { + state.inputTokens = int(it) + } + if ot, ok := u["completion_tokens"].(float64); ok { + state.outputTokens = int(ot) + } + } + continue + } + + choice := choices[0].(map[string]any) + delta, _ := choice["delta"].(map[string]any) + + if !state.messageStarted { + state.startMessage() + } + + if content, ok := delta["content"].(string); ok && content != "" { + state.handleText(content) + } + + if tcs, ok := delta["tool_calls"].([]any); ok { + for _, tcAny := range tcs { + tc := tcAny.(map[string]any) + idxFloat, ok := tc["index"].(float64) + if !ok { + continue + } + state.handleToolCall(int(idxFloat), tc) + } + } + + if fr, ok := choice["finish_reason"].(string); ok && fr != "" && !state.finished { + state.finish(mapFinishReason(fr)) + } + } + + if _, err := fmt.Fprint(w, ""); err != nil { + return nil + } + flusher.Flush() + return nil +} + +func writeAnthropicSSE(w io.Writer, event anthropicSSE, flusher http.Flusher) { + data, _ := json.Marshal(event) + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.Type, data) + if flusher != nil { + flusher.Flush() + } +} + +// ---------- Forwarding for Anthropic ---------- + +// ForwardAnthropicMessages converts an Anthropic request, forwards to Zen, and returns Anthropic response. +func (p *Proxy) ForwardAnthropicMessages(w http.ResponseWriter, r *http.Request, body []byte) error { + var ar AnthropicRequest + if err := json.Unmarshal(body, &ar); err != nil { + return fmt.Errorf("invalid Anthropic request: %w", err) + } + + // Resolve model + resolved, _ := p.ResolveModel(ar.Model) + // Keep original client-requested model name for response rewriting. + // Claude Code validates that the response model matches the request model. + clientModel := ar.Model + + // Auto-route to vision model if the request has images and resolved model can't handle them + hasImages := requestHasImages(&ar) + if hasImages { + mi := ModelByID(resolved) + if mi == nil || !mi.SupportsVision { + fmt.Printf("[anthropic] model=%s lacks vision, auto-routing to mimo-v2.5-free\n", resolved) + resolved = "mimo-v2.5-free" + } + } + + fmt.Printf("[anthropic] model=%s → %s, max_tokens=%d, stream=%v, messages=%d, tools=%d\n", + clientModel, resolved, ar.MaxTokens, ar.Stream, len(ar.Messages), len(ar.Tools)) + + // Convert Anthropic → OpenAI + oaReq := AnthropicToOpenAI(&ar) + oaReq.Model = resolved + + oaBody, err := json.Marshal(oaReq) + if err != nil { + return fmt.Errorf("failed to marshal OpenAI request: %w", err) + } + + fmt.Printf("[anthropic] → openai request (%d bytes)\n", len(oaBody)) + + // Generate request/session IDs + requestID := RequestID() + sessionID := p.SessionID("anthropic") + + // Build upstream request + upstreamReq, err := http.NewRequestWithContext(r.Context(), "POST", ZenChatURL(), bytes.NewReader(oaBody)) + if err != nil { + return fmt.Errorf("failed to create upstream request: %w", err) + } + + for k, v := range UpstreamHeaders(requestID, sessionID) { + upstreamReq.Header.Set(k, v) + } + + // Execute + client := &http.Client{Timeout: 10 * time.Minute} + resp, err := client.Do(upstreamReq) + if err != nil { + return fmt.Errorf("upstream request failed: %w", err) + } + defer resp.Body.Close() + + fmt.Printf("[anthropic] upstream status=%d, content-type=%s\n", resp.StatusCode, resp.Header.Get("Content-Type")) + + if resp.StatusCode != 200 { + bodyBytes, _ := io.ReadAll(resp.Body) + fmt.Printf("[anthropic] upstream error body (%d bytes): %s\n", len(bodyBytes), string(bodyBytes)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + json.NewEncoder(w).Encode(AnthropicError{ + Type: "error", + Error: AnthropicErrorBody{ + Type: "api_error", + Message: string(bodyBytes), + }, + }) + return nil + } + + // Handle streaming vs non-streaming + if ar.Stream { + return StreamOpenAIAsAnthropic(w, resp, clientModel, requestID) + } + + // Non-streaming: collect full response and convert + return p.anthropicNonStreamResponse(w, resp, clientModel) +} + +func (p *Proxy) anthropicNonStreamResponse(w http.ResponseWriter, resp *http.Response, model string) error { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read upstream: %w", err) + } + + contentType := resp.Header.Get("Content-Type") + if strings.Contains(contentType, "text/event-stream") { + // Accumulate SSE chunks into a single OpenAI response, then convert + var accumulated OpenAIChatResponse + accumulated.ID = "msg_" + RequestID() + accumulated.Object = "chat.completion" + accumulated.Created = time.Now().Unix() + accumulated.Model = model + accumulated.Choices = []OpenAIChoice{{ + Index: 0, + Message: OpenAIMessage{ + Role: "assistant", + }, + }} + + var contentBuf strings.Builder + toolCallsByIndex := make(map[int]*OpenAIToolCall) + var toolIndices []int + scanner := bufio.NewScanner(bytes.NewReader(bodyBytes)) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + continue + } + var chunk map[string]any + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue + } + if choices, ok := chunk["choices"].([]any); ok && len(choices) > 0 { + choice := choices[0].(map[string]any) + if delta, ok := choice["delta"].(map[string]any); ok { + if content, ok := delta["content"].(string); ok { + contentBuf.WriteString(content) + } + if tcs, ok := delta["tool_calls"].([]any); ok && len(tcs) > 0 { + for _, tcAny := range tcs { + tc := tcAny.(map[string]any) + idxFloat, ok := tc["index"].(float64) + if !ok { + continue + } + idx := int(idxFloat) + fn, _ := tc["function"].(map[string]any) + + existing, exists := toolCallsByIndex[idx] + if !exists { + id, _ := tc["id"].(string) + name, _ := fn["name"].(string) + existing = &OpenAIToolCall{ + ID: id, + Type: "function", + Function: OpenAIToolFunc{ + Name: name, + Arguments: "", + }, + } + toolCallsByIndex[idx] = existing + toolIndices = append(toolIndices, idx) + } + + if name, ok := fn["name"].(string); ok && name != "" { + existing.Function.Name = name + } + if args, ok := fn["arguments"].(string); ok { + existing.Function.Arguments += args + } + if id, ok := tc["id"].(string); ok && id != "" { + existing.ID = id + } + } + } + } + if fr, ok := choice["finish_reason"].(string); ok && fr != "" { + accumulated.Choices[0].FinishReason = fr + } + } + if u, ok := chunk["usage"].(map[string]any); ok { + accumulated.Usage = &OpenAIUsage{} + if it, ok := u["prompt_tokens"].(float64); ok { + accumulated.Usage.PromptTokens = int(it) + } + if ot, ok := u["completion_tokens"].(float64); ok { + accumulated.Usage.CompletionTokens = int(ot) + } + accumulated.Usage.TotalTokens = accumulated.Usage.PromptTokens + accumulated.Usage.CompletionTokens + } + } + + accumulated.Choices[0].Message.Content = contentBuf.String() + sort.Ints(toolIndices) + for _, idx := range toolIndices { + accumulated.Choices[0].Message.ToolCalls = append(accumulated.Choices[0].Message.ToolCalls, *toolCallsByIndex[idx]) + } + if accumulated.Choices[0].FinishReason == "" { + accumulated.Choices[0].FinishReason = "stop" + } + + anthroResp := OpenAIToAnthropicResponse(&accumulated, model) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + return json.NewEncoder(w).Encode(anthroResp) + } + + // Direct JSON response from upstream + var oaResp OpenAIChatResponse + if err := json.Unmarshal(bodyBytes, &oaResp); err != nil { + // Pass through if not valid OpenAI JSON + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + w.Write(bodyBytes) + return nil + } + + anthroResp := OpenAIToAnthropicResponse(&oaResp, model) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("anthropic-version", "2023-06-01") + w.Header().Set("x-request-id", "req_"+RequestID()) + w.WriteHeader(200) + return json.NewEncoder(w).Encode(anthroResp) +} diff --git a/internal/proxy/models.go b/internal/proxy/models.go new file mode 100644 index 0000000..21a3e40 --- /dev/null +++ b/internal/proxy/models.go @@ -0,0 +1,194 @@ +package proxy + +import ( + "crypto/rand" + "encoding/hex" + "sync" + "time" +) + +// ZenBaseURL is the upstream OpenCode Zen API base URL. +const ZenBaseURL = "https://opencode.ai/zen/v1" + +// Free models available via OpenCode Zen (no API key required). +// These are the 5 models currently offered for free. +var zenModels = []ModelInfo{ + { + ID: "big-pickle", + Name: "Big Pickle (Free)", + OwnedBy: "opencode-zen", + ContextWindow: 200_000, + MaxOutputTokens: 32_000, + Description: "Permanentemente gratis. Código legacy, refactors, mantenimiento.", + SupportsVision: false, + }, + { + ID: "deepseek-v4-flash-free", + Name: "DeepSeek V4 Flash (Free)", + OwnedBy: "deepseek", + ContextWindow: 1_000_000, + MaxOutputTokens: 384_000, + Description: "El todoterreno. 1M contexto, 284B params (13B activos), razonamiento.", + SupportsVision: false, + }, + { + ID: "mimo-v2.5-free", + Name: "MiMo V2.5 (Free)", + OwnedBy: "xiaomi", + ContextWindow: 1_000_000, + MaxOutputTokens: 32_000, + Description: "Multimodal de Xiaomi. 1M contexto, imágenes → código. MIT license.", + SupportsVision: true, + }, + { + ID: "north-mini-code-free", + Name: "North Mini Code (Free)", + OwnedBy: "cohere", + ContextWindow: 256_000, + MaxOutputTokens: 64_000, + Description: "Cohere. 30B total / 3B activos (MoE). Respuesta rápida, scripts.", + SupportsVision: false, + }, + { + ID: "nemotron-3-ultra-free", + Name: "Nemotron 3 Ultra (Free)", + OwnedBy: "nvidia", + ContextWindow: 1_000_000, + MaxOutputTokens: 16_384, + Description: "NVIDIA. 550B params (55B activos). Mamba-2 + MoE híbrido. Razonamiento.", + SupportsVision: false, + }, +} + +// Model aliases: short name → full model ID. +var modelAliases = map[string]string{ + // Short aliases + "pickle": "big-pickle", + "big-pickle": "big-pickle", + "deepseek": "deepseek-v4-flash-free", + "deepseek-v4": "deepseek-v4-flash-free", + "ds": "deepseek-v4-flash-free", + "mimo": "mimo-v2.5-free", + "mimo-v2.5": "mimo-v2.5-free", + "xiaomi": "mimo-v2.5-free", + "north": "north-mini-code-free", + "north-mini": "north-mini-code-free", + "cohere": "north-mini-code-free", + "nemotron": "nemotron-3-ultra-free", + "nemotron-3": "nemotron-3-ultra-free", + "nvidia": "nemotron-3-ultra-free", + + // Claude model name aliases (for Claude Code compatibility) + // Claude Code validates model names client-side; set ANTHROPIC_MODEL to a Claude name. + "claude-sonnet-4-6": "deepseek-v4-flash-free", + "claude-sonnet-4-5": "deepseek-v4-flash-free", + "claude-sonnet-4": "deepseek-v4-flash-free", + "claude-opus-4-8": "deepseek-v4-flash-free", + "claude-opus-4-5": "deepseek-v4-flash-free", + "claude-opus-4": "deepseek-v4-flash-free", + "claude-haiku-4-5": "north-mini-code-free", + "claude-haiku-4": "north-mini-code-free", + "claude-3.5-sonnet": "north-mini-code-free", + "claude-3.5-haiku": "big-pickle", +} + +// ModelInfo describes a single model. +type ModelInfo struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + OwnedBy string `json:"owned_by"` + ContextWindow int `json:"context_window"` + MaxOutputTokens int `json:"max_output_tokens"` + Description string `json:"description"` + SupportsVision bool `json:"supports_vision"` +} + +// Proxy handles forwarding OpenAI-format requests to OpenCode Zen. +type Proxy struct { + apiKey string + sessions map[string]*session // keyed by client-provided API key + mu sync.Mutex +} + +type session struct { + id string + expiresAt time.Time +} + +// NewProxy creates a new Proxy instance. +func NewProxy(apiKey string) *Proxy { + return &Proxy{ + apiKey: apiKey, + sessions: make(map[string]*session), + } +} + +// APIKey returns the configured API key (empty = no auth). +func (p *Proxy) APIKey() string { return p.apiKey } + +// Models returns the list of available free models. +func (p *Proxy) Models() []ModelInfo { return zenModels } + +// ModelByID returns the ModelInfo entry for a given model ID, or nil if not found. +func ModelByID(id string) *ModelInfo { + for _, m := range zenModels { + if m.ID == id { + return &m + } + } + return nil +} + +// ResolveModel maps a requested model name to a Zen model ID. +// Returns the resolved model ID and whether it was found. +func (p *Proxy) ResolveModel(requested string) (string, bool) { + // Check aliases first (covers both short names and exact IDs) + if mapped, ok := modelAliases[requested]; ok { + return mapped, true + } + // Check if it's already a valid model ID (belt and suspenders) + for _, m := range zenModels { + if m.ID == requested { + return requested, true + } + } + // Pass through unknown models (might be newly added upstream) + return requested, false +} + +// ZenChatURL returns the full upstream URL for chat completions. +func ZenChatURL() string { + return ZenBaseURL + "/chat/completions" +} + +// ZenModelsURL returns the full upstream URL for model listing. +func ZenModelsURL() string { + return ZenBaseURL + "/models" +} + +// SessionID returns a session ID for the given user key, rotating every 30 minutes. +func (p *Proxy) SessionID(userKey string) string { + p.mu.Lock() + defer p.mu.Unlock() + + if s, ok := p.sessions[userKey]; ok && time.Now().Before(s.expiresAt) { + return s.id + } + + p.sessions[userKey] = &session{ + id: "ses_" + randomHex(12), + expiresAt: time.Now().Add(30 * time.Minute), + } + return p.sessions[userKey].id +} + +// RequestID generates a new unique request ID. +func RequestID() string { + return "msg_" + randomHex(12) +} + +func randomHex(n int) string { + b := make([]byte, n) + rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go new file mode 100644 index 0000000..efbda1b --- /dev/null +++ b/internal/proxy/proxy.go @@ -0,0 +1,315 @@ +package proxy + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// ---------- OpenAI request/response types ---------- + +// OpenAIChatRequest is the incoming OpenAI-format chat completion request. +type OpenAIChatRequest struct { + Model string `json:"model"` + Messages []OpenAIMessage `json:"messages"` + Stream bool `json:"stream,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + Tools []OpenAITool `json:"tools,omitempty"` +} + +// OpenAIMessage represents a chat message. +type OpenAIMessage struct { + Role string `json:"role"` + Content any `json:"content"` + Name string `json:"name,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"` +} + +// OpenAIToolCall represents a tool call from the assistant. +type OpenAIToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function OpenAIToolFunc `json:"function"` +} + +// OpenAIToolFunc is the function part of a tool call. +type OpenAIToolFunc struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +// OpenAITool defines a function tool. +type OpenAITool struct { + Type string `json:"type"` + Function OpenAIFunction `json:"function"` +} + +// OpenAIFunction is the function definition within a tool. +type OpenAIFunction struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters map[string]any `json:"parameters,omitempty"` +} + +// OpenAIChatResponse is the non-streaming response. +type OpenAIChatResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []OpenAIChoice `json:"choices"` + Usage *OpenAIUsage `json:"usage,omitempty"` +} + +// OpenAIChoice is a single completion choice. +type OpenAIChoice struct { + Index int `json:"index"` + Message OpenAIMessage `json:"message,omitempty"` + Delta *OpenAIDelta `json:"delta,omitempty"` + FinishReason string `json:"finish_reason,omitempty"` +} + +// OpenAIDelta is a streaming delta. +type OpenAIDelta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"` +} + +// OpenAIUsage holds token usage info. +type OpenAIUsage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// OpenAIError is a standard error response. +type OpenAIError struct { + Error OpenAIErrorDetail `json:"error"` +} + +// OpenAIErrorDetail holds error info. +type OpenAIErrorDetail struct { + Message string `json:"message"` + Type string `json:"type"` + Code string `json:"code,omitempty"` +} + +// ---------- Proxy forwarding logic ---------- + +// UpstreamHeaders builds the required headers for OpenCode Zen. +func UpstreamHeaders(requestID, sessionID string) map[string]string { + return map[string]string{ + "Authorization": "Bearer public", + "User-Agent": "opencode/1.15.0 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.13", + "x-opencode-client": "cli", + "x-opencode-project": "global", + "x-opencode-request": requestID, + "x-opencode-session": sessionID, + "Content-Type": "application/json", + "Accept": "text/event-stream, application/json", + } +} + +// ForwardChatCompletion sends the OpenAI request to OpenCode Zen and streams or collects the response. +func (p *Proxy) ForwardChatCompletion(w http.ResponseWriter, r *http.Request, body []byte) error { + var req OpenAIChatRequest + if err := json.Unmarshal(body, &req); err != nil { + return fmt.Errorf("invalid request body: %w", err) + } + + // Resolve model name + resolved, ok := p.ResolveModel(req.Model) + if !ok { + // Unknown model — log but still forward (might be a new model) + fmt.Printf("[proxy] unknown model '%s', forwarding as-is\n", req.Model) + } + + // Update the body with resolved model + if resolved != req.Model { + var reqMap map[string]any + json.Unmarshal(body, &reqMap) + reqMap["model"] = resolved + body, _ = json.Marshal(reqMap) + req.Model = resolved + } + + // Generate request/session IDs for upstream + requestID := RequestID() + + // Get session ID from the client's proxy API key (or default) + userKey := "default" + if p.apiKey != "" { + userKey = r.Header.Get("Authorization") + if userKey == "" { + userKey = r.Header.Get("x-api-key") + } + } + sessionID := p.SessionID(userKey) + + // Build upstream request + upstreamReq, err := http.NewRequestWithContext(r.Context(), "POST", ZenChatURL(), bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create upstream request: %w", err) + } + + for k, v := range UpstreamHeaders(requestID, sessionID) { + upstreamReq.Header.Set(k, v) + } + + // Execute + client := &http.Client{Timeout: 10 * time.Minute} + resp, err := client.Do(upstreamReq) + if err != nil { + return fmt.Errorf("upstream request failed: %w", err) + } + defer resp.Body.Close() + + // Handle non-200 responses + if resp.StatusCode != 200 { + bodyBytes, _ := io.ReadAll(resp.Body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + + // Try to parse upstream error, wrap as standard OpenAI error + var upstreamErr map[string]any + if json.Unmarshal(bodyBytes, &upstreamErr) == nil { + errMsg := "upstream error" + if msg, ok := upstreamErr["message"]; ok { + errMsg = fmt.Sprintf("%v", msg) + } + json.NewEncoder(w).Encode(OpenAIError{ + Error: OpenAIErrorDetail{ + Message: errMsg, + Type: "upstream_error", + }, + }) + return nil + } + + w.Write(bodyBytes) + return nil + } + + // Handle streaming vs non-streaming + if req.Stream { + return p.streamResponse(w, resp) + } + return p.nonStreamResponse(w, resp, req.Model) +} + +// streamResponse forwards SSE chunks directly from upstream to client. +func (p *Proxy) streamResponse(w http.ResponseWriter, resp *http.Response) error { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(200) + + flusher, ok := w.(http.Flusher) + if !ok { + return fmt.Errorf("streaming not supported") + } + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + for scanner.Scan() { + line := scanner.Text() + // Write the line. If the client disconnects, stop silently. + if _, err := fmt.Fprint(w, line+"\n"); err != nil { + return nil + } + flusher.Flush() + } + + // Scanner errors (e.g., connection reset) are not real errors + // after streaming has started — the client likely disconnected. + return nil +} + +// nonStreamResponse collects the full upstream response and returns it as a single JSON. +func (p *Proxy) nonStreamResponse(w http.ResponseWriter, resp *http.Response, model string) error { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read upstream response: %w", err) + } + + // If the upstream already returned a single JSON (non-streaming), pass it through + contentType := resp.Header.Get("Content-Type") + if !strings.Contains(contentType, "text/event-stream") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + w.Write(bodyBytes) + return nil + } + + // Otherwise accumulate SSE chunks into a single response + var response OpenAIChatResponse + response.ID = "chatcmpl-" + randomHex(12) + response.Object = "chat.completion" + response.Created = time.Now().Unix() + response.Model = model + response.Choices = []OpenAIChoice{{ + Index: 0, + Message: OpenAIMessage{ + Role: "assistant", + }, + }} + + var contentBuf strings.Builder + scanner := bufio.NewScanner(bytes.NewReader(bodyBytes)) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + continue + } + + var chunk map[string]any + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue + } + + // Extract content from delta + if choices, ok := chunk["choices"].([]any); ok && len(choices) > 0 { + choice := choices[0].(map[string]any) + if delta, ok := choice["delta"].(map[string]any); ok { + if content, ok := delta["content"].(string); ok { + contentBuf.WriteString(content) + } + } + if fr, ok := choice["finish_reason"].(string); ok && fr != "" { + response.Choices[0].FinishReason = fr + } + } + } + + response.Choices[0].Message.Content = contentBuf.String() + if response.Choices[0].FinishReason == "" { + response.Choices[0].FinishReason = "stop" + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + return json.NewEncoder(w).Encode(response) +} + +// WriteSSE writes a JSON object as an SSE data line. +func WriteSSE(w io.Writer, v any) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + _, err = fmt.Fprintf(w, "data: %s\n\n", data) + return err +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..b2cb721 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,308 @@ +package server + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "opencode-proxy/internal/proxy" +) + +const defaultPort = "6446" +const defaultHost = "127.0.0.1" + +// Server is the HTTP server that exposes OpenAI-compatible endpoints. +type Server struct { + proxy *proxy.Proxy + port string + host string + mux *http.ServeMux +} + +// NewServer creates a new Server with the given proxy. +func NewServer(p *proxy.Proxy) *Server { + s := &Server{ + proxy: p, + port: defaultPort, + host: defaultHost, + mux: http.NewServeMux(), + } + s.routes() + return s +} + +// SetPort sets the server port. +func (s *Server) SetPort(port string) { s.port = port } + +// SetHost sets the server host. +func (s *Server) SetHost(host string) { s.host = host } + +// Port returns the current port. +func (s *Server) Port() string { return s.port } + +// Host returns the current host. +func (s *Server) Host() string { return s.host } + +func (s *Server) routes() { + // Catch-all logger first — logs every request we receive + s.mux.HandleFunc("/", s.logAll) + + s.mux.HandleFunc("GET /health", s.handleHealth) + s.mux.HandleFunc("GET /v1/models", s.handleModels) + s.mux.HandleFunc("GET /v1/models/{id}", s.handleModelByID) + s.mux.HandleFunc("POST /v1/chat/completions", s.handleChatCompletions) + s.mux.HandleFunc("POST /v1/messages", s.handleMessages) + + // Claude Code appends /v1/messages to ANTHROPIC_BASE_URL automatically. + // If the user sets ANTHROPIC_BASE_URL=http://...:6446/v1, we get /v1/v1/messages. + // Fix: also handle the double /v1/v1/messages path. + s.mux.HandleFunc("POST /v1/v1/messages", s.handleMessages) +} + +// logAll captures unmatched requests so we can see what Claude Code is actually hitting. +func (s *Server) logAll(w http.ResponseWriter, r *http.Request) { + fmt.Printf("[CATCH-ALL] %s %s | Headers: ", r.Method, r.URL.Path) + for k, v := range r.Header { + fmt.Printf("%s=%q ", k, v[0]) + } + fmt.Println() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, `{"error":{"message":"not found: %s %s","type":"not_found"}}`, r.Method, r.URL.Path) +} + +// Start begins listening on the configured host:port. +func (s *Server) Start() error { + addr := fmt.Sprintf("%s:%s", s.host, s.port) + fmt.Printf("Listening on http://%s\n", addr) + return http.ListenAndServe(addr, s.mux) +} + +// ---------- Auth middleware ---------- + +// requireAuth returns true if the request has a valid API key. +// If no API key is configured on the proxy, all requests are allowed. +func (s *Server) requireAuth(w http.ResponseWriter, r *http.Request) bool { + key := s.proxy.APIKey() + if key == "" { + return true // no auth required + } + + // Check Authorization: Bearer + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + if strings.TrimPrefix(authHeader, "Bearer ") == key { + return true + } + } + + // Check x-api-key header + if r.Header.Get("x-api-key") == key { + return true + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(proxy.OpenAIError{ + Error: proxy.OpenAIErrorDetail{ + Message: "Invalid or missing API key. Use Authorization: Bearer or x-api-key header.", + Type: "authentication_error", + Code: "invalid_api_key", + }, + }) + return false +} + +// ---------- Endpoints ---------- + +// GET /health +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "version": "1.0.0", + "models": len(s.proxy.Models()), + "endpoints": []string{"/v1/chat/completions", "/v1/messages", "/v1/models", "/v1/models/{id}", "/health"}, + "auth_required": s.proxy.APIKey() != "", + }) +} + +// GET /v1/models +func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) { + if !s.requireAuth(w, r) { + return + } + + type modelEntry struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` + ContextWindow int `json:"context_window"` + MaxOutputTokens int `json:"max_output_tokens"` + Description string `json:"description"` + } + + models := s.proxy.Models() + entries := make([]modelEntry, len(models)) + for i, m := range models { + entries[i] = modelEntry{ + ID: m.ID, + Object: "model", + Created: 1779000000, + OwnedBy: m.OwnedBy, + ContextWindow: m.ContextWindow, + MaxOutputTokens: m.MaxOutputTokens, + Description: m.Description, + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "object": "list", + "data": entries, + }) +} + +// GET /v1/models/{id} +func (s *Server) handleModelByID(w http.ResponseWriter, r *http.Request) { + if !s.requireAuth(w, r) { + return + } + + id := r.PathValue("id") + for _, m := range s.proxy.Models() { + if m.ID == id { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": m.ID, + "object": "model", + "created": 1779000000, + "owned_by": m.OwnedBy, + "context_window": m.ContextWindow, + "max_output_tokens": m.MaxOutputTokens, + "description": m.Description, + }) + return + } + } + + writeError(w, http.StatusNotFound, fmt.Sprintf("Model '%s' not found", id), "model_not_found") +} + +// POST /v1/chat/completions +func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { + logRequest(r, "chat") + if !s.requireAuth(w, r) { + return + } + + // Read the full body + body, err := io.ReadAll(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, "Failed to read request body", "invalid_request") + return + } + defer r.Body.Close() + + logBody("chat", body) + + if len(body) == 0 { + writeError(w, http.StatusBadRequest, "Empty request body", "invalid_request") + return + } + + // Forward to upstream + if err := s.proxy.ForwardChatCompletion(w, r, body); err != nil { + fmt.Printf("[server] error forwarding request: %v\n", err) + writeError(w, http.StatusInternalServerError, err.Error(), "proxy_error") + } +} + +// POST /v1/messages — Anthropic Messages API +func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) { + logRequest(r, "anthropic") + if !s.requireAuth(w, r) { + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, "Failed to read request body", "invalid_request") + return + } + defer r.Body.Close() + + logBody("anthropic", body) + + if len(body) == 0 { + writeError(w, http.StatusBadRequest, "Empty request body", "invalid_request") + return + } + + // Check for Anthropic version header + av := r.Header.Get("anthropic-version") + if av != "" { + fmt.Printf("[anthropic] version: %s\n", av) + } + + if err := s.proxy.ForwardAnthropicMessages(w, r, body); err != nil { + fmt.Printf("[server] error forwarding anthropic request: %v\n", err) + writeError(w, http.StatusInternalServerError, err.Error(), "proxy_error") + } +} + +func logRequest(r *http.Request, kind string) { + fmt.Printf("[%s] %s %s | Auth=%s | ContentType=%s\n", + kind, r.Method, r.URL.Path, + truncate(r.Header.Get("Authorization"), 30), + r.Header.Get("Content-Type")) +} + +func logBody(kind string, body []byte) { + if len(body) > 500 { + fmt.Printf("[%s] body (%d bytes): %s...\n", kind, len(body), string(body[:500])) + } else { + fmt.Printf("[%s] body: %s\n", kind, string(body)) + } +} + +func truncate(s string, maxLen int) string { + if len(s) > maxLen { + return s[:maxLen] + "..." + } + return s +} + +// ---------- Helpers ---------- + +func writeError(w http.ResponseWriter, status int, message, errType string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(proxy.OpenAIError{ + Error: proxy.OpenAIErrorDetail{ + Message: message, + Type: errType, + }, + }) +} + +func writeMethodNotAllowed(w http.ResponseWriter, allowed string) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Allow", allowed) + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(proxy.OpenAIError{ + Error: proxy.OpenAIErrorDetail{ + Message: fmt.Sprintf("Method not allowed. Use %s.", allowed), + Type: "invalid_request_error", + }, + }) +} + +// init sets timezone for consistent timestamps +func init() { + time.Local = time.UTC +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..c8861d2 --- /dev/null +++ b/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "opencode-proxy/internal/proxy" + "opencode-proxy/internal/server" +) + +const appVersion = "1.0.0" + +func main() { + port := flag.String("port", "", "Server port (default: 6446)") + host := flag.String("host", "", "Server host (default: 127.0.0.1)") + apiKey := flag.String("api-key", "", "API key to protect the proxy (optional, can also be set via OPENCODE_PROXY_KEY env var)") + showVersion := flag.Bool("version", false, "Show version and exit") + flag.Parse() + + if *showVersion { + fmt.Printf("opencode-proxy v%s\n", appVersion) + return + } + + // Resolve API key: flag takes precedence, then env var + key := *apiKey + if key == "" { + key = os.Getenv("OPENCODE_PROXY_KEY") + } + + p := proxy.NewProxy(key) + srv := server.NewServer(p) + + if *port != "" { + srv.SetPort(*port) + } + if *host != "" { + srv.SetHost(*host) + } + + fmt.Printf("OpenCode Zen Proxy v%s\n", appVersion) + fmt.Printf("Starting on %s:%s\n", srv.Host(), srv.Port()) + if key != "" { + fmt.Println("API key protection: ENABLED") + } else { + fmt.Println("API key protection: DISABLED (no key set)") + } + fmt.Printf("Free models available: %d\n", len(p.Models())) + + if err := srv.Start(); err != nil { + fmt.Fprintf(os.Stderr, "Server error: %v\n", err) + os.Exit(1) + } +}