Skip to content

Commit f41ec9f

Browse files
jhammantclaude
andauthored
feat: stable local gateway (aiod gateway) with opt-in Anthropic endpoint (#6)
Promotes the auto-spin proxy into an always-on local gateway that everything points at — CCR, the chat UI, any OpenAI/Anthropic client — so the ephemeral remote box (whose IP:port rotates each spin) hides behind one stable URL. - aiod/translate.py (NEW): pure, fully unit-tested OpenAI<->Anthropic dialect translation (system/content blocks, tools + tool_choice, stop-reason map, non-stream + streaming with tool_use input_json_delta accumulation + usage). - aiod/proxy.py: `aiod gateway` via run_gateway(); `aiod proxy`/run_proxy kept working as a thin wrapper. Loopback-default-open bearer auth (enforced off 127.0.0.1 or via AIOD_GATEWAY_REQUIRE_AUTH=1), /healthz, a synthesized /v1/models cold path, and an OPT-IN native /v1/messages endpoint (--anthropic, default OFF — keeps the stateful translator off the critical path). Endpoint churn handled by re-resolving state per request + a pre-first-byte-only retry (never mid-stream). gateway.json pidfile for PR2/PR3 discovery. - aiod/config.py: persist_vllm_api_key() so a freshly-minted sk-aiod-* token is shared across CCR, the gateway bearer, and the upstream container. - Surface upstream HTTP errors on the non-stream Anthropic path instead of a blank 200. Claude Code stays on CCR (untouched); native Anthropic /v1/messages is opt-in. 29 new tests; 93 passing, ruff clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 91b2e79 commit f41ec9f

6 files changed

Lines changed: 1514 additions & 25 deletions

File tree

aiod/cli.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from __future__ import annotations
1212

13+
import os
1314
import time
1415
import webbrowser
1516

@@ -20,7 +21,7 @@
2021

2122
from . import branding, ccr, events, model_configs, onboard, profiles, providers, state
2223
from .bootstrap import CONTAINER_PORT, ServerConfig
23-
from .config import Settings
24+
from .config import Settings, persist_vllm_api_key, was_token_minted
2425
from .health import wait_until_ready
2526
from .sizing import QUANT_LABELS, size_any
2627
from .vast import PricedOption, recommend_disk_gb
@@ -971,6 +972,90 @@ def proxy(
971972
console.print("\n[yellow]Proxy stopped[/] — any running box is left up (aiod status/teardown).")
972973

973974

975+
@app.command()
976+
def gateway(
977+
profile: str = typer.Option(None, "--profile", "-p", help="Profile to spin on first request"),
978+
model: str = typer.Option(None, "--model", help="Model to spin (if no profile)"),
979+
quant: str = typer.Option(None, "--quant", "-q"),
980+
provider: str = typer.Option(None, "--provider"),
981+
max_price: float = typer.Option(None, "--max-price"),
982+
ttl: float = typer.Option(None, "--ttl"),
983+
idle: int = typer.Option(20, "--idle", help="Auto-destroy after N idle minutes"),
984+
context: int = typer.Option(None, "--context"),
985+
port: int = typer.Option(4000, "--port"),
986+
bind: str = typer.Option("127.0.0.1", "--bind", help="Address to bind (non-loopback enforces auth)"),
987+
write_ccr: bool = typer.Option(True, "--ccr/--no-ccr", help="Point CCR at the gateway"),
988+
anthropic: bool = typer.Option(
989+
False, "--anthropic/--no-anthropic", help="Mount native Anthropic /v1/messages (opt-in)"
990+
),
991+
require_auth: bool = typer.Option(
992+
False, "--require-auth/--no-require-auth", help="Enforce the bearer gate even on loopback"
993+
),
994+
):
995+
"""Run the always-on local gateway: an auto-spin-up proxy with a /healthz route,
996+
a synthesized /v1/models cold path, optional native Anthropic /v1/messages
997+
(--anthropic), and a loopback-default-open bearer gate."""
998+
s = Settings.load()
999+
1000+
prof = profiles.get(profile) if profile else None
1001+
if profile and not prof:
1002+
console.print(f"[red]No profile '{profile}'.[/] See [bold]aiod profile list[/].")
1003+
raise typer.Exit(1)
1004+
model = model or (prof.model if prof else None)
1005+
if not model:
1006+
console.print("[red]Provide --model or --profile.[/]")
1007+
raise typer.Exit(1)
1008+
provider = (provider or (prof.provider if prof else "vast")).lower()
1009+
_require_provider_key(s, provider)
1010+
1011+
if was_token_minted(s) and persist_vllm_api_key(s.vllm_api_key):
1012+
console.print("[dim]persisted VLLM_API_KEY to ~/.config/aiod/.env[/]")
1013+
1014+
spin_kwargs = dict(
1015+
model=model,
1016+
quant=quant or (prof.quant if prof else "bf16"),
1017+
provider=provider,
1018+
max_price=max_price if max_price is not None else (prof.max_price if prof else None),
1019+
ttl_hours=ttl if ttl is not None else (prof.ttl_hours if prof else None),
1020+
idle_minutes=idle,
1021+
context=context if context is not None else (prof.context if prof else None),
1022+
concurrency=prof.concurrency if prof else 4,
1023+
tool_parser=prof.tool_call_parser if prof else None, # None -> model_configs
1024+
extra_args=list(prof.extra_vllm_args) if prof else [],
1025+
)
1026+
1027+
base = f"http://127.0.0.1:{port}/v1"
1028+
if write_ccr:
1029+
ccr.write_config(base, s.vllm_api_key, model)
1030+
console.print("[green]✓[/] CCR pointed at the gateway. Run [bold]ccr restart && ccr code[/].")
1031+
1032+
env_require = os.getenv("AIOD_GATEWAY_REQUIRE_AUTH", "").strip().lower() in ("1", "true", "yes", "on")
1033+
req_auth = bind != "127.0.0.1" or require_auth or env_require
1034+
console.print(
1035+
Panel(
1036+
f"Listening on [bold]http://{bind}:{port}[/]\n"
1037+
f"model: {model} ({spin_kwargs['quant']}) · provider: {spin_kwargs['provider']} · "
1038+
f"idle-shutdown: {idle}m\n"
1039+
f"anthropic /v1/messages: {'on' if anthropic else 'off'} · "
1040+
f"auth: {'enforced' if req_auth else 'loopback-open'}\n"
1041+
f"Routes: /healthz · /aiod/status · /v1/* · GET /v1/models (cold-synthesized)\n"
1042+
f"Status: [bold]aiod status[/] / the TUI / GET /aiod/status · Ctrl-C stops the gateway.",
1043+
title="aiod gateway",
1044+
border_style="green",
1045+
)
1046+
)
1047+
from .proxy import run_gateway
1048+
1049+
try:
1050+
run_gateway(
1051+
s, spin_kwargs, idle_minutes=idle, host=bind, port=port,
1052+
enable_anthropic=anthropic, require_auth=req_auth,
1053+
on_event=lambda phase, msg: console.log(f"[{phase}] {msg}"),
1054+
)
1055+
except KeyboardInterrupt:
1056+
console.print("\n[yellow]Gateway stopped[/] — any running box is left up (aiod status/teardown).")
1057+
1058+
9741059
@app.command()
9751060
def bench(
9761061
n: int = typer.Option(8, "--n", help="Number of requests"),

aiod/config.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,28 @@ def load(cls) -> Settings:
3838
max_price=float(os.getenv("AIOD_MAX_PRICE", "6.0") or 6.0),
3939
runpod_api_key=os.getenv("RUNPOD_API_KEY", "").strip(),
4040
)
41+
42+
43+
def was_token_minted(s: Settings) -> bool:
44+
"""True when the bearer token was freshly minted this process (VLLM_API_KEY
45+
unset in the environment), so callers know to persist + warn exactly once."""
46+
return s.vllm_api_key.startswith("sk-aiod-") and not os.getenv("VLLM_API_KEY")
47+
48+
49+
def persist_vllm_api_key(token: str, env_path: Path = GLOBAL_ENV) -> bool:
50+
"""Persist a minted bearer token to the global ~/.config/aiod/.env so CCR, the
51+
gateway bearer, the upstream vLLM bearer and the chat page share one
52+
deterministic token across restarts.
53+
54+
No-op (returns False) when VLLM_API_KEY is already set in the OS environment or
55+
already present in the global .env. Returns True when it appends the token."""
56+
if os.getenv("VLLM_API_KEY"):
57+
return False
58+
existing = env_path.read_text() if env_path.exists() else ""
59+
if "VLLM_API_KEY=" in existing:
60+
return False
61+
env_path.parent.mkdir(parents=True, exist_ok=True)
62+
sep = "" if (not existing or existing.endswith("\n")) else "\n"
63+
with open(env_path, "a") as f:
64+
f.write(f"{sep}VLLM_API_KEY={token}\n")
65+
return True

0 commit comments

Comments
 (0)