Skip to content

Commit b1a023d

Browse files
AbirAbbasclaude
andauthored
feat(harness): parse real cost from opencode JSON output (#269)
* fix: capture stderr from Claude Code CLI for error diagnosis The ClaudeCodeProvider was not passing a stderr callback to ClaudeAgentOptions, so when the claude CLI exited with code 1, the actual error message was lost. Logs only showed "Command failed with exit code 1" with no actionable details. Now passes a stderr callback that collects output and includes it in both the error log and the RawResult.error_message field. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: set stderr callback on opts object, not agent_options dict Avoids test assertion failures caused by unexpected 'stderr' key in the agent_options dictionary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(harness): parse real cost from opencode JSON output Use -f json flag when invoking opencode CLI and parse cost, prompt_tokens, and completion_tokens from the response. Falls back to estimate_cli_cost() for older opencode versions that don't include metrics in their JSON output. Depends on: opencode-ai/opencode#TBD Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e96167c commit b1a023d

2 files changed

Lines changed: 182 additions & 7 deletions

File tree

sdk/python/agentfield/harness/providers/opencode.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import json
67
import logging
78
import os
89
import shutil
@@ -77,6 +78,7 @@ async def _execute_impl(self, prompt: str, options: dict[str, object]) -> RawRes
7778
f"---\n\nUSER REQUEST:\n{prompt}"
7879
)
7980

81+
cmd.extend(["-f", "json"])
8082
cmd.append(effective_prompt)
8183

8284
env: Dict[str, str] = {}
@@ -119,7 +121,26 @@ async def _execute_impl(self, prompt: str, options: dict[str, object]) -> RawRes
119121
shutil.rmtree(temp_data_dir, ignore_errors=True)
120122

121123
api_ms = int((time.monotonic() - start_api) * 1000)
122-
result_text = stdout.strip() if stdout.strip() else None
124+
125+
parsed_cost: float | None = None
126+
parsed_prompt_tokens: int | None = None
127+
parsed_completion_tokens: int | None = None
128+
129+
try:
130+
json_output = json.loads(stdout.strip())
131+
result_text = json_output.get("response", "").strip() or None
132+
raw_cost = json_output.get("cost")
133+
if raw_cost is not None and float(raw_cost) > 0:
134+
parsed_cost = float(raw_cost)
135+
raw_pt = json_output.get("prompt_tokens")
136+
if raw_pt is not None:
137+
parsed_prompt_tokens = int(raw_pt)
138+
raw_ct = json_output.get("completion_tokens")
139+
if raw_ct is not None:
140+
parsed_completion_tokens = int(raw_ct)
141+
except (json.JSONDecodeError, ValueError, TypeError):
142+
result_text = stdout.strip() if stdout.strip() else None
143+
123144
clean_stderr = strip_ansi(stderr.strip()) if stderr else ""
124145

125146
logger.info(
@@ -152,19 +173,30 @@ async def _execute_impl(self, prompt: str, options: dict[str, object]) -> RawRes
152173
is_error = False
153174
error_message = None
154175

155-
estimated_cost = estimate_cli_cost(
156-
model=str(options.get("model", "")),
157-
prompt=effective_prompt,
158-
result_text=result_text,
159-
)
176+
if parsed_cost is not None:
177+
final_cost = parsed_cost
178+
else:
179+
final_cost = estimate_cli_cost(
180+
model=str(options.get("model", "")),
181+
prompt=effective_prompt,
182+
result_text=result_text,
183+
)
184+
185+
usage_data = None
186+
if parsed_prompt_tokens is not None or parsed_completion_tokens is not None:
187+
usage_data = {
188+
"prompt_tokens": parsed_prompt_tokens or 0,
189+
"completion_tokens": parsed_completion_tokens or 0,
190+
}
160191

161192
return RawResult(
162193
result=result_text,
163194
messages=[],
164195
metrics=Metrics(
165196
duration_api_ms=api_ms,
166197
num_turns=1 if result_text else 0,
167-
total_cost_usd=estimated_cost,
198+
total_cost_usd=final_cost,
199+
usage=usage_data,
168200
session_id="",
169201
),
170202
is_error=is_error,

sdk/python/tests/test_harness_provider_opencode.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
# pyright: reportMissingImports=false
44

5+
import json
56
from typing import Any
67
from unittest.mock import patch
78

@@ -42,6 +43,8 @@ async def fake_run_cli(cmd, *, env=None, cwd=None, timeout=None):
4243
assert captured["cmd"] == [
4344
"/usr/local/bin/opencode",
4445
"run",
46+
"-f",
47+
"json",
4548
"hello",
4649
]
4750
assert captured["env"]["A"] == "1"
@@ -125,6 +128,8 @@ async def fake_run_cli(cmd, *, env=None, cwd=None, timeout=None):
125128
"run",
126129
"--model",
127130
"openai/gpt-5",
131+
"-f",
132+
"json",
128133
"hello",
129134
]
130135
assert raw.is_error is False
@@ -165,3 +170,141 @@ async def fake_run_cli(cmd, *, env=None, cwd=None, timeout=None):
165170

166171
# No model → estimate_cli_cost gets empty string → returns None
167172
assert raw.metrics.total_cost_usd is None
173+
174+
175+
# ---------------------------------------------------------------------------
176+
# Functional tests for JSON cost parsing (new opencode -f json output)
177+
# ---------------------------------------------------------------------------
178+
179+
180+
@pytest.mark.functional
181+
@pytest.mark.asyncio
182+
async def test_json_parsing_full_cost_data(monkeypatch: pytest.MonkeyPatch):
183+
"""JSON output with all cost fields populates metrics correctly."""
184+
json_output = json.dumps(
185+
{
186+
"response": "hello",
187+
"cost": 0.04329,
188+
"prompt_tokens": 13985,
189+
"completion_tokens": 89,
190+
}
191+
)
192+
193+
async def fake_run_cli(cmd, *, env=None, cwd=None, timeout=None):
194+
_ = (env, cwd, timeout)
195+
return json_output, "", 0
196+
197+
monkeypatch.setattr("agentfield.harness.providers.opencode.run_cli", fake_run_cli)
198+
199+
provider = OpenCodeProvider(server_url="http://127.0.0.1:9999")
200+
raw = await provider.execute("test prompt", {"model": "openai/gpt-4o"})
201+
202+
assert raw.result == "hello"
203+
assert raw.metrics.total_cost_usd == 0.04329
204+
assert raw.metrics.usage == {
205+
"prompt_tokens": 13985,
206+
"completion_tokens": 89,
207+
}
208+
assert raw.is_error is False
209+
assert raw.metrics.num_turns == 1
210+
211+
212+
@pytest.mark.functional
213+
@pytest.mark.asyncio
214+
async def test_json_without_cost_fields_falls_back_to_estimate(
215+
monkeypatch: pytest.MonkeyPatch,
216+
):
217+
"""JSON output missing cost fields falls back to estimate_cli_cost."""
218+
json_output = json.dumps({"response": "some answer"})
219+
220+
async def fake_run_cli(cmd, *, env=None, cwd=None, timeout=None):
221+
_ = (env, cwd, timeout)
222+
return json_output, "", 0
223+
224+
monkeypatch.setattr("agentfield.harness.providers.opencode.run_cli", fake_run_cli)
225+
226+
with patch(
227+
"agentfield.harness.providers.opencode.estimate_cli_cost",
228+
return_value=0.002,
229+
) as mock_estimate:
230+
provider = OpenCodeProvider(server_url="http://127.0.0.1:9999")
231+
raw = await provider.execute("test", {"model": "openai/gpt-4o"})
232+
233+
assert raw.result == "some answer"
234+
assert raw.metrics.total_cost_usd == 0.002
235+
assert raw.metrics.usage is None
236+
mock_estimate.assert_called_once()
237+
238+
239+
@pytest.mark.functional
240+
@pytest.mark.asyncio
241+
async def test_plain_text_fallback_pre_json_opencode(
242+
monkeypatch: pytest.MonkeyPatch,
243+
):
244+
"""Plain text (non-JSON) stdout falls back to text result + estimate."""
245+
246+
async def fake_run_cli(cmd, *, env=None, cwd=None, timeout=None):
247+
_ = (env, cwd, timeout)
248+
return "This is plain text, not JSON.\n", "", 0
249+
250+
monkeypatch.setattr("agentfield.harness.providers.opencode.run_cli", fake_run_cli)
251+
252+
with patch(
253+
"agentfield.harness.providers.opencode.estimate_cli_cost",
254+
return_value=0.001,
255+
) as mock_estimate:
256+
provider = OpenCodeProvider(server_url="http://127.0.0.1:9999")
257+
raw = await provider.execute("test", {"model": "openai/gpt-4o"})
258+
259+
assert raw.result == "This is plain text, not JSON."
260+
assert raw.metrics.total_cost_usd == 0.001
261+
assert raw.metrics.usage is None
262+
mock_estimate.assert_called_once()
263+
264+
265+
@pytest.mark.functional
266+
@pytest.mark.asyncio
267+
async def test_zero_cost_falls_back_to_estimate(monkeypatch: pytest.MonkeyPatch):
268+
"""Zero cost in JSON is treated as unknown and falls back to estimate."""
269+
json_output = json.dumps(
270+
{"response": "answer", "cost": 0, "prompt_tokens": 0, "completion_tokens": 0}
271+
)
272+
273+
async def fake_run_cli(cmd, *, env=None, cwd=None, timeout=None):
274+
_ = (env, cwd, timeout)
275+
return json_output, "", 0
276+
277+
monkeypatch.setattr("agentfield.harness.providers.opencode.run_cli", fake_run_cli)
278+
279+
with patch(
280+
"agentfield.harness.providers.opencode.estimate_cli_cost",
281+
return_value=0.003,
282+
) as mock_estimate:
283+
provider = OpenCodeProvider(server_url="http://127.0.0.1:9999")
284+
raw = await provider.execute("test", {"model": "openai/gpt-4o"})
285+
286+
assert raw.result == "answer"
287+
# cost <= 0 → parsed_cost stays None → falls back to estimate
288+
assert raw.metrics.total_cost_usd == 0.003
289+
mock_estimate.assert_called_once()
290+
291+
292+
@pytest.mark.functional
293+
@pytest.mark.asyncio
294+
async def test_partial_fields_cost_without_tokens(monkeypatch: pytest.MonkeyPatch):
295+
"""JSON with cost but no token fields: cost is used, usage is None."""
296+
json_output = json.dumps({"response": "answer", "cost": 0.05})
297+
298+
async def fake_run_cli(cmd, *, env=None, cwd=None, timeout=None):
299+
_ = (env, cwd, timeout)
300+
return json_output, "", 0
301+
302+
monkeypatch.setattr("agentfield.harness.providers.opencode.run_cli", fake_run_cli)
303+
304+
provider = OpenCodeProvider(server_url="http://127.0.0.1:9999")
305+
raw = await provider.execute("test", {"model": "openai/gpt-4o"})
306+
307+
assert raw.result == "answer"
308+
assert raw.metrics.total_cost_usd == 0.05
309+
assert raw.metrics.usage is None
310+
assert raw.is_error is False

0 commit comments

Comments
 (0)