From 95104c635414109a4392ff7663976bed72f03c5d Mon Sep 17 00:00:00 2001 From: Gudals0320 Date: Sat, 31 Oct 2026 16:16:17 +0900 Subject: [PATCH 1/3] feat(claude): keep the Chat tool catalog stable across ToolSearch loads (opt-in dispatcher) On a translated openai-chat route every client ToolSearch load rewrote the tool-name list in the system message and the Chat tools array, so the next request missed the provider prompt cache for the whole conversation. #7453 fixed this for Responses through additional_tools; the Chat wire has no such carrier. claudeCode.chatToolDispatchModels (same selector contract as appendOnlyToolLoadingModels) keeps one stable use_loaded_tool wrapper in the catalog on a selected direct openai-chat route, appends each discovered definition to the ToolSearch result that loaded it, replays earlier calls in the wrapper shape, and maps wrapper calls back to the client tool on both parse paths, only for tools discovered in that request. Forced undiscovered tools, duplicate call ids and ambiguous discovery send the request unchanged. Combos, policies and non-Chat physical routes never project, and absent, empty or unlisted configuration is byte-identical. Discovery reconstruction is shared with the Responses projection through readClientToolDiscovery. Refs #7963 Co-Authored-By: Claude Opus 5.5 --- .../src/content/docs/guides/claude-code.md | 41 ++- .../src/content/docs/ko/guides/claude-code.md | 4 + scripts/test-layout/layout-adapters.json | 2 + src/claude/chat-tool-dispatch.ts | 228 ++++++++++++++ src/claude/inbound-tool-loading.ts | 47 ++- src/config/salvage.ts | 1 + src/config/schema/config-schema.ts | 5 +- src/providers/provider-id-rewrite.ts | 3 + src/server/claude-messages.ts | 3 +- src/server/responses/request-transport.ts | 9 + src/types/config.ts | 2 + structure/config.md | 1 + structure/data-planes/claude-tool-loading.md | 36 +++ .../claude-chat-tool-dispatch-routes.test.ts | 221 ++++++++++++++ .../claude-chat-tool-dispatch.test.ts | 283 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 3 +- 16 files changed, 872 insertions(+), 17 deletions(-) create mode 100644 src/claude/chat-tool-dispatch.ts create mode 100644 tests/claude-integration/claude-chat-tool-dispatch-routes.test.ts create mode 100644 tests/claude-integration/claude-chat-tool-dispatch.test.ts diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index c0b83efa0ce..6bd79d32989 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -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 @@ -1277,6 +1278,44 @@ 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. Claude Code always receives its own tool names. + +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 an undiscovered tool, or whose history +is ambiguous, is sent unchanged. 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 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index e6f0ebe5d2b..77f644b1ce7 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -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에 전달합니다. 정의가 텍스트로 전달되므로 공급자 측 strict 스키마 검사는 적용되지 않습니다. 호출 인자는 호출이 끝날 때까지 스트리밍되지 않습니다. 안내가 도구 결과 안에 있어 따르지 않는 모델이 있을 수 있으니 모델마다 검증한 뒤 켜세요. 옵션이 없거나 비어 있거나 목록에 없는 경로는 이전과 같은 요청 바이트를 보냅니다. 캐시 적중을 보장하지는 않습니다. [영문 설정 안내](/guides/claude-code/#chat-routes)를 참고하세요. + **서브에이전트가 잘못된 모델로 디스패치됨** — 로스터 에이전트(`ocx-*`)는 Agent 도구의 `model` 인자가 아니라 `` 지시문을 사용해요. 지시문이 원하는 라우트와 일치하는지 확인하고, 모델 자리 표시자로 `"haiku"`를 전달하세요. diff --git a/scripts/test-layout/layout-adapters.json b/scripts/test-layout/layout-adapters.json index 8d455e45720..7918094d014 100644 --- a/scripts/test-layout/layout-adapters.json +++ b/scripts/test-layout/layout-adapters.json @@ -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" } diff --git a/src/claude/chat-tool-dispatch.ts b/src/claude/chat-tool-dispatch.ts new file mode 100644 index 00000000000..4e24bbe9344 --- /dev/null +++ b/src/claude/chat-tool-dispatch.ts @@ -0,0 +1,228 @@ +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; +} + +/** Loaded definitions as transcript text; deterministic so the same history re-encodes identically. */ +function loadedToolsText(tools: readonly OcxTool[]): string { + const blocks = tools.map(tool => `\n` + + `Description: ${tool.description}\n` + + `Parameters (JSON Schema): ${JSON.stringify(tool.parameters)}\n`); + return `\n[These tools are now loaded. Call them through \`${CHAT_TOOL_DISPATCH_NAME}\` with ` + + `{"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(); + 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(); + 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(); + 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(); + 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; + }); + + return { + parsed: { + ...parsed, + context: { + ...parsed.context, + messages, + tools: [...tools.filter(tool => !hidden.has(tool.name)), DISPATCH_TOOL], + }, + }, + loaded, + }; +} + +/** + * Rewrites dispatcher calls back to the client tool they name, one adapter event at a time. + * Only a tool in `loaded` (discovered in this request's history) is a valid target, so the + * dispatcher can never reach a deferred tool ToolSearch has not loaded. A call whose arguments are + * not a well-formed `{name, arguments}` pair for such a tool passes through unchanged, and the + * client reports the unknown dispatcher call instead of the proxy guessing. + */ +function chatToolDispatchRewriter(loaded: ReadonlySet) { + let pending: { start: Extract; 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") { + let target: { name: string; args: string } | null = null; + if (!event.incomplete) { + try { + const value = JSON.parse(pending.args) as unknown; + if (isRec(value) && typeof value.name === "string" && loaded.has(value.name) + && (value.arguments === undefined || isRec(value.arguments))) { + target = { name: value.name, args: JSON.stringify(value.arguments ?? {}) }; + } + } catch { /* pass through unchanged */ } + } + 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, + loaded: ReadonlySet, +): AsyncGenerator { + const rewriter = chatToolDispatchRewriter(loaded); + 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[], loaded: ReadonlySet): AdapterEvent[] { + const rewriter = chatToolDispatchRewriter(loaded); + 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 loaded: ReadonlySet | null = null; + adapter.buildRequest = (parsed, incoming) => { + const projection = projectChatToolDispatch(parsed, discovery); + loaded = projection?.loaded ?? null; + return build(projection ? projection.parsed : parsed, incoming); + }; + adapter.parseStream = (...args) => { + const events = parseStream(...args); + return loaded ? rewriteChatToolDispatchEvents(events, loaded) : events; + }; + if (parseResponse) { + adapter.parseResponse = async (...args) => { + const events = await parseResponse(...args); + return loaded ? rewriteChatToolDispatchEventList(events, loaded) : events; + }; + } +} diff --git a/src/claude/inbound-tool-loading.ts b/src/claude/inbound-tool-loading.ts index 3114dafc188..87fd3a697a7 100644 --- a/src/claude/inbound-tool-loading.ts +++ b/src/claude/inbound-tool-loading.ts @@ -11,22 +11,27 @@ export function appendOnlyToolLoadingMatches(value: unknown, model: string): boo pattern.endsWith("*") ? model.startsWith(pattern.slice(0, -1)) : model === pattern); } -/** Replay client discovery in place, using only definitions still declared by this request. */ -export function appendLoadedToolDefinitions(body: Rec, raw: Rec): Rec { - if (!Array.isArray(body.tools) || !Array.isArray(body.input) || !Array.isArray(raw.tools) - || !Array.isArray(raw.messages)) return body; +/** Client discovery evidence in the original Messages body (shared by the Responses and Chat projections). */ +export interface ClientToolDiscovery { + /** Tools the client declared with `defer_loading: true`. */ + deferred: Set; + /** Every tool name the assistant has called. */ + used: Set; + /** Deferred tool -> the ToolSearch call id whose successful result first loaded it. */ + loadAt: Map; +} - const declared = new Map(); +/** + * Read discovery evidence: a successful structured ToolSearch `tool_reference` for a deferred tool + * this request still declares and has not called before that result. Returns null when the + * client body is not the expected shape or reuses a tool_use id, so callers send it unchanged. + */ +export function readClientToolDiscovery(raw: Rec, isDeclared: (name: string) => boolean): ClientToolDiscovery | null { + if (!Array.isArray(raw.tools) || !Array.isArray(raw.messages)) return null; const deferred = new Set(); - for (const tool of body.tools) { - if (!isRec(tool) || tool.type !== "function" || typeof tool.name !== "string") continue; - if (declared.has(tool.name)) return body; - declared.set(tool.name, tool); - } for (const tool of raw.tools) { if (isRec(tool) && tool.defer_loading === true && typeof tool.name === "string") deferred.add(tool.name); } - const calls = new Map(); const used = new Set(); const loadAt = new Map(); @@ -36,7 +41,7 @@ export function appendLoadedToolDefinitions(body: Rec, raw: Rec): Rec { if (!isRec(block)) continue; if (message.role === "assistant" && block.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string") { - if (calls.has(block.id)) return body; + if (calls.has(block.id)) return null; calls.set(block.id, block.name); used.add(block.name); } @@ -46,12 +51,28 @@ export function appendLoadedToolDefinitions(body: Rec, raw: Rec): Rec { for (const reference of block.content) { if (!isRec(reference) || reference.type !== "tool_reference" || typeof reference.tool_name !== "string") continue; const name = reference.tool_name; - if (declared.has(name) && deferred.has(name) && !used.has(name) && !loadAt.has(name)) { + if (isDeclared(name) && deferred.has(name) && !used.has(name) && !loadAt.has(name)) { loadAt.set(name, block.tool_use_id); } } } } + return { deferred, used, loadAt }; +} + +/** Replay client discovery in place, using only definitions still declared by this request. */ +export function appendLoadedToolDefinitions(body: Rec, raw: Rec): Rec { + if (!Array.isArray(body.tools) || !Array.isArray(body.input)) return body; + + const declared = new Map(); + for (const tool of body.tools) { + if (!isRec(tool) || tool.type !== "function" || typeof tool.name !== "string") continue; + if (declared.has(tool.name)) return body; + declared.set(tool.name, tool); + } + const discovery = readClientToolDiscovery(raw, name => declared.has(name)); + if (!discovery) return body; + const { deferred, used, loadAt } = discovery; // Discovery must remain callable; this optimization does not create a search capability. if (!declared.has("ToolSearch") || deferred.has("ToolSearch")) return body; diff --git a/src/config/salvage.ts b/src/config/salvage.ts index e9a19ca5352..f0ca99feb09 100644 --- a/src/config/salvage.ts +++ b/src/config/salvage.ts @@ -33,6 +33,7 @@ const SALVAGEABLE_OPTIONAL_FIELDS: ReadonlyArray = [ ["claudeCode", "desktopProfile"], ["claudeCode", "desktopModelVisibility"], ["claudeCode", "appendOnlyToolLoadingModels"], + ["claudeCode", "chatToolDispatchModels"], ]; function isSalvageableConfigPath(section: string, id: string): boolean { diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index b57c3efa537..10479161995 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -437,10 +437,13 @@ export const configSchema = z.object({ if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) { ctx.addIssue({ code: "custom", path: ["claudeCode"], message: "claudeCode must be an object" }); } else if (claudeCode) { - const claude = claudeCode as { desktopProfile?: unknown; desktopModelVisibility?: unknown; desktopMode?: unknown; intercept?: unknown; appendOnlyToolLoadingModels?: unknown }; + const claude = claudeCode as { desktopProfile?: unknown; desktopModelVisibility?: unknown; desktopMode?: unknown; intercept?: unknown; appendOnlyToolLoadingModels?: unknown; chatToolDispatchModels?: unknown }; if (claude.appendOnlyToolLoadingModels !== undefined && !isAppendOnlyToolLoadingModels(claude.appendOnlyToolLoadingModels)) { ctx.addIssue({ code: "custom", path: ["claudeCode", "appendOnlyToolLoadingModels"], message: "appendOnlyToolLoadingModels must be up to 128 provider/model selectors (max 512 characters each), optionally ending in a single wildcard" }); } + if (claude.chatToolDispatchModels !== undefined && !isAppendOnlyToolLoadingModels(claude.chatToolDispatchModels)) { + ctx.addIssue({ code: "custom", path: ["claudeCode", "chatToolDispatchModels"], message: "chatToolDispatchModels must be up to 128 provider/model selectors (max 512 characters each), optionally ending in a single wildcard" }); + } if (claude.desktopMode !== undefined && claude.desktopMode !== "first-party" && claude.desktopMode !== "gateway") { ctx.addIssue({ code: "custom", path: ["claudeCode", "desktopMode"], message: "desktopMode must be \"first-party\" or \"gateway\"" }); } diff --git a/src/providers/provider-id-rewrite.ts b/src/providers/provider-id-rewrite.ts index 43186f7b673..c10824db731 100644 --- a/src/providers/provider-id-rewrite.ts +++ b/src/providers/provider-id-rewrite.ts @@ -98,6 +98,9 @@ export function rewriteProviderReferences(config: OcxConfig, from: string, to: s if (config.claudeCode?.appendOnlyToolLoadingModels) { config.claudeCode.appendOnlyToolLoadingModels = config.claudeCode.appendOnlyToolLoadingModels.map(id => route(id) ?? id); } + if (config.claudeCode?.chatToolDispatchModels) { + config.claudeCode.chatToolDispatchModels = config.claudeCode.chatToolDispatchModels.map(id => route(id) ?? id); + } // First-party picker bindings hold routes too; their keys are Anthropic picker ids. routeRecordValues(config.claudeCode?.intercept?.modelMap); diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index a2d6b25c676..2cf66c6311a 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -1232,7 +1232,8 @@ async function handleClaudeMessagesWithBudget( anthropicServerReplay: replayedAnthropicServerBlocks(anthropicBody), // Preserve the original catalog through late fallback; only the physical adapter projects it. anthropicToolLoadingSource: cacheKeySource === "metadata" && isRec(anthropicBody) - && Array.isArray(cc?.appendOnlyToolLoadingModels) && cc.appendOnlyToolLoadingModels.length > 0 + && ((Array.isArray(cc?.appendOnlyToolLoadingModels) && cc.appendOnlyToolLoadingModels.length > 0) + || (Array.isArray(cc?.chatToolDispatchModels) && cc.chatToolDispatchModels.length > 0)) ? { tools: anthropicBody.tools, messages: anthropicBody.messages, tool_choice: anthropicBody.tool_choice } : undefined, claudeGoAffinity: { sessionLane: claudeGoSessionLane }, claudeNativeSessionId, diff --git a/src/server/responses/request-transport.ts b/src/server/responses/request-transport.ts index aceb6dfea01..ce45c8b46ee 100644 --- a/src/server/responses/request-transport.ts +++ b/src/server/responses/request-transport.ts @@ -1,5 +1,6 @@ import { SendBudgetExhaustedError } from "../../lib/upstream-retry"; import { appendLoadedToolDefinitions, appendOnlyToolLoadingMatches } from "../../claude/inbound-tool-loading"; +import { applyChatToolDispatch } from "../../claude/chat-tool-dispatch"; import { isRec } from "../../claude/inbound-records"; import { isOpenCodeGoResponsesUrl } from "../../adapters/opencode-go-additional-tools"; import { buildResponsesTransport } from "../../adapters/openai-responses/transport"; @@ -578,6 +579,14 @@ export async function prepareResponsesTransport( && !options.policyFallbackCandidate && !options.policyRequestScope?.eligibility && appendOnlyToolLoadingMatches(config.claudeCode?.appendOnlyToolLoadingModels, `${route.providerName}/${parsed._wireModelOverride ?? route.modelId}`); + // The Chat wire has no additional_tools; its own opt-in keeps the catalog stable through a + // dispatcher instead, projected and un-projected at this one physical adapter (#7452). + const chatDispatch = inboundWire === "anthropic" && discovery && provider.adapter === "openai-chat" + && !options.comboAttempt && !route.combo && route.routeKind !== "combo" && route.routeKind !== "policy" + && !options.policyFallbackCandidate && !options.policyRequestScope?.eligibility + && appendOnlyToolLoadingMatches(config.claudeCode?.chatToolDispatchModels, + `${route.providerName}/${parsed._wireModelOverride ?? route.modelId}`); + if (chatDispatch) applyChatToolDispatch(resolved, discovery); if (appendOnly) { const buildWithOriginalCatalog = resolved.buildRequest.bind(resolved); resolved.buildRequest = (requestParsed, incoming) => { diff --git a/src/types/config.ts b/src/types/config.ts index 10fcdcff171..4349c690e3b 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -64,6 +64,8 @@ export interface OcxClaudeCodeConfig { * Applies only after routing settles on a direct openai-responses route; combos and policies are skipped. */ appendOnlyToolLoadingModels?: string[]; + /** Opt-in direct openai-chat routes (same syntax): one stable `use_loaded_tool` dispatcher across ToolSearch loads. */ + chatToolDispatchModels?: string[]; /** Opt-in translated Messages admission; unset keeps legacy behavior. Native passthrough is exempt. */ compatibility?: "shadow" | "enforce"; /** Kill switch for the /v1/messages inbound (GUI "Claude ON" toggle). Default: enabled. */ diff --git a/structure/config.md b/structure/config.md index 5e33e304171..7f611993752 100644 --- a/structure/config.md +++ b/structure/config.md @@ -122,6 +122,7 @@ All config publication continues through the existing required ACL-hardened writ `claudeCode.desktopProfile` follows the same preserve-the-rest rule. JSON `null` (or any non-string) `appliedFingerprint` / `appliedAt` is treated as unset. A profile that is still invalid after that is dropped as a whole — `src/config/salvage.ts` already does this for independent `routingProfiles` / `combos` entries — so one bad Desktop marker cannot replace the operator's providers with `getDefaultConfig()`. A `claudeCode` value that is not an object still fails the document, because there is no safe subtree to keep. `claudeCode.cliFirstParty` is an optional boolean in `src/types/config.ts`. The schema passes it through; the load normalizer (`src/config/load-degrade.ts`) drops a non-boolean hand edit, every reader treats only `true` as on, and `PUT /api/claude-code` accepts only a boolean. Absence means off. It is independent of `claudeCode.desktopMode`; changing CLI first-party pins an absent Desktop mode from the observation that will apply after the flag flips — an opt-out observes with `cliFirstParty` already cleared, so a shared env that predates the marker stays attributed to Desktop instead of being pinned `gateway` and removed from under it, and an owned env cannot be mistaken for CLI-only intent. The flag is written by a standalone `PUT /api/claude-code { cliFirstParty }`, including `ocx claude config set --first-party`; the mutation pins an absent `desktopMode` at the same time. The shared settings proxy status follows the ordered classifier in `src/claude/first-party-settings.ts`: unreadable settings are `unknown`; absent or unrecognized proxy URLs are `none`; a token-bearing opencodex URL beside a foreign CA is `foreign`, while a tokenless loopback URL beside that CA is `local` with unconfirmed ownership. An attributed proxy with no bound listener is `stopped`; a usable applied pair on a bound listener is `disabled` when Claude routing is ineligible and `live` when eligible; remaining mismatches are `broken` regardless of eligibility. Inspection never mints a token. A separate `ocx ensure` may write a config-derived port while this server remains bound elsewhere; status is then `broken` until the server restarts or ensure runs after restart. `claudeCode.contextAccounting` in `src/types/config.ts` stores only `"200k"`; absence is the 1m default and `"1m"` deletes the key. `ocx claude config set --context-accounting` writes it. The surface contract is [Claude Desktop picker mode](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy). `claudeCode.appendOnlyToolLoadingModels` follows the [append-only discovery contract](data-planes/claude-tool-loading.md): at most 128 provider-qualified selectors of 512 characters, optional trailing prefix wildcard, absent/off by default. `src/claude/inbound-tool-loading.ts` shares fail-closed validation with the schema; writes reject malformed lists and `src/config/salvage.ts` drops only that optional field on load, preserving providers and other Claude settings. Provider-ID rewrites carry exact and wildcard selectors without changing their model suffixes. +`claudeCode.chatToolDispatchModels` is the Chat-wire counterpart with the identical selector contract, validation, salvage and provider-ID rewrite; its runtime behavior is the [Chat-wire dispatcher](data-planes/claude-tool-loading.md#chat-wire-dispatcher). Absent or empty is off and byte-identical to an unset key. `showCodexCredits` is an optional, display-only boolean in `src/types/config.ts`, default off. The load schema degrades malformed values to false; `src/config/diagnostics.ts` rejects malformed write candidates. `GET /api/settings`, its successful PUT response, and the safe `/api/config` DTO always project a boolean. PUT accepts a partial boolean update, persists it, and restores both the previous value and key presence if saving fails. The switch gates account DTO exposure without changing probes or routing; [credits identity binding](providers/openai-accounts.md#display-only-codex-credits) owns the observation contract. The former `showCodexSparkQuota` key is inert passthrough data when loading an old config. It is absent from the typed settings contract and cannot re-enable Spark quota through the management API. Retirement does not migrate user-selected model ids or erase usage history. diff --git a/structure/data-planes/claude-tool-loading.md b/structure/data-planes/claude-tool-loading.md index 4e57e5a8ae2..816e6b77717 100644 --- a/structure/data-planes/claude-tool-loading.md +++ b/structure/data-planes/claude-tool-loading.md @@ -53,6 +53,42 @@ in both opt-in directions, including a fallback onto Chat that retains every ori `tests/claude-integration/claude-tool-loading-destinations.test.ts` covers excluded physical destinations, an ordinary gateway positive control and provider-ID selector migration. +A Responses destination that accepts the request but silently drops `additional_tools` must not +be selected either: the loaded definition never reaches the model. DeepSeek's official Responses +API behaves this way (same input token count with and without the item, measured 2026-10-31). + +## Chat-wire dispatcher + +The Chat wire has no chronological tool carrier, and `src/adapters/tool-catalog-nudge.ts` lists +every current tool name in the system message, so a ToolSearch load rewrites the prefix twice. For +routes selected by `claudeCode.chatToolDispatchModels` (same selector grammar, validation, salvage +and provider-ID migration as `appendOnlyToolLoadingModels`), `request-transport.ts` applies +`applyChatToolDispatch` from `src/claude/chat-tool-dispatch.ts` at the same physical-adapter +binding, only when the final adapter is a direct `openai-chat`; combo attempts and policy-owned +requests are excluded, and late subagent fallback is judged by the physical route. It reads the +same discovery evidence through `readClientToolDiscovery`. + +Each build projects afresh. The catalog carries one stable `use_loaded_tool {name, arguments}` +function from the first request with an eager `ToolSearch`; unused deferred tools stay out of it, +so the catalog and the nudge never change on load. A discovered definition is appended as text to +the ToolSearch result that loaded it, and earlier calls to discovered tools are re-encoded as +dispatcher calls so replay is byte-stable. Both parse paths (`parseStream`, `parseResponse`) map a +dispatcher call back to the client tool only when it names a tool discovered in that request; +anything else, malformed or incomplete passes through unchanged. Dispatcher-call arguments are +buffered until the call ends. Tools called before discovery keep their native slot. A forced +choice of an undiscovered tool, duplicate tool_use ids, a discovery result that is not present +exactly once, a duplicate declared name, or a client tool already named `use_loaded_tool` sends +the request unprojected and leaves its response untouched. Absent, empty or unlisted +configuration is byte-identical to no configuration. Definitions travel as text, so provider-side +strict schema enforcement does not apply to them. +`tests/claude-integration/claude-chat-tool-dispatch.test.ts` covers projection, prefix stability on +the real Chat adapter output, bail-out cases and the stream rewrite; +`tests/claude-integration/claude-chat-tool-dispatch-routes.test.ts` covers byte identity when off, +Responses and combo routes, late fallback in both directions, the non-streaming client response, +both parse paths and configuration salvage/migration. + +## Claude models behind a Responses gateway + This opt-in does not stabilize Claude models routed through a Responses gateway that lifts `additional_tools` into Claude's top-level tools. For those, the documented operator path is a provider with `adapter: "anthropic"`, per-model `modelAdapters` keeping non-Claude models on diff --git a/tests/claude-integration/claude-chat-tool-dispatch-routes.test.ts b/tests/claude-integration/claude-chat-tool-dispatch-routes.test.ts new file mode 100644 index 00000000000..ba45ea7a333 --- /dev/null +++ b/tests/claude-integration/claude-chat-tool-dispatch-routes.test.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { handleClaudeMessages } from "../../src/server/claude-messages"; +import { applyChatToolDispatch, CHAT_TOOL_DISPATCH_NAME } from "../../src/claude/chat-tool-dispatch"; +import { anthropicToResponsesTranslation } from "../../src/claude/inbound"; +import { parseRequest } from "../../src/responses/parser"; +import type { ProviderAdapter } from "../../src/adapters/base"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { salvageConfigCandidate } from "../../src/config/salvage"; +import { rewriteProviderReferences } from "../../src/providers/provider-id-rewrite"; +import { noteSubagentModelFailure, resetSubagentModelFallbackStateForTests } from "../../src/codex/subagent-model-fallback"; +import type { AdapterEvent, OcxConfig, OcxParsedRequest } from "../../src/types"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; + +// Routing boundaries of claudeCode.chatToolDispatchModels: the projection applies only on the +// physical direct openai-chat adapter that the selector names, and is byte-identical when off. +const chatProvider = { adapter: "openai-chat", baseUrl: "https://chat.example.com/v1", apiKey: "synthetic-fixture", models: ["test-model"] }; +const responsesProvider = { ...chatProvider, adapter: "openai-responses", baseUrl: "https://responses.example.com/v1" }; +const tool = (name: string, deferred = false) => ({ + name, description: `${name} tool`, input_schema: { type: "object", properties: { q: { type: "string" } } }, + ...(deferred ? { defer_loading: true } : {}), +}); +const loadedRequest = (model: string, extra: Record = {}) => ({ + model, metadata: { user_id: "synthetic-cache-test" }, system: "Synthetic test instructions", max_tokens: 100, + tools: [tool("ToolSearch"), tool("Bash"), tool("WebFetch", true)], + messages: [ + { role: "user", content: "Use the available tools." }, + { role: "assistant", content: [{ type: "tool_use", id: "call_search", name: "ToolSearch", input: { query: "WebFetch" } }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call_search", content: [{ type: "tool_reference", tool_name: "WebFetch" }] }] }, + ], + ...extra, +}); +const chatSse = (text = "ok") => `data: ${JSON.stringify({ id: "chat_route", object: "chat.completion.chunk", model: "test-model", + choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: "stop" }] })}\n\ndata: [DONE]\n\n`; +const responsesJson = () => Response.json({ id: "resp_route", object: "response", status: "completed", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] }], + usage: { input_tokens: 20, output_tokens: 5, total_tokens: 25 } }); +const toolNames = (body: any): string[] => body.tools.map((t: any) => t.function?.name ?? t.name); + +async function send(config: unknown, raw: unknown, headers: Record = {}, log = { model: "", provider: "" }) { + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(raw), + }), config as OcxConfig, log); + return { status: response.status, text: await response.text() }; +} + +const originalFetch = globalThis.fetch; +let home: TempHome; +let release: () => void; +beforeEach(() => { + home = createTempHome("ocx-chat-dispatch-routes-"); + release = acquireOwnedSpendHome(); + resetSubagentModelFallbackStateForTests(); +}); +afterEach(() => { + globalThis.fetch = originalFetch; + release(); + home.remove(); +}); + +describe("chatToolDispatchModels configuration", () => { + test("invalid selectors are rejected and salvage drops only that key", () => { + const base = { defaultProvider: "chatgw", providers: { chatgw: chatProvider } }; + for (const value of [true, "chatgw/test-model", [1], ["*"], ["chatgw/te*st"], [" chatgw/test-model"]]) { + expect(configSchema.safeParse({ ...base, claudeCode: { chatToolDispatchModels: value } }).success).toBe(false); + } + for (const value of [undefined, [], ["chatgw/test-model"], ["chatgw/test-*"]]) { + expect(configSchema.safeParse({ ...base, claudeCode: { chatToolDispatchModels: value } }).success).toBe(true); + } + const config = { ...base, claudeCode: { enabled: true, chatToolDispatchModels: [false] } }; + const result = configSchema.safeParse(config); + if (result.success) throw new Error("Expected malformed configuration rejection"); + const salvaged = salvageConfigCandidate(config, result.error, config); + expect(salvaged?.parsed.claudeCode).toEqual({ enabled: true }); + expect(salvaged?.dropped).toEqual(["claudeCode.chatToolDispatchModels"]); + }); + + test("provider rename carries exact and wildcard selectors without widening them", () => { + const config: OcxConfig = { port: 0, defaultProvider: "other", providers: {}, claudeCode: { + chatToolDispatchModels: ["old/m-one", "old/m-*", "older/m-*", "other/m-one"], + } }; + expect(rewriteProviderReferences(config, "old", "new").changed).toBe(2); + expect(config.claudeCode!.chatToolDispatchModels).toEqual(["new/m-one", "new/m-*", "older/m-*", "other/m-one"]); + }); +}); + +describe("chatToolDispatchModels routing boundaries", () => { + test("off, empty and unlisted configurations send byte-identical upstream requests", async () => { + const bodies: string[] = []; + globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => { + bodies.push(String(init?.body)); + return new Response(chatSse(), { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const raw = loadedRequest("chatgw/test-model", { stream: true }); + for (const claudeCode of [undefined, {}, { chatToolDispatchModels: [] }, { chatToolDispatchModels: ["chatgw/other-model"] }]) { + const config = { defaultProvider: "chatgw", providers: { chatgw: chatProvider }, ...(claudeCode ? { claudeCode } : {}) }; + expect((await send(config, raw)).status).toBe(200); + } + expect(bodies).toHaveLength(4); + for (const body of bodies) expect(body).toBe(bodies[0]); + expect(toolNames(JSON.parse(bodies[0]))).toEqual(["ToolSearch", "Bash", "WebFetch"]); + }); + + test("a selector on a Responses route never applies the Chat dispatcher", async () => { + const captured: any[] = []; + globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => { + captured.push(JSON.parse(String(init?.body))); + return responsesJson(); + }) as typeof fetch; + const config = { defaultProvider: "respgw", providers: { respgw: responsesProvider }, + claudeCode: { chatToolDispatchModels: ["respgw/test-model"] } }; + expect((await send(config, loadedRequest("respgw/test-model", { stream: false }))).status).toBe(200); + expect(captured).toHaveLength(1); + expect(toolNames(captured[0])).toEqual(["ToolSearch", "Bash", "WebFetch"]); + }); + + test("a selected combo route keeps the original catalog even when its target is opted-in Chat", async () => { + const captured: any[] = []; + globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => { + captured.push(JSON.parse(String(init?.body))); + return new Response(chatSse(), { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const config = { defaultProvider: "chatgw", providers: { chatgw: chatProvider }, + combos: { pick: { strategy: "failover", targets: [{ provider: "chatgw", model: "test-model" }] } }, + claudeCode: { chatToolDispatchModels: ["combo/pick", "chatgw/test-model"] } }; + expect((await send(config, loadedRequest("combo/pick", { stream: true }))).status).toBe(200); + expect(captured).toHaveLength(1); + expect(toolNames(captured[0])).toEqual(["ToolSearch", "Bash", "WebFetch"]); + }); + + for (const [primary, fallback, expectDispatch] of [ + [chatProvider, responsesProvider, false], + [responsesProvider, chatProvider, true], + ] as const) { + test(`late fallback from ${primary.adapter} to ${fallback.adapter} follows only the physical adapter`, async () => { + const captured: Array<{ url: string; body: any }> = []; + globalThis.fetch = (async (url: RequestInfo | URL, init?: RequestInit) => { + captured.push({ url: String(url), body: JSON.parse(String(init?.body)) }); + return fallback.adapter === "openai-chat" + ? new Response(chatSse(), { headers: { "content-type": "text/event-stream" } }) + : responsesJson(); + }) as typeof fetch; + const config = { + defaultProvider: "primary", providers: { + primary: { ...primary, baseUrl: "https://primary.example/v1" }, + fallback: { ...fallback, baseUrl: "https://fallback.example/v1" }, + }, subagentModelFallback: ["fallback/test-model"], + claudeCode: { chatToolDispatchModels: ["primary/test-model", "fallback/test-model"] }, + }; + noteSubagentModelFailure("primary/test-model", "429", config as unknown as OcxConfig); + const log = { model: "", provider: "" }; + const result = await send(config, loadedRequest("primary/test-model", { stream: true }), { "x-openai-subagent": "collab_spawn" }, log); + expect(result.status).toBe(200); + expect(log).toMatchObject({ subagentModelFallbackTo: "fallback/test-model" }); + expect(captured).toHaveLength(1); + expect(captured[0].url.startsWith("https://fallback.example/v1")).toBe(true); + expect(toolNames(captured[0].body)).toEqual(expectDispatch + ? ["ToolSearch", "Bash", CHAT_TOOL_DISPATCH_NAME] + : ["ToolSearch", "Bash", "WebFetch"]); + }); + } + + test("a non-streaming client response carries the client tool, never the dispatcher", async () => { + const chunk = (delta: Record, finish: string | null = null) => + `data: ${JSON.stringify({ id: "chat_json", object: "chat.completion.chunk", model: "test-model", + choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`; + globalThis.fetch = (async () => new Response( + chunk({ role: "assistant", tool_calls: [{ index: 0, id: "call_json", type: "function", function: { name: CHAT_TOOL_DISPATCH_NAME, arguments: "" } }] }) + + chunk({ tool_calls: [{ index: 0, function: { arguments: JSON.stringify({ name: "WebFetch", arguments: { q: "https://x" } }) } }] }) + + chunk({}, "tool_calls") + "data: [DONE]\n\n", + { headers: { "content-type": "text/event-stream" } }, + )) as typeof fetch; + const config = { defaultProvider: "chatgw", providers: { chatgw: chatProvider }, + claudeCode: { chatToolDispatchModels: ["chatgw/test-model"] } }; + const result = await send(config, loadedRequest("chatgw/test-model", { stream: false })); + expect(result.status).toBe(200); + const toolUse = JSON.parse(result.text).content.find((block: any) => block.type === "tool_use"); + expect(toolUse).toMatchObject({ id: "call_json", name: "WebFetch", input: { q: "https://x" } }); + expect(result.text).not.toContain(CHAT_TOOL_DISPATCH_NAME); + }); +}); + +describe("applyChatToolDispatch adapter wrapping", () => { + const event = (name: string, args: string): AdapterEvent[] => [ + { type: "tool_call_start", id: "call_x", name }, { type: "tool_call_delta", arguments: args }, { type: "tool_call_end" }, + ]; + const dispatched = event(CHAT_TOOL_DISPATCH_NAME, JSON.stringify({ name: "WebFetch", arguments: { q: "u" } })); + const stubAdapter = () => { + const built: OcxParsedRequest[] = []; + const adapter = { + name: "stub", + buildRequest: (parsed: OcxParsedRequest) => { built.push(parsed); return { url: "https://chat.example.com/v1/chat/completions", headers: {}, body: "{}" }; }, + async *parseStream() { yield* dispatched; }, + parseResponse: async () => [...dispatched], + } as unknown as ProviderAdapter; + return { adapter, built }; + }; + const parsedOf = (raw: Record) => parseRequest(anthropicToResponsesTranslation(raw, {}).body as any); + + test("both parse paths are rewritten after a projected build", async () => { + const raw = loadedRequest("chatgw/test-model"); + const { adapter, built } = stubAdapter(); + applyChatToolDispatch(adapter, raw); + await adapter.buildRequest(parsedOf(raw), { headers: new Headers() } as any); + expect(built[0].context.tools!.map(t => t.name)).toEqual(["ToolSearch", "Bash", CHAT_TOOL_DISPATCH_NAME]); + const streamed: AdapterEvent[] = []; + for await (const e of adapter.parseStream(new Response(""), {} as any)) streamed.push(e); + const listed = await adapter.parseResponse!(new Response(""), {} as any); + for (const out of [streamed, listed]) expect(out).toEqual(event("WebFetch", '{"q":"u"}')); + }); + + test("a build that bails out sends the request unchanged and leaves responses untouched", async () => { + const raw = { ...loadedRequest("chatgw/test-model"), tool_choice: { type: "tool", name: "WebFetch" } }; + const { adapter, built } = stubAdapter(); + applyChatToolDispatch(adapter, raw); + const parsed = parsedOf(raw); + await adapter.buildRequest(parsed, { headers: new Headers() } as any); + expect(built[0]).toBe(parsed); + expect(await adapter.parseResponse!(new Response(""), {} as any)).toEqual(dispatched); + }); +}); diff --git a/tests/claude-integration/claude-chat-tool-dispatch.test.ts b/tests/claude-integration/claude-chat-tool-dispatch.test.ts new file mode 100644 index 00000000000..4e16412045f --- /dev/null +++ b/tests/claude-integration/claude-chat-tool-dispatch.test.ts @@ -0,0 +1,283 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleClaudeMessages } from "../../src/server/claude-messages"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { anthropicToResponsesTranslation } from "../../src/claude/inbound"; +import { + CHAT_TOOL_DISPATCH_NAME, projectChatToolDispatch, rewriteChatToolDispatchEventList, rewriteChatToolDispatchEvents, +} from "../../src/claude/chat-tool-dispatch"; +import { parseRequest } from "../../src/responses/parser"; +import { resolveAdapter } from "../../src/server/adapter-resolve"; +import type { AdapterEvent } from "../../src/types"; + +const model = "chatgw/deepseek-test"; +const provider = { adapter: "openai-chat" as const, baseUrl: "https://chat.example.com/v1", apiKey: "synthetic-fixture" }; +const tool = (name: string, deferred = false) => ({ + name, description: `${name} tool`, input_schema: { type: "object", properties: { q: { type: "string" } } }, + ...(deferred ? { defer_loading: true } : {}), +}); +const search = (id: string, names: string[]) => [ + { role: "assistant", content: [{ type: "tool_use", id, name: "ToolSearch", input: { query: names.join(",") } }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: id, content: names.map(tool_name => ({ type: "tool_reference", tool_name })) }] }, +]; +const call = (id: string, name: string, input: Record, output: string) => [ + { role: "assistant", content: [{ type: "tool_use", id, name, input }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: id, content: output }] }, +]; +const request = (loaded: string[], messages: unknown[] = []) => ({ + model, metadata: { user_id: "synthetic-cache-test" }, system: "Synthetic test instructions", max_tokens: 100, + tools: [tool("ToolSearch"), tool("Bash"), ...loaded.map(n => tool(n, true))], + messages: [{ role: "user", content: "Use the available tools." }, ...messages], +}); + +const parse = (raw: ReturnType) => parseRequest(anthropicToResponsesTranslation(raw, {}).body as any); + +/** The actual upstream Chat body the openai-chat adapter would send. */ +async function chatBody(raw: ReturnType, dispatch = true): Promise<{ tools: any[]; messages: any[] }> { + const parsed = parse(raw); + const projected = dispatch ? projectChatToolDispatch(parsed, raw as any)?.parsed ?? parsed : parsed; + const built = await resolveAdapter(provider as any, undefined, "chatgw").buildRequest(projected, { headers: new Headers() } as any); + return JSON.parse(String(built.body)); +} + +const LOADED: ReadonlySet = new Set(["WebFetch"]); + +/** Run both rewrite forms and require them to agree. */ +async function collect(events: AdapterEvent[], loaded = LOADED): Promise { + async function* source() { yield* events; } + const out: AdapterEvent[] = []; + for await (const event of rewriteChatToolDispatchEvents(source(), loaded)) out.push(event); + expect(rewriteChatToolDispatchEventList(events, loaded)).toEqual(out); + return out; +} +const dispatchCall = (id: string, args: string, end: AdapterEvent = { type: "tool_call_end" }): AdapterEvent[] => [ + { type: "tool_call_start", id, name: CHAT_TOOL_DISPATCH_NAME }, + { type: "tool_call_delta", arguments: args }, + end, +]; + +describe("Chat-wire dispatcher for ToolSearch-loaded tools", () => { + test("no ToolSearch means no projection", () => { + const raw = request([]); + raw.tools = raw.tools.filter(t => t.name !== "ToolSearch"); + expect(projectChatToolDispatch(parse(raw), raw as any)).toBeNull(); + }); + + test("the dispatcher is in the catalog from the first request", async () => { + const body = await chatBody(request([])); + expect(body.tools.map((t: any) => t.function.name)).toEqual(["ToolSearch", "Bash", CHAT_TOOL_DISPATCH_NAME]); + }); + + test("loading a tool keeps the tool catalog and every earlier message byte-identical", async () => { + const before = request([], [...call("call_bash", "Bash", { q: "ls" }, "a.txt")]); + const afterLoad = request(["WebFetch"], [...call("call_bash", "Bash", { q: "ls" }, "a.txt"), ...search("call_search", ["WebFetch"])]); + const b = await chatBody(before); + const a = await chatBody(afterLoad); + expect(JSON.stringify(a.tools)).toBe(JSON.stringify(b.tools)); + expect(JSON.stringify(a.messages.slice(0, b.messages.length))).toBe(JSON.stringify(b.messages)); + // Without the dispatcher the same load rewrites the catalog: the defect this fixes. + const plainBefore = await chatBody(before, false); + const plainAfter = await chatBody(afterLoad, false); + expect(JSON.stringify(plainAfter.tools)).not.toBe(JSON.stringify(plainBefore.tools)); + }); + + test("the loaded definition rides the ToolSearch result and later calls go through the dispatcher", async () => { + const loadAndUse = request(["WebFetch"], [ + ...search("call_search", ["WebFetch"]), + ...call("call_fetch", "WebFetch", { q: "https://example.com" }, "page"), + ]); + const body = await chatBody(loadAndUse); + expect(body.tools.map((t: any) => t.function.name)).not.toContain("WebFetch"); + const searchResult = body.messages.find((m: any) => m.role === "tool" && m.tool_call_id === "call_search"); + expect(JSON.stringify(searchResult.content)).toContain(''); + const assistant = body.messages.find((m: any) => m.tool_calls?.some((c: any) => c.id === "call_fetch")); + const fetchCall = assistant.tool_calls.find((c: any) => c.id === "call_fetch"); + expect(fetchCall.function.name).toBe(CHAT_TOOL_DISPATCH_NAME); + expect(JSON.parse(fetchCall.function.arguments)).toEqual({ name: "WebFetch", arguments: { q: "https://example.com" } }); + }); + + test("history re-encodes identically on the next request", async () => { + const history = [...search("call_search", ["WebFetch"]), ...call("call_fetch", "WebFetch", { q: "u" }, "page")]; + const first = await chatBody(request(["WebFetch"], history)); + const next = await chatBody(request(["WebFetch"], [...history, ...call("call_bash", "Bash", { q: "pwd" }, "/")])); + expect(JSON.stringify(next.tools)).toBe(JSON.stringify(first.tools)); + expect(JSON.stringify(next.messages.slice(0, first.messages.length))).toBe(JSON.stringify(first.messages)); + }); + + test("a deferred tool called before any discovery result keeps its native slot", () => { + const raw = request(["Legacy"], [...call("call_old", "Legacy", { q: "x" }, "ok")]); + const projection = projectChatToolDispatch(parse(raw), raw as any)!; + expect(projection.parsed.context.tools!.map(t => t.name)).toContain("Legacy"); + expect(projection.loaded.has("Legacy")).toBe(false); + }); + + test("a forced hidden tool leaves the request unprojected", () => { + const raw = request(["WebFetch"], search("call_search", ["WebFetch"])) as any; + raw.tool_choice = { type: "tool", name: "WebFetch" }; + expect(projectChatToolDispatch(parse(raw), raw)).toBeNull(); + }); + + test("a duplicate client call id leaves the request unprojected", () => { + const raw = request(["WebFetch"], [ + ...search("call_dup", ["WebFetch"]), + ...call("call_dup", "Bash", { q: "ls" }, "a.txt"), + ]); + expect(projectChatToolDispatch(parse(raw), raw as any)).toBeNull(); + }); + + test("a discovery result that is not present exactly once leaves the request unprojected", () => { + const [searchCall, searchResult] = search("call_search", ["WebFetch"]); + const raw = request(["WebFetch"], [searchCall, searchResult, searchResult]); + expect(projectChatToolDispatch(parse(raw), raw as any)).toBeNull(); + }); + + test("only tools with a discovery result in this request are dispatchable", () => { + const raw = request(["WebFetch", "NotebookEdit"], search("call_search", ["WebFetch"])); + const projection = projectChatToolDispatch(parse(raw), raw as any)!; + expect([...projection.loaded]).toEqual(["WebFetch"]); + // Both deferred tools stay out of the catalog; only the discovered one is reachable. + expect(projection.parsed.context.tools!.map(t => t.name)).toEqual(["ToolSearch", "Bash", CHAT_TOOL_DISPATCH_NAME]); + }); + + test("a client tool already named like the dispatcher disables projection", () => { + const raw = request([]) as any; + raw.tools.push(tool(CHAT_TOOL_DISPATCH_NAME)); + expect(projectChatToolDispatch(parse(raw), raw)).toBeNull(); + }); +}); + +describe("dispatcher response rewrite", () => { + test("a dispatcher call becomes the client tool call", async () => { + const out = await collect([ + { type: "text_delta", text: "ok" } as any, + { type: "tool_call_start", id: "c1", name: CHAT_TOOL_DISPATCH_NAME }, + { type: "tool_call_delta", arguments: '{"name":"WebFetch",' }, + { type: "tool_call_delta", arguments: '"arguments":{"url":"https://x"}}' }, + { type: "tool_call_end" }, + ]); + expect(out).toEqual([ + { type: "text_delta", text: "ok" } as any, + { type: "tool_call_start", id: "c1", name: "WebFetch" }, + { type: "tool_call_delta", arguments: '{"url":"https://x"}' }, + { type: "tool_call_end" }, + ]); + }); + + test("ordinary calls and malformed dispatcher calls pass through unchanged", async () => { + const plain: AdapterEvent[] = [ + { type: "tool_call_start", id: "c2", name: "Bash" }, + { type: "tool_call_delta", arguments: '{"q":"ls"}' }, + { type: "tool_call_end" }, + ]; + expect(await collect(plain)).toEqual(plain); + const malformed: AdapterEvent[] = [ + { type: "tool_call_start", id: "c3", name: CHAT_TOOL_DISPATCH_NAME }, + { type: "tool_call_delta", arguments: '{"arguments":{}}' }, + { type: "tool_call_end" }, + ]; + expect(await collect(malformed)).toEqual(malformed); + }); + + test("a dispatcher call naming a tool outside the loaded set passes through unchanged", async () => { + for (const name of ["NotebookEdit", "Bash", CHAT_TOOL_DISPATCH_NAME, ""]) { + const events = dispatchCall("c5", JSON.stringify({ name, arguments: {} })); + expect(await collect(events)).toEqual(events); + } + }); + + test("consecutive dispatcher calls in one response are rewritten independently", async () => { + const out = await collect([ + ...dispatchCall("c6", JSON.stringify({ name: "WebFetch", arguments: { url: "a" } })), + { type: "tool_call_start", id: "c7", name: "Bash" }, + { type: "tool_call_delta", arguments: '{"q":"ls"}' }, + { type: "tool_call_end" }, + ...dispatchCall("c8", JSON.stringify({ name: "NotebookEdit", arguments: {} })), + ], new Set(["WebFetch", "NotebookEdit"])); + expect(out.filter(e => e.type === "tool_call_start").map((e: any) => [e.id, e.name])) + .toEqual([["c6", "WebFetch"], ["c7", "Bash"], ["c8", "NotebookEdit"]]); + }); + + test("a dispatcher call cut off by another event is surfaced as received", async () => { + const events: AdapterEvent[] = [ + { type: "tool_call_start", id: "c9", name: CHAT_TOOL_DISPATCH_NAME }, + { type: "tool_call_delta", arguments: '{"name":"WebFetch"' }, + { type: "error", message: "upstream closed" } as any, + ]; + expect(await collect(events)).toEqual(events); + expect(await collect(events.slice(0, 2))).toEqual(events.slice(0, 2)); + }); + + test("an incomplete dispatcher call is surfaced as received", async () => { + const events: AdapterEvent[] = [ + { type: "tool_call_start", id: "c4", name: CHAT_TOOL_DISPATCH_NAME }, + { type: "tool_call_delta", arguments: '{"name":"Web' }, + { type: "tool_call_end", incomplete: true }, + ]; + expect(await collect(events)).toEqual(events); + }); +}); + +describe("dispatcher at the actual Messages HTTP boundary", () => { + const originalFetch = globalThis.fetch; + let previousHome: string | undefined; + let isolated: IsolatedCodexHome | undefined; + let home: string; + let release: (() => void) | undefined; + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolated = installIsolatedCodexHome("ocx-chat-dispatch-"); + home = mkdtempSync(join(tmpdir(), "ocx-chat-dispatch-config-")); + process.env.OPENCODEX_HOME = home; + release = acquireOwnedSpendHome(); + }); + afterEach(() => { + release?.(); + globalThis.fetch = originalFetch; + isolated?.restore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); + }); + + const chunk = (delta: Record, finish: string | null = null) => + `data: ${JSON.stringify({ id: "chat_dispatch", object: "chat.completion.chunk", model: "deepseek-test", + choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`; + + for (const selected of [true, false]) { + test(`an ${selected ? "opted-in" : "unlisted"} Chat route ${selected ? "dispatches" : "keeps the native catalog"}`, async () => { + const captured: any[] = []; + globalThis.fetch = (async (url: RequestInfo | URL, init?: RequestInit) => { + expect(String(url)).toBe("https://chat.example.com/v1/chat/completions"); + captured.push(JSON.parse(String(init?.body))); + const name = selected ? CHAT_TOOL_DISPATCH_NAME : "WebFetch"; + const args = selected ? JSON.stringify({ name: "WebFetch", arguments: { q: "https://x" } }) : JSON.stringify({ q: "https://x" }); + const body = chunk({ role: "assistant", tool_calls: [{ index: 0, id: "call_new", type: "function", function: { name, arguments: "" } }] }) + + chunk({ tool_calls: [{ index: 0, function: { arguments: args } }] }) + + chunk({}, "tool_calls") + "data: [DONE]\n\n"; + return new Response(body, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const config = { + defaultProvider: "chatgw", providers: { chatgw: { ...provider, models: ["deepseek-test"] } }, + claudeCode: { chatToolDispatchModels: selected ? [model] : ["chatgw/other"] }, + } as unknown as OcxConfig; + const raw = { ...request(["WebFetch"], search("call_search", ["WebFetch"])), stream: true }; + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(raw), + }), config, { model: "", provider: "" }); + const sse = await response.text(); + expect(response.status).toBe(200); + expect(captured).toHaveLength(1); + const names = captured[0].tools.map((t: any) => t.function.name); + expect(names).toEqual(selected ? ["ToolSearch", "Bash", CHAT_TOOL_DISPATCH_NAME] : ["ToolSearch", "Bash", "WebFetch"]); + // The client always sees its own tool name and arguments. + expect(sse).toContain('"name":"WebFetch"'); + expect(sse).not.toContain(CHAT_TOOL_DISPATCH_NAME); + expect(sse).toContain('\\"q\\":\\"https://x\\"'); + }); + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 80d15abbd48..86b28119a82 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -481,7 +481,8 @@ "claude-first-party-union.test.ts": "claude-integration", "claude-desktop-system-proxy.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", "claude-config-home-isolation.test.ts": "claude-integration", "claude-inbound-cache-stabilize.test.ts": "claude-integration", "claude-inbound-token-footer.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", "claude-inbound.test.ts": "claude-integration", - "claude-inbound-tool-loading.test.ts": "claude-integration", + "claude-inbound-tool-loading.test.ts": "claude-integration", "claude-chat-tool-dispatch.test.ts": "claude-integration", + "claude-chat-tool-dispatch-routes.test.ts": "claude-integration", "claude-inbound-tool-reference.test.ts": "claude-integration", "claude-native-reasoning-continuity.test.ts": "claude-integration", "claude-native-reasoning-provenance.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", From 19e003641ad7fc1edc96703dabbe6eb0e99a6f54 Mon Sep 17 00:00:00 2001 From: Gudals0320 Date: Sat, 31 Oct 2026 16:40:31 +0900 Subject: [PATCH 2/3] fix(claude): accept only exact dispatcher payloads and map visible tools back A wrapper call with extra top-level keys used to lose them and reach the client as an empty-argument call. It now passes through unchanged. Arguments double-encoded as a JSON string holding an object are decoded once. A wrapper call that names a tool already in the visible catalog maps back to it, since that grants nothing new. Docs now state the bail-out on any forced hidden tool and the one catalog change when a model calls an undiscovered deferred tool directly. structure/config.md stays within its 600-line budget. Co-Authored-By: Claude Opus 5.5 --- .../src/content/docs/guides/claude-code.md | 8 ++- .../src/content/docs/ko/guides/claude-code.md | 2 +- src/claude/chat-tool-dispatch.ts | 68 ++++++++++--------- structure/config.md | 3 +- structure/data-planes/claude-tool-loading.md | 15 ++-- .../claude-chat-tool-dispatch-routes.test.ts | 32 +++++++++ .../claude-chat-tool-dispatch.test.ts | 29 +++++++- 7 files changed, 112 insertions(+), 45 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 6bd79d32989..66d63463480 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -1307,13 +1307,15 @@ route that is a direct `openai-chat` provider. On a selected route: 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. Claude Code always receives its own tool names. + 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 an undiscovered tool, or whose history -is ambiguous, is sent unchanged. Absent, empty or unlisted configuration sends exactly the same +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 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 77f644b1ce7..76fade4a044 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -729,7 +729,7 @@ OpenCode Go의 Responses 엔드포인트와 공식 xAI Responses 호스트는 `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에 전달합니다. 정의가 텍스트로 전달되므로 공급자 측 strict 스키마 검사는 적용되지 않습니다. 호출 인자는 호출이 끝날 때까지 스트리밍되지 않습니다. 안내가 도구 결과 안에 있어 따르지 않는 모델이 있을 수 있으니 모델마다 검증한 뒤 켜세요. 옵션이 없거나 비어 있거나 목록에 없는 경로는 이전과 같은 요청 바이트를 보냅니다. 캐시 적중을 보장하지는 않습니다. [영문 설정 안내](/guides/claude-code/#chat-routes)를 참고하세요. +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` 인자가 아니라 `` 지시문을 사용해요. 지시문이 원하는 라우트와 일치하는지 diff --git a/src/claude/chat-tool-dispatch.ts b/src/claude/chat-tool-dispatch.ts index 4e24bbe9344..fb1513115f2 100644 --- a/src/claude/chat-tool-dispatch.ts +++ b/src/claude/chat-tool-dispatch.ts @@ -38,6 +38,8 @@ export interface ChatToolDispatchProjection { parsed: OcxParsedRequest; /** Client tools the dispatcher currently stands in for. */ loaded: ReadonlySet; + /** Names a dispatcher call may map back to: the loaded tools plus the visible catalog. */ + targets: ReadonlySet; } /** Loaded definitions as transcript text; deterministic so the same history re-encodes identically. */ @@ -123,27 +125,40 @@ export function projectChatToolDispatch(parsed: OcxParsedRequest, raw: Rec): Cha return message; }); + const visible = tools.filter(tool => !hidden.has(tool.name)); return { - parsed: { - ...parsed, - context: { - ...parsed.context, - messages, - tools: [...tools.filter(tool => !hidden.has(tool.name)), DISPATCH_TOOL], - }, - }, + 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): { 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 tool in `loaded` (discovered in this request's history) is a valid target, so the - * dispatcher can never reach a deferred tool ToolSearch has not loaded. A call whose arguments are - * not a well-formed `{name, arguments}` pair for such a tool passes through unchanged, and the - * client reports the unknown dispatcher call instead of the proxy guessing. + * 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(loaded: ReadonlySet) { +function chatToolDispatchRewriter(targets: ReadonlySet) { let pending: { start: Extract; args: string } | null = null; const release = (): AdapterEvent[] => { if (!pending) return []; @@ -156,16 +171,7 @@ function chatToolDispatchRewriter(loaded: ReadonlySet) { if (pending) { if (event.type === "tool_call_delta") { pending.args += event.arguments; return []; } if (event.type === "tool_call_end") { - let target: { name: string; args: string } | null = null; - if (!event.incomplete) { - try { - const value = JSON.parse(pending.args) as unknown; - if (isRec(value) && typeof value.name === "string" && loaded.has(value.name) - && (value.arguments === undefined || isRec(value.arguments))) { - target = { name: value.name, args: JSON.stringify(value.arguments ?? {}) }; - } - } catch { /* pass through unchanged */ } - } + const target = event.incomplete ? null : dispatchTarget(pending.args, targets); if (!target) return [...release(), event]; const start = { ...pending.start, name: target.name }; pending = null; @@ -187,16 +193,16 @@ function chatToolDispatchRewriter(loaded: ReadonlySet) { /** Streaming form: rewrite dispatcher calls in an adapter stream (see chatToolDispatchRewriter). */ export async function* rewriteChatToolDispatchEvents( events: AsyncIterable, - loaded: ReadonlySet, + targets: ReadonlySet, ): AsyncGenerator { - const rewriter = chatToolDispatchRewriter(loaded); + 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[], loaded: ReadonlySet): AdapterEvent[] { - const rewriter = chatToolDispatchRewriter(loaded); +export function rewriteChatToolDispatchEventList(events: readonly AdapterEvent[], targets: ReadonlySet): AdapterEvent[] { + const rewriter = chatToolDispatchRewriter(targets); return [...events.flatMap(event => rewriter.push(event)), ...rewriter.flush()]; } @@ -209,20 +215,20 @@ export function applyChatToolDispatch(adapter: ProviderAdapter, discovery: Rec): const build = adapter.buildRequest.bind(adapter); const parseStream = adapter.parseStream.bind(adapter); const parseResponse = adapter.parseResponse?.bind(adapter); - let loaded: ReadonlySet | null = null; + let targets: ReadonlySet | null = null; adapter.buildRequest = (parsed, incoming) => { const projection = projectChatToolDispatch(parsed, discovery); - loaded = projection?.loaded ?? null; + targets = projection?.targets ?? null; return build(projection ? projection.parsed : parsed, incoming); }; adapter.parseStream = (...args) => { const events = parseStream(...args); - return loaded ? rewriteChatToolDispatchEvents(events, loaded) : events; + return targets ? rewriteChatToolDispatchEvents(events, targets) : events; }; if (parseResponse) { adapter.parseResponse = async (...args) => { const events = await parseResponse(...args); - return loaded ? rewriteChatToolDispatchEventList(events, loaded) : events; + return targets ? rewriteChatToolDispatchEventList(events, targets) : events; }; } } diff --git a/structure/config.md b/structure/config.md index 7f611993752..58461d53124 100644 --- a/structure/config.md +++ b/structure/config.md @@ -121,8 +121,7 @@ All config publication continues through the existing required ACL-hardened writ `claudeCode.desktopProfile` follows the same preserve-the-rest rule. JSON `null` (or any non-string) `appliedFingerprint` / `appliedAt` is treated as unset. A profile that is still invalid after that is dropped as a whole — `src/config/salvage.ts` already does this for independent `routingProfiles` / `combos` entries — so one bad Desktop marker cannot replace the operator's providers with `getDefaultConfig()`. A `claudeCode` value that is not an object still fails the document, because there is no safe subtree to keep. `claudeCode.cliFirstParty` is an optional boolean in `src/types/config.ts`. The schema passes it through; the load normalizer (`src/config/load-degrade.ts`) drops a non-boolean hand edit, every reader treats only `true` as on, and `PUT /api/claude-code` accepts only a boolean. Absence means off. It is independent of `claudeCode.desktopMode`; changing CLI first-party pins an absent Desktop mode from the observation that will apply after the flag flips — an opt-out observes with `cliFirstParty` already cleared, so a shared env that predates the marker stays attributed to Desktop instead of being pinned `gateway` and removed from under it, and an owned env cannot be mistaken for CLI-only intent. The flag is written by a standalone `PUT /api/claude-code { cliFirstParty }`, including `ocx claude config set --first-party`; the mutation pins an absent `desktopMode` at the same time. The shared settings proxy status follows the ordered classifier in `src/claude/first-party-settings.ts`: unreadable settings are `unknown`; absent or unrecognized proxy URLs are `none`; a token-bearing opencodex URL beside a foreign CA is `foreign`, while a tokenless loopback URL beside that CA is `local` with unconfirmed ownership. An attributed proxy with no bound listener is `stopped`; a usable applied pair on a bound listener is `disabled` when Claude routing is ineligible and `live` when eligible; remaining mismatches are `broken` regardless of eligibility. Inspection never mints a token. A separate `ocx ensure` may write a config-derived port while this server remains bound elsewhere; status is then `broken` until the server restarts or ensure runs after restart. `claudeCode.contextAccounting` in `src/types/config.ts` stores only `"200k"`; absence is the 1m default and `"1m"` deletes the key. `ocx claude config set --context-accounting` writes it. The surface contract is [Claude Desktop picker mode](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy). -`claudeCode.appendOnlyToolLoadingModels` follows the [append-only discovery contract](data-planes/claude-tool-loading.md): at most 128 provider-qualified selectors of 512 characters, optional trailing prefix wildcard, absent/off by default. `src/claude/inbound-tool-loading.ts` shares fail-closed validation with the schema; writes reject malformed lists and `src/config/salvage.ts` drops only that optional field on load, preserving providers and other Claude settings. Provider-ID rewrites carry exact and wildcard selectors without changing their model suffixes. -`claudeCode.chatToolDispatchModels` is the Chat-wire counterpart with the identical selector contract, validation, salvage and provider-ID rewrite; its runtime behavior is the [Chat-wire dispatcher](data-planes/claude-tool-loading.md#chat-wire-dispatcher). Absent or empty is off and byte-identical to an unset key. +`claudeCode.appendOnlyToolLoadingModels` follows the [append-only discovery contract](data-planes/claude-tool-loading.md): at most 128 provider-qualified selectors of 512 characters, optional trailing prefix wildcard, absent/off by default. `src/claude/inbound-tool-loading.ts` shares fail-closed validation with the schema; writes reject malformed lists and `src/config/salvage.ts` drops only that optional field on load, preserving providers and other Claude settings. Provider-ID rewrites carry exact and wildcard selectors without changing their model suffixes. `claudeCode.chatToolDispatchModels` is the Chat-wire counterpart with the identical selector contract, validation, salvage and provider-ID rewrite; its runtime behavior is the [Chat-wire dispatcher](data-planes/claude-tool-loading.md#chat-wire-dispatcher). Absent or empty is off and byte-identical to an unset key. `showCodexCredits` is an optional, display-only boolean in `src/types/config.ts`, default off. The load schema degrades malformed values to false; `src/config/diagnostics.ts` rejects malformed write candidates. `GET /api/settings`, its successful PUT response, and the safe `/api/config` DTO always project a boolean. PUT accepts a partial boolean update, persists it, and restores both the previous value and key presence if saving fails. The switch gates account DTO exposure without changing probes or routing; [credits identity binding](providers/openai-accounts.md#display-only-codex-credits) owns the observation contract. The former `showCodexSparkQuota` key is inert passthrough data when loading an old config. It is absent from the typed settings contract and cannot re-enable Spark quota through the management API. Retirement does not migrate user-selected model ids or erase usage history. diff --git a/structure/data-planes/claude-tool-loading.md b/structure/data-planes/claude-tool-loading.md index 816e6b77717..b6de62cb65b 100644 --- a/structure/data-planes/claude-tool-loading.md +++ b/structure/data-planes/claude-tool-loading.md @@ -73,12 +73,15 @@ function from the first request with an eager `ToolSearch`; unused deferred tool so the catalog and the nudge never change on load. A discovered definition is appended as text to the ToolSearch result that loaded it, and earlier calls to discovered tools are re-encoded as dispatcher calls so replay is byte-stable. Both parse paths (`parseStream`, `parseResponse`) map a -dispatcher call back to the client tool only when it names a tool discovered in that request; -anything else, malformed or incomplete passes through unchanged. Dispatcher-call arguments are -buffered until the call ends. Tools called before discovery keep their native slot. A forced -choice of an undiscovered tool, duplicate tool_use ids, a discovery result that is not present -exactly once, a duplicate declared name, or a client tool already named `use_loaded_tool` sends -the request unprojected and leaves its response untouched. Absent, empty or unlisted +dispatcher call back to the client tool only when its payload is exactly `{name, arguments?}` +(`arguments` an object, or a JSON string holding one) and the name is a tool discovered in that +request or already in the visible catalog; an undiscovered deferred tool, extra top-level keys, +malformed or incomplete calls pass through unchanged. Dispatcher-call arguments are buffered +until the call ends. Tools called before discovery, including a hidden deferred tool the model +called directly, keep or regain their native slot, which changes the catalog once. A forced +choice of any tool the projection would hide, duplicate tool_use ids, a discovery result that is +not present exactly once, a duplicate declared name, or a client tool already named +`use_loaded_tool` sends the request unprojected and leaves its response untouched. Absent, empty or unlisted configuration is byte-identical to no configuration. Definitions travel as text, so provider-side strict schema enforcement does not apply to them. `tests/claude-integration/claude-chat-tool-dispatch.test.ts` covers projection, prefix stability on diff --git a/tests/claude-integration/claude-chat-tool-dispatch-routes.test.ts b/tests/claude-integration/claude-chat-tool-dispatch-routes.test.ts index ba45ea7a333..8542a0167d9 100644 --- a/tests/claude-integration/claude-chat-tool-dispatch-routes.test.ts +++ b/tests/claude-integration/claude-chat-tool-dispatch-routes.test.ts @@ -196,6 +196,17 @@ describe("applyChatToolDispatch adapter wrapping", () => { return { adapter, built }; }; const parsedOf = (raw: Record) => parseRequest(anthropicToResponsesTranslation(raw, {}).body as any); + /** A fresh wrapped adapter whose stream yields `events`, after one projected build of `raw`. */ + async function* applyAgain(events: AdapterEvent[], raw: Record) { + const adapter = { + name: "stub", + buildRequest: () => ({ url: "https://chat.example.com/v1/chat/completions", headers: {}, body: "{}" }), + async *parseStream() { yield* events; }, + } as unknown as ProviderAdapter; + applyChatToolDispatch(adapter, raw); + await adapter.buildRequest(parsedOf(raw), { headers: new Headers() } as any); + yield* adapter.parseStream(new Response(""), {} as any); + } test("both parse paths are rewritten after a projected build", async () => { const raw = loadedRequest("chatgw/test-model"); @@ -209,6 +220,27 @@ describe("applyChatToolDispatch adapter wrapping", () => { for (const out of [streamed, listed]) expect(out).toEqual(event("WebFetch", '{"q":"u"}')); }); + test("a visible tool named through the dispatcher maps back without widening access", async () => { + const raw = loadedRequest("chatgw/test-model"); + const out: AdapterEvent[] = []; + for await (const e of applyAgain(event(CHAT_TOOL_DISPATCH_NAME, JSON.stringify({ name: "Bash", arguments: { q: "ls" } })), raw)) out.push(e); + expect(out).toEqual(event("Bash", '{"q":"ls"}')); + }); + + test("each build decides afresh: a bail-out between two projected builds leaves only its response untouched", async () => { + const raw = loadedRequest("chatgw/test-model"); + const forced = { ...raw, tool_choice: { type: "tool", name: "WebFetch" } }; + const { adapter, built } = stubAdapter(); + applyChatToolDispatch(adapter, raw); + const outcomes: AdapterEvent[][] = []; + for (const body of [raw, forced, raw]) { + await adapter.buildRequest(parsedOf(body), { headers: new Headers() } as any); + outcomes.push(await adapter.parseResponse!(new Response(""), {} as any)); + } + expect(built.map(p => p.context.tools!.some(t => t.name === CHAT_TOOL_DISPATCH_NAME))).toEqual([true, false, true]); + expect(outcomes).toEqual([event("WebFetch", '{"q":"u"}'), dispatched, event("WebFetch", '{"q":"u"}')]); + }); + test("a build that bails out sends the request unchanged and leaves responses untouched", async () => { const raw = { ...loadedRequest("chatgw/test-model"), tool_choice: { type: "tool", name: "WebFetch" } }; const { adapter, built } = stubAdapter(); diff --git a/tests/claude-integration/claude-chat-tool-dispatch.test.ts b/tests/claude-integration/claude-chat-tool-dispatch.test.ts index 4e16412045f..fbec993945b 100644 --- a/tests/claude-integration/claude-chat-tool-dispatch.test.ts +++ b/tests/claude-integration/claude-chat-tool-dispatch.test.ts @@ -136,10 +136,11 @@ describe("Chat-wire dispatcher for ToolSearch-loaded tools", () => { expect(projectChatToolDispatch(parse(raw), raw as any)).toBeNull(); }); - test("only tools with a discovery result in this request are dispatchable", () => { + test("only discovered or already visible tools are dispatch targets", () => { const raw = request(["WebFetch", "NotebookEdit"], search("call_search", ["WebFetch"])); const projection = projectChatToolDispatch(parse(raw), raw as any)!; expect([...projection.loaded]).toEqual(["WebFetch"]); + expect([...projection.targets].sort()).toEqual(["Bash", "ToolSearch", "WebFetch"]); // Both deferred tools stay out of the catalog; only the discovered one is reachable. expect(projection.parsed.context.tools!.map(t => t.name)).toEqual(["ToolSearch", "Bash", CHAT_TOOL_DISPATCH_NAME]); }); @@ -183,13 +184,37 @@ describe("dispatcher response rewrite", () => { expect(await collect(malformed)).toEqual(malformed); }); - test("a dispatcher call naming a tool outside the loaded set passes through unchanged", async () => { + test("a dispatcher call naming a tool outside the target set passes through unchanged", async () => { for (const name of ["NotebookEdit", "Bash", CHAT_TOOL_DISPATCH_NAME, ""]) { const events = dispatchCall("c5", JSON.stringify({ name, arguments: {} })); expect(await collect(events)).toEqual(events); } }); + test("a dispatch must be exactly {name, arguments}; extra top-level keys are never dropped silently", async () => { + for (const args of [ + JSON.stringify({ name: "WebFetch", url: "https://x" }), + JSON.stringify({ name: "WebFetch", arguments: { url: "https://x" }, url: "https://y" }), + JSON.stringify({ name: "WebFetch", arguments: "not json" }), + JSON.stringify({ name: "WebFetch", arguments: "[1]" }), + JSON.stringify({ name: "WebFetch", arguments: [1] }), + ]) { + const events = dispatchCall("c10", args); + expect(await collect(events)).toEqual(events); + } + }); + + test("string-encoded arguments holding an object are decoded once; absent arguments are empty", async () => { + const encoded = await collect(dispatchCall("c11", JSON.stringify({ name: "WebFetch", arguments: JSON.stringify({ url: "https://x" }) }))); + expect(encoded).toEqual([ + { type: "tool_call_start", id: "c11", name: "WebFetch" }, + { type: "tool_call_delta", arguments: '{"url":"https://x"}' }, + { type: "tool_call_end" }, + ]); + const bare = await collect(dispatchCall("c12", JSON.stringify({ name: "WebFetch" }))); + expect(bare[1]).toEqual({ type: "tool_call_delta", arguments: "{}" }); + }); + test("consecutive dispatcher calls in one response are rewritten independently", async () => { const out = await collect([ ...dispatchCall("c6", JSON.stringify({ name: "WebFetch", arguments: { url: "a" } })), From 9712ce679c266b7580a010c403923db2f548d11b Mon Sep 17 00:00:00 2001 From: Gudals0320 Date: Sun, 1 Nov 2026 10:14:53 +0900 Subject: [PATCH 3/3] refactor(claude): share the direct-route gate of both tool-loading opt-ins appendOnlyToolLoadingModels and chatToolDispatchModels excluded combo and policy-owned requests with two copies of the same condition. One shared condition keeps a later exclusion from reaching only one of them. Behavior is unchanged. Co-Authored-By: Claude Opus 5.5 --- src/server/responses/request-transport.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/server/responses/request-transport.ts b/src/server/responses/request-transport.ts index ce45c8b46ee..5f9f9e0bdc4 100644 --- a/src/server/responses/request-transport.ts +++ b/src/server/responses/request-transport.ts @@ -574,18 +574,18 @@ export async function prepareResponsesTransport( // Capture this physical adapter's destination. A retry/rebind gets a fresh decision, and // each build projects a copy so unselected fallbacks retain the original tool catalog. const discovery = options.anthropicToolLoadingSource; - const appendOnly = inboundWire === "anthropic" && discovery && provider.adapter === "openai-responses" + // Shared by both direct-route opt-ins: combo attempts and policy-owned requests keep the + // original catalog. + const directToolLoadingRoute = inboundWire === "anthropic" && !options.comboAttempt && !route.combo && route.routeKind !== "combo" && route.routeKind !== "policy" - && !options.policyFallbackCandidate && !options.policyRequestScope?.eligibility - && appendOnlyToolLoadingMatches(config.claudeCode?.appendOnlyToolLoadingModels, - `${route.providerName}/${parsed._wireModelOverride ?? route.modelId}`); + && !options.policyFallbackCandidate && !options.policyRequestScope?.eligibility; + const wireModel = `${route.providerName}/${parsed._wireModelOverride ?? route.modelId}`; + const appendOnly = discovery && directToolLoadingRoute && provider.adapter === "openai-responses" + && appendOnlyToolLoadingMatches(config.claudeCode?.appendOnlyToolLoadingModels, wireModel); // The Chat wire has no additional_tools; its own opt-in keeps the catalog stable through a // dispatcher instead, projected and un-projected at this one physical adapter (#7452). - const chatDispatch = inboundWire === "anthropic" && discovery && provider.adapter === "openai-chat" - && !options.comboAttempt && !route.combo && route.routeKind !== "combo" && route.routeKind !== "policy" - && !options.policyFallbackCandidate && !options.policyRequestScope?.eligibility - && appendOnlyToolLoadingMatches(config.claudeCode?.chatToolDispatchModels, - `${route.providerName}/${parsed._wireModelOverride ?? route.modelId}`); + const chatDispatch = discovery && directToolLoadingRoute && provider.adapter === "openai-chat" + && appendOnlyToolLoadingMatches(config.claudeCode?.chatToolDispatchModels, wireModel); if (chatDispatch) applyChatToolDispatch(resolved, discovery); if (appendOnly) { const buildWithOriginalCatalog = resolved.buildRequest.bind(resolved);