Skip to content

SSE JSON parse failure with GLM-5.1 (Z.AI) — malformed JSON in content field kills stream #23442

Closed as not planned

Description

@darkhipo

Description

When using GLM-5.1 via Z.AI's OpenAI-compatible API, the SSE stream occasionally contains invalid JSON. The model hallucinates SSE-formatted text (e.g. data: {"id":"...","choices":[...]}) as its actual response content. Z.AI's server does not properly escape the quotes in the content field, producing malformed JSON that fails parsing.

Observed Error

JSON parsing failed: Text: {"id":"20260420032348d6275404213948ac","created":1776626628,"object":"chat.completion.chunk","model":"glm-5.1","choices":[{
  "index":0,"delta":{"role":"assistant","content":"data: {"id":"20260420032457d424e96cb1da4f11","created":1776626698,"object":"chat.completion.chunk",
  "model":"glm-5.1","choices":[{"index":0,"delta":{"role":"assistant","content":"Two"}}]}.
  Error message: JSON Parse error: Expected '}'

Root Cause

The SSE data: line is:

data: {"id":"...","choices":[{"delta":{"content":"data: {"id":"...","choices":[{"delta":{"content":"Two"}}]}}]}

The inner " in the hallucinated "data: {"id":"..." breaks the outer JSON string boundary. Z.AI's server should be escaping these quotes (\") but isn't.

Where It Fails

  1. EventSourceParserStream (eventsource-parser) correctly extracts the line after data:
  2. safeParseJSON() in @ai-sdk/provider-utils/src/parse-json-event-stream.ts calls JSON.parse() on the malformed string → throws
  3. The error propagates up through processor.ts and kills the stream

Suggested Fix

Skip-and-continue: When a chunk fails JSON parsing, log a warning and continue to the next chunk instead of killing the entire stream. The hallucinated SSE chunks are typically noise — the real content arrives in adjacent valid chunks. A single malformed chunk shouldn't terminate the conversation.

// In the TransformStream inside parseJsonEventStream:
async transform({ data }, controller) {
  if (data === "[DONE]") return;
  try {
    controller.enqueue(await safeParseJSON({ text: data, schema }));
  } catch (err) {
    // Provider sent malformed JSON in this chunk — skip it
    // rather than killing the entire stream.
    console.warn("SSE chunk JSON parse failed, skipping:", data.slice(0, 200));
  }
}

Environment

  • opencode v1.14.x
  • Provider: Z.AI (api.z.ai/api/coding/paas/v4)
  • Model: glm-5.1
  • Stream mode: SSE

Reproduction

Any multi-turn conversation with GLM-5.1 via opencode will eventually trigger this. The model sporadically hallucinates SSE chunks in its response text. The failure is intermittent but frequent enough to disrupt normal usage.

Workaround

We added content sanitization in our downstream provider layer that strips data: {...} artifacts from response content, but the fix belongs in the SSE parser itself so all providers benefit.

Activity

  1. added
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on Apr 19, 2026
  2. github-actions commented on Apr 19, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    If your issue is specifically about the stream being killed on a parse error (rather than the malformed JSON itself), it may still warrant its own report — but please review the above to avoid duplicate effort.

  3. darkhipo commented on Apr 30, 2026

    @darkhipo
    Author

    Reproduction from MultiAgency body session (2026-04-30)

    Running opencode 1.4.10 (homebrew, darwin-arm64) with GLM-5.1 via z.ai. During a long coding session with heavy MCP tool usage, the SSE stream dies with:

    JSON parsing failed: Text: {"id":"20260430204055297b97348c8e426c","created":1777552855,"object":"chat.cdata: {"id":"2026043020420635c6abc56a2741ac",
    "created":1777552926,"object":"chat.completion.chunk","model":"glm-5.1","choices":[{"index":0,"delta":{"role":"assistant","content":"11"}}]}.
    Error message: JSON Parse error: Expected '}'
    

    The malformed text shows two SSE data: frames concatenated — the first object is truncated mid-field ("object":"chat.c before "data:" from the next frame appears inline). This is the GLM-5 model generating truncated JSON in streaming chunks, not a network issue.

    Environment:

    • opencode 1.4.10 (homebrew)
    • macOS darwin-arm64 (M1 Air)
    • Model: zai-coding-plan/glm-5.1
    • Session had 50+ tool calls before failure

    The skip-and-continue approach would solve this — a single malformed chunk should not kill the entire conversation. The real content is in adjacent valid chunks.

  4. darkhipo commented on Apr 30, 2026

    @darkhipo
    Author

    Update: Filed with z.ai as well

    We filed a detailed root-cause analysis on the z.ai side: zai-org/GLM-5#66

    Two distinct failure modes identified

    1. Truncated SSE chunks — the server emits a data: frame containing incomplete JSON (cut off mid-field, e.g. "object":"chat.c before the next data: appears inline). This is a server-side framing bug.

    2. Hallucinated SSE content — the model generates data: {"id":"..."} as literal text in its response, and the server doesn't escape the inner quotes. This is a model output + server escaping issue.

    Both produce the same symptom: JSON Parse error: Expected '}'.

    Our environment (for the record)

    • opencode 1.4.10 (homebrew, darwin-arm64)
    • macOS M1 Air 8GB
    • Model: zai-coding-plan/glm-5.1 via api.z.ai
    • 50+ tool calls before failure
    • Using MultiAgency body with 60+ MCP tools

    The skip-and-continue approach suggested in the issue body would be a good client-side mitigation for both modes. But the root cause fix needs to happen server-side at z.ai.

  5. water-in-stone commented on May 2, 2026

    @water-in-stone

    @darkhipo There is a general method to handle this problem: we could use jsonrepair

    by the way, jsonrepair is a popular tool used for fixing malformed JSON text from LLMs. Its key features include:

    • Automatically completing missing quotes, brackets, and commas
    • Repairing unescaped double quotes (which directly addresses the notebook issue)
    • Streaming support (jsonrepair/stream)
    • Zero dependencies, written in native TypeScript
  6. removed
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on May 3, 2026
  7. darkhipo commented on May 14, 2026

    @darkhipo
    Author

    Update: additional analysis + client-side workaround

    We've seen this exact bug again with GLM-5.1 and have added a client-side resilience fix in our downstream SSE parser. The same pattern could be applied to opencode's parseJsonEventStream transform.

    Bug pattern (confirmed)

    Two SSE data: frames arrive as one line. The first JSON object is truncated mid-key (never gets a closing }), then data: appears as literal text inside the stream, followed by a complete second object.

    Example:

    data: {"id":"...","object":"chat.completion.cdata: {"id":"...","object":"chat.completion.chunk","choices":[{"delta":{"content":"I"}}]}
    

    The first object is cut off at "chat.completion.c (should be "chat.completion.chunk"). The \n\n SSE frame boundary is missing — both frames are on one continuous line.

    Root cause

    Server-side SSE buffering bug. The server holds a chunk in its write buffer without flushing, then writes the next chunk into the same buffer before flushing both as one line. The timestamps in our incident (1778724482 vs 1778726922) are ~40 minutes apart, confirming stale-buffer behavior.

    This affects at least:

    Fix pattern: split-on-data: fallback

    When the SSE parser's brace-depth tracker finds zero complete objects (because the first JSON is truncated and never reaches depth 0), fall back to splitting the raw string on data: boundaries and retrying each fragment independently.

    Our implementation (Python, parse_sse_json_chunks):

    # Primary: brace-depth tracking
    for i, ch in enumerate(data_str):
        if ch == "{": depth += 1
        elif ch == "}":
            depth -= 1
            if depth == 0: chunks.append(json.loads(...))
    
    # Fallback: if no complete objects found AND line contains "data: "
    if not chunks and "data: " in data_str:
        for fragment in data_str.split("data: "):
            # retry brace-depth tracking on each fragment

    This recovers the valid second chunk while correctly discarding the truncated first one. The key insight: the malformed first chunk is garbage anyway (truncated mid-key), so losing it is fine. The valid second chunk was previously lost because it was unreachable inside the malformed first object.

    Suggested opencode fix

    In the TransformStream inside parseJsonEventStream (or wherever safeParseJSON is called), add a pre-processing step:

    // Before parsing, check if the line contains multiple "data: " prefixes
    // (indicating concatenated SSE frames from a buggy server)
    function splitMalformedSSE(data: string): string[] {
      if (!data.includes("data: ")) return [data];
      return data.split(/(?=data: )/).map(s => s.replace(/^data:\s*/, "").trim()).filter(Boolean);
    }

    Then iterate over the fragments and parse each one, catching JSON.parse errors per-fragment rather than per-line.

    Related

  8. CamiloFernando commented on May 15, 2026

    @CamiloFernando

    Related case: opencode-go sends a valid-JSON but semantically-empty event after [DONE]

    Your proposed fix (skip-and-continue on malformed JSON) would solve one class of this problem. Here's a second, distinct case that needs the same defensive posture — but at a different level: ignoring events that arrive after [DONE].


    What opencode-go sends:

    data: {"id":"...","choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}
    
    data: [DONE]
    
    data: {"choices":[],"cost":"0"}    ← valid JSON, but arrives after DONE
    

    The trailing event is syntactically valid, so it won't be caught by a JSON parse guard. The problem is that the stream parser processes it as another completion chunk, sees choices: [], and overwrites the assembled message with empty content.

    Reproduce:

    curl -s -N -X POST https://opencode.ai/zen/go/v1/chat/completions \
      -H "Authorization: Bearer <your-opencode-go-key>" \
      -H "Content-Type: application/json" \
      -d '{"model":"opencode-go/mimo-v2.5","messages":[{"role":"user","content":"Reply with only: ok"}],"stream":true,"max_tokens":200}'

    Symptom in --format json mode

    The TUI is unaffected (it appears to stop consuming after DONE). --format json mode — used by any programmatic consumer — processes the trailing event and the session never completes:

    # opencode log shows content streaming fine:
    INFO service=bus type=message.part.delta publishing   ← repeated
    INFO service=bus type=message.updated publishing
    INFO service=bus type=session.status publishing
    # ← exits here. No "exiting loop", no HTTP 200 back to caller.
    

    A downstream consumer sees:

    [test:agent] OpenCode → no_text (exit 0)
    

    Suggested fix (complements #23442)

    In addition to the skip-malformed-JSON guard, gate on [DONE]:

    let done = false;
    async transform({ data }, controller) {
      if (data === "[DONE]") { done = true; return; }
      if (done) return; // ignore any event after DONE
      try {
        controller.enqueue(await safeParseJSON({ text: data, schema }));
      } catch (err) {
        console.warn("SSE chunk JSON parse failed, skipping:", data.slice(0, 200));
      }
    }

    Both guards together make the parser resilient to the two known failure modes:

    1. Malformed JSON in content (your case — GLM-5.1 / Z.AI)
    2. Valid-JSON but semantically-empty event after DONE (opencode-go, all reasoning models)
  9. github-actions commented on Jul 14, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions