Skip to content

fix(gateway): keep reasoning-model conversations from stalling#46

Merged
albanm merged 26 commits into
mainfrom
fix-interrupted-conv
Jun 29, 2026
Merged

fix(gateway): keep reasoning-model conversations from stalling#46
albanm merged 26 commits into
mainfrom
fix-interrupted-conv

Conversation

@albanm

@albanm albanm commented Jun 29, 2026

Copy link
Copy Markdown
Member

Harden the gateway against conversations that stall or silently drop when the
model is a reasoning model on a Scaleway / OpenAI-compatible provider.

  • Reasoning capture & display: route Scaleway and openai-compatible (compatible
    mode) through @ai-sdk/openai-compatible so reasoning_content is parsed instead
    of dropped; forward it on the reasoning_content SSE channel and show it in an
    opt-in, render-only "Reasoning" panel (off by default, never resets the chat).
  • Scaleway GLM streamed tool calls: glm-5.2 drops tool calls when streaming but
    returns them non-streamed; streamedToolCallsBroken() runs the upstream call
    non-streaming when tools are present and re-emits the normal SSE stream. Includes
    dev/scripts/scw-toolcall-probe.mjs to detect when the workaround can be removed.
  • Moderation/summary fail-open fix: reasoning models spent the tiny moderation
    budget on hidden reasoning and returned content:null → fail-open. Send
    reasoning_effort:none, retrying once without it when a non-reasoning model rejects
    it with a 400.
  • Diagnostics: per-provider DEBUG=agents:upstream:* / agents:downstream:*
    logging (never logs headers), and a console dump of the physical request/response
    on an empty assistant turn.

Why: reasoning models on Scaleway/OpenAI-compatible providers made conversations
look interrupted — the assistant would "narrate then stop", moderation failed open,
and reasoning was discarded.

Heads-up: streamedToolCallsBroken forces non-streaming for any openai-compatible
provider serving a glm model, not only Scaleway — a native GLM endpoint that streams
tool calls fine would lose incremental streaming (UX only, not correctness).

albanm and others added 26 commits June 26, 2026 17:15
An empty turn (clean finish, no assistant text) is anomalous; treat it like an
error for diagnosis and dump the request/response/finishReason/usage to the
console before showing the generic fallback bubble.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add getUpstreamExchange tool to the evaluator's toolset to inspect raw upstream
request/response pairs for physical-request entries. Mirrors the existing
getTraceEntry tool pattern using pickRecorder/compare-mode design. Handles
truncation notes and missing upstream gracefully. Includes unit tests verifying
response formatting and null-handling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Use optional-chaining on v-if guard; expand single-line div to clear
singleline-html-element-content-newline lint warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix stale references flagged in final review: capture lives in
api/src/models/{capturing-fetch,operations}.ts and the evaluator tool in
ui/src/traces/evaluator-tools.ts; rawChars is a character count, not bytes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Scoped via DEBUG=agents:downstream:<type>:<id>; serialisation only runs
when the namespace is enabled, independent of trace storage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	api/src/models/operations.ts
A reasoning ("thinking") moderator spends the whole maxOutputTokens:100 budget
on reasoning_content, returns finish_reason:"length" with content:null, and the
generateObject parse rejects -> the gate fails open (every message allowed). It
also can't beat the 3.5s gate. Document this at the call site and expand the
moderator role description in the settings form (EN+FR) so admins are warned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eaming

Scaleway's glm-5.2 deployment returns finish_reason:"stop" with no tool-call
deltas under stream:true, while the identical stream:false request returns the
tool call. The app streams for UX, so tool-using turns (e.g. the evaluator)
collapsed to a "narrate then stop" preamble and looked interrupted. Proven by
toggling only `stream` against the endpoint; isolated to Scaleway (not LiteLLM)
and to glm-5.2 specifically (qwen3.5-397b, qwen3-235b, gpt-oss-120b, devstral
all stream tool calls fine).

streamedToolCallsBroken() flags scaleway|openai-compatible + /glm/; when set and
tools are present the gateway runs the upstream call non-streaming (generateText)
and re-emits the SSE chunks through the existing moderation-gate machinery, so
the client still gets a normal stream. Removable once Scaleway fixes it; recheck
with dev/scripts/scw-toolcall-probe.mjs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ai-sdk/openai silently drops the OpenAI-compatible `reasoning_content` field, so
reasoning models (GLM, DeepSeek, Qwen-thinking, …) had their thinking discarded at
the provider boundary. Route reasoning-capable OpenAI-compatible endpoints through
@ai-sdk/openai-compatible (the ai-v6 tag, 2.0.52) which maps reasoning_content to
reasoning parts, and carry it end to end:

- api: scaleway and openai-compatible('compatible') use createOpenAICompatible.
- gateway: forward reasoning-delta on the OpenAI `reasoning_content` SSE channel
  (streaming, the non-streaming-upstream GLM workaround, and the non-stream branch).
- ui: the client provider is openai-compatible too (else it would drop reasoning on
  the gateway→client hop); agent-stream-parts accumulates reasoning onto the message.
- ui: render reasoning as a collapsed "thinking" panel above the answer.

Verified end to end against the real Scaleway/LiteLLM endpoint (reasoning captured in
both modes) and with a mock reasoning seam ("reason") + gateway tests. Confirmed
reasoning round-trip across tool steps is NOT required for GLM (no 400, coherent
follow-up), so history does not echo reasoning back.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The moderator runs on a tiny token budget (maxOutputTokens:100) behind a 3.5s
gate. A reasoning ("thinking") model spent the whole budget on reasoning_content
and returned content:null, so the verdict parse failed and moderation fell open
(every message allowed) — verified against Scaleway glm-5.2.

Send reasoning_effort:none on the moderator call (and the admin probe), which
Scaleway/GLM honour: thinking is disabled, the JSON verdict comes back in ~8
tokens, well within budget and the gate. Verified end to end: without it,
generateObject throws "no object generated"; with it, {"action":"allow"} in 7
tokens. It is keyed by the shared OPENAI_COMPATIBLE_PROVIDER_NAME (scaleway +
openai-compatible now use one provider name) and ignored by other providers;
non-reasoning OpenAI-compatible models reject reasoning_effort with a 400, so
those retry once without it (isReasoningEffortRejected).

Requires the proxy to pass reasoning_effort through: see the litellm
allowed_openai_params change for glm-5.2-scw (separate infra repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
History compaction doesn't need to think. Send reasoning_effort:none for the
summarizer role so reasoning models produce the summary fast and cheap (same
rationale as the moderator). Ignored by non-openai-compatible providers; a
non-reasoning OpenAI-compatible model that 400s on the param falls back to a
plain call (isReasoningEffortRejected). Requires the proxy to pass reasoning_effort
through (litellm allowed_openai_params, separate infra repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reasoning models' "thinking" was always surfaced as a foldable panel above
each answer, which adds noticeable verbosity. Add a per-user "Full reasoning
display" flag (default off) that switches between two modes:

- Compact (new default): no reasoning panel; the transient "Thinking…"
  activity line is the only feedback and nothing persists afterward.
- Full: the previous collapsed "Reasoning" panel.

Reasoning is still always captured onto the message, so the flag is purely
render-time: toggling it never resets the conversation and is reversible
mid-chat. The flag lives in the existing service-scoped agent-chat-flags
cookie next to toolExploration/subAgents/mermaid, toggled from the Settings
tab. EvaluatorChat passes show-reasoning=true (admin tool wants full
visibility). Server (api/src/traces) is untouched: render-only flag, its
cookie parser ignores unknown fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the optional `upstream` field that recorded the raw gateway→provider
exchange in every consented trace document, along with its capturing-fetch
wrapper, evaluator getUpstreamExchange tool, trace-viewer panel, and test
suite. The same raw bytes remain available on demand via the per-provider
DEBUG=agents:upstream:* logging, which has no storage cost or per-request
overhead when disabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ctation

reconstructTrace falls back to the UI DEFAULT_FLAGS when no request carries
flags; that default gained showReasoning, so the unit expectation needed the
new key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@albanm albanm changed the title Fix interrupted conv fix(gateway): keep reasoning-model conversations from stalling Jun 29, 2026
@github-actions github-actions Bot added the fix label Jun 29, 2026
@albanm albanm merged commit 181aa0b into main Jun 29, 2026
4 of 5 checks passed
@albanm albanm deleted the fix-interrupted-conv branch June 29, 2026 08:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant