feat(providers): probe streaming native-tool contract at setup - #315
Merged
Conversation
A live API key proves the account, not the route. `/v1/models` proves
reachability, and the pre-save key check proves one non-streaming
completion with no tools — neither exercises what an Atomic turn
actually is: a streamed chat completion carrying a `tools` payload from
which a native tool call must come back whole. Routes that pass both and
then fail a turn are common (STREAM_EARLY_EOF, HTTP 400 only once
`tools` is in the body, forced tool choice refused, incomplete tool-call
deltas), and until now the operator's first message was the test.
Adds a conformance probe next to the existing key check, built as a
three-rung ladder so a healthy route costs exactly one request:
1. forced named tool, streaming, tools payload — a complete tool call
here is the whole answer;
2. `tool_choice: auto` with the same payload, reached only when rung 1
was refused for a reason that is not the key, the quota or the
model — a tool call now means it was the forcing the route refused;
3. the same streamed completion with no tools at all, reached only
when both tool requests were refused — if this answers, the route
works and it is specifically `tools` it rejects.
Rung 3 is an experiment rather than a regex over error wording, which is
not stable across providers; "refuses with tools, answers without them"
is. A text answer under `auto` stays inconclusive and is never reported
as "tools unsupported".
The synthetic function is a diagnostic fixture: nothing registers it,
nothing dispatches it, and its name sits outside the dotted namespace
every built-in tool uses so it cannot shadow one. Details are bounded
and credential-scrubbed — the exact key by match, key-shaped strings by
pattern — and that redaction is now shared with the key check.
Wired into the two setup paths (Providers wizard save, first-run cloud
onboarding) as advisory only: it can never refuse a save, so a custom
endpoint that blocks synthetic probes stays configurable, but a route it
could not prove no longer reports as a working backend. `/llm check`
runs it on demand against a provider that is already saved. It never
runs on a turn path.
Review of the contract probe found several verdicts that describe something Atomic did, not something the route did. Each one reaches an operator as a sentence about their provider, so each is a bug. * **Our deadline was reported as `stream_early_eof`.** The timeout flag was discarded the moment any byte arrived, so a queued OpenRouter request (it sends `: OPENROUTER PROCESSING` while it waits) or a slow first token was classified as "closed the stream before finishing the answer. Turns will end mid-tool-call on this route" — and blocked from `reportModelConfigured`. The flag now survives: only a stream that announced its own end before the timer fired is classified at all, anything else is `timeout`, which is what it always was. * **An abort mid-stream was reported the same way.** Once headers had arrived the reader's rejection was swallowed and the partial body classified, so cancelling produced an invented route defect. The read now races the caller's signal, which also means Esc gets the screen back at once instead of at the deadline. * **Forced-tool-choice verdicts warned about a request Atomic never makes.** `step-executor` sends `tool_choice: "auto"` on every turn, deliberately, with production-observed reasons written down beside it. A route that refuses a forced choice and streams a complete call under `auto` therefore runs every real turn — it is now `proven`, not a warning that suppresses "this install has a working backend". A route that *accepts* forcing and ignores it no longer stops the ladder either: rung 2 asks the way a turn asks, and only if that also produces nothing is `forced_tool_choice_ignored` reported. * **The probe sent no `max_tokens`, which every turn sends.** `buildOpenAiChatBody` sets it unconditionally and has no `max_completion_tokens` fallback, so a model that rejects the field fails every turn — exactly the class of failure this probe exists to catch, and one it passed. The cap is now in the body, generous enough (1024) that it cannot truncate a one-field tool call, a `retry_token_field` refusal is reported as the new `token_cap_rejected` instead of being retried in a shape Atomic never uses, and `finish_reason: "length"` is read as inconclusive so our own cap can never be reported as a route defect either. Smaller corrections from the same review: * `parallel_tool_calls` is no longer hardcoded: the wizard resolves what a turn would send (`agent.maxParallelToolCalls > 1`). * Keyless-listing presets (Nous, Novita, Ollama Cloud, SambaNova, Sarvam) are skipped rather than probed with an empty credential and told their absent key "was rejected" — that flag is about listing models, not about completions. * `contractProbeFoundDefect` is gone. It had no caller and its doc described a warning policy the shipped code contradicts. * One timeout constant instead of two: the 20s default no caller passed described a budget that never existed. * The unreachable `MAX_PROBE_REQUESTS` guard is gone; the ladder is three fixed rungs and the deadline is the real bound. * `/llm check` is now in the `/llm` menu description, and prints the redacted provider detail when the route did not pass — until now that string was computed, scrubbed and never shown to anyone.
Three claims the feature is sold on had no test that could fail. Both save paths could be deleted wholesale and every test still passed: the wizard's `completeWizard` tests all stop at a refused key, so nothing reached the probe call, the gate, or the note the operator gets. `completeWizard` now runs end to end with only the disk writes stubbed — real key check, real probe, real gate — in two shapes: a route that streams a native tool call is saved *and* reported as a working backend, and a route whose stream ends early is saved, warned about, and not reported. First-run onboarding gets the same pair. Deleting either call site, or weakening the gate to `gate.warning === null`, now fails four tests. The redaction test was vacuous: 400 filler characters sat in front of the credentials, so the 300-character cap satisfied both assertions on its own and the test passed with `redactProviderDetail` reduced to a plain slice. The fixture now puts both keys inside the cap, and `redact-provider-detail.test.ts` pins each rule on strings short enough that truncation cannot do the work — including the pattern pass, the one capability this adds over the key check's exact-key removal, which nothing in the repo detected the loss of. Verified by mutation, each of these now fails at least one test: redaction disabled; the `KEY_SHAPED` loop deleted; the probe removed from either save path; the gate weakened; the old "timed out with zero bytes" rule restored; the abort dropped from the read race; `max_tokens` removed from the probe body; `forced_tool_choice_rejected` dropped from the proven set.
praxstack
pushed a commit
to praxstack/AtomicBot-ai-atomic-agent
that referenced
this pull request
Sep 4, 2026
Conflict resolutions against the context-usage work already on the rc branch: - runtime/bootstrap.ts: one save at the end of a turn now stamps both the turn's context usage (AtomicBot-ai#320) and the provider/model it ran on (AtomicBot-ai#321). Two PRs rewrote the same choke point; keeping either alone silently dropped the other's stamp. - session/index.ts: both barrels re-exported. - bootstrap.test.ts: the two near-identical turn-stamp tests folded into one that asserts both stamps, returned and reloaded. Also fixes merge-caused breakage between AtomicBot-ai#309 and AtomicBot-ai#315: AtomicBot-ai#309 made `OpenAiToolCallDelta.index` optional, so the contract probe's accumulator no longer type-checks and would have keyed every index-less call into slot 0. It now resolves call identity the way the stream consumer does — index > id > the call currently open.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
A successful
/v1/modelsrequest proves an OpenAI-compatible endpoint isreachable. The pre-save key check (
verifyProviderKey) goes further and provesthe credential is live and funded — but it sends one non-streaming completion
with no tools.
Neither exercises what an Atomic turn actually is: a streamed chat completion
carrying a
toolspayload, from which a native tool call has to come backwhole. Routes that pass both checks and then fail a real turn are exactly what
issue #110 reports from the field —
STREAM_EARLY_EOF, HTTP 400 that appearsonly once
toolsis in the body, forced tool choice refused, incomplete SSEtool-call deltas — and until now the operator's first message was the test.
The fix
A conformance probe next to the existing key check, extending
src/llm/provider/verify/rather than starting a parallel subsystem. It reusesclassifyVerifyResponsefor auth/quota/model refusals (so the two checks cannotdisagree about a 402 or a Gemini 400),
parseOpenAiSseEvent+mergeToolNamefor reading the stream (so the probe and a real turn cannot disagree about what a
provider sent), and
openAiFetchfor transport.The ladder
Three rungs, each reached only when the one above left the question open, so a
healthy route costs exactly one request:
here is the whole answer →
tools_supported.tool_choice: auto, same payload. This is the request a real turn makes,so it is what settles anything rung 1 left open — a refusal that was not
about the key, the quota or the model, or an acceptance the route then
ignored. A tool call here after a refusal means only the forcing was
unacceptable (
forced_tool_choice_rejected); after an ignored forcing itmeans the route emits tool calls in the mode Atomic uses (
tools_supported).both tool requests were refused. If this answers, the route works and it is
specifically
toolsit rejects →tools_payload_rejected; if it fails too,the failure was never about tools →
provider_error. Skipped when rung 1streamed:
toolsin the body is already demonstrably fine, and the controlcould only mislead.
Rung 3 is deliberately an experiment, not a regex over the error body.
Provider wording for "tools unsupported" is not stable enough to hang a verdict
on; "refuses with tools, answers without them" is unambiguous, and it is the same
evidence a human would gather by hand. That is why no tools-related wording
matching exists anywhere in the classifier.
The request is the request a turn makes
Same streamed transport, same tools payload shape,
parallel_tool_callsresolvedthe way
step-executorresolves it (agent.maxParallelToolCalls > 1), and thesame
max_tokenscap —buildOpenAiChatBodysets that on every turn and has nomax_completion_tokensfallback, so a model that rejects the field fails everyreal turn. A probe that omitted it could pass a route whose every message 400s,
which is the exact failure #110 is about; a
retry_token_fieldrefusal istherefore reported (
token_cap_rejected) rather than retried in a shape Atomicnever sends. The cap (1024) is far above a one-field tool call, and
finish_reason: "length"is read as inconclusive so it can never be reported asa route defect either.
The one thing a turn sends that the probe cannot is a forced tool choice —
because a turn never sends one.
tool_choice: "auto"is deliberate instep-executor, with production-observed reasons written down beside it (theAlibaba/Qwen-thinking gate answers
400 InvalidParameterto a forced choice atall). The forced rung is the probe's instrument, not a requirement: it is the
only mode in which "no tool call" says something about the route rather than
about the model's mood.
Statuses distinguished
tools_supported,inconclusive_no_tool_call,forced_tool_choice_ignored(forced choice accepted and ignored, and nothing called under
autoeither),forced_tool_choice_rejected,tools_payload_rejected,token_cap_rejected,model_unavailable,endpoint_auth_failed,quota_or_routing_failed,stream_early_eof,malformed_tool_call, plusunreachable/timeout/cancelled/provider_error. Each gets a sentence naming the next move(
describe-contract-probe.ts), notHTTP N.stream_early_eofis judged first and unconditionally among acceptedstreams: a tool call assembled out of a body that never announced its end may be
missing argument fragments still in flight, which is the mistake
applyToolCallTerminationSafetyalready exists to prevent on the real path. Itis reached only for a body that actually stopped — our own deadline and our own
abort are reported as
timeoutandcancelled, never as a route defect.Safety properties
registers it, nothing dispatches it, the probe does not import the registry,
and its name (
atomic_contract_probe) sits outside the dotted namespace everybuilt-in tool uses so it cannot shadow one. A test pins that against
DEFAULT_TOOL_DESCRIPTORS.autois a legalmodel choice; it is reported as unproven, never as incompatible.
reportModelConfiguredrequires aclean key check and a probe that proved the route. A skip (local server,
CLI-backed provider, no key yet) reports as a skip with its own reason, never
as a pass.
choice and streams a call under
autoruns every real turn, so it counts asproven and produces no warning.
endpoint the operator knows works must stay configurable, so the probe is
advisory and the warning is the override.
credential-scrubbed: the exact key by match plus key-shaped strings
(
sk-…,AIza…,Bearer …) by pattern. That redaction is now shared withverifyProviderKey, which previously only removed the exact key./llm check.Wiring
gate passes and while Esc can still abandon the whole submit (nothing has
reached disk yet). 12s budget, and the abort reaches the open stream, not just
the connect.
/llm checkruns it on demand against a saved provider, described to the probethrough the same
configurewizard state the panel builds forc, so endpoint/ key / model resolution is literally the wizard's rather than a second
implementation that could drift. It prints the redacted provider detail when
the route did not pass.
Review round (commits 2–3)
An adversarial review of the first commit found five high-severity problems.
All are addressed:
reportModelConfiguredgate could be deleted and every test still passed.completeWizardand first-run onboarding are now driven end to end with onlythe disk writes stubbed, in both shapes (proven → saved and reported;
early-EOF → saved, warned, not reported).
cap satisfied it with redaction removed. Fixture fixed, and
redact-provider-detail.test.tspins each rule on short strings.stream_early_eofas soon as onebyte had arrived (an OpenRouter queue keepalive is enough). Now
timeout.forced_tool_choice_rejectedis proven; an ignored forcing escalates to theautorung instead of stopping there.max_tokens, which every turn sends and some modelsreject — a pass could precede a turn failure of exactly the class Provider setup should probe streaming native-tool compatibility #110 is
about. Now sent, with
token_cap_rejectedfor the refusal.Also from that review: a mid-stream abort is
cancelledrather thanstream_early_eof; the deadcontractProbeFoundDefect(whose doc described apolicy the code contradicted) is gone;
parallel_tool_callsis resolved insteadof hardcoded; keyless-listing presets are skipped rather than told their absent
key "was rejected"; the unreachable
MAX_PROBE_REQUESTSguard and the second,unused timeout constant are gone;
/llm checkis in the/llmmenu description;and the redacted detail now has a consumer.
Test evidence
(The first version of this PR quoted "27 files / 415 tests" for the third
command; that number was wrong and did not reproduce. The numbers above are from
runs on the current head, Node v25.7.0.)
The broad sweep reports one pre-existing unhandled error unrelated to this
change:
spawn .../llama-server EACCESfromsrc/tui/local-models/local-models-orchestrator-auto-update.test.ts, anenvironment permission issue on this machine. No test failed.
New test files:
run-contract-probe.test.ts(23),classify-contract-probe.test.ts(17),probe-wizard-contract.test.ts(9),accumulate-probe-stream.test.ts(7),redact-provider-detail.test.ts(6),plus new cases in
providers-orchestrator.test.ts,cloud-provider-onboarding.test.tsxandslash-command-handler.test.ts.run-contract-probe.test.ts"proves support from one forced, streamed native tool call" (also asserts exactly one request)tools/tool_choiceclassify-contract-probe.test.tsredact-provider-detail.test.ts(per rule) + "keeps credentials and whole response bodies out of the detail"providers-orchestrator.test.ts"probes the route on save and reports a backend only once it is proven" / "saves but reports no working backend when the probe fails";cloud-provider-onboarding.test.tsx"exercises the streaming tool contract before finishing first-run" / "carries the probe's verdict into the finish note without blocking the save"Each of these mutations now fails at least one test: redaction disabled; the
KEY_SHAPEDloop deleted; the probe removed from either save path; the gateweakened to
gate.warning === null; the old "timed out with zero bytes" rulerestored; the abort dropped from the read race;
max_tokensremoved from theprobe body;
forced_tool_choice_rejecteddropped from the proven set.Deliberately left out
"with an explicit warning/override". This ships the warning half: the probe
cannot refuse a save, so every endpoint remains savable and the operator is
told what was not proven. A persisted "I know, stop telling me" acknowledgement
is a config-shape decision (where it lives, whether it survives a model change)
I did not want to make unilaterally.
emitted as
providers_status+runtime_infoand not persisted, so a staleverdict can never outlive the route it described. A badge on the provider row
would need a staleness policy first.
max_completion_tokensfallback in the probe. Adding one would make theprobe pass a route the turn path cannot use; the fix for such a route belongs
in
buildOpenAiChatBody, as its own change.openai-provider.ts'shealth(). That method is called onpaths where spending a generation would be wrong; the probe stays setup-time
and on-demand as the issue requires.
OpenAI-compatible contract, which is what Atomic uses for every cloud kind
including Gemini's
/v1beta/openai.Fixes #110