Skip to content

anthropic adapter: baseUrl ending in /v1/messages doubles the path, input_json_delta is not scoped to tool_use, and Anthropic-compatible relays need tool-call quirk tolerance #765

Closed

Description

@brunoflma

Client or integration

Codex CLI

Provider or upstream service

AgentRouter (agentrouter.org), an Anthropic Messages-compatible relay. Two of the four problems below are provider-agnostic and affect the anthropic adapter for any host.

OpenCodex version

2.7.43

Endpoint or capability

POST /v1/messages — streaming tool calls, and baseUrl normalisation.

Current behaviour

1. baseUrl ending in /v1/messages produces a doubled path (provider-agnostic).

const base = provider.baseUrl.replace(/\/v1\/?$/, "");
const url = `${base}/v1/messages`;

The regex only strips a trailing /v1. A baseUrl of https://host/v1/messages — a natural thing to paste from provider documentation — becomes https://host/v1/messages/v1/messages and every request 404s. Affects any Anthropic-compatible provider, not just this one.

2. input_json_delta is applied outside tool_use blocks (provider-agnostic).

} else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {

There is no check that the current content block is a tool_use. Per the Messages streaming spec input_json_delta only occurs within tool_use; a provider that emits it elsewhere (or emits deltas after a block closes) corrupts the accumulated tool arguments.

3. tool_use blocks arrive without an id. The adapter forwards block.id ?? "", so an empty tool-call id reaches the Codex client, which then cannot correlate the tool result.

4. Streams close without terminal frames. This relay sometimes ends an SSE stream after content blocks without message_stop / message_delta, so the adapter never emits a completion event and the client hangs waiting for one.

Additionally, tool_use.input sometimes arrives as a JSON string rather than an object, occasionally with a malformed prefix. JSON.stringify(block.input ?? {}) then produces a doubly-encoded string that the client rejects.

Expected behaviour

  1. baseUrl normalisation should tolerate /, /v1 and /v1/messages suffixes and produce exactly one /v1/messages.
  2. input_json_delta should only accumulate while the current block is a tool_use, per the Anthropic streaming spec.
  3. A tool_use block with no usable id should get a synthesised one (toolu_…) so tool results can be correlated, rather than an empty string being forwarded.
  4. A stream that ends after content without terminal frames should still produce a completion event instead of leaving the client waiting.
  5. tool_use.input that arrives as a JSON string should be parsed before being re-serialised, so the client receives a single-encoded object.

Items 1 and 2 look like plain correctness fixes worth applying unconditionally. Items 3-5 are quirk tolerance and could reasonably be gated per provider — locally I gate them on the endpoint hostname, but a capability flag on the provider config would be cleaner and would not hard-code a vendor name in the adapter.

Minimal redacted request or reproduction

Configure an Anthropic-compatible provider and route a tool-using model through it:

{
  "id": "<provider-id>",
  "type": "anthropic",
  "baseUrl": "https://<host>/v1/messages",
  "authMode": "key",
  "apiKey": "<redacted>"
}
  1. With that baseUrl, every request 404s (problem 1). Changing it to https://<host> works around it.
  2. With a working baseUrl, ask the model to call a tool. The tool call arrives with an empty id and/or doubly-encoded arguments (problems 3 and 5).
  3. Some responses end without message_stop, and the client waits indefinitely (problem 4).

Actual response or error

Problem 1 — the composed URL:

POST https://<host>/v1/messages/v1/messages  ->  404

Problem 3/5 — what reaches the client:

{ "type": "tool_call_start", "id": "", "name": "shell" }
{ "type": "tool_call_delta", "arguments": "\"{\\\"command\\\":[\\\"ls\\\"]}\"" }

Expected:

{ "type": "tool_call_start", "id": "toolu_<generated>", "name": "shell" }
{ "type": "tool_call_delta", "arguments": "{\"command\":[\"ls\"]}" }

Problem 4 — the stream simply stops after the last content_block_stop, with no message_delta or message_stop.

Upstream documentation

Suggested mapping or implementation notes

What I run locally, in case it is useful:

// 1. Tolerate /, /v1 and /v1/messages suffixes.
export function anthropicMessagesUrl(baseUrl: string): string {
  const base = baseUrl.replace(/\/(?:v1(?:\/messages)?)?\/?$/, "");
  return `${base}/v1/messages`;
}

// 3. Synthesise a correlatable id when the provider omits one.
function ensureAnthropicToolUseId(id: unknown): string {
  return typeof id === "string" && id.trim() !== ""
    ? id
    : `toolu_${crypto.randomUUID().replace(/-/g, "")}`;
}

// 5. Accept a JSON string, recovering from a malformed prefix, and emit one object.
function normalizeAnthropicToolInput(input: unknown): string {
  let value = input;
  if (typeof input === "string") {
    const raw = input;
    try {
      value = JSON.parse(raw);
    } catch {
      for (let index = raw.lastIndexOf("{"); index >= 0; index = raw.lastIndexOf("{", index - 1)) {
        try { value = JSON.parse(raw.slice(index)); break; } catch { /* try an inner object */ }
      }
    }
  }
  return value && typeof value === "object" && !Array.isArray(value)
    ? JSON.stringify(value)
    : "{}";
}

For problem 2 the fix is just adding the block-type guard:

} else if (currentBlockType === "tool_use" && delta.type === "input_json_delta" && ...) {

For problem 4 I track whether any content was seen and emit the completion event when the stream ends without a terminator.

One convention note worth documenting. Any per-provider predicate that can throw on a malformed baseUrl must be resolved lazily inside buildRequest, not eagerly in createAnthropicAdapter. I initially resolved mine in the factory and it broke tests/anthropic-hardening.test.ts -> rethrows a malformed baseUrl when prompt caching is enabled, which expects a rejected promise rather than a constructor throw. usesNativeAnthropicEndpoint already gets this right by being called inside the request path; making that explicit would help anyone adding a provider check.

Additional context and attachments

Happy to send a PR. Items 1 and 2 are two-line unconditional fixes; for 3-5 I would rather add a small provider capability flag than hard-code a hostname, and would welcome a hint on the shape you would accept.

Checks

  • I searched existing provider and compatibility issues.
  • The request and response were redacted.
  • The expected behaviour is based on an upstream specification or a concrete client requirement.

Activity

  1. github-actions commented on Jul 30, 2026

    @github-actions
    Contributor

    Automated translation bookkeeping — detected language: unknown.

  2. Wibias commented on Jul 30, 2026

    @Wibias
    Contributor

    [shipping-github] Research review

    Claim: Anthropic adapter doubles /v1/messages when baseUrl already ends with it; input_json_delta not scoped to tool_use; relay quirk tolerance for empty tool ids / missing terminal frames / string tool_use.input.
    Checked against: dev@be177ea5

    Field Finding
    Still an issue on latest development? yes
    Exact issue baseUrl.replace(/\/v1\/?$/, "") only strips /v1; input_json_delta branch has no currentBlockType === "tool_use" guard
    Fixed on development? no
    Open PR covering this? none
    Duplicate of? item 4 overlaps open #658 (AgentRouter missing terminal frames)
    Security relevance none — n/a
    Priority high for items 1–2 (provider-agnostic correctness); middle for quirk items 3–5
    Verdict Needs fix on development

    Evidence: src/adapters/anthropic.ts ~683–684 URL build; ~802–803 input_json_delta without block-type guard. Related #658 still open for terminal-frame tolerance.
    Gaps: No live AgentRouter stream capture in this session; capability-flag shape still a product choice.

  3. Wibias commented on Jul 30, 2026

    @Wibias
    Contributor

    [shipping-github] Opened PR #781 to address this.

  4. lidge-jun commented on Jul 31, 2026

    @lidge-jun
    Owner

    All five reported items are now on dev (verified at b79eb71a2).

    Item Where Landed
    baseUrl ending in /v1/messages doubled the path anthropic.ts:261 — anthropicMessagesUrl() normalizes /, /v1, and /v1/messages to one form decb08ba2
    input_json_delta not scoped to tool_use anthropic.ts:864 — now gated on currentBlockType === "tool_use" decb08ba2
    Unusable tool IDs usableToolUseId() — b79eb71a2 this round
    Relays needing tool-call quirk tolerance toolCallArgumentsUsable() in src/bridge.ts cancels a call whose assembled arguments do not parse, rather than completing it 67219b3b6 (from PR #781 by @Wibias)
    Streams ending without a terminal frame openai-chat half via PR #793 4f0633fcd

    The tool-ID fix is worth a note. The original code was block.id ?? synthesizeToolUseId(), which only synthesizes when the field is missing. A relay sending "" or " " passed the nullish check, so the call shipped with a blank id and the client had nothing to echo back — the next turn could not pair the result with its call. Blank is now treated as absent, on both the streaming and non-stream paths.

    The bridge item is the one that took two attempts. Fixing it in the adapter was reverted in 95d8ed77f, because the bridge completed the open tool call before the failure reached the client. It had to be fixed at the bridge.

    Still open and tracked separately: #658, AgentRouter streams ending without stop_reason. That needs a provider-scoped compatibility profile rather than a global relaxation — relaxing it everywhere would stop us noticing real truncation.

    Thanks @Wibias — three of these five came from your PRs.

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