mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(grok): capture one-shot Grok usage/cost from the opencode store
A GROK agent runs opencode, not Claude Code: it has no SDK /usage/status server and writes no Claude transcript, so _resolve_final_token_usage found nothing and every Grok agent finalized at 0 tokens / $0 — the opencode_usage reader existed but had no caller. - Mount a per-agent opencode data dir ($DATA/opencode/<agent_id> → /home/agent/.local/share/opencode) so opencode.db is captured, and mount the same host dir into the orchestrator (/data/opencode) in all three compose files so the finalizer can read it back — the opencode analogue of the mounted Claude transcript. - _resolve_final_token_usage branches on provider_type: GROK reads opencode.db via opencode_usage (reasoning folded into output, billed at the output rate) and skips the SDK/transcript path. A 0-token read logs a WARNING so a silent mount failure isn't mistaken for a real zero-cost run. - ROBOCO_OPENCODE_DATA_DIR overrides the in-orchestrator path for local runs.
This commit is contained in:
@@ -76,6 +76,7 @@ class _FakeHost:
|
||||
if config.mcp_config_path
|
||||
else None,
|
||||
"settings": str(agent_settings_path) if agent_settings_path else None,
|
||||
"opencode": f"/host/opencode/{config.agent_id}",
|
||||
}
|
||||
|
||||
def _build_mount_args(
|
||||
@@ -200,6 +201,9 @@ async def test_grok_spawn_wires_gateway_and_image_last() -> None:
|
||||
# Tool restriction lives in the rendered opencode.json (opencode `tools`),
|
||||
# not a spawn env var — no ROBOCO_AGENT_TOOLS is injected.
|
||||
assert not any(c.startswith("ROBOCO_AGENT_TOOLS=") for c in cmd)
|
||||
# The opencode store is mounted so the orchestrator can read usage/cost
|
||||
# back at finalize.
|
||||
assert "/host/opencode/be-dev-1:/home/agent/.local/share/opencode" in cmd
|
||||
# Identity wiring from the shared host helpers is present.
|
||||
assert "ROBOCO_AGENT_TOKEN=hmac-be-dev-1" in cmd
|
||||
# The image is the final docker-run argument.
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""GROK agents capture token usage/cost from their opencode SQLite store.
|
||||
|
||||
A Grok agent runs opencode — no SDK /usage/status server and no Claude
|
||||
transcript — so finalize must read opencode.db (mounted into the orchestrator)
|
||||
instead. Reasoning folds into output (it bills at the output rate).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from roboco.models.runtime import AgentInstance
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _make_db(path: Path, cols: dict[str, float]) -> None:
|
||||
con = sqlite3.connect(path)
|
||||
con.execute(
|
||||
"CREATE TABLE session (id TEXT, tokens_input INT, tokens_output INT, "
|
||||
"tokens_cache_read INT, tokens_cache_write INT, tokens_reasoning INT, "
|
||||
"cost REAL)"
|
||||
)
|
||||
con.execute(
|
||||
"INSERT INTO session (id, tokens_input, tokens_output, tokens_cache_read, "
|
||||
"tokens_cache_write, tokens_reasoning, cost) VALUES (?,?,?,?,?,?,?)",
|
||||
(
|
||||
"s1",
|
||||
cols["tokens_input"],
|
||||
cols["tokens_output"],
|
||||
cols["tokens_cache_read"],
|
||||
cols["tokens_cache_write"],
|
||||
cols["tokens_reasoning"],
|
||||
cols["cost"],
|
||||
),
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
|
||||
def test_grok_usage_folds_reasoning_into_output(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
db = tmp_path / "opencode.db"
|
||||
_make_db(
|
||||
db,
|
||||
{
|
||||
"tokens_input": 100,
|
||||
"tokens_output": 50,
|
||||
"tokens_reasoning": 30,
|
||||
"tokens_cache_read": 10,
|
||||
"tokens_cache_write": 5,
|
||||
"cost": 0.02,
|
||||
},
|
||||
)
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
monkeypatch.setattr(orch, "_opencode_db_path", lambda _aid: str(db))
|
||||
|
||||
# reasoning (30) folded into output (50) → 80; bills at the output rate.
|
||||
assert orch._grok_usage_from_opencode("be-dev-1") == (100, 80, 10, 5)
|
||||
|
||||
|
||||
def test_grok_usage_zero_when_store_missing(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
monkeypatch.setattr(
|
||||
orch, "_opencode_db_path", lambda _aid: str(tmp_path / "absent.db")
|
||||
)
|
||||
assert orch._grok_usage_from_opencode("be-dev-1") == (0, 0, 0, 0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_final_usage_routes_grok_to_opencode(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
db = tmp_path / "opencode.db"
|
||||
_make_db(
|
||||
db,
|
||||
{
|
||||
"tokens_input": 7,
|
||||
"tokens_output": 3,
|
||||
"tokens_reasoning": 2,
|
||||
"tokens_cache_read": 0,
|
||||
"tokens_cache_write": 0,
|
||||
"cost": 0.01,
|
||||
},
|
||||
)
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
monkeypatch.setattr(orch, "_opencode_db_path", lambda _aid: str(db))
|
||||
cfg = type("C", (), {"provider_type": "grok"})()
|
||||
orch._instances = {"be-dev-1": AgentInstance(agent_id="be-dev-1", config=cfg)}
|
||||
|
||||
# No SDK fetch / transcript read for GROK — usage comes from opencode.db.
|
||||
assert await orch._resolve_final_token_usage("be-dev-1") == (7, 5, 0, 0)
|
||||
Reference in New Issue
Block a user