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:
Renn F
2026-06-18 11:54:58 +02:00
parent 7e077fe642
commit e36549f01f
7 changed files with 190 additions and 4 deletions
+2
View File
@@ -231,6 +231,8 @@ services:
- ${ROBOCO_DATA_DIR:-./data}/prompts-generated:/app/prompts-generated - ${ROBOCO_DATA_DIR:-./data}/prompts-generated:/app/prompts-generated
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings - ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
- ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces - ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces
# Per-agent opencode stores (GROK usage/cost capture).
- ${ROBOCO_DATA_DIR:-./data}/opencode:/data/opencode
- ${ROBOCO_DATA_DIR:-./data}/logs:/data/logs - ${ROBOCO_DATA_DIR:-./data}/logs:/data/logs
- ${ROBOCO_DATA_DIR:-./data}/briefings:/app/briefings - ${ROBOCO_DATA_DIR:-./data}/briefings:/app/briefings
- ${ROBOCO_DATA_DIR:-./data}/manifests:/app/manifests - ${ROBOCO_DATA_DIR:-./data}/manifests:/app/manifests
+3
View File
@@ -330,6 +330,9 @@ services:
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings - ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
# Agent workspaces (git clones) - persisted across restarts # Agent workspaces (git clones) - persisted across restarts
- ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces - ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces
# Per-agent opencode stores (GROK usage/cost capture): each Grok agent
# writes opencode.db under <agent_id>/; the finalizer reads it back here.
- ${ROBOCO_DATA_DIR:-./data}/opencode:/data/opencode
# Persistent logs — survive `docker compose down/up`. Orchestrator and # Persistent logs — survive `docker compose down/up`. Orchestrator and
# each spawned agent write structured logs here so we can audit past # each spawned agent write structured logs here so we can audit past
# runs instead of relying on ephemeral `docker logs`. # runs instead of relying on ephemeral `docker logs`.
+3
View File
@@ -330,6 +330,9 @@ services:
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings - ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
# Agent workspaces (git clones) - persisted across restarts # Agent workspaces (git clones) - persisted across restarts
- ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces - ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces
# Per-agent opencode stores (GROK usage/cost capture): each Grok agent
# writes opencode.db under <agent_id>/; the finalizer reads it back here.
- ${ROBOCO_DATA_DIR:-./data}/opencode:/data/opencode
# Persistent logs — survive `docker compose down/up`. Orchestrator and # Persistent logs — survive `docker compose down/up`. Orchestrator and
# each spawned agent write structured logs here so we can audit past # each spawned agent write structured logs here so we can audit past
# runs instead of relying on ephemeral `docker logs`. # runs instead of relying on ephemeral `docker logs`.
+20
View File
@@ -63,6 +63,10 @@ _DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"
# In-container paths mounted by the orchestrator's `_build_mount_args`. # In-container paths mounted by the orchestrator's `_build_mount_args`.
_MCP_CONFIG_IN_CONTAINER = "/app/mcp-config.json" _MCP_CONFIG_IN_CONTAINER = "/app/mcp-config.json"
_SYSTEM_PROMPT_IN_CONTAINER = "/app/system-prompt.md" _SYSTEM_PROMPT_IN_CONTAINER = "/app/system-prompt.md"
# opencode's data dir inside the agent (HOME=/home/agent); opencode.db lands
# here. Mounted to a per-agent host dir so the orchestrator can read usage back
# (mirror of roboco.llm.providers.opencode_usage.DEFAULT_DB_PATH's parent).
_OPENCODE_DATA_DIR_IN_CONTAINER = "/home/agent/.local/share/opencode"
# Reasoning effort by role. grok-build-0.1 reasons heavily by default, and # Reasoning effort by role. grok-build-0.1 reasons heavily by default, and
# reasoning bills at the output rate — it dominates cost (a live "say ok" call # reasoning bills at the output rate — it dominates cost (a live "say ok" call
@@ -168,6 +172,7 @@ class GrokProvider(AgentProvider):
cmd = self._host._build_mount_args(container_name, mount_config, hosts) cmd = self._host._build_mount_args(container_name, mount_config, hosts)
self._host._append_agent_auth_env(cmd, config) self._host._append_agent_auth_env(cmd, config)
self._host._append_git_context_env(cmd, config) self._host._append_git_context_env(cmd, config)
self._append_opencode_data_mount(cmd, hosts)
self._append_grok_env(cmd, config, initial_prompt) self._append_grok_env(cmd, config, initial_prompt)
cmd.append(self._image) cmd.append(self._image)
@@ -187,6 +192,21 @@ class GrokProvider(AgentProvider):
extra={"container_id": stdout.decode().strip(), "model": config.model}, extra={"container_id": stdout.decode().strip(), "model": config.model},
) )
def _append_opencode_data_mount(
self, cmd: list[str], hosts: dict[str, str | None]
) -> None:
"""Mount the per-agent opencode data dir so the orchestrator can read it.
opencode persists token usage to ``opencode.db`` under its data dir
(``$HOME/.local/share/opencode``). Binding a per-agent host dir there
lets the finalizer read the store back over the shared data volume —
the opencode analogue of the mounted Claude transcript. Without this a
Grok agent finalizes at 0 tokens / $0.
"""
opencode_host = hosts.get("opencode")
if opencode_host:
cmd.extend(["-v", f"{opencode_host}:{_OPENCODE_DATA_DIR_IN_CONTAINER}"])
def _append_grok_env( def _append_grok_env(
self, cmd: list[str], config: AgentConfig, initial_prompt: str | None self, cmd: list[str], config: AgentConfig, initial_prompt: str | None
) -> None: ) -> None:
+59 -4
View File
@@ -163,6 +163,11 @@ CLAUDE_AUTH_HOST_PATH = os.environ.get(
) )
PROJECT_HOST_PATH = os.environ.get("ROBOCO_HOST_PROJECT_DIR", "") PROJECT_HOST_PATH = os.environ.get("ROBOCO_HOST_PROJECT_DIR", "")
DATA_HOST_PATH = os.environ.get("ROBOCO_HOST_DATA_DIR", "") DATA_HOST_PATH = os.environ.get("ROBOCO_HOST_DATA_DIR", "")
# In-orchestrator path where each GROK agent's opencode store is visible. The
# agent writes <DATA_HOST_PATH>/opencode/<agent_id>; the compose file mounts the
# same host dir here so the finalizer can read opencode.db back (mirrors how the
# Claude transcript is read from the mounted ~/.claude). Override for local runs.
OPENCODE_DATA_DIR = os.environ.get("ROBOCO_OPENCODE_DATA_DIR", "/data/opencode")
# ============================================================================= # =============================================================================
@@ -1692,6 +1697,9 @@ class AgentOrchestrator:
"workspaces": f"{DATA_HOST_PATH}/workspaces", "workspaces": f"{DATA_HOST_PATH}/workspaces",
"claude": CLAUDE_AUTH_HOST_PATH, "claude": CLAUDE_AUTH_HOST_PATH,
"mcp_config": f"{DATA_HOST_PATH}/mcp-configs/{mcp_name}", "mcp_config": f"{DATA_HOST_PATH}/mcp-configs/{mcp_name}",
# Per-agent opencode store (GROK only); the orchestrator reads it
# back at finalize via the shared data volume (see OPENCODE_DATA_DIR).
"opencode": f"{DATA_HOST_PATH}/opencode/{config.agent_id}",
"prompt": ( "prompt": (
f"{DATA_HOST_PATH}/prompts-generated/{config.agent_id}-prompt.md" f"{DATA_HOST_PATH}/prompts-generated/{config.agent_id}-prompt.md"
), ),
@@ -1711,6 +1719,9 @@ class AgentOrchestrator:
"workspaces": str(Path(settings.workspaces_root)), "workspaces": str(Path(settings.workspaces_root)),
"claude": CLAUDE_AUTH_HOST_PATH, "claude": CLAUDE_AUTH_HOST_PATH,
"mcp_config": str(config.mcp_config_path), "mcp_config": str(config.mcp_config_path),
"opencode": str(
Path(tempfile.gettempdir()) / "roboco-opencode" / config.agent_id
),
"prompt": str( "prompt": str(
Path(tempfile.gettempdir()) Path(tempfile.gettempdir())
/ "roboco-prompts" / "roboco-prompts"
@@ -3671,17 +3682,61 @@ class AgentOrchestrator:
except OSError: except OSError:
return (0, 0, 0, 0) return (0, 0, 0, 0)
def _opencode_db_path(self, agent_id: str) -> str:
"""In-orchestrator path to a GROK agent's opencode SQLite store.
The agent writes opencode.db under the shared data volume; the compose
file mounts that host dir at ``OPENCODE_DATA_DIR`` here, so finalize can
read it back the opencode analogue of the mounted Claude transcript.
"""
return str(Path(OPENCODE_DATA_DIR) / agent_id / "opencode.db")
def _grok_usage_from_opencode(self, agent_id: str) -> tuple[int, int, int, int]:
"""Sum a GROK agent's token usage from its opencode SQLite store.
A GROK agent runs opencode no SDK ``/usage/status`` server and no
Claude transcript so its usage lands in opencode.db. Reasoning is
folded into output (it bills at the output rate, matching
``calculate_cost``). A WARNING is logged on a 0-token read because a
silent mount/uid failure is otherwise indistinguishable from a genuine
zero-cost run. Returns ``(input, output, cache_read, cache_write)``.
"""
from roboco.llm.providers.opencode_usage import read_session_usage
db_path = self._opencode_db_path(agent_id)
usage = read_session_usage(db_path)
if usage is None:
logger.warning(
"GROK agent finalized with no readable opencode usage "
"(0 tokens / $0) — check the opencode db mount",
agent_id=agent_id,
db_path=db_path,
)
return (0, 0, 0, 0)
return (
usage.tokens_input,
usage.tokens_output + usage.tokens_reasoning,
usage.tokens_cache_read,
usage.tokens_cache_write,
)
async def _resolve_final_token_usage( async def _resolve_final_token_usage(
self, agent_id: str self, agent_id: str
) -> tuple[int, int, int, int]: ) -> tuple[int, int, int, int]:
"""Resolve final token counts for a stopping agent. """Resolve final token counts for a stopping agent.
Tries the live SDK ``/usage/status`` first; if that misses the SDK's For a GROK agent, reads the opencode SQLite store (no SDK server / Claude
in-memory counts race container teardown for short-lived agents it transcript exists). Otherwise tries the live SDK ``/usage/status`` first;
falls back to the agent's Claude Code transcript, which is durable and if that misses the SDK's in-memory counts race container teardown for
mounted into this container. Returns short-lived agents it falls back to the agent's Claude Code transcript,
which is durable and mounted into this container. Returns
``(input, output, cache_read, cache_write)``. ``(input, output, cache_read, cache_write)``.
""" """
from roboco.models.base import ModelProvider
if self.get_provider_for_agent(agent_id) == ModelProvider.GROK.value:
return self._grok_usage_from_opencode(agent_id)
tokens = (0, 0, 0, 0) tokens = (0, 0, 0, 0)
sdk_url = f"http://roboco-agent-{agent_id}:{SDK_PORT}/usage/status" sdk_url = f"http://roboco-agent-{agent_id}:{SDK_PORT}/usage/status"
try: try:
+4
View File
@@ -76,6 +76,7 @@ class _FakeHost:
if config.mcp_config_path if config.mcp_config_path
else None, else None,
"settings": str(agent_settings_path) if agent_settings_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( 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`), # Tool restriction lives in the rendered opencode.json (opencode `tools`),
# not a spawn env var — no ROBOCO_AGENT_TOOLS is injected. # not a spawn env var — no ROBOCO_AGENT_TOOLS is injected.
assert not any(c.startswith("ROBOCO_AGENT_TOOLS=") for c in cmd) 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. # Identity wiring from the shared host helpers is present.
assert "ROBOCO_AGENT_TOKEN=hmac-be-dev-1" in cmd assert "ROBOCO_AGENT_TOKEN=hmac-be-dev-1" in cmd
# The image is the final docker-run argument. # 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)