Spaces:
Build error
Fix Codex WS proxy (Issue #86), LiteLLM metrics, and backend forwarding
Browse files- Fix WS /v1/responses: forward Sec-WebSocket-Protocol (subprotocol) to
upstream instead of stripping it — root cause of Codex HTTP 500 errors
- Fix WS relay: handle binary messages properly instead of crashing on
.decode(), add debug logging instead of silent except:pass
- Add Authorization header fallback from OPENAI_API_KEY env var for WS
- Extract response body from websockets InvalidStatus for error debugging
- Fix streaming /v1/responses: pass optimized_tokens (not original_tokens
twice) so compression savings appear in streaming metrics
- Fix hardcoded provider="bedrock" in 4 metrics/log locations — now uses
self.anthropic_backend.name so LiteLLM backends report correctly
- Forward --backend, --anyllm-provider, --region flags from wrap commands
(codex, aider) to the proxy subprocess via _start_proxy()
- Forward API key from request headers to LiteLLM acompletion() calls
- Forward region to Vertex AI (vertex_location) not just Bedrock
- Redesign proxy startup banner: show routing table instead of misleading
"Backend: Anthropic" label
Closes #86
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- headroom/backends/litellm.py +52 -13
- headroom/cli/proxy.py +9 -18
- headroom/cli/wrap.py +92 -6
- headroom/proxy/server.py +101 -23
|
@@ -662,9 +662,19 @@ class LiteLLMBackend(Backend):
|
|
| 662 |
)
|
| 663 |
kwargs["messages"].insert(0, {"role": "system", "content": system_text})
|
| 664 |
|
| 665 |
-
#
|
| 666 |
-
if self.
|
| 667 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 668 |
|
| 669 |
logger.debug(f"LiteLLM request: model={litellm_model}")
|
| 670 |
|
|
@@ -752,8 +762,19 @@ class LiteLLMBackend(Backend):
|
|
| 752 |
)
|
| 753 |
kwargs["messages"].insert(0, {"role": "system", "content": system_text})
|
| 754 |
|
| 755 |
-
|
| 756 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 757 |
|
| 758 |
msg_id = f"msg_{uuid.uuid4().hex[:24]}"
|
| 759 |
|
|
@@ -961,12 +982,19 @@ class LiteLLMBackend(Backend):
|
|
| 961 |
if param in body:
|
| 962 |
kwargs[param] = body[param]
|
| 963 |
|
| 964 |
-
# Provider-specific config
|
| 965 |
-
if self.
|
| 966 |
-
|
| 967 |
-
|
| 968 |
-
|
| 969 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 970 |
|
| 971 |
logger.debug(f"LiteLLM OpenAI request: model={litellm_model}")
|
| 972 |
|
|
@@ -1086,8 +1114,19 @@ class LiteLLMBackend(Backend):
|
|
| 1086 |
if "stream_options" in body:
|
| 1087 |
kwargs["stream_options"] = body["stream_options"]
|
| 1088 |
|
| 1089 |
-
|
| 1090 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1091 |
|
| 1092 |
response = await acompletion(**kwargs)
|
| 1093 |
|
|
|
|
| 662 |
)
|
| 663 |
kwargs["messages"].insert(0, {"role": "system", "content": system_text})
|
| 664 |
|
| 665 |
+
# Provider-specific region config
|
| 666 |
+
if self.region:
|
| 667 |
+
if self.provider == "bedrock":
|
| 668 |
+
kwargs["aws_region_name"] = self.region
|
| 669 |
+
elif self.provider in ("vertex_ai", "vertex_ai_beta"):
|
| 670 |
+
kwargs["vertex_location"] = self.region
|
| 671 |
+
|
| 672 |
+
# Forward API key from request headers if present
|
| 673 |
+
auth_header = headers.get("authorization", headers.get("Authorization", ""))
|
| 674 |
+
if auth_header.startswith("Bearer "):
|
| 675 |
+
kwargs["api_key"] = auth_header[7:]
|
| 676 |
+
elif headers.get("x-api-key"):
|
| 677 |
+
kwargs["api_key"] = headers["x-api-key"]
|
| 678 |
|
| 679 |
logger.debug(f"LiteLLM request: model={litellm_model}")
|
| 680 |
|
|
|
|
| 762 |
)
|
| 763 |
kwargs["messages"].insert(0, {"role": "system", "content": system_text})
|
| 764 |
|
| 765 |
+
# Provider-specific region config
|
| 766 |
+
if self.region:
|
| 767 |
+
if self.provider == "bedrock":
|
| 768 |
+
kwargs["aws_region_name"] = self.region
|
| 769 |
+
elif self.provider in ("vertex_ai", "vertex_ai_beta"):
|
| 770 |
+
kwargs["vertex_location"] = self.region
|
| 771 |
+
|
| 772 |
+
# Forward API key from request headers if present
|
| 773 |
+
auth_header = headers.get("authorization", headers.get("Authorization", ""))
|
| 774 |
+
if auth_header.startswith("Bearer "):
|
| 775 |
+
kwargs["api_key"] = auth_header[7:]
|
| 776 |
+
elif headers.get("x-api-key"):
|
| 777 |
+
kwargs["api_key"] = headers["x-api-key"]
|
| 778 |
|
| 779 |
msg_id = f"msg_{uuid.uuid4().hex[:24]}"
|
| 780 |
|
|
|
|
| 982 |
if param in body:
|
| 983 |
kwargs[param] = body[param]
|
| 984 |
|
| 985 |
+
# Provider-specific region config
|
| 986 |
+
if self.region:
|
| 987 |
+
if self.provider == "bedrock":
|
| 988 |
+
kwargs["aws_region_name"] = self.region
|
| 989 |
+
elif self.provider in ("vertex_ai", "vertex_ai_beta"):
|
| 990 |
+
kwargs["vertex_location"] = self.region
|
| 991 |
+
|
| 992 |
+
# Forward API key from request headers if present
|
| 993 |
+
auth_header = headers.get("authorization", headers.get("Authorization", ""))
|
| 994 |
+
if auth_header.startswith("Bearer "):
|
| 995 |
+
kwargs["api_key"] = auth_header[7:]
|
| 996 |
+
elif headers.get("x-api-key"):
|
| 997 |
+
kwargs["api_key"] = headers["x-api-key"]
|
| 998 |
|
| 999 |
logger.debug(f"LiteLLM OpenAI request: model={litellm_model}")
|
| 1000 |
|
|
|
|
| 1114 |
if "stream_options" in body:
|
| 1115 |
kwargs["stream_options"] = body["stream_options"]
|
| 1116 |
|
| 1117 |
+
# Provider-specific region config
|
| 1118 |
+
if self.region:
|
| 1119 |
+
if self.provider == "bedrock":
|
| 1120 |
+
kwargs["aws_region_name"] = self.region
|
| 1121 |
+
elif self.provider in ("vertex_ai", "vertex_ai_beta"):
|
| 1122 |
+
kwargs["vertex_location"] = self.region
|
| 1123 |
+
|
| 1124 |
+
# Forward API key from request headers if present
|
| 1125 |
+
auth_header = headers.get("authorization", headers.get("Authorization", ""))
|
| 1126 |
+
if auth_header.startswith("Bearer "):
|
| 1127 |
+
kwargs["api_key"] = auth_header[7:]
|
| 1128 |
+
elif headers.get("x-api-key"):
|
| 1129 |
+
kwargs["api_key"] = headers["x-api-key"]
|
| 1130 |
|
| 1131 |
response = await acompletion(**kwargs)
|
| 1132 |
|
|
@@ -250,13 +250,12 @@ def proxy(
|
|
| 250 |
if license_key:
|
| 251 |
license_status = f"MANAGED (key={license_key[:8]}...)"
|
| 252 |
|
| 253 |
-
|
| 254 |
-
|
| 255 |
backend_section = ""
|
| 256 |
|
| 257 |
if config.backend == "anyllm" or config.backend.startswith("anyllm-"):
|
| 258 |
# any-llm backend
|
| 259 |
-
backend_status = f"{effective_anyllm_provider.title()} via any-llm"
|
| 260 |
backend_section = """
|
| 261 |
Set credentials for your provider (e.g., OPENAI_API_KEY, MISTRAL_API_KEY)
|
| 262 |
Providers: https://mozilla-ai.github.io/any-llm/providers/
|
|
@@ -268,14 +267,6 @@ def proxy(
|
|
| 268 |
provider = config.backend.replace("litellm-", "")
|
| 269 |
provider_config = get_provider_config(provider)
|
| 270 |
|
| 271 |
-
# Build backend status
|
| 272 |
-
if provider_config.uses_region:
|
| 273 |
-
backend_status = (
|
| 274 |
-
f"{provider_config.display_name} via LiteLLM (region={effective_region})"
|
| 275 |
-
)
|
| 276 |
-
else:
|
| 277 |
-
backend_status = f"{provider_config.display_name} via LiteLLM"
|
| 278 |
-
|
| 279 |
# Build usage instructions from provider config
|
| 280 |
env_vars_str = (
|
| 281 |
", ".join(provider_config.env_vars) if provider_config.env_vars else "See docs"
|
|
@@ -314,7 +305,6 @@ Memory (Multi-Provider):
|
|
| 314 |
Starting proxy server...
|
| 315 |
|
| 316 |
URL: http://{config.host}:{config.port}
|
| 317 |
-
Backend: {backend_status}
|
| 318 |
Mode: {config.mode}
|
| 319 |
Optimization: {"ENABLED" if config.optimize else "DISABLED"}
|
| 320 |
Caching: {"ENABLED" if config.cache_enabled else "DISABLED"}
|
|
@@ -322,18 +312,19 @@ Starting proxy server...
|
|
| 322 |
Memory: {memory_status}
|
| 323 |
License: {license_status}
|
| 324 |
{backend_section}
|
| 325 |
-
|
| 326 |
-
|
|
|
|
|
|
|
| 327 |
|
| 328 |
-
Usage
|
| 329 |
-
|
|
|
|
| 330 |
{memory_section}
|
| 331 |
Endpoints:
|
| 332 |
GET /health Health check
|
| 333 |
GET /stats Detailed statistics
|
| 334 |
GET /metrics Prometheus metrics
|
| 335 |
-
POST /v1/messages Anthropic API
|
| 336 |
-
POST /v1/chat/completions OpenAI API
|
| 337 |
|
| 338 |
Press Ctrl+C to stop.
|
| 339 |
""")
|
|
|
|
| 250 |
if license_key:
|
| 251 |
license_status = f"MANAGED (key={license_key[:8]}...)"
|
| 252 |
|
| 253 |
+
anthropic_url = config.anthropic_api_url or "https://api.anthropic.com"
|
| 254 |
+
openai_url = config.openai_api_url or "https://api.openai.com"
|
| 255 |
backend_section = ""
|
| 256 |
|
| 257 |
if config.backend == "anyllm" or config.backend.startswith("anyllm-"):
|
| 258 |
# any-llm backend
|
|
|
|
| 259 |
backend_section = """
|
| 260 |
Set credentials for your provider (e.g., OPENAI_API_KEY, MISTRAL_API_KEY)
|
| 261 |
Providers: https://mozilla-ai.github.io/any-llm/providers/
|
|
|
|
| 267 |
provider = config.backend.replace("litellm-", "")
|
| 268 |
provider_config = get_provider_config(provider)
|
| 269 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
# Build usage instructions from provider config
|
| 271 |
env_vars_str = (
|
| 272 |
", ".join(provider_config.env_vars) if provider_config.env_vars else "See docs"
|
|
|
|
| 305 |
Starting proxy server...
|
| 306 |
|
| 307 |
URL: http://{config.host}:{config.port}
|
|
|
|
| 308 |
Mode: {config.mode}
|
| 309 |
Optimization: {"ENABLED" if config.optimize else "DISABLED"}
|
| 310 |
Caching: {"ENABLED" if config.cache_enabled else "DISABLED"}
|
|
|
|
| 312 |
Memory: {memory_status}
|
| 313 |
License: {license_status}
|
| 314 |
{backend_section}
|
| 315 |
+
Routing:
|
| 316 |
+
/v1/messages → {anthropic_url}
|
| 317 |
+
/v1/chat/completions → {openai_url}
|
| 318 |
+
/v1/responses → {openai_url} (HTTP + WebSocket)
|
| 319 |
|
| 320 |
+
Usage:
|
| 321 |
+
Claude Code: ANTHROPIC_BASE_URL=http://{config.host}:{config.port} claude
|
| 322 |
+
Codex / OpenAI: OPENAI_BASE_URL=http://{config.host}:{config.port}/v1 your-app
|
| 323 |
{memory_section}
|
| 324 |
Endpoints:
|
| 325 |
GET /health Health check
|
| 326 |
GET /stats Detailed statistics
|
| 327 |
GET /metrics Prometheus metrics
|
|
|
|
|
|
|
| 328 |
|
| 329 |
Press Ctrl+C to stop.
|
| 330 |
""")
|
|
@@ -54,7 +54,14 @@ def _get_log_path() -> Path:
|
|
| 54 |
return log_dir / "proxy.log"
|
| 55 |
|
| 56 |
|
| 57 |
-
def _start_proxy(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
"""Start Headroom proxy as a background subprocess.
|
| 59 |
|
| 60 |
Logs are written to ~/.headroom/logs/proxy.log to avoid pipe buffer
|
|
@@ -72,6 +79,19 @@ def _start_proxy(port: int, *, learn: bool = False) -> subprocess.Popen:
|
|
| 72 |
if learn:
|
| 73 |
cmd.append("--learn")
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
log_path = _get_log_path()
|
| 76 |
log_file = open(log_path, "a") # noqa: SIM115
|
| 77 |
|
|
@@ -233,7 +253,15 @@ def _inject_rtk_instructions(file_path: Path, verbose: bool = False) -> bool:
|
|
| 233 |
return True
|
| 234 |
|
| 235 |
|
| 236 |
-
def _ensure_proxy(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
"""Start or verify proxy. Returns process handle if we started it."""
|
| 238 |
if not no_proxy:
|
| 239 |
if _check_proxy(port):
|
|
@@ -242,7 +270,13 @@ def _ensure_proxy(port: int, no_proxy: bool, *, learn: bool = False) -> subproce
|
|
| 242 |
else:
|
| 243 |
click.echo(f" Starting Headroom proxy on port {port}...")
|
| 244 |
try:
|
| 245 |
-
proc = _start_proxy(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
click.echo(f" Proxy ready on http://127.0.0.1:{port}")
|
| 247 |
return proc
|
| 248 |
except RuntimeError as e:
|
|
@@ -303,6 +337,9 @@ def _launch_tool(
|
|
| 303 |
env_vars_display: list[str],
|
| 304 |
*,
|
| 305 |
learn: bool = False,
|
|
|
|
|
|
|
|
|
|
| 306 |
) -> None:
|
| 307 |
"""Common logic: start proxy, launch tool, clean up."""
|
| 308 |
proxy_holder: list[subprocess.Popen | None] = [None]
|
|
@@ -318,7 +355,14 @@ def _launch_tool(
|
|
| 318 |
click.echo(" ╚═══════════════════════════════════════════════╝")
|
| 319 |
click.echo()
|
| 320 |
|
| 321 |
-
proxy_holder[0] = _ensure_proxy(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
|
| 323 |
click.echo()
|
| 324 |
click.echo(f" Launching {tool_label} (API routed through Headroom)...")
|
|
@@ -449,10 +493,31 @@ def claude(
|
|
| 449 |
@click.option(
|
| 450 |
"--learn", is_flag=True, help="Enable live traffic learning (patterns saved to AGENTS.md)"
|
| 451 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
|
| 453 |
@click.argument("codex_args", nargs=-1, type=click.UNPROCESSED)
|
| 454 |
def codex(
|
| 455 |
-
port: int,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
) -> None:
|
| 457 |
"""Launch OpenAI Codex CLI through Headroom proxy.
|
| 458 |
|
|
@@ -467,6 +532,7 @@ def codex(
|
|
| 467 |
headroom wrap codex -- "fix the bug" # Pass prompt to codex
|
| 468 |
headroom wrap codex --no-rtk # Skip rtk setup
|
| 469 |
headroom wrap codex --port 9999 # Custom proxy port
|
|
|
|
| 470 |
"""
|
| 471 |
codex_bin = shutil.which("codex")
|
| 472 |
if not codex_bin:
|
|
@@ -499,6 +565,9 @@ def codex(
|
|
| 499 |
tool_label="CODEX",
|
| 500 |
env_vars_display=[f"OPENAI_BASE_URL=http://127.0.0.1:{port}/v1"],
|
| 501 |
learn=learn,
|
|
|
|
|
|
|
|
|
|
| 502 |
)
|
| 503 |
|
| 504 |
|
|
@@ -512,10 +581,23 @@ def codex(
|
|
| 512 |
@click.option("--no-rtk", is_flag=True, help="Skip rtk installation and conventions injection")
|
| 513 |
@click.option("--no-proxy", is_flag=True, help="Skip proxy startup (use existing proxy)")
|
| 514 |
@click.option("--learn", is_flag=True, help="Enable live traffic learning")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
|
| 516 |
@click.argument("aider_args", nargs=-1, type=click.UNPROCESSED)
|
| 517 |
def aider(
|
| 518 |
-
port: int,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
) -> None:
|
| 520 |
"""Launch aider through Headroom proxy.
|
| 521 |
|
|
@@ -530,6 +612,7 @@ def aider(
|
|
| 530 |
headroom wrap aider -- --model gpt-4o # Use GPT-4o
|
| 531 |
headroom wrap aider -- --model claude-sonnet-4 # Use Claude
|
| 532 |
headroom wrap aider --no-rtk # Skip rtk setup
|
|
|
|
| 533 |
"""
|
| 534 |
aider_bin = shutil.which("aider")
|
| 535 |
if not aider_bin:
|
|
@@ -562,6 +645,9 @@ def aider(
|
|
| 562 |
f"ANTHROPIC_BASE_URL=http://127.0.0.1:{port}",
|
| 563 |
],
|
| 564 |
learn=learn,
|
|
|
|
|
|
|
|
|
|
| 565 |
)
|
| 566 |
|
| 567 |
|
|
|
|
| 54 |
return log_dir / "proxy.log"
|
| 55 |
|
| 56 |
|
| 57 |
+
def _start_proxy(
|
| 58 |
+
port: int,
|
| 59 |
+
*,
|
| 60 |
+
learn: bool = False,
|
| 61 |
+
backend: str | None = None,
|
| 62 |
+
anyllm_provider: str | None = None,
|
| 63 |
+
region: str | None = None,
|
| 64 |
+
) -> subprocess.Popen:
|
| 65 |
"""Start Headroom proxy as a background subprocess.
|
| 66 |
|
| 67 |
Logs are written to ~/.headroom/logs/proxy.log to avoid pipe buffer
|
|
|
|
| 79 |
if learn:
|
| 80 |
cmd.append("--learn")
|
| 81 |
|
| 82 |
+
# Forward backend configuration to proxy subprocess
|
| 83 |
+
_backend = backend or os.environ.get("HEADROOM_BACKEND")
|
| 84 |
+
if _backend:
|
| 85 |
+
cmd.extend(["--backend", _backend])
|
| 86 |
+
|
| 87 |
+
_anyllm = anyllm_provider or os.environ.get("HEADROOM_ANYLLM_PROVIDER")
|
| 88 |
+
if _anyllm:
|
| 89 |
+
cmd.extend(["--anyllm-provider", _anyllm])
|
| 90 |
+
|
| 91 |
+
_region = region or os.environ.get("HEADROOM_REGION")
|
| 92 |
+
if _region:
|
| 93 |
+
cmd.extend(["--region", _region])
|
| 94 |
+
|
| 95 |
log_path = _get_log_path()
|
| 96 |
log_file = open(log_path, "a") # noqa: SIM115
|
| 97 |
|
|
|
|
| 253 |
return True
|
| 254 |
|
| 255 |
|
| 256 |
+
def _ensure_proxy(
|
| 257 |
+
port: int,
|
| 258 |
+
no_proxy: bool,
|
| 259 |
+
*,
|
| 260 |
+
learn: bool = False,
|
| 261 |
+
backend: str | None = None,
|
| 262 |
+
anyllm_provider: str | None = None,
|
| 263 |
+
region: str | None = None,
|
| 264 |
+
) -> subprocess.Popen | None:
|
| 265 |
"""Start or verify proxy. Returns process handle if we started it."""
|
| 266 |
if not no_proxy:
|
| 267 |
if _check_proxy(port):
|
|
|
|
| 270 |
else:
|
| 271 |
click.echo(f" Starting Headroom proxy on port {port}...")
|
| 272 |
try:
|
| 273 |
+
proc = _start_proxy(
|
| 274 |
+
port,
|
| 275 |
+
learn=learn,
|
| 276 |
+
backend=backend,
|
| 277 |
+
anyllm_provider=anyllm_provider,
|
| 278 |
+
region=region,
|
| 279 |
+
)
|
| 280 |
click.echo(f" Proxy ready on http://127.0.0.1:{port}")
|
| 281 |
return proc
|
| 282 |
except RuntimeError as e:
|
|
|
|
| 337 |
env_vars_display: list[str],
|
| 338 |
*,
|
| 339 |
learn: bool = False,
|
| 340 |
+
backend: str | None = None,
|
| 341 |
+
anyllm_provider: str | None = None,
|
| 342 |
+
region: str | None = None,
|
| 343 |
) -> None:
|
| 344 |
"""Common logic: start proxy, launch tool, clean up."""
|
| 345 |
proxy_holder: list[subprocess.Popen | None] = [None]
|
|
|
|
| 355 |
click.echo(" ╚═══════════════════════════════════════════════╝")
|
| 356 |
click.echo()
|
| 357 |
|
| 358 |
+
proxy_holder[0] = _ensure_proxy(
|
| 359 |
+
port,
|
| 360 |
+
no_proxy,
|
| 361 |
+
learn=learn,
|
| 362 |
+
backend=backend,
|
| 363 |
+
anyllm_provider=anyllm_provider,
|
| 364 |
+
region=region,
|
| 365 |
+
)
|
| 366 |
|
| 367 |
click.echo()
|
| 368 |
click.echo(f" Launching {tool_label} (API routed through Headroom)...")
|
|
|
|
| 493 |
@click.option(
|
| 494 |
"--learn", is_flag=True, help="Enable live traffic learning (patterns saved to AGENTS.md)"
|
| 495 |
)
|
| 496 |
+
@click.option(
|
| 497 |
+
"--backend",
|
| 498 |
+
default=None,
|
| 499 |
+
help="API backend for the proxy: 'anthropic', 'anyllm', 'litellm-vertex', etc. (env: HEADROOM_BACKEND)",
|
| 500 |
+
)
|
| 501 |
+
@click.option(
|
| 502 |
+
"--anyllm-provider",
|
| 503 |
+
default=None,
|
| 504 |
+
help="Provider for any-llm backend: openai, mistral, groq, etc. (env: HEADROOM_ANYLLM_PROVIDER)",
|
| 505 |
+
)
|
| 506 |
+
@click.option(
|
| 507 |
+
"--region", default=None, help="Cloud region for Bedrock/Vertex (env: HEADROOM_REGION)"
|
| 508 |
+
)
|
| 509 |
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
|
| 510 |
@click.argument("codex_args", nargs=-1, type=click.UNPROCESSED)
|
| 511 |
def codex(
|
| 512 |
+
port: int,
|
| 513 |
+
no_rtk: bool,
|
| 514 |
+
no_proxy: bool,
|
| 515 |
+
learn: bool,
|
| 516 |
+
backend: str | None,
|
| 517 |
+
anyllm_provider: str | None,
|
| 518 |
+
region: str | None,
|
| 519 |
+
verbose: bool,
|
| 520 |
+
codex_args: tuple,
|
| 521 |
) -> None:
|
| 522 |
"""Launch OpenAI Codex CLI through Headroom proxy.
|
| 523 |
|
|
|
|
| 532 |
headroom wrap codex -- "fix the bug" # Pass prompt to codex
|
| 533 |
headroom wrap codex --no-rtk # Skip rtk setup
|
| 534 |
headroom wrap codex --port 9999 # Custom proxy port
|
| 535 |
+
headroom wrap codex --backend anyllm --anyllm-provider groq
|
| 536 |
"""
|
| 537 |
codex_bin = shutil.which("codex")
|
| 538 |
if not codex_bin:
|
|
|
|
| 565 |
tool_label="CODEX",
|
| 566 |
env_vars_display=[f"OPENAI_BASE_URL=http://127.0.0.1:{port}/v1"],
|
| 567 |
learn=learn,
|
| 568 |
+
backend=backend,
|
| 569 |
+
anyllm_provider=anyllm_provider,
|
| 570 |
+
region=region,
|
| 571 |
)
|
| 572 |
|
| 573 |
|
|
|
|
| 581 |
@click.option("--no-rtk", is_flag=True, help="Skip rtk installation and conventions injection")
|
| 582 |
@click.option("--no-proxy", is_flag=True, help="Skip proxy startup (use existing proxy)")
|
| 583 |
@click.option("--learn", is_flag=True, help="Enable live traffic learning")
|
| 584 |
+
@click.option(
|
| 585 |
+
"--backend", default=None, help="API backend: 'anthropic', 'anyllm', 'litellm-vertex', etc."
|
| 586 |
+
)
|
| 587 |
+
@click.option("--anyllm-provider", default=None, help="Provider for any-llm backend")
|
| 588 |
+
@click.option("--region", default=None, help="Cloud region for Bedrock/Vertex")
|
| 589 |
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
|
| 590 |
@click.argument("aider_args", nargs=-1, type=click.UNPROCESSED)
|
| 591 |
def aider(
|
| 592 |
+
port: int,
|
| 593 |
+
no_rtk: bool,
|
| 594 |
+
no_proxy: bool,
|
| 595 |
+
learn: bool,
|
| 596 |
+
backend: str | None,
|
| 597 |
+
anyllm_provider: str | None,
|
| 598 |
+
region: str | None,
|
| 599 |
+
verbose: bool,
|
| 600 |
+
aider_args: tuple,
|
| 601 |
) -> None:
|
| 602 |
"""Launch aider through Headroom proxy.
|
| 603 |
|
|
|
|
| 612 |
headroom wrap aider -- --model gpt-4o # Use GPT-4o
|
| 613 |
headroom wrap aider -- --model claude-sonnet-4 # Use Claude
|
| 614 |
headroom wrap aider --no-rtk # Skip rtk setup
|
| 615 |
+
headroom wrap aider --backend litellm-vertex --region us-central1
|
| 616 |
"""
|
| 617 |
aider_bin = shutil.which("aider")
|
| 618 |
if not aider_bin:
|
|
|
|
| 645 |
f"ANTHROPIC_BASE_URL=http://127.0.0.1:{port}",
|
| 646 |
],
|
| 647 |
learn=learn,
|
| 648 |
+
backend=backend,
|
| 649 |
+
anyllm_provider=anyllm_provider,
|
| 650 |
+
region=region,
|
| 651 |
)
|
| 652 |
|
| 653 |
|
|
@@ -2653,8 +2653,11 @@ class HeadroomProxy:
|
|
| 2653 |
usage = backend_response.body.get("usage", {})
|
| 2654 |
output_tokens = usage.get("output_tokens", 0)
|
| 2655 |
|
|
|
|
|
|
|
|
|
|
| 2656 |
await self.metrics.record_request(
|
| 2657 |
-
provider=
|
| 2658 |
model=model,
|
| 2659 |
input_tokens=optimized_tokens,
|
| 2660 |
output_tokens=output_tokens,
|
|
@@ -2674,7 +2677,7 @@ class HeadroomProxy:
|
|
| 2674 |
RequestLog(
|
| 2675 |
request_id=request_id,
|
| 2676 |
timestamp=datetime.now().isoformat(),
|
| 2677 |
-
provider=
|
| 2678 |
model=model,
|
| 2679 |
input_tokens_original=original_tokens,
|
| 2680 |
input_tokens_optimized=optimized_tokens,
|
|
@@ -4869,8 +4872,11 @@ class HeadroomProxy:
|
|
| 4869 |
total_latency = (time.time() - start_time) * 1000
|
| 4870 |
output_tokens = stream_state["output_tokens"]
|
| 4871 |
|
|
|
|
|
|
|
|
|
|
| 4872 |
await self.metrics.record_request(
|
| 4873 |
-
provider=
|
| 4874 |
model=model,
|
| 4875 |
input_tokens=optimized_tokens,
|
| 4876 |
output_tokens=output_tokens,
|
|
@@ -4891,7 +4897,7 @@ class HeadroomProxy:
|
|
| 4891 |
RequestLog(
|
| 4892 |
request_id=request_id,
|
| 4893 |
timestamp=datetime.now().isoformat(),
|
| 4894 |
-
provider=
|
| 4895 |
model=model,
|
| 4896 |
input_tokens_original=original_tokens,
|
| 4897 |
input_tokens_optimized=optimized_tokens,
|
|
@@ -6264,6 +6270,14 @@ class HeadroomProxy:
|
|
| 6264 |
|
| 6265 |
body["input"] = messages_to_responses_items(opt_msgs, original_items, preserved_indices)
|
| 6266 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6267 |
url = f"{self.OPENAI_API_URL}/v1/responses"
|
| 6268 |
|
| 6269 |
try:
|
|
@@ -6277,7 +6291,7 @@ class HeadroomProxy:
|
|
| 6277 |
model,
|
| 6278 |
request_id,
|
| 6279 |
original_tokens,
|
| 6280 |
-
|
| 6281 |
tokens_saved,
|
| 6282 |
transforms_applied,
|
| 6283 |
tags,
|
|
@@ -6360,31 +6374,46 @@ class HeadroomProxy:
|
|
| 6360 |
)
|
| 6361 |
return
|
| 6362 |
|
| 6363 |
-
await websocket.accept()
|
| 6364 |
request_id = await self._next_request_id()
|
| 6365 |
|
| 6366 |
# Forward client headers to upstream, adding required OpenAI-Beta header
|
| 6367 |
ws_headers = dict(websocket.headers)
|
| 6368 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6369 |
# Build upstream WebSocket URL (http→ws, https→wss)
|
| 6370 |
base = self.OPENAI_API_URL
|
| 6371 |
ws_base = base.replace("https://", "wss://").replace("http://", "ws://")
|
| 6372 |
upstream_url = f"{ws_base}/v1/responses"
|
| 6373 |
|
| 6374 |
-
# Forward all
|
| 6375 |
-
#
|
|
|
|
|
|
|
| 6376 |
_skip_headers = frozenset(
|
| 6377 |
{
|
| 6378 |
-
"host",
|
| 6379 |
-
"connection",
|
| 6380 |
-
"upgrade",
|
| 6381 |
-
"sec-websocket-key",
|
| 6382 |
-
"sec-websocket-version",
|
| 6383 |
-
"sec-websocket-extensions",
|
| 6384 |
-
"sec-websocket-accept",
|
| 6385 |
-
"sec-websocket-protocol",
|
| 6386 |
-
"content-length",
|
| 6387 |
-
"transfer-encoding",
|
| 6388 |
}
|
| 6389 |
)
|
| 6390 |
upstream_headers: dict[str, str] = {}
|
|
@@ -6392,10 +6421,30 @@ class HeadroomProxy:
|
|
| 6392 |
if k.lower() not in _skip_headers:
|
| 6393 |
upstream_headers[k] = v
|
| 6394 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6395 |
# Ensure the required beta header is present — OpenAI returns 500 without it
|
| 6396 |
if "openai-beta" not in {k.lower() for k in upstream_headers}:
|
| 6397 |
upstream_headers["OpenAI-Beta"] = "responses-api=v1"
|
| 6398 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6399 |
try:
|
| 6400 |
# Receive the first message from client (the response.create request)
|
| 6401 |
first_msg_raw = await websocket.receive_text()
|
|
@@ -6479,6 +6528,11 @@ class HeadroomProxy:
|
|
| 6479 |
async with websockets.connect(
|
| 6480 |
upstream_url,
|
| 6481 |
additional_headers=upstream_headers,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6482 |
ssl=ssl_ctx if upstream_url.startswith("wss://") else None,
|
| 6483 |
) as upstream:
|
| 6484 |
# Send (potentially compressed) first message
|
|
@@ -6490,16 +6544,28 @@ class HeadroomProxy:
|
|
| 6490 |
while True:
|
| 6491 |
msg = await websocket.receive_text()
|
| 6492 |
await upstream.send(msg)
|
| 6493 |
-
except Exception:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6494 |
with contextlib.suppress(Exception):
|
| 6495 |
await upstream.close()
|
| 6496 |
|
| 6497 |
async def _upstream_to_client() -> None:
|
| 6498 |
try:
|
| 6499 |
async for msg in upstream:
|
| 6500 |
-
|
| 6501 |
-
|
| 6502 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6503 |
finally:
|
| 6504 |
with contextlib.suppress(Exception):
|
| 6505 |
await websocket.close()
|
|
@@ -6524,7 +6590,19 @@ class HeadroomProxy:
|
|
| 6524 |
|
| 6525 |
except Exception as e:
|
| 6526 |
if "WebSocketDisconnect" not in type(e).__name__:
|
| 6527 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6528 |
with contextlib.suppress(Exception):
|
| 6529 |
await websocket.close(code=1011, reason=str(e)[:120])
|
| 6530 |
|
|
|
|
| 2653 |
usage = backend_response.body.get("usage", {})
|
| 2654 |
output_tokens = usage.get("output_tokens", 0)
|
| 2655 |
|
| 2656 |
+
_backend_name = (
|
| 2657 |
+
self.anthropic_backend.name if self.anthropic_backend else "anthropic"
|
| 2658 |
+
)
|
| 2659 |
await self.metrics.record_request(
|
| 2660 |
+
provider=_backend_name,
|
| 2661 |
model=model,
|
| 2662 |
input_tokens=optimized_tokens,
|
| 2663 |
output_tokens=output_tokens,
|
|
|
|
| 2677 |
RequestLog(
|
| 2678 |
request_id=request_id,
|
| 2679 |
timestamp=datetime.now().isoformat(),
|
| 2680 |
+
provider=_backend_name,
|
| 2681 |
model=model,
|
| 2682 |
input_tokens_original=original_tokens,
|
| 2683 |
input_tokens_optimized=optimized_tokens,
|
|
|
|
| 4872 |
total_latency = (time.time() - start_time) * 1000
|
| 4873 |
output_tokens = stream_state["output_tokens"]
|
| 4874 |
|
| 4875 |
+
_backend_name = (
|
| 4876 |
+
self.anthropic_backend.name if self.anthropic_backend else "anthropic"
|
| 4877 |
+
)
|
| 4878 |
await self.metrics.record_request(
|
| 4879 |
+
provider=_backend_name,
|
| 4880 |
model=model,
|
| 4881 |
input_tokens=optimized_tokens,
|
| 4882 |
output_tokens=output_tokens,
|
|
|
|
| 4897 |
RequestLog(
|
| 4898 |
request_id=request_id,
|
| 4899 |
timestamp=datetime.now().isoformat(),
|
| 4900 |
+
provider=_backend_name,
|
| 4901 |
model=model,
|
| 4902 |
input_tokens_original=original_tokens,
|
| 4903 |
input_tokens_optimized=optimized_tokens,
|
|
|
|
| 6270 |
|
| 6271 |
body["input"] = messages_to_responses_items(opt_msgs, original_items, preserved_indices)
|
| 6272 |
|
| 6273 |
+
# /v1/responses is OpenAI-specific (Codex) — always routes direct.
|
| 6274 |
+
# LiteLLM/AnyLLM backends use /v1/chat/completions or /v1/messages.
|
| 6275 |
+
if self.anthropic_backend is not None:
|
| 6276 |
+
logger.debug(
|
| 6277 |
+
f"[{request_id}] /v1/responses always routes to OpenAI direct "
|
| 6278 |
+
f"(backend '{self.anthropic_backend.name}' not used for Responses API)"
|
| 6279 |
+
)
|
| 6280 |
+
|
| 6281 |
url = f"{self.OPENAI_API_URL}/v1/responses"
|
| 6282 |
|
| 6283 |
try:
|
|
|
|
| 6291 |
model,
|
| 6292 |
request_id,
|
| 6293 |
original_tokens,
|
| 6294 |
+
optimized_tokens,
|
| 6295 |
tokens_saved,
|
| 6296 |
transforms_applied,
|
| 6297 |
tags,
|
|
|
|
| 6374 |
)
|
| 6375 |
return
|
| 6376 |
|
|
|
|
| 6377 |
request_id = await self._next_request_id()
|
| 6378 |
|
| 6379 |
# Forward client headers to upstream, adding required OpenAI-Beta header
|
| 6380 |
ws_headers = dict(websocket.headers)
|
| 6381 |
|
| 6382 |
+
# Extract subprotocol from client — this is an application-level negotiation
|
| 6383 |
+
# that MUST be forwarded end-to-end (unlike sec-websocket-key which is per-connection).
|
| 6384 |
+
# Codex and OpenAI negotiate a subprotocol; stripping it causes OpenAI to return 500.
|
| 6385 |
+
client_subprotocols: list[str] = []
|
| 6386 |
+
raw_protocol = ws_headers.get("sec-websocket-protocol", "")
|
| 6387 |
+
if raw_protocol:
|
| 6388 |
+
client_subprotocols = [p.strip() for p in raw_protocol.split(",") if p.strip()]
|
| 6389 |
+
|
| 6390 |
+
# Accept client connection with the requested subprotocol
|
| 6391 |
+
if client_subprotocols:
|
| 6392 |
+
await websocket.accept(subprotocol=client_subprotocols[0])
|
| 6393 |
+
else:
|
| 6394 |
+
await websocket.accept()
|
| 6395 |
+
|
| 6396 |
# Build upstream WebSocket URL (http→ws, https→wss)
|
| 6397 |
base = self.OPENAI_API_URL
|
| 6398 |
ws_base = base.replace("https://", "wss://").replace("http://", "ws://")
|
| 6399 |
upstream_url = f"{ws_base}/v1/responses"
|
| 6400 |
|
| 6401 |
+
# Forward all client headers except hop-by-hop / per-connection headers.
|
| 6402 |
+
# These are WebSocket handshake mechanics that the `websockets` library
|
| 6403 |
+
# generates fresh for the upstream connection — forwarding them would conflict.
|
| 6404 |
+
# Everything else (auth, org, beta, user-agent, custom headers) is forwarded as-is.
|
| 6405 |
_skip_headers = frozenset(
|
| 6406 |
{
|
| 6407 |
+
"host", # must match upstream, not local proxy
|
| 6408 |
+
"connection", # hop-by-hop
|
| 6409 |
+
"upgrade", # hop-by-hop
|
| 6410 |
+
"sec-websocket-key", # per-connection cryptographic nonce
|
| 6411 |
+
"sec-websocket-version", # protocol version (websockets lib sets this)
|
| 6412 |
+
"sec-websocket-extensions", # per-connection negotiation
|
| 6413 |
+
"sec-websocket-accept", # server-side only
|
| 6414 |
+
"sec-websocket-protocol", # handled via subprotocols param below
|
| 6415 |
+
"content-length", # hop-by-hop
|
| 6416 |
+
"transfer-encoding", # hop-by-hop
|
| 6417 |
}
|
| 6418 |
)
|
| 6419 |
upstream_headers: dict[str, str] = {}
|
|
|
|
| 6421 |
if k.lower() not in _skip_headers:
|
| 6422 |
upstream_headers[k] = v
|
| 6423 |
|
| 6424 |
+
# Ensure Authorization header is present — fall back to OPENAI_API_KEY env var.
|
| 6425 |
+
# Safety net for clients that don't forward auth headers via WebSocket upgrade.
|
| 6426 |
+
_has_auth = "authorization" in {k.lower() for k in upstream_headers}
|
| 6427 |
+
if not _has_auth:
|
| 6428 |
+
api_key = os.environ.get("OPENAI_API_KEY")
|
| 6429 |
+
if api_key:
|
| 6430 |
+
upstream_headers["Authorization"] = f"Bearer {api_key}"
|
| 6431 |
+
logger.debug(f"[{request_id}] WS: injected Authorization from OPENAI_API_KEY env")
|
| 6432 |
+
else:
|
| 6433 |
+
logger.warning(
|
| 6434 |
+
f"[{request_id}] WS: no Authorization header from client and "
|
| 6435 |
+
f"OPENAI_API_KEY not set — upstream will likely reject"
|
| 6436 |
+
)
|
| 6437 |
+
|
| 6438 |
# Ensure the required beta header is present — OpenAI returns 500 without it
|
| 6439 |
if "openai-beta" not in {k.lower() for k in upstream_headers}:
|
| 6440 |
upstream_headers["OpenAI-Beta"] = "responses-api=v1"
|
| 6441 |
|
| 6442 |
+
logger.debug(
|
| 6443 |
+
f"[{request_id}] WS upstream headers: "
|
| 6444 |
+
f"{[k for k in upstream_headers if k.lower() != 'authorization']}, "
|
| 6445 |
+
f"subprotocols={client_subprotocols}"
|
| 6446 |
+
)
|
| 6447 |
+
|
| 6448 |
try:
|
| 6449 |
# Receive the first message from client (the response.create request)
|
| 6450 |
first_msg_raw = await websocket.receive_text()
|
|
|
|
| 6528 |
async with websockets.connect(
|
| 6529 |
upstream_url,
|
| 6530 |
additional_headers=upstream_headers,
|
| 6531 |
+
subprotocols=(
|
| 6532 |
+
[websockets.Subprotocol(p) for p in client_subprotocols]
|
| 6533 |
+
if client_subprotocols and hasattr(websockets, "Subprotocol")
|
| 6534 |
+
else client_subprotocols or None
|
| 6535 |
+
),
|
| 6536 |
ssl=ssl_ctx if upstream_url.startswith("wss://") else None,
|
| 6537 |
) as upstream:
|
| 6538 |
# Send (potentially compressed) first message
|
|
|
|
| 6544 |
while True:
|
| 6545 |
msg = await websocket.receive_text()
|
| 6546 |
await upstream.send(msg)
|
| 6547 |
+
except Exception as relay_err:
|
| 6548 |
+
if "WebSocketDisconnect" not in type(relay_err).__name__:
|
| 6549 |
+
logger.debug(
|
| 6550 |
+
f"[{request_id}] WS client→upstream relay ended: {relay_err}"
|
| 6551 |
+
)
|
| 6552 |
with contextlib.suppress(Exception):
|
| 6553 |
await upstream.close()
|
| 6554 |
|
| 6555 |
async def _upstream_to_client() -> None:
|
| 6556 |
try:
|
| 6557 |
async for msg in upstream:
|
| 6558 |
+
if isinstance(msg, str):
|
| 6559 |
+
await websocket.send_text(msg)
|
| 6560 |
+
elif isinstance(msg, bytes):
|
| 6561 |
+
await websocket.send_bytes(msg)
|
| 6562 |
+
else:
|
| 6563 |
+
await websocket.send_text(str(msg))
|
| 6564 |
+
except Exception as relay_err:
|
| 6565 |
+
if "WebSocketDisconnect" not in type(relay_err).__name__:
|
| 6566 |
+
logger.debug(
|
| 6567 |
+
f"[{request_id}] WS upstream→client relay ended: {relay_err}"
|
| 6568 |
+
)
|
| 6569 |
finally:
|
| 6570 |
with contextlib.suppress(Exception):
|
| 6571 |
await websocket.close()
|
|
|
|
| 6590 |
|
| 6591 |
except Exception as e:
|
| 6592 |
if "WebSocketDisconnect" not in type(e).__name__:
|
| 6593 |
+
# Extract response body from websockets InvalidStatus for better debugging
|
| 6594 |
+
error_detail = str(e)
|
| 6595 |
+
if hasattr(e, "response"):
|
| 6596 |
+
try:
|
| 6597 |
+
resp = e.response
|
| 6598 |
+
body_bytes = getattr(resp, "body", None) or b""
|
| 6599 |
+
if body_bytes:
|
| 6600 |
+
error_detail += (
|
| 6601 |
+
f" | body: {body_bytes[:500].decode('utf-8', errors='replace')}"
|
| 6602 |
+
)
|
| 6603 |
+
except Exception:
|
| 6604 |
+
pass
|
| 6605 |
+
logger.error(f"[{request_id}] WS proxy error: {error_detail}")
|
| 6606 |
with contextlib.suppress(Exception):
|
| 6607 |
await websocket.close(code=1011, reason=str(e)[:120])
|
| 6608 |
|