"""OpenAI-compatible bridge for Naldo_Bot backed exclusively by OpenCode. Listens on 127.0.0.1 only. Translates POST /v1/chat/completions into the real OpenCode session API discovered via /doc: - POST /session -> create session (body may carry title) - POST /session/{sessionID}/message -> send prompt, wait for reply - GET /global/health -> opencode health Each inference creates a fresh session (isolated per Voyager call) and sends ``tools: {}`` on the message. OpenCode 1.18.33 was verified to return no tool parts and perform no requested bash side effect in this mode. Custom deny-all session permissions are not used because Zen free-tier rejects them with 403. Only assistant ``text`` parts are returned. Temperature: OpenCode session API exposes no temperature field, so Voyager's requested temperature is recorded and logged, never forwarded. See naldo_opencode.TEMPERATURE_NOTE. Embeddings (SkillManager/QA) are NOT served here; /v1/embeddings proxies to the existing local 7860 endpoint to preserve upstream behavior. No OPENCODE_API_KEY is used. No credential file is read or copied. Logs never include prompts, completions, or credentials. """ from __future__ import annotations import json import os import threading import time import urllib.error import urllib.request import uuid from typing import Any from fastapi import FastAPI, HTTPException, Request from fastapi.responses import JSONResponse, StreamingResponse from starlette.concurrency import run_in_threadpool from naldo_opencode import ( ALLOWED_MODELS, BRIDGE_PORT, FALLBACK_MODELS, LOCAL_EMBED_BASE, OPENCODE_BASE, OPENCODE_PROVIDER, OPENCODE_SESSION_DIR, PRIMARY_MODEL, TEMPERATURE_NOTE, build_opencode_prompt, canonical_model_id, extract_assistant_text, is_allowed_model, opencode_message_body, ) OPENCODE = OPENCODE_BASE.rstrip("/") EMBED_BASE = LOCAL_EMBED_BASE.rstrip("/") PORT = int(os.environ.get("NALDO_BRIDGE_PORT", str(BRIDGE_PORT))) REQUEST_TIMEOUT = float(os.environ.get("NALDO_BRIDGE_TIMEOUT", "240")) SESSION_TIMEOUT = float(os.environ.get("NALDO_BRIDGE_SESSION_TIMEOUT", "15")) EMBED_RETRIES = max(1, int(os.environ.get("NALDO_EMBED_RETRIES", "8"))) EMBED_BACKOFF = max(0.1, float(os.environ.get("NALDO_EMBED_BACKOFF", "1"))) EMBED_RETRYABLE = frozenset({429, 500, 502, 503, 504}) STATE_LOCK = threading.Lock() state: dict[str, Any] = { "calls": 0, "failures": 0, "fallbacks_used": 0, "last_model": None, "last_fallback": None, "last_elapsed_s": None, "prompt_tokens": 0, "completion_tokens": 0, "last_error": None, "last_requested_temperature": None, "last_requested_max_tokens": None, "effective_temperature": TEMPERATURE_NOTE, "opencode_base": OPENCODE, } def _http_json( url: str, method: str = "GET", body: dict[str, Any] | None = None, timeout: float = 10.0, retries: int = 2, ) -> Any: last: Exception | None = None for attempt in range(retries + 1): try: data = json.dumps(body).encode("utf-8") if body is not None else None request = urllib.request.Request(url, data=data, method=method) if data is not None: request.add_header("Content-Type", "application/json") with urllib.request.urlopen(request, timeout=timeout) as response: raw = response.read().decode("utf-8", errors="replace") return json.loads(raw) if raw else {} except Exception as exc: # noqa: BLE001 - mapped to HTTP below last = exc if attempt < retries: time.sleep(min(2.0 * (attempt + 1), 5.0)) raise RuntimeError(f"{type(last).__name__}: {str(last)[:200]}" if last else "request failed") def _create_session(model_id: str, timeout: float = SESSION_TIMEOUT) -> str: model = canonical_model_id(model_id) if model not in ALLOWED_MODELS: raise RuntimeError(f"refusing non-OpenCode/free session model: {model_id}") OPENCODE_SESSION_DIR.mkdir(parents=True, exist_ok=True) session_body = { "title": "naldo-voyager", "model": {"id": model, "providerID": OPENCODE_PROVIDER}, "location": {"directory": str(OPENCODE_SESSION_DIR)}, } payload = _http_json( OPENCODE + "/session", method="POST", body=session_body, timeout=timeout, retries=0 ) session_id = "" if isinstance(payload, dict): session_id = str(payload.get("id") or payload.get("sessionID") or payload.get("session_id") or "") info = payload.get("info") if not session_id and isinstance(info, dict): session_id = str(info.get("id") or "") if not session_id: raise RuntimeError("OpenCode session creation returned no id") return session_id def _send_message(session_id: str, model_id: str, system: str, prompt: str) -> str: body = opencode_message_body(model_id, system, prompt) url = OPENCODE + f"/session/{session_id}/message" try: # Never retry the same generation POST: a loopback timeout can mean the # first request is still running, and retrying would duplicate the turn. result = _http_json(url, method="POST", body=body, timeout=REQUEST_TIMEOUT, retries=0) finally: try: _http_json(OPENCODE + f"/session/{session_id}", method="DELETE", timeout=5.0) except Exception: pass text = extract_assistant_text(result) if not text: raise RuntimeError("OpenCode returned no assistant text parts") return text def _candidate_models(requested: str) -> list[str]: ordered: list[str] = [] bare_requested = canonical_model_id(requested) if bare_requested in ALLOWED_MODELS: ordered.append(bare_requested) for model in FALLBACK_MODELS: if model not in ordered: ordered.append(model) return ordered def _chat_payload(body: dict[str, Any]) -> dict[str, Any]: requested_model = str(body.get("model") or PRIMARY_MODEL) if not is_allowed_model(requested_model): raise ValueError(f"Unknown model for Naldo OpenCode bridge: {requested_model}") requested_temp = body.get("temperature") requested_max_tokens = body.get("max_tokens", body.get("max_completion_tokens")) with STATE_LOCK: state["last_requested_temperature"] = requested_temp state["last_requested_max_tokens"] = requested_max_tokens state["effective_temperature"] = TEMPERATURE_NOTE if isinstance(requested_temp, (int, float)) and float(requested_temp) != 0.0: # Voyager normally sends temperature 0; OpenCode session API has no # temperature parameter, so record drift without inventing support. print( f"[naldo-bridge] requested temperature={requested_temp}; {TEMPERATURE_NOTE}", flush=True, ) # max_tokens is also not supported by the OpenCode session endpoint. Keep # it only as telemetry: never character-truncate JavaScript/JSON responses. rendered = build_opencode_prompt(body.get("messages") or []) last_error = "OpenCode inference failed" tried: list[str] = [] for model_id in _candidate_models(requested_model): tried.append(model_id) started = time.monotonic() try: session_id = _create_session(model_id) answer = _send_message(session_id, model_id, rendered["system"], rendered["prompt"]) except Exception as exc: # noqa: BLE001 - retried across free models last_error = f"{type(exc).__name__}: {str(exc)[:200]}" continue elapsed = round(time.monotonic() - started, 2) with STATE_LOCK: state["calls"] += 1 state["last_model"] = model_id state["last_elapsed_s"] = elapsed state["last_error"] = None if model_id != canonical_model_id(requested_model): state["fallbacks_used"] += 1 state["last_fallback"] = model_id else: state["last_fallback"] = None return { "id": "chatcmpl-" + uuid.uuid4().hex[:16], "object": "chat.completion", "created": int(time.time()), "model": canonical_model_id(requested_model), "choices": [ {"index": 0, "message": {"role": "assistant", "content": answer}, "finish_reason": "stop"} ], "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, "backend": "opencode-bridge", "provider": OPENCODE_PROVIDER, } with STATE_LOCK: state["failures"] += 1 state["last_error"] = f"tried={tried} last={last_error}" raise RuntimeError(f"All OpenCode free models failed (tried={tried}): {last_error}") api = FastAPI(title="Naldo OpenCode Bridge", version="1.0.0") @api.get("/health") def health() -> dict[str, Any]: try: upstream = _http_json(OPENCODE + "/global/health", timeout=5.0, retries=0) upstream_ok = bool(isinstance(upstream, dict) and upstream.get("healthy") is True) except Exception as exc: # noqa: BLE001 - health must not raise upstream = {"error": f"{type(exc).__name__}"} upstream_ok = False with STATE_LOCK: snapshot = dict(state) return { "ready": upstream_ok, "bridge": "naldo-opencode", "provider": OPENCODE_PROVIDER, "primary_model": PRIMARY_MODEL, "fallback_models": list(FALLBACK_MODELS), "opencode_base": OPENCODE, "opencode": {"ok": upstream_ok, "detail": "ok" if upstream_ok else upstream}, "temperature": TEMPERATURE_NOTE, **{k: v for k, v in snapshot.items() if k != "opencode_base"}, } @api.get("/v1/models") def models() -> dict[str, Any]: now = int(time.time()) return { "object": "list", "data": [ {"id": model, "object": "model", "owned_by": OPENCODE_PROVIDER} for model in FALLBACK_MODELS ] + [ {"id": f"{OPENCODE_PROVIDER}/{model}", "object": "model", "owned_by": OPENCODE_PROVIDER} for model in FALLBACK_MODELS ], } @api.post("/v1/chat/completions") async def chat(request: Request) -> Any: try: body = await request.json() except Exception as exc: raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(exc)[:160]}") from exc if not isinstance(body.get("messages"), list): raise HTTPException(status_code=400, detail="messages must be a list") requested_model = str(body.get("model") or PRIMARY_MODEL) if not is_allowed_model(requested_model): raise HTTPException(status_code=404, detail=f"Unknown model: {requested_model}") try: payload = await run_in_threadpool(_chat_payload, body) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)[:200]) from None except RuntimeError as exc: raise HTTPException(status_code=503, detail=str(exc)[:800]) from None if not body.get("stream"): return JSONResponse(payload) content = payload["choices"][0]["message"]["content"] chunk = { "id": payload["id"], "object": "chat.completion.chunk", "created": payload["created"], "model": payload["model"], "choices": [{"index": 0, "delta": {"role": "assistant", "content": content}, "finish_reason": "stop"}], } async def events(): # pragma: no cover - streaming passthrough yield "data: " + json.dumps(chunk, ensure_ascii=False) + "\n\n" yield "data: [DONE]\n\n" return StreamingResponse(events(), media_type="text/event-stream") def _forward_embeddings(raw: bytes) -> dict[str, Any]: last_error: Exception | None = None for attempt in range(EMBED_RETRIES): forward = urllib.request.Request( EMBED_BASE.rstrip("/") + "/embeddings", data=raw, method="POST" ) forward.add_header("Content-Type", "application/json") api_key = os.environ.get("API_KEY", "").strip() if api_key: forward.add_header("Authorization", f"Bearer {api_key}") try: with urllib.request.urlopen(forward, timeout=120) as response: return json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: last_error = exc if exc.code not in EMBED_RETRYABLE: raise except (urllib.error.URLError, TimeoutError, OSError) as exc: last_error = exc if attempt + 1 < EMBED_RETRIES: time.sleep(min(EMBED_BACKOFF * (2 ** attempt), 8.0)) assert last_error is not None raise last_error @api.post("/v1/embeddings") async def embeddings(request: Request) -> Any: """Proxy embeddings with bounded retry so transient boot 503s do not kill Voyager.""" raw = await request.body() try: payload = await run_in_threadpool(_forward_embeddings, raw) return JSONResponse(payload) except urllib.error.HTTPError as exc: status = exc.code if 400 <= exc.code < 500 and exc.code not in EMBED_RETRYABLE else 503 raise HTTPException(status_code=status, detail=f"embedding backend unavailable: HTTP {exc.code}") from None except Exception as exc: # noqa: BLE001 - hide upstream internals raise HTTPException(status_code=503, detail=f"embedding backend unavailable: {type(exc).__name__}") from None if __name__ == "__main__": import uvicorn # Loopback only: never expose OpenCode or this bridge externally. uvicorn.run(api, host="127.0.0.1", port=PORT, log_level="warning")