From 05a52d29e2ef5f2329677b01ed74459abea5eba1 Mon Sep 17 00:00:00 2001 From: Renato Date: Fri, 26 Jun 2026 19:08:01 +0200 Subject: [PATCH] feat: multi-backend proxy with auto rate-limit jailing Refactor from monolithic proxy to multi-backend architecture (Backend interface: OpenCode Zen + Kilo gateway). - Add auto rate-limit jailing: models returning HTTP 429 are hidden from /v1/models immediately and re-probed every 30 min - Backend interface supports model aliases, custom headers, and per-backend routing - Full Anthropic Messages API support with OpenAI format conversion - 9 free models across two backends with Claude name aliases --- .gitignore | 4 + AGENTS.md | 47 +++-- README.md | 163 +++++++++------- internal/proxy/anthropic.go | 25 ++- internal/proxy/backend.go | 27 +++ internal/proxy/kilo.go | 91 +++++++++ internal/proxy/models.go | 358 +++++++++++++++++++++++------------- internal/proxy/opencode.go | 142 ++++++++++++++ internal/proxy/proxy.go | 40 ++-- 9 files changed, 652 insertions(+), 245 deletions(-) create mode 100644 internal/proxy/backend.go create mode 100644 internal/proxy/kilo.go create mode 100644 internal/proxy/opencode.go diff --git a/.gitignore b/.gitignore index cec9d65..47f1ac8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ *.exe *.exe~ opencode-proxy +free-ide-proxy +/opencode-proxy +/free-ide-proxy deploy/ vendor/ *.log +.atl/ diff --git a/AGENTS.md b/AGENTS.md index d02c5cb..ec22df5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,13 +1,13 @@ -# OpenCode Zen Proxy +# Free IDE Proxy -Proxy translating OpenAI and Anthropic Messages API requests to OpenCode Zen's free model API. +OpenAI/Anthropic-compatible proxy that aggregates the **free** models from multiple opencode-based editors (currently OpenCode Zen and Kilo Code) under a single endpoint. Each upstream lives behind a `Backend` implementation and the proxy routes by model name. ## Build & Run ```bash -go build -o opencode-proxy . -./opencode-proxy # no auth -./opencode-proxy -api-key "secret" # with auth +go build -o free-ide-proxy . +./free-ide-proxy # no auth +./free-ide-proxy -api-key "secret" # with auth ``` Auth reads `OPENCODE_PROXY_KEY` env var; `-api-key` flag takes precedence. @@ -16,15 +16,18 @@ 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 +Client → internal/server/ (HTTP, auth, routing) → internal/proxy/ (model resolve, backend router, session mgmt, forwarding, Anthropic↔OpenAI conversion) → Backend → upstream ``` - **Pure stdlib Go 1.22** — no router, no deps beyond stdlib - **Entrypoint:** `main.go` wires `proxy.NewProxy(key)` into `server.NewServer(p)` +- **Multi-backend:** `internal/proxy/backend.go` defines the `Backend` interface. The `Proxy` holds an ordered list and routes each request to the first backend that recognises the model. Unknown models fall back to the default backend (opencode) and are forwarded as-is. +- **Backends:** `opencode.go` (OpenCode Zen, `opencode.ai/zen/v1`, `Bearer public`) and `kilo.go` (Kilo gateway, `api.kilo.ai/api/gateway`, no auth). Add a new file implementing `Backend` and register it in `NewProxy` (`models.go`) to support another upstream. - **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 +- **Model aliases** live in each backend (`opencode.go` and `kilo.go`) — short names, company names, and Claude model names → full IDs +- **Session rotation:** session IDs rotate every 30 min per user key (`proxy.SessionID()`) +- **Auto rate-limit jailing:** models returning HTTP 429 are automatically hidden from `/v1/models` and re-probed every 30 min until they recover. See `models.go` (`modelHealth`, `healthLoop`, `pingModel`, `markRateLimited`, `markActive`, `isRateLimited`, `Models()` filtering). +- **Pass-through:** unknown model names are forwarded as-is to the default backend, not rejected ## Endpoints @@ -45,7 +48,9 @@ Fully implemented in `internal/proxy/anthropic.go`. Converts Anthropic → OpenA **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`) +## Available Models (from `opencode.go` + `kilo.go`) + +### OpenCode Zen backend (`opencode.go` → `opencode.ai/zen/v1`, `Bearer public`) | Model ID | Aliases | Context | Max Output | |----------|---------|---------|------------| @@ -55,18 +60,28 @@ Fully implemented in `internal/proxy/anthropic.go`. Converts Anthropic → OpenA | `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. +### Kilo gateway backend (`kilo.go` → `api.kilo.ai/api/gateway`, no auth) + +| Model ID | Aliases | Context | Max Output | +|----------|---------|---------|------------| +| `stepfun/step-3.7-flash:free` | `stepfun`, `stepfun-free` | 256K | 32K | +| `poolside/laguna-m.1:free` | `poolside`, `poolside-free`, `laguna` | 256K | 32K | +| `nvidia/nemotron-3-ultra-550b-a55b:free` | — | 1M | 16K | +| `openrouter/free` | `openrouter` | 256K | 32K | + +**Source of truth is the code (`opencode.go`, `kilo.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. +- **In-memory only.** Session and health state dies on restart. The health map auto-rebuilds via initial scan. - **`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`). +- Upstream Zen API expects `x-opencode-request`, `x-opencode-session`, `x-opencode-client`, `x-opencode-project` headers (set in `opencode.go` `OpenCodeBackend.Headers`). Kilo needs no auth — its gateway returns HTTP 200 for free models with no `Authorization` header. - **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. +- **Upstream timeout:** `proxy.go` sets a 10-minute HTTP client timeout — adjust if Zen models are slow on first call. +- **Unknown models are forwarded as-is** via the default backend (opencode) — useful when new models appear upstream before code is updated. +- **Reasoning models** (DeepSeek, Nemotron, StepFun) need `max_tokens` ≥ 500 — first tokens go to reasoning, not visible content. With `max_tokens:30` they return empty content; that is model behaviour, not a proxy bug. +- **Rate limit jailing:** `proxy.go:164` and `anthropic.go:713` check for 429 and call `markRateLimited` immediately. `healthLoop()` in `models.go` does a full scan on startup, then re-checks caged models every 30 min. `Models()` filters out rate-limited models — they reappear once recovered. diff --git a/README.md b/README.md index f5acdb1..fc408bb 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,36 @@ -# OpenCode Zen Proxy +# Free IDE 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.). +OpenAI/Anthropic-compatible proxy that aggregates **free** models from [OpenCode Zen](https://opencode.ai) and [Kilo Code](https://kilo.ai) under a single endpoint. Use DeepSeek V4 Flash, StepFun, Nemotron, OpenRouter free, and more — for free — in any OpenAI-compatible tool (Cursor, Claude Code, Continue, Cline, etc.). ## How it works ``` -Your tool → opencode-proxy (localhost:6446) → opencode.ai/zen/v1 +Your tool → free-ide-proxy (localhost:6446) → { opencode.ai/zen/v1 | api.kilo.ai/api/gateway } ``` -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. +The proxy routes by model name to the matching backend, injecting the required headers. OpenCode Zen expects `x-opencode-*` headers with `Bearer public`; Kilo's gateway needs no auth for free models. Your tools talk standard OpenAI protocol to the proxy; the proxy handles the rest. + +### Auto rate-limit jailing + +When an upstream starts returning HTTP 429 (rate limit) for a model, the proxy automatically **cages** it — the model disappears from `/v1/models` immediately. A background checker re-probes caged models every 30 minutes; if they recover, they reappear. + +This means your client just needs to query `/v1/models` before each request and pick an available model. No hardcoded model names, no manual retries. ## Quick Start ```bash # Build -go build -o opencode-proxy . +go build -o free-ide-proxy . # Run (no auth) -./opencode-proxy +./free-ide-proxy # Run with API key protection -./opencode-proxy -api-key "your-secret-key" +./free-ide-proxy -api-key "your-secret-key" # Or via env var export OPENCODE_PROXY_KEY="your-secret-key" -./opencode-proxy +./free-ide-proxy ``` The server starts on `http://127.0.0.1:6446`. @@ -44,9 +50,12 @@ Also reads `OPENCODE_PROXY_KEY` environment variable if `-api-key` is not set. | Method | Path | Description | |--------|------|-------------| -| `GET` | `/health` | Health check | -| `GET` | `/v1/models` | List available free models | -| `POST` | `/v1/chat/completions` | Chat completions (streaming + non-streaming) | +| `GET` | `/health` | Health check (version, active model count, auth status) | +| `GET` | `/v1/models` | List currently **active** (not rate-limited) free models | +| `GET` | `/v1/models/{id}` | Single model detail (404 if rate-limited) | +| `POST` | `/v1/chat/completions` | OpenAI Chat Completions (streaming + non-streaming) | +| `POST` | `/v1/messages` | Anthropic Messages API | +| `POST` | `/v1/v1/messages` | Workaround for Claude Code double-path bug | ### Authentication @@ -56,58 +65,60 @@ If `-api-key` is set, all requests require one of: ## 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 | +### OpenCode Zen (5 models) — `opencode.ai/zen/v1` -All support streaming, tool calls, and system messages. +| 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 | + +**API auth:** `Bearer public` (no real token needed). + +### Kilo gateway (4 models) — `api.kilo.ai/api/gateway` + +| Model ID | Aliases | Context | Max Output | +|----------|---------|---------|------------| +| `stepfun/step-3.7-flash:free` | `stepfun`, `stepfun-free` | 256K | 32K | +| `poolside/laguna-m.1:free` | `poolside`, `poolside-free`, `laguna` | 256K | 32K | +| `nvidia/nemotron-3-ultra-550b-a55b:free` | — | 1M | 16K | +| `openrouter/free` | `openrouter` | 256K | 32K | + +**API auth:** None needed for free models. + +All models support streaming, tool calls, and system messages. Reasoning models (DeepSeek, Nemotron, StepFun) need `max_tokens` ≥ 500 or they return empty content (first tokens go to reasoning). + +## Claude Code + +Claude Code appends `/v1/messages` to `ANTHROPIC_BASE_URL` automatically. Set: + +```bash +export ANTHROPIC_BASE_URL=http://127.0.0.1:6446 +export ANTHROPIC_MODEL=claude-sonnet-4-6 # maps to deepseek-v4-flash-free +``` + +The proxy also handles `/v1/v1/messages` as a fallback for the double-path issue. + +Claude model name aliases (Claude Code validates model names client-side): + +| Claude Model | Maps to | +|-------------|---------| +| `claude-sonnet-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4` | `deepseek-v4-flash-free` | +| `claude-opus-4-8`, `claude-opus-4-5`, `claude-opus-4` | `deepseek-v4-flash-free` | +| `claude-haiku-4-5`, `claude-haiku-4` | `north-mini-code-free` | +| `claude-3.5-sonnet` | `north-mini-code-free` | +| `claude-3.5-haiku` | `big-pickle` | ## Tool Configuration ### Cursor / Continue / Cline - **Base URL**: `http://127.0.0.1:6446/v1` -- **API Key**: your proxy key (or `public` if no auth) +- **API Key**: your proxy key (or empty 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 @@ -115,7 +126,7 @@ 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 + api_key="your-proxy-key" ) response = client.chat.completions.create( @@ -128,35 +139,31 @@ response = client.chat.completions.create( ```bash # Build for Linux -GOOS=linux GOARCH=amd64 go build -o opencode-proxy . +GOOS=linux GOARCH=amd64 go build -o free-ide-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) +scp free-ide-proxy user@vps:/home/user/ +ssh user@vps './free-ide-proxy -api-key "secure-key" -host 0.0.0.0' ``` -systemd unit: +### systemd service ```ini [Unit] -Description=OpenCode Zen Proxy +Description=Free IDE Proxy After=network.target [Service] -ExecStart=/home/user/opencode-proxy -api-key "${PROXY_KEY}" -host 0.0.0.0 +ExecStart=/home/user/free-ide-proxy -api-key "${PROXY_KEY}" -host 0.0.0.0 Restart=always User=user -EnvironmentFile=/etc/opencode-proxy.env +EnvironmentFile=/etc/free-ide-proxy.env [Install] WantedBy=multi-user.target ``` -## Local SSH tunnel - -If you don't want to expose the port publicly: +### Local SSH tunnel ```bash ssh -L 6446:127.0.0.1:6446 user@your-vps @@ -164,6 +171,28 @@ ssh -L 6446:127.0.0.1:6446 user@your-vps Then point your tools at `http://127.0.0.1:6446/v1`. +## Quirks & Gotchas + +- **No tests** exist yet. All manual testing. +- **In-memory only** — session and health state dies on restart. Health map auto-rebuilds via initial scan. +- **Health check logs** show rate-limit activity: `sudo journalctl -u free-ide-proxy | grep health` +- **Unknown models** are forwarded as-is via the default backend (opencode) — catch-all for new models. +- **Reasoning model caveat:** DeepSeek, Nemotron and StepFun need `max_tokens` ≥ 500; with very low values they return empty content. This is model behaviour, not a proxy bug. + +## Architecture + +``` +Client → internal/server/ (HTTP, auth, routing) + → internal/proxy/ (model resolve, backend router, session mgmt, + forwarding, Anthropic↔OpenAI conversion) + → Backend → upstream +``` + +- **Pure stdlib Go 1.22** — no router, no dependencies beyond stdlib. +- **Multi-backend** via `Backend` interface (`internal/proxy/backend.go`). Add a new backend by implementing the interface and registering it in `NewProxy` (`models.go`). +- **Model aliases** live in each backend (`opencode.go`, `kilo.go`). +- **Auto rate-limit jailing** built into `Models()` — models returning 429 are hidden until they recover. + ## License MIT diff --git a/internal/proxy/anthropic.go b/internal/proxy/anthropic.go index 4206219..0715587 100644 --- a/internal/proxy/anthropic.go +++ b/internal/proxy/anthropic.go @@ -652,15 +652,19 @@ func (p *Proxy) ForwardAnthropicMessages(w http.ResponseWriter, r *http.Request, return fmt.Errorf("invalid Anthropic request: %w", err) } - // Resolve model - resolved, _ := p.ResolveModel(ar.Model) + // Route to the backend that owns this model (default = pass-through). + backend, resolved, known := p.resolveModel(ar.Model) + if !known { + resolved = 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 + // Auto-route to a vision model only inside the opencode backend (which owns + // a vision-capable free model). Kilo models are forwarded as-is. hasImages := requestHasImages(&ar) - if hasImages { + if hasImages && backend.Name() == "opencode" { mi := ModelByID(resolved) if mi == nil || !mi.SupportsVision { fmt.Printf("[anthropic] model=%s lacks vision, auto-routing to mimo-v2.5-free\n", resolved) @@ -668,8 +672,8 @@ func (p *Proxy) ForwardAnthropicMessages(w http.ResponseWriter, r *http.Request, } } - 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)) + fmt.Printf("[anthropic] model=%s → %s via %s, max_tokens=%d, stream=%v, messages=%d, tools=%d\n", + clientModel, resolved, backend.Name(), ar.MaxTokens, ar.Stream, len(ar.Messages), len(ar.Tools)) // Convert Anthropic → OpenAI oaReq := AnthropicToOpenAI(&ar) @@ -686,13 +690,13 @@ func (p *Proxy) ForwardAnthropicMessages(w http.ResponseWriter, r *http.Request, requestID := RequestID() sessionID := p.SessionID("anthropic") - // Build upstream request - upstreamReq, err := http.NewRequestWithContext(r.Context(), "POST", ZenChatURL(), bytes.NewReader(oaBody)) + // Build upstream request to the chosen backend + upstreamReq, err := http.NewRequestWithContext(r.Context(), "POST", backend.ChatURL(), bytes.NewReader(oaBody)) if err != nil { return fmt.Errorf("failed to create upstream request: %w", err) } - for k, v := range UpstreamHeaders(requestID, sessionID) { + for k, v := range backend.Headers(requestID, sessionID) { upstreamReq.Header.Set(k, v) } @@ -709,6 +713,9 @@ func (p *Proxy) ForwardAnthropicMessages(w http.ResponseWriter, r *http.Request, if resp.StatusCode != 200 { bodyBytes, _ := io.ReadAll(resp.Body) fmt.Printf("[anthropic] upstream error body (%d bytes): %s\n", len(bodyBytes), string(bodyBytes)) + if resp.StatusCode == http.StatusTooManyRequests { + p.markRateLimited(resolved) + } w.Header().Set("Content-Type", "application/json") w.WriteHeader(resp.StatusCode) json.NewEncoder(w).Encode(AnthropicError{ diff --git a/internal/proxy/backend.go b/internal/proxy/backend.go new file mode 100644 index 0000000..b52650e --- /dev/null +++ b/internal/proxy/backend.go @@ -0,0 +1,27 @@ +package proxy + +// Backend represents one upstream free-model provider (opencode zen, kilo, mimo, ...). +// +// Each backend knows its own model catalogue, how to resolve a requested model +// name to the upstream ID, and the URL + headers required to forward a chat +// completion request to it. The Proxy holds an ordered list of backends and +// routes each request to the first one that recognises the model. +type Backend interface { + // Name is a short stable identifier ("opencode", "kilo"). + Name() string + + // Models returns the free models this backend exposes, for /v1/models listing. + Models() []ModelInfo + + // Resolve maps a client-requested model name to the upstream model ID. + // Returns ok=false when this backend does not recognise the model, so the + // router can try the next backend (or fall back to pass-through). + Resolve(model string) (resolved string, ok bool) + + // ChatURL is the full upstream chat completions URL. + ChatURL() string + + // Headers returns the headers required to authenticate/identify the + // request to the upstream (auth, client-id, request/session tracking, etc.). + Headers(requestID, sessionID string) map[string]string +} diff --git a/internal/proxy/kilo.go b/internal/proxy/kilo.go new file mode 100644 index 0000000..9613fe7 --- /dev/null +++ b/internal/proxy/kilo.go @@ -0,0 +1,91 @@ +package proxy + +// KiloGatewayBase is the Kilo Code public gateway base URL. +// Confirmed working without auth for free models (HTTP 200 on both +// GET /api/gateway/models and POST /api/gateway/chat/completions). +const KiloGatewayBase = "https://api.kilo.ai/api/gateway" + +// KiloBackend forwards to Kilo Code's free gateway models. +// +// Kilo is an opencode-based CLI whose gateway exposes a handful of free models +// (suffixed ":free") reachable without authentication, in standard OpenAI +// Chat Completions format. +type KiloBackend struct{} + +// kiloFreeModels are the free models advertised by the Kilo gateway. +// Sourced from GET /api/gateway/models filtering isFree:true. +var kiloFreeModels = []ModelInfo{ + { + ID: "stepfun/step-3.7-flash:free", + Name: "StepFun Step 3.7 Flash (Free)", + OwnedBy: "stepfun", + ContextWindow: 256_000, + MaxOutputTokens: 32_000, + Description: "StepFun. Flash rápido, tareas generales.", + }, + { + ID: "poolside/laguna-m.1:free", + Name: "Poolside Laguna M.1 (Free)", + OwnedBy: "poolside", + ContextWindow: 256_000, + MaxOutputTokens: 32_000, + Description: "Poolside. Modelo orientado a código.", + }, + { + ID: "nvidia/nemotron-3-ultra-550b-a55b:free", + Name: "Nemotron 3 Ultra 550B (Free)", + OwnedBy: "nvidia", + ContextWindow: 1_000_000, + MaxOutputTokens: 16_384, + Description: "NVIDIA Nemotron 3 Ultra (550B/55B MoE). 1M contexto, razonamiento.", + }, + { + ID: "openrouter/free", + Name: "OpenRouter Best Free (Free)", + OwnedBy: "openrouter", + ContextWindow: 256_000, + MaxOutputTokens: 32_000, + Description: "OpenRouter selecciona automáticamente el mejor modelo free disponible.", + }, +} + +// kiloAliases lets clients use shorter names for Kilo free models. +var kiloAliases = map[string]string{ + "stepfun": "stepfun/step-3.7-flash:free", + "stepfun-free": "stepfun/step-3.7-flash:free", + "poolside": "poolside/laguna-m.1:free", + "poolside-free": "poolside/laguna-m.1:free", + "laguna": "poolside/laguna-m.1:free", + "openrouter": "openrouter/free", +} + +// Name returns the backend identifier. +func (KiloBackend) Name() string { return "kilo" } + +// Models returns the Kilo free models. +func (KiloBackend) Models() []ModelInfo { return kiloFreeModels } + +// Resolve maps a requested name to a Kilo free model ID. +func (KiloBackend) Resolve(model string) (string, bool) { + if mapped, ok := kiloAliases[model]; ok { + return mapped, true + } + for _, m := range kiloFreeModels { + if m.ID == model { + return model, true + } + } + return "", false +} + +// ChatURL is the Kilo gateway chat completions endpoint. +func (KiloBackend) ChatURL() string { return KiloGatewayBase + "/chat/completions" } + +// Headers returns minimal headers for the Kilo gateway (no auth needed for free). +func (KiloBackend) Headers(_, _ string) map[string]string { + return map[string]string{ + "Content-Type": "application/json", + "Accept": "text/event-stream, application/json", + "User-Agent": "kilo/7.3.54", + } +} diff --git a/internal/proxy/models.go b/internal/proxy/models.go index 21a3e40..368fef3 100644 --- a/internal/proxy/models.go +++ b/internal/proxy/models.go @@ -1,98 +1,16 @@ package proxy import ( + "bytes" "crypto/rand" "encoding/hex" + "fmt" + "net/http" "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. +// ModelInfo describes a single model exposed by a backend. type ModelInfo struct { ID string `json:"id"` Name string `json:"name,omitempty"` @@ -103,11 +21,31 @@ type ModelInfo struct { SupportsVision bool `json:"supports_vision"` } -// Proxy handles forwarding OpenAI-format requests to OpenCode Zen. +// healthCheckInterval is how often caged models get re-probed. +const healthCheckInterval = 30 * time.Minute + +// healthPingTimeout caps each health probe. Set high enough for slow reasoning +// models (nvidia/nemotron can take ~80s to first token). +const healthPingTimeout = 90 * time.Second + +// modelHealth tracks the rate-limit state of a single model. +type modelHealth struct { + status string // "active" | "rate_limited" + lastCheck time.Time +} + +// Proxy routes OpenAI-format requests to the right Backend and manages session IDs. type Proxy struct { - apiKey string - sessions map[string]*session // keyed by client-provided API key + apiKey string + + backends []Backend + defaultBackend Backend + + sessions map[string]*session mu sync.Mutex + + health map[string]*modelHealth + healthMu sync.RWMutex } type session struct { @@ -115,63 +53,74 @@ type session struct { expiresAt time.Time } -// NewProxy creates a new Proxy instance. +// NewProxy creates a Proxy with the default set of free-model backends. +// +// Order matters: more specific backends come first so their model IDs win over +// the catch-all opencode aliases. The default backend is the fallback for +// unknown models (pass-through as-is). func NewProxy(apiKey string) *Proxy { - return &Proxy{ - apiKey: apiKey, - sessions: make(map[string]*session), + oc := OpenCodeBackend{} + kl := KiloBackend{} + p := &Proxy{ + apiKey: apiKey, + backends: []Backend{kl, oc}, + defaultBackend: oc, + sessions: make(map[string]*session), + health: make(map[string]*modelHealth), } + go p.healthLoop() + return p } -// APIKey returns the configured API key (empty = no auth). +// APIKey returns the configured proxy 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 +// Models returns the aggregated free-model catalogue across all backends, +// excluding any model currently caged for rate limiting (HTTP 429). +func (p *Proxy) Models() []ModelInfo { + var all []ModelInfo + for _, b := range p.backends { + for _, m := range b.Models() { + if !p.isRateLimited(m.ID) { + all = append(all, m) + } } } - return nil + return all } -// ResolveModel maps a requested model name to a Zen model ID. -// Returns the resolved model ID and whether it was found. +// BackendFor returns the backend that recognises the model, or the default +// backend (pass-through) when no backend claims it. +func (p *Proxy) BackendFor(model string) Backend { + b, _, _ := p.resolveModel(model) + return b +} + +// resolveModel walks the backends in order and returns the first match. +// When nothing matches it returns the default backend with the model unchanged +// and ok=false, so callers can forward it as-is (pass-through). +func (p *Proxy) resolveModel(model string) (backend Backend, resolved string, ok bool) { + for _, b := range p.backends { + if r, matched := b.Resolve(model); matched { + return b, r, true + } + } + return p.defaultBackend, model, false +} + +// ResolveModel maps a requested model name to its upstream ID (backend-aware). +// Kept for backwards compatibility with existing call sites. 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 + _, r, ok := p.resolveModel(requested) + return r, ok } -// 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. +// SessionID returns a session ID for the given user key, rotating every 30 min. 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) { + if s, exists := p.sessions[userKey]; exists && time.Now().Before(s.expiresAt) { return s.id } @@ -192,3 +141,154 @@ func randomHex(n int) string { rand.Read(b) return hex.EncodeToString(b) } + +// ---------- Model health / rate-limit tracking ---------- + +// markRateLimited cages a model so it disappears from /v1/models until it recovers. +// Only logs the transition to avoid spam when many 429s arrive in a row. +func (p *Proxy) markRateLimited(modelID string) { + p.healthMu.Lock() + defer p.healthMu.Unlock() + h := p.health[modelID] + if h == nil { + h = &modelHealth{} + p.health[modelID] = h + } + if h.status != "rate_limited" { + fmt.Printf("[health] caging model '%s' (rate limited)\n", modelID) + } + h.status = "rate_limited" + h.lastCheck = time.Now() +} + +// markActive restores a caged model so it reappears in /v1/models. +func (p *Proxy) markActive(modelID string) { + p.healthMu.Lock() + defer p.healthMu.Unlock() + h := p.health[modelID] + if h == nil { + h = &modelHealth{} + p.health[modelID] = h + } + h.status = "active" + h.lastCheck = time.Now() +} + +// isRateLimited reports whether a model is currently caged for rate limiting. +func (p *Proxy) isRateLimited(modelID string) bool { + p.healthMu.RLock() + defer p.healthMu.RUnlock() + h, ok := p.health[modelID] + return ok && h.status == "rate_limited" +} + +// healthLoop runs an initial full scan on startup, then re-probes caged models +// every healthCheckInterval (30 min). Released models reappear in /v1/models. +func (p *Proxy) healthLoop() { + p.scanAllModels() + ticker := time.NewTicker(healthCheckInterval) + defer ticker.Stop() + for range ticker.C { + p.recheckCagedModels() + } +} + +// scanAllModels probes every known model once. Models returning non-200 (429) +// are caged; the rest stay active. Used for the startup scan. +func (p *Proxy) scanAllModels() { + var ids []string + for _, b := range p.backends { + for _, m := range b.Models() { + ids = append(ids, m.ID) + } + } + fmt.Printf("[health] initial scan: probing %d models\n", len(ids)) + for _, id := range ids { + ok := p.pingModel(id) + p.healthMu.Lock() + h := p.health[id] + if h == nil { + h = &modelHealth{} + p.health[id] = h + } + h.lastCheck = time.Now() + if ok { + h.status = "active" + } else { + h.status = "rate_limited" + fmt.Printf("[health] caging model '%s' (rate limited on initial scan)\n", id) + } + p.healthMu.Unlock() + } + active := 0 + p.healthMu.RLock() + for _, h := range p.health { + if h.status == "active" { + active++ + } + } + p.healthMu.RUnlock() + fmt.Printf("[health] initial scan complete: %d active, %d caged\n", active, len(ids)-active) +} + +// recheckCagedModels re-probes only the models currently caged. A 200 releases +// them; a 429 or timeout keeps them caged for another interval. +func (p *Proxy) recheckCagedModels() { + p.healthMu.RLock() + var caged []string + for id, h := range p.health { + if h.status == "rate_limited" { + caged = append(caged, id) + } + } + p.healthMu.RUnlock() + + if len(caged) == 0 { + return + } + fmt.Printf("[health] re-checking %d caged models\n", len(caged)) + for _, id := range caged { + ok := p.pingModel(id) + p.healthMu.Lock() + h := p.health[id] + if h == nil { + h = &modelHealth{} + p.health[id] = h + } + h.lastCheck = time.Now() + if ok { + h.status = "active" + fmt.Printf("[health] releasing model '%s' (recovered)\n", id) + } else { + fmt.Printf("[health] model '%s' still rate limited, retry in 30m\n", id) + } + p.healthMu.Unlock() + } +} + +// pingModel sends a minimal chat completion to the model's backend and reports +// whether the upstream answered HTTP 200. Timeouts and non-200 count as +// unhealthy so the model stays caged (conservative). +func (p *Proxy) pingModel(modelID string) bool { + backend, resolved, known := p.resolveModel(modelID) + if !known { + resolved = modelID + } + + body := fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"OK"}],"max_tokens":5,"temperature":0}`, resolved) + req, err := http.NewRequest("POST", backend.ChatURL(), bytes.NewReader([]byte(body))) + if err != nil { + return false + } + for k, v := range backend.Headers(RequestID(), "ses_health") { + req.Header.Set(k, v) + } + + client := &http.Client{Timeout: healthPingTimeout} + resp, err := client.Do(req) + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == 200 +} diff --git a/internal/proxy/opencode.go b/internal/proxy/opencode.go new file mode 100644 index 0000000..c9149cd --- /dev/null +++ b/internal/proxy/opencode.go @@ -0,0 +1,142 @@ +package proxy + +// ZenBaseURL is the upstream OpenCode Zen API base URL. +const ZenBaseURL = "https://opencode.ai/zen/v1" + +// OpenCodeBackend forwards to OpenCode's free Zen models. +type OpenCodeBackend struct{} + +// zenModels are the free models exposed by OpenCode Zen (no API key required). +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.", + }, + { + 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.", + }, + { + 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.", + }, + { + 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.", + }, +} + +// modelAliases maps short/company/Claude names to OpenCode Zen model IDs. +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", + // Exact IDs + "deepseek-v4-flash-free": "deepseek-v4-flash-free", + "mimo-v2.5-free": "mimo-v2.5-free", + "north-mini-code-free": "north-mini-code-free", + "nemotron-3-ultra-free": "nemotron-3-ultra-free", + + // Claude model name aliases (Claude Code validates model names client-side). + "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", +} + +// Name returns the backend identifier. +func (OpenCodeBackend) Name() string { return "opencode" } + +// Models returns the OpenCode Zen free models. +func (OpenCodeBackend) Models() []ModelInfo { return zenModels } + +// Resolve maps a requested name to an OpenCode Zen model ID. +func (OpenCodeBackend) Resolve(model string) (string, bool) { + if mapped, ok := modelAliases[model]; ok { + return mapped, true + } + for _, m := range zenModels { + if m.ID == model { + return model, true + } + } + return "", false +} + +// ChatURL is the OpenCode Zen chat completions endpoint. +func (OpenCodeBackend) ChatURL() string { return ZenBaseURL + "/chat/completions" } + +// Headers builds the headers OpenCode Zen expects (x-opencode-* + public bearer). +func (OpenCodeBackend) Headers(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", + } +} + +// ModelByID returns the OpenCode Zen ModelInfo for an ID, or nil if not found. +// Used by the Anthropic path for vision auto-routing. +func ModelByID(id string) *ModelInfo { + for _, m := range zenModels { + if m.ID == id { + return &m + } + } + return nil +} + +// ZenChatURL returns the OpenCode Zen chat completions URL. +// Kept for backwards compatibility with existing call sites. +func ZenChatURL() string { return ZenBaseURL + "/chat/completions" } + +// ZenModelsURL returns the OpenCode Zen models listing URL. +func ZenModelsURL() string { return ZenBaseURL + "/models" } diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index efbda1b..f14fa38 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -104,32 +104,20 @@ type OpenAIErrorDetail struct { // ---------- 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. +// ForwardChatCompletion routes the OpenAI request to the matching backend 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) + // Route to the backend that owns this model (default backend = pass-through). + backend, resolved, known := p.resolveModel(req.Model) + if !known { + fmt.Printf("[proxy] unknown model '%s', forwarding as-is via %s\n", req.Model, backend.Name()) + } else { + fmt.Printf("[proxy] model '%s' -> '%s' via %s\n", req.Model, resolved, backend.Name()) } // Update the body with resolved model @@ -144,7 +132,7 @@ func (p *Proxy) ForwardChatCompletion(w http.ResponseWriter, r *http.Request, bo // Generate request/session IDs for upstream requestID := RequestID() - // Get session ID from the client's proxy API key (or default) + // Session ID keyed by the client's proxy API key (or default) userKey := "default" if p.apiKey != "" { userKey = r.Header.Get("Authorization") @@ -154,13 +142,13 @@ func (p *Proxy) ForwardChatCompletion(w http.ResponseWriter, r *http.Request, bo } sessionID := p.SessionID(userKey) - // Build upstream request - upstreamReq, err := http.NewRequestWithContext(r.Context(), "POST", ZenChatURL(), bytes.NewReader(body)) + // Build upstream request to the chosen backend + upstreamReq, err := http.NewRequestWithContext(r.Context(), "POST", backend.ChatURL(), bytes.NewReader(body)) if err != nil { return fmt.Errorf("failed to create upstream request: %w", err) } - for k, v := range UpstreamHeaders(requestID, sessionID) { + for k, v := range backend.Headers(requestID, sessionID) { upstreamReq.Header.Set(k, v) } @@ -174,6 +162,10 @@ func (p *Proxy) ForwardChatCompletion(w http.ResponseWriter, r *http.Request, bo // Handle non-200 responses if resp.StatusCode != 200 { + fmt.Printf("[proxy] upstream status=%d for model='%s' via %s\n", resp.StatusCode, resolved, backend.Name()) + if resp.StatusCode == http.StatusTooManyRequests { + p.markRateLimited(resolved) + } bodyBytes, _ := io.ReadAll(resp.Body) w.Header().Set("Content-Type", "application/json") w.WriteHeader(resp.StatusCode)