Skip to content

[Provider compatibility]: AgentRouter Anthropic streams can end without terminal SSE frames #658

Closed

Description

@brunoflma

Client or integration

Codex CLI and the Anthropic Python SDK through the OpenCodex Anthropic adapter.

Provider or upstream service

AgentRouter (https://agentrouter.org), using its Anthropic-compatible /v1/messages API.

OpenCodex version

2.7.42

Endpoint or capability

Anthropic-compatible streaming and tool calls via /v1/messages.

Current behaviour

Some non-Claude models exposed by this provider emit valid Anthropic-compatible SSE content but close the connection before content_block_stop, message_delta, and message_stop.

The OpenCodex Anthropic adapter treats that EOF as a fatal truncation for every provider. It discards otherwise valid visible text and completed tool-call arguments, returning an adapter error instead of completing the response.

The affected provider can also omit tool_use.id, and may return tool_use.input as a malformed concatenation such as {}{\"value\":42} in non-streaming responses.

Expected behaviour

For a deliberately configured compatibility profile for this provider, OpenCodex should be able to:

  1. preserve text when the upstream emits valid content and then closes without terminal SSE frames;
  2. complete a tool call only when the accumulated arguments form a valid JSON object;
  3. synthesize a stable non-empty tool-call ID when the upstream omits it;
  4. normalize malformed tool arguments to a valid JSON object.

The default behavior for native Anthropic and other strict providers should remain unchanged: incomplete streams must continue to fail.

Minimal redacted reproduction

import anthropic

# Instantiate the SDK with a redacted local proxy credential and this base URL.
client = anthropic.Anthropic("REDACTED", base_url="http://127.0.0.1:10100")

# The direct upstream call returns the visible text. Through OpenCodex before
# the patch, the same request fails with an adapter EOF error.
with client.messages.stream(
    model="PROVIDER/glm-5.2",
    max_tokens=64,
    messages=[{"role": "user", "content": "Reply exactly: OK"}],
) as stream:
    print("".join(stream.text_stream))

Raw redacted upstream SSE shape observed for text:

message_start
content_block_start (text)
content_block_delta (text_delta with visible text)
EOF

Raw redacted upstream SSE shape observed for a tool call:

message_start
content_block_start (tool_use; id may be present or absent)
content_block_delta (input_json_delta fragments forming a complete JSON object)
EOF

Actual response or error

upstream stream ended before message_stop — possible truncation

The direct AgentRouter request using the Anthropic Python SDK returned the expected text for glm-5.2 and gpt-5.5. Through OpenCodex before the local compatibility patch, both failed with the error above.

Upstream documentation

AgentRouter documents an Anthropic-compatible endpoint, but the observed stream does not consistently include the terminal Anthropic SSE frames.

Suggested mapping or implementation notes

A narrow compatibility fallback can be implemented in src/adapters/anthropic.ts, gated by an exact hostname match for agentrouter.org:

  • On EOF without message_stop, emit done only after valid content has been received and no incomplete tool call is open.
  • If a tool call is open, emit tool_call_end and done only when its accumulated arguments parse to a JSON object.
  • Otherwise keep emitting the current truncation error.
  • For this provider only, synthesize a toolu_<uuid> ID when a tool_use ID is absent, and normalize non-streaming tool inputs to a JSON object.

I verified this behavior with focused regression tests covering: normal AgentRouter text EOF, incomplete tool JSON, complete tool JSON at EOF, missing tool IDs, malformed concatenated tool input, and native Anthropic EOF remaining an error.

Additional context

No credentials, correlation values, account information, local paths, usage logs, or raw provider payloads are included here. I searched existing compatibility issues and found related SSE-truncation reports, but no open issue specifically for AgentRouter.

  • I searched existing provider and compatibility issues.
  • The request and response were redacted.
  • The expected behaviour is based on a concrete client requirement and the Anthropic streaming protocol.

Activity

  1. github-actions commented on Jul 29, 2026

    @github-actions
    Contributor

    Automated translation bookkeeping — detected language: unknown.

  2. added
    providerProvider adapters, OpenAI-compat presets, upstream API quirks
    streamingSSE, WebSocket, terminal stream frames
    toolstool_calls, MCP, web-search / sidecar tools
    on Aug 1, 2026
  3. Ingwannu commented on Aug 1, 2026

    @Ingwannu
    Owner

    Accepted as a narrow provider-compatibility feature, with strict Anthropic behavior unchanged by default.

    Please implement this as an explicit provider capability in the registry/config rather than a hard-coded hostname branch inside the adapter. EOF may become success only after visible text or a complete JSON-object tool input; incomplete JSON, an open non-tool block without usable content, or transport failure before usable content must remain truncation errors. Synthesized tool ids must be stable for the emitted call/result pairing, and non-stream input repair must never discard a valid object.

    The PR should include AgentRouter-gated text EOF, complete/incomplete tool EOF, missing-id, malformed concatenation, and strict native-Anthropic negative tests. Tag this issue in the PR.

  4. brunoflma commented on Aug 2, 2026

    @brunoflma
    Author

    Confirming this still reproduces on 2.10.0, and adding the test evidence plus what I run locally — with the caveat that mine is the hostname branch you explicitly do not want, so treat it as a description of the behaviour to encode, not as a proposed shape.

    Still current on 2.10.0. The EOF path is now:

    if (!emittedDone) {
      // Fail closed on transport EOF. Compatible providers may omit message_stop after message_delta.stop_reason.
      if (pendingStopReason !== undefined) { /* emit done */ }
      else { yield { type: "error", message: "upstream stream ended before message_stop — possible truncation" }; }
    }

    This provider omits both frames — no message_stop and no message_delta.stop_reason — so pendingStopReason is never set and every response becomes a truncation error. The 2.10.0 improvements from #765 (usableToolUseId, toolUseArguments, the input_json_delta scoping) all landed and work; this is the one behaviour left, exactly as your triage note said.

    Four behaviours, as regression tests. These are the cases I have observed from the provider, written against the adapter directly:

    Test Expectation
    completes a text stream at EOF without message_stop last event is done, no error
    keeps a native Anthropic EOF without message_stop as an error strict behaviour unchanged for api.anthropic.com
    does not complete a truncated tool call EOF inside a tool_use block still errors
    assigns a non-empty id to streamed tool_use without one id is not ""
    normalizes a response tool_use id and concatenated JSON input {}{"value":42} → {"value":42}
    preserves valid upstream tool ids and object inputs untouched

    The third one matters for the capability's shape: text-at-EOF should complete, but a tool call cut mid-arguments must still fail. Gating on "content was seen" alone is not enough — the current content block type has to be checked too.

    On item 4 of the original list (malformed concatenation). toolUseArguments() in 2.10.0 returns {} for any unparseable string, and the comment explains the reasoning — a caller receiving "get weather" where an object was required is unusable either way, and {} at least fails in the tool's own validation. That is the right default. But for this provider the practical effect is a tool invoked with no arguments at all, which fails silently rather than loudly. {}{"value":42} is not really malformed so much as prefixed: the trailing object is intact and recoverable by scanning back to the last { that parses. Worth folding into the same capability rather than changing the default.

    What I run locally, for reference only — two gated behaviours, resolved lazily so a malformed baseUrl still surfaces as a buildRequest rejection rather than a constructor throw (otherwise tests/anthropic-hardening.test.ts breaks):

    // EOF: complete instead of erroring, but only outside a tool_use block.
    if (resolveAgentRouter() && currentBlockType !== "tool_use") {
      emittedDone = true;
      yield { type: "done", usage: usageFromAnthropic(pendingUsage) };
    } else if (pendingStopReason !== undefined) { /* upstream path */ }
    
    // Response tool args: recover the trailing object, else fall through to toolUseArguments().
    arguments: (resolveAgentRouter() ? recoverAgentRouterToolInput(block.input) : undefined)
      ?? toolUseArguments(block.input)

    I agree the hostname branch is the wrong home for this and I am not proposing it. If you can point me at the shape you want — a field on the provider registry entry, or a capabilities object in provider config — I am happy to send the PR with the six tests above, keeping strict behaviour as the default for everything that does not opt in.

  5. Ingwannu commented on Aug 4, 2026

    @Ingwannu
    Owner

    Follow-up: the requested provider capability has now landed on current dev as commit 538d755f.

    The implementation uses an explicit anthropicEofTolerance provider capability rather than a hostname branch. Strict Anthropic behavior remains the default. When opted in, EOF becomes success only after visible text or a complete JSON-object tool call; incomplete tool JSON and empty-content EOF remain truncation errors. Missing tool IDs are synthesized once and reused, and concatenated non-stream tool input recovery is byte/candidate bounded.

    The AgentRouter directory row opts into the capability, while custom providers can set the same config field explicitly. I reran the focused suite on the current stacked head: 14 passed, 0 failed, covering strict negative behavior, complete/incomplete tool EOF, missing IDs, bounded JSON repair, budget overflow, and hostile input caps.

    No tagged release contains this commit yet, but the issue implementation is complete on dev. Closing as completed; it will be available in the next release containing 538d755f.

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

    providerProvider adapters, OpenAI-compat presets, upstream API quirksprovider-compatibilityProvider compatibility reportsstreamingSSE, WebSocket, terminal stream framestoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions