mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
refactor: move install init logic into provider slices
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
ce8a2f3cf8
commit
93a1f2113f
11 changed files with 398 additions and 196 deletions
|
|
@ -8,6 +8,7 @@ from collections.abc import Iterable
|
|||
import click
|
||||
|
||||
from headroom import paths as _paths
|
||||
from headroom.providers.install_registry import build_install_target_envs
|
||||
|
||||
from .models import (
|
||||
ConfigScope,
|
||||
|
|
@ -95,44 +96,9 @@ def resolve_targets(
|
|||
return normalized
|
||||
|
||||
|
||||
def _copilot_env(port: int, backend: str) -> dict[str, str]:
|
||||
if backend == "anthropic":
|
||||
return {
|
||||
"COPILOT_PROVIDER_TYPE": "anthropic",
|
||||
"COPILOT_PROVIDER_BASE_URL": f"http://127.0.0.1:{port}",
|
||||
}
|
||||
return {
|
||||
"COPILOT_PROVIDER_TYPE": "openai",
|
||||
"COPILOT_PROVIDER_BASE_URL": f"http://127.0.0.1:{port}/v1",
|
||||
"COPILOT_PROVIDER_WIRE_API": "completions",
|
||||
}
|
||||
|
||||
|
||||
def build_tool_envs(port: int, backend: str, targets: list[str]) -> dict[str, dict[str, str]]:
|
||||
"""Build per-target environment variables for the selected tools."""
|
||||
|
||||
target_envs: dict[str, dict[str, str]] = {}
|
||||
if ToolTarget.CLAUDE.value in targets:
|
||||
target_envs[ToolTarget.CLAUDE.value] = {
|
||||
"ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
|
||||
}
|
||||
if ToolTarget.CODEX.value in targets:
|
||||
target_envs[ToolTarget.CODEX.value] = {
|
||||
"OPENAI_BASE_URL": f"http://127.0.0.1:{port}/v1",
|
||||
}
|
||||
if ToolTarget.AIDER.value in targets:
|
||||
target_envs[ToolTarget.AIDER.value] = {
|
||||
"OPENAI_API_BASE": f"http://127.0.0.1:{port}/v1",
|
||||
"ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
|
||||
}
|
||||
if ToolTarget.COPILOT.value in targets:
|
||||
target_envs[ToolTarget.COPILOT.value] = _copilot_env(port, backend)
|
||||
if ToolTarget.CURSOR.value in targets:
|
||||
target_envs[ToolTarget.CURSOR.value] = {
|
||||
"OPENAI_BASE_URL": f"http://127.0.0.1:{port}/v1",
|
||||
"ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
|
||||
}
|
||||
return target_envs
|
||||
return build_install_target_envs(port, backend, targets)
|
||||
|
||||
|
||||
def build_manifest(
|
||||
|
|
|
|||
|
|
@ -2,23 +2,21 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from headroom.providers.install_registry import (
|
||||
apply_provider_scope_mutations,
|
||||
revert_provider_scope_mutation,
|
||||
)
|
||||
|
||||
from .models import ConfigScope, DeploymentManifest, ManagedMutation, ToolTarget
|
||||
from .models import ConfigScope, DeploymentManifest, ManagedMutation
|
||||
from .paths import (
|
||||
claude_settings_path,
|
||||
codex_config_path,
|
||||
openclaw_config_path,
|
||||
unix_system_env_targets,
|
||||
unix_user_env_targets,
|
||||
)
|
||||
from .runtime import resolve_headroom_command
|
||||
|
||||
_ENV_MARKER_START = "# >>> headroom persistent env >>>"
|
||||
_ENV_MARKER_END = "# <<< headroom persistent env <<<"
|
||||
|
|
@ -26,12 +24,6 @@ _ENV_PATTERN = re.compile(
|
|||
re.escape(_ENV_MARKER_START) + r".*?" + re.escape(_ENV_MARKER_END),
|
||||
re.DOTALL,
|
||||
)
|
||||
_CODEX_MARKER_START = "# --- Headroom persistent provider ---"
|
||||
_CODEX_MARKER_END = "# --- end Headroom persistent provider ---"
|
||||
_CODEX_PATTERN = re.compile(
|
||||
re.escape(_CODEX_MARKER_START) + r".*?" + re.escape(_CODEX_MARKER_END),
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _merge_marker_block(file_path: Path, block: str, pattern: re.Pattern[str], marker: str) -> str:
|
||||
|
|
@ -153,112 +145,6 @@ def _remove_windows_env_scope(mutations: list[ManagedMutation]) -> None:
|
|||
subprocess.run(command, check=True)
|
||||
|
||||
|
||||
def _apply_claude_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
|
||||
path = claude_settings_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload: dict[str, object] = {}
|
||||
if path.exists():
|
||||
payload = json.loads(path.read_text())
|
||||
env = payload.get("env")
|
||||
env_map = dict(env) if isinstance(env, dict) else {}
|
||||
previous = {
|
||||
name: env_map.get(name) for name in manifest.tool_envs.get(ToolTarget.CLAUDE.value, {})
|
||||
}
|
||||
env_map.update(manifest.tool_envs[ToolTarget.CLAUDE.value])
|
||||
payload["env"] = env_map
|
||||
path.write_text(json.dumps(payload, indent=2) + "\n")
|
||||
return ManagedMutation(
|
||||
target=ToolTarget.CLAUDE.value,
|
||||
kind="json-env",
|
||||
path=str(path),
|
||||
data={"previous": previous},
|
||||
)
|
||||
|
||||
|
||||
def _revert_claude_provider_scope(mutation: ManagedMutation, values: dict[str, str]) -> None:
|
||||
if not mutation.path:
|
||||
return
|
||||
path = Path(mutation.path)
|
||||
if not path.exists():
|
||||
return
|
||||
payload = json.loads(path.read_text())
|
||||
env = payload.get("env")
|
||||
env_map = dict(env) if isinstance(env, dict) else {}
|
||||
previous: dict[str, object] = mutation.data.get("previous", {})
|
||||
for name in values:
|
||||
if previous.get(name) is None:
|
||||
env_map.pop(name, None)
|
||||
else:
|
||||
env_map[name] = previous[name]
|
||||
payload["env"] = env_map
|
||||
path.write_text(json.dumps(payload, indent=2) + "\n")
|
||||
|
||||
|
||||
def _apply_codex_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
|
||||
path = codex_config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
section = (
|
||||
f"{_CODEX_MARKER_START}\n"
|
||||
'model_provider = "headroom"\n\n'
|
||||
"[model_providers.headroom]\n"
|
||||
'name = "Headroom persistent proxy"\n'
|
||||
f'base_url = "http://127.0.0.1:{manifest.port}/v1"\n'
|
||||
'env_key = "OPENAI_API_KEY"\n'
|
||||
"requires_openai_auth = true\n"
|
||||
"supports_websockets = true\n"
|
||||
f"{_CODEX_MARKER_END}\n"
|
||||
)
|
||||
merged = _merge_marker_block(path, section, _CODEX_PATTERN, _CODEX_MARKER_START)
|
||||
path.write_text(merged)
|
||||
return ManagedMutation(target=ToolTarget.CODEX.value, kind="toml-block", path=str(path))
|
||||
|
||||
|
||||
def _revert_codex_provider_scope(mutation: ManagedMutation) -> None:
|
||||
if not mutation.path:
|
||||
return
|
||||
path = Path(mutation.path)
|
||||
if not path.exists():
|
||||
return
|
||||
content = path.read_text()
|
||||
if _CODEX_MARKER_START not in content:
|
||||
return
|
||||
path.write_text(_CODEX_PATTERN.sub("", content).strip() + "\n")
|
||||
|
||||
|
||||
def _invoke_openclaw(command: list[str]) -> None:
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
|
||||
def _apply_openclaw_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
|
||||
if not shutil_which("openclaw"):
|
||||
raise click.ClickException("openclaw not found in PATH; cannot apply provider scope.")
|
||||
command = [
|
||||
*resolve_headroom_command(),
|
||||
"wrap",
|
||||
"openclaw",
|
||||
"--no-auto-start",
|
||||
"--proxy-port",
|
||||
str(manifest.port),
|
||||
]
|
||||
_invoke_openclaw(command)
|
||||
return ManagedMutation(
|
||||
target=ToolTarget.OPENCLAW.value, kind="openclaw-wrap", path=str(openclaw_config_path())
|
||||
)
|
||||
|
||||
|
||||
def _revert_openclaw_provider_scope() -> None:
|
||||
if not shutil_which("openclaw"):
|
||||
return
|
||||
command = [*resolve_headroom_command(), "unwrap", "openclaw"]
|
||||
_invoke_openclaw(command)
|
||||
|
||||
|
||||
def shutil_which(name: str) -> str | None:
|
||||
from shutil import which
|
||||
|
||||
return which(name)
|
||||
|
||||
|
||||
def apply_mutations(manifest: DeploymentManifest) -> list[ManagedMutation]:
|
||||
"""Apply provider/user/system configuration for a deployment."""
|
||||
|
||||
|
|
@ -268,17 +154,10 @@ def apply_mutations(manifest: DeploymentManifest) -> list[ManagedMutation]:
|
|||
mutations.extend(_apply_windows_env_scope(manifest))
|
||||
else:
|
||||
mutations.extend(_apply_unix_env_scope(manifest))
|
||||
if ToolTarget.OPENCLAW.value in manifest.targets:
|
||||
mutations.append(_apply_openclaw_provider_scope(manifest))
|
||||
mutations.extend(apply_provider_scope_mutations(manifest))
|
||||
return mutations
|
||||
|
||||
if ToolTarget.CLAUDE.value in manifest.targets:
|
||||
mutations.append(_apply_claude_provider_scope(manifest))
|
||||
if ToolTarget.CODEX.value in manifest.targets:
|
||||
mutations.append(_apply_codex_provider_scope(manifest))
|
||||
if ToolTarget.OPENCLAW.value in manifest.targets:
|
||||
mutations.append(_apply_openclaw_provider_scope(manifest))
|
||||
return mutations
|
||||
return [*mutations, *apply_provider_scope_mutations(manifest)]
|
||||
|
||||
|
||||
def revert_mutations(manifest: DeploymentManifest) -> None:
|
||||
|
|
@ -292,11 +171,4 @@ def revert_mutations(manifest: DeploymentManifest) -> None:
|
|||
_remove_unix_env_scope(shell_mutations)
|
||||
|
||||
for mutation in manifest.mutations:
|
||||
if mutation.target == ToolTarget.CLAUDE.value and mutation.kind == "json-env":
|
||||
_revert_claude_provider_scope(
|
||||
mutation, manifest.tool_envs.get(ToolTarget.CLAUDE.value, {})
|
||||
)
|
||||
elif mutation.target == ToolTarget.CODEX.value and mutation.kind == "toml-block":
|
||||
_revert_codex_provider_scope(mutation)
|
||||
elif mutation.target == ToolTarget.OPENCLAW.value and mutation.kind == "openclaw-wrap":
|
||||
_revert_openclaw_provider_scope()
|
||||
revert_provider_scope_mutation(manifest, mutation)
|
||||
|
|
|
|||
12
headroom/providers/aider/install.py
Normal file
12
headroom/providers/aider/install.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"""Aider install-time helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .runtime import build_launch_env
|
||||
|
||||
|
||||
def build_install_env(*, port: int, backend: str) -> dict[str, str]:
|
||||
"""Build the persistent install environment for Aider."""
|
||||
del backend
|
||||
env, _lines = build_launch_env(port=port, environ={})
|
||||
return {key: env[key] for key in ("OPENAI_API_BASE", "ANTHROPIC_BASE_URL")}
|
||||
63
headroom/providers/claude/install.py
Normal file
63
headroom/providers/claude/install.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Claude install-time helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from headroom.install.models import ConfigScope, DeploymentManifest, ManagedMutation, ToolTarget
|
||||
from headroom.install.paths import claude_settings_path
|
||||
|
||||
from .runtime import proxy_base_url
|
||||
|
||||
|
||||
def build_install_env(*, port: int, backend: str) -> dict[str, str]:
|
||||
"""Build the persistent install environment for Claude."""
|
||||
del backend
|
||||
return {"ANTHROPIC_BASE_URL": proxy_base_url(port)}
|
||||
|
||||
|
||||
def apply_provider_scope(manifest: DeploymentManifest) -> ManagedMutation | None:
|
||||
"""Apply Claude provider-scope configuration when requested."""
|
||||
if manifest.scope != ConfigScope.PROVIDER.value:
|
||||
return None
|
||||
|
||||
path = claude_settings_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload: dict[str, object] = {}
|
||||
if path.exists():
|
||||
payload = json.loads(path.read_text())
|
||||
env = payload.get("env")
|
||||
env_map = dict(env) if isinstance(env, dict) else {}
|
||||
values = manifest.tool_envs.get(ToolTarget.CLAUDE.value, {})
|
||||
previous = {name: env_map.get(name) for name in values}
|
||||
env_map.update(values)
|
||||
payload["env"] = env_map
|
||||
path.write_text(json.dumps(payload, indent=2) + "\n")
|
||||
return ManagedMutation(
|
||||
target=ToolTarget.CLAUDE.value,
|
||||
kind="json-env",
|
||||
path=str(path),
|
||||
data={"previous": previous},
|
||||
)
|
||||
|
||||
|
||||
def revert_provider_scope(mutation: ManagedMutation, manifest: DeploymentManifest) -> None:
|
||||
"""Revert Claude provider-scope configuration."""
|
||||
if not mutation.path:
|
||||
return
|
||||
path = Path(mutation.path)
|
||||
if not path.exists():
|
||||
return
|
||||
payload = json.loads(path.read_text())
|
||||
env = payload.get("env")
|
||||
env_map = dict(env) if isinstance(env, dict) else {}
|
||||
previous: dict[str, object] = mutation.data.get("previous", {})
|
||||
values = manifest.tool_envs.get(ToolTarget.CLAUDE.value, {})
|
||||
for name in values:
|
||||
if previous.get(name) is None:
|
||||
env_map.pop(name, None)
|
||||
else:
|
||||
env_map[name] = previous[name]
|
||||
payload["env"] = env_map
|
||||
path.write_text(json.dumps(payload, indent=2) + "\n")
|
||||
68
headroom/providers/codex/install.py
Normal file
68
headroom/providers/codex/install.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Codex install-time helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from headroom.install.models import ConfigScope, DeploymentManifest, ManagedMutation, ToolTarget
|
||||
from headroom.install.paths import codex_config_path
|
||||
|
||||
from .runtime import proxy_base_url
|
||||
|
||||
_CODEX_MARKER_START = "# --- Headroom persistent provider ---"
|
||||
_CODEX_MARKER_END = "# --- end Headroom persistent provider ---"
|
||||
_CODEX_PATTERN = re.compile(
|
||||
re.escape(_CODEX_MARKER_START) + r".*?" + re.escape(_CODEX_MARKER_END),
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def build_install_env(*, port: int, backend: str) -> dict[str, str]:
|
||||
"""Build the persistent install environment for Codex."""
|
||||
del backend
|
||||
return {"OPENAI_BASE_URL": proxy_base_url(port)}
|
||||
|
||||
|
||||
def apply_provider_scope(manifest: DeploymentManifest) -> ManagedMutation | None:
|
||||
"""Apply Codex provider-scope configuration when requested."""
|
||||
if manifest.scope != ConfigScope.PROVIDER.value:
|
||||
return None
|
||||
|
||||
path = codex_config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
section = (
|
||||
f"{_CODEX_MARKER_START}\n"
|
||||
'model_provider = "headroom"\n\n'
|
||||
"[model_providers.headroom]\n"
|
||||
'name = "Headroom persistent proxy"\n'
|
||||
f'base_url = "{proxy_base_url(manifest.port)}"\n'
|
||||
'env_key = "OPENAI_API_KEY"\n'
|
||||
"requires_openai_auth = true\n"
|
||||
"supports_websockets = true\n"
|
||||
f"{_CODEX_MARKER_END}\n"
|
||||
)
|
||||
if path.exists():
|
||||
existing = path.read_text()
|
||||
if _CODEX_MARKER_START in existing:
|
||||
merged = _CODEX_PATTERN.sub(section, existing)
|
||||
else:
|
||||
merged = existing.rstrip() + "\n\n" + section + "\n"
|
||||
else:
|
||||
merged = section + "\n"
|
||||
path.write_text(merged)
|
||||
return ManagedMutation(target=ToolTarget.CODEX.value, kind="toml-block", path=str(path))
|
||||
|
||||
|
||||
def revert_provider_scope(mutation: ManagedMutation, manifest: DeploymentManifest) -> None:
|
||||
"""Revert Codex provider-scope configuration."""
|
||||
del manifest
|
||||
if not mutation.path:
|
||||
return
|
||||
path = Path(mutation.path)
|
||||
if not path.exists():
|
||||
return
|
||||
content = path.read_text()
|
||||
if _CODEX_MARKER_START not in content:
|
||||
return
|
||||
path.write_text(_CODEX_PATTERN.sub("", content).strip() + "\n")
|
||||
25
headroom/providers/copilot/install.py
Normal file
25
headroom/providers/copilot/install.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""Copilot install-time helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .wrap import build_launch_env, resolve_provider_type
|
||||
|
||||
|
||||
def build_install_env(*, port: int, backend: str) -> dict[str, str]:
|
||||
"""Build the persistent install environment for Copilot."""
|
||||
provider_type = resolve_provider_type(backend, "auto", {"HEADROOM_BACKEND": backend})
|
||||
env, _lines = build_launch_env(
|
||||
port=port,
|
||||
provider_type=provider_type,
|
||||
wire_api=None,
|
||||
environ={},
|
||||
)
|
||||
return {
|
||||
key: env[key]
|
||||
for key in (
|
||||
"COPILOT_PROVIDER_TYPE",
|
||||
"COPILOT_PROVIDER_BASE_URL",
|
||||
"COPILOT_PROVIDER_WIRE_API",
|
||||
)
|
||||
if key in env
|
||||
}
|
||||
15
headroom/providers/cursor/install.py
Normal file
15
headroom/providers/cursor/install.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"""Cursor install-time helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .runtime import build_proxy_targets
|
||||
|
||||
|
||||
def build_install_env(*, port: int, backend: str) -> dict[str, str]:
|
||||
"""Build the persistent install environment for Cursor."""
|
||||
del backend
|
||||
targets = build_proxy_targets(port)
|
||||
return {
|
||||
"OPENAI_BASE_URL": targets.openai_base_url,
|
||||
"ANTHROPIC_BASE_URL": targets.anthropic_base_url,
|
||||
}
|
||||
86
headroom/providers/install_registry.py
Normal file
86
headroom/providers/install_registry.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Install-time provider registry helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from headroom.install.models import DeploymentManifest, ManagedMutation
|
||||
from headroom.providers.aider.install import build_install_env as _build_aider_install_env
|
||||
from headroom.providers.claude.install import (
|
||||
apply_provider_scope as _apply_claude_provider_scope,
|
||||
)
|
||||
from headroom.providers.claude.install import (
|
||||
build_install_env as _build_claude_install_env,
|
||||
)
|
||||
from headroom.providers.claude.install import (
|
||||
revert_provider_scope as _revert_claude_provider_scope,
|
||||
)
|
||||
from headroom.providers.codex.install import (
|
||||
apply_provider_scope as _apply_codex_provider_scope,
|
||||
)
|
||||
from headroom.providers.codex.install import build_install_env as _build_codex_install_env
|
||||
from headroom.providers.codex.install import (
|
||||
revert_provider_scope as _revert_codex_provider_scope,
|
||||
)
|
||||
from headroom.providers.copilot.install import (
|
||||
build_install_env as _build_copilot_install_env,
|
||||
)
|
||||
from headroom.providers.cursor.install import build_install_env as _build_cursor_install_env
|
||||
from headroom.providers.openclaw.install import (
|
||||
apply_provider_scope as _apply_openclaw_provider_scope,
|
||||
)
|
||||
from headroom.providers.openclaw.install import (
|
||||
revert_provider_scope as _revert_openclaw_provider_scope,
|
||||
)
|
||||
|
||||
_InstallEnvBuilder = Callable[..., dict[str, str]]
|
||||
_ProviderScopeApplier = Callable[[DeploymentManifest], ManagedMutation | None]
|
||||
_ProviderScopeReverter = Callable[[ManagedMutation, DeploymentManifest], None]
|
||||
|
||||
_ENV_BUILDERS: dict[str, _InstallEnvBuilder] = {
|
||||
"claude": _build_claude_install_env,
|
||||
"copilot": _build_copilot_install_env,
|
||||
"codex": _build_codex_install_env,
|
||||
"aider": _build_aider_install_env,
|
||||
"cursor": _build_cursor_install_env,
|
||||
}
|
||||
|
||||
_PROVIDER_SCOPE_HANDLERS: dict[str, tuple[_ProviderScopeApplier, _ProviderScopeReverter]] = {
|
||||
"claude": (_apply_claude_provider_scope, _revert_claude_provider_scope),
|
||||
"codex": (_apply_codex_provider_scope, _revert_codex_provider_scope),
|
||||
"openclaw": (_apply_openclaw_provider_scope, _revert_openclaw_provider_scope),
|
||||
}
|
||||
|
||||
|
||||
def build_install_target_envs(
|
||||
port: int, backend: str, targets: list[str]
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""Build per-target install environment values via provider slices."""
|
||||
target_envs: dict[str, dict[str, str]] = {}
|
||||
for target in targets:
|
||||
builder = _ENV_BUILDERS.get(target)
|
||||
if builder is None:
|
||||
continue
|
||||
target_envs[target] = builder(port=port, backend=backend)
|
||||
return target_envs
|
||||
|
||||
|
||||
def apply_provider_scope_mutations(manifest: DeploymentManifest) -> list[ManagedMutation]:
|
||||
"""Apply provider-scope mutations owned by provider slices."""
|
||||
mutations: list[ManagedMutation] = []
|
||||
for target in manifest.targets:
|
||||
handlers = _PROVIDER_SCOPE_HANDLERS.get(target)
|
||||
if handlers is None:
|
||||
continue
|
||||
mutation = handlers[0](manifest)
|
||||
if mutation is not None:
|
||||
mutations.append(mutation)
|
||||
return mutations
|
||||
|
||||
|
||||
def revert_provider_scope_mutation(manifest: DeploymentManifest, mutation: ManagedMutation) -> None:
|
||||
"""Revert a provider-scope mutation via the owning provider slice."""
|
||||
handlers = _PROVIDER_SCOPE_HANDLERS.get(mutation.target)
|
||||
if handlers is None:
|
||||
return
|
||||
handlers[1](mutation, manifest)
|
||||
50
headroom/providers/openclaw/install.py
Normal file
50
headroom/providers/openclaw/install.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""OpenClaw install-time helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import click
|
||||
|
||||
from headroom.install.models import DeploymentManifest, ManagedMutation, ToolTarget
|
||||
from headroom.install.paths import openclaw_config_path
|
||||
from headroom.install.runtime import resolve_headroom_command
|
||||
|
||||
|
||||
def shutil_which(name: str) -> str | None:
|
||||
from shutil import which
|
||||
|
||||
return which(name)
|
||||
|
||||
|
||||
def _invoke_openclaw(command: list[str]) -> None:
|
||||
import subprocess
|
||||
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
|
||||
def apply_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
|
||||
"""Configure OpenClaw to route through the persistent proxy."""
|
||||
if not shutil_which("openclaw"):
|
||||
raise click.ClickException("openclaw not found in PATH; cannot apply provider scope.")
|
||||
command = [
|
||||
*resolve_headroom_command(),
|
||||
"wrap",
|
||||
"openclaw",
|
||||
"--no-auto-start",
|
||||
"--proxy-port",
|
||||
str(manifest.port),
|
||||
]
|
||||
_invoke_openclaw(command)
|
||||
return ManagedMutation(
|
||||
target=ToolTarget.OPENCLAW.value,
|
||||
kind="openclaw-wrap",
|
||||
path=str(openclaw_config_path()),
|
||||
)
|
||||
|
||||
|
||||
def revert_provider_scope(mutation: ManagedMutation, manifest: DeploymentManifest) -> None:
|
||||
"""Undo OpenClaw persistent proxy configuration."""
|
||||
del mutation, manifest
|
||||
if not shutil_which("openclaw"):
|
||||
return
|
||||
command = [*resolve_headroom_command(), "unwrap", "openclaw"]
|
||||
_invoke_openclaw(command)
|
||||
|
|
@ -44,6 +44,41 @@ def test_build_manifest_for_persistent_docker_sets_expected_defaults() -> None:
|
|||
assert "--memory" in manifest.proxy_args
|
||||
|
||||
|
||||
def test_build_manifest_uses_provider_slice_env_builders_for_all_supported_targets() -> None:
|
||||
manifest = build_manifest(
|
||||
profile="default",
|
||||
preset=InstallPreset.PERSISTENT_SERVICE.value,
|
||||
runtime_kind="python",
|
||||
scope="user",
|
||||
provider_mode="manual",
|
||||
targets=["claude", "copilot", "codex", "aider", "cursor"],
|
||||
port=9999,
|
||||
backend="anyllm",
|
||||
anyllm_provider="groq",
|
||||
region=None,
|
||||
proxy_mode="token",
|
||||
memory_enabled=False,
|
||||
telemetry_enabled=True,
|
||||
image="ghcr.io/chopratejas/headroom:latest",
|
||||
)
|
||||
|
||||
assert manifest.tool_envs["claude"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9999"
|
||||
assert manifest.tool_envs["codex"]["OPENAI_BASE_URL"] == "http://127.0.0.1:9999/v1"
|
||||
assert manifest.tool_envs["aider"] == {
|
||||
"OPENAI_API_BASE": "http://127.0.0.1:9999/v1",
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:9999",
|
||||
}
|
||||
assert manifest.tool_envs["cursor"] == {
|
||||
"OPENAI_BASE_URL": "http://127.0.0.1:9999/v1",
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:9999",
|
||||
}
|
||||
assert manifest.tool_envs["copilot"] == {
|
||||
"COPILOT_PROVIDER_TYPE": "openai",
|
||||
"COPILOT_PROVIDER_BASE_URL": "http://127.0.0.1:9999/v1",
|
||||
"COPILOT_PROVIDER_WIRE_API": "completions",
|
||||
}
|
||||
|
||||
|
||||
def test_resolve_targets_provider_scope_auto_excludes_copilot(monkeypatch) -> None:
|
||||
monkeypatch.setattr("headroom.install.planner.detect_targets", lambda: [])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from headroom.install.models import DeploymentManifest, ManagedMutation
|
||||
from headroom.install.providers import (
|
||||
_apply_claude_provider_scope,
|
||||
_apply_codex_provider_scope,
|
||||
_apply_windows_env_scope,
|
||||
_remove_windows_env_scope,
|
||||
_revert_claude_provider_scope,
|
||||
_revert_codex_provider_scope,
|
||||
)
|
||||
from headroom.install.providers import _apply_windows_env_scope, _remove_windows_env_scope
|
||||
from headroom.providers.claude.install import apply_provider_scope as apply_claude_provider_scope
|
||||
from headroom.providers.claude.install import revert_provider_scope as revert_claude_provider_scope
|
||||
from headroom.providers.codex.install import apply_provider_scope as apply_codex_provider_scope
|
||||
from headroom.providers.codex.install import revert_provider_scope as revert_codex_provider_scope
|
||||
|
||||
|
||||
def _manifest(tmp_path: Path) -> DeploymentManifest:
|
||||
|
|
@ -39,15 +37,18 @@ def test_apply_and_revert_claude_provider_scope(monkeypatch, tmp_path: Path) ->
|
|||
settings_path.write_text(
|
||||
json.dumps({"env": {"ANTHROPIC_API_KEY": "keep", "ANTHROPIC_BASE_URL": "https://old"}})
|
||||
)
|
||||
monkeypatch.setattr("headroom.install.providers.claude_settings_path", lambda: settings_path)
|
||||
monkeypatch.setattr(
|
||||
"headroom.providers.claude.install.claude_settings_path", lambda: settings_path
|
||||
)
|
||||
manifest = _manifest(tmp_path)
|
||||
|
||||
mutation = _apply_claude_provider_scope(manifest)
|
||||
mutation = apply_claude_provider_scope(manifest)
|
||||
payload = json.loads(settings_path.read_text())
|
||||
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
|
||||
assert payload["env"]["ANTHROPIC_API_KEY"] == "keep"
|
||||
|
||||
_revert_claude_provider_scope(mutation, manifest.tool_envs["claude"])
|
||||
assert mutation is not None
|
||||
revert_claude_provider_scope(mutation, manifest)
|
||||
reverted = json.loads(settings_path.read_text())
|
||||
assert reverted["env"]["ANTHROPIC_BASE_URL"] == "https://old"
|
||||
assert reverted["env"]["ANTHROPIC_API_KEY"] == "keep"
|
||||
|
|
@ -56,15 +57,16 @@ def test_apply_and_revert_claude_provider_scope(monkeypatch, tmp_path: Path) ->
|
|||
def test_apply_and_revert_codex_provider_scope(monkeypatch, tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "config.toml"
|
||||
config_path.write_text('model = "gpt-4o"\n')
|
||||
monkeypatch.setattr("headroom.install.providers.codex_config_path", lambda: config_path)
|
||||
monkeypatch.setattr("headroom.providers.codex.install.codex_config_path", lambda: config_path)
|
||||
manifest = _manifest(tmp_path)
|
||||
|
||||
mutation = _apply_codex_provider_scope(manifest)
|
||||
mutation = apply_codex_provider_scope(manifest)
|
||||
content = config_path.read_text()
|
||||
assert 'model_provider = "headroom"' in content
|
||||
assert 'base_url = "http://127.0.0.1:8787/v1"' in content
|
||||
|
||||
_revert_codex_provider_scope(mutation)
|
||||
assert mutation is not None
|
||||
revert_codex_provider_scope(mutation, manifest)
|
||||
reverted = config_path.read_text()
|
||||
assert 'model_provider = "headroom"' not in reverted
|
||||
assert reverted.strip() == 'model = "gpt-4o"'
|
||||
|
|
@ -72,25 +74,27 @@ def test_apply_and_revert_codex_provider_scope(monkeypatch, tmp_path: Path) -> N
|
|||
|
||||
def test_apply_openclaw_provider_scope_uses_manifest_port(monkeypatch, tmp_path: Path) -> None:
|
||||
recorded: list[list[str]] = []
|
||||
monkeypatch.setattr("headroom.install.providers.shutil_which", lambda name: "openclaw")
|
||||
monkeypatch.setattr("headroom.providers.openclaw.install.shutil_which", lambda name: "openclaw")
|
||||
monkeypatch.setattr(
|
||||
"headroom.install.providers.resolve_headroom_command",
|
||||
"headroom.providers.openclaw.install.resolve_headroom_command",
|
||||
lambda: ["headroom"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"headroom.install.providers._invoke_openclaw",
|
||||
"headroom.providers.openclaw.install._invoke_openclaw",
|
||||
lambda command: recorded.append(command),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"headroom.install.providers.openclaw_config_path",
|
||||
"headroom.providers.openclaw.install.openclaw_config_path",
|
||||
lambda: tmp_path / "openclaw.json",
|
||||
)
|
||||
manifest = _manifest(tmp_path)
|
||||
manifest.port = 9999
|
||||
|
||||
from headroom.install.providers import _apply_openclaw_provider_scope
|
||||
from headroom.providers.openclaw.install import (
|
||||
apply_provider_scope as apply_openclaw_provider_scope,
|
||||
)
|
||||
|
||||
_apply_openclaw_provider_scope(manifest)
|
||||
apply_openclaw_provider_scope(manifest)
|
||||
|
||||
assert recorded == [["headroom", "wrap", "openclaw", "--no-auto-start", "--proxy-port", "9999"]]
|
||||
|
||||
|
|
@ -165,11 +169,17 @@ def test_apply_mutations_runs_openclaw_for_user_scope(monkeypatch, tmp_path: Pat
|
|||
manifest.base_env = {"HEADROOM_PORT": "8787"}
|
||||
manifest.tool_envs = {}
|
||||
|
||||
monkeypatch.setattr("headroom.install.providers.os.name", "posix")
|
||||
monkeypatch.setattr("headroom.install.providers._apply_unix_env_scope", lambda deployment: [])
|
||||
if os.name == "nt":
|
||||
monkeypatch.setattr(
|
||||
"headroom.install.providers._apply_windows_env_scope", lambda deployment: []
|
||||
)
|
||||
else:
|
||||
monkeypatch.setattr(
|
||||
"headroom.install.providers._apply_unix_env_scope", lambda deployment: []
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"headroom.install.providers._apply_openclaw_provider_scope",
|
||||
lambda deployment: ManagedMutation(target="openclaw", kind="openclaw-wrap"),
|
||||
"headroom.install.providers.apply_provider_scope_mutations",
|
||||
lambda deployment: [ManagedMutation(target="openclaw", kind="openclaw-wrap")],
|
||||
)
|
||||
|
||||
from headroom.install.providers import apply_mutations
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue