Spaces:
Build error
Build error
Merge pull request #109 from JerrettDavis/feat/openclaw-upstream-gateway
Browse files- README.md +2 -1
- e2e/wrap/run.py +6 -0
- headroom/cli/proxy.py +18 -0
- headroom/cli/wrap.py +232 -40
- headroom/proxy/handlers/openai.py +50 -5
- headroom/proxy/server.py +32 -0
- plugins/openclaw/README.md +63 -0
- plugins/openclaw/openclaw.plugin.json +35 -0
- plugins/openclaw/src/engine.ts +52 -8
- plugins/openclaw/src/gateway-config.ts +143 -0
- plugins/openclaw/src/index.ts +6 -0
- plugins/openclaw/src/plugin/index.ts +47 -0
- plugins/openclaw/src/proxy-manager.ts +38 -13
- plugins/openclaw/test/engine.test.ts +78 -239
- plugins/openclaw/test/gateway-config.test.ts +278 -0
- plugins/openclaw/test/plugin-runtime-routing.test.ts +141 -0
- plugins/openclaw/test/proxy-manager.test.ts +68 -0
- tests/test_cli/test_wrap_openclaw.py +250 -0
- tests/test_cli_proxy_env.py +24 -0
- tests/test_openai_codex_routing.py +217 -0
- tests/test_proxy_codex_route_aliases.py +73 -0
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 |
-
|
| 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(
|
| 1090 |
-
|
| 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 |
-
|
| 1097 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 55 |
-
|
| 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: "
|
| 215 |
-
checkArgs:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 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",
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
| 16 |
-
const PROXY_URL = process.env.HEADROOM_PROXY_URL ?? "http://127.0.0.1:8787";
|
| 17 |
|
| 18 |
afterEach(() => {
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
});
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
});
|
| 31 |
|
| 32 |
-
it("
|
| 33 |
-
const
|
| 34 |
-
|
| 35 |
-
|
| 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 |
-
|
| 51 |
-
|
| 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 |
-
|
| 70 |
-
|
| 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 |
-
|
| 85 |
-
|
| 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("
|
| 93 |
-
const
|
| 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 |
-
|
| 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 |
-
|
| 132 |
-
|
| 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 |
-
|
| 149 |
-
|
| 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 |
-
|
| 157 |
-
|
| 158 |
-
expect(resp.ok).toBe(true);
|
| 159 |
-
} finally {
|
| 160 |
-
await manager.stop();
|
| 161 |
-
}
|
| 162 |
-
});
|
| 163 |
});
|
| 164 |
|
| 165 |
-
|
| 166 |
-
|
| 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 |
-
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
messages,
|
| 214 |
-
|
| 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 |
+
]
|