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
1 change: 1 addition & 0 deletions docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,7 @@ The proxy translates every Anthropic Messages API request into the Codex Respons
| Assistant text | `output_text` |
| Assistant `tool_use` | `function_call` (`input` → JSON-stringified `arguments`) |
| User `tool_result` | `function_call_output` (`is_error` → `[tool error]` prefix) |
| `tool_reference` in a tool result | Text `Tool loaded: <tool_name>` in the paired result; preserves loaded-tool names without adding declarations or enabling translated server-side deferral |
| `thinking` / `redacted_thinking` replay | `reasoning` items with bounded `ocxr1` envelopes for signatures and redacted payloads |
| Function tools | `{type: "function"}` (`web_search*` → `{type: "web_search"}`) |
| `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, named function→`{type:"function",name}`, hosted WebSearch/web_search→`{type:"web_search"}` |
Expand Down
1 change: 1 addition & 0 deletions docs-site/src/content/docs/ko/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,7 @@ Claude Code의 `/effort` 설정은 어댑터에서도 유지돼요.
| Assistant 텍스트 | `output_text` |
| Assistant `tool_use` | `function_call`(`input` → JSON 문자열로 변환한 `arguments`) |
| 사용자 `tool_result` | `function_call_output`(`is_error` → `[tool error]` 접두사) |
| 도구 결과의 `tool_reference` | 짝지어진 결과에 `Tool loaded: <tool_name>` 텍스트로 보존합니다. 도구 선언을 추가하거나 번역 경로의 서버 측 지연 로딩을 활성화하지는 않습니다. |
| `thinking` / `redacted_thinking` 재생 | 서명과 비공개 페이로드를 제한된 `ocxr1` 봉투에 담은 `reasoning` 항목 |
| Function 도구 | `{type: "function"}`(`web_search*` → `{type: "web_search"}`) |
| `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, 이름 지정 함수→`{type:"function",name}`, 호스팅 WebSearch/web_search→`{type:"web_search"}` |
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@
"claude-gateway-cache.test.ts": "claude-integration",
"claude-inbound-cache-stabilize.test.ts": "claude-integration",
"claude-inbound-debug.test.ts": "claude-integration",
"claude-inbound.test.ts": "claude-integration",
"claude-inbound.test.ts": "claude-integration", "claude-inbound-tool-reference.test.ts": "claude-integration",
"claude-output-schema-strict.test.ts": "claude-integration",
"claude-intercept-integration.test.ts": "server",
"claude-intercept-local-ca.test.ts": "claude-integration",
Expand Down
9 changes: 9 additions & 0 deletions src/claude/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@ function toolResultOutput(block: Rec): string | Rec[] {
if (!isRec(item)) continue;
if (item.type === "text" && typeof item.text === "string") {
out.push({ type: "input_text", text: item.text });
} else if (item.type === "tool_reference" && typeof item.tool_name === "string" && item.tool_name.length > 0) {
// Responses has no tool_reference result carrier. Preserve ToolSearch's loaded-name
// evidence as result text so a reference-only reply does not become empty (#6647).
// This describes client output; it must not create tools or widen authorization.
// Text-only tool output is later joined without separators, so the marker carries its
// own line breaks to keep each loaded name distinct from its neighbours.
const prev = out[out.length - 1];
const lead = prev?.type === "input_text" && typeof prev.text === "string" && !prev.text.endsWith("\n") ? "\n" : "";
out.push({ type: "input_text", text: `${lead}Tool loaded: ${item.tool_name}\n` });
} else if (item.type === "image") {
const img = imageBlockToInputImage(item);
if (img) out.push(img);
Expand Down
2 changes: 2 additions & 0 deletions structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,8 @@ The [compaction routing override](../transports/responses-failover.md#compaction

## Routed bundled-skill text

Translated tool results in `src/claude/inbound.ts` retain nonempty string `tool_reference.tool_name` values as `Tool loaded: <name>` text lines in the paired `function_call_output`, preserving mixed-content order and the error marker. Each marker ends with a newline and starts with one after text that does not, because text-only tool output is joined without separators downstream. References describe client output; they do not declare or authorize executable tools, enable translated server-side deferral, or alter native passthrough. `tests/claude-integration/claude-inbound-tool-reference.test.ts` covers reference-only results, mixed/error output, malformed names, caller immutability and the real Responses parser.

`src/claude/inbound.ts` bounds the text-carrier skill-directory probe to 4,096 UTF-16 code units, plus one character to recognize the terminating newline. A longer first line is preserved intact instead of being scanned or stubbed; normal POSIX, Windows, mixed and UNC separators retain their basename matching. The existing 10,000-character payload threshold and `claudeCode.blockedSkills` policy remain: `claude-api` is blocked by default, and an explicit empty list disables elision. Native Anthropic passthrough and tool-call/result pairing are unchanged. `tests/claude-integration/claude-inbound.test.ts` covers the exact 4,096/4,097 boundary and a long newline-free carrier.

`src/claude/inbound-content-options.ts` strips Claude Code's leading `x-anthropic-billing-header:` line from a string system prompt or from the first text block of a system array before it becomes Responses `instructions`, dropping a block left empty. The line's `cch` value rotates per request, so keeping it made the translated prefix and the system-derived fallback `prompt_cache_key` change every turn (#6627). The match is anchored at the prompt start, like the Antigravity strip in `src/adapters/google.ts`; native Anthropic passthrough does not use this translation and keeps the client preamble. `tests/claude-integration/claude-inbound.test.ts` covers string and array systems, header-only blocks, later mentions and key stability.
Expand Down
84 changes: 84 additions & 0 deletions tests/claude-integration/claude-inbound-tool-reference.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, test } from "bun:test";
import { anthropicToResponsesBody } from "../../src/claude/inbound";
import { parseRequest } from "../../src/responses/parser";
import { responsesRequestSchema } from "../../src/responses/schema";

function request(content: unknown[], isError = false) {
return {
model: "routed-model", max_tokens: 256,
tools: [{ name: "ToolSearch", input_schema: { type: "object", properties: {} } }],
messages: [
{ role: "assistant", content: [{ type: "tool_use", id: "search_1", name: "ToolSearch", input: {} }] },
{ role: "user", content: [{ type: "tool_result", tool_use_id: "search_1", is_error: isError, content }] },
{ role: "user", content: "Continue the original task." },
],
};
}

describe("translated Claude tool references", () => {
test("reference-only ToolSearch results survive schema parsing and retain the call slot", () => {
const raw = request([
{ type: "tool_reference", tool_name: "WebFetch" },
{ type: "tool_reference", tool_name: "mcp__docs__lookup" },
]);
const before = JSON.stringify(raw);
const body = anthropicToResponsesBody(raw);
expect(() => responsesRequestSchema.parse(body)).not.toThrow();
expect(() => parseRequest(body)).not.toThrow();
expect(body.input).toEqual([
{ type: "function_call", call_id: "search_1", name: "ToolSearch", arguments: "{}" },
{ type: "function_call_output", call_id: "search_1", output: [
{ type: "input_text", text: "Tool loaded: WebFetch\n" },
{ type: "input_text", text: "Tool loaded: mcp__docs__lookup\n" },
] },
{ type: "message", role: "user", content: [{ type: "input_text", text: "Continue the original task." }] },
]);
expect(JSON.stringify(raw)).toBe(before);
// A result describes client-loaded tools; it must not add executable declarations.
expect(body.tools).toHaveLength(1);
});

test("mixed output preserves order and errors without mutating frozen references", () => {
const reference = Object.freeze({ type: "tool_reference", tool_name: "Read" });
const body = anthropicToResponsesBody(request([
{ type: "text", text: "Before" }, reference,
{ type: "image", source: { type: "url", url: "https://example.invalid/fixture.png" } },
{ type: "text", text: "After" },
], true));
expect(body.input[1]).toEqual({ type: "function_call_output", call_id: "search_1", output: [
{ type: "input_text", text: "[tool error]" },
{ type: "input_text", text: "Before" },
{ type: "input_text", text: "\nTool loaded: Read\n" },
{ type: "input_image", image_url: "https://example.invalid/fixture.png" },
{ type: "input_text", text: "After" },
] });
expect(reference).toEqual({ type: "tool_reference", tool_name: "Read" });
});

test("loaded names stay line-separated after the parser joins text-only output", () => {
const parsed = parseRequest(anthropicToResponsesBody(request([
{ type: "text", text: "Before" },
{ type: "tool_reference", tool_name: "Read" },
{ type: "tool_reference", tool_name: "Write" },
{ type: "text", text: "After" },
])));
const result = parsed.context.messages.find((message) => message.role === "toolResult");
expect(result?.content).toBe("Before\nTool loaded: Read\nTool loaded: Write\nAfter");
const onlyRefs = parseRequest(anthropicToResponsesBody(request([
{ type: "tool_reference", tool_name: "WebFetch" },
{ type: "tool_reference", tool_name: "mcp__docs__lookup" },
])));
expect(onlyRefs.context.messages.find((message) => message.role === "toolResult")?.content)
.toBe("Tool loaded: WebFetch\nTool loaded: mcp__docs__lookup\n");
});

test("missing, non-string and empty names do not fabricate loaded tools", () => {
const body = anthropicToResponsesBody(request([
{ type: "tool_reference" }, { type: "tool_reference", tool_name: 42 },
{ type: "tool_reference", tool_name: "" }, { type: "text", text: "Still present" },
]));
expect(body.input[1]).toEqual({ type: "function_call_output", call_id: "search_1", output: [
{ type: "input_text", text: "Still present" },
] });
});
});
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@
"claude-first-party-union.test.ts": "claude-integration", "claude-desktop-system-proxy.test.ts": "claude-integration",
"claude-gateway-cache.test.ts": "claude-integration", "claude-inbound-cache-stabilize.test.ts": "claude-integration",
"claude-inbound-debug.test.ts": "claude-integration", "claude-inbound.test.ts": "claude-integration",
"claude-inbound-tool-reference.test.ts": "claude-integration",
"claude-output-schema-strict.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server",
"claude-intercept-local-ca.test.ts": "claude-integration",
"claude-intercept-local-ca-files.test.ts": "claude-integration",
Expand Down
Loading