diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index afe05a5d9..128db09f6 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -436,14 +436,23 @@ def _copy_openclaw_plugin_into_extensions( raise click.ClickException( f"Plugin dist folder missing at {dist_dir}. Build the plugin first." ) + hook_shim_dir = plugin_dir / "hook-shim" + if not hook_shim_dir.exists(): + raise click.ClickException( + f"Plugin hook-shim folder missing at {hook_shim_dir}. Build the plugin first." + ) extensions_dir = _resolve_openclaw_extensions_dir(openclaw_bin) target_dir = extensions_dir / "headroom" target_dist = target_dir / "dist" + target_hook_shim = target_dir / "hook-shim" target_dir.mkdir(parents=True, exist_ok=True) if target_dist.exists(): shutil.rmtree(target_dist) + if target_hook_shim.exists(): + shutil.rmtree(target_hook_shim) shutil.copytree(dist_dir, target_dist) + shutil.copytree(hook_shim_dir, target_hook_shim) for filename in ("openclaw.plugin.json", "package.json", "README.md"): source = plugin_dir / filename diff --git a/headroom/proxy/handlers/anthropic.py b/headroom/proxy/handlers/anthropic.py index 064a6683a..a41210c2c 100644 --- a/headroom/proxy/handlers/anthropic.py +++ b/headroom/proxy/handlers/anthropic.py @@ -681,6 +681,17 @@ class AnthropicHandlerMixin: # Flag compression failure for observability _compression_failed = True + # Guard: if "optimization" inflated tokens, revert to originals. + # Skip in cache mode where prefix-stability may legitimately shift counts. + if optimized_tokens > original_tokens and not is_cache_mode(self.config.mode): + logger.warning( + f"[{request_id}] Optimization inflated tokens " + f"({original_tokens} -> {optimized_tokens}), reverting to original messages" + ) + optimized_messages = original_messages + optimized_tokens = original_tokens + transforms_applied = [] + tokens_saved = max(0, original_tokens - optimized_tokens) optimization_latency = (time.time() - start_time) * 1000 @@ -1592,9 +1603,18 @@ class AnthropicHandlerMixin: # Use pipeline's token counts for consistency with pipeline logs original_tokens = result.tokens_before optimized_tokens = result.tokens_after + # Guard: if "optimization" inflated tokens, revert to originals + if optimized_tokens > original_tokens: + logger.warning( + f"[{request_id}] Batch item optimization inflated tokens " + f"({original_tokens} -> {optimized_tokens}), reverting" + ) + optimized_messages = messages + optimized_tokens = original_tokens + total_original_tokens += original_tokens total_optimized_tokens += optimized_tokens - tokens_saved = max(0, original_tokens - optimized_tokens) + tokens_saved = original_tokens - optimized_tokens total_tokens_saved += tokens_saved # CCR Tool Injection: Inject retrieval tool if compression occurred diff --git a/headroom/proxy/handlers/batch.py b/headroom/proxy/handlers/batch.py index 14076ec63..8ce5fe683 100644 --- a/headroom/proxy/handlers/batch.py +++ b/headroom/proxy/handlers/batch.py @@ -158,9 +158,18 @@ class BatchHandlerMixin: # Use pipeline's token counts for consistency with pipeline logs original_tokens = result.tokens_before optimized_tokens = result.tokens_after + # Guard: if "optimization" inflated tokens, revert to originals + if optimized_tokens > original_tokens: + logger.warning( + f"[{request_id}] Batch item optimization inflated tokens " + f"({original_tokens} -> {optimized_tokens}), reverting" + ) + optimized_messages = messages + optimized_tokens = original_tokens + total_original_tokens += original_tokens total_optimized_tokens += optimized_tokens - tokens_saved = max(0, original_tokens - optimized_tokens) + tokens_saved = original_tokens - optimized_tokens total_tokens_saved += tokens_saved # CCR Tool Injection: Inject retrieval tool if compression occurred diff --git a/headroom/proxy/handlers/gemini.py b/headroom/proxy/handlers/gemini.py index 3ded54954..d11abb6ad 100644 --- a/headroom/proxy/handlers/gemini.py +++ b/headroom/proxy/handlers/gemini.py @@ -265,7 +265,17 @@ class GeminiHandlerMixin: _compression_failed = True logger.warning(f"[{request_id}] Gemini optimization failed: {e}") - tokens_saved = max(0, original_tokens - optimized_tokens) + # Guard: if "optimization" inflated tokens, revert to originals + if optimized_tokens > original_tokens: + logger.warning( + f"[{request_id}] Optimization inflated tokens " + f"({original_tokens} -> {optimized_tokens}), reverting to original messages" + ) + optimized_messages = messages + optimized_tokens = original_tokens + transforms_applied = [] + + tokens_saved = original_tokens - optimized_tokens optimization_latency = (time.time() - start_time) * 1000 # Query Echo: disabled — hurts prefix caching in long conversations. diff --git a/headroom/proxy/handlers/openai.py b/headroom/proxy/handlers/openai.py index 40150e77e..f088cff4f 100644 --- a/headroom/proxy/handlers/openai.py +++ b/headroom/proxy/handlers/openai.py @@ -298,7 +298,17 @@ class OpenAIHandlerMixin: # Flag compression failure for observability _compression_failed = True - tokens_saved = max(0, original_tokens - optimized_tokens) + # Guard: if "optimization" inflated tokens, revert to originals + if optimized_tokens > original_tokens: + logger.warning( + f"[{request_id}] Optimization inflated tokens " + f"({original_tokens} -> {optimized_tokens}), reverting to original messages" + ) + optimized_messages = original_messages + optimized_tokens = original_tokens + transforms_applied = [] + + tokens_saved = original_tokens - optimized_tokens optimization_latency = (time.time() - start_time) * 1000 # Hook: post_compress @@ -795,7 +805,17 @@ class OpenAIHandlerMixin: except Exception as e: logger.warning(f"[{request_id}] Responses API optimization failed: {e}") - tokens_saved = max(0, original_tokens - optimized_tokens) + # Guard: if "optimization" inflated tokens, revert to originals + if optimized_tokens > original_tokens: + logger.warning( + f"[{request_id}] Optimization inflated tokens " + f"({original_tokens} -> {optimized_tokens}), reverting to original messages" + ) + optimized_messages = messages + optimized_tokens = original_tokens + transforms_applied = [] + + tokens_saved = original_tokens - optimized_tokens optimization_latency = (time.time() - start_time) * 1000 # Convert compressed messages back to Responses API items @@ -1060,7 +1080,15 @@ class OpenAIHandlerMixin: if instructions and opt and opt[0].get("role") == "system": body["instructions"] = opt[0]["content"] opt = opt[1:] - body["input"] = messages_to_responses_items(opt, input_data, preserved) + if result.tokens_after <= original_tokens: + body["input"] = messages_to_responses_items( + opt, input_data, preserved + ) + else: + logger.warning( + f"[{request_id}] WS optimization inflated tokens " + f"({original_tokens} -> {result.tokens_after}), reverting" + ) tokens_saved = max(0, original_tokens - result.tokens_after) first_msg_raw = json.dumps(body) logger.info( diff --git a/headroom/proxy/helpers.py b/headroom/proxy/helpers.py index 98adbe177..e4d857133 100644 --- a/headroom/proxy/helpers.py +++ b/headroom/proxy/helpers.py @@ -78,8 +78,13 @@ def _setup_file_logging() -> None: handler.setFormatter( logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") ) - # Attach to the headroom root logger so all sub-loggers are captured - logging.getLogger("headroom").addHandler(handler) + # Attach to the headroom root logger so all sub-loggers are captured. + # Disable propagation to root to avoid duplicate writes when + # wrap.py redirects stderr to the same log file. + headroom_logger = logging.getLogger("headroom") + if not any(isinstance(h, RotatingFileHandler) for h in headroom_logger.handlers): + headroom_logger.addHandler(handler) + headroom_logger.propagate = False except OSError: # Non-fatal: can't write logs (read-only fs, permissions, etc.) pass diff --git a/tests/test_cli/test_wrap_openclaw.py b/tests/test_cli/test_wrap_openclaw.py index efecf64c4..3072e9074 100644 --- a/tests/test_cli/test_wrap_openclaw.py +++ b/tests/test_cli/test_wrap_openclaw.py @@ -25,6 +25,9 @@ def plugin_dir(tmp_path: Path) -> Path: plugin.mkdir(parents=True) (plugin / "package.json").write_text('{"name":"headroom-openclaw"}\n') (plugin / "openclaw.plugin.json").write_text('{"id":"headroom"}\n') + hook_shim = plugin / "hook-shim" + hook_shim.mkdir() + (hook_shim / "index.js").write_text("export default {};\n") return plugin @@ -416,11 +419,24 @@ def test_copy_openclaw_plugin_into_extensions_handles_missing_and_existing_dist( (plugin / "package.json").write_text("{}\n") (plugin / "openclaw.plugin.json").write_text("{}\n") + with patch("headroom.cli.wrap._resolve_openclaw_extensions_dir", return_value=tmp_path): + with pytest.raises(Exception, match="Plugin hook-shim folder missing"): + wrap_cli._copy_openclaw_plugin_into_extensions( + plugin_dir=plugin, openclaw_bin="openclaw" + ) + + hook_shim = plugin / "hook-shim" + hook_shim.mkdir() + (hook_shim / "index.js").write_text("shim\n") + ext_root = tmp_path / ".openclaw" / "extensions" target_headroom = ext_root / "headroom" target_dist = target_headroom / "dist" + target_hook_shim = target_headroom / "hook-shim" target_dist.mkdir(parents=True) (target_dist / "old.js").write_text("old\n") + target_hook_shim.mkdir(parents=True) + (target_hook_shim / "old.js").write_text("old-shim\n") with patch("headroom.cli.wrap._resolve_openclaw_extensions_dir", return_value=ext_root): out = wrap_cli._copy_openclaw_plugin_into_extensions( @@ -430,3 +446,5 @@ def test_copy_openclaw_plugin_into_extensions_handles_missing_and_existing_dist( assert out == target_headroom assert (target_dist / "index.js").exists() assert not (target_dist / "old.js").exists() + assert (target_hook_shim / "index.js").exists() + assert not (target_hook_shim / "old.js").exists()