Skip to content

feat(models): evaluate xAI models directly via the Responses API - #50

Merged
BenchCAD merged 10 commits into
mainfrom
feat/xai-grok-adapter
Sep 26, 2026
Merged

BenchCAD merged 10 commits into
mainfrom
feat/xai-grok-adapter

Conversation

@BenchCAD

@BenchCAD BenchCAD commented Aug 8, 2026 •

Copy link
Copy Markdown
Owner

xAI was only reachable through openrouter/x-ai/*. grok-* and xai/<slug> ids now route to a new adapter calling https://api.x.ai/v1, reading XAI_API_KEY (or GROK_API_KEY).

uv run python benchcad.py --task vision2code --model grok-4.5
uv run python benchcad.py --model grok-4.5:reasoning=low

xAI serves an OpenAI-shaped /v1/responses, so this is the openai adapter with a different key, base URL and effort ladder; images and :reasoning= behave as they do elsewhere. The Responses-API usage reader moved to models/__init__.py, shared by both.

Two model-id forms. grok-* covers the published line. xai/<slug> is the escape hatch — not every model xAI serves is named grok-something, and a key may be ACL-scoped to a subset, so prefix-only routing would send a valid model to the wrong provider. The slug after xai/ is passed through verbatim, so no model list is baked in.

How the adapter calls xAI (measured against the live API)

  • gen.max_tokens is not forwarded. A cap in the model's working range acts as a reasoning target rather than a ceiling. With max_output_tokens=16000 a record used 32395 output tokens in 398 s; with no cap, 10089 tokens in 134 s. Both runs produced a complete program. A fixed max_output_tokens=512000 backstop, far above anything observed (~33k), is sent instead.
  • Requests are streamed. The endpoint buffers the answer either way. A stream, though, sends a keepalive every ~15 s, and a silent non-streamed connection does not survive a long pass: on identical requests, 4/4 streamed and 2/4 non-streamed calls returned. The SDK timeout applies per read, so it bounds a stalled connection, not a long generation.
  • No timeout floor. gen.timeout is used as configured. Two earlier revisions clamped it up, to 900 s and then 3000 s. Both values were sized from latencies that silently included the SDK's retries.
  • Temperature 0.7, the endpoint's own default, pinned explicitly. Other adapters send 0.0, but reasoning length varies run to run regardless. It is withheld from grok-3, and a 400 that mentions temperature is retried once without it.
  • The effort ladder (none|low|medium|high|xhigh) is validated at parse time. A ladder borrowed from another provider therefore fails before the run starts, not partway through it.

Also in this PR: concurrent evaluation

An optional concurrency: block (api_workers, score_workers; defaults 1 and 2, i.e. the previous sequential behaviour) backed by benchcad_core/parallel.py. The pools are separate because a model call is a socket that blocks for minutes, while scoring is a ~0.5 GB CadQuery subprocess that takes seconds. It also handles three failures that only appear under load:

  • Ground-truth composites are pre-rendered on the main thread, because VTK aborts if a worker opens a render window.
  • The results.jsonl read-modify-write is serialised.
  • A worker exception fails one record rather than the batch.

Tests

The offline tests cover:

  • routing, including that openrouter/x-ai/grok-4.5 still goes to OpenRouter;
  • the suffix parser;
  • against a stubbed streaming client, the request body itself: endpoint and key, the system prompt in instructions, the backstop, the temperature policy and its retry, and PNG data-url inlining;
  • the concurrency helpers.

ruff is clean.

Not covered

pricing.yaml has grok-4.5 / grok-4.3 at the under-200k tier; xAI doubles both above 200k prompt tokens, so cost_usd understates a long-context run. Unlisted models record cost_usd: null.

xAI was only reachable through `openrouter/x-ai/*`. `grok-*` and `xai/<slug>`
ids now route to a new adapter calling https://api.x.ai/v1, reading
XAI_API_KEY (or GROK_API_KEY).

xAI serves an OpenAI-shaped /v1/responses, so this is the openai adapter with a
different key, base URL and effort ladder. The Responses-API usage reader moves
to models/__init__.py, shared by both. `grok-*` covers the published line;
`xai/<slug>` is the escape hatch for models served under another name, passed
through verbatim.

Verified against the live API, which corrected two guesses made from the docs:

- Temperature: docs say it is unsupported by reasoning models, but a reasoning
  model accepted `temperature: 0` and echoed it back. A benchmark wants
  determinism, so it is sent by default and withheld only from grok-3, the one
  family the docs name. A 400 mentioning temperature retries once without it
  rather than taking down a run; other BadRequestErrors propagate.
- Output budget: passed through unchanged. An earlier revision floored it, to
  stop reasoning starving the answer. That bought nothing — the API does not
  truncate the answer to fit; a 2000-token cap returned 2047 tokens and a
  complete program — and cost a lot, because these models spend the whole budget
  on reasoning at ~36 tok/s. The floor put a call at ~15 min, past the configs'
  per-call timeout, after which the SDK retries twice and burns ~30 min per
  record silently. That is what a run that appeared to hang was doing.

The effort ladder (none|low|medium|high|xhigh) is validated at parse time, so a
ladder borrowed from another provider fails before the run, not partway through.

38 offline tests: routing, the suffix parser, and the request body against a
stubbed client — endpoint, instructions vs system turn, budget pass-through,
temperature policy and its retry, PNG data-url inlining.
@BenchCAD
BenchCAD force-pushed the feat/xai-grok-adapter branch from be28caf to 6005778 Compare August 8, 2026 22:55
@BenchCAD BenchCAD changed the title feat(models): evaluate xAI Grok directly via the Responses API feat(models): evaluate xAI models directly via the Responses API Aug 8, 2026
The timeout is the only effective bound on an xAI call. `max_output_tokens`
looks like one but is not: it bounds the answer, not the reasoning, so a request
capped at 2000 output tokens came back with 5360 tokens — 5326 of them
reasoning — and reported status=completed. Raising or lowering it does not
bound cost or duration.

900s comes from measurement. A high-effort call on a hard vision task normally
takes 4-5 minutes, but re-running an identical request can blow past 7 minutes,
so the latency tail is stochastic rather than a property of the prompt. The
floor mirrors the anthropic and openrouter adapters, which already floor their
reasoning paths; a larger explicitly configured timeout still wins.

The openai SDK's default max_retries=2 is deliberately left in place. An earlier
note in the openai adapter says its spirals re-spiral on retry, which would make
retries pure waste — but that is not what xAI does here, where the same request
completed once and stalled once, so a retry has a real chance of succeeding.
Retries also cover the 429s any concurrent run will meet. The cost is that a
hopeless call takes up to 3x the timeout, which is precisely why the floor is
900s and not an hour: bound the worst case in the timeout, not the retries.
@BenchCAD

BenchCAD commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

Added a 900s floor on the per-call timeout, and a note on why max_output_tokens cannot serve as one.

max_output_tokens bounds the answer, not the reasoning. Measured against the live API on a hard vision task:

cap elapsed output tokens of which reasoning visible answer
2000 112s 5360 5326 34
4000 217s 10657 10609 48
8000 268s 12319 12271 48

Every call overshot its cap and still reported status=completed with incomplete_details: null. The visible answer never approaches the cap, so the cap never binds — it is not a cost or duration control.

That leaves the timeout as the only real bound. 900s is chosen against the measured normal case of 4-5 minutes, with the openai SDK's default max_retries=2 left in place: the openai_adapter notes that its spirals re-spiral on retry, but xAI's tail is stochastic rather than prompt-determined (an identical request completed in 268s once and exceeded 420s another time), so a retry can genuinely succeed — and retries also cover the 429s any concurrent run will hit. A hopeless call therefore costs up to 3x the timeout, which is why the floor is 900s rather than the hour it is tempting to set. A larger explicitly configured timeout still wins.

109 tests pass.

On every other provider a token cap is a ceiling. Here it behaves as a reasoning
target, so sending one makes a run slower rather than safer. Same record, high
effort, only this parameter varied:

    max_output_tokens=16000  ->  32395 output tokens (32158 reasoning), 398 s
    max_output_tokens absent ->  10089 output tokens (10022 reasoning), 134 s

Both returned status=completed with a complete CadQuery program, and note the
capped call overshot its own cap 2x. The parameter only ever bounds the visible
answer, which is a few dozen tokens and never approaches it — so it cannot serve
as a cost or duration control, and passing a large value to "remove the limit"
is the worst of the options: it triples reasoning and wall-clock for no gain.

The per-call timeout floor is left as the only real bound.
…3000s

The 900s floor was calibrated on a 20-record sample where every call that hit it
was rescued by an SDK retry, which made it look safe. Over a 299-record
high-effort sweep it is not: successful calls have a median latency of ~1190s
and a maximum of ~2520s, so 900s truncates more than half of the requests that
are working normally. Each one then burns the SDK's full 3x retry budget
(~2700s) and frequently still fails, so the "bound the worst case" floor was
converting healthy requests into expensive failures.

3000s covers the observed maximum with margin. An explicitly configured larger
timeout still wins, and max_retries is still left at the SDK default.
… memory

A run is dominated by waiting: each model call blocks for minutes while the
runner sits in a sequential for-loop, so a 17,900-record sweep is measured in
weeks. Concurrency is the obvious fix and the obvious implementation is wrong --
sizing one pool for both halves of a record either starves the API or kills the
machine, because the two halves cost completely different amounts of memory.

A model call is a socket plus a base64 image, a few MB, blocked on the network
for minutes. Scoring spawns a CadQuery/OCP subprocess (~0.5 GB resident) and
voxelises two meshes, and takes seconds. So `concurrency:` exposes two pools:

    concurrency:
      api_workers: 8       # concurrent model calls
      score_workers: 2     # concurrent CadQuery runs -- bounds peak memory

A thread waiting on the API holds no scoring slot, which is what lets a large
API pool sit in front of a small scoring pool without deadlocking. The ratio is
forgiving: a couple of scoring slots keep up with dozens of API workers. Sized
naively at 64 concurrent scorers, the same work needs ~25 GB.

`api_workers: 1` is the default, so existing configs and their output are
unchanged; the sequential branch is kept verbatim rather than emulated.

benchcad_core/parallel.py also handles three things only visible under load,
each found by hitting it:

- VTK renders only on the main thread. Its Cocoa backend aborts the *process*
  with an NSException if a worker builds a render window, and it leaks a
  graphics context per render, so a few hundred in-process renders exhaust
  memory. Ground-truth composites are therefore warmed up front, on the main
  thread, into the on-disk cache the workers read.
- run_record persists results.jsonl read-modify-write, which silently drops rows
  when threads interleave. The write re-reads and merges under a lock.
- A worker exception is reported against its record instead of ending the batch;
  a record whose ground truth cannot be tessellated is dropped during the warm-up
  and named, so a data defect is not mistaken for a model failure.

Also stops tracking Vision2Code/raw_responses.jsonl, which slipped into the
previous commit -- it is fetched model output, regenerable, and not source. It
carries no model identifier. lite_data/ is ignored and excluded from ruff for
the same reason test_data/ is: it holds CadQuery programs, not source.
@BenchCAD
BenchCAD force-pushed the feat/xai-grok-adapter branch from e33c805 to 5009ef6 Compare August 10, 2026 19:29
Warming the ground-truth composite cache was not enough to keep VTK off the
worker threads. Scoring renders a second image -- a preview of the *generated*
solid -- and that call reached vtkCocoaRenderWindow from a worker and aborted
the process with an NSException on the first concurrent run against the live
API. The unit tests missed it because they stub the runner, so no real render
site was exercised.

The preview is explicitly non-critical to scoring, so rather than dropping it,
`deferred_previews()` records the (step, png) pairs and `replay_previews()`
draws them on the main thread once the pool has drained. Prompt building calls
the same function without an output path and is served from the cache
`prerender()` warmed, so it passes straight through and never reaches VTK.

Verified end to end against xAI with api_workers=4 / score_workers=2: four
records scored, four previews rendered, four rows persisted, 41.6s wall clock
against 100.6s of summed API latency.
The adapter stopped sending max_output_tokens entirely, on the reasoning that a
cap behaves as a reasoning target rather than a ceiling here -- a request capped
at 2000 came back with 5360 tokens. That observation still holds, so the run
config's budget is still not forwarded: a cap near the working range distorts
the run rather than bounding it.

Omitting the parameter outright was a step too far though. It leaves the request
open-ended for no benefit, and it was briefly suspected of causing calls to hang.
That suspicion was tested and is wrong -- at concurrency 1, with the endpoint
degraded, 512000, 16000 and no cap at all time out identically, including the
exact 16000 that was in flight during earlier successful runs. The cap is not a
reliability knob.

So send a ceiling far above anything observed (max ~33k reasoning tokens) as a
backstop: it never binds, so it cannot shape the answer, but the request stays
bounded instead of open-ended.
The adapter floored the per-call timeout, first to 900s and then to 3000s. Both
values were derived from observed latencies that silently included the openai
SDK's retries, so a call that really took ~350s looked like it took ~1200s and
the floor kept being raised to "protect" work that was never at risk.

Measured directly, without the SDK in the path: a successful call completes in
280-410s, and a call that has not returned by then does not return at all. The
floor therefore never rescued a single request. What it did do was override the
run config without saying so -- a fetch configured for 450s was silently issued
at 3000s, so every dead call cost 9000s across its retries instead of 1350s, and
throughput came out roughly 50x below what the same requests achieved when
measured outside the adapter.

The caller decides now. Task configs already carry an explicit `gen.timeout`.
Not for incremental output -- this endpoint buffers content and delivers it in a
burst at the end either way. Streaming is for the keepalive.

A high-effort call spends 300-1100s generating, and a non-streaming request
sends no bytes at all for that entire window. Silent connections of that length
do not survive the path to this endpoint: they are blackholed, which is why
every failure was an indefinite hang rather than an error, a reset, or a 429.

Paired A/B on identical requests, one at a time: 4/4 streaming versus 2/4
non-streaming, and the streaming set included a 1109s call that produced 40k
reasoning tokens -- a pass no non-streaming timeout could have survived. That
also explains the rest of the evidence: effort=none (5s) and low (30s) always
succeeded, while medium and high did not; image size, record identity, token cap
and concurrency were all irrelevant; and successful calls always ran at full
speed rather than degrading, which is a connection-layer failure signature
rather than a capacity one.

The practical cost of getting this wrong was large: long-but-healthy passes were
being cut off and recorded as endpoint failures, and the response was to keep
tuning timeouts and worker counts against a problem that was not there.

The keepalive fixes the timeout semantics too. httpx applies its read timeout
per read, and a frame arrives every ~15s, so a generous per-call timeout no
longer truncates a legitimately long generation.
The CHANGELOG entry and the module docstring were written against revisions of
this branch that later commits reversed. They said the adapter floored the
timeout, first at 3000 s and then at 900 s, in the same paragraph, and that a
large output cap is the worst case, while the code sends a 512000-token
backstop and has no floor at all.

Describe what ships. `gen.max_tokens` is not forwarded, and a fixed backstop
is sent in its place. Requests are streamed so their keepalive holds a long
pass open. The caller's timeout is used as given. Temperature is pinned to
0.7.
@BenchCAD
BenchCAD merged commit ad495aa into main Sep 26, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant