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)
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
*.exe
|
||||
*.exe~
|
||||
opencode-proxy
|
||||
deploy/
|
||||
vendor/
|
||||
*.log
|
||||
@@ -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.
|
||||
@@ -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 <key>`
|
||||
- `x-api-key: <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
|
||||
@@ -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'})
|
||||
}
|
||||
@@ -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 <image-path> [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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <key>
|
||||
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 <key> 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user