Skip to content

[Bug]: Routed providers emit 'undeclared client tool Write' on Claude Code passthrough due to missing inboundWire exemption in passthrough-dispatch #6648

Closed

Description

@yuhang0521

Client or integration

Claude Code

Area

Proxy and routing

Summary

When proxying Claude Code (or VS Code Claude Agent) through OpenCodex using the Anthropic inbound wire (inboundWire: "anthropic"), routed third-party providers (such as DeepSeek V3/V4.1, Qwen, etc. routed via openai-responses or passthrough) frequently fail during multi-turn coding sessions when the model calls standard Claude tools (e.g. Write, Edit, Bash, Read).

The proxy aborts the streaming session and returns:

API Error: routed provider emitted undeclared client tool "Write"; only request-declared tools may be called

Root Cause Analysis

There is an inconsistency in how the undeclared tool guard is enforced between the bridged adapter path and the passthrough dispatch path:

  1. In src/server/responses/adapter-delivery.ts (lines 186 and 270), enforceDeclaredToolNames explicitly excludes Anthropic and Chat inbound wires:

    enforceDeclaredToolNames: options.inboundWire !== "chat" && options.inboundWire !== "anthropic",

    This is intentional, because Anthropic clients declare and execute their own client-side tool catalog (Write, Edit, Bash, etc.) under the Anthropic tool wire schema rather than Codex's internal declared wire tool representations.

  2. However, in src/server/responses/passthrough-dispatch.ts (line 513), undeclaredToolGuardActive omitted the inbound wire check:

    undeclaredToolGuardActive = (
      declaredWireToolNames.size > 0
      || clientDeclaredNamelessCallTypes.size > 0
      || clientExplicitWireToolCatalog
    ) && route.provider.authMode !== "forward";

    Notice that options.inboundWire !== "chat" and options.inboundWire !== "anthropic" are missing.

  3. Consequently, when a routed provider returns a tool call under passthrough-dispatch, undeclaredToolGuardActive evaluates to true. Because Claude's client tools (such as Write) are not in declaredWireToolNames, createUndeclaredToolCallGuardBlockRewrite trips and abruptly terminates the session with the error above.

Proposed Fix

1. Synchronize undeclaredToolGuardActive in src/server/responses/passthrough-dispatch.ts

Align the guard condition with adapter-delivery.ts:

--- a/src/server/responses/passthrough-dispatch.ts
+++ b/src/server/responses/passthrough-dispatch.ts
@@ -515,7 +515,10 @@ export async function dispatchPassthroughTurn(
         declaredWireToolNames.size > 0
         || clientDeclaredNamelessCallTypes.size > 0
         || clientExplicitWireToolCatalog
-      ) && route.provider.authMode !== "forward";
+      ) && route.provider.authMode !== "forward"
+      && options.inboundWire !== "chat"
+      && options.inboundWire !== "anthropic"
+      && inboundWire !== "anthropic";
       options.nativeControl?.configureToolAuthorization?.(
         undeclaredToolGuardActive,
         declaredWireToolNames,

2. (Optional Defense-in-Depth) Recognize Claude Code built-in tools in src/server/responses-undeclared-tool-guard.ts

When Codex code-mode shell execution is not active (!declared.has(CODE_MODE_EXEC_TOOL_NAME)), allow standard Claude Code client tools (Write, Edit, Read, Bash, etc.) through undeclaredNameInItem:

--- a/src/server/responses-undeclared-tool-guard.ts
+++ b/src/server/responses-undeclared-tool-guard.ts
@@ -93,6 +93,9 @@ function undeclaredNameInItem(
   const effectiveName = normalizeDeclaredToolName(name, declared, declaredBare, declaredCustom);
   if (declared.has(effectiveName)) return undefined;
+  if (CLAUDE_CODE_CLIENT_TOOLS.has(name) && !declared.has(CODE_MODE_EXEC_TOOL_NAME)) {
+    return undefined;
+  }
   return name;

3. (Optional) Case-insensitive normalization fallback in src/types/tools.ts

In normalizeDeclaredToolName, add a case-insensitive lookup fallback if exact case match fails.

Reproduction

  1. Start OpenCodex proxy (ocx start --port 10100).
  2. Route Anthropic wire requests to a non-forward routed provider (e.g., DeepSeek-V3 or DeepSeek-V4.1 via openai-responses or passthrough).
  3. Connect Claude Code or VS Code Claude Agent to http://127.0.0.1:10100.
  4. Prompt the model with a multi-step task that causes it to invoke the Write or Edit tool.
  5. Observed: The turn fails immediately with API Error: routed provider emitted undeclared client tool "Write"; only request-declared tools may be called.
  6. Expected: The tool call passes through to the client for local execution, just as it does in adapter-delivery.ts.

Version

@bitkyc08/opencodex 2.78.0

Operating system

Windows 11 (Build 26200)

Provider and model

Routed providers (e.g. DeepSeek-V3 / DeepSeek-V4.1 Flash) via Anthropic inbound wire

Logs or error output

API Error: routed provider emitted undeclared client tool "Write"; only request-declared tools may be called

Redacted configuration

{
  "routing": {
    "providers": {
      "deepseek": {
        "authMode": "api-key",
        "wire": "openai-responses"
      }
    }
  }
}

Checks

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Activity

  1. added
    bugSomething isn't working
    proxyHTTP proxy, routing, reverse-proxy / management auth
    toolstool_calls, MCP, web-search / sidecar tools
    on Oct 5, 2026
  2. Ingwannu commented on Oct 6, 2026

    @Ingwannu
    Owner

    At current dev f924820652, the passthrough guard condition does differ from the adapter-delivery condition as reported. However, removing the entire guard for Anthropic/Chat ingress does not by itself explain why a genuinely declared Write is absent from its normalized declared-name set.

    Please attach a minimal synthetic failing fixture showing the inbound tools declaration, the translated tool name sent upstream, and the returned tool-call name/event. The fix should preserve an actually declared Claude tool while retaining refusal of a genuinely undeclared call on Responses ingress, and cover streaming plus terminal/non-stream output. The optional blanket builtin allowlist and case-insensitive fallback should not be bundled in without demonstrating the corresponding mismatch; they can authorize a name the caller did not declare.

    This is separate from the Cursor wire-alias work in #6640 and ToolSearch result-content loss in #6647. No guard bypass or upstream request was executed during triage; the local failure mechanism still needs a focused regression before changing authorization.

  3. lidge-jun commented on Oct 7, 2026

    @lidge-jun
    Owner

    Held: the passthrough guard asymmetry is real in code, but translation already preserves a declared Write name, so the reported abort is not yet reproduced. A minimal reproduction (declared tool list in, routed tool call out) would let us land the parity change with confidence.

  4. lidge-jun commented on Oct 8, 2026

    @lidge-jun
    Owner

    Fixed on dev by #6732 (merge b1e1735cdf).

    Cause. The documented contract (#4799, structure/adapters/compatibility-contracts.md) is that only the responses inbound wire refuses a routed provider's call to an undeclared tool (#1700); /v1/messages and /v1/chat/completions relay it to the client's own tool runner. The adapter and runTurn paths already did that, but the Responses passthrough path (src/server/responses/passthrough-delivery.ts) refused on every wire. A routed openai-responses provider calling a tool the Claude Code request had not declared (for example a deferred or runtime-discovered tool) therefore ended the turn with routed provider emitted undeclared client tool "Write". A new endpoint test reproduced this on unmodified dev: 8 failures across Anthropic and Chat, stream and buffered.

    Fix. Only the two passthrough refusal points (SSE rewrite and buffered JSON) are now scoped by inbound wire. Name normalization (default.Read → Read) and continuation suppression still run on every wire. A relayed undeclared call is still never written to previous_response_id replay state. /v1/responses refusal is unchanged; that endpoint sets its wire server-side, so a caller cannot opt out of it.

    The optional builtin-name allowlist and case-insensitive fallback from the report were not adopted, because they would authorize undeclared names on the responses wire.

    Thanks for the detailed report.

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 workingproxyHTTP proxy, routing, reverse-proxy / management authtoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions