Skip to content

[Provider compatibility] Anthropic adapter forwards tool_use ids unsanitized, breaking cross-provider history replay (fix already exists in google.ts) #1767

Closed

Description

@hoonysis

Client or integration

Other (Grok Build, via x-opencodex-grok custom model over /v1/chat/completions)

Provider or upstream service

anthropic

OpenCodex version

2.19.0

Endpoint or capability

/v1/messages (tool call id mapping on history replay)

Current behaviour

src/adapters/anthropic.ts forwards OcxToolCall.id / OcxToolResultMessage.toolCallId to Anthropic verbatim, with no character-set normalization:

  • anthropic.ts:646 — toolUses.push({ type: "tool_use", id: tc.id, ... })
  • anthropic.ts:576 — tool_use_id: msg.toolCallId
  • anthropic.ts:674 — tool_use_id: id (synthetic "missing tool_result" filler)

Anthropic validates tool_use.id against ^[a-zA-Z0-9_-]+$. When a conversation history contains tool call ids produced by a different provider path and that history is replayed to Anthropic (cross-provider model switch inside one client session), any id with a character outside that class produces a hard 400 and the whole conversation becomes unusable on Anthropic models until the history is discarded.

Observed in a real session: of 153 distinct tool call ids in the transcript, 151 were fine (call-<uuid>-<n>), and 2 were composite ids that concatenate a Responses-style call id and item id with a literal newline:

call_5sNzuhhhfcuN91ysezpcwXjp\nfc_0c71abbccafaad67016a803ba3007487d2afa509a7ca8c9687
call_YXIRuPJVG24WeRlYxDWYhDh4\nfc_0c71abbccafaad67016a803ba3008887d2a75cb919b833062c

The embedded \n alone is enough to fail validation, so a single legacy id anywhere in the history kills every subsequent Anthropic request in that session.

Notably, this repository already solves exactly this problem in another adapter. src/adapters/google.ts:97 defines geminiToolCallId(), whose doc comment states:

Anthropic's tool_use.id only accepts [a-zA-Z0-9_-], so non-conforming characters are mapped to _. To keep the mapping injective ... a short hash of the original raw id is appended whenever any character had to be rewritten.

So the Google adapter respects the Anthropic id constraint, while the Anthropic adapter itself does not. This looks like an oversight rather than a design decision.

Expected behaviour

src/adapters/anthropic.ts should normalize tool call ids to ^[a-zA-Z0-9_-]+$ before emitting them, using the same injective, deterministic transform already implemented as geminiToolCallId() — replace disallowed characters with _ and append a short hash of the raw id when a rewrite occurred.

Because the transform is deterministic and applied identically to the tool_use id and the matching tool_result.tool_use_id, existing call/result pairing is preserved. Ids that already conform are returned unchanged, so there is no behaviour change for the normal path.

Cross-provider history replay is a core value proposition of a universal proxy, so a foreign id should be sanitized rather than allowed to permanently break the conversation.

Minimal redacted request or reproduction

# 1. Run a session against a non-Anthropic provider through the proxy until the
#    history contains a tool call id with a character outside [a-zA-Z0-9_-].
#    (Real-world example: a Responses-style composite id joined by a newline,
#     "call_XXXX\nfc_YYYY".)
#
# 2. Switch the same client session to an Anthropic model through the proxy, so
#    the accumulated history is replayed to /v1/messages.
#
# Equivalent direct repro against the proxy:

curl -s http://127.0.0.1:10100/v1/chat/completions \
  -H 'content-type: application/json' \
  -H 'authorization: Bearer <redacted>' \
  -d '{
    "model": "anthropic/claude-opus-5",
    "messages": [
      {"role": "user", "content": "hi"},
      {"role": "assistant", "content": null,
       "tool_calls": [{"id": "call_AAAA\nfc_BBBB", "type": "function",
                       "function": {"name": "read_file", "arguments": "{}"}}]},
      {"role": "tool", "tool_call_id": "call_AAAA\nfc_BBBB", "content": "ok"},
      {"role": "user", "content": "continue"}
    ]
  }'

Actual response or error

HTTP 400

messages.155.content.0.tool_use.id: String should match pattern '^[a-zA-Z0-9_-]+$'

Every subsequent request on the same conversation fails identically, since the offending id stays in the replayed history. The only user-side workarounds are starting a fresh session or compacting the context so the tool call records are dropped.

Upstream documentation

https://docs.anthropic.com/en/api/messages

The tool_use.id / tool_result.tool_use_id pattern constraint ^[a-zA-Z0-9_-]+$ is enforced by the Messages API and reported directly in the 400 body quoted above. The same constraint is already documented in this repository at src/adapters/google.ts:89-96.

Suggested mapping or implementation notes

Reuse the existing transform rather than writing a new one — ideally lift geminiToolCallId() into a shared helper (it is provider-agnostic despite the name) and apply it at the three emission points in src/adapters/anthropic.ts:

function anthropicToolCallId(rawId: string | undefined): string | undefined {
  const raw = rawId ?? "";
  if (raw.length === 0) return undefined;
  const cleaned = raw.replace(/[^a-zA-Z0-9_-]/g, "_");
  if (cleaned === raw) return cleaned;
  const suffix = createHash("sha256").update(raw).digest("hex").slice(0, 8);
  return `${cleaned}_${suffix}`;
}

Apply at:

  • anthropic.ts:646 — the tool_use block id
  • anthropic.ts:576 — toAnthropicToolResult()'s tool_use_id
  • anthropic.ts:674 — the synthetic missing-tool_result filler id

All three must be changed together; sanitizing only one side would break call/result pairing. Note that toolUseIds / requiredIds pairing logic (anthropic.ts:659-677) compares raw ids internally, so normalization is safest applied at the wire-emission boundary, leaving internal bookkeeping on raw ids.

One edge case worth deciding: Anthropic also enforces a maximum id length, so if a rewritten id plus the 8-char hash suffix could exceed it, truncating the cleaned portion before appending the suffix would keep the result both valid and injective.

Additional context and attachments

Encountered while using OpenCodex-proxied models inside Grok Build via manually registered custom models (x-opencodex-grok: 1), switching models mid-session between xAI/OpenAI-path models and anthropic/claude-opus-5. Fresh Anthropic sessions through the same proxy work correctly, which isolates the failure to foreign ids in replayed history rather than to auth, routing, or model availability.

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. coderabbitai commented on Aug 15, 2026

    @coderabbitai
    Contributor
    🔗 Related PRs

    #302 - feat(kiro): harden completion and transport integration [merged]
    #304 - fix(kiro): restore hardening and complete text turns [open]
    #360 - fix: repair oversized Responses call IDs on replay [merged]
    #640 - fix(anthropic): reorder interleaved text before tool_use [merged]
    #781 - fix(bridge): cancel open tool calls on terminal error (#765) [open]
    #1266 - fix(google): replay Vertex thought signatures [merged]
    #1531 - fix(openai-chat): refuse to emit an unnamed streamed tool call [merged]
    #1726 - fix(xai): merge root tool unions into one object schema [merged]


    📝 Issue Planner

    Check the box below or use the @coderabbitai plan command to generate an implementation plan and prompts that you can use with your favorite coding assistant.

    • Create Plan

    🧪 Issue enrichment is currently in open beta.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  2. added
    providerProvider adapters, OpenAI-compat presets, upstream API quirks
    toolstool_calls, MCP, web-search / sidecar tools
    on Aug 15, 2026
  3. lidge-jun commented on Aug 16, 2026

    @lidge-jun
    Owner

    Fixed on dev by #1780.

    Tool call ids are now allocated per request rather than transformed per id, which closes the three defects that would have kept cross-provider replay broken:

    Empty ids no longer reach the wire. The helper returns undefined for an empty id precisely because it is not usable, and the ?? rawId fallback restored it anyway. A call with no usable id is now carried as text and its result follows it there, so neither tool_use.id nor tool_result.tool_use_id can be empty.

    Result matching compares the same domain. requiredIds held normalized ids while the result scan tested the raw toolCallId, so every rewritten pair lost its real result to orphan text and gained a synthetic missing-result block — worse than the original symptom. Matching now uses the wire id, via a lookup that never mints one, so a genuinely orphaned result still stays orphaned.

    The mapping is actually injective now. call:a normalizes to call_a_<hash>, and a distinct raw id already equal to that value conforms and passes through unchanged — two sources, one wire id. Ordinary 32-bit hash collisions had the same effect. A request-scoped allocator reserves conforming ids first and disambiguates rewrites around them.

    The maximum-length constraint you called out is covered too: isConformingToolCallId includes the bound, so a character-valid but over-long id is rewritten rather than passed through, and candidates are assembled as prefix_hash with only the prefix truncated — slicing the finished string would have cut off the discriminator. Collision suffixes reserve their own space.

    src/adapters/google.ts shares the same allocator instead of aliasing the stateless helper, which would have left the collision defect in place there.

    Regressions run through the real buildRequest path: rewritten pair stays paired, empty id never emitted, rewritten id stays distinct from a conforming id equal to its normalized form, result-only id mints nothing, over-length id rewritten to fit, and conforming pairs unchanged.

    Verified at the exact merge head: full Cross-platform CI green.

    Closing as completed. The fix is on dev and ships with the next release; it is not in a published version yet.

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

    bugSomething isn't workingproviderProvider adapters, OpenAI-compat presets, upstream API quirksprovider-compatibilityProvider compatibility reportstoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions