diff --git a/CHANGELOG.md b/CHANGELOG.md index 46d782a..732b9b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Playwright MCP prompt: `browser_run_code` is now `browser_run_code_unsafe`, and the nonexistent `browser_scroll` tool is no longer listed. - The Claude SDK message buffer is raised to 32 MB, so a large screenshot no longer aborts a run. - `list` and `show` report the cost of Claude, Copilot and Cursor runs. They used to read only `total_cost` or `cost`, while those engines record `estimated_cost_usd`, so the cost was always empty. +- OpenCode runs on free or local models (`opencode/big-pickle`, Ollama) report a cost of $0. When OpenCode reported no cost, rae priced every unknown model as Claude Sonnet 4.6; it now estimates only for models with known prices, and short names such as `opus` are priced as the model they map to. +- OpenCode runs report their cost and cache writes in `--json` usage (`total_cost_usd`, `cache_write_tokens`) and their cost in `/history` and `/resume`. +- README: the `agent --json` usage fields are `total_cost_usd` and the cache token counts, not `total_cost`. ### Security - Raise security minimums to aiohttp >=3.14.4, certifi >=2026.10.19, multidict >=6.9.1, OpenTelemetry API >=1.45.1, PyJWT >=2.15.0 and urllib3 >=2.8.0, and lock the patched versions. certifi drops the emSign root CAs whose intermediate keys were compromised. diff --git a/README.md b/README.md index dcb951a..1847fb6 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ Pass `--no-interactive` (and/or `--json`) to skip prompts. With `--json`, stdout | `mode` | `string` \| `null` | `"auto"`, `"chrome-mcp"`, or `"agent-browser"`. | | `har_path` | `string` \| `null` | Captured HAR. | | `script_path` | `string` \| `null` | Generated client; exists whenever `status` is `"ok"`. | -| `usage` | `object` | `{input_tokens, output_tokens, total_cost}`. | +| `usage` | `object` | `{input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_cost_usd}`, each present only when the SDK reports it, plus the SDK's own usage under `raw`. | | `error` | `string` \| `null` | When `status == "error"`. | ### Exit codes diff --git a/src/reverse_api/cli.py b/src/reverse_api/cli.py index be2417d..d652055 100644 --- a/src/reverse_api/cli.py +++ b/src/reverse_api/cli.py @@ -268,7 +268,7 @@ def _select_opencode_pair_for_settings(mode_color: str = THEME_PRIMARY) -> tuple "input_tokens": ("input_tokens",), "output_tokens": ("output_tokens",), "cache_read_tokens": ("cache_read_input_tokens", "cache_read_tokens"), - "cache_write_tokens": ("cache_creation_input_tokens", "cache_write_tokens"), + "cache_write_tokens": ("cache_creation_input_tokens", "cache_write_tokens", "cache_creation_tokens"), "total_cost_usd": ("estimated_cost_usd", "total_cost_usd", "total_cost"), } diff --git a/src/reverse_api/opencode_engineer.py b/src/reverse_api/opencode_engineer.py index 70e48b7..f8f1507 100644 --- a/src/reverse_api/opencode_engineer.py +++ b/src/reverse_api/opencode_engineer.py @@ -736,11 +736,14 @@ async def _handle_part_update(self, properties: dict, seen_parts: set) -> None: cache_read = cache.get("read", 0) cache_write = cache.get("write", 0) - if api_cost == 0 and (input_tokens > 0 or output_tokens > 0): - from .pricing import calculate_cost + # A zero API cost is estimated only for a model with known prices: free models + # (opencode/big-pickle) and local Ollama ones really cost nothing. + from .pricing import calculate_cost, get_model_pricing + model_id = self.MODEL_MAP.get(self.opencode_model, self.opencode_model) + if api_cost == 0 and (input_tokens > 0 or output_tokens > 0) and get_model_pricing(model_id) is not None: calculated_cost = calculate_cost( - model_id=self.opencode_model, + model_id=model_id, input_tokens=input_tokens, output_tokens=output_tokens, cache_creation_tokens=cache_write, @@ -758,6 +761,8 @@ async def _handle_part_update(self, properties: dict, seen_parts: set) -> None: self.usage_metadata["cache_read_tokens"] = self.usage_metadata.get("cache_read_tokens", 0) + cache_read self.usage_metadata["cache_creation_tokens"] = self.usage_metadata.get("cache_creation_tokens", 0) + cache_write self.usage_metadata["cost"] = self.usage_metadata.get("cost", 0) + cost + # The key the other engines use: --json usage, /history and /resume read it. + self.usage_metadata["estimated_cost_usd"] = self.usage_metadata["cost"] else: # Log unhandled part types for debugging diff --git a/tests/test_cli_followups.py b/tests/test_cli_followups.py index 79e32d6..6851ef0 100644 --- a/tests/test_cli_followups.py +++ b/tests/test_cli_followups.py @@ -288,6 +288,14 @@ def test_normalize_usage_alt_legacy_keys(self): assert out["total_cost_usd"] == 0.42 assert out["cache_read_tokens"] == 5 + def test_normalize_usage_opencode_keys(self): + from reverse_api.cli import _normalize_usage + + out = _normalize_usage({"cache_creation_tokens": 50, "cache_read_tokens": 100, "cost": 0.15, "estimated_cost_usd": 0.15}) + assert out["cache_write_tokens"] == 50 + assert out["cache_read_tokens"] == 100 + assert out["total_cost_usd"] == 0.15 + def test_normalize_usage_empty(self): from reverse_api.cli import _normalize_usage diff --git a/tests/test_opencode_engineer.py b/tests/test_opencode_engineer.py index 05681ee..2c02e9f 100644 --- a/tests/test_opencode_engineer.py +++ b/tests/test_opencode_engineer.py @@ -424,6 +424,7 @@ async def test_step_finish(self, tmp_path): async def test_step_finish_calculates_cost_when_zero(self, tmp_path): """Step finish calculates cost locally when API returns 0.""" eng = self._make_engineer(tmp_path) + eng.opencode_model = "claude-sonnet-4-6" seen_parts = set() properties = { "part": { @@ -442,6 +443,40 @@ async def test_step_finish_calculates_cost_when_zero(self, tmp_path): await eng._handle_part_update(properties, seen_parts) assert eng.usage_metadata["cost"] > 0 # Should have calculated locally + @staticmethod + def _step_finish(part_id="step1", cost=0.0): + tokens = {"input": 1000, "output": 500, "reasoning": 0, "cache": {"read": 0, "write": 0}} + return {"part": {"id": part_id, "type": "step-finish", "cost": cost, "tokens": tokens, "sessionID": "session_abc"}} + + @pytest.mark.asyncio + @pytest.mark.parametrize(("provider", "model"), [("opencode", "big-pickle"), ("ollama", "qwen3:8b")]) + async def test_free_and_local_models_cost_nothing(self, tmp_path, provider, model): + eng = self._make_engineer(tmp_path) + eng.opencode_provider, eng.opencode_model = provider, model + await eng._handle_part_update(self._step_finish(), set()) + assert eng.usage_metadata["cost"] == 0 + assert eng.usage_metadata["estimated_cost_usd"] == 0 + + @pytest.mark.asyncio + async def test_short_model_names_are_priced_as_the_model_they_map_to(self, tmp_path): + from reverse_api.pricing import calculate_cost + + eng = self._make_engineer(tmp_path) + eng.opencode_model = "opus" + await eng._handle_part_update(self._step_finish(), set()) + expected = calculate_cost(model_id="claude-opus-4-6", input_tokens=1000, output_tokens=500) + assert eng.usage_metadata["estimated_cost_usd"] == pytest.approx(expected) + assert expected != pytest.approx(calculate_cost(model_id="claude-sonnet-4-6", input_tokens=1000, output_tokens=500)) + + @pytest.mark.asyncio + async def test_cost_adds_up_across_steps_under_both_keys(self, tmp_path): + eng = self._make_engineer(tmp_path) + seen_parts: set = set() + await eng._handle_part_update(self._step_finish("step1", cost=0.05), seen_parts) + await eng._handle_part_update(self._step_finish("step2", cost=0.1), seen_parts) + assert eng.usage_metadata["cost"] == pytest.approx(0.15) + assert eng.usage_metadata["estimated_cost_usd"] == pytest.approx(0.15) + @pytest.mark.asyncio async def test_wrong_session_ignored(self, tmp_path): """Parts for other sessions are ignored."""