chopratejas commited on
Commit
b78eec8
·
2 Parent(s): 4452bea73ff535

Merge pull request #109 from JerrettDavis/feat/openclaw-upstream-gateway

Browse files
README.md CHANGED
@@ -135,6 +135,7 @@ headroom wrap codex # Starts proxy + launches OpenAI Codex CLI
135
  headroom wrap aider # Starts proxy + launches Aider
136
  headroom wrap cursor # Starts proxy + prints Cursor config
137
  headroom wrap openclaw # Installs + configures OpenClaw plugin
 
138
  ```
139
 
140
  Headroom starts a proxy, points your tool at it, and compresses everything automatically.
@@ -174,7 +175,7 @@ Gives your AI tool three MCP tools: `headroom_compress`, `headroom_retrieve`, `h
174
  | **Any Python proxy** | ASGI Middleware | `app.add_middleware(CompressionMiddleware)` |
175
  | **Agno agents** | Wrap model | `HeadroomAgnoModel(your_model)` |
176
  | **LangChain** | Wrap model | `HeadroomChatModel(your_llm)` |
177
- | **OpenClaw** | One-command wrap | `headroom wrap openclaw` |
178
  | **Claude Code** | Wrap | `headroom wrap claude` |
179
  | **Codex / Aider** | Wrap | `headroom wrap codex` or `headroom wrap aider` |
180
 
 
135
  headroom wrap aider # Starts proxy + launches Aider
136
  headroom wrap cursor # Starts proxy + prints Cursor config
137
  headroom wrap openclaw # Installs + configures OpenClaw plugin
138
+ headroom unwrap openclaw # Disables plugin + restores legacy engine
139
  ```
140
 
141
  Headroom starts a proxy, points your tool at it, and compresses everything automatically.
 
175
  | **Any Python proxy** | ASGI Middleware | `app.add_middleware(CompressionMiddleware)` |
176
  | **Agno agents** | Wrap model | `HeadroomAgnoModel(your_model)` |
177
  | **LangChain** | Wrap model | `HeadroomChatModel(your_llm)` |
178
+ | **OpenClaw** | One-command wrap/unwrap | `headroom wrap openclaw` / `headroom unwrap openclaw` |
179
  | **Claude Code** | Wrap | `headroom wrap claude` |
180
  | **Codex / Aider** | Wrap | `headroom wrap codex` or `headroom wrap aider` |
181
 
e2e/wrap/run.py CHANGED
@@ -355,6 +355,11 @@ def start_openclaw_gateway(env: dict[str, str], cwd: Path) -> subprocess.Popen[s
355
  )
356
 
357
 
 
 
 
 
 
358
  def verify_installs() -> None:
359
  log("Verifying installed packages and binaries")
360
  for tool in ("headroom", "codex", "aider", "openclaw"):
@@ -617,6 +622,7 @@ def verify_openclaw_wrap(
617
  finally:
618
  if gateway_proc is not None:
619
  stop_process(gateway_proc)
 
620
 
621
 
622
  def main() -> None:
 
355
  )
356
 
357
 
358
+ def stop_openclaw_gateway(env: dict[str, str], cwd: Path) -> None:
359
+ log("Stopping OpenClaw gateway after e2e verification")
360
+ run(["openclaw", "gateway", "stop"], env=env, cwd=cwd, timeout=60)
361
+
362
+
363
  def verify_installs() -> None:
364
  log("Verifying installed packages and binaries")
365
  for tool in ("headroom", "codex", "aider", "openclaw"):
 
622
  finally:
623
  if gateway_proc is not None:
624
  stop_process(gateway_proc)
625
+ stop_openclaw_gateway(base_env, project_dir)
626
 
627
 
628
  def main() -> None:
headroom/cli/proxy.py CHANGED
@@ -35,6 +35,18 @@ from .main import main
35
  @click.option("--no-optimize", is_flag=True, help="Disable optimization (passthrough mode)")
36
  @click.option("--no-cache", is_flag=True, help="Disable semantic caching")
37
  @click.option("--no-rate-limit", is_flag=True, help="Disable rate limiting")
 
 
 
 
 
 
 
 
 
 
 
 
38
  @click.option("--log-file", default=None, help="Path to JSONL log file")
39
  @click.option("--budget", type=float, default=None, help="Daily budget limit in USD")
40
  # Code-aware compression (ON by default if installed)
@@ -154,6 +166,8 @@ def proxy(
154
  no_optimize: bool,
155
  no_cache: bool,
156
  no_rate_limit: bool,
 
 
157
  log_file: str | None,
158
  budget: float | None,
159
  no_code_aware: bool,
@@ -232,6 +246,10 @@ def proxy(
232
  optimize=not no_optimize,
233
  cache_enabled=not no_cache,
234
  rate_limit_enabled=not no_rate_limit,
 
 
 
 
235
  log_file=log_file,
236
  budget_limit_usd=budget,
237
  # Code-aware: ON by default (use --no-code-aware to disable)
 
35
  @click.option("--no-optimize", is_flag=True, help="Disable optimization (passthrough mode)")
36
  @click.option("--no-cache", is_flag=True, help="Disable semantic caching")
37
  @click.option("--no-rate-limit", is_flag=True, help="Disable rate limiting")
38
+ @click.option(
39
+ "--retry-max-attempts",
40
+ type=int,
41
+ default=None,
42
+ help="Maximum upstream retry attempts for connect/read/5xx failures (default: 3)",
43
+ )
44
+ @click.option(
45
+ "--connect-timeout-seconds",
46
+ type=int,
47
+ default=None,
48
+ help="Upstream connection timeout in seconds (default: 10)",
49
+ )
50
  @click.option("--log-file", default=None, help="Path to JSONL log file")
51
  @click.option("--budget", type=float, default=None, help="Daily budget limit in USD")
52
  # Code-aware compression (ON by default if installed)
 
166
  no_optimize: bool,
167
  no_cache: bool,
168
  no_rate_limit: bool,
169
+ retry_max_attempts: int | None,
170
+ connect_timeout_seconds: int | None,
171
  log_file: str | None,
172
  budget: float | None,
173
  no_code_aware: bool,
 
246
  optimize=not no_optimize,
247
  cache_enabled=not no_cache,
248
  rate_limit_enabled=not no_rate_limit,
249
+ retry_max_attempts=retry_max_attempts if retry_max_attempts is not None else 3,
250
+ connect_timeout_seconds=connect_timeout_seconds
251
+ if connect_timeout_seconds is not None
252
+ else 10,
253
  log_file=log_file,
254
  budget_limit_usd=budget,
255
  # Code-aware: ON by default (use --no-code-aware to disable)
headroom/cli/wrap.py CHANGED
@@ -478,6 +478,131 @@ def _resolve_openclaw_extensions_dir(openclaw_bin: str) -> Path:
478
  return config_path.parent / "extensions"
479
 
480
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
  def _copy_openclaw_plugin_into_extensions(
482
  *,
483
  plugin_dir: Path,
@@ -533,6 +658,11 @@ def wrap() -> None:
533
  """
534
 
535
 
 
 
 
 
 
536
  # =============================================================================
537
  # Claude Code
538
  # =============================================================================
@@ -913,6 +1043,12 @@ def cursor(port: int, no_rtk: bool, no_proxy: bool, learn: bool, verbose: bool)
913
  )
914
  @click.option("--proxy-port", default=8787, type=int, help="Headroom proxy port")
915
  @click.option("--startup-timeout-ms", default=20000, type=int, help="Proxy startup timeout")
 
 
 
 
 
 
916
  @click.option(
917
  "--python-path",
918
  default=None,
@@ -936,6 +1072,7 @@ def openclaw(
936
  copy: bool,
937
  proxy_port: int,
938
  startup_timeout_ms: int,
 
939
  python_path: str | None,
940
  no_auto_start: bool,
941
  no_restart: bool,
@@ -995,6 +1132,24 @@ def openclaw(
995
  elif not local_source_mode and skip_build:
996
  click.echo(" Skipping build: npm install mode does not build local source.")
997
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
998
  install_cmd = [
999
  openclaw_bin,
1000
  "plugins",
@@ -1047,38 +1202,7 @@ def openclaw(
1047
  elif verbose and install_result.stdout.strip():
1048
  click.echo(install_result.stdout.strip())
1049
 
1050
- plugin_config: dict[str, object] = {
1051
- "proxyPort": proxy_port,
1052
- "autoStart": not no_auto_start,
1053
- "startupTimeoutMs": startup_timeout_ms,
1054
- }
1055
- if python_path:
1056
- plugin_config["pythonPath"] = python_path
1057
- entry = {"enabled": True, "config": plugin_config}
1058
-
1059
- click.echo(" Writing plugin configuration...")
1060
- _run_checked(
1061
- [
1062
- openclaw_bin,
1063
- "config",
1064
- "set",
1065
- "plugins.entries.headroom",
1066
- json.dumps(entry, separators=(",", ":")),
1067
- "--strict-json",
1068
- ],
1069
- action="openclaw config set plugins.entries.headroom",
1070
- )
1071
- _run_checked(
1072
- [
1073
- openclaw_bin,
1074
- "config",
1075
- "set",
1076
- "plugins.slots.contextEngine",
1077
- json.dumps("headroom"),
1078
- "--strict-json",
1079
- ],
1080
- action="openclaw config set plugins.slots.contextEngine",
1081
- )
1082
  _run_checked(
1083
  [openclaw_bin, "config", "validate"],
1084
  action="openclaw config validate",
@@ -1086,15 +1210,15 @@ def openclaw(
1086
 
1087
  if no_restart:
1088
  click.echo(" Skipping gateway restart (--no-restart).")
1089
- click.echo(" Run `openclaw gateway restart` to apply plugin changes.")
1090
- else:
1091
- click.echo(" Warning: restarting OpenClaw gateway to apply plugin changes.")
1092
- restart_result = _run_checked(
1093
- [openclaw_bin, "gateway", "restart"],
1094
- action="openclaw gateway restart",
1095
  )
1096
- if verbose and restart_result.stdout.strip():
1097
- click.echo(restart_result.stdout.strip())
 
 
 
 
1098
 
1099
  inspect_result = _run_checked(
1100
  [openclaw_bin, "plugins", "inspect", "headroom"],
@@ -1108,3 +1232,71 @@ def openclaw(
1108
  click.echo(" Plugin: headroom")
1109
  click.echo(" Slot: plugins.slots.contextEngine = headroom")
1110
  click.echo()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
  return config_path.parent / "extensions"
479
 
480
 
481
+ def _normalize_openclaw_gateway_provider_ids(provider_ids: tuple[str, ...] | None) -> list[str]:
482
+ """Normalize configured OpenClaw provider ids, defaulting to openai-codex."""
483
+ values = provider_ids or ()
484
+ seen: set[str] = set()
485
+ normalized: list[str] = []
486
+
487
+ for entry in values:
488
+ provider_id = entry.strip()
489
+ if not provider_id or provider_id in seen:
490
+ continue
491
+ seen.add(provider_id)
492
+ normalized.append(provider_id)
493
+
494
+ if normalized:
495
+ return normalized
496
+ return ["openai-codex"]
497
+
498
+
499
+ def _read_openclaw_config_value(openclaw_bin: str, path: str) -> Any | None:
500
+ """Read an OpenClaw config value when present, returning None on missing paths."""
501
+ result = subprocess.run(
502
+ [openclaw_bin, "config", "get", path],
503
+ capture_output=True,
504
+ text=True,
505
+ encoding="utf-8",
506
+ errors="replace",
507
+ )
508
+ if result.returncode != 0:
509
+ return None
510
+
511
+ output = result.stdout.strip()
512
+ if not output:
513
+ return None
514
+
515
+ try:
516
+ return json.loads(output)
517
+ except json.JSONDecodeError:
518
+ return output
519
+
520
+
521
+ def _build_openclaw_plugin_entry(
522
+ *,
523
+ existing_entry: Any,
524
+ proxy_port: int,
525
+ startup_timeout_ms: int,
526
+ python_path: str | None,
527
+ no_auto_start: bool,
528
+ gateway_provider_ids: tuple[str, ...] | None,
529
+ enabled: bool,
530
+ ) -> dict[str, object]:
531
+ """Merge managed Headroom plugin settings with any existing entry payload."""
532
+ base_entry = existing_entry if isinstance(existing_entry, dict) else {}
533
+ existing_config = base_entry.get("config")
534
+ next_config = dict(existing_config) if isinstance(existing_config, dict) else {}
535
+
536
+ next_config["proxyPort"] = proxy_port
537
+ next_config["autoStart"] = not no_auto_start
538
+ next_config["startupTimeoutMs"] = startup_timeout_ms
539
+ next_config["gatewayProviderIds"] = _normalize_openclaw_gateway_provider_ids(
540
+ gateway_provider_ids
541
+ )
542
+
543
+ if python_path:
544
+ next_config["pythonPath"] = python_path
545
+ else:
546
+ next_config.pop("pythonPath", None)
547
+
548
+ return {
549
+ **base_entry,
550
+ "enabled": enabled,
551
+ "config": next_config,
552
+ }
553
+
554
+
555
+ def _write_openclaw_plugin_entry(openclaw_bin: str, entry: dict[str, object]) -> None:
556
+ """Persist the Headroom plugin config entry."""
557
+ _run_checked(
558
+ [
559
+ openclaw_bin,
560
+ "config",
561
+ "set",
562
+ "plugins.entries.headroom",
563
+ json.dumps(entry, separators=(",", ":")),
564
+ "--strict-json",
565
+ ],
566
+ action="openclaw config set plugins.entries.headroom",
567
+ )
568
+
569
+
570
+ def _set_openclaw_context_engine_slot(openclaw_bin: str, engine_id: str) -> None:
571
+ """Persist the selected OpenClaw context engine slot."""
572
+ _run_checked(
573
+ [
574
+ openclaw_bin,
575
+ "config",
576
+ "set",
577
+ "plugins.slots.contextEngine",
578
+ json.dumps(engine_id),
579
+ "--strict-json",
580
+ ],
581
+ action="openclaw config set plugins.slots.contextEngine",
582
+ )
583
+
584
+
585
+ def _restart_or_start_openclaw_gateway(openclaw_bin: str) -> tuple[str, str]:
586
+ """Restart the gateway when running, otherwise start it."""
587
+ restart_result = subprocess.run(
588
+ [openclaw_bin, "gateway", "restart"],
589
+ capture_output=True,
590
+ text=True,
591
+ encoding="utf-8",
592
+ errors="replace",
593
+ )
594
+ if restart_result.returncode == 0:
595
+ output = restart_result.stdout.strip() or restart_result.stderr.strip()
596
+ return "restarted", output
597
+
598
+ start_result = _run_checked(
599
+ [openclaw_bin, "gateway", "start"],
600
+ action="openclaw gateway start",
601
+ )
602
+ output = start_result.stdout.strip() or start_result.stderr.strip()
603
+ return "started", output
604
+
605
+
606
  def _copy_openclaw_plugin_into_extensions(
607
  *,
608
  plugin_dir: Path,
 
658
  """
659
 
660
 
661
+ @main.group()
662
+ def unwrap() -> None:
663
+ """Undo durable Headroom wrapping for supported tools."""
664
+
665
+
666
  # =============================================================================
667
  # Claude Code
668
  # =============================================================================
 
1043
  )
1044
  @click.option("--proxy-port", default=8787, type=int, help="Headroom proxy port")
1045
  @click.option("--startup-timeout-ms", default=20000, type=int, help="Proxy startup timeout")
1046
+ @click.option(
1047
+ "--gateway-provider-id",
1048
+ "gateway_provider_ids",
1049
+ multiple=True,
1050
+ help="OpenClaw provider id to route through Headroom (repeatable; default: openai-codex)",
1051
+ )
1052
  @click.option(
1053
  "--python-path",
1054
  default=None,
 
1072
  copy: bool,
1073
  proxy_port: int,
1074
  startup_timeout_ms: int,
1075
+ gateway_provider_ids: tuple[str, ...],
1076
  python_path: str | None,
1077
  no_auto_start: bool,
1078
  no_restart: bool,
 
1132
  elif not local_source_mode and skip_build:
1133
  click.echo(" Skipping build: npm install mode does not build local source.")
1134
 
1135
+ effective_python_path = python_path
1136
+ if effective_python_path is None and not no_auto_start and sys.executable:
1137
+ effective_python_path = sys.executable
1138
+
1139
+ existing_entry = _read_openclaw_config_value(openclaw_bin, "plugins.entries.headroom")
1140
+ entry = _build_openclaw_plugin_entry(
1141
+ existing_entry=existing_entry,
1142
+ proxy_port=proxy_port,
1143
+ startup_timeout_ms=startup_timeout_ms,
1144
+ python_path=effective_python_path,
1145
+ no_auto_start=no_auto_start,
1146
+ gateway_provider_ids=gateway_provider_ids,
1147
+ enabled=True,
1148
+ )
1149
+
1150
+ click.echo(" Writing plugin configuration...")
1151
+ _write_openclaw_plugin_entry(openclaw_bin, entry)
1152
+
1153
  install_cmd = [
1154
  openclaw_bin,
1155
  "plugins",
 
1202
  elif verbose and install_result.stdout.strip():
1203
  click.echo(install_result.stdout.strip())
1204
 
1205
+ _set_openclaw_context_engine_slot(openclaw_bin, "headroom")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1206
  _run_checked(
1207
  [openclaw_bin, "config", "validate"],
1208
  action="openclaw config validate",
 
1210
 
1211
  if no_restart:
1212
  click.echo(" Skipping gateway restart (--no-restart).")
1213
+ click.echo(
1214
+ " Run `openclaw gateway restart` (or `openclaw gateway start`) to apply plugin changes."
 
 
 
 
1215
  )
1216
+ else:
1217
+ click.echo(" Applying plugin changes to OpenClaw gateway...")
1218
+ gateway_action, gateway_output = _restart_or_start_openclaw_gateway(openclaw_bin)
1219
+ click.echo(f" Gateway {gateway_action}.")
1220
+ if verbose and gateway_output:
1221
+ click.echo(gateway_output)
1222
 
1223
  inspect_result = _run_checked(
1224
  [openclaw_bin, "plugins", "inspect", "headroom"],
 
1232
  click.echo(" Plugin: headroom")
1233
  click.echo(" Slot: plugins.slots.contextEngine = headroom")
1234
  click.echo()
1235
+
1236
+
1237
+ @unwrap.command("openclaw")
1238
+ @click.option("--no-restart", is_flag=True, help="Do not restart OpenClaw gateway at the end")
1239
+ @click.option("--verbose", "-v", is_flag=True, help="Verbose output")
1240
+ def unwrap_openclaw(no_restart: bool, verbose: bool) -> None:
1241
+ """Disable the Headroom OpenClaw plugin and restore the legacy engine slot."""
1242
+ openclaw_bin = shutil.which("openclaw")
1243
+ if not openclaw_bin:
1244
+ raise click.ClickException("'openclaw' not found in PATH. Install OpenClaw CLI first.")
1245
+
1246
+ click.echo()
1247
+ click.echo(" ╔═══════════════════════════════════════════════╗")
1248
+ click.echo(" ║ HEADROOM UNWRAP: OPENCLAW ║")
1249
+ click.echo(" ╚═══════════════════════════════════════════════╝")
1250
+ click.echo()
1251
+ click.echo(" Disabling Headroom plugin and removing engine mapping...")
1252
+
1253
+ existing_entry = _read_openclaw_config_value(openclaw_bin, "plugins.entries.headroom")
1254
+ existing_config = {}
1255
+ if isinstance(existing_entry, dict) and isinstance(existing_entry.get("config"), dict):
1256
+ existing_config = {
1257
+ key: value
1258
+ for key, value in existing_entry["config"].items()
1259
+ if key
1260
+ not in {
1261
+ "gatewayProviderIds",
1262
+ "proxyUrl",
1263
+ "proxyPort",
1264
+ "autoStart",
1265
+ "startupTimeoutMs",
1266
+ "pythonPath",
1267
+ }
1268
+ }
1269
+
1270
+ entry = {"enabled": False, "config": existing_config}
1271
+ _write_openclaw_plugin_entry(openclaw_bin, entry)
1272
+ _set_openclaw_context_engine_slot(openclaw_bin, "legacy")
1273
+ _run_checked(
1274
+ [openclaw_bin, "config", "validate"],
1275
+ action="openclaw config validate",
1276
+ )
1277
+
1278
+ if no_restart:
1279
+ click.echo(" Skipping gateway restart (--no-restart).")
1280
+ click.echo(
1281
+ " Run `openclaw gateway restart` (or `openclaw gateway start`) to apply unwrap changes."
1282
+ )
1283
+ else:
1284
+ click.echo(" Applying unwrap changes to OpenClaw gateway...")
1285
+ gateway_action, gateway_output = _restart_or_start_openclaw_gateway(openclaw_bin)
1286
+ click.echo(f" Gateway {gateway_action}.")
1287
+ if verbose and gateway_output:
1288
+ click.echo(gateway_output)
1289
+
1290
+ if verbose:
1291
+ inspect_result = _run_checked(
1292
+ [openclaw_bin, "plugins", "inspect", "headroom"],
1293
+ action="openclaw plugins inspect headroom",
1294
+ )
1295
+ if inspect_result.stdout.strip():
1296
+ click.echo(inspect_result.stdout.strip())
1297
+
1298
+ click.echo()
1299
+ click.echo("✓ OpenClaw Headroom wrap removed.")
1300
+ click.echo(" Plugin: headroom (installed, disabled)")
1301
+ click.echo(" Slot: plugins.slots.contextEngine = legacy")
1302
+ click.echo()
headroom/proxy/handlers/openai.py CHANGED
@@ -6,6 +6,7 @@ Contains all OpenAI Chat Completions, Responses API, and passthrough handlers.
6
  from __future__ import annotations
7
 
8
  import asyncio
 
9
  import contextlib
10
  import copy
11
  import json
@@ -24,6 +25,51 @@ if TYPE_CHECKING:
24
  logger = logging.getLogger("headroom.proxy")
25
 
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  class OpenAIHandlerMixin:
28
  """Mixin providing OpenAI API handler methods for HeadroomProxy."""
29
 
@@ -836,9 +882,11 @@ class OpenAIHandlerMixin:
836
  f"(backend '{self.anthropic_backend.name}' not used for Responses API)"
837
  )
838
 
 
 
839
  # Route to correct endpoint based on auth mode.
840
  # ChatGPT session auth (codex login) uses chatgpt.com, not api.openai.com.
841
- if headers.get("chatgpt-account-id"):
842
  url = "https://chatgpt.com/backend-api/codex/responses"
843
  else:
844
  url = f"{self.OPENAI_API_URL}/v1/responses"
@@ -983,11 +1031,8 @@ class OpenAIHandlerMixin:
983
  if k.lower() not in _skip_headers:
984
  upstream_headers[k] = v
985
 
986
- # Detect ChatGPT session auth vs API key auth.
987
- # Codex sends `ChatGPT-Account-ID` header when using `codex login`
988
- # (ChatGPT OAuth), which requires routing to chatgpt.com, not api.openai.com.
989
  _lower_headers = {k.lower(): v for k, v in upstream_headers.items()}
990
- is_chatgpt_auth = "chatgpt-account-id" in _lower_headers
991
 
992
  # Build upstream WebSocket URL based on auth mode
993
  if is_chatgpt_auth:
 
6
  from __future__ import annotations
7
 
8
  import asyncio
9
+ import base64
10
  import contextlib
11
  import copy
12
  import json
 
25
  logger = logging.getLogger("headroom.proxy")
26
 
27
 
28
+ def _decode_openai_bearer_payload(headers: dict[str, str]) -> dict[str, Any] | None:
29
+ """Best-effort decode of an OpenAI OAuth bearer token payload.
30
+
31
+ OpenClaw's Codex OAuth flow may forward only the bearer token after the
32
+ provider base URL is overridden. In that case the explicit
33
+ ``ChatGPT-Account-ID`` header can be missing even though the JWT still
34
+ carries the account id we need to route to the ChatGPT Codex backend.
35
+ """
36
+ auth = headers.get("authorization") or headers.get("Authorization")
37
+ if not auth:
38
+ return None
39
+
40
+ scheme, _, token = auth.partition(" ")
41
+ if scheme.lower() != "bearer" or token.count(".") < 2:
42
+ return None
43
+
44
+ payload = token.split(".", 2)[1]
45
+ payload += "=" * (-len(payload) % 4)
46
+ try:
47
+ decoded = base64.urlsafe_b64decode(payload.encode("ascii"))
48
+ data = json.loads(decoded.decode("utf-8"))
49
+ except (ValueError, UnicodeDecodeError):
50
+ return None
51
+
52
+ return data if isinstance(data, dict) else None
53
+
54
+
55
+ def _resolve_codex_routing_headers(headers: dict[str, str]) -> tuple[dict[str, str], bool]:
56
+ """Resolve ChatGPT Codex routing hints from explicit headers or OAuth JWT."""
57
+ resolved = dict(headers)
58
+ lower_lookup = {k.lower(): k for k in resolved}
59
+
60
+ if "chatgpt-account-id" in lower_lookup:
61
+ return resolved, True
62
+
63
+ payload = _decode_openai_bearer_payload(resolved)
64
+ auth_claims = payload.get("https://api.openai.com/auth") if isinstance(payload, dict) else None
65
+ account_id = auth_claims.get("chatgpt_account_id") if isinstance(auth_claims, dict) else None
66
+ if isinstance(account_id, str) and account_id.strip():
67
+ resolved["ChatGPT-Account-ID"] = account_id.strip()
68
+ return resolved, True
69
+
70
+ return resolved, False
71
+
72
+
73
  class OpenAIHandlerMixin:
74
  """Mixin providing OpenAI API handler methods for HeadroomProxy."""
75
 
 
882
  f"(backend '{self.anthropic_backend.name}' not used for Responses API)"
883
  )
884
 
885
+ headers, is_chatgpt_auth = _resolve_codex_routing_headers(headers)
886
+
887
  # Route to correct endpoint based on auth mode.
888
  # ChatGPT session auth (codex login) uses chatgpt.com, not api.openai.com.
889
+ if is_chatgpt_auth:
890
  url = "https://chatgpt.com/backend-api/codex/responses"
891
  else:
892
  url = f"{self.OPENAI_API_URL}/v1/responses"
 
1031
  if k.lower() not in _skip_headers:
1032
  upstream_headers[k] = v
1033
 
1034
+ upstream_headers, is_chatgpt_auth = _resolve_codex_routing_headers(upstream_headers)
 
 
1035
  _lower_headers = {k.lower(): v for k, v in upstream_headers.items()}
 
1036
 
1037
  # Build upstream WebSocket URL based on auth mode
1038
  if is_chatgpt_auth:
headroom/proxy/server.py CHANGED
@@ -1747,6 +1747,16 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
1747
  """OpenAI Responses API (new API introduced March 2025)."""
1748
  return await proxy.handle_openai_responses(request)
1749
 
 
 
 
 
 
 
 
 
 
 
1750
  @app.websocket("/v1/responses")
1751
  async def openai_responses_ws(websocket: WebSocket):
1752
  """OpenAI Responses API via WebSocket (Codex gpt-5.4+)."""
@@ -1793,6 +1803,28 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
1793
  logger.error(f"Passthrough /v1/responses/{sub_path} failed: {e}")
1794
  return Response(content=str(e), status_code=502)
1795
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1796
  # OpenAI Batch API endpoints (with compression!)
1797
  @app.post("/v1/batches")
1798
  async def create_batch(request: Request):
 
1747
  """OpenAI Responses API (new API introduced March 2025)."""
1748
  return await proxy.handle_openai_responses(request)
1749
 
1750
+ @app.post("/backend-api/responses")
1751
+ async def openai_codex_responses(request: Request):
1752
+ """OpenAI Codex Responses API path preserved from ChatGPT backend."""
1753
+ return await proxy.handle_openai_responses(request)
1754
+
1755
+ @app.post("/backend-api/codex/responses")
1756
+ async def openai_codex_nested_responses(request: Request):
1757
+ """OpenAI Codex Responses API path for codex-shaped proxy base URLs."""
1758
+ return await proxy.handle_openai_responses(request)
1759
+
1760
  @app.websocket("/v1/responses")
1761
  async def openai_responses_ws(websocket: WebSocket):
1762
  """OpenAI Responses API via WebSocket (Codex gpt-5.4+)."""
 
1803
  logger.error(f"Passthrough /v1/responses/{sub_path} failed: {e}")
1804
  return Response(content=str(e), status_code=502)
1805
 
1806
+ @app.websocket("/backend-api/responses")
1807
+ async def openai_codex_responses_ws(websocket: WebSocket):
1808
+ """OpenAI Codex Responses WebSocket path preserved from ChatGPT backend."""
1809
+ await proxy.handle_openai_responses_ws(websocket)
1810
+
1811
+ @app.websocket("/backend-api/codex/responses")
1812
+ async def openai_codex_nested_responses_ws(websocket: WebSocket):
1813
+ """OpenAI Codex Responses WebSocket path for codex-shaped proxy base URLs."""
1814
+ await proxy.handle_openai_responses_ws(websocket)
1815
+
1816
+ @app.api_route("/backend-api/responses/{sub_path:path}", methods=["GET", "POST", "DELETE"])
1817
+ async def openai_codex_responses_sub(request: Request, sub_path: str):
1818
+ """Passthrough for /backend-api/responses/* sub-endpoints."""
1819
+ return await openai_responses_sub(request, sub_path)
1820
+
1821
+ @app.api_route(
1822
+ "/backend-api/codex/responses/{sub_path:path}", methods=["GET", "POST", "DELETE"]
1823
+ )
1824
+ async def openai_codex_nested_responses_sub(request: Request, sub_path: str):
1825
+ """Passthrough for /backend-api/codex/responses/* sub-endpoints."""
1826
+ return await openai_responses_sub(request, sub_path)
1827
+
1828
  # OpenAI Batch API endpoints (with compression!)
1829
  @app.post("/v1/batches")
1830
  async def create_batch(request: Request):
plugins/openclaw/README.md CHANGED
@@ -79,6 +79,67 @@ Install automatically selects the `contextEngine` slot for `headroom` on current
79
 
80
  Default `proxyPort` is `8787`.
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  ### Local proxy (auto-start)
83
 
84
  When `proxyUrl` points to localhost (or is omitted), the plugin will auto-start `headroom proxy` if no running proxy is detected. Launch order:
@@ -141,6 +202,8 @@ Compression is lossless via CCR (Compress-Cache-Retrieve): originals are stored
141
  | `pythonPath` | auto-detected | Optional Python executable override for Python fallback launcher. |
142
  | `autoStart` | `true` | Auto-start a local `headroom proxy` if not already running (local URLs only; ignored for remote proxies) |
143
  | `startupTimeoutMs` | `20000` | Time to wait for auto-started proxy to become healthy |
 
 
144
 
145
  ## Comparison with lossless-claw
146
 
 
79
 
80
  Default `proxyPort` is `8787`.
81
 
82
+ ### Upstream gateway routing
83
+
84
+ By default, the plugin also rewrites the built-in `openai-codex` provider base URL to the active Headroom proxy at runtime. That means Codex provider traffic flows through Headroom, so `/stats` can observe real upstream request and cache activity instead of only local context compression.
85
+
86
+ This does not replace Headroom's existing Codex routing rules. The proxy already decides between `api.openai.com` and `chatgpt.com/backend-api/codex/responses` based on ChatGPT auth. The plugin change only points OpenClaw's provider config at the active proxy in memory and preserves the rest of the provider config.
87
+
88
+ You can also route additional provider ids such as `anthropic`, `github-copilot`, `google`, or `openrouter` through the same proxy:
89
+
90
+ ```json
91
+ {
92
+ "plugins": {
93
+ "entries": {
94
+ "headroom": {
95
+ "enabled": true,
96
+ "config": {
97
+ "gatewayProviderIds": ["openai-codex", "anthropic", "github-copilot", "google", "openrouter"]
98
+ }
99
+ }
100
+ }
101
+ }
102
+ }
103
+ ```
104
+
105
+ When `gatewayProviderIds` is set, it becomes the exact list the plugin rewrites in memory for the current gateway process.
106
+
107
+ For convenience, the plugin also accepts family aliases:
108
+ - `codex` -> `openai-codex`
109
+ - `claude` -> `anthropic`
110
+ - `copilot` -> `github-copilot`
111
+ - `gemini` -> `google`
112
+
113
+ When OpenClaw has already resolved a provider's upstream `baseUrl`, the plugin preserves protocol-specific path segments while swapping only the origin. That keeps provider families on the right proxy route:
114
+ - Codex / ChatGPT backend: `/backend-api`
115
+ - OpenAI-compatible providers: `/v1` or `/api/v1`
116
+ - GitHub Copilot Claude-family models: `/anthropic`
117
+ - Gemini: `/v1beta`
118
+
119
+ GitHub Copilot is a special case because OpenClaw can route it through either OpenAI Responses or Anthropic Messages depending on the selected model. The plugin only rewrites Copilot when OpenClaw has already resolved the upstream `baseUrl`, so it can preserve the correct `/v1` or `/anthropic` path instead of guessing.
120
+
121
+ The routing is intentionally lightweight and reversible:
122
+ - the plugin does not persist provider `baseUrl` changes back to `openclaw.json`
123
+ - disabling the plugin, clearing `gatewayProviderIds`, or restarting without Headroom restores OpenClaw's normal provider resolution
124
+ - if you want durable provider rewrites, use `headroom wrap openclaw` instead of relying on plugin install side effects
125
+
126
+ If you need to disable that behavior:
127
+
128
+ ```json
129
+ {
130
+ "plugins": {
131
+ "entries": {
132
+ "headroom": {
133
+ "enabled": true,
134
+ "config": {
135
+ "routeCodexViaProxy": false
136
+ }
137
+ }
138
+ }
139
+ }
140
+ }
141
+ ```
142
+
143
  ### Local proxy (auto-start)
144
 
145
  When `proxyUrl` points to localhost (or is omitted), the plugin will auto-start `headroom proxy` if no running proxy is detected. Launch order:
 
202
  | `pythonPath` | auto-detected | Optional Python executable override for Python fallback launcher. |
203
  | `autoStart` | `true` | Auto-start a local `headroom proxy` if not already running (local URLs only; ignored for remote proxies) |
204
  | `startupTimeoutMs` | `20000` | Time to wait for auto-started proxy to become healthy |
205
+ | `routeCodexViaProxy` | `true` | Rewrite OpenClaw's built-in `openai-codex` provider to use the active Headroom proxy in memory so upstream Codex requests pass through Headroom. |
206
+ | `gatewayProviderIds` | `[]` | Optional explicit list of OpenClaw provider ids to route through the active Headroom proxy in memory. Friendly aliases `codex`, `claude`, `copilot`, and `gemini` are also accepted. When set, this overrides the default `openai-codex` routing list. |
207
 
208
  ## Comparison with lossless-claw
209
 
plugins/openclaw/openclaw.plugin.json CHANGED
@@ -13,6 +13,22 @@
13
  "pythonPath": {
14
  "label": "Python Path",
15
  "help": "Optional explicit python executable for python fallback launcher (for example: python, python3, py, or full path)."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  }
17
  },
18
  "configSchema": {
@@ -44,6 +60,25 @@
44
  "minimum": 1000,
45
  "maximum": 120000,
46
  "default": 20000
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  }
48
  }
49
  },
 
13
  "pythonPath": {
14
  "label": "Python Path",
15
  "help": "Optional explicit python executable for python fallback launcher (for example: python, python3, py, or full path)."
16
+ },
17
+ "retryMaxAttempts": {
18
+ "label": "Retry Max Attempts",
19
+ "help": "Optional maximum number of upstream retry attempts for connection/read/5xx failures when the plugin auto-starts a local Headroom proxy. Lower values fail faster for interactive chat."
20
+ },
21
+ "connectTimeoutSeconds": {
22
+ "label": "Connect Timeout Seconds",
23
+ "help": "Optional upstream connection timeout for the auto-started local Headroom proxy. Lower values surface network failures sooner."
24
+ },
25
+ "routeCodexViaProxy": {
26
+ "label": "Route OpenAI Codex Via Headroom",
27
+ "help": "When enabled, OpenClaw will use the active Headroom proxy as the in-memory upstream base URL for the built-in openai-codex provider so provider traffic flows through Headroom."
28
+ },
29
+ "gatewayProviderIds": {
30
+ "label": "Gateway Provider IDs",
31
+ "help": "Optional list of OpenClaw provider ids to route through the active Headroom proxy in memory. Friendly aliases codex, claude, copilot, and gemini are also accepted. When set, this overrides the default openai-codex-only routing."
32
  }
33
  },
34
  "configSchema": {
 
60
  "minimum": 1000,
61
  "maximum": 120000,
62
  "default": 20000
63
+ },
64
+ "retryMaxAttempts": {
65
+ "type": "integer",
66
+ "minimum": 1
67
+ },
68
+ "connectTimeoutSeconds": {
69
+ "type": "integer",
70
+ "minimum": 1
71
+ },
72
+ "routeCodexViaProxy": {
73
+ "type": "boolean",
74
+ "default": true
75
+ },
76
+ "gatewayProviderIds": {
77
+ "type": "array",
78
+ "items": {
79
+ "type": "string"
80
+ },
81
+ "default": []
82
  }
83
  }
84
  },
plugins/openclaw/src/engine.ts CHANGED
@@ -27,6 +27,8 @@ export class HeadroomContextEngine {
27
  private proxyUrl: string | null = null;
28
  private config: HeadroomEngineConfig;
29
  private logger: ProxyManagerLogger;
 
 
30
  private stats = {
31
  totalCompressions: 0,
32
  totalTokensSaved: 0,
@@ -51,14 +53,8 @@ export class HeadroomContextEngine {
51
  return { bootstrapped: false, reason: "disabled" };
52
  }
53
 
54
- try {
55
- this.proxyUrl = await this.proxyManager.start();
56
- this.logger.info(`Engine bootstrapped (proxy: ${this.proxyUrl})`);
57
- return { bootstrapped: true };
58
- } catch (error) {
59
- this.logger.error(`Bootstrap failed: ${error}`);
60
- return { bootstrapped: false, reason: String(error) };
61
- }
62
  }
63
 
64
  async ingest(params: {
@@ -95,6 +91,7 @@ export class HeadroomContextEngine {
95
  systemPromptAddition?: string;
96
  }> {
97
  if (!this.proxyUrl || this.config.enabled === false) {
 
98
  // Fallback: return messages unchanged
99
  return { messages: normalizeAgentMessages(params.messages), estimatedTokens: 0 };
100
  }
@@ -237,4 +234,51 @@ export class HeadroomContextEngine {
237
  getProxyUrl(): string | null {
238
  return this.proxyUrl;
239
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  }
 
27
  private proxyUrl: string | null = null;
28
  private config: HeadroomEngineConfig;
29
  private logger: ProxyManagerLogger;
30
+ private proxyReadyListeners = new Set<(proxyUrl: string) => void | Promise<void>>();
31
+ private proxyStartupPromise: Promise<string> | null = null;
32
  private stats = {
33
  totalCompressions: 0,
34
  totalTokensSaved: 0,
 
53
  return { bootstrapped: false, reason: "disabled" };
54
  }
55
 
56
+ this.ensureProxyStarted();
57
+ return { bootstrapped: true, reason: "proxy startup scheduled" };
 
 
 
 
 
 
58
  }
59
 
60
  async ingest(params: {
 
91
  systemPromptAddition?: string;
92
  }> {
93
  if (!this.proxyUrl || this.config.enabled === false) {
94
+ this.ensureProxyStarted();
95
  // Fallback: return messages unchanged
96
  return { messages: normalizeAgentMessages(params.messages), estimatedTokens: 0 };
97
  }
 
234
  getProxyUrl(): string | null {
235
  return this.proxyUrl;
236
  }
237
+
238
+ ensureProxyStarted(): void {
239
+ if (this.config.enabled === false || this.proxyUrl || this.proxyStartupPromise) {
240
+ return;
241
+ }
242
+
243
+ this.proxyStartupPromise = this.proxyManager
244
+ .start()
245
+ .then(async (proxyUrl) => {
246
+ this.proxyUrl = proxyUrl;
247
+ await this.notifyProxyReady(proxyUrl);
248
+ this.logger.info(`Headroom proxy ready at ${proxyUrl}`);
249
+ return proxyUrl;
250
+ })
251
+ .catch((error) => {
252
+ this.logger.warn(`Headroom proxy unavailable: ${error}`);
253
+ throw error;
254
+ })
255
+ .finally(() => {
256
+ this.proxyStartupPromise = null;
257
+ });
258
+ }
259
+
260
+ onProxyReady(listener: (proxyUrl: string) => void | Promise<void>): () => void {
261
+ this.proxyReadyListeners.add(listener);
262
+ return () => {
263
+ this.proxyReadyListeners.delete(listener);
264
+ };
265
+ }
266
+
267
+ async ensureProxyUrl(): Promise<string> {
268
+ if (this.proxyUrl) {
269
+ return this.proxyUrl;
270
+ }
271
+
272
+ this.ensureProxyStarted();
273
+ if (!this.proxyStartupPromise) {
274
+ throw new Error("Headroom proxy startup is disabled");
275
+ }
276
+ return this.proxyStartupPromise;
277
+ }
278
+
279
+ private async notifyProxyReady(proxyUrl: string): Promise<void> {
280
+ for (const listener of this.proxyReadyListeners) {
281
+ await listener(proxyUrl);
282
+ }
283
+ }
284
  }
plugins/openclaw/src/gateway-config.ts ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+
3
+ export const DEFAULT_GATEWAY_PROVIDER_IDS = ["openai-codex"] as const;
4
+
5
+ const DEFAULT_PROVIDER_BASE_URLS: Readonly<Record<string, string>> = {
6
+ "openai-codex": "https://chatgpt.com/backend-api",
7
+ };
8
+
9
+ const GATEWAY_PROVIDER_ID_ALIASES: Readonly<Record<string, string>> = {
10
+ codex: "openai-codex",
11
+ claude: "anthropic",
12
+ copilot: "github-copilot",
13
+ gemini: "google",
14
+ };
15
+
16
+ const EXPLICIT_BASE_URL_REQUIRED_PROVIDER_IDS = new Set<string>(["github-copilot"]);
17
+
18
+ export function resolveGatewayProviderIds(config: Record<string, unknown> | undefined): string[] {
19
+ const configuredProviderIds = normalizeGatewayProviderIds(config?.gatewayProviderIds);
20
+ if (configuredProviderIds.length > 0) {
21
+ return configuredProviderIds;
22
+ }
23
+
24
+ if (config?.routeCodexViaProxy === false) {
25
+ return [];
26
+ }
27
+
28
+ return [...DEFAULT_GATEWAY_PROVIDER_IDS];
29
+ }
30
+
31
+ function normalizeGatewayProviderIds(value: unknown): string[] {
32
+ if (!Array.isArray(value)) {
33
+ return [];
34
+ }
35
+
36
+ const seen = new Set<string>();
37
+ const normalized: string[] = [];
38
+
39
+ for (const entry of value) {
40
+ if (typeof entry !== "string") {
41
+ continue;
42
+ }
43
+
44
+ const rawProviderId = entry.trim();
45
+ const providerId = GATEWAY_PROVIDER_ID_ALIASES[rawProviderId.toLowerCase()] ?? rawProviderId;
46
+ if (!providerId || seen.has(providerId)) {
47
+ continue;
48
+ }
49
+
50
+ seen.add(providerId);
51
+ normalized.push(providerId);
52
+ }
53
+
54
+ return normalized;
55
+ }
56
+
57
+ export function applyGatewayProviderBaseUrls<T>(
58
+ cfg: T,
59
+ proxyUrl: string,
60
+ providerIds: readonly string[],
61
+ ): { changed: boolean; config: T } {
62
+ const next = structuredClone((cfg ?? {}) as any);
63
+ const changed = applyGatewayProviderBaseUrlsInPlace(next, proxyUrl, providerIds);
64
+ return { changed, config: next as T };
65
+ }
66
+
67
+ export function applyGatewayProviderBaseUrlsInPlace(
68
+ cfg: any,
69
+ proxyUrl: string,
70
+ providerIds: readonly string[],
71
+ ): boolean {
72
+ if (!cfg || typeof cfg !== "object" || providerIds.length === 0) {
73
+ return false;
74
+ }
75
+
76
+ const models = (cfg.models ??= {});
77
+ const providers = (models.providers ??= {});
78
+ let changed = false;
79
+
80
+ for (const providerId of providerIds) {
81
+ const currentValue = providers[providerId];
82
+ const currentConfig =
83
+ currentValue && typeof currentValue === "object" && !Array.isArray(currentValue)
84
+ ? currentValue
85
+ : {};
86
+ const nextConfig = { ...currentConfig };
87
+ const currentBaseUrl =
88
+ typeof nextConfig.baseUrl === "string" && nextConfig.baseUrl.trim().length > 0
89
+ ? nextConfig.baseUrl
90
+ : undefined;
91
+ const defaultBaseUrl = DEFAULT_PROVIDER_BASE_URLS[providerId];
92
+ if (
93
+ !currentBaseUrl &&
94
+ !defaultBaseUrl &&
95
+ EXPLICIT_BASE_URL_REQUIRED_PROVIDER_IDS.has(providerId)
96
+ ) {
97
+ continue;
98
+ }
99
+ const nextBaseUrl = routeBaseUrlThroughProxy({
100
+ providerId,
101
+ proxyUrl,
102
+ currentBaseUrl,
103
+ });
104
+
105
+ if (!Array.isArray(nextConfig.models)) {
106
+ nextConfig.models = [];
107
+ changed = true;
108
+ }
109
+
110
+ if (nextConfig.baseUrl === nextBaseUrl) {
111
+ providers[providerId] = nextConfig;
112
+ continue;
113
+ }
114
+
115
+ nextConfig.baseUrl = nextBaseUrl;
116
+ providers[providerId] = nextConfig;
117
+ changed = true;
118
+ }
119
+
120
+ return changed;
121
+ }
122
+
123
+ function routeBaseUrlThroughProxy(params: {
124
+ providerId: string;
125
+ proxyUrl: string;
126
+ currentBaseUrl?: string;
127
+ }): string {
128
+ const upstreamBaseUrl = params.currentBaseUrl ?? DEFAULT_PROVIDER_BASE_URLS[params.providerId];
129
+ if (!upstreamBaseUrl) {
130
+ return params.proxyUrl;
131
+ }
132
+
133
+ try {
134
+ const proxy = new URL(params.proxyUrl);
135
+ const upstream = new URL(upstreamBaseUrl);
136
+ proxy.pathname = upstream.pathname;
137
+ proxy.search = upstream.search;
138
+ proxy.hash = "";
139
+ return proxy.toString().replace(/\/$/, "");
140
+ } catch {
141
+ return params.proxyUrl;
142
+ }
143
+ }
plugins/openclaw/src/index.ts CHANGED
@@ -3,3 +3,9 @@ export { HeadroomContextEngine } from "./engine.js";
3
  export { ProxyManager, normalizeAndValidateProxyUrl, isLocalProxyUrl, defaultLogger, probeHeadroomProxy } from "./proxy-manager.js";
4
  export { agentToOpenAI, normalizeAgentMessages, openAIToAgent } from "./convert.js";
5
  export { createHeadroomRetrieveTool } from "./tools/headroom-retrieve.js";
 
 
 
 
 
 
 
3
  export { ProxyManager, normalizeAndValidateProxyUrl, isLocalProxyUrl, defaultLogger, probeHeadroomProxy } from "./proxy-manager.js";
4
  export { agentToOpenAI, normalizeAgentMessages, openAIToAgent } from "./convert.js";
5
  export { createHeadroomRetrieveTool } from "./tools/headroom-retrieve.js";
6
+ export {
7
+ DEFAULT_GATEWAY_PROVIDER_IDS,
8
+ applyGatewayProviderBaseUrls,
9
+ applyGatewayProviderBaseUrlsInPlace,
10
+ resolveGatewayProviderIds,
11
+ } from "./gateway-config.js";
plugins/openclaw/src/plugin/index.ts CHANGED
@@ -16,6 +16,10 @@
16
  /* eslint-disable @typescript-eslint/no-explicit-any */
17
 
18
  import { HeadroomContextEngine } from "../engine.js";
 
 
 
 
19
  import { normalizeAndValidateProxyUrl } from "../proxy-manager.js";
20
  import { createHeadroomRetrieveTool } from "../tools/headroom-retrieve.js";
21
 
@@ -34,6 +38,43 @@ export default function headroomPlugin(api: any) {
34
  error: (m: string) => logger.error(m),
35
  debug: (m: string) => logger.debug?.(m),
36
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  // Register as context engine
39
  api.registerContextEngine("headroom", () => engine);
@@ -45,5 +86,11 @@ export default function headroomPlugin(api: any) {
45
  return createHeadroomRetrieveTool({ proxyUrl: activeProxyUrl });
46
  });
47
 
 
 
 
 
 
 
48
  logger.info("[headroom] Plugin registered");
49
  }
 
16
  /* eslint-disable @typescript-eslint/no-explicit-any */
17
 
18
  import { HeadroomContextEngine } from "../engine.js";
19
+ import {
20
+ applyGatewayProviderBaseUrlsInPlace,
21
+ resolveGatewayProviderIds,
22
+ } from "../gateway-config.js";
23
  import { normalizeAndValidateProxyUrl } from "../proxy-manager.js";
24
  import { createHeadroomRetrieveTool } from "../tools/headroom-retrieve.js";
25
 
 
38
  error: (m: string) => logger.error(m),
39
  debug: (m: string) => logger.debug?.(m),
40
  });
41
+ const gatewayProviderIds = resolveGatewayProviderIds(config);
42
+
43
+ const applyGatewayRouting = async (activeProxyUrl: string) => {
44
+ if (gatewayProviderIds.length === 0) {
45
+ return;
46
+ }
47
+
48
+ try {
49
+ const changed = applyGatewayProviderBaseUrlsInPlace(api.config, activeProxyUrl, gatewayProviderIds);
50
+
51
+ if (changed) {
52
+ logger.info(
53
+ `[headroom] Routed ${gatewayProviderIds.join(", ")} through Headroom proxy in memory at ${activeProxyUrl}`,
54
+ );
55
+ } else {
56
+ logger.info(
57
+ `[headroom] Upstream gateway already routed in memory for ${gatewayProviderIds.join(", ")} at ${activeProxyUrl}`,
58
+ );
59
+ }
60
+ } catch (error) {
61
+ logger.warn(`[headroom] Failed to configure upstream gateway routing: ${error}`);
62
+ }
63
+ };
64
+
65
+ const ensureGatewayRouting = async () => {
66
+ const activeProxyUrl = engine.getProxyUrl();
67
+ if (!activeProxyUrl) {
68
+ logger.debug?.("[headroom] Deferring upstream gateway routing until proxy is available");
69
+ engine.ensureProxyStarted();
70
+ return;
71
+ }
72
+ await applyGatewayRouting(activeProxyUrl);
73
+ };
74
+
75
+ engine.onProxyReady(async (activeProxyUrl) => {
76
+ await applyGatewayRouting(activeProxyUrl);
77
+ });
78
 
79
  // Register as context engine
80
  api.registerContextEngine("headroom", () => engine);
 
86
  return createHeadroomRetrieveTool({ proxyUrl: activeProxyUrl });
87
  });
88
 
89
+ api.on("gateway_start", async () => {
90
+ await ensureGatewayRouting();
91
+ });
92
+
93
+ void ensureGatewayRouting();
94
+
95
  logger.info("[headroom] Plugin registered");
96
  }
plugins/openclaw/src/proxy-manager.ts CHANGED
@@ -18,6 +18,8 @@ export interface ProxyManagerConfig {
18
  pythonPath?: string;
19
  autoStart?: boolean;
20
  startupTimeoutMs?: number;
 
 
21
  }
22
 
23
  export interface ProxyManagerLogger {
@@ -47,8 +49,13 @@ interface LaunchSpec {
47
  args: string[];
48
  checkCommand: string;
49
  checkArgs: string[];
 
 
50
  }
51
 
 
 
 
52
  export class ProxyManager {
53
  private config: ProxyManagerConfig;
54
  private logger: ProxyManagerLogger;
@@ -177,7 +184,7 @@ export class ProxyManager {
177
  const errors: string[] = [];
178
 
179
  for (const spec of specs) {
180
- if (!this.canExecute(spec.checkCommand, spec.checkArgs)) {
181
  this.logger.debug(`Launcher unavailable: ${spec.label}`);
182
  continue;
183
  }
@@ -185,6 +192,7 @@ export class ProxyManager {
185
  try {
186
  const child = spawn(spec.command, spec.args, {
187
  detached: true,
 
188
  stdio: "ignore",
189
  });
190
  child.unref();
@@ -204,6 +212,16 @@ export class ProxyManager {
204
 
205
  private buildLaunchSpecs(host: string, port: string): LaunchSpec[] {
206
  const commonArgs = ["proxy", "--host", host, "--port", port];
 
 
 
 
 
 
 
 
 
 
207
  const specs: LaunchSpec[] = [];
208
 
209
  // 1) PATH
@@ -211,8 +229,12 @@ export class ProxyManager {
211
  label: "PATH: headroom",
212
  command: "headroom",
213
  args: commonArgs,
214
- checkCommand: "headroom",
215
- checkArgs: ["--version"],
 
 
 
 
216
  });
217
 
218
  // 2) Local npm install (inside plugin install path)
@@ -224,14 +246,15 @@ export class ProxyManager {
224
  : [join(localBinDir, "headroom")];
225
  for (const localBin of localBins) {
226
  if (!existsSync(localBin)) continue;
227
- specs.push({
228
- label: `Local npm: ${localBin}`,
229
- command: localBin,
230
- args: commonArgs,
231
- checkCommand: localBin,
232
- checkArgs: ["--version"],
233
- });
234
- }
 
235
 
236
  // 3) Global npm install
237
  const npmPrefix = this.getNpmGlobalPrefix();
@@ -248,6 +271,7 @@ export class ProxyManager {
248
  args: commonArgs,
249
  checkCommand: globalBin,
250
  checkArgs: ["--version"],
 
251
  });
252
  }
253
  }
@@ -260,7 +284,7 @@ export class ProxyManager {
260
  command: pyCmd,
261
  args: ["-m", "headroom.cli", ...commonArgs],
262
  checkCommand: pyCmd,
263
- checkArgs: ["-c", "import headroom"],
264
  });
265
  }
266
 
@@ -281,9 +305,10 @@ export class ProxyManager {
281
  return commands;
282
  }
283
 
284
- private canExecute(command: string, args: string[]): boolean {
285
  try {
286
  const result = spawnSync(command, args, {
 
287
  stdio: "ignore",
288
  timeout: 5000,
289
  });
 
18
  pythonPath?: string;
19
  autoStart?: boolean;
20
  startupTimeoutMs?: number;
21
+ retryMaxAttempts?: number;
22
+ connectTimeoutSeconds?: number;
23
  }
24
 
25
  export interface ProxyManagerLogger {
 
49
  args: string[];
50
  checkCommand: string;
51
  checkArgs: string[];
52
+ useShell?: boolean;
53
+ checkUseShell?: boolean;
54
  }
55
 
56
+ const HEADROOM_MODULE_DISCOVERY_SNIPPET =
57
+ "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('headroom') else 1)";
58
+
59
  export class ProxyManager {
60
  private config: ProxyManagerConfig;
61
  private logger: ProxyManagerLogger;
 
184
  const errors: string[] = [];
185
 
186
  for (const spec of specs) {
187
+ if (!this.canExecute(spec.checkCommand, spec.checkArgs, spec.checkUseShell ?? spec.useShell)) {
188
  this.logger.debug(`Launcher unavailable: ${spec.label}`);
189
  continue;
190
  }
 
192
  try {
193
  const child = spawn(spec.command, spec.args, {
194
  detached: true,
195
+ shell: spec.useShell === true,
196
  stdio: "ignore",
197
  });
198
  child.unref();
 
212
 
213
  private buildLaunchSpecs(host: string, port: string): LaunchSpec[] {
214
  const commonArgs = ["proxy", "--host", host, "--port", port];
215
+ const retryMaxAttempts = this.config.retryMaxAttempts;
216
+ if (Number.isInteger(retryMaxAttempts)) {
217
+ commonArgs.push("--retry-max-attempts", String(retryMaxAttempts));
218
+ }
219
+
220
+ const connectTimeoutSeconds = this.config.connectTimeoutSeconds;
221
+ if (Number.isInteger(connectTimeoutSeconds)) {
222
+ commonArgs.push("--connect-timeout-seconds", String(connectTimeoutSeconds));
223
+ }
224
+
225
  const specs: LaunchSpec[] = [];
226
 
227
  // 1) PATH
 
229
  label: "PATH: headroom",
230
  command: "headroom",
231
  args: commonArgs,
232
+ checkCommand: process.platform === "win32" ? "where.exe" : "sh",
233
+ checkArgs: process.platform === "win32"
234
+ ? ["headroom"]
235
+ : ["-lc", "command -v headroom >/dev/null 2>&1"],
236
+ useShell: process.platform === "win32",
237
+ checkUseShell: false,
238
  });
239
 
240
  // 2) Local npm install (inside plugin install path)
 
246
  : [join(localBinDir, "headroom")];
247
  for (const localBin of localBins) {
248
  if (!existsSync(localBin)) continue;
249
+ specs.push({
250
+ label: `Local npm: ${localBin}`,
251
+ command: localBin,
252
+ args: commonArgs,
253
+ checkCommand: localBin,
254
+ checkArgs: ["--version"],
255
+ useShell: process.platform === "win32",
256
+ });
257
+ }
258
 
259
  // 3) Global npm install
260
  const npmPrefix = this.getNpmGlobalPrefix();
 
271
  args: commonArgs,
272
  checkCommand: globalBin,
273
  checkArgs: ["--version"],
274
+ useShell: process.platform === "win32",
275
  });
276
  }
277
  }
 
284
  command: pyCmd,
285
  args: ["-m", "headroom.cli", ...commonArgs],
286
  checkCommand: pyCmd,
287
+ checkArgs: ["-c", HEADROOM_MODULE_DISCOVERY_SNIPPET],
288
  });
289
  }
290
 
 
305
  return commands;
306
  }
307
 
308
+ private canExecute(command: string, args: string[], useShell = false): boolean {
309
  try {
310
  const result = spawnSync(command, args, {
311
+ shell: useShell,
312
  stdio: "ignore",
313
  timeout: 5000,
314
  });
plugins/openclaw/test/engine.test.ts CHANGED
@@ -1,262 +1,101 @@
1
- /**
2
- * Integration tests for HeadroomContextEngine.
3
- *
4
- * Tests the full flow: proxy management, AgentMessage conversion,
5
- * compression via proxy, and round-trip back to AgentMessage.
6
- *
7
- * Requires: Python 3 + headroom-ai[proxy] installed
8
- * Run: HEADROOM_INTEGRATION=1 npx vitest run test/engine.test.ts
9
- */
10
- import { describe, it, expect, beforeAll, afterAll, vi, afterEach } from "vitest";
11
- import { HeadroomContextEngine } from "../src/engine.js";
12
- import { agentToOpenAI, openAIToAgent } from "../src/convert.js";
13
- import { ProxyManager } from "../src/proxy-manager.js";
 
 
 
 
 
 
 
 
 
 
 
14
 
15
- const RUN = process.env.HEADROOM_INTEGRATION === "1";
16
- const PROXY_URL = process.env.HEADROOM_PROXY_URL ?? "http://127.0.0.1:8787";
17
 
18
  afterEach(() => {
19
- vi.restoreAllMocks();
 
 
 
 
 
 
20
  });
21
 
22
- // Proxy probing and ProxyManager.start tests live in proxy-manager.test.ts
23
-
24
- describe("AgentMessage conversion", () => {
25
- it("converts user message", () => {
26
- const agent = [{ role: "user", content: "hello", timestamp: Date.now() }];
27
- const openai = agentToOpenAI(agent);
28
- expect(openai).toHaveLength(1);
29
- expect(openai[0]).toMatchObject({ role: "user", content: "hello" });
 
 
 
 
 
 
30
  });
31
 
32
- it("converts assistant with tool_use blocks", () => {
33
- const agent = [
34
- {
35
- role: "assistant",
36
- content: [
37
- { type: "text", text: "Let me search" },
38
- { type: "tool_use", id: "tu_1", name: "search", input: { q: "test" } },
39
- ],
40
- timestamp: Date.now(),
41
- },
42
- ];
43
- const openai = agentToOpenAI(agent);
44
- expect(openai[0].role).toBe("assistant");
45
- expect(openai[0].content).toBe("Let me search");
46
- expect(openai[0].tool_calls).toHaveLength(1);
47
- expect(openai[0].tool_calls![0].function.name).toBe("search");
48
- });
49
 
50
- it("converts assistant with toolCall blocks", () => {
51
- const agent = [
52
- {
53
- role: "assistant",
54
- content: [
55
- { type: "text", text: "Let me search" },
56
- { type: "toolCall", id: "call_1|fc_1", name: "search", arguments: { q: "test" } },
57
- ],
58
- timestamp: Date.now(),
59
- },
60
- ];
61
- const openai = agentToOpenAI(agent);
62
- expect(openai[0].role).toBe("assistant");
63
- expect(openai[0].content).toBe("Let me search");
64
- expect(openai[0].tool_calls).toHaveLength(1);
65
- expect(openai[0].tool_calls![0].id).toBe("call_1|fc_1");
66
- expect(openai[0].tool_calls![0].function.name).toBe("search");
67
- });
68
 
69
- it("converts toolResult message", () => {
70
- const agent = [
71
- {
72
- role: "toolResult",
73
- content: '{"results": [1, 2, 3]}',
74
- tool_use_id: "tu_1",
75
- timestamp: Date.now(),
76
- },
77
- ];
78
- const openai = agentToOpenAI(agent);
79
- expect(openai[0].role).toBe("tool");
80
- expect(openai[0].content).toBe('{"results": [1, 2, 3]}');
81
- expect(openai[0].tool_call_id).toBe("tu_1");
82
- });
83
 
84
- it("round-trips user message", () => {
85
- const original = [{ role: "user", content: "hello", timestamp: Date.now() }];
86
- const openai = agentToOpenAI(original);
87
- const back = openAIToAgent(openai);
88
- expect(back[0].role).toBe("user");
89
- expect(back[0].content).toBe("hello");
90
  });
91
 
92
- it("round-trips assistant text-only (content always array)", () => {
93
- const original = [
94
- {
95
- role: "assistant",
96
- content: [{ type: "text", text: "Hello there!" }],
97
- timestamp: Date.now(),
98
- },
99
- ];
100
- const openai = agentToOpenAI(original);
101
- const back = openAIToAgent(openai);
102
- expect(back[0].role).toBe("assistant");
103
- // OpenClaw requires content to ALWAYS be an array for assistant messages
104
- const content = back[0].content;
105
- expect(Array.isArray(content)).toBe(true);
106
- expect(content[0]).toEqual({ type: "text", text: "Hello there!" });
107
- });
108
 
109
- it("round-trips assistant with tool calls", () => {
110
- const original = [
111
- {
112
- role: "assistant",
113
- content: [
114
- { type: "text", text: "Searching..." },
115
- { type: "toolCall", id: "call_1|fc_1", name: "search", arguments: { q: "test" } },
116
- ],
117
- timestamp: Date.now(),
118
- },
119
- ];
120
- const openai = agentToOpenAI(original);
121
- const back = openAIToAgent(openai);
122
- expect(back[0].role).toBe("assistant");
123
- const content = back[0].content;
124
- expect(Array.isArray(content)).toBe(true);
125
- expect(content).toContainEqual(expect.objectContaining({ type: "text", text: "Searching..." }));
126
- expect(content).toContainEqual(
127
- expect.objectContaining({ type: "toolCall", id: "call_1|fc_1", name: "search" }),
128
- );
129
- });
130
 
131
- it("round-trips toolResult", () => {
132
- const original = [
133
- {
134
- role: "toolResult",
135
- content: '{"data": true}',
136
- tool_use_id: "tu_1",
137
- timestamp: Date.now(),
138
- },
139
- ];
140
- const openai = agentToOpenAI(original);
141
- const back = openAIToAgent(openai);
142
- expect(back[0].role).toBe("toolResult");
143
- expect(back[0].content).toEqual([{ type: "text", text: '{"data": true}' }]);
144
- expect(back[0].tool_use_id).toBe("tu_1");
145
  });
146
- });
147
 
148
- if (RUN) {
149
- describe("ProxyManager", () => {
150
- it("connects to configured proxy URL", { timeout: 30000 }, async () => {
151
- const manager = new ProxyManager({ proxyUrl: PROXY_URL });
152
- try {
153
- const url = await manager.start();
154
- expect(url).toMatch(/^http:\/\/(127\.0\.0\.1|localhost):\d+$/);
155
 
156
- // Verify health
157
- const resp = await fetch(`${url}/health`);
158
- expect(resp.ok).toBe(true);
159
- } finally {
160
- await manager.stop();
161
- }
162
- });
163
  });
164
 
165
- describe("HeadroomContextEngine", () => {
166
- let engine: HeadroomContextEngine;
167
-
168
- beforeAll(async () => {
169
- engine = new HeadroomContextEngine({ proxyUrl: PROXY_URL });
170
- await engine.bootstrap({
171
- sessionId: "test-session",
172
- sessionFile: "/tmp/test-session.jsonl",
173
- });
174
- }, 30000);
175
-
176
- afterAll(async () => {
177
- await engine.dispose();
178
- });
179
-
180
- it("assemble() compresses tool outputs", { timeout: 15000 }, async () => {
181
- // Simulate an OpenClaw agent conversation with large tool result
182
- const serverData = Array.from({ length: 100 }, (_, i) => ({
183
- id: i + 1,
184
- name: `server-${i + 1}`,
185
- status: i % 15 === 0 ? "critical" : i % 5 === 0 ? "warning" : "healthy",
186
- cpu: Math.round(Math.random() * 100),
187
- memory: Math.round(Math.random() * 100),
188
- region: ["us-east-1", "eu-west-1", "ap-southeast-1"][i % 3],
189
- description: `Production server ${i + 1} running service-${["auth", "payment", "user", "api"][i % 4]}`,
190
- lastAlert: i % 15 === 0 ? `Disk usage at ${90 + (i % 10)}%` : null,
191
- }));
192
-
193
- const messages = [
194
- { role: "user", content: "Check the fleet status", timestamp: Date.now() },
195
- {
196
- role: "assistant",
197
- content: [
198
- { type: "tool_use", id: "tu_fleet", name: "getFleetStatus", input: {} },
199
- ],
200
- timestamp: Date.now(),
201
- },
202
- {
203
- role: "toolResult",
204
- content: JSON.stringify(serverData),
205
- tool_use_id: "tu_fleet",
206
- timestamp: Date.now(),
207
- },
208
- { role: "user", content: "Which servers are critical?", timestamp: Date.now() },
209
- ];
210
 
211
- const result = await engine.assemble({
212
- sessionId: "test-session",
 
 
 
 
213
  messages,
214
- model: "claude-sonnet-4-5",
215
- });
216
-
217
- console.log(
218
- ` assemble(): estimatedTokens=${result.estimatedTokens}, ` +
219
- `systemPrompt=${result.systemPromptAddition ? "yes" : "no"}`,
220
- );
221
-
222
- // Messages should be returned (compressed or not)
223
- expect(result.messages.length).toBeGreaterThan(0);
224
- // First and last messages should still be user messages
225
- expect(result.messages[0].role).toBe("user");
226
- expect(result.messages[result.messages.length - 1].role).toBe("user");
227
- });
228
-
229
- it("assemble() preserves small conversations", { timeout: 15000 }, async () => {
230
- const messages = [
231
- { role: "user", content: "Hello", timestamp: Date.now() },
232
- { role: "assistant", content: "Hi there!", timestamp: Date.now() },
233
- ];
234
-
235
- const result = await engine.assemble({
236
- sessionId: "test-session",
237
- messages,
238
- });
239
-
240
- expect(result.messages).toHaveLength(2);
241
- expect(result.messages[0].content).toBe("Hello");
242
- expect(result.messages[1].content).toBe("Hi there!");
243
- });
244
-
245
- it("compact() returns success (compression handled in assemble)", async () => {
246
- const result = await engine.compact({
247
- sessionId: "test-session",
248
- sessionFile: "/tmp/test.jsonl",
249
- });
250
-
251
- expect(result.ok).toBe(true);
252
- expect(result.compacted).toBe(true);
253
- });
254
-
255
- it("getStats() returns compression statistics", () => {
256
- const stats = engine.getStats();
257
- expect(stats).toHaveProperty("totalCompressions");
258
- expect(stats).toHaveProperty("totalTokensSaved");
259
- expect(stats.totalCompressions).toBeGreaterThanOrEqual(0);
260
  });
 
261
  });
262
- }
 
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocked = vi.hoisted(() => ({
4
+ start: vi.fn(async () => "http://127.0.0.1:8787"),
5
+ stop: vi.fn(async () => undefined),
6
+ logger: {
7
+ debug: vi.fn(),
8
+ error: vi.fn(),
9
+ info: vi.fn(),
10
+ warn: vi.fn(),
11
+ },
12
+ }));
13
+
14
+ vi.mock("headroom-ai", () => ({
15
+ compress: vi.fn(),
16
+ }));
17
+
18
+ vi.mock("../src/proxy-manager.js", () => ({
19
+ ProxyManager: class {
20
+ start = mocked.start;
21
+ stop = mocked.stop;
22
+ },
23
+ defaultLogger: mocked.logger,
24
+ }));
25
 
26
+ import { HeadroomContextEngine } from "../src/engine.js";
 
27
 
28
  afterEach(() => {
29
+ mocked.start.mockReset();
30
+ mocked.start.mockResolvedValue("http://127.0.0.1:8787");
31
+ mocked.stop.mockClear();
32
+ mocked.logger.debug.mockClear();
33
+ mocked.logger.error.mockClear();
34
+ mocked.logger.info.mockClear();
35
+ mocked.logger.warn.mockClear();
36
  });
37
 
38
+ describe("HeadroomContextEngine proxy startup helpers", () => {
39
+ it("bootstraps by scheduling proxy startup when enabled", async () => {
40
+ const engine = new HeadroomContextEngine();
41
+
42
+ await expect(
43
+ engine.bootstrap({
44
+ sessionId: "session-1",
45
+ sessionFile: "session.jsonl",
46
+ }),
47
+ ).resolves.toEqual({
48
+ bootstrapped: true,
49
+ reason: "proxy startup scheduled",
50
+ });
51
+ expect(mocked.start).toHaveBeenCalledTimes(1);
52
  });
53
 
54
+ it("removes unsubscribed proxy listeners before notifying readiness", async () => {
55
+ const engine = new HeadroomContextEngine();
56
+ const first = vi.fn();
57
+ const second = vi.fn();
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
+ const unsubscribeFirst = engine.onProxyReady(first);
60
+ engine.onProxyReady(second);
61
+ unsubscribeFirst();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
+ engine.ensureProxyStarted();
64
+ await engine.ensureProxyUrl();
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
+ expect(first).not.toHaveBeenCalled();
67
+ expect(second).toHaveBeenCalledWith("http://127.0.0.1:8787");
 
 
 
 
68
  });
69
 
70
+ it("returns the existing proxy URL without starting again", async () => {
71
+ const engine = new HeadroomContextEngine();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
+ (engine as { proxyUrl: string | null }).proxyUrl = "http://127.0.0.1:8787";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
+ await expect(engine.ensureProxyUrl()).resolves.toBe("http://127.0.0.1:8787");
76
+ expect(mocked.start).not.toHaveBeenCalled();
 
 
 
 
 
 
 
 
 
 
 
 
77
  });
 
78
 
79
+ it("throws when proxy startup is disabled", async () => {
80
+ const engine = new HeadroomContextEngine({ enabled: false });
 
 
 
 
 
81
 
82
+ await expect(engine.ensureProxyUrl()).rejects.toThrow("Headroom proxy startup is disabled");
83
+ expect(mocked.start).not.toHaveBeenCalled();
 
 
 
 
 
84
  });
85
 
86
+ it("schedules startup and returns original messages when assembling before proxy readiness", async () => {
87
+ const engine = new HeadroomContextEngine();
88
+ const messages = [{ role: "user", content: "hello" }];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
+ await expect(
91
+ engine.assemble({
92
+ sessionId: "session-1",
93
+ messages,
94
+ }),
95
+ ).resolves.toEqual({
96
  messages,
97
+ estimatedTokens: 0,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  });
99
+ expect(mocked.start).toHaveBeenCalledTimes(1);
100
  });
101
+ });
plugins/openclaw/test/gateway-config.test.ts ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ applyGatewayProviderBaseUrls,
4
+ applyGatewayProviderBaseUrlsInPlace,
5
+ resolveGatewayProviderIds,
6
+ } from "../src/gateway-config.js";
7
+
8
+ describe("resolveGatewayProviderIds", () => {
9
+ it("routes openai-codex by default", () => {
10
+ expect(resolveGatewayProviderIds(undefined)).toEqual(["openai-codex"]);
11
+ });
12
+
13
+ it("allows an explicit provider list to override the default", () => {
14
+ expect(
15
+ resolveGatewayProviderIds({
16
+ gatewayProviderIds: ["anthropic", "github-copilot", "minimax-portal"],
17
+ }),
18
+ ).toEqual(["anthropic", "github-copilot", "minimax-portal"]);
19
+ });
20
+
21
+ it("normalizes explicit provider ids and friendly aliases", () => {
22
+ expect(
23
+ resolveGatewayProviderIds({
24
+ gatewayProviderIds: [" claude ", "", "copilot", "codex", "gemini", "anthropic"],
25
+ }),
26
+ ).toEqual(["anthropic", "github-copilot", "openai-codex", "google"]);
27
+ });
28
+
29
+ it("allows routing to be disabled", () => {
30
+ expect(resolveGatewayProviderIds({ routeCodexViaProxy: false })).toEqual([]);
31
+ });
32
+ });
33
+
34
+ describe("applyGatewayProviderBaseUrls", () => {
35
+ it("creates an openai-codex provider config when missing", () => {
36
+ const result = applyGatewayProviderBaseUrls({}, "http://127.0.0.1:8787", ["openai-codex"]);
37
+
38
+ expect(result.changed).toBe(true);
39
+ expect((result.config as any).models.providers["openai-codex"]).toEqual({
40
+ baseUrl: "http://127.0.0.1:8787/backend-api",
41
+ models: [],
42
+ });
43
+ });
44
+
45
+ it("creates provider configs for multiple configured provider ids", () => {
46
+ const result = applyGatewayProviderBaseUrls(
47
+ {},
48
+ "http://127.0.0.1:8787",
49
+ ["anthropic", "openrouter", "google", "minimax-portal"],
50
+ );
51
+
52
+ expect(result.changed).toBe(true);
53
+ expect((result.config as any).models.providers).toEqual({
54
+ anthropic: {
55
+ baseUrl: "http://127.0.0.1:8787",
56
+ models: [],
57
+ },
58
+ openrouter: {
59
+ baseUrl: "http://127.0.0.1:8787",
60
+ models: [],
61
+ },
62
+ google: {
63
+ baseUrl: "http://127.0.0.1:8787",
64
+ models: [],
65
+ },
66
+ "minimax-portal": {
67
+ baseUrl: "http://127.0.0.1:8787",
68
+ models: [],
69
+ },
70
+ });
71
+ });
72
+
73
+ it("preserves existing provider config fields", () => {
74
+ const result = applyGatewayProviderBaseUrls(
75
+ {
76
+ models: {
77
+ providers: {
78
+ "openai-codex": {
79
+ api: "openai-codex-responses",
80
+ baseUrl: "https://chatgpt.com/backend-api",
81
+ },
82
+ },
83
+ },
84
+ },
85
+ "http://127.0.0.1:8787",
86
+ ["openai-codex"],
87
+ );
88
+
89
+ expect(result.changed).toBe(true);
90
+ expect((result.config as any).models.providers["openai-codex"]).toEqual({
91
+ api: "openai-codex-responses",
92
+ baseUrl: "http://127.0.0.1:8787/backend-api",
93
+ models: [],
94
+ });
95
+ });
96
+
97
+ it("is a no-op when the provider already points at headroom", () => {
98
+ const cfg = {
99
+ models: {
100
+ providers: {
101
+ "openai-codex": {
102
+ baseUrl: "http://127.0.0.1:8787/backend-api",
103
+ models: [],
104
+ },
105
+ },
106
+ },
107
+ };
108
+
109
+ const result = applyGatewayProviderBaseUrls(cfg, "http://127.0.0.1:8787", ["openai-codex"]);
110
+
111
+ expect(result.changed).toBe(false);
112
+ expect(result.config).toEqual(cfg);
113
+ });
114
+
115
+ it("preserves upstream path segments when routing through the proxy", () => {
116
+ const result = applyGatewayProviderBaseUrls(
117
+ {
118
+ models: {
119
+ providers: {
120
+ anthropic: {
121
+ baseUrl: "https://api.anthropic.com/v1",
122
+ },
123
+ },
124
+ },
125
+ },
126
+ "http://127.0.0.1:8787",
127
+ ["anthropic"],
128
+ );
129
+
130
+ expect(result.changed).toBe(true);
131
+ expect((result.config as any).models.providers.anthropic).toEqual({
132
+ baseUrl: "http://127.0.0.1:8787/v1",
133
+ models: [],
134
+ });
135
+ });
136
+
137
+ it("preserves protocol-specific GitHub Copilot OpenAI-family paths", () => {
138
+ const result = applyGatewayProviderBaseUrls(
139
+ {
140
+ models: {
141
+ providers: {
142
+ "github-copilot": {
143
+ baseUrl: "https://api.githubcopilot.com/v1",
144
+ },
145
+ },
146
+ },
147
+ },
148
+ "http://127.0.0.1:8787",
149
+ ["github-copilot"],
150
+ );
151
+
152
+ expect(result.changed).toBe(true);
153
+ expect((result.config as any).models.providers["github-copilot"]).toEqual({
154
+ baseUrl: "http://127.0.0.1:8787/v1",
155
+ models: [],
156
+ });
157
+ });
158
+
159
+ it("preserves protocol-specific GitHub Copilot Claude-family paths", () => {
160
+ const result = applyGatewayProviderBaseUrls(
161
+ {
162
+ models: {
163
+ providers: {
164
+ "github-copilot": {
165
+ baseUrl: "https://api.githubcopilot.com/anthropic",
166
+ },
167
+ },
168
+ },
169
+ },
170
+ "http://127.0.0.1:8787",
171
+ ["github-copilot"],
172
+ );
173
+
174
+ expect(result.changed).toBe(true);
175
+ expect((result.config as any).models.providers["github-copilot"]).toEqual({
176
+ baseUrl: "http://127.0.0.1:8787/anthropic",
177
+ models: [],
178
+ });
179
+ });
180
+
181
+ it("preserves OpenAI-compatible /api/v1 paths", () => {
182
+ const result = applyGatewayProviderBaseUrls(
183
+ {
184
+ models: {
185
+ providers: {
186
+ openrouter: {
187
+ baseUrl: "https://openrouter.ai/api/v1",
188
+ },
189
+ },
190
+ },
191
+ },
192
+ "http://127.0.0.1:8787",
193
+ ["openrouter"],
194
+ );
195
+
196
+ expect(result.changed).toBe(true);
197
+ expect((result.config as any).models.providers.openrouter).toEqual({
198
+ baseUrl: "http://127.0.0.1:8787/api/v1",
199
+ models: [],
200
+ });
201
+ });
202
+
203
+ it("preserves Gemini /v1beta paths", () => {
204
+ const result = applyGatewayProviderBaseUrls(
205
+ {
206
+ models: {
207
+ providers: {
208
+ google: {
209
+ baseUrl: "https://generativelanguage.googleapis.com/v1beta",
210
+ },
211
+ },
212
+ },
213
+ },
214
+ "http://127.0.0.1:8787",
215
+ ["google"],
216
+ );
217
+
218
+ expect(result.changed).toBe(true);
219
+ expect((result.config as any).models.providers.google).toEqual({
220
+ baseUrl: "http://127.0.0.1:8787/v1beta",
221
+ models: [],
222
+ });
223
+ });
224
+
225
+ it("does not invent a GitHub Copilot proxy baseUrl without an upstream baseUrl", () => {
226
+ const result = applyGatewayProviderBaseUrls({}, "http://127.0.0.1:8787", ["github-copilot"]);
227
+
228
+ expect(result.changed).toBe(false);
229
+ expect((result.config as any).models?.providers?.["github-copilot"]).toBeUndefined();
230
+ });
231
+ });
232
+
233
+ describe("applyGatewayProviderBaseUrlsInPlace", () => {
234
+ it("updates the live config object in place", () => {
235
+ const cfg: any = { models: { providers: {} } };
236
+
237
+ const changed = applyGatewayProviderBaseUrlsInPlace(
238
+ cfg,
239
+ "http://127.0.0.1:8787",
240
+ ["openai-codex"],
241
+ );
242
+
243
+ expect(changed).toBe(true);
244
+ expect(cfg.models.providers["openai-codex"]).toEqual({
245
+ baseUrl: "http://127.0.0.1:8787/backend-api",
246
+ models: [],
247
+ });
248
+ });
249
+
250
+ it("does not clobber existing provider logic when changing only the base URL", () => {
251
+ const cfg: any = {
252
+ models: {
253
+ providers: {
254
+ "openai-codex": {
255
+ api: "openai-codex-responses",
256
+ baseUrl: "https://chatgpt.com/backend-api",
257
+ envKey: "OPENAI_API_KEY",
258
+ models: ["gpt-5.3-codex"],
259
+ },
260
+ },
261
+ },
262
+ };
263
+
264
+ const changed = applyGatewayProviderBaseUrlsInPlace(
265
+ cfg,
266
+ "http://127.0.0.1:8787",
267
+ ["openai-codex"],
268
+ );
269
+
270
+ expect(changed).toBe(true);
271
+ expect(cfg.models.providers["openai-codex"]).toEqual({
272
+ api: "openai-codex-responses",
273
+ envKey: "OPENAI_API_KEY",
274
+ baseUrl: "http://127.0.0.1:8787/backend-api",
275
+ models: ["gpt-5.3-codex"],
276
+ });
277
+ });
278
+ });
plugins/openclaw/test/plugin-runtime-routing.test.ts ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocked = vi.hoisted(() => ({
4
+ ensureProxyUrl: vi.fn(async () => "http://127.0.0.1:8787"),
5
+ ensureProxyStarted: vi.fn(),
6
+ getProxyUrl: vi.fn(() => null as string | null),
7
+ createHeadroomRetrieveTool: vi.fn(({ proxyUrl }: { proxyUrl: string }) => ({ proxyUrl })),
8
+ }));
9
+
10
+ const proxyReadyListeners: Array<(proxyUrl: string) => void | Promise<void>> = [];
11
+
12
+ vi.mock("../src/engine.js", () => ({
13
+ HeadroomContextEngine: class {
14
+ ensureProxyUrl = mocked.ensureProxyUrl;
15
+ ensureProxyStarted = mocked.ensureProxyStarted;
16
+ getProxyUrl = mocked.getProxyUrl;
17
+ onProxyReady(listener: (proxyUrl: string) => void | Promise<void>) {
18
+ proxyReadyListeners.push(listener);
19
+ return () => {};
20
+ }
21
+ },
22
+ }));
23
+
24
+ vi.mock("../src/tools/headroom-retrieve.js", () => ({
25
+ createHeadroomRetrieveTool: mocked.createHeadroomRetrieveTool,
26
+ }));
27
+
28
+ import headroomPlugin from "../src/plugin/index.js";
29
+
30
+ afterEach(() => {
31
+ mocked.ensureProxyUrl.mockClear();
32
+ mocked.ensureProxyStarted.mockClear();
33
+ mocked.getProxyUrl.mockClear();
34
+ mocked.createHeadroomRetrieveTool.mockClear();
35
+ proxyReadyListeners.length = 0;
36
+ });
37
+
38
+ describe("headroomPlugin runtime routing", () => {
39
+ it("routes configured providers in memory once the proxy becomes available", async () => {
40
+ const gatewayHandlers = new Map<string, () => Promise<void>>();
41
+ const writeConfigFile = vi.fn();
42
+ const loadConfig = vi.fn(() => ({
43
+ models: {
44
+ providers: {
45
+ anthropic: {
46
+ api: "anthropic-messages",
47
+ },
48
+ },
49
+ },
50
+ }));
51
+
52
+ const api: any = {
53
+ config: {
54
+ plugins: {
55
+ entries: {
56
+ headroom: {
57
+ config: {
58
+ gatewayProviderIds: ["codex", "claude", "copilot", "gemini", "openrouter"],
59
+ },
60
+ },
61
+ },
62
+ },
63
+ models: {
64
+ providers: {
65
+ anthropic: {
66
+ api: "anthropic-messages",
67
+ baseUrl: "https://api.anthropic.com",
68
+ },
69
+ "github-copilot": {
70
+ baseUrl: "https://api.githubcopilot.com/v1",
71
+ },
72
+ google: {
73
+ baseUrl: "https://generativelanguage.googleapis.com/v1beta",
74
+ },
75
+ openrouter: {
76
+ baseUrl: "https://openrouter.ai/api/v1",
77
+ },
78
+ },
79
+ },
80
+ },
81
+ logger: {
82
+ info: vi.fn(),
83
+ warn: vi.fn(),
84
+ error: vi.fn(),
85
+ debug: vi.fn(),
86
+ },
87
+ registerContextEngine: vi.fn(),
88
+ registerTool: vi.fn(),
89
+ on: vi.fn((event: string, handler: () => Promise<void>) => {
90
+ gatewayHandlers.set(event, handler);
91
+ }),
92
+ runtime: {
93
+ config: {
94
+ loadConfig,
95
+ writeConfigFile,
96
+ },
97
+ },
98
+ };
99
+
100
+ headroomPlugin(api);
101
+ await Promise.resolve();
102
+
103
+ expect(mocked.ensureProxyUrl).not.toHaveBeenCalled();
104
+ expect(mocked.ensureProxyStarted).toHaveBeenCalledTimes(1);
105
+ expect(writeConfigFile).not.toHaveBeenCalled();
106
+ expect(loadConfig).not.toHaveBeenCalled();
107
+ expect(api.config.models.providers["openai-codex"]).toBeUndefined();
108
+
109
+ await proxyReadyListeners[0]?.("http://127.0.0.1:8787");
110
+
111
+ expect(api.config.models.providers["openai-codex"]).toEqual({
112
+ baseUrl: "http://127.0.0.1:8787/backend-api",
113
+ models: [],
114
+ });
115
+ expect(api.config.models.providers.anthropic).toEqual({
116
+ api: "anthropic-messages",
117
+ baseUrl: "http://127.0.0.1:8787",
118
+ models: [],
119
+ });
120
+ expect(api.config.models.providers["github-copilot"]).toEqual({
121
+ baseUrl: "http://127.0.0.1:8787/v1",
122
+ models: [],
123
+ });
124
+ expect(api.config.models.providers.google).toEqual({
125
+ baseUrl: "http://127.0.0.1:8787/v1beta",
126
+ models: [],
127
+ });
128
+ expect(api.config.models.providers.openrouter).toEqual({
129
+ baseUrl: "http://127.0.0.1:8787/api/v1",
130
+ models: [],
131
+ });
132
+
133
+ const gatewayStart = gatewayHandlers.get("gateway_start");
134
+ expect(gatewayStart).toBeTypeOf("function");
135
+ await gatewayStart?.();
136
+ expect(mocked.ensureProxyStarted).toHaveBeenCalledTimes(2);
137
+ expect(writeConfigFile).not.toHaveBeenCalled();
138
+ expect(loadConfig).not.toHaveBeenCalled();
139
+ expect(mocked.ensureProxyUrl).not.toHaveBeenCalled();
140
+ });
141
+ });
plugins/openclaw/test/proxy-manager.test.ts CHANGED
@@ -204,6 +204,53 @@ describe("ProxyManager launch internals", () => {
204
  expect(commands).toContain("py");
205
  });
206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  it("uses first available launcher from provided specs", async () => {
208
  const manager = new ProxyManager({});
209
  (manager as any).buildLaunchSpecs = () => [
@@ -229,6 +276,27 @@ describe("ProxyManager launch internals", () => {
229
  expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining("second-node"));
230
  });
231
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  it("throws when no launcher is executable", async () => {
233
  const manager = new ProxyManager({});
234
  (manager as any).buildLaunchSpecs = () => [
 
204
  expect(commands).toContain("py");
205
  });
206
 
207
+ it("uses lightweight PATH checks instead of booting the headroom CLI", () => {
208
+ const manager = new ProxyManager({});
209
+ const specs = (manager as any).buildLaunchSpecs("127.0.0.1", "8787") as Array<Record<string, unknown>>;
210
+ const pathSpec = specs[0];
211
+
212
+ expect(pathSpec.command).toBe("headroom");
213
+ expect(pathSpec.args).toEqual(["proxy", "--host", "127.0.0.1", "--port", "8787"]);
214
+ if (process.platform === "win32") {
215
+ expect(pathSpec.checkCommand).toBe("where.exe");
216
+ expect(pathSpec.checkArgs).toEqual(["headroom"]);
217
+ expect(pathSpec.checkUseShell).toBe(false);
218
+ } else {
219
+ expect(pathSpec.checkCommand).toBe("sh");
220
+ expect(pathSpec.checkArgs).toEqual(["-lc", "command -v headroom >/dev/null 2>&1"]);
221
+ }
222
+ });
223
+
224
+ it("passes through fast-fail launch flags when configured", () => {
225
+ const manager = new ProxyManager({ retryMaxAttempts: 1, connectTimeoutSeconds: 3 });
226
+ const specs = (manager as any).buildLaunchSpecs("127.0.0.1", "8787") as Array<Record<string, unknown>>;
227
+ const pathSpec = specs[0];
228
+
229
+ expect(pathSpec.args).toEqual([
230
+ "proxy",
231
+ "--host",
232
+ "127.0.0.1",
233
+ "--port",
234
+ "8787",
235
+ "--retry-max-attempts",
236
+ "1",
237
+ "--connect-timeout-seconds",
238
+ "3",
239
+ ]);
240
+ });
241
+
242
+ it("uses lightweight module discovery for python fallback checks", () => {
243
+ const manager = new ProxyManager({ pythonPath: "C:\\Python311\\python.exe" });
244
+ const specs = (manager as any).buildLaunchSpecs("127.0.0.1", "8787") as Array<Record<string, unknown>>;
245
+ const pythonSpec = specs.find((spec) => spec.command === "C:\\Python311\\python.exe");
246
+
247
+ expect(pythonSpec).toBeDefined();
248
+ expect(pythonSpec?.checkArgs).toEqual([
249
+ "-c",
250
+ "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('headroom') else 1)",
251
+ ]);
252
+ });
253
+
254
  it("uses first available launcher from provided specs", async () => {
255
  const manager = new ProxyManager({});
256
  (manager as any).buildLaunchSpecs = () => [
 
276
  expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining("second-node"));
277
  });
278
 
279
+ it("supports shell-backed launch specs for PATH shims and script wrappers", async () => {
280
+ const manager = new ProxyManager({});
281
+ const shellBuiltin = process.platform === "win32" ? "dir" : ":";
282
+ (manager as any).buildLaunchSpecs = () => [
283
+ {
284
+ label: "shell-backed",
285
+ command: shellBuiltin,
286
+ args: [],
287
+ checkCommand: shellBuiltin,
288
+ checkArgs: [],
289
+ useShell: true,
290
+ },
291
+ ];
292
+ const infoSpy = vi.spyOn((manager as any).logger, "info");
293
+
294
+ await (manager as any).startHeadroomProxy("http://127.0.0.1:8787", 8787);
295
+
296
+ expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining("Auto-start launcher selected"));
297
+ expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining("shell-backed"));
298
+ });
299
+
300
  it("throws when no launcher is executable", async () => {
301
  const manager = new ProxyManager({});
302
  (manager as any).buildLaunchSpecs = () => [
tests/test_cli/test_wrap_openclaw.py CHANGED
@@ -67,6 +67,18 @@ def test_wrap_openclaw_default_installs_from_npm_and_restarts(runner: CliRunner)
67
  assert ["openclaw", "gateway", "restart"] in cmds
68
  assert ["openclaw", "plugins", "inspect", "headroom"] in cmds
69
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  # Verify plugin install in npm mode does not set cwd
71
  install_call = next(
72
  c
@@ -90,6 +102,8 @@ def test_wrap_openclaw_default_installs_from_npm_and_restarts(runner: CliRunner)
90
  assert payload["config"]["proxyPort"] == 8787
91
  assert payload["config"]["autoStart"] is True
92
  assert payload["config"]["startupTimeoutMs"] == 20000
 
 
93
 
94
 
95
  def test_wrap_openclaw_skip_build_and_no_restart(runner: CliRunner, plugin_dir: Path) -> None:
@@ -272,6 +286,143 @@ def test_wrap_openclaw_verbose_prints_install_restart_and_inspect_output(
272
  assert "inspect-ok" in result.output
273
 
274
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  def test_wrap_openclaw_fails_for_npm_mode_hook_pack_bug_without_local_fallback(
276
  runner: CliRunner,
277
  ) -> None:
@@ -448,3 +599,102 @@ def test_copy_openclaw_plugin_into_extensions_handles_missing_and_existing_dist(
448
  assert not (target_dist / "old.js").exists()
449
  assert (target_hook_shim / "index.js").exists()
450
  assert not (target_hook_shim / "old.js").exists()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  assert ["openclaw", "gateway", "restart"] in cmds
68
  assert ["openclaw", "plugins", "inspect", "headroom"] in cmds
69
 
70
+ config_set_index = next(
71
+ i
72
+ for i, cmd in enumerate(cmds)
73
+ if cmd[:4] == ["openclaw", "config", "set", "plugins.entries.headroom"]
74
+ )
75
+ install_index = next(
76
+ i
77
+ for i, cmd in enumerate(cmds)
78
+ if cmd[:4] == ["openclaw", "plugins", "install", "--dangerously-force-unsafe-install"]
79
+ )
80
+ assert config_set_index < install_index
81
+
82
  # Verify plugin install in npm mode does not set cwd
83
  install_call = next(
84
  c
 
102
  assert payload["config"]["proxyPort"] == 8787
103
  assert payload["config"]["autoStart"] is True
104
  assert payload["config"]["startupTimeoutMs"] == 20000
105
+ assert payload["config"]["gatewayProviderIds"] == ["openai-codex"]
106
+ assert payload["config"]["pythonPath"] == wrap_cli.sys.executable
107
 
108
 
109
  def test_wrap_openclaw_skip_build_and_no_restart(runner: CliRunner, plugin_dir: Path) -> None:
 
286
  assert "inspect-ok" in result.output
287
 
288
 
289
+ def test_wrap_openclaw_starts_gateway_when_restart_fails(runner: CliRunner) -> None:
290
+ calls: list[dict] = []
291
+
292
+ def which(name: str) -> str | None:
293
+ return {"openclaw": "openclaw", "npm": "npm"}.get(name)
294
+
295
+ def run(cmd, **kwargs): # noqa: ANN001
296
+ calls.append({"cmd": list(cmd), **kwargs})
297
+ if cmd[:3] == ["openclaw", "gateway", "restart"]:
298
+ return MagicMock(returncode=1, stdout="", stderr="gateway not running")
299
+ if cmd[:3] == ["openclaw", "gateway", "start"]:
300
+ return MagicMock(returncode=0, stdout="started-ok", stderr="")
301
+ return MagicMock(returncode=0, stdout="", stderr="")
302
+
303
+ with patch("headroom.cli.wrap.shutil.which", side_effect=which):
304
+ with patch("headroom.cli.wrap.subprocess.run", side_effect=run):
305
+ result = runner.invoke(main, ["wrap", "openclaw", "--verbose"])
306
+
307
+ assert result.exit_code == 0, result.output
308
+ cmds = [c["cmd"] for c in calls]
309
+ assert ["openclaw", "gateway", "restart"] in cmds
310
+ assert ["openclaw", "gateway", "start"] in cmds
311
+ assert "Gateway started." in result.output
312
+ assert "started-ok" in result.output
313
+
314
+
315
+ def test_wrap_openclaw_accepts_repeatable_gateway_provider_ids(runner: CliRunner) -> None:
316
+ calls: list[dict] = []
317
+
318
+ def which(name: str) -> str | None:
319
+ return {"openclaw": "openclaw", "npm": "npm"}.get(name)
320
+
321
+ with patch("headroom.cli.wrap.shutil.which", side_effect=which):
322
+ with patch("headroom.cli.wrap.subprocess.run", side_effect=_make_successful_run(calls)):
323
+ result = runner.invoke(
324
+ main,
325
+ [
326
+ "wrap",
327
+ "openclaw",
328
+ "--gateway-provider-id",
329
+ "openai-codex",
330
+ "--gateway-provider-id",
331
+ "anthropic",
332
+ "--no-restart",
333
+ ],
334
+ )
335
+
336
+ assert result.exit_code == 0, result.output
337
+ set_entry = next(
338
+ c
339
+ for c in calls
340
+ if c["cmd"][:4] == ["openclaw", "config", "set", "plugins.entries.headroom"]
341
+ )
342
+ payload = json.loads(set_entry["cmd"][4])
343
+ assert payload["config"]["gatewayProviderIds"] == ["openai-codex", "anthropic"]
344
+
345
+
346
+ def test_normalize_openclaw_gateway_provider_ids_dedupes_blanks_and_defaults() -> None:
347
+ assert wrap_cli._normalize_openclaw_gateway_provider_ids(
348
+ (" openai-codex ", "", "anthropic", "openai-codex", " ")
349
+ ) == ["openai-codex", "anthropic"]
350
+ assert wrap_cli._normalize_openclaw_gateway_provider_ids(None) == ["openai-codex"]
351
+
352
+
353
+ def test_read_openclaw_config_value_handles_missing_and_raw_strings() -> None:
354
+ missing = MagicMock(returncode=1, stdout="", stderr="missing")
355
+ raw_string = MagicMock(returncode=0, stdout="plain-text-value\n", stderr="")
356
+
357
+ with patch("headroom.cli.wrap.subprocess.run", side_effect=[missing, raw_string]):
358
+ assert wrap_cli._read_openclaw_config_value("openclaw", "plugins.entries.headroom") is None
359
+ assert (
360
+ wrap_cli._read_openclaw_config_value(
361
+ "openclaw", "plugins.entries.headroom.config.pythonPath"
362
+ )
363
+ == "plain-text-value"
364
+ )
365
+
366
+
367
+ def test_build_openclaw_plugin_entry_sets_and_clears_python_path() -> None:
368
+ with_python = wrap_cli._build_openclaw_plugin_entry(
369
+ existing_entry={"config": {"customFlag": True}},
370
+ proxy_port=8787,
371
+ startup_timeout_ms=20000,
372
+ python_path="C:\\Python312\\python.exe",
373
+ no_auto_start=False,
374
+ gateway_provider_ids=("openai-codex",),
375
+ enabled=True,
376
+ )
377
+ assert with_python["config"]["pythonPath"] == "C:\\Python312\\python.exe"
378
+
379
+ without_python = wrap_cli._build_openclaw_plugin_entry(
380
+ existing_entry={"config": {"pythonPath": "C:\\Old\\python.exe", "customFlag": True}},
381
+ proxy_port=8787,
382
+ startup_timeout_ms=20000,
383
+ python_path=None,
384
+ no_auto_start=False,
385
+ gateway_provider_ids=("openai-codex",),
386
+ enabled=True,
387
+ )
388
+ assert "pythonPath" not in without_python["config"]
389
+ assert without_python["config"]["customFlag"] is True
390
+
391
+
392
+ def test_wrap_openclaw_no_auto_start_does_not_default_python_path(
393
+ runner: CliRunner, plugin_dir: Path
394
+ ) -> None:
395
+ calls: list[dict] = []
396
+
397
+ def which(name: str) -> str | None:
398
+ return {"openclaw": "openclaw", "npm": "npm"}.get(name)
399
+
400
+ with patch("headroom.cli.wrap.shutil.which", side_effect=which):
401
+ with patch("headroom.cli.wrap.subprocess.run", side_effect=_make_successful_run(calls)):
402
+ result = runner.invoke(
403
+ main,
404
+ [
405
+ "wrap",
406
+ "openclaw",
407
+ "--plugin-path",
408
+ str(plugin_dir),
409
+ "--skip-build",
410
+ "--no-auto-start",
411
+ "--no-restart",
412
+ ],
413
+ )
414
+
415
+ assert result.exit_code == 0, result.output
416
+ set_entry = next(
417
+ c
418
+ for c in calls
419
+ if c["cmd"][:4] == ["openclaw", "config", "set", "plugins.entries.headroom"]
420
+ )
421
+ payload = json.loads(set_entry["cmd"][4])
422
+ assert payload["config"]["autoStart"] is False
423
+ assert "pythonPath" not in payload["config"]
424
+
425
+
426
  def test_wrap_openclaw_fails_for_npm_mode_hook_pack_bug_without_local_fallback(
427
  runner: CliRunner,
428
  ) -> None:
 
599
  assert not (target_dist / "old.js").exists()
600
  assert (target_hook_shim / "index.js").exists()
601
  assert not (target_hook_shim / "old.js").exists()
602
+
603
+
604
+ def test_unwrap_openclaw_disables_plugin_and_restores_legacy_slot(runner: CliRunner) -> None:
605
+ calls: list[dict] = []
606
+
607
+ def which(name: str) -> str | None:
608
+ return {"openclaw": "openclaw"}.get(name)
609
+
610
+ def run(cmd, **kwargs): # noqa: ANN001
611
+ calls.append({"cmd": list(cmd), **kwargs})
612
+ if cmd[:4] == ["openclaw", "config", "get", "plugins.entries.headroom"]:
613
+ return MagicMock(
614
+ returncode=0,
615
+ stdout=json.dumps(
616
+ {
617
+ "enabled": True,
618
+ "config": {
619
+ "proxyPort": 8787,
620
+ "gatewayProviderIds": ["openai-codex"],
621
+ "customFlag": True,
622
+ },
623
+ }
624
+ ),
625
+ stderr="",
626
+ )
627
+ return MagicMock(returncode=0, stdout="", stderr="")
628
+
629
+ with patch("headroom.cli.wrap.shutil.which", side_effect=which):
630
+ with patch("headroom.cli.wrap.subprocess.run", side_effect=run):
631
+ result = runner.invoke(main, ["unwrap", "openclaw"])
632
+
633
+ assert result.exit_code == 0, result.output
634
+ set_entry = next(
635
+ c
636
+ for c in calls
637
+ if c["cmd"][:4] == ["openclaw", "config", "set", "plugins.entries.headroom"]
638
+ )
639
+ payload = json.loads(set_entry["cmd"][4])
640
+ assert payload == {"enabled": False, "config": {"customFlag": True}}
641
+
642
+ set_slot = next(
643
+ c
644
+ for c in calls
645
+ if c["cmd"][:4] == ["openclaw", "config", "set", "plugins.slots.contextEngine"]
646
+ )
647
+ assert json.loads(set_slot["cmd"][4]) == "legacy"
648
+ assert ["openclaw", "gateway", "restart"] in [c["cmd"] for c in calls]
649
+
650
+
651
+ def test_unwrap_openclaw_no_restart_skips_gateway_restart(runner: CliRunner) -> None:
652
+ calls: list[dict] = []
653
+
654
+ def which(name: str) -> str | None:
655
+ return {"openclaw": "openclaw"}.get(name)
656
+
657
+ def run(cmd, **kwargs): # noqa: ANN001
658
+ calls.append({"cmd": list(cmd), **kwargs})
659
+ return MagicMock(returncode=0, stdout="", stderr="")
660
+
661
+ with patch("headroom.cli.wrap.shutil.which", side_effect=which):
662
+ with patch("headroom.cli.wrap.subprocess.run", side_effect=run):
663
+ result = runner.invoke(main, ["unwrap", "openclaw", "--no-restart"])
664
+
665
+ assert result.exit_code == 0, result.output
666
+ assert ["openclaw", "gateway", "restart"] not in [c["cmd"] for c in calls]
667
+
668
+
669
+ def test_unwrap_openclaw_fails_when_openclaw_missing(runner: CliRunner) -> None:
670
+ with patch("headroom.cli.wrap.shutil.which", return_value=None):
671
+ result = runner.invoke(main, ["unwrap", "openclaw"])
672
+
673
+ assert result.exit_code != 0
674
+ assert "'openclaw' not found in PATH" in result.output
675
+
676
+
677
+ def test_unwrap_openclaw_verbose_prints_gateway_and_inspect_output(runner: CliRunner) -> None:
678
+ def which(name: str) -> str | None:
679
+ return {"openclaw": "openclaw"}.get(name)
680
+
681
+ def run(cmd, **kwargs): # noqa: ANN001
682
+ if cmd[:4] == ["openclaw", "config", "get", "plugins.entries.headroom"]:
683
+ return MagicMock(
684
+ returncode=0,
685
+ stdout=json.dumps({"enabled": True, "config": {"proxyPort": 8787}}),
686
+ stderr="",
687
+ )
688
+ if cmd[:3] == ["openclaw", "gateway", "restart"]:
689
+ return MagicMock(returncode=0, stdout="gateway-restarted", stderr="")
690
+ if cmd[:3] == ["openclaw", "plugins", "inspect"]:
691
+ return MagicMock(returncode=0, stdout="inspect-disabled", stderr="")
692
+ return MagicMock(returncode=0, stdout="", stderr="")
693
+
694
+ with patch("headroom.cli.wrap.shutil.which", side_effect=which):
695
+ with patch("headroom.cli.wrap.subprocess.run", side_effect=run):
696
+ result = runner.invoke(main, ["unwrap", "openclaw", "--verbose"])
697
+
698
+ assert result.exit_code == 0, result.output
699
+ assert "gateway-restarted" in result.output
700
+ assert "inspect-disabled" in result.output
tests/test_cli_proxy_env.py CHANGED
@@ -142,6 +142,30 @@ class TestCLIProxyEnvVars:
142
  assert captured_config["config"].openai_api_url == "http://my-vllm:4000"
143
  assert captured_config["config"].gemini_api_url == "http://my-gemini:5000"
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
 
146
  class TestCLIProxyBackend:
147
  """Test that litellm-* backends are accepted by the CLI."""
 
142
  assert captured_config["config"].openai_api_url == "http://my-vllm:4000"
143
  assert captured_config["config"].gemini_api_url == "http://my-gemini:5000"
144
 
145
+ def test_retry_and_connect_timeout_cli_flags(self, runner):
146
+ """Fast-fail CLI flags should map into ProxyConfig."""
147
+ captured_config = {}
148
+
149
+ def mock_run_server(config):
150
+ captured_config["config"] = config
151
+
152
+ with patch("headroom.proxy.server.run_server", mock_run_server):
153
+ result = runner.invoke(
154
+ main,
155
+ [
156
+ "proxy",
157
+ "--retry-max-attempts",
158
+ "1",
159
+ "--connect-timeout-seconds",
160
+ "3",
161
+ ],
162
+ catch_exceptions=False,
163
+ )
164
+
165
+ assert result.exit_code == 0, result.output
166
+ assert captured_config["config"].retry_max_attempts == 1
167
+ assert captured_config["config"].connect_timeout_seconds == 3
168
+
169
 
170
  class TestCLIProxyBackend:
171
  """Test that litellm-* backends are accepted by the CLI."""
tests/test_openai_codex_routing.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import json
3
+ import sys
4
+ from types import SimpleNamespace
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import anyio
8
+ import pytest
9
+ from fastapi import Request
10
+
11
+ from headroom.proxy.handlers.openai import (
12
+ OpenAIHandlerMixin,
13
+ _resolve_codex_routing_headers,
14
+ )
15
+
16
+
17
+ def _jwt(payload: dict) -> str:
18
+ header = {"alg": "none", "typ": "JWT"}
19
+
20
+ def encode(part: dict) -> str:
21
+ raw = json.dumps(part, separators=(",", ":")).encode("utf-8")
22
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
23
+
24
+ return f"{encode(header)}.{encode(payload)}."
25
+
26
+
27
+ def test_resolve_codex_routing_prefers_explicit_header():
28
+ headers, is_chatgpt = _resolve_codex_routing_headers(
29
+ {
30
+ "Authorization": "Bearer sk-test",
31
+ "ChatGPT-Account-ID": "acct-explicit",
32
+ }
33
+ )
34
+
35
+ assert is_chatgpt is True
36
+ assert headers["ChatGPT-Account-ID"] == "acct-explicit"
37
+
38
+
39
+ def test_resolve_codex_routing_derives_account_id_from_oauth_jwt():
40
+ token = _jwt(
41
+ {
42
+ "https://api.openai.com/auth": {
43
+ "chatgpt_account_id": "acct-from-jwt",
44
+ }
45
+ }
46
+ )
47
+
48
+ headers, is_chatgpt = _resolve_codex_routing_headers(
49
+ {
50
+ "authorization": f"Bearer {token}",
51
+ }
52
+ )
53
+
54
+ assert is_chatgpt is True
55
+ assert headers["ChatGPT-Account-ID"] == "acct-from-jwt"
56
+
57
+
58
+ def test_resolve_codex_routing_leaves_regular_openai_bearer_tokens_unchanged():
59
+ token = _jwt({"aud": ["https://api.openai.com/v1"]})
60
+
61
+ headers, is_chatgpt = _resolve_codex_routing_headers(
62
+ {
63
+ "authorization": f"Bearer {token}",
64
+ }
65
+ )
66
+
67
+ assert is_chatgpt is False
68
+ assert "ChatGPT-Account-ID" not in headers
69
+
70
+
71
+ def test_resolve_codex_routing_returns_none_without_bearer_auth():
72
+ headers, is_chatgpt = _resolve_codex_routing_headers({})
73
+
74
+ assert is_chatgpt is False
75
+ assert headers == {}
76
+
77
+
78
+ def test_resolve_codex_routing_ignores_non_jwt_bearer_tokens():
79
+ headers, is_chatgpt = _resolve_codex_routing_headers(
80
+ {
81
+ "authorization": "Bearer not-a-jwt",
82
+ }
83
+ )
84
+
85
+ assert is_chatgpt is False
86
+ assert headers["authorization"] == "Bearer not-a-jwt"
87
+
88
+
89
+ def test_resolve_codex_routing_ignores_invalid_jwt_payloads():
90
+ invalid_payload = base64.urlsafe_b64encode(b"not-json").decode("ascii").rstrip("=")
91
+ token = f"test-header.{invalid_payload}.signature"
92
+
93
+ headers, is_chatgpt = _resolve_codex_routing_headers(
94
+ {
95
+ "authorization": f"Bearer {token}",
96
+ }
97
+ )
98
+
99
+ assert is_chatgpt is False
100
+ assert headers["authorization"] == f"Bearer {token}"
101
+
102
+
103
+ class _DummyMetrics:
104
+ async def record_request(self, **kwargs): # noqa: ANN003
105
+ return None
106
+
107
+ async def record_failed(self):
108
+ return None
109
+
110
+
111
+ class _DummyTokenizer:
112
+ def count_messages(self, messages):
113
+ return len(messages)
114
+
115
+
116
+ class _ResponseStub:
117
+ def json(self):
118
+ return {"usage": {"input_tokens": 2, "output_tokens": 1}}
119
+
120
+
121
+ class _DummyOpenAIHandler(OpenAIHandlerMixin):
122
+ OPENAI_API_URL = "https://api.openai.com"
123
+
124
+ def __init__(self) -> None:
125
+ self.rate_limiter = None
126
+ self.metrics = _DummyMetrics()
127
+ self.config = SimpleNamespace(optimize=False)
128
+ self.usage_reporter = None
129
+ self.openai_provider = SimpleNamespace()
130
+ self.anthropic_backend = None
131
+ self.cost_tracker = None
132
+ self.captured_request: tuple[str, str, dict, dict] | None = None
133
+
134
+ async def _next_request_id(self) -> str:
135
+ return "req-1"
136
+
137
+ def _extract_tags(self, headers: dict[str, str]) -> list[str]:
138
+ return []
139
+
140
+ async def _retry_request(self, method: str, url: str, headers: dict, body: dict):
141
+ self.captured_request = (method, url, headers, body)
142
+ return _ResponseStub()
143
+
144
+
145
+ def _build_request(body: dict, headers: dict[str, str]) -> Request:
146
+ payload = json.dumps(body).encode("utf-8")
147
+
148
+ async def receive():
149
+ return {"type": "http.request", "body": payload, "more_body": False}
150
+
151
+ scope = {
152
+ "type": "http",
153
+ "asgi": {"version": "3.0"},
154
+ "http_version": "1.1",
155
+ "method": "POST",
156
+ "scheme": "https",
157
+ "path": "/v1/responses",
158
+ "raw_path": b"/v1/responses",
159
+ "query_string": b"",
160
+ "headers": [
161
+ (key.lower().encode("utf-8"), value.encode("utf-8")) for key, value in headers.items()
162
+ ],
163
+ "client": ("127.0.0.1", 12345),
164
+ "server": ("testserver", 443),
165
+ }
166
+ return Request(scope, receive)
167
+
168
+
169
+ def test_handle_openai_responses_routes_chatgpt_auth_to_backend_api(monkeypatch):
170
+ token = _jwt(
171
+ {
172
+ "https://api.openai.com/auth": {
173
+ "chatgpt_account_id": "acct-from-jwt",
174
+ }
175
+ }
176
+ )
177
+ request = _build_request(
178
+ {"model": "gpt-5.4", "input": "hello"},
179
+ {"Authorization": f"Bearer {token}"},
180
+ )
181
+ handler = _DummyOpenAIHandler()
182
+
183
+ monkeypatch.setattr("headroom.tokenizers.get_tokenizer", lambda model: _DummyTokenizer())
184
+
185
+ anyio.run(handler.handle_openai_responses, request)
186
+
187
+ assert handler.captured_request is not None
188
+ method, url, headers, body = handler.captured_request
189
+ assert method == "POST"
190
+ assert url == "https://chatgpt.com/backend-api/codex/responses"
191
+ assert headers["ChatGPT-Account-ID"] == "acct-from-jwt"
192
+ assert body["input"] == "hello"
193
+
194
+
195
+ class _DummyWebSocket:
196
+ def __init__(self, headers: dict[str, str]):
197
+ self.headers = headers
198
+ self.accepted_subprotocol = None
199
+
200
+ async def accept(self, subprotocol=None):
201
+ self.accepted_subprotocol = subprotocol
202
+
203
+
204
+ def test_handle_openai_responses_ws_resolves_codex_routing_headers():
205
+ class SentinelError(RuntimeError):
206
+ pass
207
+
208
+ handler = _DummyOpenAIHandler()
209
+ websocket = _DummyWebSocket({"authorization": "Bearer token"})
210
+
211
+ with patch.dict(sys.modules, {"websockets": MagicMock()}):
212
+ with patch(
213
+ "headroom.proxy.handlers.openai._resolve_codex_routing_headers",
214
+ side_effect=SentinelError("resolved"),
215
+ ):
216
+ with pytest.raises(SentinelError, match="resolved"):
217
+ anyio.run(handler.handle_openai_responses_ws, websocket)
tests/test_proxy_codex_route_aliases.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ from fastapi import WebSocket
3
+ from fastapi.responses import JSONResponse
4
+ from fastapi.testclient import TestClient
5
+
6
+ from headroom.proxy.server import HeadroomProxy, ProxyConfig, create_app
7
+
8
+
9
+ def test_codex_responses_aliases_delegate_to_openai_handler(monkeypatch):
10
+ async def fake_handle(self, request): # type: ignore[no-untyped-def]
11
+ return JSONResponse({"ok": True, "path": request.url.path})
12
+
13
+ monkeypatch.setattr(HeadroomProxy, "handle_openai_responses", fake_handle)
14
+
15
+ with TestClient(create_app(ProxyConfig())) as client:
16
+ for path in ("/backend-api/responses", "/backend-api/codex/responses"):
17
+ response = client.post(path, json={"model": "gpt-5.3-codex"})
18
+ assert response.status_code == 200
19
+ assert response.json() == {"ok": True, "path": path}
20
+
21
+
22
+ def test_codex_responses_websocket_aliases_delegate_to_openai_handler(monkeypatch):
23
+ seen_paths: list[str] = []
24
+
25
+ async def fake_handle_ws(self, websocket: WebSocket): # type: ignore[no-untyped-def]
26
+ seen_paths.append(websocket.url.path)
27
+ await websocket.accept()
28
+ await websocket.send_json({"ok": True, "path": websocket.url.path})
29
+ await websocket.close()
30
+
31
+ monkeypatch.setattr(HeadroomProxy, "handle_openai_responses_ws", fake_handle_ws)
32
+
33
+ with TestClient(create_app(ProxyConfig())) as client:
34
+ for path in ("/backend-api/responses", "/backend-api/codex/responses"):
35
+ with client.websocket_connect(path) as websocket:
36
+ assert websocket.receive_json() == {"ok": True, "path": path}
37
+
38
+ assert seen_paths == ["/backend-api/responses", "/backend-api/codex/responses"]
39
+
40
+
41
+ def test_codex_responses_subpath_aliases_delegate_to_passthrough():
42
+ class FakeAsyncClient:
43
+ def __init__(self) -> None:
44
+ self.calls: list[tuple[str, str]] = []
45
+
46
+ async def request(self, method, url, **_kwargs): # type: ignore[no-untyped-def]
47
+ self.calls.append((method, url))
48
+ return httpx.Response(200, json={"method": method, "url": url})
49
+
50
+ async def aclose(self) -> None:
51
+ return None
52
+
53
+ with TestClient(create_app(ProxyConfig())) as client:
54
+ fake_http_client = FakeAsyncClient()
55
+ client.app.state.proxy.http_client = fake_http_client
56
+ client.app.state.proxy.OPENAI_API_URL = "https://api.openai.test"
57
+
58
+ api_key_response = client.post(
59
+ "/backend-api/responses/compact?trace=1",
60
+ json={"model": "gpt-5.3-codex"},
61
+ )
62
+ chatgpt_response = client.post(
63
+ "/backend-api/codex/responses/compact?trace=2",
64
+ headers={"chatgpt-account-id": "acct_123"},
65
+ json={"model": "gpt-5.3-codex"},
66
+ )
67
+
68
+ assert api_key_response.status_code == 200
69
+ assert chatgpt_response.status_code == 200
70
+ assert fake_http_client.calls == [
71
+ ("POST", "https://api.openai.test/v1/responses/compact?trace=1"),
72
+ ("POST", "https://chatgpt.com/backend-api/codex/responses/compact?trace=2"),
73
+ ]