Skip to content

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

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:646toolUses.push({ type: "tool_use", id: tc.id, ... })
  • anthropic.ts:576tool_use_id: msg.toolCallId
  • anthropic.ts:674tool_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:576toAnthropicToolResult()'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.

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