diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index c6d1eb5abc5..f70461f82fe 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -960,7 +960,7 @@ The proxy translates every Anthropic Messages API request into the Codex Respons | User `tool_result` | `function_call_output` (`is_error` → `[tool error]` prefix) | | `tool_reference` in a tool result | Text `Tool loaded: ` in the paired result; preserves loaded-tool names without adding declarations or enabling translated server-side deferral | | `thinking` / `redacted_thinking` replay | `reasoning` items with bounded `ocxr1` envelopes for signatures and redacted payloads | -| Function tools | `{type: "function"}` (`web_search*` → `{type: "web_search"}`) | +| Function tools | `{type: "function"}` (`web_search*` → `{type: "web_search"}`). On a route to `api.anthropic.com`, the typed `web_search_*` and `web_fetch_*` declarations are also sent to Anthropic as declared and run server-side, unless the web-search sidecar supplies `web_search` itself. Each search or fetch comes back to the client as its `server_tool_use` block and its `web_search_tool_result` or `web_fetch_tool_result` | | `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, named function→`{type:"function",name}`, hosted WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index fa094752c93..a59e92bb180 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -203,7 +203,11 @@ The **Usage** summary and Models/Providers tables show end-to-end output through The **Usage** Models and Providers tables show the estimated priced portion for each row. Requests without a matching price or usable usage are counted as excluded beside that amount when the proxy -reports pricing coverage fields. A row with only excluded requests shows an em dash with that count +reports pricing coverage fields. Usage that was only partly measured (`unreported` or `unsupported`) +is not priced, and one excluded part excludes the whole request from that row's priced count. A +leg that reached no upstream, or that upstream refused with an HTTP error before any output, spent +nothing and does not; a leg whose response began (or ended without a status) but reported no usage +may have been billed, so it does. A row with only excluded requests shows an em dash with that count instead of a zero-dollar estimate, and a displayed zero-dollar value is a confirmed priced estimate. An older proxy that reports no coverage fields keeps a bare em dash with no count, so missing coverage stays distinct from an all-excluded row and from a confirmed zero estimate. diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index 27618ac397d..8476e7eb932 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -535,7 +535,7 @@ Messages → Responses に変換されるリクエストでは、Claude Code の | Assistant `tool_use` | `function_call`(`input` → JSON 文字列に変換した `arguments`) | | ユーザー `tool_result` | `function_call_output`(`is_error` → `[tool error]` 接頭辞) | | `thinking` / `redacted_thinking` 再生 | シグネチャと秘匿ペイロードを境界付き `ocxr1` エンベロープに保持した `reasoning` 項目 | -| Function ツール | `{type: "function"}`(`web_search*` → `{type: "web_search"}`) | +| Function ツール | `{type: "function"}`(`web_search*` → `{type: "web_search"}`). `api.anthropic.com` への翻訳ルートでは、型付き `web_search_*` と `web_fetch_*` 宣言も Anthropic に送信され、サーバー側で実行されます。ただし Web 検索サイドカーが `web_search` を提供する場合はそちらが優先されます。検索と取得の結果は `server_tool_use` と対応する `web_search_tool_result` / `web_fetch_tool_result` としてクライアントに返されます | | `tool_choice` | `auto`→`auto`、`none`→`none`、`any`→`required`、名前指定関数→`{type:"function",name}`、ホスト型 WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | 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 eb05333120d..14ebff76cba 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -575,7 +575,7 @@ Messages → Responses로 변환되는 요청에서는 Claude Code의 `/effort` | 사용자 `tool_result` | `function_call_output`(`is_error` → `[tool error]` 접두사) | | 도구 결과의 `tool_reference` | 짝지어진 결과에 `Tool loaded: ` 텍스트로 보존합니다. 도구 선언을 추가하거나 번역 경로의 서버 측 지연 로딩을 활성화하지는 않습니다. | | `thinking` / `redacted_thinking` 재생 | 서명과 비공개 페이로드를 제한된 `ocxr1` 봉투에 담은 `reasoning` 항목 | -| Function 도구 | `{type: "function"}`(`web_search*` → `{type: "web_search"}`) | +| Function 도구 | `{type: "function"}`(`web_search*` → `{type: "web_search"}`). `api.anthropic.com`으로 번역하여 보내는 경로에서는 타입이 지정된 `web_search_*`와 `web_fetch_*` 선언도 Anthropic에 전달되어 서버에서 실행됩니다. 웹 검색 사이드카가 `web_search`를 제공하면 사이드카가 우선합니다. 검색과 가져오기 결과는 `server_tool_use` 및 대응하는 `web_search_tool_result` / `web_fetch_tool_result` 블록으로 클라이언트에 반환됩니다 | | `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, 이름 지정 함수→`{type:"function",name}`, 호스팅 WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index c4735b880ed..6aa3625655b 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -100,7 +100,7 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR | `maxUpstreamBodyBytes?` | `number` | `0` | Opt-in ceiling, in bytes, on a serialized native Responses **passthrough** body. `0` or omitted disables it — no limit is inferred for any destination. When set, a built body above the ceiling is refused locally before the send: streaming turns receive a terminal `response.failed` / `context_length_exceeded` so the client compacts instead of resending, and non-streaming turns receive a `413` naming the size, the number of embedded `input_image` items, and roughly how many megabytes of image data they represent. Checked at every build and rebuild point, including OAuth-refresh replay and alternate-account retry. Translated adapter paths are not covered. There is deliberately no default: the only measured ceiling here belongs to the WebSocket transport, which already falls back to HTTP for oversized turns, so a default would refuse requests that currently succeed. Set it when your gateway has a known request-size limit and you would rather see an actionable local error than an opaque upstream failure. | | `maxInboundBodyBytes?` | `number` | `0` | Opt-in ceiling, in bytes, on a decompressed **inbound** data-plane request body — the mirror of `maxUpstreamBodyBytes` above. `0` or omitted keeps the built-in 256 MiB default. Raise it when a large-context session can no longer compact: Codex replays the whole history to the compaction model, so on the 922k-token opt-in window the compaction request is itself the one that crosses the limit, and the session is stuck at the only operation that would have shrunk it. Clamped to 1 MiB–512 MiB. The ceiling is not negotiable: the reader materializes the body several times over (wire bytes, decoded bytes, the decoded string, and the parsed object graph), so peak memory is a multiple of whatever is admitted, and an unbounded value would be a memory exhaustion lever. Applies to `/v1/responses`, `/v1/responses/compact`, `/v1/chat/completions`, and `/v1/messages`. The listener's accept size is fixed when the proxy binds, so a change takes effect on restart. A body above the limit is refused locally with HTTP 413 and `code: "inbound_body_too_large"`, which is deliberately distinct from the `context_length_exceeded` 413 a provider size refusal produces. | | `modelCacheTtlMs?` | `number` | `300000` | Freshness window for the per-provider `/models` cache. | -| `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt-cache policy: disabled, 5-minute ephemeral, or 1-hour extended. | +| `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt-cache policy: disabled, 5-minute ephemeral, or 1-hour extended. When unset, a `/v1/messages` request on a translated route whose own `cache_control` breakpoints all ask for `ttl: "1h"` is treated as `"long"`; a request that mixes 1h and 5m breakpoints keeps the 5-minute default, and an explicit value always wins. | | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | Optional proactive OAuth refresh and Codex-account warmup policy. | Across these strategies, each account's effective threshold is its `codexAccountAutoSwitchThresholds` entry when present, otherwise global `autoSwitchThreshold`. A zero override disables only proactive usage switching; startup binding, hard-lock, cooldown, entitlement checks, and failure recovery remain enforced. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 6881f6edbd5..8bd8fb78ef2 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -290,10 +290,11 @@ upstream model evidence remains absent rather than being inferred from the reque Failed request rows can include bounded `upstreamErrorType`, `upstreamErrorCode`, and `upstreamRequestId` diagnostics. On the Codex upstream WebSocket path, a bare error followed by -EOF retains recognized rate-limit or overload codes in the synthesized failed terminal. -Request logs record these as 429 for `rate_limit_exceeded` / `rate_limit_error`, or 503 for -`server_is_overloaded` / `overloaded_error`. A recognized type without a code uses the same -status. On the streaming path, an explicit unknown code records the 5xx status the Codex +EOF retains recognized rate-limit, overload, or client-refusal codes in the synthesized failed terminal. +Request logs record these as 429 for `rate_limit_exceeded` / `rate_limit_error`, 503 for +`server_is_overloaded` / `overloaded_error`, and 400/401/403/404 for `invalid_request_error` / +`authentication_error` / `permission_error` / `not_found_error`. A recognized type without a code +uses the same status. On the streaming path, an explicit unknown code records the 5xx status the Codex WebSocket wrapper states as a top-level integer `status` or `status_code` (one of the two, not both); otherwise, and for buffered JSON callers, it remains a generic 502 failure. The client terminal keeps `upstream_server_error` for unknown codes either way. Bare-error status is provisional: diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index c520548e72b..84bf72f2142 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -571,7 +571,7 @@ Claude Code — это лишь учётные данные для доступ | `tool_use` ассистента | `function_call` (`input` → `arguments` в виде JSON-строки) | | `tool_result` пользователя | `function_call_output` (`is_error` → префикс `[tool error]`) | | Повтор `thinking` / `redacted_thinking` | Элементы `reasoning` с ограниченными конвертами `ocxr1` для подписей и скрытых данных | -| Function-инструменты | `{type: "function"}` (`web_search*` → `{type: "web_search"}`) | +| Function-инструменты | `{type: "function"}` (`web_search*` → `{type: "web_search"}`). На переведённом маршруте к `api.anthropic.com` типизированные объявления `web_search_*` и `web_fetch_*` также передаются Anthropic для выполнения на сервере, если только сайдкар веб-поиска сам не предоставляет `web_search`. Поиск и загрузка возвращаются клиенту как `server_tool_use` и соответствующий `web_search_tool_result` / `web_fetch_tool_result` | | `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, именованная функция→`{type:"function",name}`, размещённый WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 8652c595cd7..67e176bebb9 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -497,7 +497,7 @@ Search 和图像描述沿用仓库已有的 Claude Code OAuth fingerprint 先例 | Assistant `tool_use` | `function_call`(`input` → JSON 字符串化的 `arguments`) | | 用户 `tool_result` | `function_call_output`(`is_error` → `[tool error]` 前缀) | | 重放 `thinking` / `redacted_thinking` | `reasoning` 项;签名和脱敏载荷保存在有界 `ocxr1` 信封中 | -| Function 工具 | `{type: "function"}`(`web_search*` → `{type: "web_search"}`) | +| Function 工具 | `{type: "function"}`(`web_search*` → `{type: "web_search"}`). 在到 `api.anthropic.com` 的转换路由上,带类型的 `web_search_*` 和 `web_fetch_*` 声明也会发送到 Anthropic 并在服务端执行;如果网页搜索边车提供了 `web_search`,则优先使用边车。搜索和抓取结果以 `server_tool_use` 及对应的 `web_search_tool_result` / `web_fetch_tool_result` 块返回客户端 | | `tool_choice` | `auto`→`auto`,`none`→`none`,`any`→`required`,指定函数→`{type:"function",name}`,托管 WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 872ec08a657..c778b7d49be 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -77,6 +77,14 @@ "owner-registry-acl.test.ts": "config", "minimax-combo-image-input.test.ts": "providers", "web-search-forced-declaration.test.ts": "web-search", + "usage-partly-measured-cost.test.ts": "usage", + "claude-gateway-output-cap.test.ts": "claude-integration", + "claude-client-refusal-status.test.ts": "claude-integration", + "claude-translated-cache-ttl.test.ts": "claude-integration", + "claude-translated-server-tools.test.ts": "claude-integration", + "web-search-loop-iteration-usage.test.ts": "web-search", + "web-search-loop-error-usage.test.ts": "web-search", + "web-search-rotation-usage-attribution.test.ts": "web-search", "claude-devin-output-order.test.ts": "claude-integration", "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index c9a18603a1d..653d91593ce 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -522,6 +522,16 @@ function streamedToolArgumentsParse(assembled: string): boolean { } } +/** A string field of a server tool's streamed input (web_search `query`, web_fetch `url`); "" when it did not parse. */ +function serverToolInputField(json: string, key: "query" | "url"): string { + try { + const value = (JSON.parse(json) as Record | null)?.[key]; + return typeof value === "string" ? value : ""; + } catch { + return ""; + } +} + function anthropicKeyUsesBearer(provider: OcxProviderConfig): boolean { return provider.apiKeyTransport === "bearer"; } @@ -897,6 +907,18 @@ function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: ( return converted; } +/** + * Appends the Claude client's typed web tools (`parsed._anthropicServerTools`) so Anthropic runs + * them server-side. A same-named function tool wins: the web-search sidecar's `web_search` keeps + * priority, and Anthropic rejects duplicate tool names. + */ +function withAnthropicServerTools(tools: unknown[] | undefined, parsed: OcxParsedRequest): unknown[] | undefined { + const serverTools = parsed._anthropicServerTools?.filter(t => !parsed.context.tools?.some(f => f.name === t.name)); + if (!serverTools?.length) return tools; + // The caller's breakpoints are dropped on this lane; applyPromptCaching places its own. + return [...(tools ?? []), ...serverTools.map(t => ({ ...t, cache_control: undefined }))]; +} + function normalizeAnthropicInputSchema(schema: unknown): Record { const stripped = stripResponsesOnlyEncryptedMarker(schema); const obj = stripped && typeof stripped === "object" && !Array.isArray(stripped) @@ -989,7 +1011,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // Anthropic rejects many-image requests (>20 images) carrying any image over // 2000px per side; see anthropic-image-guard.ts for the full limit policy. enforceAnthropicImageLimits(messages); - const tools = toolsToAnthropicFormat(parsed, toolNames); + const converted = toolsToAnthropicFormat(parsed, toolNames); + // A third-party Anthropic-compatible endpoint may not implement Anthropic's server tools. + const tools = usesNativeAnthropicEndpoint(provider) ? withAnthropicServerTools(converted, parsed) : converted; // Codex never sends `max_output_tokens`, so the omitted-limit default decides how // long a Claude answer may run. Honor the provider's configured output budget @@ -1221,6 +1245,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti let pendingStopReason: string | undefined; let emittedDone = false; let sawVisibleText = false; + /** The open server-side web_search / web_fetch call, until its result block. */ + let serverSearch: { id: string; json: string; streaming: boolean } | undefined; const emitDone = function* (): Generator { if (emittedDone) return; @@ -1289,7 +1315,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti break; } case "content_block_start": { - const block = data.content_block as { type: string; id?: string; name?: unknown; data?: string; thinking?: string } | undefined; + const block = data.content_block as { type: string; id?: string; name?: unknown; data?: string; thinking?: string; tool_use_id?: unknown; content?: unknown } | undefined; if (!block) break; currentBlockType = block.type; if (block.type === "thinking") { @@ -1304,6 +1330,46 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti budget.openCall(currentToolCallId); yield { type: "tool_call_start", id: currentToolCallId, name: currentToolCallName }; } + if (block.type === "server_tool_use" && (block.name === "web_search" || block.name === "web_fetch") && typeof block.id === "string") { + // Only the input (query / url) is collected here (input_json_delta below). The cell + // opens at its result: a call Anthropic defers beside a client tool_use has + // no result in this response and must not surface as a completed search. + serverSearch = { id: block.id, json: "", streaming: true }; + } + if (block.type === "web_search_tool_result" && typeof block.tool_use_id === "string") { + const hits = Array.isArray(block.content) ? block.content as Array<{ url?: unknown; title?: unknown }> : undefined; + // The begin/end pair the sidecar emits. A result whose call came in an earlier response has no query here. + const query = serverSearch?.id === block.tool_use_id ? serverToolInputField(serverSearch.json, "query") : ""; + yield { type: "web_search_call_begin", id: block.tool_use_id }; + yield { + type: "web_search_call_end", + id: block.tool_use_id, + queries: query ? [query] : [], + // A failed search carries one error object instead of the hit list. + status: hits ? "completed" : "failed", + sources: (hits ?? []).flatMap(h => typeof h.url === "string" + ? [{ url: h.url, ...(typeof h.title === "string" ? { title: h.title } : {}) }] : []), + }; + serverSearch = undefined; + } + if (block.type === "web_fetch_tool_result" && typeof block.tool_use_id === "string") { + // Relayed as the same cell, opened at the result for the same reason; the result is + // carried verbatim (a web_fetch_result document or a web_fetch_tool_error). + const result = block.content ?? { type: "web_fetch_tool_error", error_code: "unavailable" }; + const url = (result as { url?: unknown }).url; + yield { type: "web_search_call_begin", id: block.tool_use_id }; + yield { + type: "web_search_call_end", + id: block.tool_use_id, + queries: [], + status: (result as { type?: unknown }).type === "web_fetch_result" ? "completed" : "failed", + fetch: { + url: typeof url === "string" ? url : serverSearch?.id === block.tool_use_id ? serverToolInputField(serverSearch.json, "url") : "", + result, + }, + }; + serverSearch = undefined; + } if (block.type === "redacted_thinking" && typeof block.data === "string") { // Opaque redacted block: replay verbatim later or tool-use turns 400. yield { type: "redacted_thinking", data: block.data }; @@ -1347,10 +1413,15 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti throw error; } yield { type: "tool_call_delta", arguments: delta.partial_json }; + } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && serverSearch?.streaming + && serverSearch.json.length < 4096) { + // Capped: an input past 4 KiB loses its label (query / url), never the call. + serverSearch.json += delta.partial_json; } break; } case "content_block_stop": { + if (serverSearch) serverSearch.streaming = false; if (currentBlockType === "tool_use") { // The non-stream path repairs an unparseable payload in toolUseArguments(); the // stream cannot, because the fragments are already downstream. Fail the turn diff --git a/src/bridge/internal.ts b/src/bridge/internal.ts index 4b1543d1f13..45e70a610c4 100644 --- a/src/bridge/internal.ts +++ b/src/bridge/internal.ts @@ -186,7 +186,9 @@ export function adapterFailureFromEvent(event: Extract { +export function webSearchAction(queries: string[], fetch?: { url: string }): Record { + // Anthropic's server-side web_fetch opens one page instead of searching. + if (fetch) return { type: "open_page", url: fetch.url }; const first = queries[0] ?? ""; return { type: "search", query: first, queries: queries.length > 0 ? queries : [first] }; } diff --git a/src/bridge/response-json.ts b/src/bridge/response-json.ts index fb2cc73ae04..518a3013cd7 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -544,8 +544,9 @@ function buildResponseJSONWithBudget( const safeSources = safeWebSearchSources(e.sources); pushOutput({ type: "web_search_call", id: `ws_${uuid()}`, status: e.status ?? "completed", - action: webSearchAction(e.queries), + action: webSearchAction(e.queries, e.fetch), ...(safeSources.length > 0 ? { sources: safeSources } : {}), + ...(e.fetch ? { fetch_result: e.fetch.result } : {}), }); if (safeSources.length > 0) { for (const source of safeSources) { diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index 765065b9310..28c6ca040d8 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -684,12 +684,15 @@ export function bridgeToResponsesSSE( // leaves a "Searching the web" spinner spinning forever. // `sources` rides on the done item (additive field; codex-rs serde ignores unknown fields) so // downstream translators (claude outbound) can fill web_search_tool_result content. - const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[]) => { + // `fetch` (Anthropic's server-side web_fetch) makes the cell a page open and rides its result + // verbatim as `fetch_result`, for the same claude outbound translator. + const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], fetch?: { url: string; result: unknown }) => { if (!currentWebSearch) return; const item = { type: "web_search_call", id: currentWebSearch.itemId, status, - action: webSearchAction(queries), + action: webSearchAction(queries, fetch), ...(sources && sources.length > 0 ? { sources } : {}), + ...(fetch ? { fetch_result: fetch.result } : {}), }; emit("response.output_item.done", { output_index: currentWebSearch.outputIndex, item }); retainFinishedItem(item as OutputItem); @@ -1227,7 +1230,7 @@ export function bridgeToResponsesSSE( currentWebSearch = { itemId: wsItemId2, eventId: event.id, outputIndex }; } const safeSources = safeWebSearchSources(event.sources); - closeCurrentWebSearch(event.status ?? "completed", event.queries, safeSources); + closeCurrentWebSearch(event.status ?? "completed", event.queries, safeSources, event.fetch); // Queue this search's sources for the next assistant message (dedup by URL). if (safeSources.length > 0) { for (const source of safeSources) { diff --git a/src/claude/inbound-content-options.ts b/src/claude/inbound-content-options.ts index cd7c93b15e4..1358de6e1a8 100644 --- a/src/claude/inbound-content-options.ts +++ b/src/claude/inbound-content-options.ts @@ -76,6 +76,17 @@ export function toolsToResponses(tools: unknown): Rec[] | undefined { return out.length > 0 ? out : undefined; } +/** + * The caller's typed Anthropic web tools (`web_search_*`, `web_fetch_*`), as declared. The + * Responses body has no slot for them — web_search becomes the hosted sidecar tool above and + * web_fetch is dropped — so an Anthropic destination receives them out of body, unchanged. + */ +export function declaredAnthropicServerTools(raw: unknown): Rec[] | undefined { + if (!isRec(raw) || !Array.isArray(raw.tools)) return undefined; + const tools = raw.tools.filter((t): t is Rec => isRec(t) && typeof t.type === "string" && /^web_(search|fetch)_\d+$/.test(t.type)); + return tools.length > 0 ? tools : undefined; +} + export function toolChoiceToResponses(choice: unknown, body: Rec): void { if (!isRec(choice)) return; if (choice.disable_parallel_tool_use === true) body.parallel_tool_calls = false; diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 478be44037e..84335049477 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -387,6 +387,39 @@ function canonicalJson(value: unknown): string { /** Provenance of the generated prompt_cache_key (never serialized into the wire body). */ export type ClaudeCacheKeySource = "metadata" | "system" | null; +/** + * The translated lane places its own prompt-cache breakpoints, so the caller's are dropped; + * their TTL is the one thing worth keeping. When every caller breakpoint asks for `ttl: "1h"` + * (Claude Code on a subscription marks them so) this maps to `cacheRetention: "long"`. A mixed + * caller (1h prefix, 5m tail) stays on the default: "long" also makes the moving per-turn + * breakpoint 1h, which bills writes the caller asked to keep at the 5m rate at the 1h rate. + * Read out of body: the Responses body has no field for it. + */ +export function callerCacheRetention(raw: unknown): "long" | undefined { + if (!isRec(raw)) return undefined; + // Top-level automatic caching is a breakpoint too, and content blocks nest (tool_result, + // search_result, document sources), so every block container is walked. Tool inputs and + // schemas are caller data, not blocks: a `cache_control` key there is not a breakpoint. + const ttls: unknown[] = []; + const visit = (node: unknown, depth: number): void => { + if (depth > 16) return; + if (Array.isArray(node)) { for (const item of node) visit(item, depth + 1); return; } + if (!isRec(node)) return; + if (isRec(node.cache_control)) ttls.push(node.cache_control.ttl); + for (const [key, value] of Object.entries(node)) { + if (key !== "cache_control" && key !== "input" && key !== "input_schema" + && (Array.isArray(value) || isRec(value))) visit(value, depth + 1); + } + }; + if (isRec(raw.cache_control)) ttls.push(raw.cache_control.ttl); + visit(raw.system, 0); + visit(raw.tools, 0); + visit(raw.messages, 0); + return ttls.length > 0 && ttls.every(ttl => ttl === "1h") + ? "long" + : undefined; +} + export interface ClaudeInboundTranslation { body: Rec; cacheKeySource: ClaudeCacheKeySource; diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index b5e44e85ba9..c920d9acdb8 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -223,10 +223,20 @@ export function sanitizeWebSearchInput(input: unknown): Rec { /** * Map a Responses `web_search_call` item to its Anthropic pair: the server_tool_use * input (query/queries) and the web_search_tool_result content (hits, or the error - * object when the search failed). Shared by the SSE and JSON translation paths. + * object when the search failed). An item carrying `fetch_result` is Anthropic's + * server-side web_fetch and maps to web_fetch + its web_fetch_tool_result, verbatim. + * Shared by the SSE and JSON translation paths. */ -export function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resultContent: unknown; completed: boolean } { +export function webSearchPairFromItem(item: Rec): { + id: string; name: "web_search" | "web_fetch"; resultType: string; input: Rec; resultContent: unknown; completed: boolean; +} { const action = isRec(item.action) ? item.action : {}; + const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`; + const completed = item.status !== "failed"; + if (item.fetch_result !== undefined) { + const input = { url: typeof action.url === "string" ? action.url : "" }; + return { id, name: "web_fetch", resultType: "web_fetch_tool_result", input, resultContent: item.fetch_result, completed }; + } const queries = Array.isArray(action.queries) ? action.queries.filter((q): q is string => typeof q === "string" && q.length > 0) : []; @@ -234,7 +244,6 @@ export function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resu const input = sanitizeWebSearchInput( queries.length > 1 ? { queries } : { query: queries[0] ?? query }, ); - const completed = item.status !== "failed"; let resultContent: unknown; if (completed) { const hits: Rec[] = []; @@ -249,8 +258,7 @@ export function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resu } else { resultContent = { type: "web_search_tool_result_error", error_code: "unavailable" }; } - const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`; - return { id, input, resultContent, completed }; + return { id, name: "web_search", resultType: "web_search_tool_result", input, resultContent, completed }; } /** @@ -712,7 +720,7 @@ export function responsesSseToAnthropicSse( const toolIndex = blockIndex++; emit("content_block_start", { type: "content_block_start", index: toolIndex, - content_block: { type: "server_tool_use", id: pair.id, name: "web_search" }, + content_block: { type: "server_tool_use", id: pair.id, name: pair.name }, }); emit("content_block_delta", { type: "content_block_delta", index: toolIndex, @@ -722,10 +730,10 @@ export function responsesSseToAnthropicSse( const resultIndex = blockIndex++; emit("content_block_start", { type: "content_block_start", index: resultIndex, - content_block: { type: "web_search_tool_result", tool_use_id: pair.id, content: pair.resultContent }, + content_block: { type: pair.resultType, tool_use_id: pair.id, content: pair.resultContent }, }); emit("content_block_stop", { type: "content_block_stop", index: resultIndex }); - if (pair.completed) webSearchRequests++; + if (pair.completed && pair.name === "web_search") webSearchRequests++; break; } // Close the matching open block (message/reasoning items close implicitly on @@ -1051,9 +1059,9 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string, tr case "web_search_call": { // Server-side search: emit the Anthropic pair. Does NOT set sawToolUse. const pair = webSearchPairFromItem(raw); - content.push({ type: "server_tool_use", id: pair.id, name: "web_search", input: pair.input }); - content.push({ type: "web_search_tool_result", tool_use_id: pair.id, content: pair.resultContent }); - if (pair.completed) webSearchRequests++; + content.push({ type: "server_tool_use", id: pair.id, name: pair.name, input: pair.input }); + content.push({ type: pair.resultType, tool_use_id: pair.id, content: pair.resultContent }); + if (pair.completed && pair.name === "web_search") webSearchRequests++; break; } default: diff --git a/src/images/loop.ts b/src/images/loop.ts index 21cbf3d349d..9d729678cd8 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -326,6 +326,12 @@ export interface ImageBridgeDeps { waitForRequestSlot?: (signal?: AbortSignal) => Promise; /** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */ onUsage?: (usage: OcxUsage | undefined) => void; + /** + * After an `on429` rotation, offered the usage of the hidden iterations the left credential + * served. Returning true means it was billed to that credential's attempt, so `onUsage` will not + * report it again; the client terminal still includes it. + */ + onRotatedUsage?: (usage: OcxUsage) => boolean; /** * Optional 429 failover for the routed (non-xAI) model. Return a rebuilt adapter for the * rotated credential, or null when the pool is exhausted. Async hooks support OAuth refresh; @@ -386,6 +392,10 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise