Skip to content
Closed
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
43 changes: 42 additions & 1 deletion docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -1202,7 +1202,8 @@ it drops the typed search declaration and ordinarily sends function schemas eage
make the client's context accounting undercount the provider's input.

There is a separate, narrow exception for **client-executed `ToolSearch`** on a verified Responses
destination selected by [`claudeCode.appendOnlyToolLoadingModels`](#preserve-cache-prefixes-when-client-tools-load).
destination selected by [`claudeCode.appendOnlyToolLoadingModels`](#preserve-cache-prefixes-when-client-tools-load),
or on a verified Chat destination selected by [`claudeCode.chatToolDispatchModels`](#chat-routes).
For that final physical route, the opt-in keeps unused deferred definitions out of the initial
catalog and adds current definitions at successful structured discovery results. Client-side
search must already work and an eager `ToolSearch` function must be declared. The launcher
Expand Down Expand Up @@ -1277,6 +1278,46 @@ admission or turn on tool search in the client. It applies when the client alrea
It does not guarantee cache hits: schema changes, revocations, model switches, compaction and
upstream cache availability can still require a new prefix.

A Responses provider that accepts `additional_tools` but silently drops it is not verified for this
opt-in: the loaded definition never reaches the model. DeepSeek's official Responses API behaved this
way when measured on 2026-10-31. Use the Chat-route opt-in below for DeepSeek instead.

#### Chat routes

A Chat Completions route has no equivalent of `additional_tools`. On a translated Chat route, each
client ToolSearch load changes the tool catalog and the tool-name list in the system message, so the
next request misses the provider's prompt cache for the whole conversation. A separate opt-in keeps
both stable for **direct Chat routes verified with the models you use**:

```json
{
"claudeCode": {
"chatToolDispatchModels": ["deepseek/deepseek-flash"]
}
}
```

Selectors, limits, the metadata and eager `ToolSearch` prerequisites, and the combo, policy and
late-fallback rules are the same as for `appendOnlyToolLoadingModels`, applied to a final physical
route that is a direct `openai-chat` provider. On a selected route:

- From the first request, the catalog carries one extra function, `use_loaded_tool`, and leaves
unused deferred tools out. Loading a tool changes neither the catalog nor the system message.
- A discovered tool's name, description and parameter schema are appended as text to the ToolSearch
result that loaded it. The model calls the tool through `use_loaded_tool` with
`{"name": ..., "arguments": {...}}`, and earlier calls are replayed in that same shape.
- opencodex rewrites those calls back to the real tool before Claude Code sees them, and only for
tools discovered in that conversation or already in the catalog. Claude Code always receives its
own tool names; a malformed wrapper call reaches it unchanged and fails as an unknown tool.

Limitations: the definitions are text, so provider-side strict schema enforcement does not apply to
them and an invalid argument costs a turn; arguments of a dispatched call are not streamed until
the call completes; the instruction sits inside a tool result, which some models may not follow, so
verify each model before selecting it. A request that forces a hidden tool, or whose history is
ambiguous, is sent unchanged. If a model calls an undiscovered deferred tool directly, that tool
returns to the catalog and the cached prefix is lost once. Absent, empty or unlisted configuration sends exactly the same
request bytes as before. This does not guarantee cache hits either.

#### Claude models behind a gateway that also serves GPT

`appendOnlyToolLoadingModels` cannot fix Claude models that are routed through a Responses
Expand Down
4 changes: 4 additions & 0 deletions docs-site/src/content/docs/ko/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,10 @@ Anthropic 백엔드를 명시하면 의도적으로 실패 후 중단해요.

OpenCode Go의 Responses 엔드포인트와 공식 xAI Responses 호스트는 선택 목록에 있어도 원래 전체 도구 카탈로그를 유지합니다. 이 목적지들은 `additional_tools`를 초기 `tools`로 승격하거나 거절하므로 시간순 로딩 prefix를 보존할 수 없습니다. 검색 결과의 정의를 초기 `tools`로 승격하는 다른 게이트웨이에서도 이 옵션을 꺼 두세요.

`additional_tools`를 받아들이지만 오류 없이 버리는 Responses 공급자도 이 옵션의 대상이 아닙니다. 불러온 정의가 모델에게 전달되지 않기 때문입니다. 2026-10-31 측정에서 DeepSeek 공식 Responses API가 이렇게 동작했습니다. DeepSeek에는 아래 Chat 경로 옵션을 사용하세요.

Chat Completions 경로에는 `additional_tools`에 해당하는 수단이 없습니다. 변환된 Chat 경로에서는 ToolSearch로 도구를 불러올 때마다 도구 카탈로그와 시스템 메시지의 도구 이름 목록이 바뀌어, 다음 요청이 대화 전체에서 공급자 프롬프트 캐시를 놓칩니다. 사용하는 모델로 검증한 직접 Chat 경로라면 `claudeCode.chatToolDispatchModels`(예: `["deepseek/deepseek-flash"]`)로 둘을 고정할 수 있습니다. 선택자 문법, 제한, 메타데이터와 eager `ToolSearch` 전제, combo·policy·늦은 대체 경로 규칙은 `appendOnlyToolLoadingModels`와 같고, 최종 물리 경로가 직접 `openai-chat` 공급자일 때만 적용됩니다. 선택된 경로에서는 첫 요청부터 카탈로그에 `use_loaded_tool` 함수 하나를 더하고 사용하지 않은 지연 도구는 뺍니다. 검색으로 찾은 도구의 이름, 설명, 매개변수 스키마는 그 도구를 불러온 ToolSearch 결과 뒤에 텍스트로 붙습니다. 모델은 `use_loaded_tool`로 그 도구를 호출하고, opencodex는 그 대화에서 검색됐거나 이미 카탈로그에 있는 도구에 한해 호출을 원래 도구로 되돌려 Claude Code에 전달합니다. 형식이 맞지 않는 호출은 그대로 전달되어 알 수 없는 도구로 실패합니다. 모델이 검색하지 않은 지연 도구를 직접 호출하면 그 도구가 카탈로그로 돌아와 캐시 prefix를 한 번 잃습니다. 정의가 텍스트로 전달되므로 공급자 측 strict 스키마 검사는 적용되지 않습니다. 호출 인자는 호출이 끝날 때까지 스트리밍되지 않습니다. 안내가 도구 결과 안에 있어 따르지 않는 모델이 있을 수 있으니 모델마다 검증한 뒤 켜세요. 옵션이 없거나 비어 있거나 목록에 없는 경로는 이전과 같은 요청 바이트를 보냅니다. 캐시 적중을 보장하지는 않습니다. [영문 설정 안내](/guides/claude-code/#chat-routes)를 참고하세요.

**서브에이전트가 잘못된 모델로 디스패치됨** — 로스터 에이전트(`ocx-*`)는 Agent 도구의 `model`
인자가 아니라 `<!-- ocx-route: ... -->` 지시문을 사용해요. 지시문이 원하는 라우트와 일치하는지
확인하고, 모델 자리 표시자로 `"haiku"`를 전달하세요.
Expand Down
2 changes: 2 additions & 0 deletions scripts/test-layout/layout-adapters.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"claude-inbound-tool-loading.test.ts": "claude-integration",
"command-code-plan-denial.test.ts": "providers",
"claude-tool-loading-destinations.test.ts": "claude-integration",
"claude-chat-tool-dispatch.test.ts": "claude-integration",
"claude-chat-tool-dispatch-routes.test.ts": "claude-integration",
"anthropic-affinity-restart.test.ts": "adapters/anthropic",
"anthropic-affinity-store.test.ts": "adapters/anthropic"
}
234 changes: 234 additions & 0 deletions src/claude/chat-tool-dispatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import type { ProviderAdapter } from "../adapters/base";
import type { AdapterEvent, OcxAssistantContentPart, OcxContentPart, OcxMessage, OcxParsedRequest, OcxTool } from "../types";
import { isRec, type Rec } from "./inbound-records";
import { readClientToolDiscovery } from "./inbound-tool-loading";

/**
* Chat-wire counterpart of the Responses `additional_tools` projection (#7452/#7453).
*
* A Chat Completions upstream renders the tool catalog ahead of every message, so a definition
* that ToolSearch loads mid-conversation rewrites the prompt prefix and the provider re-reads
* the whole history. The Chat wire has no chronological tool carrier, so on opted-in Chat routes
* the catalog instead carries one stable dispatcher from the first request on. A loaded deferred
* tool's definition is appended as text to the ToolSearch result that loaded it (the transcript
* tail at the time), the model calls it through the dispatcher, and the response is rewritten
* back to the client tool before the client sees it. History is re-encoded the same way on every
* request, so earlier bytes never change.
*/
export const CHAT_TOOL_DISPATCH_NAME = "use_loaded_tool";

const DISPATCH_TOOL: OcxTool = {
name: CHAT_TOOL_DISPATCH_NAME,
description: "Call a tool that was loaded during this conversation with ToolSearch. Loaded tools and their "
+ "parameters are listed in the ToolSearch result that loaded them. Pass the loaded tool's exact name and "
+ "an arguments object that matches its parameters.",
parameters: {
type: "object",
properties: {
name: { type: "string", description: "Exact name of a tool loaded with ToolSearch." },
arguments: { type: "object", description: "Arguments for that tool, matching its parameters.", additionalProperties: true },
},
required: ["name", "arguments"],
additionalProperties: false,
},
strict: false,
};

export interface ChatToolDispatchProjection {
parsed: OcxParsedRequest;
/** Client tools the dispatcher currently stands in for. */
loaded: ReadonlySet<string>;
/** Names a dispatcher call may map back to: the loaded tools plus the visible catalog. */
targets: ReadonlySet<string>;
}

/** Loaded definitions as transcript text; deterministic so the same history re-encodes identically. */
function loadedToolsText(tools: readonly OcxTool[]): string {
const blocks = tools.map(tool => `<tool name=${JSON.stringify(tool.name)}>\n`
+ `Description: ${tool.description}\n`
+ `Parameters (JSON Schema): ${JSON.stringify(tool.parameters)}\n</tool>`);
return `\n[These tools are now loaded. Call them through \`${CHAT_TOOL_DISPATCH_NAME}\` with `
+ `{"name": "<tool name>", "arguments": {...}}.]\n${blocks.join("\n")}\n`;
}

function appendText(content: string | OcxContentPart[], text: string): string | OcxContentPart[] {
if (typeof content === "string") return content + text;
return [...content, { type: "text", text }];
}

/**
* Project an opted-in Chat request onto a stable catalog, or return null to send it unchanged.
* Same discovery evidence as appendLoadedToolDefinitions: a successful structured ToolSearch
* result naming a deferred tool that this request still declares.
*/
export function projectChatToolDispatch(parsed: OcxParsedRequest, raw: Rec): ChatToolDispatchProjection | null {
const tools = parsed.context.tools;
if (!Array.isArray(tools)) return null;

const declared = new Map<string, OcxTool>();
for (const tool of tools) {
if (declared.has(tool.name)) return null;
declared.set(tool.name, tool);
}
// A client tool that already uses the dispatcher name would make the mapping ambiguous.
if (declared.has(CHAT_TOOL_DISPATCH_NAME)) return null;
const discovery = readClientToolDiscovery(raw, name => declared.has(name));
if (!discovery) return null;
const { deferred, used, loadAt } = discovery;
// The dispatcher exists only where discovery exists, and from that session's first request.
if (!declared.has("ToolSearch") || deferred.has("ToolSearch")) return null;

// Deferred tools stay out of the catalog unless they were already called without a discovery
// result (eager history, or a load the client compacted away): those keep their own slot.
const nativeUsed = new Set([...used].filter(name => !loadAt.has(name)));
const hidden = new Set([...deferred].filter(name => declared.has(name) && !nativeUsed.has(name)));
// Explicit forced-tool intent on a hidden tool cannot be expressed through the dispatcher.
const choice = parsed.options.toolChoice;
if (isRec(choice) && typeof (choice as Rec).name === "string" && hidden.has((choice as Rec).name as string)) return null;

const additions = new Map<string, OcxTool[]>();
for (const [name, id] of loadAt) {
const list = additions.get(id) ?? [];
list.push(declared.get(name)!);
additions.set(id, list);
}
// Every discovery result must be present exactly once to carry its definitions.
const resultCounts = new Map<string, number>();
for (const message of parsed.context.messages) {
if (message.role === "toolResult") resultCounts.set(message.toolCallId, (resultCounts.get(message.toolCallId) ?? 0) + 1);
}
for (const id of additions.keys()) if (resultCounts.get(id) !== 1) return null;

const loaded = new Set(loadAt.keys());
const dispatchedCalls = new Set<string>();
const messages: OcxMessage[] = parsed.context.messages.map(message => {
if (message.role === "assistant") {
let changed = false;
const content = message.content.map((part): OcxAssistantContentPart => {
if (part.type !== "toolCall" || !loaded.has(part.name)) return part;
changed = true;
dispatchedCalls.add(part.id);
return { ...part, name: CHAT_TOOL_DISPATCH_NAME, arguments: { name: part.name, arguments: part.arguments } };
});
return changed ? { ...message, content } : message;
}
if (message.role === "toolResult") {
const added = additions.get(message.toolCallId);
const renamed = dispatchedCalls.has(message.toolCallId);
if (!added && !renamed) return message;
return {
...message,
...(renamed ? { toolName: CHAT_TOOL_DISPATCH_NAME } : {}),
...(added ? { content: appendText(message.content, loadedToolsText(added)) } : {}),
};
}
return message;
});

const visible = tools.filter(tool => !hidden.has(tool.name));
return {
parsed: { ...parsed, context: { ...parsed.context, messages, tools: [...visible, DISPATCH_TOOL] } },
loaded,
// A visible tool named through the dispatcher grants nothing new, so it maps back too.
targets: new Set([...loaded, ...visible.map(tool => tool.name)]),
};
}

/**
* Read a dispatcher call's arguments as exactly `{name, arguments?}`. `arguments` may be an object
* or a JSON string holding one (a common double-encoding). Anything else, including extra
* top-level keys that would otherwise be dropped silently, is not a dispatch.
*/
function dispatchTarget(text: string, targets: ReadonlySet<string>): { name: string; args: string } | null {
let value: unknown;
try { value = JSON.parse(text); } catch { return null; }
if (!isRec(value) || typeof value.name !== "string" || !targets.has(value.name)) return null;
if (Object.keys(value).some(key => key !== "name" && key !== "arguments")) return null;
let args: unknown = value.arguments ?? {};
if (typeof args === "string") {
try { args = JSON.parse(args); } catch { return null; }
}
return isRec(args) ? { name: value.name, args: JSON.stringify(args) } : null;
}

/**
* Rewrites dispatcher calls back to the client tool they name, one adapter event at a time.
* Only a name in `targets` (a tool discovered in this request's history, or one already in the
* visible catalog) is valid, so the dispatcher can never reach a deferred tool ToolSearch has not
* loaded. A call that is not a well-formed dispatch for such a tool passes through unchanged, and
* the client reports the unknown dispatcher call instead of the proxy guessing.
*/
function chatToolDispatchRewriter(targets: ReadonlySet<string>) {
let pending: { start: Extract<AdapterEvent, { type: "tool_call_start" }>; args: string } | null = null;
const release = (): AdapterEvent[] => {
if (!pending) return [];
const out: AdapterEvent[] = [pending.start];
if (pending.args.length > 0) out.push({ type: "tool_call_delta", arguments: pending.args });
pending = null;
return out;
};
const push = (event: AdapterEvent): AdapterEvent[] => {
if (pending) {
if (event.type === "tool_call_delta") { pending.args += event.arguments; return []; }
if (event.type === "tool_call_end") {
const target = event.incomplete ? null : dispatchTarget(pending.args, targets);
if (!target) return [...release(), event];
const start = { ...pending.start, name: target.name };
pending = null;
return [start, { type: "tool_call_delta", arguments: target.args }, event];
}
// Any other event closes buffering without a terminal; surface what was buffered.
const out = release();
return [...out, ...push(event)];
}
if (event.type === "tool_call_start" && event.name === CHAT_TOOL_DISPATCH_NAME) {
pending = { start: event, args: "" };
return [];
}
return [event];
};
return { push, flush: release };
}

/** Streaming form: rewrite dispatcher calls in an adapter stream (see chatToolDispatchRewriter). */
export async function* rewriteChatToolDispatchEvents(
events: AsyncIterable<AdapterEvent>,
targets: ReadonlySet<string>,
): AsyncGenerator<AdapterEvent> {
const rewriter = chatToolDispatchRewriter(targets);
for await (const event of events) yield* rewriter.push(event);
yield* rewriter.flush();
}

/** Non-streaming form of rewriteChatToolDispatchEvents for adapter `parseResponse` results. */
export function rewriteChatToolDispatchEventList(events: readonly AdapterEvent[], targets: ReadonlySet<string>): AdapterEvent[] {
const rewriter = chatToolDispatchRewriter(targets);
return [...events.flatMap(event => rewriter.push(event)), ...rewriter.flush()];
}

/**
* Wrap one physical openai-chat adapter: project each built request and un-project both parse
* paths. Each build decides afresh, so a request that bails out (forced hidden tool, duplicate
* call id) is sent unchanged and its response is not rewritten.
*/
export function applyChatToolDispatch(adapter: ProviderAdapter, discovery: Rec): void {
const build = adapter.buildRequest.bind(adapter);
const parseStream = adapter.parseStream.bind(adapter);
const parseResponse = adapter.parseResponse?.bind(adapter);
let targets: ReadonlySet<string> | null = null;
adapter.buildRequest = (parsed, incoming) => {
const projection = projectChatToolDispatch(parsed, discovery);
targets = projection?.targets ?? null;
return build(projection ? projection.parsed : parsed, incoming);
};
adapter.parseStream = (...args) => {
const events = parseStream(...args);
return targets ? rewriteChatToolDispatchEvents(events, targets) : events;
};
if (parseResponse) {
adapter.parseResponse = async (...args) => {
const events = await parseResponse(...args);
return targets ? rewriteChatToolDispatchEventList(events, targets) : events;
};
}
}
Loading
Loading