Skip to content

bug(llm): cross-provider tool-call IDs can exceed OpenAI-compatible length limit and permanently break sessions #54146

Open

Description

@ton618to

Description

Severity: high — this can permanently brick a long-running session. Switching models/providers (or resuming an affected session) makes every subsequent request fail with HTTP 400, and the invalid data is already persisted in history, so the error keeps repeating. Ordinary users cannot recover without starting a new session or hand-editing storage.

Summary

Cross-provider conversation replay preserves a provider-native tool-call ID verbatim and sends it unchanged to an OpenAI-compatible /chat/completions endpoint. Vertex/Gemini (and gateways that embed a signature in the call ID, e.g. SAP AI Core) can produce IDs >1000 characters, while OpenAI-compatible providers enforce a short tool_call_id limit (official OpenAI = 40, others 64). The request is rejected:

Error: 400 {
  "error": {
    "code": "string_above_max_length",
    "message": "Invalid 'messages[n].tool_calls[0].id': string too long. Expected a string with maximum length 64, but got a string with length 1262 instead.",
    "param": "messages[n].tool_calls[0].id",
    "type": "invalid_request_error"
  }
}

A historical assistant message carries an ID shaped like vertex_tool_<uuid>__sig_<long opaque base64 signature>, and that full value is emitted as both messages[n].tool_calls[0].id and the matching messages[n+1].tool_call_id.

Root cause

There is no tool-call-ID length normalization anywhere in the request serialization path. Incoming IDs are stored verbatim and replayed verbatim regardless of the target API's constraints.

New LLM protocol (packages/llm):

  • Inbound streaming preserves the provider ID as-is: packages/llm/src/protocols/openai-chat.ts (step, tool delta → { id: tool.id ?? undefined, ... }).
  • Outbound serialization emits it verbatim with no cap:
    • openai-chat.ts lowerToolCall → id: part.id; tool result → tool_call_id: part.id.
    • openai-responses.ts → call_id: part.id for both function_call and function_call_output.
    • openai-compatible-chat.ts reuses OpenAIChat.protocol end-to-end, so every non-OpenAI gateway inherits the same unbounded behavior.

Legacy session path (packages/opencode):

  • packages/opencode/src/session/message-v2.ts passes toolCallId: part.callID straight to the AI SDK, which forwards it to the provider unchanged.

A repo-wide search finds no cap/repair/dedup for tool-call ID length (provider-error.ts only matches context-window errors). Locally generated IDs are short, so this only triggers for externally supplied provider IDs.

Why this hits real users

Gemini/Vertex thinking models and gateways encode a thoughtSignature into the call ID (observed via SAP AI Core as vertex_tool_<uuid>__sig_<signature>, ~1200+ chars). Once such IDs are in history, switching to any OpenAI-compatible target — or resuming the session — fails immediately and keeps failing.

Prior (unresolved) reports

This has surfaced repeatedly but was never root-caused or fixed; all were auto-closed as stale/not-planned, and the bot explicitly asks to reopen a fresh issue:

Steps to reproduce

  1. Run a session on a Vertex/Gemini model (or a gateway such as SAP AI Core / Portkey) that returns signed/long tool-call IDs.
  2. Make at least one tool call so a long ID is persisted in history.
  3. Switch to an OpenAI-compatible model (e.g. gpt-5* via official OpenAI, LiteLLM, or Copilot), or resume the session with that model.
  4. Next request → HTTP 400 string_above_max_length on tool_calls[].id; the session stays broken.

Expected behavior

Before serializing history for a target API, tool-call IDs must satisfy that target's constraints:

  1. When the target enforces a length limit, replace the oversized ID with a deterministic, collision-safe ≤limit ID.
  2. Rewrite both tool_calls[].id / call_id and every matching tool_call_id / function_call_output.call_id so pairing is preserved.
  3. Preserve opaque provider-native IDs only where the target API supports them (keep native Vertex/Gemini replay intact).

Suggested regression coverage: replay a history containing a Vertex-style signed tool-call ID through an OpenAI Chat target with a short limit and assert every emitted ID is within the limit, each result ID is rewritten to match its call, multiple calls stay uniquely paired, and native Gemini replay still carries signatures.

Plugins

none

OpenCode version

dev @ 1.18.35 (github-v1.2.25-2143-gf17f13bdc0); also reported on 1.1.53 (#12653) and 1.2.27 (#17933)

Operating System

macOS (reproducible independent of OS)

Terminal

n/a

Activity

  1. opencode-agent commented on Oct 9, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report and root-cause analysis!

    This was reported on OpenCode v1 (1.18.35), and the code paths you cite (packages/llm, packages/opencode/src/session/message-v2.ts) are v1 code. v1 only gets fixes for critical bugs (data loss, security problems, or breakage with no workaround). There is a workaround here: switch back to the provider that produced the long IDs, or start a new session. So this is unlikely to be fixed in v1.

    Please try OpenCode v2 (@opencode/cli, the opencode command), which has a separate provider and protocol implementation. If you can reproduce this on v2, for example by running a session on Vertex/Gemini or a gateway like SAP AI Core, then switching to an OpenAI-compatible model, please reply here with the v2 version and the error, and we'll look at it again. Please also reply if you think this is critical in a way I've missed.

    Related issues:

  2. ton618to commented on Oct 9, 2026

    @ton618to
    Author

    V2 (packages/ai) also affected — partial mitigation only

    Checked the v2 branch (v2.0.0-951, architecture is packages/ai, not packages/llm). It adds a conditional tool-call-ID normalization but still reproduces this bug for the exact #15056-style scenario. The pattern matches the known failure mode: normalization is conditional and leaves cross-provider / foreign IDs unchanged.

    Chat path — packages/ai/src/protocols/openai-chat.ts

    lowerMessages builds a toolCallID(id) function (L580-590) that is applied consistently to both tool_calls[].id (lowerToolCall, L356) and tool_call_id (lowerToolMessages, L512 / L522), so call/result pairing stays correct. But the length cap is whitelist-based:

    target rule bounded?
    openai / azure / openai/-prefixed model id.slice(0, 40) yes
    mistral family strip + slice(0, 9) yes
    claude-matching model id replace invalid chars only, no length cap no
    everything else (litellm, openrouter, copilot-chat, portkey, custom openai-compatible, ...) return id verbatim no

    The generic provider in packages/ai/src/providers/openai-compatible.ts defaults provider to "openai-compatible" (or a user string like litellm), which is neither openai/azure nor an openai/-prefixed model id, so toolCallID returns the ID verbatim. A vertex_tool_<uuid>__sig_<...> ID (>1000 chars) therefore passes through unchanged → HTTP 400 string_above_max_length, exactly the LiteLLM/gpt-5.6-sol case.

    Responses path — packages/ai/src/protocols/open-responses.ts

    No normalization at all: call_id: part.id verbatim at L494-495 (function_call) and L757-758 (function_call_output). This path backs openai-compatible-responses (e.g. GitHub Copilot codex), matching the older report #17933 (input[].id string too long).

    No per-provider length flag

    The model compatibility schema (packages/ai/src/schema/options.ts L194-201, packages/schema/src/model.ts L77-83) has fields like requireReasoning / requireAssistantAfterTool but no toolCallIdLimit / usesOpenAIToolCallIdLimit. There is no way for an OpenAI-compatible gateway to declare its 64-char limit; the cap is hardcoded by provider-name/model-id string matching.

    Summary

    • dev (packages/llm): no ID normalization anywhere → every provider affected.
    • v2 Chat (packages/ai): bounded only for openai/azure/mistral; claude and all other gateways pass through verbatim.
    • v2 Responses (packages/ai): no normalization at all.

    A proper fix should be the same on both branches and both protocol families: when the target API enforces a tool-call-ID length limit, deterministically rewrite oversized IDs to a valid, collision-safe form and rewrite every matching call/result ID — rather than a provider-name whitelist — and extend the same handling to the Responses path.

  3. opencode-agent commented on Oct 9, 2026

    @opencode-agent
    Contributor

    Thanks for checking v2. I confirmed this on the latest v2 (5cfe3b4), and v2.0.26 has the same code.

    I built a history with a ~1254-character vertex_tool_<uuid>__sig_<...> tool-call ID and its tool result, then compiled requests for OpenAI-compatible targets in packages/ai:

    • Chat (provider litellm, openai-compatible, and openrouter with anthropic/claude-sonnet-4): tool_calls[].id and tool_call_id are both sent at the full 1254 characters.
    • Responses (github-copilot): function_call.call_id and function_call_output.call_id are also sent unchanged.

    This matches your analysis. The chat path only shortens IDs for mistral-family models and for openai/azure/openai/* models, and the Responses path doesn't shorten them at all. I didn't have provider credentials, so I couldn't send the request to a real endpoint and see the 400. But these lengths are far over the 40/64-character limits. The script below reproduces it from a v2 checkout.

    Related: #12653 and #17933 (earlier reports of the same error).

    Reproduction script (repro-54146.ts)
    // Repro for anomalyco/opencode#54146: long provider-native tool-call IDs are replayed
    // verbatim to OpenAI-compatible targets. Copy to packages/ai/test/ in a v2 checkout, then from packages/ai: bun test/repro-54146.ts
    import { Effect } from "effect"
    import { LLM, Message, ToolCallPart } from "../src/index.js"
    import { compileRequest } from "../src/route/client.js"
    import * as OpenAICompatibleChat from "../src/protocols/openai-compatible-chat.js"
    import * as OpenAICompatibleResponses from "../src/protocols/openai-compatible-responses.js"
    
    // Vertex/Gemini-via-gateway style signed tool-call ID (~1260 chars)
    const longID = `vertex_tool_8f14e45f-ceea-467a-9a3c-1b2d3e4f5a6b__sig_${"Q2lNQkFZ".repeat(150)}`
    const LIMIT = 64
    
    const messages = [
      Message.user("What's the weather?"),
      Message.assistant([ToolCallPart.make({ id: longID, name: "lookup", input: {} })]),
      Message.tool({ id: longID, name: "lookup", result: { forecast: "sunny" } }),
      Message.user("Thanks, continue."),
    ]
    
    const targets = [
      { name: "chat / provider=litellm model=gpt-5.6", route: OpenAICompatibleChat.route, provider: "litellm", model: "gpt-5.6" },
      { name: "chat / provider=openai-compatible", route: OpenAICompatibleChat.route, provider: "openai-compatible", model: "gpt-5-mini" },
      { name: "chat / provider=openrouter model=anthropic/claude-sonnet-4", route: OpenAICompatibleChat.route, provider: "openrouter", model: "anthropic/claude-sonnet-4" },
      { name: "responses / provider=github-copilot", route: OpenAICompatibleResponses.route, provider: "github-copilot", model: "gpt-5.3-codex" },
    ]
    
    const ids = (body: any): string[] => {
      const out: string[] = []
      for (const m of body.messages ?? []) {
        for (const c of m.tool_calls ?? []) out.push(`tool_calls[].id=${c.id.length}`)
        if (m.tool_call_id) out.push(`tool_call_id=${m.tool_call_id.length}`)
      }
      for (const i of body.input ?? []) if (i.call_id) out.push(`${i.type}.call_id=${i.call_id.length}`)
      return out
    }
    
    let failed = 0
    for (const t of targets) {
      const prepared = await Effect.runPromise(
        compileRequest(
          LLM.request({
            model: t.route.with({ provider: t.provider, endpoint: { baseURL: "https://gateway.test/v1" } }).model({ id: t.model }),
            messages,
          }),
        ),
      )
      const lens = ids(prepared.body)
      const bad = lens.some((s) => Number(s.split("=")[1]) > LIMIT)
      if (bad) failed++
      console.log(`${bad ? "FAIL" : "ok  "} ${t.name}: ${lens.join(", ")} (limit ${LIMIT})`)
    }
    console.log(failed ? `\n${failed} target(s) would be rejected with 400 string_above_max_length` : "\nall IDs within limit")
    process.exit(failed ? 1 : 0)

    Run it: bun repro-54146.ts

  4. argszero commented on Oct 10, 2026

    @argszero
    Contributor

    I'd like to work on this.

    I verified both gaps in the code on the current v2 tip (f714e5f68b), by reading the serialization path — not by hitting a live endpoint:

    • Chat — packages/ai/src/protocols/openai-chat.ts builds a single toolCallID(id) that is applied to both tool_calls[].id and tool_call_id, so call/result pairing is already consistent. The length cap, however, is whitelist-based: mistral-family gets slice(0, 9), claude model IDs are sanitized with no cap, openai/azure/openai/* get slice(0, 40), and every other target returns the ID verbatim — which is exactly the reported case (litellm, openai-compatible, other gateways).
    • Responses — packages/ai/src/protocols/open-responses.ts does no normalization at all: call_id: part.id is emitted verbatim for both function_call and function_call_output.

    So the change I'd like to prepare is one bounded, collision-safe derivation applied whenever a target enforces an ID length limit, on the Chat and Responses paths alike, instead of a provider-name whitelist. Extending the whitelist would leave the reported setup broken.

    If you'd rather keep the first change narrower — for example, only the Chat path, or a per-model compatibility flag instead of a derivation — say so and I'll scope it that way.

  5. fleuryc-owkin commented on Oct 13, 2026

    @fleuryc-owkin

    Hit this today on v2.0.28 via Requesty — a second minting source beyond cross-provider replay. When Requesty doesn't return a tool-call ID it synthesizes chatcmpl-tool-<32hex> (46 chars), and the stored tool-part ID becomes the composite <uuid>.chatcmpl-tool-<32hex> (83 chars), over the 64-char cap from the moment it is minted. A single-provider session with no model switch then fails on every replay: Invalid 'input[641].call_id': string too long (Responses path).

    Evidence from local storage (session_message, v2.0.28): 34 over-long IDs in one session, all of that composite shape, with tool calls in the same assistant message sharing the <uuid> prefix. The planned serialization-time derivation will have to absorb these composites regardless; capping at the mint/join point would collapse both sources into one.

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions