gateway: close the last two holes — audio progress and generated files

Audited all 66 /v1 endpoints against the capability map. One was
unmapped: /v1/audio/progress, so polling an audio job would be answered
locally while the job ran on the remote — always empty. Mapped to
audio_gen, and a test now asserts the whole set, so a newly added
endpoint cannot quietly stay local.

The bigger hole was results. With the default response_format "url" a
render answers with a URL built from the REMOTE's base
(http://pod-xyz:8000/v1/files/out.png): unreachable from the client, and
a 404 if fetched here because the file is on the pod. The gateway now
rewrites those URLs to this instance and remembers which remote produced
each file, so GET /v1/files/<name> follows back to it. Only JSON bodies
are buffered for the rewrite; everything else still streams untouched.

The map is a bounded routing hint (newest 4096), not a store: a pod
reaped for idleness takes its outputs with it, so fetch what you need or
ask for b64_json. Documented.

Admin, /internal, /chat, /login and /healthz stay local by design —
they administer or identify THIS instance.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182DhR1eFNPQyGmDrpHbedY
parent 55681acd
...@@ -56,6 +56,8 @@ _CAPABILITY_PREFIXES = ( ...@@ -56,6 +56,8 @@ _CAPABILITY_PREFIXES = (
("/v1/audio/convert", "voice"), ("/v1/audio/convert", "voice"),
("/v1/audio/watermark", "voice"), ("/v1/audio/watermark", "voice"),
("/v1/audio/generate", "audio_gen"), ("/v1/audio/generate", "audio_gen"),
# Progress polling has to follow the job: it is the remote that is rendering.
("/v1/audio/progress", "audio_gen"),
("/v1/audio/stems", "stems"), ("/v1/audio/stems", "stems"),
("/v1/audio/cleanup", "audio_clean"), ("/v1/audio/cleanup", "audio_clean"),
("/v1/images/to3d", "spatial"), ("/v1/images/to3d", "spatial"),
...@@ -76,6 +78,30 @@ _CAPABILITY_PREFIXES = ( ...@@ -76,6 +78,30 @@ _CAPABILITY_PREFIXES = (
("/v1/faceswap", "faceswap"), ("/v1/faceswap", "faceswap"),
) )
#: filename -> the remote base that produced it. A generated file lives on the
#: machine that rendered it, and `response_format: "url"` hands the client a URL
#: built from THAT machine's base — unreachable from here, and a 404 if fetched
#: locally. So rewrite those URLs to point at this instance and remember where to
#: fetch them from. Bounded: this is a routing hint, not a store.
_FILE_ORIGINS: "OrderedDict[str, str]" = None
_FILE_ORIGINS_MAX = 4096
def _remember_file(filename: str, base: str) -> None:
global _FILE_ORIGINS
from collections import OrderedDict
if _FILE_ORIGINS is None:
_FILE_ORIGINS = OrderedDict()
_FILE_ORIGINS[filename] = base
_FILE_ORIGINS.move_to_end(filename)
while len(_FILE_ORIGINS) > _FILE_ORIGINS_MAX:
_FILE_ORIGINS.popitem(last=False)
def file_origin(filename: str) -> str:
return (_FILE_ORIGINS or {}).get(filename, "")
#: Handled by RemoteOpenAIBackend instead — see the module docstring. #: Handled by RemoteOpenAIBackend instead — see the module docstring.
_SKIP_PATHS = ("/v1/chat/completions", "/v1/completions", "/v1/models") _SKIP_PATHS = ("/v1/chat/completions", "/v1/completions", "/v1/models")
...@@ -230,6 +256,10 @@ def resolve_target(path: str, method: str, query: str, body: bytes, ...@@ -230,6 +256,10 @@ def resolve_target(path: str, method: str, query: str, body: bytes,
"""Decide where this request should go, or None to serve it locally.""" """Decide where this request should go, or None to serve it locally."""
if not path.startswith("/v1/") or path in _SKIP_PATHS: if not path.startswith("/v1/") or path in _SKIP_PATHS:
return None return None
# A generated file is fetched from whichever remote rendered it.
if path.startswith("/v1/files/"):
base = file_origin(path[len("/v1/files/"):])
return Target(url=base, reason="generated file") if base else None
cap = capability_for(path) cap = capability_for(path)
model = _model_from_body(body, content_type) model = _model_from_body(body, content_type)
if not model and query: if not model and query:
...@@ -347,11 +377,26 @@ class RemoteGatewayMiddleware: ...@@ -347,11 +377,26 @@ class RemoteGatewayMiddleware:
except Exception as exc: except Exception as exc:
return await _error(send, 502, f"remote endpoint {base} unreachable: {exc}") return await _error(send, 502, f"remote endpoint {base} unreachable: {exc}")
out = [(b"content-type", (resp.headers.get("Content-Type") or "application/json") ctype = resp.headers.get("Content-Type") or "application/json"
.encode("latin-1"))] out = [(b"content-type", ctype.encode("latin-1"))]
for h in ("content-disposition", "cache-control"): for h in ("content-disposition", "cache-control"):
if resp.headers.get(h): if resp.headers.get(h):
out.append((h.encode(), resp.headers[h].encode("latin-1"))) out.append((h.encode(), resp.headers[h].encode("latin-1")))
# A JSON answer may carry /v1/files/… URLs built from the REMOTE's base.
# Those are useless to the client (wrong host, and a 404 if fetched here),
# so rewrite them to this instance and remember where the file actually
# is. Small bodies only: everything else streams through untouched.
if "json" in ctype.lower():
raw = await asyncio.to_thread(lambda: resp.content)
resp.close()
raw = _rewrite_file_urls(raw, base, _local_base(scope, headers))
out = [h for h in out if h[0] != b"content-length"]
out.append((b"content-length", str(len(raw)).encode()))
await send({"type": "http.response.start", "status": resp.status_code,
"headers": out})
return await send({"type": "http.response.body", "body": raw})
await send({"type": "http.response.start", "status": resp.status_code, await send({"type": "http.response.start", "status": resp.status_code,
"headers": out}) "headers": out})
it = resp.iter_content(chunk_size=65536) it = resp.iter_content(chunk_size=65536)
...@@ -364,6 +409,31 @@ class RemoteGatewayMiddleware: ...@@ -364,6 +409,31 @@ class RemoteGatewayMiddleware:
resp.close() resp.close()
#: Any absolute URL pointing at a served file.
_FILE_URL = re.compile(rb'https?://[^"\s]*?/v1/files/([^"\s/?]+)')
def _local_base(scope, headers) -> str:
"""This instance's public base URL, as the client reached it."""
proto = (headers.get("x-forwarded-proto") or scope.get("scheme") or "http").split(",")[0]
host = headers.get("host") or ""
prefix = (headers.get("x-forwarded-prefix") or "").rstrip("/")
return f"{proto}://{host}{prefix}" if host else prefix
def _rewrite_file_urls(raw: bytes, remote_base: str, local_base: str) -> bytes:
"""Point every /v1/files/ URL at us, and note which remote holds the file."""
if b"/v1/files/" not in raw:
return raw
def _sub(m):
name = m.group(1).decode("latin-1")
_remember_file(name, remote_base)
return (local_base.encode("latin-1") if local_base else b"") + b"/v1/files/" + m.group(1)
return _FILE_URL.sub(_sub, raw)
async def _error(send, status: int, message: str): async def _error(send, status: int, message: str):
msg = json.dumps({"error": {"message": message, msg = json.dumps({"error": {"message": message,
"type": "remote_gateway_error"}}).encode() "type": "remote_gateway_error"}}).encode()
......
...@@ -142,6 +142,35 @@ Capability keys: `images`, `video`, `embeddings`, `rerank`, `ocr`, `tts`, `stt`, ...@@ -142,6 +142,35 @@ Capability keys: `images`, `video`, `embeddings`, `rerank`, `ocr`, `tts`, `stt`,
Precedence: a model's own `service_url` wins over the capability map. Precedence: a model's own `service_url` wins over the capability map.
Every `/v1` endpoint is mapped to one of these capabilities — a test asserts it,
so a newly added endpoint cannot quietly stay local. Three are excluded on
purpose: `/v1/chat/completions` and `/v1/completions` (RemoteOpenAIBackend serves
those) and `/v1/models` (the catalogue is this instance's own).
What is **not** gatewayed, by design:
* `/admin/**` — administers *this* instance: its model list, its engines, its
settings. To manage a remote coderai, open its own admin.
* `/internal/**` — the front↔engine control plane (VRAM reservations, thermal
pause, config reload), already gated by the internal token.
* `/chat`, `/login`, `/logout`, `/healthz`, `/coderai/capabilities` — this
instance's own UI and identity.
### Generated files
With the default `response_format: "url"` a render answers with a URL built from
the base of the machine that produced it — `http://pod-xyz:8000/v1/files/out.png`
— which the client usually cannot reach, and which 404s if fetched here because
the file is on the pod. So the gateway rewrites those URLs to point at this
instance and remembers which remote holds each file; a later
`GET /v1/files/<name>` is forwarded back there. Nothing to configure.
Two consequences worth knowing: the file lives on the pod, so **a pod reaped for
idleness takes its outputs with it** — fetch what you need, or ask for
`response_format: "b64_json"` and get the bytes inline. And the mapping is a
routing hint held in memory (bounded, newest 4096), not a store: it does not
survive a restart.
### Letting coderai rent the pod ### Letting coderai rent the pod
A URL means you started the machine and you own its bill. To have coderai A URL means you started the machine and you own its bill. To have coderai
......
...@@ -466,6 +466,80 @@ def test_pod_plan_per_engine(): ...@@ -466,6 +466,80 @@ def test_pod_plan_per_engine():
assert cus["args"] == "--serve" and cus["health_path"] == "/ping" assert cus["args"] == "--serve" and cus["health_path"] == "/ping"
def test_every_v1_endpoint_is_either_mapped_or_deliberately_skipped():
"""A new /v1 endpoint that nobody mapped would silently stay local."""
import re
from codai.api.remote_gateway import capability_for, _SKIP_PATHS
root = Path(__file__).resolve().parents[1] / "codai" / "api"
pat = re.compile(r'@router\.(?:post|get|put|delete)\("(/v1[^"]*)"')
paths = {m for f in root.glob("*.py") for m in pat.findall(f.read_text())}
assert paths, "no /v1 routes found — did the decorator style change?"
unmapped = sorted(p for p in paths
if not capability_for(p) and p not in _SKIP_PATHS)
assert unmapped == [], f"unmapped /v1 endpoints: {unmapped}"
def test_generated_file_urls_are_rewritten_and_followed_back(gateway_app, monkeypatch):
"""`response_format: url` returns a URL built from the REMOTE's base. Left
alone it points at a host the client may not reach, and fetching it here is a
404 — the file is on the pod."""
client, gw, url = gateway_app
from fastapi import Request
app = client.app
@app.post("/v1/images/edits")
async def edits(req: Request): # pragma: no cover - remote in this test
return {"served_by": "local"}
@app.get("/v1/files/{name}")
async def files(name: str): # pragma: no cover - remote in this test
return {"served_by": "local", "name": name}
class _Files(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a):
pass
def _send(self, obj):
raw = json.dumps(obj).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
self.rfile.read(n)
# exactly what a remote coderai answers with response_format=url
self._send({"data": [{"url": "http://pod-xyz:8000/v1/files/out-1.png"}]})
def do_GET(self):
self._send({"served_by": "remote-files", "path": self.path})
remote, shutdown = _serve(_Files)
try:
gw.capability_endpoints = lambda: {"images": remote}
r = client.post("/v1/images/edits", json={"model": "sdxl"})
got = r.json()["data"][0]["url"]
# rewritten to this instance...
assert got.endswith("/v1/files/out-1.png") and "pod-xyz" not in got
# ...and we remember which remote actually holds it
assert gw.file_origin("out-1.png") == remote
# so fetching it follows back to that remote, not to the local route
gw.capability_endpoints = lambda: {}
assert client.get("/v1/files/out-1.png").json()["served_by"] == "remote-files"
# a file we never saw is served locally
assert client.get("/v1/files/unknown.png").json()["served_by"] == "local"
finally:
shutdown()
def test_unreachable_remote_is_a_clean_502(gateway_app): def test_unreachable_remote_is_a_clean_502(gateway_app):
client, gw, _ = gateway_app client, gw, _ = gateway_app
gw.capability_endpoints = lambda: {"images": "http://127.0.0.1:1"} gw.capability_endpoints = lambda: {"images": "http://127.0.0.1:1"}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment