Codefree / app.py
bahi-bh's picture
Update app.py
a264089 verified
Raw History Blame Contribute Delete
24.4 kB
"""
OpenCode ↔ Anthropic Gateway — latency-optimized edition
"""
import os
import hashlib
import base64
import uuid
import asyncio
import logging
from contextlib import asynccontextmanager
try:
import orjson as _json_lib
def _dumps(obj: object) -> str:
return _json_lib.dumps(obj).decode()
def _loads(s):
return _json_lib.loads(s)
except ImportError:
import json as _json_lib # type: ignore[assignment]
def _dumps(obj: object) -> str:
return _json_lib.dumps(obj, separators=(",", ":"))
def _loads(s):
return _json_lib.loads(s)
import json
import httpx
from fastapi import FastAPI, Request, Header
from fastapi.responses import JSONResponse, StreamingResponse
from starlette.background import BackgroundTask
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger("OpenCodeGateway")
OPENCODE_API_KEY = os.getenv("OPENCODE_API_KEY", "").strip()
UPSTREAM_URL = "https://opencode.ai/zen/v1/chat/completions"
MAX_RETRIES = 2
THINKING_MODE = os.getenv("THINKING_MODE", "enabled").strip().lower()
_OPENCODE_VERSION = os.getenv("OPENCODE_VERSION", "1.15.4")
_USER_ID = os.getenv("OPENCODE_USER_ID", uuid.uuid4().hex)
_PROJECT_ID = os.getenv("OPENCODE_PROJECT_ID", uuid.uuid4().hex)
_STATIC_UPSTREAM_HEADERS = {
"Content-Type": "application/json",
"Accept-Encoding": "identity",
"Accept": "application/json",
"Origin": "https://opencode.ai",
"Referer": "https://opencode.ai/",
"User-Agent": f"opencode/{_OPENCODE_VERSION}",
"x-opencode-client": "cli",
}
def _make_upstream_headers(api_key: str) -> dict:
return {
**_STATIC_UPSTREAM_HEADERS,
"x-api-key": api_key,
"x-opencode-request": _USER_ID,
"x-opencode-project": _PROJECT_ID,
"x-opencode-session": uuid.uuid4().hex,
}
STREAM_RESPONSE_HEADERS = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
# =========================================================
# MODELS
# =========================================================
# النماذج المجانية الحالية في OpenCode Zen (مُحدَّثة 2026-06):
# deepseek-v4-flash → DeepSeek V4 Flash Free (الأقوى والأسرع)
# mimo-v2.5 → MiMo-V2.5 Free (سريع جداً)
# north-mini-code → North Mini Code Free (مخصص للكود)
# big-pickle → Big Pickle Free (مجاني دائم)
#
# الأسماء القديمة (nemotron-3-super-free, deepseek-v4-flash-free …)
# لم تعد موجودة — الأسماء الصحيحة الآن بدون لاحقة "-free".
MODEL_MAP = {
# Opus → أقوى نموذج مجاني متاح
"claude-opus-4-6": "deepseek-v4-flash",
"claude-opus-4-5": "deepseek-v4-flash",
# Sonnet → DeepSeek V4 Flash (أفضل توازن جودة/سرعة)
"claude-sonnet-4-6": "deepseek-v4-flash",
"claude-sonnet-4-5": "deepseek-v4-flash",
# Haiku → MiMo-V2.5 (أسرع للمهام الخفيفة)
"claude-haiku-4-6": "mimo-v2.5",
"claude-haiku-4-5": "mimo-v2.5",
"claude-haiku-4-5-20251001": "mimo-v2.5",
}
DEFAULT_MODEL = "deepseek-v4-flash"
_MODELS_CACHE = {
"object": "list",
"data": [
{"id": m, "object": "model", "created": 1710000000, "owned_by": "gateway"}
for m in MODEL_MAP
],
}
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.client = httpx.AsyncClient(
http2=True,
timeout=httpx.Timeout(connect=15, read=300, write=60, pool=30),
limits=httpx.Limits(max_connections=100, max_keepalive_connections=30),
follow_redirects=True,
)
logger.info("Gateway started — THINKING_MODE=%s json_backend=%s",
THINKING_MODE, _json_lib.__name__)
yield
await app.state.client.aclose()
app = FastAPI(lifespan=lifespan)
@app.get("/")
@app.head("/")
async def root():
return {"status": "ok", "service": "OpenCode Gateway"}
@app.get("/health")
async def health():
return {"status": "healthy"}
@app.get("/v1/models")
async def get_models():
return _MODELS_CACHE
def _fix_schema(schema):
if not isinstance(schema, dict):
return {"type": "object", "properties": {}}
if schema.get("type") not in (
"object", "array", "string", "number", "boolean", "integer"
):
schema = dict(schema)
schema["type"] = "object"
schema.setdefault("properties", {})
return schema
def _synthetic_signature(text: str) -> str:
digest = hashlib.sha256(text.encode()).digest()
return "synthetic_" + base64.urlsafe_b64encode(digest).decode().rstrip("=")
def normalize_messages(messages: list) -> list:
output = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
reasoning_content = msg.get("reasoning_content")
if isinstance(content, str):
result = {"role": role, "content": content}
if reasoning_content is not None:
result["reasoning_content"] = reasoning_content
output.append(result)
continue
if isinstance(content, list):
text_parts = []
tool_calls = []
tool_results = []
thinking_parts = []
for item in content:
if not isinstance(item, dict):
text_parts.append(str(item))
continue
t = item.get("type", "")
if t == "text":
txt = item.get("text", "")
if txt: text_parts.append(txt)
elif t == "thinking":
think_txt = item.get("thinking", "")
if think_txt: thinking_parts.append(think_txt)
elif t == "tool_use":
tool_calls.append({
"id": item.get("id", f"call_{uuid.uuid4().hex[:8]}"),
"type": "function",
"function": {
"name": item.get("name", ""),
"arguments": json.dumps(item.get("input", {})),
},
})
elif t == "tool_result":
rc = item.get("content", "")
if isinstance(rc, list):
rc = "\n".join(
c.get("text", "") if isinstance(c, dict) else str(c)
for c in rc
)
tool_results.append({
"role": "tool",
"tool_call_id": item.get("tool_use_id", ""),
"content": str(rc),
})
final_reasoning = (
reasoning_content if reasoning_content is not None
else ("\n".join(thinking_parts) if thinking_parts else None)
)
if tool_calls:
assistant_msg = {
"role": "assistant",
"tool_calls": tool_calls,
"content": "\n".join(text_parts) if text_parts else None,
}
if final_reasoning is not None:
assistant_msg["reasoning_content"] = final_reasoning
output.append(assistant_msg)
elif tool_results:
output.extend(tool_results)
else:
result = {"role": role, "content": "\n".join(text_parts)}
if final_reasoning is not None:
result["reasoning_content"] = final_reasoning
output.append(result)
continue
result = {"role": role, "content": str(content)}
if reasoning_content is not None:
result["reasoning_content"] = reasoning_content
output.append(result)
return output
def convert_tools_to_openai(tools: list) -> list:
if not tools:
return []
return [
{
"type": "function",
"function": {
"name": t.get("name", ""),
"description": t.get("description", ""),
"parameters": _fix_schema(t.get("input_schema", {})),
},
}
for t in tools
]
def map_tool_choice(raw, has_tools: bool, thinking_on: bool):
if not raw or not has_tools: return None
if isinstance(raw, dict):
kind = raw.get("type", "auto")
if thinking_on and kind in ("any", "tool"):
logger.warning("tool_choice '%s' incompatible with extended thinking → auto", kind)
return "auto"
if kind == "any": return "required"
if kind == "tool": return {"type": "function", "function": {"name": raw.get("name", "")}}
return kind
if thinking_on and raw in ("any", "required"):
return "auto"
return raw
def build_payload(body, messages, tools, tool_choice, model, stream):
thinking_on = THINKING_MODE == "enabled"
payload = {
"model": model,
"messages": messages,
"max_tokens": min(body.get("max_tokens", 32000), 64000),
"stream": stream,
"thinking": {"type": "enabled"} if thinking_on else {"type": "disabled"},
}
if not thinking_on:
payload["temperature"] = body.get("temperature", 0.7)
elif "temperature" in body:
payload["temperature"] = body["temperature"]
if tools:
payload["tools"] = tools
if tool_choice is not None:
payload["tool_choice"] = tool_choice
return payload
_FINISH_MAP = {"tool_calls": "tool_use", "length": "max_tokens"}
def convert_to_anthropic(parsed: dict, requested_model: str) -> dict:
content_blocks = []
stop_reason = "end_turn"
try:
choice = (parsed.get("choices") or [{}])[0]
message = choice.get("message", {})
stop_reason = _FINISH_MAP.get(choice.get("finish_reason"), "end_turn")
reasoning = message.get("reasoning_content")
if reasoning:
content_blocks.append({
"type": "thinking", "thinking": reasoning,
"signature": _synthetic_signature(reasoning),
})
text = message.get("content") or ""
if text:
content_blocks.append({"type": "text", "text": text})
for tc in (message.get("tool_calls") or []):
fn = tc.get("function", {})
try: parsed_args = _loads(fn.get("arguments", "{}"))
except: parsed_args = {"raw": fn.get("arguments", "")}
content_blocks.append({
"type": "tool_use",
"id": tc.get("id", f"toolu_{uuid.uuid4().hex[:8]}"),
"name": fn.get("name", ""),
"input": parsed_args,
})
except Exception as exc:
logger.warning("convert_to_anthropic error: %s", exc)
if not content_blocks:
content_blocks.append({"type": "text", "text": ""})
usage = parsed.get("usage", {})
return {
"id": f"msg_{uuid.uuid4().hex}", "type": "message",
"role": "assistant", "model": requested_model,
"content": content_blocks, "stop_reason": stop_reason,
"stop_sequence": None,
"usage": {
"input_tokens": usage.get("prompt_tokens", 0),
"output_tokens": usage.get("completion_tokens", 0),
},
}
async def _iter_sse_lines(response):
buf = b""
async for chunk in response.aiter_bytes():
if not chunk: continue
buf += chunk
while True:
nl = buf.find(b"\n")
if nl == -1: break
line = buf[:nl].rstrip(b"\r").decode("utf-8", errors="replace")
buf = buf[nl + 1:]
yield line
_MSG_START = "event: message_start\ndata: "
_BLK_START = "event: content_block_start\ndata: "
_BLK_DELTA = "event: content_block_delta\ndata: "
_BLK_STOP = "event: content_block_stop\ndata: "
_MSG_DELTA = "event: message_delta\ndata: "
_MSG_STOP = "event: message_stop\ndata: "
async def convert_stream_to_anthropic(response, requested_model: str):
msg_id = f"msg_{uuid.uuid4().hex}"
input_tokens = 0
output_tokens = 0
yield (f"{_MSG_START}" + _dumps({
"type": "message_start",
"message": {
"id": msg_id, "type": "message", "role": "assistant",
"model": requested_model, "content": [],
"stop_reason": None, "stop_sequence": None,
"usage": {"input_tokens": 0, "output_tokens": 0},
},
}) + "\n\n")
yield 'event: ping\ndata: {"type":"ping"}\n\n'
tool_state = {}
tool_block_idx = {}
next_idx = 0
reasoning_block_idx = None
reasoning_text_acc = ""
reasoning_closed = False
text_block_idx = None
stop_reason = "end_turn"
try:
async for line in _iter_sse_lines(response):
if not line or not line.startswith("data:"): continue
data = line[5:].strip()
if data == "[DONE]": break
try: chunk = _loads(data)
except: continue
if chunk.get("usage"):
u = chunk["usage"]
input_tokens = u.get("prompt_tokens", input_tokens)
output_tokens = u.get("completion_tokens", output_tokens)
choices = chunk.get("choices")
if not choices: continue
choice = choices[0]
delta = choice.get("delta", {})
finish_reason = choice.get("finish_reason")
if finish_reason == "tool_calls": stop_reason = "tool_use"
elif finish_reason == "length": stop_reason = "max_tokens"
reasoning_delta = delta.get("reasoning_content")
if reasoning_delta:
reasoning_text_acc += reasoning_delta
if reasoning_block_idx is None:
reasoning_block_idx = next_idx; next_idx += 1
yield (_BLK_START + _dumps({"type": "content_block_start",
"index": reasoning_block_idx,
"content_block": {"type": "thinking", "thinking": ""}}) + "\n\n")
yield (_BLK_DELTA + _dumps({"type": "content_block_delta",
"index": reasoning_block_idx,
"delta": {"type": "thinking_delta", "thinking": reasoning_delta}}) + "\n\n")
text_delta = delta.get("content")
if text_delta:
if reasoning_block_idx is not None and not reasoning_closed:
sig = _synthetic_signature(reasoning_text_acc)
yield (_BLK_DELTA + _dumps({"type": "content_block_delta",
"index": reasoning_block_idx,
"delta": {"type": "signature_delta", "signature": sig}}) + "\n\n")
yield (_BLK_STOP + _dumps({"type": "content_block_stop",
"index": reasoning_block_idx}) + "\n\n")
reasoning_closed = True
if text_block_idx is None:
text_block_idx = next_idx; next_idx += 1
yield (_BLK_START + _dumps({"type": "content_block_start",
"index": text_block_idx,
"content_block": {"type": "text", "text": ""}}) + "\n\n")
yield (_BLK_DELTA + _dumps({"type": "content_block_delta",
"index": text_block_idx,
"delta": {"type": "text_delta", "text": text_delta}}) + "\n\n")
tool_call_deltas = delta.get("tool_calls")
if tool_call_deltas:
if reasoning_block_idx is not None and not reasoning_closed:
sig = _synthetic_signature(reasoning_text_acc)
yield (_BLK_DELTA + _dumps({"type": "content_block_delta",
"index": reasoning_block_idx,
"delta": {"type": "signature_delta", "signature": sig}}) + "\n\n")
yield (_BLK_STOP + _dumps({"type": "content_block_stop",
"index": reasoning_block_idx}) + "\n\n")
reasoning_closed = True
for tc_delta in tool_call_deltas:
idx = tc_delta.get("index", 0)
if idx not in tool_state:
fn_init = tc_delta.get("function", {})
tool_state[idx] = {
"id": tc_delta.get("id", f"toolu_{uuid.uuid4().hex[:8]}"),
"name": fn_init.get("name", ""), "args": "",
}
blk = next_idx; next_idx += 1; tool_block_idx[idx] = blk
yield (_BLK_START + _dumps({"type": "content_block_start", "index": blk,
"content_block": {"type": "tool_use", "id": tool_state[idx]["id"],
"name": tool_state[idx]["name"], "input": {}}}) + "\n\n")
fn = tc_delta.get("function", {})
args = fn.get("arguments", "")
if args:
tool_state[idx]["args"] += args
yield (_BLK_DELTA + _dumps({"type": "content_block_delta",
"index": tool_block_idx[idx],
"delta": {"type": "input_json_delta", "partial_json": args}}) + "\n\n")
except Exception as exc:
logger.warning("stream error: %s", exc)
if reasoning_block_idx is not None and not reasoning_closed:
sig = _synthetic_signature(reasoning_text_acc)
yield (_BLK_DELTA + _dumps({"type": "content_block_delta", "index": reasoning_block_idx,
"delta": {"type": "signature_delta", "signature": sig}}) + "\n\n")
yield (_BLK_STOP + _dumps({"type": "content_block_stop",
"index": reasoning_block_idx}) + "\n\n")
if text_block_idx is not None:
yield (_BLK_STOP + _dumps({"type": "content_block_stop",
"index": text_block_idx}) + "\n\n")
for blk in tool_block_idx.values():
yield (_BLK_STOP + _dumps({"type": "content_block_stop", "index": blk}) + "\n\n")
yield (_MSG_DELTA + _dumps({"type": "message_delta",
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
"usage": {"output_tokens": output_tokens}}) + "\n\n")
yield f"{_MSG_STOP}" + '{"type":"message_stop"}' + "\n\n"
@app.post("/v1/messages")
@app.post("/v1/chat/completions")
async def messages(
request: Request,
authorization: str = Header(default=None),
):
request_id = uuid.uuid4().hex[:8]
if not OPENCODE_API_KEY:
return JSONResponse(status_code=401, content={"error": "Missing OPENCODE_API_KEY"})
try:
body = await request.json()
except Exception:
return JSONResponse(status_code=400, content={"error": "Invalid JSON"})
requested_model = body.get("model", "claude-sonnet-4-6")
mapped_model = MODEL_MAP.get(requested_model, DEFAULT_MODEL)
stream = bool(body.get("stream", False))
thinking_on = THINKING_MODE == "enabled"
anthropic_tools = body.get("tools", [])
openai_tools = convert_tools_to_openai(anthropic_tools)
openai_tool_choice = map_tool_choice(body.get("tool_choice"), bool(openai_tools), thinking_on)
openai_messages = normalize_messages(body.get("messages", []))
system_prompt = body.get("system", "")
if system_prompt:
if isinstance(system_prompt, list):
system_prompt = "\n".join(
b.get("text", "") if isinstance(b, dict) else str(b)
for b in system_prompt
)
openai_messages.insert(0, {"role": "system", "content": system_prompt})
payload = build_payload(body, openai_messages, openai_tools,
openai_tool_choice, mapped_model, stream)
headers = _make_upstream_headers(OPENCODE_API_KEY)
logger.info("[%s] %s→%s stream=%s tools=%d thinking=%s msgs=%d",
request_id, requested_model, mapped_model,
stream, len(openai_tools), THINKING_MODE, len(openai_messages))
client = request.app.state.client
last_error = None
for attempt in range(1, MAX_RETRIES + 1):
if attempt > 1:
await asyncio.sleep(0.5 * (attempt - 1))
try:
if stream:
upstream_req = client.build_request(
"POST", UPSTREAM_URL, json=payload, headers=headers
)
response = await client.send(upstream_req, stream=True)
if response.status_code == 429:
retry_after = float(response.headers.get("retry-after", "0"))
raw_err = (await response.aread()).decode(errors="ignore")
logger.warning("[%s] 429 — retry-after=%.0fs — body: %s",
request_id, retry_after, raw_err[:400])
await response.aclose()
return JSONResponse(status_code=429, content={
"type": "error", "error": {
"type": "rate_limit_error",
"message": f"Upstream rate-limited. retry-after={retry_after:.0f}s. Detail: {raw_err[:200]}",
}}, headers={"retry-after": str(int(retry_after))} if retry_after else {})
if response.status_code != 200:
raw_err = (await response.aread()).decode(errors="ignore")
logger.warning("[%s] upstream %d: %s",
request_id, response.status_code, raw_err[:300])
last_error = raw_err; await response.aclose(); continue
return StreamingResponse(
convert_stream_to_anthropic(response, requested_model),
media_type="text/event-stream",
headers=STREAM_RESPONSE_HEADERS,
background=BackgroundTask(response.aclose),
)
response = await client.post(UPSTREAM_URL, json=payload, headers=headers)
if response.status_code == 429:
retry_after = float(response.headers.get("retry-after", "0"))
logger.warning("[%s] 429 — retry-after=%.0fs", request_id, retry_after)
return JSONResponse(status_code=429, content={
"type": "error", "error": {
"type": "rate_limit_error",
"message": f"Upstream rate-limited. retry-after={retry_after:.0f}s. Detail: {response.text[:200]}",
}}, headers={"retry-after": str(int(retry_after))} if retry_after else {})
if response.status_code != 200:
logger.warning("[%s] upstream %d", request_id, response.status_code)
last_error = response.text; continue
raw = response.content
if not raw or not raw.strip():
return JSONResponse(status_code=502, content={"error": "Empty upstream response"})
try: parsed = _loads(raw)
except: return JSONResponse(status_code=502, content={"error": "Invalid upstream JSON"})
return JSONResponse(content=convert_to_anthropic(parsed, requested_model))
except Exception as exc:
last_error = str(exc)
logger.exception("[%s] exception attempt %d", request_id, attempt)
return JSONResponse(status_code=502,
content={"error": "Upstream failed", "details": last_error})