Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 91 additions & 6 deletions src/claude/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,43 @@ function sseFrame(name: string, data: Rec): string {
return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
}

/**
* Claude Code / Anthropic WebSearch domain filters are optional and mutually exclusive.
* Empty arrays are rejected ("ambiguous"); both fields together are rejected. Routed models
* often emit both shapes — sanitize before Claude Code sees the tool_use / server_tool_use
* input (issue #381).
*/
export function isClaudeWebSearchToolName(name: string): boolean {
const trimmed = name.trim();
return trimmed === "WebSearch" || /^web_search/i.test(trimmed);
}

function normalizeWebSearchDomainList(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const domains = value
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
.map(entry => entry.trim());
return domains.length > 0 ? domains : undefined;
}

/** Strip empty domain filters; if both remain, keep `allowed_domains` and drop `blocked_domains`. */
export function sanitizeWebSearchInput(input: unknown): Rec {
const src: Rec = isRec(input) ? { ...input } : {};
const allowed = normalizeWebSearchDomainList(src.allowed_domains);
const blocked = normalizeWebSearchDomainList(src.blocked_domains);
delete src.allowed_domains;
delete src.blocked_domains;
if (allowed && blocked) {
// Prefer allow-list (restrict-to) when a routed model sets both non-empty fields.
src.allowed_domains = allowed;
} else if (allowed) {
src.allowed_domains = allowed;
} else if (blocked) {
src.blocked_domains = blocked;
}
return src;
}

/**
* Map a Responses `web_search_call` item to its Anthropic pair: the server_tool_use
* input (query/queries) and the web_search_tool_result content (hits, or the error
Expand All @@ -87,7 +124,9 @@ function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resultConte
? action.queries.filter((q): q is string => typeof q === "string" && q.length > 0)
: [];
const query = typeof action.query === "string" ? action.query : "";
const input: Rec = queries.length > 1 ? { queries } : { query: queries[0] ?? query };
const input = sanitizeWebSearchInput(
queries.length > 1 ? { queries } : { query: queries[0] ?? query },
);
const completed = item.status !== "failed";
let resultContent: unknown;
if (completed) {
Expand Down Expand Up @@ -125,6 +164,10 @@ interface OpenBlock {
index: number;
/** Responses item_id (tool calls) so output_item.done can match. */
itemId?: string;
/** Buffer WebSearch args and emit one sanitized input_json_delta on close (#381). */
bufferWebSearchArgs?: boolean;
argsBuf?: string;
webSearchArgsEmitted?: boolean;
}

/** Streaming: Responses SSE bytes -> Anthropic Messages SSE bytes. */
Expand Down Expand Up @@ -170,6 +213,19 @@ export function responsesSseToAnthropicSse(
}
const closeOpenBlock = () => {
if (!open) return;
if (open.kind === "tool_use" && open.bufferWebSearchArgs && !open.webSearchArgsEmitted) {
// Emit sanitized args even if output_item.done never supplied a full arguments field
// (finish/fail paths, or deltas-only streams).
let parsed: unknown = {};
const rawArgs = open.argsBuf ?? "";
try { parsed = rawArgs.length > 0 ? JSON.parse(rawArgs) : {}; } catch { parsed = {}; }
emit("content_block_delta", {
type: "content_block_delta",
index: open.index,
delta: { type: "input_json_delta", partial_json: JSON.stringify(sanitizeWebSearchInput(parsed)) },
});
open.webSearchArgsEmitted = true;
}
if (open.kind === "thinking") {
// Synthetic signature: Claude Code accepts it (003 E6); inbound drops replays anyway.
emit("content_block_delta", {
Expand Down Expand Up @@ -253,21 +309,34 @@ export function responsesSseToAnthropicSse(
closeOpenBlock();
sawToolUse = true;
const index = blockIndex++;
const name = typeof item.name === "string" ? item.name : "";
const bufferWebSearchArgs = isClaudeWebSearchToolName(name);
emit("content_block_start", {
type: "content_block_start", index,
content_block: {
type: "tool_use",
id: typeof item.call_id === "string" ? item.call_id : `toolu_${uuid()}`,
name: typeof item.name === "string" ? item.name : "",
name,
input: {},
},
});
open = { kind: "tool_use", index, itemId: typeof item.id === "string" ? item.id : undefined };
open = {
kind: "tool_use",
index,
itemId: typeof item.id === "string" ? item.id : undefined,
bufferWebSearchArgs,
argsBuf: "",
webSearchArgsEmitted: false,
};
break;
}
case "response.function_call_arguments.delta": {
if (typeof data.delta !== "string" || data.delta.length === 0) break;
if (!open || open.kind !== "tool_use") break;
if (open.bufferWebSearchArgs) {
open.argsBuf = `${open.argsBuf ?? ""}${data.delta}`;
break;
}
emit("content_block_delta", {
type: "content_block_delta", index: open.index,
delta: { type: "input_json_delta", partial_json: data.delta },
Expand Down Expand Up @@ -307,7 +376,22 @@ export function responsesSseToAnthropicSse(
if (!open) break;
// Close the matching open block (message/reasoning items close implicitly on
// the next block; function_call items must close here so tool input parses).
if (open.kind === "tool_use" && item.type === "function_call") closeOpenBlock();
if (open.kind === "tool_use" && item.type === "function_call") {
if (open.bufferWebSearchArgs && !open.webSearchArgsEmitted) {
const rawArgs = typeof item.arguments === "string" && item.arguments.length > 0
? item.arguments
: (open.argsBuf ?? "");
let parsed: unknown = {};
try { parsed = rawArgs.length > 0 ? JSON.parse(rawArgs) : {}; } catch { parsed = {}; }
emit("content_block_delta", {
type: "content_block_delta",
index: open.index,
delta: { type: "input_json_delta", partial_json: JSON.stringify(sanitizeWebSearchInput(parsed)) },
});
open.webSearchArgsEmitted = true;
}
closeOpenBlock();
}
else if (open.kind === "text" && item.type === "message") closeOpenBlock();
else if (open.kind === "thinking" && item.type === "reasoning") closeOpenBlock();
break;
Expand Down Expand Up @@ -442,11 +526,12 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string): R
if (typeof raw.arguments === "string" && raw.arguments.length > 0) {
try { input = JSON.parse(raw.arguments); } catch { input = {}; }
}
const name = typeof raw.name === "string" ? raw.name : "";
content.push({
type: "tool_use",
id: typeof raw.call_id === "string" ? raw.call_id : `toolu_${uuid()}`,
name: typeof raw.name === "string" ? raw.name : "",
input,
name,
input: isClaudeWebSearchToolName(name) ? sanitizeWebSearchInput(input) : input,
});
break;
}
Expand Down
94 changes: 94 additions & 0 deletions tests/claude-outbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
collectAnthropicMessage,
responsesJsonToAnthropicMessage,
responsesSseToAnthropicSse,
sanitizeWebSearchInput,
} from "../src/claude/outbound";

function sse(name: string, data: Record<string, unknown>): string {
Expand Down Expand Up @@ -434,3 +435,96 @@ describe("claude outbound web_search translation", () => {
expect(msg.usage.server_tool_use).toEqual({ web_search_requests: 1 });
});
});

describe("sanitizeWebSearchInput (#381)", () => {
test("omits empty allowed_domains and blocked_domains arrays", () => {
expect(sanitizeWebSearchInput({
query: "CLAUDE.md practices",
allowed_domains: ["code.claude.com"],
blocked_domains: [],
})).toEqual({
query: "CLAUDE.md practices",
allowed_domains: ["code.claude.com"],
});
expect(sanitizeWebSearchInput({
query: "community",
allowed_domains: [],
blocked_domains: ["example.com"],
})).toEqual({
query: "community",
blocked_domains: ["example.com"],
});
expect(sanitizeWebSearchInput({
query: "open",
allowed_domains: [],
blocked_domains: [],
})).toEqual({ query: "open" });
});

test("keeps allowed_domains and drops blocked_domains when both are non-empty", () => {
expect(sanitizeWebSearchInput({
query: "docs",
allowed_domains: ["code.claude.com"],
blocked_domains: ["example.com"],
})).toEqual({
query: "docs",
allowed_domains: ["code.claude.com"],
});
});

test("JSON path sanitizes WebSearch function_call arguments", () => {
const msg = responsesJsonToAnthropicMessage({
status: "completed",
output: [{
type: "function_call",
call_id: "toolu_ws",
name: "WebSearch",
arguments: JSON.stringify({
query: "site:code.claude.com memory",
allowed_domains: ["code.claude.com"],
blocked_domains: [],
}),
}],
usage: { input_tokens: 3, output_tokens: 1 },
}, "claude-ocx-native--gpt-5.6-sol") as Record<string, any>;
expect(msg.stop_reason).toBe("tool_use");
expect(msg.content[0]).toEqual({
type: "tool_use",
id: "toolu_ws",
name: "WebSearch",
input: {
query: "site:code.claude.com memory",
allowed_domains: ["code.claude.com"],
},
});
});

test("SSE path buffers WebSearch args and emits one sanitized input_json_delta", async () => {
const args = JSON.stringify({
query: "CLAUDE.md token cost",
allowed_domains: [],
blocked_domains: ["example.com"],
});
const upstream = [
sse("response.created", { response: {} }),
sse("response.output_item.added", {
output_index: 0,
item: { type: "function_call", id: "fc_ws", call_id: "toolu_ws", name: "WebSearch", arguments: "", status: "in_progress" },
}),
sse("response.function_call_arguments.delta", { item_id: "fc_ws", output_index: 0, delta: args.slice(0, 20) }),
sse("response.function_call_arguments.delta", { item_id: "fc_ws", output_index: 0, delta: args.slice(20) }),
sse("response.output_item.done", {
output_index: 0,
item: { type: "function_call", id: "fc_ws", call_id: "toolu_ws", name: "WebSearch", arguments: args },
}),
sse("response.completed", { response: { status: "completed", usage: { input_tokens: 2, output_tokens: 1 } } }),
].join("");
const events = await collectEvents(responsesSseToAnthropicSse(streamFrom(upstream), "m"));
const deltas = events.filter(e => e.name === "content_block_delta" && e.data.delta?.type === "input_json_delta");
expect(deltas).toHaveLength(1);
expect(JSON.parse(deltas[0].data.delta.partial_json)).toEqual({
query: "CLAUDE.md token cost",
blocked_domains: ["example.com"],
});
});
});
Loading