File size: 13,701 Bytes
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46e3de3
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46e3de3
 
 
69f9cd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46e3de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69f9cd1
 
46e3de3
69f9cd1
 
46e3de3
 
 
 
 
 
 
69f9cd1
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
"""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")