Repository navigation
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
Description
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
baseUrlnormalisation should tolerate/,/v1and/v1/messagessuffixes and produce exactly one/v1/messages.input_json_deltashould only accumulate while the current block is atool_use, per the Anthropic streaming spec.- A
tool_useblock with no usableidshould get a synthesised one (toolu_…) so tool results can be correlated, rather than an empty string being forwarded. - A stream that ends after content without terminal frames should still produce a completion event instead of leaving the client waiting.
tool_use.inputthat 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>"
}- With that
baseUrl, every request 404s (problem 1). Changing it tohttps://<host>works around it. - 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). - 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
- Anthropic Messages streaming, including
input_json_deltaoccurring only withintool_useblocks and themessage_delta/message_stopterminators: https://docs.anthropic.com/en/docs/build-with-claude/streaming - Tool use, including the
toolu_id format used to correlatetool_usewithtool_result: https://docs.anthropic.com/en/docs/build-with-claude/tool-use
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
github-actions commentedon Jul 30, 2026
Automated translation bookkeeping — detected language: unknown.
Wibias commentedon Jul 30, 2026
[shipping-github] Research review
Claim: Anthropic adapter doubles
/v1/messageswhenbaseUrlalready ends with it;input_json_deltanot scoped totool_use; relay quirk tolerance for empty tool ids / missing terminal frames / stringtool_use.input.Checked against: dev@be177ea5
baseUrl.replace(/\/v1\/?$/, "")only strips/v1;input_json_deltabranch has nocurrentBlockType === "tool_use"guardEvidence:
src/adapters/anthropic.ts~683–684 URL build; ~802–803input_json_deltawithout 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.
Wibias commentedon Jul 30, 2026
[shipping-github] Opened PR #781 to address this.
fix(anthropic): normalize the messages baseUrl and harden stream quir…
revert(anthropic): drop the streamed tool-argument validation, keep t…
fix(bridge): cancel an open tool call on failure instead of completin…
fix(anthropic): treat a blank tool_use id as absent, not as an id
lidge-jun commentedon Jul 31, 2026
All five reported items are now on
dev(verified atb79eb71a2)./v1/messagesdoubled the pathanthropic.ts:261—anthropicMessagesUrl()normalizes/,/v1, and/v1/messagesto one formdecb08ba2input_json_deltanot scoped totool_useanthropic.ts:864— now gated oncurrentBlockType === "tool_use"decb08ba2usableToolUseId()—b79eb71a2toolCallArgumentsUsable()insrc/bridge.tscancels a call whose assembled arguments do not parse, rather than completing it67219b3b6(from PR #781 by @Wibias)4f0633fcdThe 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.
fix(anthropic): normalize the messages baseUrl and harden stream quir…
revert(anthropic): drop the streamed tool-argument validation, keep t…
fix(bridge): cancel an open tool call on failure instead of completin…
fix(anthropic): treat a blank tool_use id as absent, not as an id