Skip to content

[Provider compatibility]: Claude Messages bridge rejects data-only Responses SSE as truncated #700

Closed

Description

@beilo

Client or integration

Claude Code

Provider or upstream service

cubapi (https://xxxxx.cn/v1/responses), configured with adapter: openai-responses

OpenCodex version

2.7.42

Endpoint or capability

POST /v1/messages translating a streaming Responses API upstream

Current behaviour

The Claude Messages bridge reports a truncated upstream stream even though the upstream returned a complete Responses SSE stream.

Control tests against the same provider, model, key, and request content:

  1. Direct upstream POST /v1/responses with stream:true: HTTP 200, includes response.completed and data: [DONE].
  2. OpenCodex POST /v1/responses: HTTP 200, visible output and response.completed received.
  3. OpenCodex POST /v1/messages with either stream:true or stream:false: fails with upstream stream ended before a terminal frame (truncated response).

The relevant wire-format difference is that this upstream emits data-only SSE records:

data: {"type":"response.output_text.delta",...}

data: {"type":"response.completed",...}

data: [DONE]

It does not emit a separate event: line. An SSE event field is optional; absent fields use the default message event.

Expected behaviour

The Claude Messages bridge should accept Responses SSE frames whose event type is carried by the JSON payload's type field when the optional SSE event: field is absent. It should translate the complete stream and emit Anthropic message_stop, matching the already-successful /v1/responses passthrough path.

Minimal redacted request or reproduction

# Provider uses adapter=openai-responses and baseUrl=https://xxxx.cn/v1.
# API key is redacted.

curl http://127.0.0.1:10101/v1/messages \
  -H 'content-type: application/json' \
  -H 'x-api-key: REDACTED' \
  -H 'anthropic-version: 2023-06-01' \
  --data '{
    "model": "claude-opus-4-6",
    "max_tokens": 64,
    "stream": true,
    "thinking": {"type": "adaptive"},
    "output_config": {"effort": "low"},
    "messages": [{"role": "user", "content": "Reply exactly OK"}]
  }'

The translated upstream request was captured and redacted. It correctly used:

{
  "stream": true,
  "reasoning": {"effort": "low"}
}

reasoning.summary was intentionally disabled with modelSupportsReasoningSummaries:false; this is unrelated to the SSE parsing failure.

Actual response or error

HTTP 200 for stream:true, followed by:

data: {"type":"message_start",...}
data: {"type":"ping"}
data: {"type":"error","error":{"type":"overloaded_error","message":"upstream stream ended before a terminal frame (truncated response)"}}

For stream:false:
HTTP 502
{"type":"error","error":{"type":"overloaded_error","message":"upstream stream ended before a terminal frame (truncated response)"}}

Upstream documentation

Suggested mapping or implementation notes

src/claude/outbound.ts currently discards frames without an explicit SSE event name before parsing the JSON payload:

if (!eventName || !dataLine) continue;

At EOF it consequently never sees response.completed and emits the truncation error.

Suggested behaviour:

  1. Require dataLine, but not eventName, before parsing JSON.
  2. Parse the payload.
  3. If eventName is absent and data.type is a string, use data.type as the event name.
  4. Preserve current strict terminal-frame validation.

This should be a narrow compatibility fix: it does not weaken truncation detection because response.completed still must be observed.

Additional context

A combo failover control test also succeeded independently: an intentionally unreachable first target failed with 502, OpenCodex selected the second target, and /v1/responses returned the expected output. This isolates the failure to Responses SSE-to-Anthropic translation rather than provider routing or failover.

Checks

Activity

  1. github-actions commented on Jul 29, 2026

    @github-actions
    Contributor

    Automated translation bookkeeping — detected language: English.

  2. lidge-jun commented on Jul 29, 2026

    @lidge-jun
    Owner

    Fixed on dev by #711, which landed as d24c5233f.

    Your diagnosis was exactly right, and the wire capture is what made it actionable. The bridge required an SSE event: line and discarded frames without one, so a data-only stream reached EOF with no frame the translator had accepted — hence upstream stream ended before a terminal frame (truncated response) even though response.completed had arrived.

    The fix resolves the event name from the payload's type when the event: field is absent, in src/claude/outbound.ts:

    // Responses-compatible gateways may omit the optional SSE event field
    // while retaining the event name in the JSON payload's required type.
    const resolvedEventName = eventName || (typeof data.type === "string" ? data.type : "");

    This matches the spec position you cited. WHATWG's event-stream grammar allows a block carrying only data, and OpenAI's own Python SDK routes Responses events off the decoded payload's type rather than requiring sse.event — so a data-only Responses frame is well-formed input, not a malformed stream.

    Verified on the merged dev tip, five regression cases in tests/claude-outbound.test.ts:

    (pass) data-only Responses frames infer event names from payload types
    (pass) explicit and data-only Responses frames can interleave
    (pass) data-only Responses frames survive non-streaming aggregation
    (pass) explicit event names override payload types and untyped data-only frames stay ignored
    (pass) data-only [DONE] without a Responses terminal frame still fails closed
    

    That covers your control matrix: streaming and non-streaming /v1/messages both work, mixed framing works, and — importantly — truncation detection is preserved. A stream that really does end without a terminal frame still fails, so this loosens the parser without weakening the guarantee that prompted the original check.

    The change is on dev and will ship in the next release; it is not in 2.7.42. Closing as fixed. If your provider still reports truncation once it is released, please reopen with the same style of capture — it was unusually easy to work from.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions