Commit Graph
8 Commits
Author SHA1 Message Date
Marc-André Bombeck 5dec158c9d fix(ai): keep the reasoning-field day for what a 400 taught
The dialect cache gave a day to every value that says the reasoning
field is refused, and that includes the combined "field refused, reasons
regardless" value, which one answerless reply writes. One reply could
therefore stretch a lesson to a full fresh day, against the rule that a
lesson read from a single reply keeps the hour.

A lesson now says what taught it. A 400 refusal gives a refused field
its day. A reply that upgrades a refused field to the combined value
keeps the refusal's remaining lifetime rather than a new day or the
hour, so the refused request does not come back early either.
2026-10-29 21:58:40 +01:00
Marc-André Bombeck b1fe1f0597 fix(ai): hold a refused reasoning field for a day
A gateway that refuses the reasoning field does so by its configuration,
so the lesson now holds for 24 hours instead of one. At the hour every
expiry cost one refused request, a few a day on a gateway that runs the
briefings unattended. Lessons read from a single reply keep the hour.

Refs #1279
2026-10-29 19:39:38 +01:00
Marc-André Bombeck b90980f2a2 fix(ai): keep a cut answer from a model never seen reasoning
A reply that ran into the token ceiling with unlabelled text was read as
reasoning the server did not separate whenever the ceiling held room for
reasoning, which is every call that carries a level, Off included. A model
that does not reason at all (llama3.1) lost its own truncated answer: the
call failed as reasoning_only, the briefing never reached its JSON-shape
retry, the Coach named the wrong error, the failure counted toward the
bench, and the client learned to ask with "none" for an hour.

Such text is now reasoning only once the endpoint and model have shown that
they reason: a reasoning field or a think tag in an earlier reply, or a
lesson that says so (knownReasoner). A sighting on a request that asked to
reason speaks only for requests that ask too. Otherwise the text is the
answer, cut off, with finishReason "length".

A lone closing </think> now splits a reply only where the chat templates put
it, at the start of a line, or anywhere once the model is known to reason. An
ordinary answer that mentions the tag in a sentence stays whole.

Two expectations encoded the old rule and move: the Coach loop case whose
first reply is unlabelled template-opened text from a model never seen
reasoning, and the Local case that read a never-seen model's cut text as a
thought. The streamed think-block splitting moved into local-reasoning.ts.

Refs #1276
2026-10-25 00:20:35 +02:00
Marc-André Bombeck 73cee35917 fix(ai): bound one completion call to two requests
A gateway that refuses reasoning_effort outright (LiteLLM in front of
Ollama: "does not support parameters: ['reasoning_effort']") in front of a
model that reasons without answering sent requests until something outside
stopped it. The answerless rule learned to ask with "none", the refusal of
that "none" was learned as "send no field" over it, the next request came
back answerless again, and each lesson undid the other.

Each call of the Local and the OpenAI-compatible gateway clients now carries
one request budget through every repeat (request-hops.ts): at most two
requests, after which the call ends with the failure it has. A structural
guard holds both clients to sending only through the budget and to handing
it to every repeat.

The refusal is now learned as what it is: an endpoint that refuses the
field, in front of a model already seen reasoning unasked, keeps both facts
(FIELD_REFUSED_REASONS_REGARDLESS), so it is never asked with "none" again
and keeps its thinking room. An answerless reply from an endpoint that
refuses the field is final. When the refused "none" was the repeat of an
answerless reply, the call fails as reasoning_only, which is what it ran
into. The refusal handling of both clients lives in wire-refusal.ts.

A status run that hits its bound now aborts the call it gave up on instead
of leaving it running detached.

Refs #1279
2026-10-25 00:11:42 +02:00
Marc-André Bombeck 79cd9ab493 fix(ai): stop asking a model with none once none did not help
The #1267 rule asks a reply that reasoned without answering once more with
reasoning_effort "none", and asks every later call that way for an hour.
That is what Gemma 4 on Ollama needs. gpt-oss behind vLLM accepts only
low / medium / high and ignores "none", and a GLM chat template reads an
effort it does not know as its maximum, so for those models the repeat and
every later "none" were wasted requests or longer thoughts.

When a reply sent as "none" comes back without an answer as well, the
Local and gateway clients now learn that too: later calls go out as the
call asks, with the thinking room #1276 keeps, and an answerless reply is
final. One call costs two requests at most and one after that, and the
log gets one line for each lesson instead of one per call.

A failed answerless call now carries what it cost on its error, and a
repeat that answers reports both requests, so the day's token ledger can
charge them. The learned lessons are pinned on globalThis, so the Coach's
route handlers and the worker's jobs share one copy per process instead
of learning the same thing twice.

Refs #1279
2026-10-24 20:24:50 +02:00
Marc-André Bombeck 221960af88 fix(ai): ask a thinking model again when it answered with reasoning alone
Ollama serves thinking models such as Gemma 4 on /v1/chat/completions with
the thought in a separate `reasoning` field. A long briefing can come back
with empty `content`: the thought used the whole token budget, or the
answer itself was parsed into `reasoning`. The background briefing always
asks to reason, so every briefing on such a model ended in the empty-reply
sentinel, which the chain logged as status 0 / cause "network" in the same
second the server logged 200, and the read path showed as a timeout.

The Local client and the OpenAI-compatible gateway now treat a reply with
reasoning and no answer as a lesson: the endpoint and model are remembered
in the reasoning dialect cache as answering only unreasoned, the call is
asked once more with reasoning_effort "none", and later calls ask that way
until the lesson expires. A test drives the real client, safeFetch and the
pinned dispatcher against a loopback server that answers the way Ollama
serves Gemma 4, and reproduces the reported log line without the fix.

Failed hops now carry what the call ran into: `failure` (timeout, aborted,
refused, reset, socket, parse, empty_response, reasoning_only, ...) and a
code-shaped `code` on ai.chain.link_failed, closed vocabulary only. The
cause only says "timeout" for a deadline, and the briefing failure marker
records the kind so the empty state suggests a longer timeout only when
time ran out.

Refs #1267
2026-10-22 13:01:10 +02:00
Marc-André Bombeck 1acd41eb58 fix(ai): keep tools on the forced final round and narrow reasoning-dialect learning
The Coach loop's final round sent toolChoice "none" without the tool
definitions. With tool_use/tool_result blocks in the history Anthropic
refuses such a request (400), and OpenAI Chat Completions refuses
tool_choice without tools, so every budget, time, cap and no-progress stop
ended in an error on those wires. The final round now carries the tools
with toolChoice "none", which every wire accepts, thinking included.

Codex kept reasoning items as provider state even when the request asked
for no encrypted reasoning; replaying such an item under store:false is a
400. Items are kept only when the request included
reasoning.encrypted_content and the item carries it.

The reasoning dialect cache learned from any 4xx that mentioned
reasoning or thinking (auth, rate limits, model names such as
deepseek-reasoner) and from replay errors, and kept the downgrade for the
life of the process. Learning now requires an HTTP 400 that refuses a
sent parameter, ignores replay errors and the model name, and a learned
downgrade expires after an hour.
2026-10-06 21:50:19 +02:00
Marc-André Bombeck 8d23c79ef5 feat(ai): add the reasoning support matrix and a per-endpoint dialect cache
reasoningSupport() reads what each provider can do with reasoning from the provider type and model name: effort, summaries, live summaries, state that must travel between tool rounds, and whether off is a real off. Unknown names are treated optimistically.

The dialect cache records what an endpoint refused (keyed by provider, endpoint and model) so a client retries with the lesser request once and never pays the refused round-trip again. Downgrades are marked on the wide event with meta keys only.
2026-10-06 19:26:28 +02:00