From b0857915a1168f246628143675efe556011643c6 Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 18 Jun 2026 09:57:20 +0200 Subject: [PATCH] fix(grok): correct opencode provider (Responses API), stdin, reasoning cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live opencode run against api.x.ai/v1 surfaced three real bugs: 1. Provider package — grok-build-0.1 is driven via the OpenAI Responses API (opencode calls model.responses()). @ai-sdk/openai-compatible is chat/completions only and errors "responses is not a function". Switch the generated opencode.json provider + the grok image to @ai-sdk/openai. 2. Headless hang — `opencode run` blocks after init without a TTY; close stdin (`< /dev/null`) in the entrypoint so it proceeds to the model call. 3. Reasoning-token cost — grok-build-0.1 is a reasoning model; reasoning tokens bill as output but opencode stores them in a separate column. cost_for_session folds tokens_reasoning into output (else ~22x undercount). Verified end-to-end against a real session row (input=6120, output=1, reasoning=226, cache_read=1856): our pricing reproduces opencode's stored USD cost ($0.0069452) exactly. Tests anchored to that real row. --- docker/agent-grok.Dockerfile | 11 ++++++----- docker/scripts/grok-agent-entrypoint.sh | 6 +++++- roboco/billing/pricing.py | 4 ++++ roboco/llm/providers/opencode_config.py | 8 ++++++-- roboco/llm/providers/opencode_usage.py | 7 ++++++- tests/unit/llm/test_opencode_config.py | 3 ++- tests/unit/llm/test_opencode_usage.py | 23 +++++++++++++++++++++++ 7 files changed, 52 insertions(+), 10 deletions(-) diff --git a/docker/agent-grok.Dockerfile b/docker/agent-grok.Dockerfile index ef9e5edd..8a6f46a7 100644 --- a/docker/agent-grok.Dockerfile +++ b/docker/agent-grok.Dockerfile @@ -12,11 +12,12 @@ FROM roboco-agent-base USER root -# opencode — the OpenAI-protocol agent runtime. The @ai-sdk/openai-compatible -# package backs the custom xAI provider declared in the generated opencode.json; -# opencode also resolves it at runtime, but pre-installing keeps first spawn off -# the network. -RUN npm install -g opencode-ai @ai-sdk/openai-compatible \ +# opencode — the OpenAI-protocol agent runtime. grok-build-0.1 is driven via the +# OpenAI Responses API, so the provider package is @ai-sdk/openai (NOT +# @ai-sdk/openai-compatible, which is chat/completions only and errors with +# "responses is not a function"). opencode resolves it at runtime, but +# pre-installing keeps first spawn off the network. +RUN npm install -g opencode-ai @ai-sdk/openai \ && npm cache clean --force \ && rm -rf /root/.npm /tmp/* diff --git a/docker/scripts/grok-agent-entrypoint.sh b/docker/scripts/grok-agent-entrypoint.sh index f3c97da2..97ce6fc8 100755 --- a/docker/scripts/grok-agent-entrypoint.sh +++ b/docker/scripts/grok-agent-entrypoint.sh @@ -16,6 +16,10 @@ python -m roboco.llm.providers.opencode_config # positional); `--` separates it from flags so a prompt starting with `--` # cannot be parsed as CLI options. The model also comes from the rendered # config; --model is passed explicitly as belt-and-suspenders. +# +# `< /dev/null` is REQUIRED: without a closed stdin, `opencode run` hangs after +# init in a headless / no-TTY environment (it blocks waiting on stdin). Verified +# live — closing stdin lets the run proceed to the model call and exit cleanly. exec opencode run \ --model "xai/${ROBOCO_AGENT_MODEL:-grok-build-0.1}" \ - -- "${ROBOCO_INITIAL_PROMPT:-}" + -- "${ROBOCO_INITIAL_PROMPT:-}" < /dev/null diff --git a/roboco/billing/pricing.py b/roboco/billing/pricing.py index d8ba69b2..50424555 100644 --- a/roboco/billing/pricing.py +++ b/roboco/billing/pricing.py @@ -112,6 +112,10 @@ def calculate_cost( tokens_output: Number of output tokens (completion). tokens_cache_read: Prompt-cache read tokens (charged at reduced rate). tokens_cache_write: Prompt-cache write tokens (charged at reduced rate). + Reasoning/thinking tokens that a provider reports *separately* from + output (e.g. xAI grok-build-*) are billed at the output rate by the + caller folding them into ``tokens_output`` (see + ``opencode_usage.cost_for_session``). Returns: Estimated cost in USD as a float. Returns 0.0 for unpriced models diff --git a/roboco/llm/providers/opencode_config.py b/roboco/llm/providers/opencode_config.py index 8e7b312e..72c873aa 100644 --- a/roboco/llm/providers/opencode_config.py +++ b/roboco/llm/providers/opencode_config.py @@ -31,7 +31,11 @@ from typing import Any _OPENCODE_SCHEMA = "https://opencode.ai/config.json" _PROVIDER_ID = "xai" -_OPENAI_COMPAT_NPM = "@ai-sdk/openai-compatible" +# grok-build-0.1 is driven through the OpenAI **Responses** API (opencode calls +# model.responses()). Only @ai-sdk/openai implements that — @ai-sdk/openai-compatible +# is chat/completions only and errors with "responses is not a function". +# Confirmed via a live opencode run against api.x.ai/v1. +_PROVIDER_NPM = "@ai-sdk/openai" # Plugins baked into the roboco-agent-grok image (see docker/agent-grok.Dockerfile). # secret-scrub ports the bash-guard deny rules to opencode's tool.execute.before. @@ -85,7 +89,7 @@ def build_opencode_config( "$schema": _OPENCODE_SCHEMA, "provider": { _PROVIDER_ID: { - "npm": _OPENAI_COMPAT_NPM, + "npm": _PROVIDER_NPM, "name": "xAI", "options": {"baseURL": target.base_url, "apiKey": target.api_key}, "models": {target.model: {"name": target.model}}, diff --git a/roboco/llm/providers/opencode_usage.py b/roboco/llm/providers/opencode_usage.py index 69d7947c..7bf2c6ba 100644 --- a/roboco/llm/providers/opencode_usage.py +++ b/roboco/llm/providers/opencode_usage.py @@ -106,10 +106,15 @@ def cost_for_session( usage = read_session_usage(db_path, session_id) if usage is None: return None, 0.0 + # opencode stores tokens_input as non-cached input (disjoint from + # tokens_cache_read) and tokens_output EXCLUDING reasoning, with reasoning + # in its own column. Reasoning bills at the output rate, so fold it into + # output. Verified against a live run: this reproduces opencode's own `cost` + # column (= xAI's authoritative cost) to the cent. cost = calculate_cost( model, tokens_input=usage.tokens_input, - tokens_output=usage.tokens_output, + tokens_output=usage.tokens_output + usage.tokens_reasoning, tokens_cache_read=usage.tokens_cache_read, tokens_cache_write=usage.tokens_cache_write, ) diff --git a/tests/unit/llm/test_opencode_config.py b/tests/unit/llm/test_opencode_config.py index ad4e2ac7..8c5b6787 100644 --- a/tests/unit/llm/test_opencode_config.py +++ b/tests/unit/llm/test_opencode_config.py @@ -71,7 +71,8 @@ def test_build_opencode_config_provider_and_model() -> None: instruction_paths=["/app/system-prompt.md"], ) provider = cfg["provider"]["xai"] - assert provider["npm"] == "@ai-sdk/openai-compatible" + # grok-build-0.1 needs the Responses API → @ai-sdk/openai, not -compatible. + assert provider["npm"] == "@ai-sdk/openai" assert provider["options"]["baseURL"] == "https://api.x.ai/v1" assert provider["options"]["apiKey"] == "xai-key" assert "grok-build-0.1" in provider["models"] diff --git a/tests/unit/llm/test_opencode_usage.py b/tests/unit/llm/test_opencode_usage.py index c2d6ab2d..d2f01ef2 100644 --- a/tests/unit/llm/test_opencode_usage.py +++ b/tests/unit/llm/test_opencode_usage.py @@ -28,6 +28,11 @@ _S2_IN, _S2_OUT, _S2_CREAD = 200, 70, 10 # grok-build-0.1: 1M input ($1.00) + 1M output ($2.00) = $3.00. _GROK_COST_1M_1M = 3.00 +# A REAL grok-build-0.1 session row observed from a live opencode run. Our +# pricing must reproduce opencode's own stored `cost` (= xAI authoritative). +_REAL_IN, _REAL_OUT, _REAL_REASON, _REAL_CREAD = 6120, 1, 226, 1856 +_REAL_COST = 0.0069452 + def _make_db( path: Path, rows: list[tuple[str, int, int, int, int, int, float]] @@ -109,3 +114,21 @@ def test_cost_for_session_missing_db(tmp_path: Path) -> None: usage, cost = cost_for_session("grok-build-0.1", tmp_path / "nope.db") assert usage is None assert cost == _ZERO_COST + + +def test_cost_reproduces_opencode_authoritative_cost(tmp_path: Path) -> None: + """Real observed row: our pricing must match opencode's stored USD cost. + + Proves the column semantics (non-cached input disjoint from cache_read; + reasoning separate, billed at output rate). + """ + db = tmp_path / "opencode.db" + # (id, input, output, reasoning, cache_read, cache_write, cost) + _make_db( + db, + [("real", _REAL_IN, _REAL_OUT, _REAL_REASON, _REAL_CREAD, 0, _REAL_COST)], + ) + usage, cost = cost_for_session("grok-build-0.1", db, session_id="real") + assert usage is not None + assert abs(cost - _REAL_COST) < _TOL + assert abs(cost - usage.opencode_cost) < _TOL