Symptom
On providers that close the SSE connection without sending the OpenAI-standard data: [DONE] terminator (MiniMax MiniMax-M2.7 confirmed in the field), the full response streams out correctly and then the call returns an error — ProviderError::Other("Stream ended"). Downstream, users see their complete answer followed by a scary error block.
Field report: yologdev/yoyo-evolve#612 (yoyo 0.1.15, first-class minimax provider, Ubuntu 24.04). yoyo has shipped a cosmetic mitigation (reframing the error as a known-benign notice, yoyo-evolve@1bb04f1e), but by its own admission it cannot make the stream end cleanly from downstream — the terminator handling lives here.
Root cause (yoagent 0.13.0)
The compat reader already tolerates a missing [DONE] in one shape but errors on the identical situation in another, depending only on how the connection close surfaces:
src/provider/openai_compat.rs read loop: a graceful None from the eventsource → break → clean finish (missing [DONE] tolerated ✅)
- The same close surfacing as
reqwest_eventsource::Error::StreamEnded → Some(Err(e)) arm (line ~194) → classify_eventsource_error → catch-all other => ProviderError::Other(other.to_string()) (src/provider/traits.rs:223) → return Err ❌ — after the response has already streamed to the caller via tx.
So the failure isn't in parsing MiniMax's SSE; it's that StreamEnded has no case in the classifier and gets treated as a protocol error even when the response is demonstrably complete.
Proposed fix (guarded, ~10 lines)
In the openai_compat.rs read loop, track whether a finish_reason was observed (the loop already parses it at ~line 184), and special-case StreamEnded:
Some(Err(reqwest_eventsource::Error::StreamEnded)) => {
// Some providers (e.g. MiniMax) close the connection without the
// OpenAI-standard `data: [DONE]` terminator. If a finish_reason was
// already received, the response is complete — treat as clean EOF,
// same as the graceful `None` case above.
if saw_finish_reason {
break;
}
let provider_err = classify_eventsource_error(reqwest_eventsource::Error::StreamEnded).await;
warn!("OpenAI SSE error: {}", provider_err);
return Err(provider_err);
}
The saw_finish_reason guard is what makes this safe: a DONE-less close after finish_reason is a completed response (MiniMax's normal ending); a StreamEnded without any finish_reason is genuine mid-stream truncation and must stay an error. This distinction also keeps retry semantics honest for real drops.
Test suggestion
A compat-stream unit test feeding: chunks with content deltas → a chunk carrying finish_reason: "stop" → connection close without [DONE] (surfaced as Error::StreamEnded). Assert clean completion with the accumulated content and StopReason::Stop. A sibling test with the close arriving before any finish_reason should still assert Err.
Impact
Every yoagent consumer using MiniMax (or any provider with the same close behavior) through ModelConfig::minimax() / OpenAI-compat: yoyo-evolve, yyds-harness, and anyone else on 0.13. Downstream pins of "0.13" with unlocked lockfiles would pick up a 0.13.1 automatically.
Symptom
On providers that close the SSE connection without sending the OpenAI-standard
data: [DONE]terminator (MiniMaxMiniMax-M2.7confirmed in the field), the full response streams out correctly and then the call returns an error —ProviderError::Other("Stream ended"). Downstream, users see their complete answer followed by a scary error block.Field report: yologdev/yoyo-evolve#612 (yoyo 0.1.15, first-class
minimaxprovider, Ubuntu 24.04). yoyo has shipped a cosmetic mitigation (reframing the error as a known-benign notice,yoyo-evolve@1bb04f1e), but by its own admission it cannot make the stream end cleanly from downstream — the terminator handling lives here.Root cause (yoagent 0.13.0)
The compat reader already tolerates a missing
[DONE]in one shape but errors on the identical situation in another, depending only on how the connection close surfaces:src/provider/openai_compat.rsread loop: a gracefulNonefrom the eventsource →break→ clean finish (missing[DONE]tolerated ✅)reqwest_eventsource::Error::StreamEnded→Some(Err(e))arm (line ~194) →classify_eventsource_error→ catch-allother => ProviderError::Other(other.to_string())(src/provider/traits.rs:223) →return Err❌ — after the response has already streamed to the caller viatx.So the failure isn't in parsing MiniMax's SSE; it's that
StreamEndedhas no case in the classifier and gets treated as a protocol error even when the response is demonstrably complete.Proposed fix (guarded, ~10 lines)
In the
openai_compat.rsread loop, track whether afinish_reasonwas observed (the loop already parses it at ~line 184), and special-caseStreamEnded:The
saw_finish_reasonguard is what makes this safe: a DONE-less close afterfinish_reasonis a completed response (MiniMax's normal ending); aStreamEndedwithout anyfinish_reasonis genuine mid-stream truncation and must stay an error. This distinction also keeps retry semantics honest for real drops.Test suggestion
A compat-stream unit test feeding: chunks with content deltas → a chunk carrying
finish_reason: "stop"→ connection close without[DONE](surfaced asError::StreamEnded). Assert clean completion with the accumulated content andStopReason::Stop. A sibling test with the close arriving before anyfinish_reasonshould still assertErr.Impact
Every yoagent consumer using MiniMax (or any provider with the same close behavior) through
ModelConfig::minimax()/ OpenAI-compat: yoyo-evolve, yyds-harness, and anyone else on 0.13. Downstream pins of"0.13"with unlocked lockfiles would pick up a0.13.1automatically.