Skip to content

Commit decb08b

Browse files
Wibiaslidge-jun
andcommitted
fix(anthropic): normalize the messages baseUrl and harden stream quirks (#765)
Four defects an Anthropic-compatible relay exposes, all in the same adapter. A `baseUrl` pasted as `https://host/v1/messages` -- which is what the provider's own docs show -- got `/v1/messages` appended again. The URL is now normalized so both the bare-origin and full-endpoint forms resolve to one request path. `input_json_delta` was accepted regardless of the open block, so a relay emitting JSON deltas outside a `tool_use` block corrupted the assembled arguments; the delta is now scoped to `tool_use`. A `tool_use` block arriving without an `id` was forwarded with an empty one, which downstream treats as unpaired -- a `toolu_` id is synthesized instead. And a stream that reached EOF carrying a `message_delta.stop_reason` but no `message_stop` now settles from that stop reason rather than hanging. Two changes on top of the contributed patch: `toolUseArguments` re-encoded an unparseable string input as a JSON *string*, so `"not json at all"` arrived where the tool contract requires an object. That is the double-encoding half of #765 and it survived the original fix. It degrades to `{}` now, which still fails -- but inside the tool's own argument validation instead of as a type error. A test pins it. `sawContent` was assigned in six places and read in none; removed. The contributed branch also swapped three `/api/logs` test files onto a `logsFromApiBody` helper that accepts both the bare array and a `{logs}` envelope. That helper is not brought across: it would pre-accept the response-shape change this round has not decided on, and a matcher that tolerates both shapes cannot fail when the shape regresses. Only the adapter and its own test come over. #765 stays open -- it asks for broader relay quirk tolerance than these four fixes. Note for whoever runs the suite: `tests/claude-messages-endpoint.test.ts` and `tests/claude-native-passthrough.test.ts` already fail together on an unmodified tree ("unmapped claude model + sk-ant credential passes through verbatim"), and pass individually. That interference predates this commit. Co-authored-by: bitkyc08-arch <bitkyc08@gmail.com>
1 parent ba77eb8 commit decb08b

2 files changed

Lines changed: 148 additions & 7 deletions

File tree

src/adapters/anthropic.ts

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,40 @@ function usesNativeAnthropicEndpoint(provider: OcxProviderConfig): boolean {
250250
}
251251
}
252252

253+
/** Normalize provider baseUrl paths ending in `/`, `/v1`, or `/v1/messages` to `{origin}/v1/messages`. */
254+
export function anthropicMessagesUrl(baseUrl: string): string {
255+
try {
256+
new URL(baseUrl);
257+
} catch {
258+
throw new Error(`anthropic provider has malformed baseUrl: ${baseUrl}`);
259+
}
260+
const trimmed = baseUrl.trim().replace(/\/+$/, "");
261+
const root = trimmed.replace(/\/v1\/messages\/?$/i, "").replace(/\/v1\/?$/i, "").replace(/\/+$/, "");
262+
return `${root}/v1/messages`;
263+
}
264+
265+
function synthesizeToolUseId(): string {
266+
return `toolu_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`;
267+
}
268+
269+
function toolUseArguments(input: unknown): string {
270+
if (typeof input === "string") {
271+
const trimmed = input.trim();
272+
if (!trimmed) return "{}";
273+
try {
274+
JSON.parse(trimmed);
275+
return trimmed;
276+
} catch {
277+
// A tool call's arguments must be a JSON object. Re-encoding an unparseable string as a
278+
// JSON *string* is the double-encoding #765 reports: the caller then receives
279+
// `"get weather"` where an object was required and the tool call is unusable either way.
280+
// An empty object at least fails in the tool's own argument validation.
281+
return "{}";
282+
}
283+
}
284+
return JSON.stringify(input ?? {});
285+
}
286+
253287
function anthropicKeyUsesBearer(provider: OcxProviderConfig): boolean {
254288
return provider.apiKeyTransport === "bearer";
255289
}
@@ -680,8 +714,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
680714
else if (typeof tc === "object" && "name" in tc) body.tool_choice = { type: "tool", name: toolNames.toWire(resolveToolChoiceWireName(parsed.context.tools, tc.name)) };
681715
}
682716

683-
const base = provider.baseUrl.replace(/\/v1\/?$/, "");
684-
const url = `${base}/v1/messages`;
717+
const url = anthropicMessagesUrl(provider.baseUrl);
685718
const unresolvedPlaceholder = url.match(/\{[^}]*\}/)?.[0];
686719
if (unresolvedPlaceholder) {
687720
throw new Error(`anthropic baseUrl contains unresolved ${unresolvedPlaceholder}`);
@@ -773,7 +806,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
773806
if (!block) break;
774807
currentBlockType = block.type;
775808
if (block.type === "tool_use") {
776-
currentToolCallId = block.id ?? "";
809+
currentToolCallId = block.id ?? synthesizeToolUseId();
777810
currentToolCallName = toolNames.fromWire(block.name ?? "");
778811
yield { type: "tool_call_start", id: currentToolCallId, name: currentToolCallName };
779812
}
@@ -799,7 +832,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
799832
// Arrives once, just before the thinking block's content_block_stop; block-scoped
800833
// so a stray signature on a non-thinking block can never be captured.
801834
yield { type: "thinking_signature", signature: delta.signature };
802-
} else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
835+
} else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && currentBlockType === "tool_use") {
803836
yield { type: "tool_call_delta", arguments: delta.partial_json };
804837
}
805838
break;
@@ -831,12 +864,13 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
831864
}
832865
}
833866
if (!emittedDone) {
867+
// Fail closed on transport EOF. Compatible providers may omit message_stop after message_delta.stop_reason.
834868
if (pendingStopReason !== undefined) {
835869
const stopReason = pendingStopReason === "max_tokens"
836870
? "max_tokens"
837871
: pendingStopReason === "refusal" || pendingStopReason === "content_filter"
838872
? "content_filter"
839-
: undefined;
873+
: pendingStopReason;
840874
emittedDone = true;
841875
yield {
842876
type: "done",
@@ -867,8 +901,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
867901
} else if (block.type === "redacted_thinking" && typeof block.data === "string") {
868902
events.push({ type: "redacted_thinking", data: block.data });
869903
} else if (block.type === "tool_use") {
870-
events.push({ type: "tool_call_start", id: block.id ?? "", name: toolNames.fromWire(block.name ?? "") });
871-
events.push({ type: "tool_call_delta", arguments: JSON.stringify(block.input ?? {}) });
904+
const id = block.id ?? synthesizeToolUseId();
905+
events.push({ type: "tool_call_start", id, name: toolNames.fromWire(block.name ?? "") });
906+
events.push({ type: "tool_call_delta", arguments: toolUseArguments(block.input) });
872907
events.push({ type: "tool_call_end" });
873908
}
874909
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { anthropicMessagesUrl, createAnthropicAdapter } from "../src/adapters/anthropic";
3+
import type { AdapterEvent } from "../src/types";
4+
5+
const provider = { adapter: "anthropic", baseUrl: "https://example.test", apiKey: "key" };
6+
7+
async function collect(gen: AsyncGenerator<AdapterEvent>): Promise<AdapterEvent[]> {
8+
const out: AdapterEvent[] = [];
9+
for await (const e of gen) out.push(e);
10+
return out;
11+
}
12+
13+
describe("anthropicMessagesUrl", () => {
14+
test.each([
15+
["https://example.test", "https://example.test/v1/messages"],
16+
["https://example.test/", "https://example.test/v1/messages"],
17+
["https://example.test/v1", "https://example.test/v1/messages"],
18+
["https://example.test/v1/", "https://example.test/v1/messages"],
19+
["https://example.test/v1/messages", "https://example.test/v1/messages"],
20+
["https://example.test/v1/messages/", "https://example.test/v1/messages"],
21+
] as const)("normalizes %s", (input, expected) => {
22+
expect(anthropicMessagesUrl(input)).toBe(expected);
23+
});
24+
});
25+
26+
describe("anthropic stream hardening", () => {
27+
test("EOF after content without message_stop fails closed", async () => {
28+
const response = new Response([
29+
"event: content_block_start\n",
30+
'data: {"type":"content_block_start","content_block":{"type":"text","text":""}}\n\n',
31+
"event: content_block_delta\n",
32+
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}\n\n',
33+
].join(""));
34+
const events = await collect(createAnthropicAdapter(provider).parseStream(response));
35+
expect(events.at(-1)?.type).toBe("error");
36+
expect(events.some(e => e.type === "done")).toBe(false);
37+
});
38+
39+
test("input_json_delta outside tool_use is ignored", async () => {
40+
const response = new Response([
41+
"event: content_block_start\n",
42+
'data: {"type":"content_block_start","content_block":{"type":"text","text":""}}\n\n',
43+
"event: content_block_delta\n",
44+
'data: {"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"{\\"x\\":1}"}}\n\n',
45+
"event: message_stop\n",
46+
'data: {"type":"message_stop"}\n\n',
47+
].join(""));
48+
const events = await collect(createAnthropicAdapter(provider).parseStream(response));
49+
expect(events.some(e => e.type === "tool_call_delta")).toBe(false);
50+
expect(events.at(-1)?.type).toBe("done");
51+
});
52+
53+
test("tool_use without id synthesizes a toolu_ id", async () => {
54+
const response = new Response([
55+
"event: content_block_start\n",
56+
'data: {"type":"content_block_start","content_block":{"type":"tool_use","name":"get_weather"}}\n\n',
57+
"event: content_block_delta\n",
58+
'data: {"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"{}"}}\n\n',
59+
"event: content_block_stop\n",
60+
'data: {"type":"content_block_stop"}\n\n',
61+
"event: message_stop\n",
62+
'data: {"type":"message_stop"}\n\n',
63+
].join(""));
64+
const events = await collect(createAnthropicAdapter(provider).parseStream(response));
65+
const start = events.find(e => e.type === "tool_call_start");
66+
expect(start).toMatchObject({ type: "tool_call_start", name: "get_weather" });
67+
expect(start && "id" in start && start.id.startsWith("toolu_")).toBe(true);
68+
});
69+
70+
test("empty EOF without content still errors", async () => {
71+
const response = new Response("");
72+
const events = await collect(createAnthropicAdapter(provider).parseStream(response));
73+
expect(events.at(-1)?.type).toBe("error");
74+
expect(events.some(e => e.type === "done")).toBe(false);
75+
});
76+
});
77+
78+
describe("anthropic non-stream tool_use input", () => {
79+
test("parses string tool_use.input", async () => {
80+
const adapter = createAnthropicAdapter(provider);
81+
const events = await adapter.parseResponse!(new Response(JSON.stringify({
82+
content: [{ type: "tool_use", id: "toolu_1", name: "get_weather", input: "{\"city\":\"Paris\"}" }],
83+
stop_reason: "tool_use",
84+
})));
85+
expect(events.find(e => e.type === "tool_call_delta")).toMatchObject({
86+
type: "tool_call_delta",
87+
arguments: "{\"city\":\"Paris\"}",
88+
});
89+
expect(events.at(-1)?.type).toBe("done");
90+
});
91+
92+
test("unparseable string tool_use.input degrades to {} instead of double-encoding", async () => {
93+
// Re-encoding the raw text as a JSON string produces `"not json at all"` where the tool
94+
// contract requires an object -- that is the double-encoding half of #765. `{}` is still
95+
// wrong input, but it fails in the tool's own argument validation rather than as a type error.
96+
const adapter = createAnthropicAdapter(provider);
97+
const events = await adapter.parseResponse!(new Response(JSON.stringify({
98+
content: [{ type: "tool_use", id: "toolu_2", name: "get_weather", input: "not json at all" }],
99+
stop_reason: "tool_use",
100+
})));
101+
expect(events.find(e => e.type === "tool_call_delta")).toMatchObject({
102+
type: "tool_call_delta",
103+
arguments: "{}",
104+
});
105+
});
106+
});

0 commit comments

Comments
 (0)