chopratejas Claude Opus 4.6 (1M context) commited on
Commit
7aa6cac
·
1 Parent(s): 2b7049f

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 CHANGED
@@ -662,9 +662,19 @@ class LiteLLMBackend(Backend):
662
  )
663
  kwargs["messages"].insert(0, {"role": "system", "content": system_text})
664
 
665
- # AWS region for Bedrock
666
- if self.provider == "bedrock" and self.region:
667
- kwargs["aws_region_name"] = self.region
 
 
 
 
 
 
 
 
 
 
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
- if self.provider == "bedrock" and self.region:
756
- kwargs["aws_region_name"] = self.region
 
 
 
 
 
 
 
 
 
 
 
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.provider == "bedrock" and self.region:
966
- kwargs["aws_region_name"] = self.region
967
- elif self.provider == "databricks":
968
- # Databricks uses env vars for auth
969
- pass
 
 
 
 
 
 
 
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
- if self.provider == "bedrock" and self.region:
1090
- kwargs["aws_region_name"] = self.region
 
 
 
 
 
 
 
 
 
 
 
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
 
headroom/cli/proxy.py CHANGED
@@ -250,13 +250,12 @@ def proxy(
250
  if license_key:
251
  license_status = f"MANAGED (key={license_key[:8]}...)"
252
 
253
- effective_region = bedrock_region or region
254
- backend_status = "Anthropic (direct API)"
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
- Usage with Claude Code:
326
- ANTHROPIC_BASE_URL=http://{config.host}:{config.port} claude
 
 
327
 
328
- Usage with OpenAI-compatible clients:
329
- OPENAI_BASE_URL=http://{config.host}:{config.port}/v1 your-app
 
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
  """)
headroom/cli/wrap.py CHANGED
@@ -54,7 +54,14 @@ def _get_log_path() -> Path:
54
  return log_dir / "proxy.log"
55
 
56
 
57
- def _start_proxy(port: int, *, learn: bool = False) -> subprocess.Popen:
 
 
 
 
 
 
 
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(port: int, no_proxy: bool, *, learn: bool = False) -> subprocess.Popen | None:
 
 
 
 
 
 
 
 
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(port, learn=learn)
 
 
 
 
 
 
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(port, no_proxy, learn=learn)
 
 
 
 
 
 
 
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, no_rtk: bool, no_proxy: bool, learn: bool, verbose: bool, codex_args: tuple
 
 
 
 
 
 
 
 
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, no_rtk: bool, no_proxy: bool, learn: bool, verbose: bool, aider_args: tuple
 
 
 
 
 
 
 
 
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
 
headroom/proxy/server.py CHANGED
@@ -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="bedrock",
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="bedrock",
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="bedrock",
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="bedrock",
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
- original_tokens,
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 relevant headers (auth, org, project, beta, etc.)
6375
- # Skip hop-by-hop headers that shouldn't be forwarded
 
 
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
- await websocket.send_text(msg if isinstance(msg, str) else msg.decode())
6501
- except Exception:
6502
- pass
 
 
 
 
 
 
 
 
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
- logger.error(f"[{request_id}] WS proxy error: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
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