diff --git a/.gitignore b/.gitignore index d475273..7b74f6b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ -node_modules/ api-keys.json *.log .env +__pycache__/ +*.pyc +*.bak diff --git a/README.md b/README.md index f787a10..d0fb75b 100644 --- a/README.md +++ b/README.md @@ -4,29 +4,74 @@ Free AI models from [OpenCode](https://opencode.ai) exposed as standard OpenAI a One server — works with any tool that speaks OpenAI or Anthropic format: Cursor, Continue, Cline, Claude Code, aider, opencode CLI, raw `curl`, whatever. +## Related projects + +All three projects share the same CLI interface (`--port`, `--host`, `--proxy`, `--api-key`) and work together through [llama-swap](https://github.com/mmkeeper/llama-swap): + +| Project | Port | Prefix | What | +|---------|------|--------|------| +| [opencode-free-proxy](https://github.com/mmkeeper/opencode-free-proxy) | 6446 | `ocf-` | OpenCode free models (this) | +| [deepseek-free-api](https://github.com/mmkeeper/deepseek-free-api) | 18632 | `dsf-` | DeepSeek free API | +| [mimo-free-proxy](https://github.com/mmkeeper/mimo-free-proxy) | 8788 | `mcf-` | Xiaomi MiMo free API | + ## 30-second setup ```bash -git clone https://github.com/bigdata2211it-web/opencode-free-proxy.git +git clone https://github.com/mmkeeper/opencode-free-proxy.git cd opencode-free-proxy -npm install -node server.mjs +pip install -r requirements.txt +python server.py ``` Done. Server is at `http://localhost:6446`. API keys are in `api-keys.json` (auto-generated on first run). +## CLI arguments + +All projects use the same CLI interface: + +```bash +python server.py --port 6446 --host 127.0.0.1 --proxy socks5://127.0.0.1:9150 --api-key sk-my-key +``` + +| Argument | Default | Description | +|----------|---------|-------------| +| `--port` | `6446` | Listen port | +| `--host` | `127.0.0.1` | Listen host | +| `--proxy` | _(none)_ | SOCKS5 proxy (e.g. `socks5://127.0.0.1:9150`) | +| `--api-key` | _(from api-keys.json)_ | API key for client auth | + +## Environment variables + +| Variable | Default | What | +|----------|---------|------| +| `PROXY_PORT` | `6446` | Server port | +| `HOST` | `127.0.0.1` | Listen host | +| `KEYS_FILE` | `./api-keys.json` | API keys file path | +| `SOCKS5_PROXY` | _(none)_ | SOCKS5 proxy address for upstream requests | + ## What you get -| Model | What it is | Reliability | -|-------|-----------|-------------| -| `deepseek-v4-flash-free` | DeepSeek V4 Flash | Solid | -| `big-pickle` | DeepSeek V4 Flash (alias) | Solid | -| `minimax-m2.5-free` | MiniMax M2.5 | Solid | -| `nemotron-3-super-free` | NVIDIA Nemotron 3 Super | Hit or miss | -| `qwen3.6-plus-free` | Qwen 3.6 Plus | Intermittent | +| Model | What it is | Reliability | Works with proxy | Works without proxy | +|-------|-----------|-------------|------------------|---------------------| +| `mimo-v2.5-free` | Xiaomi MiMo v2.5 | Solid | ✅ | ✅ | +| `deepseek-v4-flash-free` | DeepSeek V4 Flash | Solid | ✅ | ✅ | +| `north-mini-code-free` | North Mini Code | Solid | ✅ | ✅ | +| `nemotron-3-ultra-free` | NVIDIA Nemotron 3 Ultra | Solid | ✅ | ⚠️ sometimes fails | +| `big-pickle` | DeepSeek V4 Flash (alias) | Solid | ✅ | ✅ | All models support streaming, tool calls, and system messages. +### Model name aliases (opencode CLI / Hermes) + +When using the proxy with tools that support provider-prefixed model names (opencode CLI, Hermes), use the `ocf-` prefix: + +| Tool format | Actual model sent to upstream | +|-------------|------------------------------| +| `ocf-mimo-v2.5-free` | `mimo-v2.5-free` | +| `ocf-deepseek-v4-flash-free` | `deepseek-v4-flash-free` | + +The `ocf-` prefix is stripped before forwarding to opencode.ai, so multiple providers (mimo-free-proxy, opencode-free-proxy) can coexist without model name conflicts. + ## API ### OpenAI format — `POST /v1/chat/completions` @@ -113,10 +158,10 @@ Add to `~/.config/opencode/opencode.json`: # On your VPS git clone https://github.com/bigdata2211it-web/opencode-free-proxy.git cd opencode-free-proxy -npm install -node server.mjs # foreground +pip install -r requirements.txt +python server.py # foreground # or -nohup node server.mjs > proxy.log 2>&1 & # background +nohup python server.py > proxy.log 2>&1 & # background ``` If your VPS doesn't expose port 6446, use an SSH tunnel: @@ -137,7 +182,7 @@ After=network.target [Service] Type=simple WorkingDirectory=/opt/opencode-proxy -ExecStart=/usr/bin/node server.mjs +ExecStart=/usr/bin/python3 server.py Restart=always RestartSec=5 Environment=PROXY_PORT=6446 @@ -150,12 +195,35 @@ WantedBy=multi-user.target sudo systemctl enable --now opencode-proxy ``` -## Environment variables +## SOCKS5 Proxy -| Variable | Default | What | -|----------|---------|------| -| `PROXY_PORT` | `6446` | Server port | -| `KEYS_FILE` | `./api-keys.json` | API keys file path | +Route all upstream requests to opencode.ai through a SOCKS5 proxy. + +### CLI argument + +```bash +python server.py --proxy 127.0.0.1:9150 +python server.py --proxy socks5://user:pass@10.0.0.1:1080 +``` + +### Environment variable + +```bash +SOCKS5_PROXY=127.0.0.1:9150 python server.py +SOCKS5_PROXY=socks5://user:pass@10.0.0.1:1080 python server.py +``` + +CLI `--proxy` takes priority over `SOCKS5_PROXY`. If neither is set, requests go directly. + +### systemd with proxy + +```ini +Environment=SOCKS5_PROXY=127.0.0.1:9150 +``` + +## llama-swap integration + +This server works with [llama-swap](https://github.com/mmkeeper/llama-swap) as a peer. See `config.yaml` in the llama-swap repo for an example. ## How it works @@ -169,6 +237,14 @@ Your tool (Cursor, CLI, curl, etc.) opencode.ai/zen/v1/ ← free tier API ``` +### Session persistence + +The proxy maintains conversation sessions automatically. It uses a hash of the message prefix to identify which upstream session to reuse, so multi-turn conversations stay coherent without the client managing session IDs. + +### Model prefix (`ocf-`) + +All models are exposed with an `ocf-` prefix (e.g. `ocf-mimo-v2.5-free`) to avoid conflicts with other providers in tools like Hermes. The prefix is stripped before forwarding to opencode.ai. + The proxy adds `x-opencode-*` authentication headers that the Zen API requires. These were discovered by reverse engineering the opencode binary — without them, even `Authorization: Bearer public` gets rejected with `AuthError`. ### Zen API auth headers (for the curious) diff --git a/package.json b/package.json index 6e52acb..346b8f2 100644 --- a/package.json +++ b/package.json @@ -2,17 +2,9 @@ "name": "opencode-free-proxy", "version": "0.9.0", "description": "Proxy server for OpenCode free-tier AI models via Zen API", - "type": "module", - "main": "server.mjs", + "main": "server.py", "scripts": { - "start": "node server.mjs", - "dev": "node --watch server.mjs" - }, - "dependencies": { - "express": "^4.21.0" - }, - "engines": { - "node": ">=18" + "start": "python server.py" }, "license": "MIT" } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..256316f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.115.0 +uvicorn>=0.32.0 +httpx[socks]>=0.28.0 diff --git a/server.mjs b/server.mjs deleted file mode 100644 index a8444a2..0000000 --- a/server.mjs +++ /dev/null @@ -1,566 +0,0 @@ -import express from "express"; -import crypto from "crypto"; -import https from "https"; -import fs from "fs"; - -const app = express(); -app.use(express.json({ limit: "10mb" })); - -const PORT = process.env.PROXY_PORT || 6446; -const OC_VERSION = "1.15.0"; -const PROXY_VERSION = "9"; - -// ── API Keys ─────────────────────────────────────────────────────── -const keysFile = process.env.KEYS_FILE || "./api-keys.json"; -let apiKeys = {}; -function loadKeys() { - try { apiKeys = JSON.parse(fs.readFileSync(keysFile, "utf8")); } catch {} - if (Object.keys(apiKeys).length === 0) { - apiKeys = { - admin: "oc-" + crypto.randomBytes(20).toString("hex"), - "user-default": "oc-" + crypto.randomBytes(20).toString("hex"), - }; - fs.writeFileSync(keysFile, JSON.stringify(apiKeys, null, 2)); - console.log("[INIT] Generated new API keys →", keysFile); - } -} -loadKeys(); - -function auth(req) { - const hdr = req.headers.authorization || req.headers["x-api-key"] || ""; - const tok = hdr.startsWith("Bearer ") ? hdr.slice(7) : hdr; - for (const [name, key] of Object.entries(apiKeys)) { - if (tok === key) return name; - } - return null; -} - -// ── Helpers ──────────────────────────────────────────────────────── -function ocId(prefix) { - const ts = Date.now().toString(16); - const rnd = crypto.randomBytes(12).toString("base64url").slice(0, 16); - return `${prefix}_${ts}${rnd}`; -} - -const MODELS = [ - "deepseek-v4-flash-free", - "big-pickle", - "minimax-m2.5-free", - "nemotron-3-super-free", - "qwen3.6-plus-free", -]; - -// Track sessions per user (rotate every 30 min) -const userSessions = {}; -function getSession(user) { - const now = Date.now(); - if (!userSessions[user] || now - userSessions[user].ts > 30 * 60 * 1000) { - userSessions[user] = { id: ocId("ses"), ts: now }; - } - return userSessions[user].id; -} - -// ── Zen API transport ────────────────────────────────────────────── -function zenRequest(model, messages, stream, tools, tool_choice, sessionId) { - const reqBody = { model, messages, stream: !!stream }; - if (tools?.length) reqBody.tools = tools; - if (tool_choice) reqBody.tool_choice = tool_choice; - const body = JSON.stringify(reqBody); - const requestId = ocId("msg"); - - return { - body, - options: { - hostname: "opencode.ai", - port: 443, - path: "/zen/v1/chat/completions", - method: "POST", - headers: { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(body), - "Authorization": "Bearer public", - "User-Agent": `opencode/${OC_VERSION} 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, - }, - timeout: 120000, - }, - }; -} - -// Pipe Zen response to client (OpenAI format passthrough) -function pipeZenResponse(zenOpts, body, stream, res) { - const req = https.request(zenOpts, (zenRes) => { - let firstChunk = null; - let headersSent = false; - - zenRes.on("data", (chunk) => { - if (!firstChunk) { - firstChunk = chunk; - const str = chunk.toString().trim(); - - if (str.startsWith("{") && (str.includes("FreeUsageLimitError") || str.includes('"error"'))) { - try { - const parsed = JSON.parse(str); - if (parsed.error || parsed.type === "error") { - const errMsg = parsed.error?.message || parsed.message || "Rate limit exceeded"; - console.log("[ZEN RATE LIMITED]", errMsg); - if (!res.headersSent) { - res.status(429).json({ - error: { message: errMsg + " (free model rate limit)", type: "rate_limit_error", code: "rate_limit_exceeded" } - }); - } - zenRes.resume(); - return; - } - } catch {} - } - - headersSent = true; - if (stream) { - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - "Transfer-Encoding": "chunked", - }); - res.flushHeaders(); - } else { - res.writeHead(zenRes.statusCode, { "Content-Type": "application/json" }); - } - res.write(firstChunk); - if (res.flush) res.flush(); - return; - } - if (headersSent) { - res.write(chunk); - if (res.flush) res.flush(); - } - }); - - zenRes.on("end", () => { - if (!headersSent && !firstChunk) { - console.log("[ZEN EMPTY] No response from Zen API"); - if (!res.headersSent) { - res.status(502).json({ error: { message: "Empty response from upstream", type: "upstream_error" } }); - } - return; - } - if (headersSent) res.end(); - }); - }); - - req.on("error", (e) => { - console.log("[ZEN ERROR]", e.message); - if (!res.headersSent) { - res.status(502).json({ error: { message: "Upstream error: " + e.message, type: "upstream_error" } }); - } - }); - - req.on("timeout", () => { - req.destroy(); - console.log("[ZEN TIMEOUT]"); - if (!res.headersSent) { - res.status(504).json({ error: { message: "Upstream timeout", type: "timeout_error" } }); - } - }); - - req.write(body); - req.end(); -} - -// Collect full Zen response (non-streaming) and return parsed JSON -function zenRequestFull(zenOpts, body) { - return new Promise((resolve, reject) => { - const req = https.request(zenOpts, (zenRes) => { - const chunks = []; - zenRes.on("data", (c) => chunks.push(c)); - zenRes.on("end", () => { - const raw = Buffer.concat(chunks).toString(); - try { - resolve({ status: zenRes.statusCode, data: JSON.parse(raw), raw }); - } catch { - resolve({ status: zenRes.statusCode, data: null, raw }); - } - }); - }); - req.on("error", reject); - req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); }); - req.write(body); - req.end(); - }); -} - -// ── Anthropic Messages → OpenAI conversion ───────────────────────── -function anthropicToOpenAI(body) { - const messages = []; - if (body.system) { - const sys = typeof body.system === "string" ? body.system - : Array.isArray(body.system) ? body.system.map(b => b.text || "").join("\n") : ""; - if (sys) messages.push({ role: "system", content: sys }); - } - for (const msg of body.messages || []) { - if (typeof msg.content === "string") { - messages.push({ role: msg.role, content: msg.content }); - } else if (Array.isArray(msg.content)) { - const text = msg.content - .filter(b => b.type === "text") - .map(b => b.text) - .join("\n"); - // tool_use blocks → assistant tool_calls - const toolUses = msg.content.filter(b => b.type === "tool_use"); - if (toolUses.length && msg.role === "assistant") { - messages.push({ - role: "assistant", - content: text || null, - tool_calls: toolUses.map(t => ({ - id: t.id, - type: "function", - function: { name: t.name, arguments: JSON.stringify(t.input || {}) }, - })), - }); - } else if (msg.content.some(b => b.type === "tool_result")) { - for (const b of msg.content.filter(b => b.type === "tool_result")) { - const resultText = typeof b.content === "string" ? b.content - : Array.isArray(b.content) ? b.content.map(c => c.text || "").join("\n") : ""; - messages.push({ role: "tool", tool_call_id: b.tool_use_id, content: resultText }); - } - } else { - messages.push({ role: msg.role, content: text }); - } - } - } - - const tools = (body.tools || []).map(t => ({ - type: "function", - function: { - name: t.name, - description: t.description || "", - parameters: t.input_schema || {}, - }, - })); - - return { messages, tools: tools.length ? tools : undefined }; -} - -// OpenAI response → Anthropic Messages format -function openAIToAnthropic(oaiResp, model, inputTokens) { - const choice = oaiResp.choices?.[0]; - if (!choice) { - return { - id: ocId("msg"), - type: "message", - role: "assistant", - content: [{ type: "text", text: "" }], - model, - stop_reason: "end_turn", - usage: { input_tokens: inputTokens || 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, - }; - } - - const content = []; - if (choice.message?.content) { - content.push({ type: "text", text: choice.message.content }); - } - if (choice.message?.tool_calls) { - for (const tc of choice.message.tool_calls) { - let input = {}; - try { input = JSON.parse(tc.function.arguments); } catch {} - content.push({ - type: "tool_use", - id: tc.id || ocId("toolu"), - name: tc.function.name, - input, - }); - } - } - if (!content.length) content.push({ type: "text", text: "" }); - - let stopReason = "end_turn"; - if (choice.finish_reason === "tool_calls") stopReason = "tool_use"; - else if (choice.finish_reason === "length") stopReason = "max_tokens"; - else if (choice.finish_reason === "stop") stopReason = "end_turn"; - - return { - id: ocId("msg"), - type: "message", - role: "assistant", - content, - model, - stop_reason: stopReason, - usage: { - input_tokens: oaiResp.usage?.prompt_tokens || inputTokens || 0, - output_tokens: oaiResp.usage?.completion_tokens || 0, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }, - }; -} - -// Stream OpenAI SSE → Anthropic SSE -function pipeZenAsAnthropic(zenOpts, body, model, res, inputTokens) { - const msgId = ocId("msg"); - - const req = https.request(zenOpts, (zenRes) => { - let headersSent = false; - let buffer = ""; - let outputTokens = 0; - let contentIdx = 0; - let toolIdx = -1; - let firstChunkHandled = false; - - function sendSSE(event, data) { - res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); - if (res.flush) res.flush(); - } - - function sendHeaders() { - if (headersSent) return; - headersSent = true; - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }); - res.flushHeaders(); - - sendSSE("message_start", { - type: "message_start", - message: { - id: msgId, type: "message", role: "assistant", content: [], - model, stop_reason: null, - usage: { input_tokens: inputTokens || 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, - }, - }); - } - - zenRes.on("data", (chunk) => { - const str = chunk.toString(); - - // Check for errors on first chunk - if (!firstChunkHandled) { - firstChunkHandled = true; - const trimmed = str.trim(); - if (trimmed.startsWith("{") && (trimmed.includes("FreeUsageLimitError") || trimmed.includes('"error"'))) { - try { - const parsed = JSON.parse(trimmed); - if (parsed.error || parsed.type === "error") { - const errMsg = parsed.error?.message || parsed.message || "Rate limit"; - if (!res.headersSent) { - res.writeHead(429, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ - type: "error", - error: { type: "rate_limit_error", message: errMsg + " (free model rate limit)" }, - })); - } - zenRes.resume(); - return; - } - } catch {} - } - } - - buffer += str; - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - const payload = line.slice(6).trim(); - if (payload === "[DONE]") continue; - - let parsed; - try { parsed = JSON.parse(payload); } catch { continue; } - const delta = parsed.choices?.[0]?.delta; - if (!delta) continue; - - sendHeaders(); - - // Text content - if (delta.content) { - if (contentIdx === 0 && toolIdx === -1) { - sendSSE("content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }); - contentIdx = 1; - } - sendSSE("content_block_delta", { - type: "content_block_delta", index: 0, - delta: { type: "text_delta", text: delta.content }, - }); - outputTokens += Math.ceil(delta.content.length / 4); - } - - // Tool calls - if (delta.tool_calls) { - for (const tc of delta.tool_calls) { - const idx = tc.index ?? 0; - if (idx > toolIdx) { - // Close previous text block if open - if (toolIdx === -1 && contentIdx > 0) { - sendSSE("content_block_stop", { type: "content_block_stop", index: 0 }); - } - toolIdx = idx; - const blockIdx = contentIdx > 0 ? idx + 1 : idx; - sendSSE("content_block_start", { - type: "content_block_start", index: blockIdx, - content_block: { type: "tool_use", id: tc.id || ocId("toolu"), name: tc.function?.name || "" }, - }); - } - if (tc.function?.arguments) { - const blockIdx = contentIdx > 0 ? idx + 1 : idx; - sendSSE("content_block_delta", { - type: "content_block_delta", index: blockIdx, - delta: { type: "input_json_delta", partial_json: tc.function.arguments }, - }); - outputTokens += Math.ceil(tc.function.arguments.length / 4); - } - } - } - - // Finish - if (parsed.choices?.[0]?.finish_reason) { - const fr = parsed.choices[0].finish_reason; - // Close open blocks - const totalBlocks = (contentIdx > 0 ? 1 : 0) + (toolIdx >= 0 ? toolIdx + 1 : 0); - for (let i = 0; i < totalBlocks; i++) { - sendSSE("content_block_stop", { type: "content_block_stop", index: i }); - } - - let stopReason = "end_turn"; - if (fr === "tool_calls") stopReason = "tool_use"; - else if (fr === "length") stopReason = "max_tokens"; - - sendSSE("message_delta", { - type: "message_delta", - delta: { stop_reason: stopReason }, - usage: { output_tokens: outputTokens }, - }); - sendSSE("message_stop", { type: "message_stop" }); - } - } - }); - - zenRes.on("end", () => { - if (!headersSent) { - if (!res.headersSent) { - res.status(502).json({ type: "error", error: { type: "upstream_error", message: "Empty response" } }); - } - return; - } - res.end(); - }); - }); - - req.on("error", (e) => { - console.log("[ZEN ERROR]", e.message); - if (!res.headersSent) { - res.status(502).json({ type: "error", error: { type: "upstream_error", message: e.message } }); - } - }); - - req.on("timeout", () => { - req.destroy(); - if (!res.headersSent) { - res.status(504).json({ type: "error", error: { type: "timeout_error", message: "Upstream timeout" } }); - } - }); - - req.write(body); - req.end(); -} - -// ── Routes: OpenAI format ────────────────────────────────────────── -app.get("/v1/models", (_req, res) => { - res.json({ - object: "list", - data: MODELS.map((id) => ({ - id, object: "model", created: 1779000000, owned_by: "opencode-free", - })), - }); -}); - -app.post("/v1/chat/completions", (req, res) => { - const user = auth(req); - if (!user) return res.status(401).json({ error: { message: "Invalid API key" } }); - - const { model, messages, stream, tools, tool_choice } = req.body; - if (!MODELS.includes(model)) { - return res.status(400).json({ error: { message: `Unknown model: ${model}. Available: ${MODELS.join(", ")}` } }); - } - - const sessionId = getSession(user); - const msgSummary = (messages || []).map(m => ({ role: m.role, len: (typeof m.content === "string" ? m.content : JSON.stringify(m.content || "")).length })); - console.log("[OAI]", new Date().toISOString(), user, model, stream ? "stream" : "sync", "msgs:", JSON.stringify(msgSummary)); - - const { body, options } = zenRequest(model, messages, stream, tools, tool_choice, sessionId); - pipeZenResponse(options, body, stream, res); -}); - -// ── Routes: Anthropic Messages format ────────────────────────────── -app.post("/v1/messages", async (req, res) => { - const user = auth(req); - if (!user) { - return res.status(401).json({ type: "error", error: { type: "authentication_error", message: "Invalid API key" } }); - } - - const { model, stream } = req.body; - if (!MODELS.includes(model)) { - return res.status(400).json({ - type: "error", - error: { type: "invalid_request_error", message: `Unknown model: ${model}. Available: ${MODELS.join(", ")}` }, - }); - } - - const sessionId = getSession(user); - const { messages, tools } = anthropicToOpenAI(req.body); - const inputTokens = JSON.stringify(messages).length / 4 | 0; - - console.log("[ANT]", new Date().toISOString(), user, model, stream ? "stream" : "sync", "msgs:", messages.length); - - const { body, options } = zenRequest(model, messages, stream, tools, undefined, sessionId); - - if (stream) { - pipeZenAsAnthropic(options, body, model, res, inputTokens); - } else { - try { - const zenResp = await zenRequestFull(options, body); - if (zenResp.status === 429 || zenResp.data?.error) { - const errMsg = zenResp.data?.error?.message || "Rate limit exceeded"; - return res.status(429).json({ - type: "error", error: { type: "rate_limit_error", message: errMsg + " (free model rate limit)" }, - }); - } - if (!zenResp.data?.choices) { - return res.status(502).json({ - type: "error", error: { type: "upstream_error", message: "Invalid upstream response" }, - }); - } - res.json(openAIToAnthropic(zenResp.data, model, inputTokens)); - } catch (e) { - console.log("[ZEN ERROR]", e.message); - res.status(502).json({ type: "error", error: { type: "upstream_error", message: e.message } }); - } - } -}); - -// ── Health ────────────────────────────────────────────────────────── -app.get("/health", (_req, res) => res.json({ - status: "ok", version: `v${PROXY_VERSION}`, models: MODELS.length, - endpoints: ["/v1/chat/completions", "/v1/messages", "/v1/models"], -})); - -// ── Start ────────────────────────────────────────────────────────── -app.listen(PORT, "0.0.0.0", () => { - console.log(`OpenCode Free Proxy v${PROXY_VERSION} on http://0.0.0.0:${PORT}`); - console.log(" OpenAI: POST /v1/chat/completions"); - console.log(" Anthropic: POST /v1/messages"); - console.log(" Models: GET /v1/models"); - console.log(" Health: GET /health"); - console.log(" Models:", MODELS.join(", ")); - for (const [name, key] of Object.entries(apiKeys)) { - console.log(` ${name.padEnd(15)} ${key}`); - } -}); diff --git a/server.py b/server.py new file mode 100644 index 0000000..925cafc --- /dev/null +++ b/server.py @@ -0,0 +1,638 @@ +import argparse +import json +import os +import secrets +import sys +import time +from datetime import datetime, timezone + +import httpx +import uvicorn + + +def log(*a): + msg = f"[{time.strftime('%H:%M:%S')}] " + " ".join(str(x) for x in a) + print(msg, flush=True) + with open("proxy.log", "a", encoding="utf-8") as f: + f.write(msg + "\n") +from fastapi import FastAPI, Request, Response +from fastapi.responses import JSONResponse, StreamingResponse + +# ── CLI args ─────────────────────────────────────────────────────── + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="OpenCode Free Proxy") + p.add_argument("--port", type=int, default=None, help="Listen port (default: 6446)") + p.add_argument("--host", default=None, help="Listen host (default: 0.0.0.0)") + p.add_argument("--proxy", default=None, help="SOCKS5 proxy (socks5://host:port)") + p.add_argument("--api-key", default=None, help="API key for client auth") + return p.parse_args() + +args = parse_args() + +# ── SOCKS5 Proxy ────────────────────────────────────────────────── + +def normalize_proxy_url(raw: str | None) -> str | None: + if not raw: + return None + if not raw.startswith("socks5://") and not raw.startswith("socks4://"): + return "socks5://" + raw + return raw + + +PROXY_URL = normalize_proxy_url(args.proxy or os.environ.get("SOCKS5_PROXY")) + +http_client = httpx.AsyncClient( + base_url="https://opencode.ai", + timeout=httpx.Timeout(120.0), + proxy=PROXY_URL, +) + +# ── App ─────────────────────────────────────────────────────────── + +app = FastAPI() + +PORT = args.port or int(os.environ.get("PORT", "6446")) +HOST = args.host or os.environ.get("HOST", "0.0.0.0") +OC_VERSION = "1.15.0" +PROXY_VERSION = "9" + +# ── API Keys ────────────────────────────────────────────────────── + +API_KEY = args.api_key or os.environ.get("LOCAL_KEY") or os.environ.get("API_KEY") + + +def auth(request: Request) -> str | None: + if not API_KEY: + return "user" + hdr = request.headers.get("authorization") or request.headers.get("x-api-key") or "" + tok = hdr[7:] if hdr.startswith("Bearer ") else hdr + if tok == API_KEY: + return "user" + return None + + +# ── Helpers ─────────────────────────────────────────────────────── + +def oc_id(prefix: str) -> str: + ts = format(int(time.time() * 1000), "x") + rnd = secrets.token_urlsafe(12)[:16] + return f"{prefix}_{ts}{rnd}" + + +MODELS = [ + "ocf-mimo-v2.5-free", + "ocf-deepseek-v4-flash-free", + "ocf-north-mini-code-free", + "ocf-nemotron-3-ultra-free", + "ocf-big-pickle", +] + +# Session per conversation (hash-based lookup) +import hashlib + +# {user: {hash_of_messages: server_session_id}} +_user_sessions: dict[str, dict[str, str]] = {} + + +def _hash_messages(messages: list[dict]) -> str: + parts = [] + for m in (messages or []): + role = m.get("role", "") + content = m.get("content") or "" + if isinstance(content, list): + content = json.dumps(content, ensure_ascii=False) + parts.append(f"{role}:{content}") + return hashlib.sha256("||".join(parts).encode()).hexdigest()[:16] + + +def get_session(user: str, messages: list[dict]) -> str: + if user not in _user_sessions: + _user_sessions[user] = {} + sessions = _user_sessions[user] + + # Check hashes from longest prefix to shortest + for n in range(len(messages), 0, -1): + h = _hash_messages(messages[:n]) + if h in sessions: + full_h = _hash_messages(messages) + sessions[full_h] = sessions[h] + log(f"SESSION match prefix={n}/{len(messages)} -> {sessions[h]}") + return sessions[h] + + # No match — new session + new_id = f"ses_{oc_id('ses')}" + full_h = _hash_messages(messages) + sessions[full_h] = new_id + log(f"SESSION new {new_id} (msgs={len(messages)})") + return new_id + + +# ── Zen API transport ───────────────────────────────────────────── + +def strip_prefix(model: str) -> str: + return model[4:] if model.startswith("ocf-") else model + + +def zen_request(model, messages, stream, tools, tool_choice, session_id): + req_body: dict = {"model": strip_prefix(model), "messages": messages, "stream": bool(stream)} + if tools: + req_body["tools"] = tools + if tool_choice: + req_body["tool_choice"] = tool_choice + + request_id = oc_id("msg") + headers = { + "Content-Type": "application/json", + "Authorization": "Bearer public", + "User-Agent": f"opencode/{OC_VERSION} ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.13", + "x-opencode-client": "cli", + "x-opencode-project": "global", + "x-opencode-request": request_id, + "x-opencode-session": session_id, + } + return req_body, headers + + +# ── Anthropic Messages → OpenAI conversion ──────────────────────── + +def anthropic_to_openai(body: dict) -> tuple[list[dict], list[dict] | None]: + messages = [] + + if body.get("system"): + sys_val = body["system"] + if isinstance(sys_val, str): + sys_text = sys_val + elif isinstance(sys_val, list): + sys_text = "\n".join(b.get("text", "") for b in sys_val) + else: + sys_text = "" + if sys_text: + messages.append({"role": "system", "content": sys_text}) + + for msg in body.get("messages", []): + content = msg.get("content") + if isinstance(content, str): + messages.append({"role": msg["role"], "content": content}) + elif isinstance(content, list): + text = "\n".join(b.get("text", "") for b in content if b.get("type") == "text") + tool_uses = [b for b in content if b.get("type") == "tool_use"] + + if tool_uses and msg.get("role") == "assistant": + messages.append({ + "role": "assistant", + "content": text or None, + "tool_calls": [ + { + "id": t["id"], + "type": "function", + "function": { + "name": t["name"], + "arguments": json.dumps(t.get("input") or {}), + }, + } + for t in tool_uses + ], + }) + elif any(b.get("type") == "tool_result" for b in content): + for b in content: + if b.get("type") == "tool_result": + c = b.get("content") + if isinstance(c, str): + result_text = c + elif isinstance(c, list): + result_text = "\n".join(x.get("text", "") for x in c) + else: + result_text = "" + messages.append({ + "role": "tool", + "tool_call_id": b["tool_use_id"], + "content": result_text, + }) + else: + messages.append({"role": msg["role"], "content": text}) + else: + messages.append({"role": msg["role"], "content": str(content)}) + + tools_out = [ + { + "type": "function", + "function": { + "name": t["name"], + "description": t.get("description") or "", + "parameters": t.get("input_schema") or {}, + }, + } + for t in body.get("tools", []) + ] + + return messages, tools_out or None + + +# ── OpenAI response → Anthropic Messages format ────────────────── + +def openai_to_anthropic(oai_resp: dict, model: str, input_tokens: int) -> dict: + choice = (oai_resp.get("choices") or [None])[0] + if not choice: + return { + "id": oc_id("msg"), + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": ""}], + "model": model, + "stop_reason": "end_turn", + "usage": { + "input_tokens": input_tokens or 0, + "output_tokens": 0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + }, + } + + content = [] + msg = choice.get("message") or {} + if msg.get("content"): + content.append({"type": "text", "text": msg["content"]}) + for tc in msg.get("tool_calls", []): + try: + inp = json.loads(tc["function"]["arguments"]) + except (json.JSONDecodeError, KeyError): + inp = {} + content.append({ + "type": "tool_use", + "id": tc.get("id") or oc_id("toolu"), + "name": tc["function"]["name"], + "input": inp, + }) + if not content: + content.append({"type": "text", "text": ""}) + + stop_reason = "end_turn" + fr = choice.get("finish_reason") + if fr == "tool_calls": + stop_reason = "tool_use" + elif fr == "length": + stop_reason = "max_tokens" + + return { + "id": oc_id("msg"), + "type": "message", + "role": "assistant", + "content": content, + "model": model, + "stop_reason": stop_reason, + "usage": { + "input_tokens": (oai_resp.get("usage") or {}).get("prompt_tokens") or input_tokens or 0, + "output_tokens": (oai_resp.get("usage") or {}).get("completion_tokens") or 0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + }, + } + + +# ── Routes: OpenAI format ───────────────────────────────────────── + +@app.get("/v1/models") +async def list_models(): + return { + "object": "list", + "data": [ + {"id": m, "object": "model", "created": 1779000000, "owned_by": "opencode-free"} + for m in MODELS + ], + } + + +@app.post("/v1/chat/completions") +async def chat_completions(request: Request): + user = auth(request) + if not user: + return JSONResponse(status_code=401, content={"error": {"message": "Invalid API key"}}) + + body = await request.json() + model = body.get("model") + messages = body.get("messages") + stream = body.get("stream") + tools = body.get("tools") + tool_choice = body.get("tool_choice") + + log(f"REQUEST model={model} stream={stream} msgs={len(messages or [])} tools={len(tools or [])}") + + if model not in MODELS: + return JSONResponse( + status_code=400, + content={"error": {"message": f"Unknown model: {model}. Available: {', '.join(MODELS)}"}}, + ) + + session_id = get_session(user, messages) + msg_summary = [ + {"role": m.get("role"), "len": len(m.get("content") if isinstance(m.get("content"), str) else json.dumps(m.get("content") or ""))} + for m in (messages or []) + ] + ts = datetime.now(timezone.utc).isoformat() + print(f"[OAI] {ts} {user} {model} {'stream' if stream else 'sync'} msgs: {json.dumps(msg_summary)}") + + req_body, headers = zen_request(model, messages, stream, tools, tool_choice, session_id) + + if stream: + return StreamingResponse( + stream_openai(req_body, headers), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", + }, + ) + else: + try: + resp = await http_client.post( + "/zen/v1/chat/completions", + json=req_body, + headers=headers, + ) + data = resp.json() + if resp.status_code == 429 or data.get("error"): + err_msg = (data.get("error") or {}).get("message") or "Rate limit exceeded" + return JSONResponse( + status_code=429, + content={"error": {"message": err_msg + " (free model rate limit)", "type": "rate_limit_error", "code": "rate_limit_exceeded"}}, + ) + return data + except Exception as e: + print(f"[ZEN ERROR] {e}") + return JSONResponse(status_code=502, content={"error": {"message": f"Upstream error: {e}", "type": "upstream_error"}}) + + +async def stream_openai(req_body: dict, headers: dict): + log(f"STREAM session={headers.get('x-opencode-session')} model={req_body.get('model')} msgs={len(req_body.get('messages', []))}") + async with http_client.stream("POST", "/zen/v1/chat/completions", json=req_body, headers=headers) as resp: + if resp.status_code == 429: + try: + raw = await resp.aread() + data = json.loads(raw) + err_msg = (data.get("error") or {}).get("message") or "Rate limit exceeded" + except Exception: + err_msg = "Rate limit exceeded" + log(f"STREAM 429: {err_msg}") + yield f'data: {json.dumps({"error": {"message": err_msg + " (free model rate limit)", "type": "rate_limit_error", "code": "rate_limit_exceeded"}})}\n\n' + return + + if resp.status_code >= 400: + raw = await resp.aread() + log(f"STREAM error {resp.status_code}: {raw[:500]}") + yield f'data: {json.dumps({"error": {"message": f"Upstream error {resp.status_code}", "type": "upstream_error"}})}\n\n' + return + + chunk_count = 0 + first_chunks = [] + last_chunks = [] + async for line in resp.aiter_lines(): + if line: + # Skip SSE comments (lines starting with :) + if line.startswith(":"): + continue + chunk_count += 1 + yield line + "\n\n" + if chunk_count <= 3: + first_chunks.append(line[:200]) + if line.strip() == "data: [DONE]": + break + last_chunks.append(line[:200]) + if len(last_chunks) > 5: + last_chunks.pop(0) + log(f"STREAM done: {chunk_count} chunks") + log(f" first: {first_chunks}") + log(f" last: {last_chunks}") + + +# ── Routes: Anthropic Messages format ───────────────────────────── + +@app.post("/v1/messages") +async def messages(request: Request): + user = auth(request) + if not user: + return JSONResponse( + status_code=401, + content={"type": "error", "error": {"type": "authentication_error", "message": "Invalid API key"}}, + ) + + body = await request.json() + model = body.get("model") + stream = body.get("stream") + + if model not in MODELS: + return JSONResponse( + status_code=400, + content={"type": "error", "error": {"type": "invalid_request_error", "message": f"Unknown model: {model}. Available: {', '.join(MODELS)}"}}, + ) + + oai_messages, tools = anthropic_to_openai(body) + session_id = get_session(user, oai_messages) + input_tokens = len(json.dumps(oai_messages)) // 4 + + ts = datetime.now(timezone.utc).isoformat() + print(f"[ANT] {ts} {user} {model} {'stream' if stream else 'sync'} msgs: {len(oai_messages)}") + + req_body, headers = zen_request(model, oai_messages, stream, tools, None, session_id) + + if stream: + return StreamingResponse( + stream_anthropic(req_body, headers, model, input_tokens), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", + }, + ) + else: + try: + resp = await http_client.post( + "/zen/v1/chat/completions", + json=req_body, + headers=headers, + ) + data = resp.json() + if resp.status_code == 429 or data.get("error"): + err_msg = (data.get("error") or {}).get("message") or "Rate limit exceeded" + return JSONResponse( + status_code=429, + content={"type": "error", "error": {"type": "rate_limit_error", "message": err_msg + " (free model rate limit)"}}, + ) + if not data.get("choices"): + return JSONResponse( + status_code=502, + content={"type": "error", "error": {"type": "upstream_error", "message": "Invalid upstream response"}}, + ) + return openai_to_anthropic(data, model, input_tokens) + except Exception as e: + print(f"[ZEN ERROR] {e}") + return JSONResponse(status_code=502, content={"type": "error", "error": {"type": "upstream_error", "message": str(e)}}) + + +async def stream_anthropic(req_body: dict, headers: dict, model: str, input_tokens: int): + msg_id = oc_id("msg") + buffer = "" + content_idx = 0 + tool_idx = -1 + output_tokens = 0 + headers_sent = False + + def send_sse(event: str, data: dict) -> str: + return f"event: {event}\ndata: {json.dumps(data)}\n\n" + + try: + async with http_client.stream("POST", "/zen/v1/chat/completions", json=req_body, headers=headers) as resp: + if resp.status_code == 429: + try: + raw = await resp.aread() + parsed = json.loads(raw) + err_msg = (parsed.get("error") or {}).get("message") or "Rate limit" + except Exception: + err_msg = "Rate limit" + yield send_sse("error", {"type": "error", "error": {"type": "rate_limit_error", "message": err_msg + " (free model rate limit)"}}) + return + + async for raw_line in resp.aiter_lines(): + if not raw_line: + continue + + # Check for errors on first chunk + if not headers_sent: + trimmed = raw_line.strip() + if trimmed.startswith("{") and ("FreeUsageLimitError" in trimmed or '"error"' in trimmed): + try: + parsed = json.loads(trimmed) + if parsed.get("error") or parsed.get("type") == "error": + err_msg = (parsed.get("error") or {}).get("message") or parsed.get("message") or "Rate limit" + yield send_sse("error", {"type": "error", "error": {"type": "rate_limit_error", "message": err_msg + " (free model rate limit)"}}) + return + except json.JSONDecodeError: + pass + + # Process SSE lines from upstream + if raw_line.startswith("data: "): + payload = raw_line[6:].strip() + if payload == "[DONE]": + # Close open blocks + total_blocks = (1 if content_idx > 0 else 0) + (tool_idx + 1 if tool_idx >= 0 else 0) + for i in range(total_blocks): + yield send_sse("content_block_stop", {"type": "content_block_stop", "index": i}) + yield send_sse("message_delta", { + "type": "message_delta", + "delta": {"stop_reason": "end_turn"}, + "usage": {"output_tokens": output_tokens}, + }) + yield send_sse("message_stop", {"type": "message_stop"}) + return + + try: + parsed = json.loads(payload) + except json.JSONDecodeError: + continue + + delta = (parsed.get("choices") or [{}])[0].get("delta") or {} + if not delta: + continue + + if not headers_sent: + headers_sent = True + yield send_sse("message_start", { + "type": "message_start", + "message": { + "id": msg_id, "type": "message", "role": "assistant", "content": [], + "model": model, "stop_reason": None, + "usage": {"input_tokens": input_tokens or 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}, + }, + }) + + # Text content + if delta.get("content"): + if content_idx == 0 and tool_idx == -1: + yield send_sse("content_block_start", {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}) + content_idx = 1 + yield send_sse("content_block_delta", { + "type": "content_block_delta", "index": 0, + "delta": {"type": "text_delta", "text": delta["content"]}, + }) + output_tokens += -(-len(delta["content"]) // 4) # ceil division + + # Tool calls + for tc in delta.get("tool_calls", []): + idx = tc.get("index", 0) + if idx > tool_idx: + if tool_idx == -1 and content_idx > 0: + yield send_sse("content_block_stop", {"type": "content_block_stop", "index": 0}) + tool_idx = idx + block_idx = idx + 1 if content_idx > 0 else idx + yield send_sse("content_block_start", { + "type": "content_block_start", "index": block_idx, + "content_block": {"type": "tool_use", "id": tc.get("id") or oc_id("toolu"), "name": (tc.get("function") or {}).get("name") or ""}, + }) + func = tc.get("function") or {} + if func.get("arguments"): + block_idx = idx + 1 if content_idx > 0 else idx + yield send_sse("content_block_delta", { + "type": "content_block_delta", "index": block_idx, + "delta": {"type": "input_json_delta", "partial_json": func["arguments"]}, + }) + output_tokens += -(-len(func["arguments"]) // 4) + + # Finish + finish_reason = (parsed.get("choices") or [{}])[0].get("finish_reason") + if finish_reason: + total_blocks = (1 if content_idx > 0 else 0) + (tool_idx + 1 if tool_idx >= 0 else 0) + for i in range(total_blocks): + yield send_sse("content_block_stop", {"type": "content_block_stop", "index": i}) + + stop_reason = "end_turn" + if finish_reason == "tool_calls": + stop_reason = "tool_use" + elif finish_reason == "length": + stop_reason = "max_tokens" + + yield send_sse("message_delta", { + "type": "message_delta", + "delta": {"stop_reason": stop_reason}, + "usage": {"output_tokens": output_tokens}, + }) + yield send_sse("message_stop", {"type": "message_stop"}) + return + + except httpx.HTTPError as e: + print(f"[ZEN ERROR] {e}") + if not headers_sent: + yield send_sse("error", {"type": "error", "error": {"type": "upstream_error", "message": str(e)}}) + + +# ── Health ──────────────────────────────────────────────────────── + +@app.get("/health") +async def health(): + return { + "status": "ok", + "version": f"v{PROXY_VERSION}", + "models": len(MODELS), + "socks5": PROXY_URL, + "endpoints": ["/v1/chat/completions", "/v1/messages", "/v1/models"], + } + + +# ── Start ───────────────────────────────────────────────────────── + +if __name__ == "__main__": + print(f"OpenCode Free Proxy v{PROXY_VERSION} on http://{HOST}:{PORT}") + if PROXY_URL: + print(f" SOCKS5 proxy: {PROXY_URL}") + else: + print(" No SOCKS5 proxy configured (use --proxy or SOCKS5_PROXY)") + print(" OpenAI: POST /v1/chat/completions") + print(" Anthropic: POST /v1/messages") + print(" Models: GET /v1/models") + print(" Health: GET /health") + print(f" Models: {', '.join(MODELS)}") + if API_KEY: + print(f" API key: {API_KEY[:8]}...") + else: + print(" API key: (none - open access)") + + uvicorn.run(app, host=HOST, port=PORT, log_level="info")