Skip to content

openai_compat: StreamEnded after finish_reason should be a clean EOF, not ProviderError (MiniMax closes without [DONE]) #76

Description

@yuanhao

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 errorProviderError::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::StreamEndedSome(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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions