mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
feat(wrap): add omp target (Oh My Pi) with models.yml override and unwrap (#1811)
## Description Adds `headroom wrap omp` / `headroom unwrap omp` — a one-command wrap for [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent) (`omp`), the pi-mono-lineage coding agent, as proposed in #1149. One honest correction to the issue: #1149 proposed reusing the `ANTHROPIC_BASE_URL` redirect from `wrap claude`. During implementation I probed that empirically and it turned out to be wrong — omp only reads `ANTHROPIC_BASE_URL` in its web-search helper; its **chat** endpoint comes from the model registry (`providers.anthropic.baseUrl` in `~/.omp/agent/models.yml`). With the env var pointed at a local probe server, omp's chat traffic still went straight to the real endpoint (0 probe hits); with a `models.yml` same-ID override, every request arrived at the probe (9/9 hits on `/v1/messages`). A same-ID override keeps omp's bundled Anthropic model catalog and stored credentials (both keyed by provider id `anthropic`), so only the endpoint moves. The wrap therefore injects a marker-fenced `providers.anthropic.baseUrl` override into `models.yml`, snapshotting the pre-wrap file **byte-for-byte** first, and `headroom unwrap omp` restores it exactly (or removes the file when the wrap created it) — the same durable-wrap + backup + unwrap contract `wrap codex` uses for `config.toml`. Closes #1149 ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/providers/omp/` (new provider slice): `models_yml_path()` (honors `PI_CODING_AGENT_DIR`), `inject_models_override()` (yaml-merge preserving user providers; pristine byte-for-byte backup, never re-snapshotted while managed), `restore_models_override()` (`restored` / `removed` / `noop`; never touches an unmanaged file), `build_launch_env()` - `headroom/cli/wrap.py`: `wrap omp` (mirrors the aider/vibe `_launch_tool` shape; rtk instructions into the project's `AGENTS.md`, which omp reads natively) and `unwrap omp` (restore models.yml + scrub rtk block + stop proxy) - `headroom/telemetry/context.py`: `omp` added to `_KNOWN_WRAP_AGENTS` so the stack slug reports `wrap_omp` instead of `unknown` - `README.md` (agent matrix row + unwrap list), `llms.txt`, `CHANGELOG.md` - `tests/test_cli/test_wrap_omp.py`: 16 tests (injection fresh/merge/re-inject, restore statuses incl. unmanaged-file safety, env passthrough, CLI wiring, unwrap flows) ## Testing - [ ] Unit tests pass (`pytest`) — all new + `test_cli` tests pass; the full suite carries **3 pre-existing failures** that reproduce identically on unmodified `origin/main` (same set, same asserts — see Test Output and the rebase-validation comment) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest -q # post-rebase, base4f22cbb03 failed, 7723 passed, 515 skipped in 262.64s FAILED tests/test_cli/test_wrap_claude_base_url.py::test_wrap_marker_is_stale_when_pid_reused FAILED tests/test_rtk_session_savings.py::test_rtk_reader_returns_none_on_nonzero_exit FAILED tests/test_rtk_session_savings.py::test_lean_ctx_reader_returns_none_on_failure_and_logs → all three reproduce identically on unmodified origin/main (4f22cbb0), run the same way (same worktree + venv, sources switched): 3 failed, 7707 passed — this branch = baseline + the 16 new tests, nothing else changes. (The pre-rebase run againste8151f05showed the same shape: one order-dependent flake that also reproduced on its baseline; these are env/order-dependent.) $ uv run pytest tests/test_cli/ -q # post-rebase 542 passed + 1 of the pre-existing failures above # includes the 16 new test_wrap_omp.py tests $ uv run ruff check . ; echo ruff-check-exit:$? All checks passed! ruff-check-exit:0 $ uv run ruff format --check . # post-rebase 1 pre-existing violation: headroom/proxy/handlers/anthropic.py — flagged identically on unmodified origin/main (not touched by this PR); every file this PR touches is clean $ uv run mypy headroom # post-rebase; output redirected to file; exit captured Success: no issues found in 409 source files mypy-exit:0 ``` ## Real Behavior Proof - Environment: macOS 15 (arm64, M1 Pro), Python 3.12.13 (uv venv, editable install incl. Rust `_core`), headroom @ this branch, base extras only (no `[ml]`), Anthropic account signed into omp. Initial proof ran on basee8151f05with omp 16.3.6 (`@oh-my-pi/pi-coding-agent` via bun); re-validated after the rebase onto4f22cbb0with omp 16.3.11 — fresh numbers in the rebase-validation comment. - Exact command / steps: four scenarios, run in this order — 1. Mechanism probe (why models.yml, not env): local HTTP probe server on `127.0.0.1:18999`; ran `omp -p "say ok" --model claude-fable-5 --no-session --no-tools` once with `ANTHROPIC_BASE_URL=http://127.0.0.1:18999`, once with `~/.omp/agent/models.yml` containing `providers.anthropic.baseUrl: http://127.0.0.1:18999`. 2. One-command path: `headroom wrap omp --no-rtk --port 8790 -- -p "Read CHANGELOG.md and count how many '### Fixed' headings it contains. Answer with just the number." --model claude-fable-5 --no-session --max-time 180` 3. Routing stats: separate proxy on :8788, wrap with `--no-proxy`, then `GET /stats`. 4. Restore: `headroom unwrap omp`, plus an isolated `PI_CODING_AGENT_DIR=/tmp/omp-agent-test` run with a pre-existing user `models.yml`, then `cmp` against the original. - Observed result: end-to-end routing through the proxy proven for every scenario — - Probe: env-var run → **0 probe hits**, omp answered normally (bypassed). models.yml run → **9 hits on `/v1/messages?beta=true`** with real Messages bodies. This is the routing mechanism the wrap uses. - One-command run: wrap started the proxy ("Proxy ready on http://127.0.0.1:8790"), wrote the override (`models.yml: providers.anthropic.baseUrl=http://127.0.0.1:8790/p/headroom-wrap-omp`), launched omp, and omp answered **"7"** (correct — real `read` tool work through the proxy). Proxy log for the session (3 requests, `anthropic_messages` path): ``` PERF model=claude-fable-5 msgs=1 tok_before=36 cache_read=0 cache_write=61939 cache_hit_pct=0 PERF model=claude-fable-5 msgs=3 tok_before=796 cache_read=0 cache_write=63308 cache_hit_pct=0 PERF model=claude-fable-5 msgs=5 tok_before=935 cache_read=63308 cache_write=215 cache_hit_pct=100 ``` Prompt caching survives the proxy (100% hit on the follow-up turn). - Routing stats (:8788 session): `requests.total: 2, by_provider: {"anthropic": 2}, by_model: {"claude-fable-5": 2}`, per-project prefix `/p/headroom-wrap-omp` attributed. - Unwrap: `Removed wrap-created models.yml` (file gone); isolated pre-existing-file run: backup created, user's `my-gw` provider preserved in the managed file, and after `unwrap omp` the restored file is **byte-identical** (`cmp` clean). - Compression: **not observed in this environment** — `tok_saved=0`, `transforms=router:noop` / `too_small`. Honest reading: omp minimizes its own tool outputs client-side (a 300-item JSON tool result reached the proxy at only ~657 tokens) and the `[ml]` text compressor wasn't installed; small print-mode payloads sit below crush thresholds, and passthrough-by-default is the documented safety contract. The wrap's value here is proven at the routing/lifecycle/cache layer; compression numbers will match whatever the proxy does for a given content mix. - Not tested: Windows / Linux; lean-ctx mode with omp (`HEADROOM_CONTEXT_TOOL=lean-ctx` — `lean-ctx init --agent omp` depends on lean-ctx recognizing the agent; failure degrades with a warning by design); long interactive (non `-p`) sessions; `--memory` / `--learn` / `--code-graph` flags combined with omp; OAuth-vs-API-key matrix beyond my local account. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes — all except the 3 documented pre-existing failures, which fail identically on unmodified origin/main - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A — terminal evidence inline above. ## Additional Notes - The models.yml override is regenerated from the pristine backup on every wrap, so re-running with a different `--port` updates the endpoint idempotently and the backup is never clobbered. - Scope note from #1149 stands: this routes omp's **Anthropic** provider family. omp's other providers (OpenAI-direct, Gemini, ...) resolve their endpoints from their own registry entries; users can already point those at Headroom with their own custom provider in `models.yml`. - `headroom/providers/omp/` deliberately contains no install-time / MCP pieces — this is the thin wrap + unwrap slice only. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com> Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
This commit is contained in:
parent
996c1174a8
commit
fcf455a7eb
10 changed files with 664 additions and 4 deletions
|
|
@ -103,6 +103,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Features
|
||||
|
||||
* **wrap:** add `headroom wrap omp` / `headroom unwrap omp` for Oh My Pi — points omp's built-in `anthropic` provider at the local proxy via a marker-fenced `providers.anthropic.baseUrl` override in `~/.omp/agent/models.yml`, snapshotting the pre-wrap file byte-for-byte and restoring it on unwrap. omp resolves its Anthropic chat endpoint from models.yml (`ANTHROPIC_BASE_URL` only feeds its web-search helper), and a same-ID override keeps omp's bundled model catalog and stored credentials ([#1149](https://github.com/headroomlabs-ai/headroom/issues/1149))
|
||||
* **compress:** expose `frozen_message_count` in library-mode `compress()` via a new `CompressConfig` field (default `0`, unchanged behavior). `read_lifecycle.apply()` already skips stale-Read replacements inside a frozen message prefix, but only the proxy handlers could pass it — `ContentRouter` reads it from transform kwargs and the public API never forwarded it. Library-mode callers that manage their own conversation loop can now stop transforms from rewriting messages already anchored in the provider's prompt cache, which would otherwise convert 0.1x cached prefix reads into full-price cache writes ([#2178](https://github.com/headroomlabs-ai/headroom/pull/2178)).
|
||||
|
||||
* **proxy:** report a new-content-relative input savings rate in `/stats`: `tokens.new_input_tokens` (provider-billed non-cache-read input: uncached + cache-write tokens, from response usage) and `tokens.new_input_savings_percent` (savings as a fraction of new input plus the tokens compression removed before they could be billed). The existing whole-request ratios recount the full transcript on every turn, so a 200-turn session counts its history 200x into the denominator and long-running cached sessions (especially 1M-context models, which never compact) dilute toward ~0% regardless of how well compression performs on content newly entering context. Purely additive; existing fields unchanged. Reports 0 when no cache usage data exists (e.g. providers without cache metrics) rather than dividing savings by themselves.
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ Headroom compresses everything your AI agent reads — tool outputs, logs, RAG c
|
|||
|
||||
- **Library** — `compress(messages)` in Python or TypeScript, inline in any app
|
||||
- **Proxy** — `headroom proxy --port 8787`, zero code changes, any language
|
||||
- **Agent wrap** — `headroom wrap claude|codex|grok|copilot|cursor|aider|opencode|cline|continue|goose|openhands|openclaw|vibe|zcode` in one command; undo with `headroom unwrap <tool>`
|
||||
- **Agent wrap** — `headroom wrap claude|codex|grok|copilot|cursor|aider|opencode|cline|continue|goose|openhands|openclaw|vibe|omp|zcode` in one command; undo with `headroom unwrap <tool>`
|
||||
- **MCP server** — `headroom_compress`, `headroom_retrieve`, `headroom_stats` for any MCP client
|
||||
- **Cross-agent memory** — shared store across Claude, Codex, Gemini, Grok, auto-dedup
|
||||
- **`headroom learn`** — mines failed sessions, writes corrections to `CLAUDE.local.md` (default, gitignored) or `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` / `GROK.md`
|
||||
|
|
@ -238,11 +238,12 @@ shows an **Output Tokens Saved** card next to input compression, labelled
|
|||
| Goose | ✅ | starts proxy + launches |
|
||||
| OpenHands | ✅ | starts proxy + launches |
|
||||
| Mistral Vibe | ✅ | starts proxy + launches |
|
||||
| Oh My Pi | ✅ | injects config · starts proxy + launches |
|
||||
| Cortex Code | Library only | 60–65% savings (library mode; no `wrap`) |
|
||||
| ZCode | ✅ | starts proxy and prints base URLs for ZCode settings |
|
||||
|
||||
Any OpenAI-compatible client works via `headroom proxy`. MCP-native: `headroom mcp install`.
|
||||
Undo durable wrapping with `headroom unwrap <tool>` (supports: `claude`, `copilot`, `codex`, `grok`, `opencode`, `openclaw`, `zcode`).
|
||||
Undo durable wrapping with `headroom unwrap <tool>` (supports: `claude`, `copilot`, `codex`, `grok`, `omp`, `opencode`, `openclaw`, `zcode`).
|
||||
Registry authors can use the canonical [`server.json`](server.json) in the repo root instead of reconstructing the `headroom mcp serve` contract from prose.
|
||||
|
||||
### GitHub Copilot CLI subscription mode
|
||||
|
|
|
|||
|
|
@ -111,6 +111,10 @@ from headroom.providers.copilot import (
|
|||
from headroom.providers.cursor import render_setup_lines as _render_cursor_setup_lines
|
||||
from headroom.providers.grok import build_launch_env as _build_grok_launch_env
|
||||
from headroom.providers.mistral_vibe import build_launch_env as _build_mistral_vibe_launch_env
|
||||
from headroom.providers.omp import build_launch_env as _build_omp_launch_env
|
||||
from headroom.providers.omp import inject_models_override as _inject_omp_models_override
|
||||
from headroom.providers.omp import models_yml_path as _omp_models_yml_path
|
||||
from headroom.providers.omp import restore_models_override as _restore_omp_models_override
|
||||
from headroom.providers.openclaw import (
|
||||
OPENCLAW_NPM_PACKAGE,
|
||||
)
|
||||
|
|
@ -3865,6 +3869,8 @@ def wrap() -> None:
|
|||
headroom wrap openhands # OpenHands CLI
|
||||
headroom wrap openclaw # OpenClaw plugin bootstrap
|
||||
headroom wrap opencode # OpenCode CLI
|
||||
headroom wrap omp # Oh My Pi CLI
|
||||
headroom wrap zcode # ZCode desktop app setup
|
||||
|
||||
\b
|
||||
`wrap` vs `proxy`:
|
||||
|
|
@ -6904,6 +6910,142 @@ def unwrap_codex(port: int, no_stop_proxy: bool) -> None:
|
|||
click.echo()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Oh My Pi (omp)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@wrap.command(context_settings={"ignore_unknown_options": True})
|
||||
@click.option(
|
||||
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
|
||||
)
|
||||
@click.option(
|
||||
"--no-context-tool",
|
||||
"--no-rtk",
|
||||
"no_rtk",
|
||||
is_flag=True,
|
||||
help="Skip CLI context-tool setup",
|
||||
)
|
||||
@click.option(
|
||||
"--code-graph",
|
||||
is_flag=True,
|
||||
help="Enable code graph indexing via codebase-memory-mcp (optional)",
|
||||
)
|
||||
@click.option("--no-proxy", is_flag=True, help="Skip proxy startup (use existing proxy)")
|
||||
@click.option("--learn", is_flag=True, help="Enable live traffic learning")
|
||||
@click.option("--memory", is_flag=True, help="Enable persistent cross-session memory")
|
||||
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
|
||||
@click.option("--prepare-only", is_flag=True, hidden=True)
|
||||
@click.argument("omp_args", nargs=-1, type=click.UNPROCESSED)
|
||||
def omp(
|
||||
port: int,
|
||||
no_rtk: bool,
|
||||
code_graph: bool,
|
||||
no_proxy: bool,
|
||||
learn: bool,
|
||||
memory: bool,
|
||||
verbose: bool,
|
||||
prepare_only: bool,
|
||||
omp_args: tuple,
|
||||
) -> None:
|
||||
"""Launch Oh My Pi (omp) through Headroom proxy.
|
||||
|
||||
\b
|
||||
Points omp's built-in `anthropic` provider at Headroom by injecting a
|
||||
marker-fenced `providers.anthropic.baseUrl` override into
|
||||
~/.omp/agent/models.yml (pre-wrap file backed up byte-for-byte; undo with
|
||||
`headroom unwrap omp`). omp resolves its Anthropic chat endpoint from
|
||||
models.yml — ANTHROPIC_BASE_URL only affects its web-search helper — and a
|
||||
same-ID override keeps omp's bundled model catalog and stored credentials.
|
||||
omp's other providers (OpenAI-direct, Gemini, ...) keep their normal
|
||||
endpoints; route those via your own custom provider in models.yml.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
headroom wrap omp # Start proxy + context tool + omp
|
||||
headroom wrap omp -- -p "fix the bug" # omp in non-interactive print mode
|
||||
headroom wrap omp -- --model opus # Pick a model (fuzzy match)
|
||||
headroom wrap omp --no-context-tool # Skip CLI context-tool setup
|
||||
headroom unwrap omp # Restore pre-wrap models.yml
|
||||
"""
|
||||
# Setup CLI context tool for omp — it reads AGENTS.md from the project root.
|
||||
if not no_rtk:
|
||||
if _selected_context_tool() == _CONTEXT_TOOL_LEAN_CTX:
|
||||
click.echo(" Setting up lean-ctx for omp...")
|
||||
_setup_lean_ctx_agent("omp", verbose=verbose)
|
||||
else:
|
||||
click.echo(" Setting up rtk for omp...")
|
||||
rtk_path = _ensure_rtk_binary(verbose=verbose)
|
||||
if rtk_path:
|
||||
agents_md = Path.cwd() / "AGENTS.md"
|
||||
_inject_rtk_instructions(agents_md, verbose=verbose)
|
||||
|
||||
if prepare_only:
|
||||
_inject_omp_models_override(port, _project_name_from_cwd())
|
||||
return
|
||||
|
||||
omp_bin = shutil.which("omp")
|
||||
if not omp_bin:
|
||||
click.echo("Error: 'omp' not found in PATH.")
|
||||
click.echo("Install Oh My Pi: npm install -g @oh-my-pi/pi-coding-agent")
|
||||
raise SystemExit(1)
|
||||
|
||||
env, env_vars_display = _build_omp_launch_env(
|
||||
port, os.environ, project=_project_name_from_cwd()
|
||||
)
|
||||
|
||||
# Durable endpoint redirect (survives omp-spawned child sessions, which
|
||||
# re-read models.yml rather than inheriting a parent env) — same durable
|
||||
# wrap + backup + unwrap contract as the Codex config.toml injection.
|
||||
models_file, _ = _inject_omp_models_override(port, _project_name_from_cwd())
|
||||
click.echo(f" models.yml override written: {models_file}")
|
||||
|
||||
_launch_tool(
|
||||
binary=omp_bin,
|
||||
args=omp_args,
|
||||
env=env,
|
||||
port=port,
|
||||
no_proxy=no_proxy,
|
||||
tool_label="OMP",
|
||||
env_vars_display=env_vars_display,
|
||||
learn=learn,
|
||||
memory=memory,
|
||||
agent_type="omp",
|
||||
code_graph=code_graph,
|
||||
)
|
||||
|
||||
|
||||
@unwrap.command("omp")
|
||||
@click.option(
|
||||
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
|
||||
)
|
||||
@click.option("--no-stop-proxy", is_flag=True, help="Do not stop the local Headroom proxy")
|
||||
def unwrap_omp(port: int, no_stop_proxy: bool) -> None:
|
||||
"""Undo ``headroom wrap omp`` edits to omp's models.yml.
|
||||
|
||||
Restores the byte-for-byte pre-wrap backup when one exists, or removes the
|
||||
wrap-created file when there was no models.yml before wrapping. A
|
||||
models.yml the wrap does not manage is never touched. Also removes the
|
||||
marker-fenced rtk guidance from the current project's AGENTS.md.
|
||||
"""
|
||||
status = _restore_omp_models_override()
|
||||
if status == "restored":
|
||||
click.echo(f" Restored pre-wrap models.yml from backup: {_omp_models_yml_path()}")
|
||||
elif status == "removed":
|
||||
click.echo(f" Removed wrap-created models.yml: {_omp_models_yml_path()}")
|
||||
else:
|
||||
click.echo(" No Headroom-managed models.yml found — nothing to restore.")
|
||||
|
||||
if _remove_rtk_instructions(Path.cwd() / "AGENTS.md"):
|
||||
click.echo(" Removed Headroom rtk instructions from AGENTS.md.")
|
||||
|
||||
click.echo()
|
||||
click.echo("✓ omp is no longer routed through the Headroom proxy.")
|
||||
if not no_stop_proxy and status != "noop":
|
||||
_echo_unwrap_proxy_stop_status(_stop_local_proxy_for_unwrap(port), port)
|
||||
click.echo()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Grok CLI (unwrap)
|
||||
# =============================================================================
|
||||
|
|
|
|||
23
headroom/providers/omp/__init__.py
Normal file
23
headroom/providers/omp/__init__.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""Oh My Pi (omp)-specific provider helpers."""
|
||||
|
||||
from .runtime import (
|
||||
MANAGED_MARKER,
|
||||
backup_path,
|
||||
build_launch_env,
|
||||
inject_models_override,
|
||||
is_managed,
|
||||
models_yml_path,
|
||||
proxy_anthropic_base_url,
|
||||
restore_models_override,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MANAGED_MARKER",
|
||||
"backup_path",
|
||||
"build_launch_env",
|
||||
"inject_models_override",
|
||||
"is_managed",
|
||||
"models_yml_path",
|
||||
"proxy_anthropic_base_url",
|
||||
"restore_models_override",
|
||||
]
|
||||
166
headroom/providers/omp/runtime.py
Normal file
166
headroom/providers/omp/runtime.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"""Runtime helpers for Oh My Pi (omp) integrations.
|
||||
|
||||
omp resolves its Anthropic chat endpoint from the model registry
|
||||
(``providers.anthropic.baseUrl`` in ``~/.omp/agent/models.yml``), not from
|
||||
``ANTHROPIC_BASE_URL`` — that env var only feeds omp's web-search helper.
|
||||
Verified empirically: with ``ANTHROPIC_BASE_URL`` pointed at a local probe
|
||||
server, omp's chat traffic still went to the real Anthropic endpoint; with a
|
||||
``models.yml`` same-ID override, every ``/v1/messages`` request arrived at the
|
||||
probe. A same-ID override keeps omp's bundled Anthropic model catalog and
|
||||
stored credentials (both keyed by provider id ``anthropic``), so only the
|
||||
endpoint moves.
|
||||
|
||||
The wrap therefore injects a marker-fenced ``providers.anthropic.baseUrl``
|
||||
override into ``models.yml``, snapshotting the pre-wrap file byte-for-byte
|
||||
first — the same durable-wrap + backup + ``headroom unwrap`` contract the
|
||||
Codex wrap uses for ``config.toml``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
from headroom.providers.claude import proxy_base_url as claude_proxy_base_url
|
||||
from headroom.proxy.project_context import with_project_prefix
|
||||
|
||||
MANAGED_MARKER = "# managed by `headroom wrap omp`"
|
||||
_MANAGED_HEADER = (
|
||||
f"{MANAGED_MARKER} — do not hand-edit while wrapped.\n"
|
||||
"# `headroom unwrap omp` restores the pre-wrap file (or removes this one\n"
|
||||
"# if it did not exist). The original is kept at <models.yml.headroom-backup>.\n"
|
||||
)
|
||||
BACKUP_SUFFIX = ".headroom-backup"
|
||||
|
||||
|
||||
def models_yml_path() -> Path:
|
||||
"""Path to omp's model/provider registry file.
|
||||
|
||||
``PI_CODING_AGENT_DIR`` relocates omp's ``~/.omp/agent`` state directory
|
||||
(per omp's environment-variable reference); ``models.yml`` moves with it.
|
||||
"""
|
||||
base = os.environ.get("PI_CODING_AGENT_DIR", "").strip()
|
||||
agent_dir = Path(base).expanduser() if base else Path.home() / ".omp" / "agent"
|
||||
return agent_dir / "models.yml"
|
||||
|
||||
|
||||
def backup_path(models_file: Path) -> Path:
|
||||
"""Backup location for the pre-wrap ``models.yml`` snapshot."""
|
||||
return models_file.with_name(models_file.name + BACKUP_SUFFIX)
|
||||
|
||||
|
||||
def proxy_anthropic_base_url(port: int, project: str | None = None) -> str:
|
||||
"""Proxy base URL omp's ``anthropic`` provider is pointed at.
|
||||
|
||||
``project`` (the wrap launch directory) is encoded as a ``/p/<name>``
|
||||
base-URL prefix — same as the Aider wrap — so the proxy attributes
|
||||
savings per project. omp appends ``/v1/messages`` after any path segments
|
||||
(its docs show path-carrying Anthropic base URLs, e.g. the Cloudflare AI
|
||||
Gateway example), and the proxy strips the prefix on arrival.
|
||||
"""
|
||||
return with_project_prefix(claude_proxy_base_url(port), project)
|
||||
|
||||
|
||||
def is_managed(models_file: Path) -> bool:
|
||||
"""Whether ``models_file`` is currently a wrap-managed override."""
|
||||
if not models_file.exists():
|
||||
return False
|
||||
try:
|
||||
head = models_file.read_bytes()
|
||||
except OSError:
|
||||
return False
|
||||
return MANAGED_MARKER.encode("utf-8") in head
|
||||
|
||||
|
||||
def inject_models_override(port: int, project: str | None = None) -> tuple[Path, str]:
|
||||
"""Point ``providers.anthropic.baseUrl`` at the local proxy.
|
||||
|
||||
Returns ``(models_file, base_url)``.
|
||||
|
||||
* First injection snapshots the user's pre-wrap file byte-for-byte to
|
||||
``models.yml.headroom-backup`` so unwrap can restore it exactly. A file
|
||||
that is already wrap-managed is never re-snapshotted (that would clobber
|
||||
the pristine backup — same guard as the Codex config snapshot).
|
||||
* The managed file is regenerated from the backup (or from scratch when
|
||||
the user had no ``models.yml``) on every call, so re-running with a
|
||||
different ``--port`` updates the override idempotently.
|
||||
* Any user-defined providers/models from the pre-wrap file are preserved:
|
||||
the override only deep-sets ``providers.anthropic.baseUrl``.
|
||||
"""
|
||||
import yaml # type: ignore[import-untyped] # PyYAML ships no stubs; lint env installs no deps
|
||||
|
||||
models_file = models_yml_path()
|
||||
backup = backup_path(models_file)
|
||||
base_url = proxy_anthropic_base_url(port, project)
|
||||
|
||||
original_bytes: bytes | None = None
|
||||
if backup.exists():
|
||||
original_bytes = backup.read_bytes()
|
||||
elif models_file.exists():
|
||||
if is_managed(models_file):
|
||||
# Managed file without a backup: we created it from scratch —
|
||||
# regenerate from scratch rather than merging our own output.
|
||||
original_bytes = None
|
||||
else:
|
||||
# Snapshot as raw bytes so the unwrap restore is byte-for-byte
|
||||
# even for non-UTF-8 or newline-sensitive files (text-mode I/O
|
||||
# would translate newlines on Windows).
|
||||
backup.parent.mkdir(parents=True, exist_ok=True)
|
||||
original_bytes = models_file.read_bytes()
|
||||
backup.write_bytes(original_bytes)
|
||||
|
||||
data: dict = {}
|
||||
if original_bytes:
|
||||
loaded = yaml.safe_load(original_bytes.decode("utf-8", errors="replace"))
|
||||
if isinstance(loaded, dict):
|
||||
data = loaded
|
||||
providers = data.setdefault("providers", {})
|
||||
if not isinstance(providers, dict): # malformed user value: keep it in backup only
|
||||
providers = {}
|
||||
data["providers"] = providers
|
||||
anthropic = providers.setdefault("anthropic", {})
|
||||
if not isinstance(anthropic, dict):
|
||||
anthropic = {}
|
||||
providers["anthropic"] = anthropic
|
||||
anthropic["baseUrl"] = base_url
|
||||
|
||||
models_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
rendered = yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
|
||||
models_file.write_text(_MANAGED_HEADER + rendered, encoding="utf-8", newline="\n")
|
||||
return models_file, base_url
|
||||
|
||||
|
||||
def restore_models_override() -> str:
|
||||
"""Undo :func:`inject_models_override`.
|
||||
|
||||
Returns one of ``"restored"`` (backup moved back), ``"removed"``
|
||||
(wrap-created file deleted), or ``"noop"`` (nothing wrap-managed found).
|
||||
Never touches a ``models.yml`` the wrap does not manage.
|
||||
"""
|
||||
models_file = models_yml_path()
|
||||
backup = backup_path(models_file)
|
||||
|
||||
if backup.exists():
|
||||
backup.replace(models_file)
|
||||
return "restored"
|
||||
if is_managed(models_file):
|
||||
models_file.unlink()
|
||||
return "removed"
|
||||
return "noop"
|
||||
|
||||
|
||||
def build_launch_env(
|
||||
port: int,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
project: str | None = None,
|
||||
) -> tuple[dict[str, str], list[str]]:
|
||||
"""Build the launch environment and display lines for the omp wrap.
|
||||
|
||||
The endpoint redirect itself lives in ``models.yml`` (see module
|
||||
docstring), so the environment passes through unchanged; the display
|
||||
lines surface where the override went.
|
||||
"""
|
||||
env = dict(environ or os.environ)
|
||||
base_url = proxy_anthropic_base_url(port, project)
|
||||
return env, [f"models.yml: providers.anthropic.baseUrl={base_url}"]
|
||||
|
|
@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
_KNOWN_WRAP_AGENTS = frozenset(
|
||||
{"claude", "copilot", "codex", "aider", "cursor", "openclaw", "opencode"}
|
||||
{"claude", "copilot", "codex", "aider", "cursor", "omp", "openclaw", "opencode"}
|
||||
)
|
||||
|
||||
# Stack slugs must start with a letter and contain only [a-z0-9_], max 64 chars.
|
||||
|
|
|
|||
2
llms.txt
2
llms.txt
|
|
@ -21,7 +21,7 @@ The canonical, always-current documentation index lives at the docs site below.
|
|||
- TypeScript / Node: `npm install headroom-ai` (or `pnpm add headroom-ai`, `bun add headroom-ai`)
|
||||
- Docker: `docker run -p 8787:8787 ghcr.io/chopratejas/headroom:latest`
|
||||
- Run the proxy: `headroom proxy --port 8787` then point any client at `http://127.0.0.1:8787`
|
||||
- Wrap an agent in one command: `headroom wrap claude` (also: `codex`, `copilot`, `cursor`, `aider`, `opencode`, `cline`, `continue`, `goose`, `openhands`, `openclaw`, `vibe`)
|
||||
- Wrap an agent in one command: `headroom wrap claude` (also: `codex`, `copilot`, `cursor`, `aider`, `opencode`, `cline`, `continue`, `goose`, `openhands`, `openclaw`, `vibe`, `omp`)
|
||||
|
||||
## Entry points
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ dependencies = [
|
|||
"rich>=13.0.0", # Rich terminal output
|
||||
"opentelemetry-api>=1.24.0", # Safe no-op OTEL API for instrumentation
|
||||
"ast-grep-cli>=0.30.0", # AST-aware code slicing (CodeCompressor); binary wheel
|
||||
"pyyaml>=6.0", # omp wrap: parse/merge omp's models.yml registry
|
||||
"tomli>=2.0.0; python_version < '3.11'", # tomllib backport for helper scripts
|
||||
]
|
||||
|
||||
|
|
|
|||
324
tests/test_cli/test_wrap_omp.py
Normal file
324
tests/test_cli/test_wrap_omp.py
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
"""Tests for `headroom wrap omp` / `headroom unwrap omp`.
|
||||
|
||||
Covers the omp runtime override contract (fresh-create vs merge-preserving
|
||||
injection, pristine backups, re-injection idempotency, restore statuses) and
|
||||
the CLI wiring that drives it. Every test isolates omp's agent directory via
|
||||
``PI_CODING_AGENT_DIR`` and runs from a tmp cwd so the real ``~/.omp`` is
|
||||
never touched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from click.testing import CliRunner
|
||||
|
||||
from headroom.cli.main import main
|
||||
from headroom.cli.wrap import _inject_rtk_instructions
|
||||
from headroom.providers.omp import (
|
||||
MANAGED_MARKER,
|
||||
backup_path,
|
||||
build_launch_env,
|
||||
inject_models_override,
|
||||
models_yml_path,
|
||||
restore_models_override,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner() -> CliRunner:
|
||||
return CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def omp_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Isolate omp's agent dir under tmp_path and run from a tmp cwd.
|
||||
|
||||
Returns the ``models.yml`` path the runtime resolves to.
|
||||
"""
|
||||
agent_dir = tmp_path / "omp-agent"
|
||||
agent_dir.mkdir()
|
||||
monkeypatch.setenv("PI_CODING_AGENT_DIR", str(agent_dir))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return agent_dir / "models.yml"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# runtime: path resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_models_yml_path_honors_pi_coding_agent_dir(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
custom = tmp_path / "relocated-agent"
|
||||
monkeypatch.setenv("PI_CODING_AGENT_DIR", str(custom))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert models_yml_path() == custom / "models.yml"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# runtime: injection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_inject_fresh_create_writes_managed_marker_and_no_backup(omp_home: Path) -> None:
|
||||
models_file, base_url = inject_models_override(8787, "proj")
|
||||
|
||||
assert models_file == omp_home
|
||||
assert base_url == "http://127.0.0.1:8787/p/proj"
|
||||
|
||||
text = models_file.read_text(encoding="utf-8")
|
||||
assert MANAGED_MARKER in text
|
||||
assert yaml.safe_load(text)["providers"]["anthropic"]["baseUrl"] == base_url
|
||||
|
||||
# Nothing pre-existed, so there is nothing to snapshot.
|
||||
assert not backup_path(models_file).exists()
|
||||
|
||||
|
||||
def test_inject_over_existing_backs_up_pristine_and_merges(omp_home: Path) -> None:
|
||||
original = (
|
||||
"providers:\n"
|
||||
" anthropic:\n"
|
||||
" apiKey: sk-user-secret\n"
|
||||
" openai:\n"
|
||||
" baseUrl: https://api.openai.com/v1\n"
|
||||
"models:\n"
|
||||
" - id: my-custom-model\n"
|
||||
)
|
||||
omp_home.write_bytes(original.encode("utf-8"))
|
||||
|
||||
_, base_url = inject_models_override(8787, "proj")
|
||||
|
||||
# Pre-wrap file is snapshotted byte-for-byte.
|
||||
assert backup_path(omp_home).read_bytes() == original.encode("utf-8")
|
||||
|
||||
merged = yaml.safe_load(omp_home.read_text(encoding="utf-8"))
|
||||
assert merged["providers"]["anthropic"]["baseUrl"] == base_url
|
||||
# Only anthropic.baseUrl is set; every other user key survives the merge.
|
||||
assert merged["providers"]["anthropic"]["apiKey"] == "sk-user-secret"
|
||||
assert merged["providers"]["openai"]["baseUrl"] == "https://api.openai.com/v1"
|
||||
assert merged["models"] == [{"id": "my-custom-model"}]
|
||||
assert MANAGED_MARKER in omp_home.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_reinject_new_port_regenerates_from_pristine_backup(omp_home: Path) -> None:
|
||||
original = "providers:\n anthropic:\n apiKey: sk-user-secret\n"
|
||||
omp_home.write_bytes(original.encode("utf-8"))
|
||||
backup = backup_path(omp_home)
|
||||
|
||||
inject_models_override(8787, "proj")
|
||||
assert backup.read_bytes() == original.encode("utf-8")
|
||||
|
||||
_, base_url_9999 = inject_models_override(9999, "proj")
|
||||
|
||||
# Re-injection never clobbers the pristine pre-wrap backup.
|
||||
assert backup.read_bytes() == original.encode("utf-8")
|
||||
|
||||
merged = yaml.safe_load(omp_home.read_text(encoding="utf-8"))
|
||||
assert base_url_9999 == "http://127.0.0.1:9999/p/proj"
|
||||
assert merged["providers"]["anthropic"]["baseUrl"] == base_url_9999
|
||||
# Regenerated from the backup, so user creds still survive the new port.
|
||||
assert merged["providers"]["anthropic"]["apiKey"] == "sk-user-secret"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# runtime: restore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_restore_restores_pristine_and_removes_backup(omp_home: Path) -> None:
|
||||
original = "providers:\n anthropic:\n apiKey: sk-user-secret\n"
|
||||
omp_home.write_bytes(original.encode("utf-8"))
|
||||
inject_models_override(8787, "proj")
|
||||
|
||||
assert restore_models_override() == "restored"
|
||||
assert omp_home.read_bytes() == original.encode("utf-8")
|
||||
assert not backup_path(omp_home).exists()
|
||||
|
||||
|
||||
def test_restore_removes_wrap_created_file(omp_home: Path) -> None:
|
||||
inject_models_override(8787, "proj") # fresh create → no backup
|
||||
assert omp_home.exists()
|
||||
|
||||
assert restore_models_override() == "removed"
|
||||
assert not omp_home.exists()
|
||||
assert not backup_path(omp_home).exists()
|
||||
|
||||
|
||||
def test_restore_noop_when_nothing_managed(omp_home: Path) -> None:
|
||||
assert restore_models_override() == "noop"
|
||||
|
||||
|
||||
def test_restore_leaves_unmanaged_file_untouched(omp_home: Path) -> None:
|
||||
user_content = "providers:\n anthropic:\n apiKey: sk-user-secret\n"
|
||||
omp_home.write_bytes(user_content.encode("utf-8"))
|
||||
|
||||
assert restore_models_override() == "noop"
|
||||
# A models.yml the wrap does not manage is never modified or deleted.
|
||||
assert omp_home.read_bytes() == user_content.encode("utf-8")
|
||||
assert not backup_path(omp_home).exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# runtime: launch env
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_launch_env_passes_env_through_and_emits_display(omp_home: Path) -> None:
|
||||
source = {"PATH": "/usr/bin", "ANTHROPIC_BASE_URL": "https://api.anthropic.com"}
|
||||
env, display = build_launch_env(8787, source, project="proj")
|
||||
|
||||
# The redirect lives in models.yml, so env is a verbatim copy — notably
|
||||
# ANTHROPIC_BASE_URL is NOT rewritten to the proxy.
|
||||
assert env == source
|
||||
assert env is not source # a copy, so caller's environ can't be mutated
|
||||
assert display == ["models.yml: providers.anthropic.baseUrl=http://127.0.0.1:8787/p/proj"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI: wrap omp
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_wrap_omp_missing_binary_exits_with_install_hint(runner: CliRunner, omp_home: Path) -> None:
|
||||
with patch("headroom.cli.wrap.shutil.which", return_value=None):
|
||||
result = runner.invoke(main, ["wrap", "omp", "--no-rtk"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "npm install -g @oh-my-pi/pi-coding-agent" in result.output
|
||||
# Fail fast before mutating omp's config.
|
||||
assert not omp_home.exists()
|
||||
|
||||
|
||||
def test_wrap_omp_happy_path_injects_before_launch(runner: CliRunner, omp_home: Path) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_launch_tool(**kwargs: object) -> None:
|
||||
captured.update(kwargs)
|
||||
# Prove models.yml is on disk BEFORE omp is launched.
|
||||
captured["models_text_at_launch"] = (
|
||||
omp_home.read_text(encoding="utf-8") if omp_home.exists() else None
|
||||
)
|
||||
|
||||
with (
|
||||
patch("headroom.cli.wrap.shutil.which", return_value="omp"),
|
||||
patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool),
|
||||
):
|
||||
result = runner.invoke(main, ["wrap", "omp", "--no-rtk", "--", "-p", "fix the bug"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["tool_label"] == "OMP"
|
||||
assert captured["agent_type"] == "omp"
|
||||
assert captured["args"] == ("-p", "fix the bug")
|
||||
|
||||
text_at_launch = captured["models_text_at_launch"]
|
||||
assert isinstance(text_at_launch, str)
|
||||
assert MANAGED_MARKER in text_at_launch
|
||||
base_url = yaml.safe_load(text_at_launch)["providers"]["anthropic"]["baseUrl"]
|
||||
assert base_url.startswith("http://127.0.0.1:8787/p/")
|
||||
|
||||
display = captured["env_vars_display"]
|
||||
assert isinstance(display, list)
|
||||
assert f"models.yml: providers.anthropic.baseUrl={base_url}" in display
|
||||
|
||||
|
||||
def test_wrap_omp_no_rtk_skips_agents_md(runner: CliRunner, omp_home: Path, tmp_path: Path) -> None:
|
||||
with (
|
||||
patch("headroom.cli.wrap.shutil.which", return_value="omp"),
|
||||
patch("headroom.cli.wrap._launch_tool"),
|
||||
):
|
||||
result = runner.invoke(main, ["wrap", "omp", "--no-rtk"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert not (tmp_path / "AGENTS.md").exists()
|
||||
|
||||
|
||||
def test_wrap_omp_rtk_injects_into_cwd_agents_md(
|
||||
runner: CliRunner, omp_home: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("HEADROOM_CONTEXT_TOOL", "rtk")
|
||||
with (
|
||||
patch("headroom.cli.wrap.shutil.which", return_value="omp"),
|
||||
patch("headroom.cli.wrap._launch_tool"),
|
||||
patch("headroom.cli.wrap._ensure_rtk_binary", return_value=tmp_path / "rtk"),
|
||||
):
|
||||
result = runner.invoke(main, ["wrap", "omp"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
agents_md = tmp_path / "AGENTS.md"
|
||||
assert agents_md.exists()
|
||||
assert "headroom:rtk-instructions" in agents_md.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI: unwrap omp
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unwrap_omp_restored_and_cleans_agents_md(
|
||||
runner: CliRunner, omp_home: Path, tmp_path: Path
|
||||
) -> None:
|
||||
original = "providers:\n anthropic:\n apiKey: sk-user-secret\n"
|
||||
omp_home.write_bytes(original.encode("utf-8"))
|
||||
inject_models_override(8787, "proj")
|
||||
|
||||
agents_md = tmp_path / "AGENTS.md"
|
||||
agents_md.write_text("# My project rules\n\nBe nice.\n", encoding="utf-8")
|
||||
_inject_rtk_instructions(agents_md)
|
||||
|
||||
stopped: list[int] = []
|
||||
with patch(
|
||||
"headroom.cli.wrap._stop_local_proxy_for_unwrap",
|
||||
side_effect=lambda port: stopped.append(port) or "not_running",
|
||||
):
|
||||
result = runner.invoke(main, ["unwrap", "omp"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Restored pre-wrap models.yml" in result.output
|
||||
assert omp_home.read_bytes() == original.encode("utf-8")
|
||||
assert not backup_path(omp_home).exists()
|
||||
|
||||
# Only the marker-fenced rtk block is scrubbed; user content survives.
|
||||
remaining = agents_md.read_text(encoding="utf-8")
|
||||
assert "headroom:rtk-instructions" not in remaining
|
||||
assert "Be nice." in remaining
|
||||
|
||||
# A real restore (not a noop) attempts to stop the proxy on the given port.
|
||||
assert stopped == [8787]
|
||||
|
||||
|
||||
def test_unwrap_omp_removes_wrap_created_file(runner: CliRunner, omp_home: Path) -> None:
|
||||
inject_models_override(8787, "proj") # fresh create → no backup
|
||||
|
||||
stopped: list[int] = []
|
||||
with patch(
|
||||
"headroom.cli.wrap._stop_local_proxy_for_unwrap",
|
||||
side_effect=lambda port: stopped.append(port) or "not_running",
|
||||
):
|
||||
result = runner.invoke(main, ["unwrap", "omp", "--port", "9191"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Removed wrap-created models.yml" in result.output
|
||||
assert not omp_home.exists()
|
||||
assert stopped == [9191]
|
||||
|
||||
|
||||
def test_unwrap_omp_noop_leaves_unmanaged_and_skips_proxy_stop(
|
||||
runner: CliRunner, omp_home: Path
|
||||
) -> None:
|
||||
user_content = "providers:\n anthropic:\n apiKey: sk-user-secret\n"
|
||||
omp_home.write_bytes(user_content.encode("utf-8"))
|
||||
|
||||
with patch("headroom.cli.wrap._stop_local_proxy_for_unwrap") as stop_proxy:
|
||||
result = runner.invoke(main, ["unwrap", "omp"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "nothing to restore" in result.output
|
||||
# Unmanaged file is left exactly as the user had it.
|
||||
assert omp_home.read_bytes() == user_content.encode("utf-8")
|
||||
# noop status → the proxy is left running (the `status != "noop"` guard).
|
||||
stop_proxy.assert_not_called()
|
||||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -1465,6 +1465,7 @@ dependencies = [
|
|||
{ name = "litellm", marker = "python_full_version < '3.14'" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "rich" },
|
||||
{ name = "tiktoken" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
|
|
@ -1760,6 +1761,7 @@ requires-dist = [
|
|||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0" },
|
||||
{ name = "qdrant-client", marker = "extra == 'memory-stack'", specifier = ">=1.9.0,<2.0" },
|
||||
{ name = "rapidocr", marker = "python_full_version >= '3.13' and extra == 'image'", specifier = ">=3.0,<4" },
|
||||
{ name = "rapidocr-onnxruntime", marker = "python_full_version < '3.13' and extra == 'image'", specifier = ">=1.4.0,<2" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue