Skip to content
All posts

Three providers, three bugs, under two cents

Before publishing 0.1.0 I drove each first-party provider adapter against its real endpoint for the first time. All three runs found a shipping-blocking bug that four thousand passing tests could not see.

Sitharaj Seenivasan6 min read


The cheapest test I had never run

Arcturn's provider adapters were written, reviewed, and unit-tested long before any of them was pointed at the thing it adapts. That is normal, and it looked fine: north of four thousand tests across 212 files, green on every run.

Before publishing 0.1.0 I did the obvious remaining thing and drove each first-party adapter against its real endpoint — Anthropic on Claude Haiku 4.5, Google on Gemini 3.5 Flash Lite, OpenAI on GPT-5 nano through both its Chat Completions and Responses surfaces. One live run each: streaming text, a tool call whose result is fed back and answered from on a second turn, and cost accounting checked against the published rates.

Total spend: under two cents.

Three runs, three bugs. Each one would have shipped. Two were in features the documentation described as working.

Anthropic: the shape a program uses was the shape that hung

The Anthropic run worked when I typed it. Run from a script with output redirected to a file, it produced nothing at all — no events, no error, no exit, for as long as I left it alone.

The difference was not stdout. It was stdin. --print read stdin to EOF whenever isTTY was false, and "not a terminal" describes two situations that want opposite handling. A pipe carrying a prompt closes when its writer is done — cat q.txt | arcturn -p — and reading to EOF is exactly right. A pipe a parent process opened and kept never closes. That is every CI runner, every Makefile recipe, every subprocess.run, and every agent that spawns this binary. Reading to EOF there waits forever, before a single event is emitted, with nothing on stderr to say why.

So the one invocation shape a program uses to drive an agent was the shape that hung, silently. Interactive use — the way I had used it for months — was the case that worked.

The fix is in packages/cli/src/cli-main.ts, in readPipedStdin, and the rule is that the prompt argument decides which situation this is. With no prompt argument, stdin is the prompt and blocking to EOF is the only correct behaviour. With a prompt argument, stdin is optional leading context: wait 250ms for a first byte, and if none arrives, run what was actually asked for. A real producer wins that race; an inherited pipe never does. The residue is a slow producer beside a prompt argument, whose context gets dropped rather than the run hanging — the safe direction, and written down as such.

Fixing the read alone moved the bug instead of removing it. The abandoned next() still held stdin's handle, so the run finished its work, emitted runEnd, and then never exited. Same hang, other end of the run, and it reads as fixed if you only check the output. The iterator is now closed and the handle unref'd when the deadline wins, and there is a test called releases the abandoned pipe so the process can still exit, because that is the failure that would otherwise have been declared a success.

Google: the deferred nicety was a total feature failure

Turn one of the Gemini run was clean. The model emitted a functionCall, arcturn assembled it, the tool executed, the result went back.

Turn two came back 400 INVALID_ARGUMENT:

Function call is missing a thought_signature in functionCall parts.
This is required for tools to work correctly.

Gemini 3 signs the call, not only the thinking that led to it, and requires the signature back on the next request. The adapter round-tripped thoughtSignature for thinking parts and dropped it for functionCall parts on both legs: the parser only read it under kind === "thinking", and the outgoing part was built without it. Every multi-turn tool use on Google failed on its second request, which is to say the agent loop had never once completed on that provider.

The part I keep coming back to is where this was already filed. PLAN.md listed it under "Contracts v2 · reasoning continuity" — an optional signature on ToolCallContent, wanted for fidelity, to get to later. A known item, correctly described, ranked as a nicety. It was the difference between tool calling working and tool calling not working at all, and nothing short of a live second turn could tell those two readings apart.

ToolCallContent.signature now exists in packages/types/src/messages.ts, parallel to ThinkingContent.signature, which is there because Anthropic rejects unsigned thinking the same way. It is carried, never interpreted: the provider's token, replayed verbatim. Two tests, both verified failing first, pin each leg.

The same session turned up a smaller one. google/gemini-2.5-flash-lite is in the catalog and Google now answers 404 for it — no longer available to new users. /model refresh is the designed answer for a stale catalog; the entry is still stale.

OpenAI: 929 lines nobody could select

The third bug is my favourite, because there was nothing wrong with the code.

openai-responses is a registered provider. builtins.ts registers it, the default API-key map points it at OPENAI_API_KEY, the docs list it in the provider table beside openai, the CLI README names both surfaces, and 41 unit tests cover the adapter. At 929 lines it is the largest provider file in the repository.

I went to run it live, and:

$ arcturn -p "..." -m openai-responses/gpt-5-nano
Unknown model "openai-responses/gpt-5-nano".

That message is followed by the whole model catalog, which is the exact list the entries were missing from, and then by a hint suggesting registerModel().

It shipped with zero catalog entries, and --model resolves against the catalog. The adapter was unreachable to every CLI user. The only way in was an embedder calling registerModel themselves. Driven directly with a hand-registered spec, it streams text, emits a tool call, takes the result back and answers from it — the adapter was fine the whole time. This was a reachability defect, which is exactly why every unit test passed.

The catalog now derives the Responses entries from the Chat Completions list — 13 models, same names, same limits, same prices, one copy of the literals. The regression test is the general form of the bug: makes every registered provider reachable from the catalog. A provider nobody can select is a provider that does not exist.

What "verified" is a word for

None of these three was reachable by a unit test, because none of them was a mistake about what the code does. The stdin read did exactly what it said. The Gemini adapter round-tripped exactly the signatures it was written to round-trip. The Responses adapter worked perfectly and was simply not in a list. Every mock agreed with every implementation, which is the one thing mocks reliably do.

So: verified is a word for things that have actually run. That distinction is why the provider table has a Verified live column rather than a longer list of ticks, why the docs separate implemented from demonstrated, and why no status on this site can be upgraded before the disclosure that backs it changes first.

Six provider paths carry a tick today. Bedrock, Vertex and Azure carry a warning: they have never reached their endpoints, because each needs a cloud account rather than an API key. Azure and Vertex at least reuse stream translation the verified runs exercise; bedrock shares none of it and is the largest genuinely untested surface in the project.

Given the hit rate above — three for three, at under two cents — I would not bet on those three being clean. The honest list is the product. When they run, I will say what they found.


Author & support

Sitharaj Seenivasan

Arcturn is built and maintained by one person. If it is useful to you, the links below are the ways to say so.

Every turn counts.

Start a session, watch every tool call ask first, then go back and read exactly what happened.