diff --git a/docs-site/src/content/docs/fr/reference/proxy-formats.md b/docs-site/src/content/docs/fr/reference/proxy-formats.md index 3d47bfaeae4..5535c90ade6 100644 --- a/docs-site/src/content/docs/fr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/fr/reference/proxy-formats.md @@ -30,6 +30,17 @@ Certains refus xAI de Chat Completions arrivent en HTTP 403 avec une phrase de r Sur une requête Responses hors combo, OpenCodex réécrit ce 403 de la liste autorisée en réponse Responses HTTP 200 avec `status: "incomplete"` et `incomplete_details.reason: "content_filter"`. La réécriture s'applique au chemin de l'adaptateur openai-chat et au passthrough openai-responses (OAuth grok-4.6 / grok-4.5). Le streaming utilise la même limite incomplete. Un corps 403 vide ou fait d'espaces reste une erreur. Les 403 d'abonnement, de crédits, de droits d'accès et `not allowed to use this model` restent des erreurs. Le basculement de combo voit toujours le HTTP 403 d'origine. +## Appels d’outils non déclarés + +Sur le point de terminaison Responses, lorsqu’une requête déclare un catalogue d’outils, une réponse en passthrough natif qui appelle un outil exécuté par le client hors de ce catalogue fait échouer le tour au lieu de relayer l’appel : un événement `response.failed` avec le code d’erreur `undeclared_tool_call`, puis `data: [DONE]` ; le JSON mis en mémoire tampon renvoie HTTP 502 avec le même code. Le refus intervient avant que la réponse puisse entrer dans l’état de continuation, et il n’est désactivé que pour les fournisseurs avec `authMode: "forward"` ou pour une requête sans catalogue déclaré. + +Les points de terminaison Chat Completions et Messages honorent les catalogues d’outils différés : ces clients peuvent ne déclarer qu’un catalogue partiel et découvrir le reste à l’exécution via leur propre registre d’outils, qui — et non l’instantané de catalogue traduit — détient la frontière complète de déclaration. Un appel hors de la liste déclarée est relayé pour que le client le valide ; un appel inventé contre un `tools: []` explicitement vide est toujours refusé, car aucun registre différé n’existe sous une déclaration vide. Ces refus conservent le code `undeclared_tool_call` sur chaque voie : + +- **Chat Completions :** une trame `data:` contenant un objet `error` au format OpenAI portant le code, puis la fermeture du flux sans fin de succès ni `[DONE]` ; le JSON mis en mémoire tampon renvoie HTTP 502 avec le code. +- **Messages :** une trame Anthropic `event: error` dont le corps est `{ "type": "error", "error": { "type": "api_error", "code": "undeclared_tool_call", ... } }`, sans `message_stop` réussi ni `[DONE]` ; le JSON mis en mémoire tampon renvoie HTTP 502 `api_error` avec le code. Une violation déterministe du catalogue n’est jamais rapportée comme un `overloaded_error` réessayable (HTTP 529). + +Par ailleurs, sur chaque voie entrante, un appel dans l’espace de noms `default.` d’un fournisseur est normalisé vers le nom d’outil nu déclaré par la requête (`default.Read` → `Read`). + ## Présentation du point de terminaison | Espace client | Point de terminaison | Résultat non-stream réussi | Résultat de flux ou de socket réussi | diff --git a/docs-site/src/content/docs/ja/reference/proxy-formats.md b/docs-site/src/content/docs/ja/reference/proxy-formats.md index db698fd90cb..aa05232953d 100644 --- a/docs-site/src/content/docs/ja/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ja/reference/proxy-formats.md @@ -24,6 +24,17 @@ provider events → internal adapter events → client dialect コンボではない Responses リクエストでは、OpenCodex はその allowlist 対象の 403 を HTTP 200 の Responses、`status: "incomplete"`、`incomplete_details.reason: "content_filter"` に書き換えます。openai-chat アダプタ経路と openai-responses パススルー(grok-4.6 / grok-4.5 OAuth)の両方です。ストリーミングも同じ incomplete 境界です。空本文の 403 はエラーのままです。サブスクリプション、クレジット、権限、`not allowed to use this model` の 403 はエラーのままです。コンボのフェイルオーバーは元の HTTP 403 を見ます。 +## 宣言されていないツール呼び出し + +Responses エンドポイントでは、リクエストがツールカタログを宣言している場合、ネイティブのパススルー応答がそのカタログにないクライアント実行ツールを呼び出すと、その呼び出しを中継せずにターンを失敗させます: エラーコード `undeclared_tool_call` を含む `response.failed` イベントに続き `data: [DONE]` を送り、バッファリングされた JSON は同じコードで HTTP 502 を返します。この拒否は応答が継続状態に入る前に実行され、`authMode: "forward"` のプロバイダーまたは宣言済みカタログのないリクエストに限り無効になります。 + +Chat Completions と Messages の各エンドポイントは遅延ツールカタログを尊重します: これらのクライアントはカタログの一部だけを宣言し、残りを実行時に自身のツールレジストリを通じて発見できます。完全な宣言境界を所有するのは翻訳されたカタログのスナップショットではなくそのレジストリです。宣言リスト外の呼び出しはクライアントが検証するために中継され、明示的に空の `tools: []` に対する呼び出しは依然として拒否されます — 空の宣言の下には遅延レジストリが存在しないためです。これらの拒否もすべてのワイヤで `undeclared_tool_call` コードを保持します: + +- **Chat Completions:** そのコードを含む OpenAI 形式の `error` オブジェクトを含む `data:` フレームを送り、成功完了や `[DONE]` を送らずにストリームを閉じます。バッファリングされた JSON はコード付きの HTTP 502 を返します。 +- **Messages:** 本文が `{ "type": "error", "error": { "type": "api_error", "code": "undeclared_tool_call", ... } }` の Anthropic `event: error` フレームを送り、成功した `message_stop` や `[DONE]` は送りません。バッファリングされた JSON はコード付きの HTTP 502 `api_error` を返します。確定的なカタログ違反は再試行可能な `overloaded_error`(HTTP 529)として報告されません。 + +これとは別に、すべての受信ワイヤで、プロバイダーの `default.` 名前空間の呼び出しはリクエストが宣言した素のツール名に正規化されます(`default.Read` → `Read`)。 + ## エンドポイントの概要 |クライアントサーフェス |エンドポイント |非ストリームの結果が成功 |成功したストリームまたはソケットの結果 | diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index e93eb315644..9cad2c81f9a 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -29,6 +29,17 @@ Responses 표현이 이 연결의 중심입니다. 네이티브 호환 경로는 콤보가 아닌 Responses 요청에서 OpenCodex는 allowlist에 오른 그 403을 HTTP 200 Responses, `status: "incomplete"`, `incomplete_details.reason: "content_filter"`로 바꿉니다. openai-chat 어댑터 경로와 openai-responses passthrough(grok-4.6 / grok-4.5 OAuth) 모두에서 동작합니다. 스트리밍도 같은 incomplete 경계입니다. 빈 본문 403은 오류로 남습니다. 구독, 크레딧, 권한, `not allowed to use this model` 403은 오류로 남습니다. 콤보 페일오버는 원래 HTTP 403을 그대로 봅니다. +## 선언되지 않은 도구 호출 + +Responses 엔드포인트에서 요청이 도구 카탈로그를 선언한 경우, 네이티브 패스스루 응답이 그 카탈로그에 없는 클라이언트 실행 도구를 호출하면 호출을 전달하는 대신 턴을 실패 처리합니다: 오류 코드 `undeclared_tool_call`이 포함된 `response.failed` 이벤트와 `data: [DONE]`을 보내고, 버퍼링된 JSON은 같은 코드와 함께 HTTP 502를 반환합니다. 이 거부는 응답이 후속 처리 상태에 들어가기 전에 실행되며, `authMode: "forward"` 공급자이거나 요청에 선언된 카탈로그가 없는 경우에만 적용되지 않습니다. + +Chat Completions와 Messages 엔드포인트는 지연된 도구 카탈로그를 지원합니다: 이러한 클라이언트는 카탈로그의 일부만 선언하고 나머지를 자체 도구 레지스트리를 통해 런타임에 발견할 수 있으며, 전체 선언 경계는 번역된 카탈로그 스냅숏이 아니라 그 레지스트리가 소유합니다. 선언된 목록에 없는 호출은 클라이언트가 검증하도록 전달되고, 명시적으로 빈 `tools: []`에 대한 호출은 여전히 거부됩니다 — 빈 선언 아래에는 지연 레지스트리가 존재하지 않기 때문입니다. 이러한 거부도 모든 와이어에서 `undeclared_tool_call` 코드를 유지합니다: + +- **Chat Completions:** 코드를 포함하는 OpenAI 형식 `error` 객체가 담긴 `data:` 프레임을 보낸 뒤, 성공 완료나 `[DONE]` 없이 스트림을 닫습니다. 버퍼링된 JSON은 코드와 함께 HTTP 502를 반환합니다. +- **Messages:** 본문이 `{ "type": "error", "error": { "type": "api_error", "code": "undeclared_tool_call", ... } }`인 Anthropic `event: error` 프레임을 보내며, 성공적인 `message_stop`이나 `[DONE]`은 보내지 않습니다. 버퍼링된 JSON은 코드가 포함된 HTTP 502 `api_error`를 반환합니다. 확정적인 카탈로그 위반은 재시도 가능한 `overloaded_error`(HTTP 529)로 보고되지 않습니다. + +별도로, 모든 인바운드 와이어에서 공급자의 `default.` 네임스페이스 호출은 요청이 선언한 원래 도구 이름으로 정규화됩니다(`default.Read` → `Read`). + ## 엔드포인트 개요 | 클라이언트 표면 | 엔드포인트 | 성공한 비스트리밍 결과 | 성공한 스트리밍 또는 소켓 결과 | diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 34b36fa30d8..0168480b8e6 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -71,6 +71,34 @@ returns 504, and client cancellation returns 499. Bun does not expose the exact cannot currently be forwarded precisely. A successful connection preserves the initial session frames in order. This handshake policy is separate from the Responses WebSocket transport. +## Undeclared tool calls + +On the Responses endpoint, when a request declares a tool catalog, a native passthrough reply +that calls a client-executed tool outside that catalog fails the turn instead of relaying the +call: a `response.failed` event with error code `undeclared_tool_call`, then `data: [DONE]`; +buffered JSON returns HTTP 502 with the same code. The refusal runs before the response can +enter continuation state, and it stands down only for `authMode: "forward"` providers or a +request with no declared catalog. + +The Chat Completions and Messages endpoints honor deferred tool catalogs: those clients may +declare a partial catalog and discover the rest at runtime through their own tool registry, +which — not the translated catalog snapshot — owns the full declaration boundary. A call +outside the declared list is relayed for the client to validate; a call invented against an +explicitly empty `tools: []` is still refused, because no deferred registry exists under an +empty declaration. Those refusals keep the `undeclared_tool_call` code on every wire: + +- **Chat Completions:** a `data:` frame containing an OpenAI-style `error` object carrying the + code, then the stream closes without a success completion or `[DONE]`; buffered JSON returns + HTTP 502 with the code. +- **Messages:** an Anthropic `event: error` frame whose body is + `{ "type": "error", "error": { "type": "api_error", "code": "undeclared_tool_call", ... } }`, + without a successful `message_stop` or `[DONE]`; buffered JSON returns HTTP 502 `api_error` + with the code. A deterministic catalog violation is never reported as a retryable + `overloaded_error` (HTTP 529). + +Separately, on every inbound wire, a provider's `default.`-namespaced call is normalized back +to the bare tool name the request declared (`default.Read` → `Read`). + ## Endpoint overview | Client surface | Endpoint | Successful non-stream result | Successful stream or socket result | diff --git a/docs-site/src/content/docs/ru/reference/proxy-formats.md b/docs-site/src/content/docs/ru/reference/proxy-formats.md index 510b3d6eadf..ad8d9d3f3b8 100644 --- a/docs-site/src/content/docs/ru/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ru/reference/proxy-formats.md @@ -31,6 +31,17 @@ control и safety ответа всё равно происходят на гр На non-combo запросе Responses OpenCodex переписывает такой allowlist-ованный 403 в HTTP 200 Responses со `status: "incomplete"` и `incomplete_details.reason: "content_filter"`. Это работает и на пути адаптера openai-chat, и на openai-responses passthrough (OAuth grok-4.6 / grok-4.5). Поток использует ту же incomplete-границу. Пустой 403 остаётся ошибкой. 403 подписки, кредитов, прав доступа и `not allowed to use this model` остаются ошибками. Combo failover по-прежнему видит исходный HTTP 403. +## Вызовы незаявленных инструментов + +На endpoint'е Responses, если запрос объявляет каталог инструментов, ответ в режиме native passthrough, вызывающий выполняемый клиентом инструмент вне этого каталога, завершает ход ошибкой вместо передачи вызова: событие `response.failed` с кодом `undeclared_tool_call`, затем `data: [DONE]`; буферизованный JSON возвращает HTTP 502 с тем же кодом. Отказ срабатывает до того, как ответ попадёт в состояние продолжения, и отключается только для провайдеров с `authMode: "forward"` или запросов без объявленного каталога. + +Endpoint'ы Chat Completions и Messages поддерживают отложенные каталоги инструментов: их клиенты могут объявить лишь часть каталога и обнаружить остальное во время выполнения через собственный реестр инструментов, который — а не переведённый снимок каталога — владеет полной границей объявления. Вызов вне объявленного списка передаётся клиенту для проверки; вызов против явно пустого `tools: []` по-прежнему отклоняется, ведь под пустым объявлением отложенного реестра не существует. Эти отказы сохраняют код `undeclared_tool_call` на каждом канале: + +- **Chat Completions:** кадр `data:` с объектом `error` в формате OpenAI, несущим код, после чего поток закрывается без успешного завершения и без `[DONE]`; буферизованный JSON возвращает HTTP 502 с кодом. +- **Messages:** кадр Anthropic `event: error` с телом `{ "type": "error", "error": { "type": "api_error", "code": "undeclared_tool_call", ... } }`, без успешного `message_stop` и `[DONE]`; буферизованный JSON возвращает HTTP 502 `api_error` с кодом. Детерминированное нарушение каталога никогда не отображается как повторяемая `overloaded_error` (HTTP 529). + +Отдельно: на каждом входящем канале вызов провайдера в пространстве имён `default.` нормализуется обратно к «голому» имени инструмента из запроса (`default.Read` → `Read`). + ## Обзор endpoint'ов | Клиентская поверхность | Endpoint | Успешный non-stream результат | Успешный результат потока или сокета | diff --git a/docs-site/src/content/docs/tr/reference/proxy-formats.md b/docs-site/src/content/docs/tr/reference/proxy-formats.md index 3178dfae456..6ca40e9fd11 100644 --- a/docs-site/src/content/docs/tr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/tr/reference/proxy-formats.md @@ -33,6 +33,17 @@ Bazı xAI Chat Completions retleri, HTTP 200 ve `finish_reason: content_filter` Kombo olmayan bir Responses isteğinde OpenCodex, izin listesindeki bu 403'ü `status: "incomplete"` ve `incomplete_details.reason: "content_filter"` içeren bir HTTP 200 Responses yanıtına dönüştürür. Dönüştürme openai-chat bağdaştırıcı yolunda ve openai-responses geçişinde (grok-4.6 / grok-4.5 OAuth) çalışır. Akış da aynı incomplete sınırını kullanır. Boş veya yalnızca boşluk içeren 403 gövdeleri hata olarak kalır. Abonelik, kredi, yetki ve `not allowed to use this model` 403'leri hata olarak kalır. Kombo yük devretmesi özgün HTTP 403'ü görmeye devam eder. +## Bildirilmemiş araç çağrıları + +Responses uç noktasında, bir istek araç kataloğu bildirdiğinde, bu kataloğun dışında istemci tarafından çalıştırılan bir aracı çağıran native passthrough yanıtı, çağrıyı aktarmak yerine turu başarısız kılar: `undeclared_tool_call` hata kodlu bir `response.failed` olayı ve ardından `data: [DONE]`; arabelleğe alınmış JSON aynı kodla HTTP 502 döndürür. Ret, yanıt devam durumuna girmeden önce gerçekleşir ve yalnızca `authMode: "forward"` sağlayıcıları veya bildirilmiş kataloğu olmayan istekler için devre dışıdır. + +Chat Completions ve Messages uç noktaları ertelenmiş araç kataloglarını destekler: bu istemciler kataloğun yalnızca bir bölümünü bildirebilir ve kalanını çalışma zamanında kendi araç kayıt defterleri aracılığıyla keşfedebilir — tam bildirim sınırına çevrilmiş katalog anlık görüntüsü değil bu kayıt defteri sahiptir. Bildirilen listenin dışındaki bir çağrı, istemcinin doğrulaması için aktarılır; açıkça boş `tools: []` üzerine kurulan bir çağrı yine de reddedilir, çünkü boş bir bildirimin altında ertelenmiş kayıt defteri bulunmaz. Bu retler de her bağlantıda `undeclared_tool_call` kodunu korur: + +- **Chat Completions:** kodu taşıyan OpenAI biçimli bir `error` nesnesi içeren `data:` çerçevesi gönderilir; ardından başarılı tamamlanma veya `[DONE]` olmadan akış kapatılır. Arabelleğe alınmış JSON kodla birlikte HTTP 502 döndürür. +- **Messages:** gövdesi `{ "type": "error", "error": { "type": "api_error", "code": "undeclared_tool_call", ... } }` olan bir Anthropic `event: error` çerçevesi gönderilir; başarılı `message_stop` veya `[DONE]` gönderilmez. Arabelleğe alınmış JSON kodla birlikte HTTP 502 `api_error` döndürür. Deterministik bir katalog ihlali asla yeniden denenebilir `overloaded_error` (HTTP 529) olarak raporlanmaz. + +Ayrıca, her gelen bağlantıda sağlayıcının `default.` ad alanlı çağrısı, isteğin bildirdiği yalın araç adına normalleştirilir (`default.Read` → `Read`). + ## Uç nokta genel bakışı | İstemci yüzeyi | Uç nokta | Başarılı akışsız sonuç | Başarılı akış veya soket sonucu | diff --git a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md index 91ea424435d..2b8778764f2 100644 --- a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md @@ -29,6 +29,17 @@ Responses 表示是这座桥的中心。原生兼容的路由可以跳过部分 在非 combo 的 Responses 请求上,OpenCodex 会把该 allowlist 中的 403 改写为 HTTP 200 Responses,`status: "incomplete"`,`incomplete_details.reason: "content_filter"`。openai-chat 适配器路径和 openai-responses passthrough(grok-4.6 / grok-4.5 OAuth)都会改写。流式响应使用同一 incomplete 边界。空正文 403 仍是错误。订阅、额度、权限以及 `not allowed to use this model` 的 403 仍是错误。combo 故障切换仍会看到原始 HTTP 403。 +## 未声明的工具调用 + +在 Responses 端点上,当请求声明了工具目录时,原生直通响应若调用目录之外由客户端执行的工具,本轮会失败而不会转发该调用:流式响应发送带有错误代码 `undeclared_tool_call` 的 `response.failed` 事件,然后发送 `data: [DONE]`;缓冲式 JSON 返回 HTTP 502 和相同错误代码。拒绝发生在响应进入续接状态之前。只有 `authMode: "forward"` 提供方或未声明目录的请求才跳过这一拒绝。 + +Chat Completions 和 Messages 端点支持延迟提供的工具目录:客户端可先声明部分目录,再通过自己的工具注册表在运行时发现其余工具;完整声明边界由客户端注册表决定,而不是翻译后的目录快照。目录之外的调用会转发给客户端验证;但针对显式空目录 `tools: []` 的调用仍会被拒绝。此类拒绝在每种协议中都保留 `undeclared_tool_call`: + +- **Chat Completions:**发送包含带有该错误代码的 OpenAI 风格 `error` 对象的 `data:` 帧,然后关闭流,不发送成功完成标记或 `[DONE]`;缓冲式 JSON 返回 HTTP 502 和该代码。 +- **Messages:**发送 Anthropic `event: error` 帧,正文为 `{ "type": "error", "error": { "type": "api_error", "code": "undeclared_tool_call", ... } }`,不发送成功的 `message_stop` 或 `[DONE]`;缓冲式 JSON 返回 HTTP 502 `api_error` 和该代码。确定性的目录违规不会被报告为可重试的 `overloaded_error`(HTTP 529)。 + +此外,在所有入站协议中,提供方返回的 `default.` 命名空间调用都会规范化为客户端声明的裸名称,即使延迟目录规则关闭了上述拒绝检查。 + ## 端点总览 | 客户端表面 | 端点 | 成功的非流式结果 | 成功的流式或套接字结果 | diff --git a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md index 5f1ca4e8a34..f45782de4b3 100644 --- a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md @@ -24,6 +24,17 @@ Responses 表示是橋接的中心。原生相容的路由可跳過部分轉譯 在非 combo 的 Responses 請求上,OpenCodex 會把這種在允許清單中的 403 改寫為 HTTP 200 的 Responses 內容,帶有 `status: "incomplete"` 與 `incomplete_details.reason: "content_filter"`。改寫同時作用於 openai-chat 轉接器路徑與 openai-responses 直通(grok-4.6 / grok-4.5 OAuth)。串流使用相同的 incomplete 邊界。空白的 403 本文仍是錯誤。訂閱、點數、權限與 `not allowed to use this model` 的 403 仍是錯誤。Combo failover 仍看到原始的 HTTP 403。 +## 未宣告的工具呼叫 + +在 Responses 端點上,當請求宣告了工具目錄時,原生直通回應若呼叫目錄之外由用戶端執行的工具,本回合會失敗而不會轉送該呼叫:串流回應傳送帶有錯誤碼 `undeclared_tool_call` 的 `response.failed` 事件,接著傳送 `data: [DONE]`;緩衝式 JSON 傳回 HTTP 502 與相同錯誤碼。拒絕發生在回應進入延續狀態之前。只有 `authMode: "forward"` 供應商或未宣告目錄的請求才略過這項拒絕。 + +Chat Completions 與 Messages 端點支援延後提供的工具目錄:用戶端可先宣告部分目錄,再透過自己的工具登錄表於執行期間探索其餘工具;完整宣告邊界由用戶端登錄表決定,而非轉譯後的目錄快照。目錄之外的呼叫會轉送給用戶端驗證;但針對明確空目錄 `tools: []` 的呼叫仍會被拒絕。這些拒絕在每種協定中都保留 `undeclared_tool_call`: + +- **Chat Completions:**傳送包含帶有該錯誤碼的 OpenAI 格式 `error` 物件的 `data:` frame,然後關閉串流,不傳送成功完成訊號或 `[DONE]`;緩衝式 JSON 傳回 HTTP 502 與該錯誤碼。 +- **Messages:**傳送 Anthropic `event: error` frame,內文為 `{ "type": "error", "error": { "type": "api_error", "code": "undeclared_tool_call", ... } }`,不傳送成功的 `message_stop` 或 `[DONE]`;緩衝式 JSON 傳回 HTTP 502 `api_error` 與該錯誤碼。確定性的目錄違規不會被回報為可重試的 `overloaded_error`(HTTP 529)。 + +此外,在所有輸入協定中,供應商回傳的 `default.` 命名空間呼叫都會正規化為用戶端宣告的裸名稱,即使延後目錄規則停用了上述拒絕檢查。 + ## 端點概覽 | 客戶端介面 | 端點 | 成功的非串流結果 | 成功的串流或 socket 結果 | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4c08a826c59..1f183b1fd60 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicit": { - "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", + "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", "anthropic-revoked-token.test.ts": "adapters/anthropic", "messages-revoked-token.test.ts": "claude-integration", diff --git a/src/bridge/errors.ts b/src/bridge/errors.ts index c9cb6a2a9c7..4c464e238d3 100644 --- a/src/bridge/errors.ts +++ b/src/bridge/errors.ts @@ -13,6 +13,7 @@ import { CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, SPEND_LEDGER_STORAGE_UNAVAILABLE_CODE, + UNDECLARED_TOOL_CALL_ERROR_CODE, type OcxErrorPayload, } from "../lib/errors"; @@ -31,6 +32,10 @@ export function formatErrorResponse( error.code = CYBER_POLICY_ERROR_CODE; error.type = cyberPolicyErrorType(type); } + // The undeclared-tool refusal is deterministic, not a transient upstream failure: its code + // survives so the Anthropic translator can classify it instead of folding it into a + // retryable overload (and so clients see the SSE path's rejection identity). + if (options?.code === UNDECLARED_TOOL_CALL_ERROR_CODE) error.code = options.code; // Only the allowlisted transport verdicts survive this formatter. Do not forward // arbitrary provider codes, and preserve the existing cyber-policy precedence. const replayBlocked = error.code !== CYBER_POLICY_ERROR_CODE diff --git a/src/chat/outbound.ts b/src/chat/outbound.ts index 8e1b684861b..d5da058caa8 100644 --- a/src/chat/outbound.ts +++ b/src/chat/outbound.ts @@ -19,6 +19,7 @@ import { CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, isCyberPolicyMessage, + UNDECLARED_TOOL_CALL_ERROR_CODE, } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; import { createSseBlockBuffer, sseDataPayload } from "../server/sse-payload-rewrite"; @@ -196,6 +197,10 @@ export function chatCompletionsStreamErrorPayload( } else if (isCyberPolicyCode(details?.code) || classified.code === CYBER_POLICY_ERROR_CODE) { classified.code = CYBER_POLICY_ERROR_CODE; classified.type = cyberPolicyErrorType(details?.type); + } else if (details?.code === UNDECLARED_TOOL_CALL_ERROR_CODE) { + // The guard's deterministic refusal keeps its wire identity past classifyError's + // generic 5xx remap — same code the buffered Chat error and the SSE frame carry. + classified.code = UNDECLARED_TOOL_CALL_ERROR_CODE; } else if (details?.code !== undefined && details.code !== null && !classified.code) { classified.code = details.code; } diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index 60924c92def..a4e0ef39843 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -11,7 +11,7 @@ * - errors: {type:"error", error:{type,message}}; may arrive mid-stream after HTTP 200. */ import { createHash } from "node:crypto"; -import { httpStatusFromTerminalError } from "../lib/errors"; +import { httpStatusFromTerminalError, UNDECLARED_TOOL_CALL_ERROR_CODE } from "../lib/errors"; import { isTransientUpstreamStatus } from "../lib/upstream-retry"; import { isTranslatorBudgetExceededError, @@ -804,11 +804,14 @@ export function responsesSseToAnthropicSse( // Unclassified status-absent response.failed (relaySseWithFailedTail synthetic // tail) still lands on a transient 5xx here — the mid-stream reset shape maps to // overloaded_error by design. + // The undeclared-tool refusal is deterministic and proxy-owned, not an upstream + // condition: keep its api_error type and code instead of a retryable overload. + const deterministic = code === UNDECLARED_TOOL_CALL_ERROR_CODE; fail( status, message, - true, - code === "context_length_exceeded" ? code : undefined, + !deterministic, + deterministic || code === "context_length_exceeded" ? code : undefined, ); break; } diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 43936634154..9de95942dfe 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -18,6 +18,13 @@ export const ENCRYPTED_FUNCTION_OUTPUT_REJECTION = */ export const SEND_BUDGET_EXHAUSTED_CODE = "request_send_budget_exhausted"; export const SPEND_LEDGER_STORAGE_UNAVAILABLE_CODE = "spend_ledger_storage_unavailable"; +/** + * Wire code for the undeclared-tool guard's deterministic refusal. Endpoint translators + * read it to keep the refusal non-retryable instead of folding it into the transient + * upstream-error class. Declared here rather than in the guard module because the shared + * envelope formatter decides whether the identity survives serialization. + */ +export const UNDECLARED_TOOL_CALL_ERROR_CODE = "undeclared_tool_call"; /** First nonblank string across the canonical upstream error paths, in priority order. */ export function upstreamErrorMessageFromPayload(payload: unknown): string | undefined { diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 2c561039bab..7dba21bce9f 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -19,7 +19,7 @@ import { responsesJsonToChatCompletion, responsesSseToChatCompletionsSse, } from "../chat/outbound"; -import { classifyError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors"; +import { classifyError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, UNDECLARED_TOOL_CALL_ERROR_CODE } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; import { resolveClientRetryAfter } from "../lib/retry-after"; import { @@ -455,6 +455,10 @@ async function handleChatCompletionsWithBudget( abortSignal: req.signal, // Body is Responses-shaped by now, but the client spoke Chat Completions. inboundWire: "chat", + // A Chat caller may send an explicit empty `tools` array — a deny-all declaration the + // translation drops along with the whole catalog. Carried separately so the + // undeclared-tool guard can still enforce it; absent/partial catalogs stay deferred. + clientDeclaredEmptyToolCatalog: Array.isArray(chatBody.tools) && chatBody.tools.length === 0, ...((settledRoute?.combo || settledRoute?.routeKind === "policy") && droidDefaultEffort ? { droidDefaultEffort } : {}), @@ -543,6 +547,10 @@ async function handleChatCompletionsWithBudget( // Structured model_not_found must win over classifyError's generic remaps. classified.code = "model_not_found"; classified.type = "invalid_request_error"; + } else if (upstreamCode === UNDECLARED_TOOL_CALL_ERROR_CODE) { + // The guard's deterministic refusal keeps its wire identity — same code the + // streamed `response.failed` frame and the Anthropic surface carry. + classified.code = UNDECLARED_TOOL_CALL_ERROR_CODE; } else if (upstreamCode !== undefined && upstreamCode !== null && classified.code == null) { classified.code = upstreamCode; } diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index e9787d79b82..878ba151003 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -40,6 +40,7 @@ import { REPLAY_REFUSED_STATUS, UPSTREAM_RESET_REPLAY_REFUSED_CODE, } from "../lib/upstream-retry"; +import { UNDECLARED_TOOL_CALL_ERROR_CODE } from "../lib/errors"; import { resolveClientRetryAfter } from "../lib/retry-after"; import { anthropicRateLimitHeaders } from "./anthropic-rate-limit-headers"; import { upstreamMessagesRequestIdHeaders } from "./messages-response-headers"; @@ -1348,6 +1349,12 @@ async function handleClaudeMessagesWithBudget( // Without this the replay would look native and a Responses-scoped wire default // would fire, disagreeing with the pre-flight decision above. inboundWire: "anthropic", + // An explicit empty `tools` array is a deny-all declaration the translation drops with + // the rest of the catalog; carried separately so the undeclared-tool guard can still + // enforce it while partial and absent catalogs stay deferred. + clientDeclaredEmptyToolCatalog: isRec(anthropicBody) + && Array.isArray(anthropicBody.tools) + && anthropicBody.tools.length === 0, nativeReasoningReplay, nativeReasoningMint, claudeGoAffinity: { sessionLane: claudeGoSessionLane }, @@ -1374,11 +1381,13 @@ async function handleClaudeMessagesWithBudget( // Re-shape the OpenAI-style error envelope into the Anthropic one, preserving status. let message = `upstream error (${response.status})`; let contextError = false; + let catalogRefusal = false; try { const text = await response.text(); try { const parsed = JSON.parse(text) as { error?: { message?: string; type?: string; code?: unknown } | string; message?: string }; contextError = typeof parsed?.error === "object" && parsed.error?.code === "context_length_exceeded"; + catalogRefusal = typeof parsed?.error === "object" && parsed.error?.code === UNDECLARED_TOOL_CALL_ERROR_CODE; const nested = typeof parsed?.error === "object" && parsed.error ? parsed.error.message : undefined; const flat = typeof parsed?.error === "string" ? parsed.error : parsed?.message; message = nested || flat || (text ? `upstream error (${response.status}): ${text.slice(0, 400)}` : message); @@ -1407,7 +1416,10 @@ async function handleClaudeMessagesWithBudget( const nativeMainFence = response.status === 503 && upstreamRetryAfter?.trim() === "1" && message === CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE; - const transient = !replayRefusal && !nativeMainFence && !contextError && isTransientUpstreamStatus(response.status); + // The undeclared-tool guard's refusal is deterministic: the same request produces the + // same verdict, so it must not arrive as a retryable overload like a transient 5xx. + const transient = !replayRefusal && !nativeMainFence && !contextError && !catalogRefusal + && isTransientUpstreamStatus(response.status); const outStatus = replayRefusal ? REPLAY_REFUSED_STATUS : nativeMainFence ? 503 : contextError ? 400 : transient ? 529 : response.status; @@ -1419,7 +1431,11 @@ async function handleClaudeMessagesWithBudget( outStatus, message, undefined, - replayRefusal ? UPSTREAM_RESET_REPLAY_REFUSED_CODE : contextError ? "context_length_exceeded" : undefined, + replayRefusal + ? UPSTREAM_RESET_REPLAY_REFUSED_CODE + : contextError + ? "context_length_exceeded" + : catalogRefusal ? UNDECLARED_TOOL_CALL_ERROR_CODE : undefined, )), { status: outStatus, headers: outHeaders, diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index 905454044af..e0a8b7029ad 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -1,3 +1,4 @@ +import { UNDECLARED_TOOL_CALL_ERROR_CODE } from "../lib/errors"; import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous, @@ -78,7 +79,7 @@ const NAMELESS_TOOL_SPEC_TYPES = new Set([ /** An upstream-supplied name reaches the error message; keep it bounded. */ const MAX_REPORTED_NAME_CHARS = 100; -export const UNDECLARED_TOOL_CALL_ERROR_CODE = "undeclared_tool_call"; +export { UNDECLARED_TOOL_CALL_ERROR_CODE }; function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); @@ -370,6 +371,24 @@ export function hasExplicitWireToolCatalog(body: unknown): boolean { ); } +/** + * Whether the caller declared an explicitly EMPTY catalog — `tools: []` or an + * `additional_tools` item with `tools: []`. Unlike an absent or partial catalog this is an + * unambiguous deny-all: no deferred registry can exist under it, so even deferred wires + * enforce it. A hosted-only catalog is NOT empty — it still defers like an absent one. + */ +export function hasExplicitEmptyToolCatalog(body: unknown): boolean { + if (!isPlainObject(body)) return false; + if (Array.isArray(body.tools) && body.tools.length === 0) return true; + if (!Array.isArray(body.input)) return false; + return body.input.some(item => + isPlainObject(item) + && item.type === "additional_tools" + && Array.isArray(item.tools) + && item.tools.length === 0 + ); +} + /** * Evaluates whether an individual output item represents an undeclared tool call. * @@ -703,7 +722,6 @@ function failedBlocks(name: string, newline: string): readonly string[] { * @param providerExecutedCallTypes - Call types executed by the provider. * @param declaredBare - Explicitly declared bare tool names without namespace provenance. * @param declaredCustom - Current bare custom declarations eligible for code-mode recovery. - * @param enforceDeclaredToolNames - Refuse undeclared calls; normalization remains active when false. * @returns An SSE block rewrite function. */ export function createUndeclaredToolCallGuardBlockRewrite( @@ -712,8 +730,11 @@ export function createUndeclaredToolCallGuardBlockRewrite( providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, declaredBare?: ReadonlySet, declaredCustom?: ReadonlySet, - enforceDeclaredToolNames = true, + options?: { refuseUndeclared?: boolean }, ): SseBlockRewrite { + // Deferred-catalog wires (Chat/Anthropic) keep the default-namespace normalization — the + // client can only resolve the bare name it declared — while the refusal itself stays off. + const refuse = options?.refuseUndeclared !== false; let tripped = false; return (block: string) => { if (tripped) return []; @@ -725,12 +746,12 @@ export function createUndeclaredToolCallGuardBlockRewrite( } catch { return [block]; } - const name = enforceDeclaredToolNames - ? undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare, declaredCustom) - : undefined; - if (name !== undefined) { - tripped = true; - return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n"); + if (refuse) { + const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare, declaredCustom); + if (name !== undefined) { + tripped = true; + return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n"); + } } const normalized = normalizeDefaultNamespaceInPayload(parsed, declared, declaredBare); if (normalized.changed) { diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index f64c681d50a..10f14a267a8 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -137,6 +137,13 @@ export interface HandleResponsesOptions { * it. Omitted means a genuine Responses inbound. */ inboundWire?: InboundWire; + /** + * Set by a translating ingress (Chat / Anthropic) when the client sent an explicit empty + * tool catalog. Those wires may defer tool delivery, so their translated body carries no + * readable catalog boundary; an explicit `[]` is still a deny-all declaration and the + * undeclared-tool guard enforces it. Absent catalogs and partial catalogs stay deferred. + */ + clientDeclaredEmptyToolCatalog?: boolean; nativeReasoningReplay?: ReadonlyMap; nativeReasoningMint?: { owner?: NativeReasoningOwner }; /** Droid's per-request effort default; each concrete combo or policy target applies it only if its ladder allows it. */ diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index 518b32fb1b4..25d4531c1ac 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -108,6 +108,7 @@ import { createResponsesFunctionToolRepairBlockRewrite } from "../responses-func import { createUndeclaredToolCallGuardBlockRewrite, currentTurnWireToolCatalogBody, + UNDECLARED_TOOL_CALL_ERROR_CODE, undeclaredToolCallNameInResponse, undeclaredToolCallMessage, normalizeDefaultNamespaceInJson, @@ -437,8 +438,7 @@ export async function deliverPassthroughResponse( normalizeFunctionCompletionJson, } = nativeExchange; const { commitReasoningReplayServingRoute, recordTerminalOutcomes } = responseEffects; - const { parsed, subagentQuotaFailureModel, clientRequestedStream, translatorBudget, inboundWire } = requestState; - const enforceDeclaredToolNames = inboundWire !== "chat" && inboundWire !== "anthropic"; + const { parsed, subagentQuotaFailureModel, clientRequestedStream, translatorBudget } = requestState; const { openAiSidecar } = sidecarState; const { requestBindings } = transportState; @@ -857,14 +857,17 @@ export async function deliverPassthroughResponse( : undefined, // Last: every rewrite above can still rename or reshape a call item, so the guard must // compare the names the client will actually receive against the declared catalog. - nativeExchange.undeclaredToolGuardActive + // Deferred-catalog wires keep the rewrite for its normalization half only: a provider's + // `default.X` call still collapses to the declared `X`, but an undeclared name is the + // client's own deferred registry's business, not this proxy's verdict. + nativeExchange.undeclaredToolGuardActive || declaredWireToolNames.size > 0 ? createUndeclaredToolCallGuardBlockRewrite( declaredWireToolNames, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBareWireToolNames, recoverableBareCustomWireToolNames, - enforceDeclaredToolNames, + { refuseUndeclared: nativeExchange.undeclaredToolGuardActive }, ) : undefined, grokUpstreamEchoEnabled @@ -1322,10 +1325,7 @@ export async function deliverPassthroughResponse( if (grokUpstreamEchoEnabled) { clientJson = stripGrokUpstreamEnvelopeEchoFromResponsesJson(clientJson); } - // #1700: same inbound-wire refusal policy as the SSE relay above. Both the plain JSON answer and - // the reframed-SSE branch below are built from this body, so one check covers them. This - // runs BEFORE the continuation cache write below: a refused turn must not become state a - // later `previous_response_id` replay can expand from. + // Both response formats use this body, before a refused turn can enter continuation state. if (nativeExchange.undeclaredToolGuardActive) { const undeclared = (() => { try { @@ -1341,9 +1341,15 @@ export async function deliverPassthroughResponse( return undefined; } })(); - if (enforceDeclaredToolNames && undeclared !== undefined) { - return formatErrorResponse(502, "upstream_error", undeclaredToolCallMessage(undeclared)); + if (undeclared !== undefined) { + return formatErrorResponse(502, "upstream_error", undeclaredToolCallMessage(undeclared), { + code: UNDECLARED_TOOL_CALL_ERROR_CODE, + }); } + } + // Same split as the SSE rewrite: normalization applies whenever a declared name exists, + // even on wires whose deferred catalog stands the refusal down. + if (declaredWireToolNames.size > 0) { clientJson = normalizeDefaultNamespaceInJson( clientJson, declaredWireToolNames, diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index 33d80678572..58a1d8be395 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -21,6 +21,7 @@ import { imageGenToolCallAliases } from "../responses-image-gen-repair"; import { rememberResponseState, isBodyNonPersistable } from "../../responses/state"; import { currentTurnWireToolCatalogBody, + hasExplicitEmptyToolCatalog, hasExplicitWireToolCatalog, collectDeclaredWireToolNames, collectDeclaredBareWireToolNames, @@ -187,6 +188,7 @@ export async function preparePassthroughExchange( | "selectedForwardHeaders" | "clientRequestedStream" | "inboundWire" + | "clientDeclaredEmptyToolCatalog" | "substituteMainCredential" | "callerAuthHeaders" | "subagentFallbackAccountId" @@ -253,6 +255,7 @@ export async function preparePassthroughExchange( responseStateOptions, clientRequestedStream, inboundWire, + clientDeclaredEmptyToolCatalog, substituteMainCredential, callerAuthHeaders, } = requestState; @@ -531,10 +534,18 @@ export async function preparePassthroughExchange( declaredWireToolNames.add(name); } } + // Chat and Anthropic inbounds may deliver tools deferred/partially: their client registry + // owns the full catalog, so this guard cannot read the translated body as the authorization + // boundary. An explicit empty catalog — flagged by the translating ingress or present + // verbatim in a replayed body — is still a deny-all the caller declared; everything else + // stays deferred to the caller's own tool handling. undeclaredToolGuardActive = ( - declaredWireToolNames.size > 0 - || clientDeclaredNamelessCallTypes.size > 0 - || clientExplicitWireToolCatalog + inboundWire === "responses" + ? declaredWireToolNames.size > 0 + || clientDeclaredNamelessCallTypes.size > 0 + || clientExplicitWireToolCatalog + : clientDeclaredEmptyToolCatalog + || hasExplicitEmptyToolCatalog(clientToolAuthorizationBody) ) && route.provider.authMode !== "forward"; options.nativeControl?.configureToolAuthorization?.( undeclaredToolGuardActive, diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 76d3d000c9d..93768abc700 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -1452,6 +1452,7 @@ export async function prepareResponsesRequest( commitSkillsSnapshot?.(); return { inboundWire, + clientDeclaredEmptyToolCatalog: options.clientDeclaredEmptyToolCatalog === true, translatorBudget, parsed, toolBridgeMaps, diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index edd5721ac7e..248d0e4b974 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -293,34 +293,34 @@ There is no rule that strips whatever precedes the first dot: a legitimate tool one in another provider's vocabulary, and a replayed item names a call that already happened, which is the worst place to guess. -Membership enforcement is that flag, `enforceDeclaredToolNames`, and only the `responses` inbound -wire enforces. Explicit enforcement with no declared catalog also refuses client tool calls rather -than treating the missing set as permission. A routed provider that names a tool the request never declared ends the turn there: +Membership enforcement refuses a routed provider that names a tool the request never declared: `src/bridge/sse.ts` emits `response.failed` and `src/bridge/response-json.ts` returns a failed response, both carrying `undeclared client tool`. That is the #1700 contract and it stands. Codex executes a top-level tool call, so a hallucinated `apply_patch` — which under code mode exists only as a nested `tools.apply_patch(...)` helper inside `exec` — is refused before it reaches the runtime, where it previously surfaced as a bare `aborted` with the file untouched. -The `chat` and `anthropic` inbound wires relay the call instead. This is a deliberate reversal of -#1700's scope for those two wires, not an oversight. Both vendor specs make the client's own runner -responsible for validating a tool call and then executing or denying it, and harnesses on those -endpoints defer part of their catalog to conserve prompt tokens and discover the rest at runtime. -Enforcing membership against a partial catalog killed those streams mid-turn with a 502 and cost the -caller the whole turn. This proxy executes no tool call on any wire, so scoping enforcement off -these two moves the decision to the party that already makes it rather than removing it. - -An explicitly empty catalog still authorizes nothing on the wire that enforces. A request declaring -an empty tool list is making a statement rather than omitting one, which is how the passthrough -guard reads it through `clientExplicitWireToolCatalog` in -`src/server/responses/passthrough-dispatch.ts`. - -The passthrough SSE/JSON refusal is wire-scoped too: only `responses` rejects undeclared calls. -`undeclaredToolGuardActive` still gates namespace normalization and continuation suppression on every wire, -standing down only for `authMode: "forward"` and requests with no catalog; relayed undeclared calls are not remembered. - -`src/server/responses/run-turn-execution.ts`, `src/server/responses/adapter-delivery.ts`, and -`src/server/responses/passthrough-delivery.ts` scope membership refusal from `inboundWire` for SSE and JSON alike. +On the native passthrough lane the refusal matches the translated delivery scope: +`src/server/responses/run-turn-execution.ts`, `src/server/responses/adapter-delivery.ts` and +the native guard in `src/server/responses/passthrough-dispatch.ts` all derive enforcement from +`inboundWire`, so only `responses` refuses an undeclared client-executed call — in SSE and +buffered JSON alike, before continuation state is stored. `chat` and `anthropic` inbound +replies relay the call instead: those harnesses may defer part of their catalog to conserve +prompt tokens and discover the rest at runtime, and both vendor specs leave the client's own +runner responsible for validating it — membership enforcement against a partial catalog would +kill those streams mid-turn for a call the client could execute. + +An explicitly empty catalog authorizes nothing wherever enforcement runs — deferred wires +included: `tools: []` is a statement, not an omission, and no deferred registry exists under +it. The ingresses carry the fact as `clientDeclaredEmptyToolCatalog` +(`src/server/chat-completions.ts`, `src/server/claude-messages.ts`) since translation drops an +empty catalog with the rest; `hasExplicitEmptyToolCatalog` reads a verbatim `tools: []` or +`additional_tools: []` too. Refusals keep `undeclared_tool_call` across the buffered envelope +and endpoint projections, so Messages sees a deterministic `api_error`, never `overloaded_error`. + +`undeclaredToolGuardActive` gates refusal and continuation suppression, standing down only for +`authMode: "forward"`, deferred catalogs, and absent ones; a refused turn never enters +continuation state. `default.` normalization is scoped separately. ## Selection outlives the declaration check diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 723d5173da7..2e95b879738 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -14,7 +14,7 @@ When a successful streamed native response has a missing or unrecognized non-JSO HTTP ingress applies the [caller header contract](../data-planes/inbound-compat.md#http-caller-conversation-identity) before admission and dispatch. Optional Codex memory selection enters `src/server/responses/request-prepare.ts` for HTTP and WebSocket frames, as specified in [memory phase routing](responses-failover.md#memory-phase-routing); an unset phase keeps its existing route and starts no background work. Responses request preparation stabilizes incoming `` under `skills.catalog_refresh`: `per_session` (default) reuses the first received catalog for a conversation; `per_turn` leaves the supplied catalog unchanged. Other instruction sections and user/tool content remain untouched. Requests without a reliable conversation identity bypass snapshots; shared prompt-cache cohorts are not conversation identities. Only a body with exactly one catalog block across its instructions and developer/system content takes part; two or more pass through unchanged. A known snapshot is substituted before parsing, but a new catalog is stored only when preparation reaches its success return, so a request rejected by parsing or admission pins nothing. Without a named principal, snapshots are shared by conversation id only on a server that requires no data-plane auth. Snapshots are process-local, expire after four idle hours, and use bounded LRU retention; oversized blocks bypass caching. The dashboard's `src/codex/prompt-layers.ts` and `src/codex/prompt-text-probe.ts` continue observing current files for previews and do not own session snapshots. `/v1/responses` is the main Codex-facing endpoint. The server parses Responses input, routes to a provider, lets the selected adapter speak the upstream protocol, then bridges adapter events back to -Responses-compatible streaming output. The routed provider name follows every adapter build, including retries and continuations; only `github-copilot` injects a configured `modelContextTiers` value as upstream `contextTier`, while unconfigured passthrough retains the caller field. For an opted-in key-auth provider, a hosted-search continuation stays bound to the API-key selection that served the first leg; the contract is the [hosted-search continuation binding](../providers-and-adapters.md#hosted-search-continuation-binding). +Responses-compatible streaming output. The routed provider name follows every adapter build, including retries and continuations; only `github-copilot` injects a configured `modelContextTiers` value as upstream `contextTier`, while unconfigured passthrough retains the caller field. For an opted-in key-auth provider, a hosted-search continuation stays bound to the API-key selection that served the first leg; the contract is the [hosted-search continuation binding](../providers-and-adapters.md#hosted-search-continuation-binding). The native passthrough undeclared-tool guard fails closed on the Responses inbound wire in SSE and buffered JSON before continuation state is stored; Chat and Anthropic inbounds defer to the caller's own registry except an explicit empty catalog, which stays deny-all. The `openai-responses` adapter preserves caller `User-Agent` unless provider headers own it, in either auth mode. `forwardClientHeaders` additionally opts into `originator`, `x-client-request-id`, `x-codex-app-version`, or `user-agent`. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0015ed71b41..0bba7113104 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -10,6 +10,7 @@ "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", + "responses-undeclared-tool-guard-inbound.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", "anthropic-revoked-token.test.ts": "adapters/anthropic", "messages-revoked-token.test.ts": "claude-integration", diff --git a/tests/responses/passthrough-deferred-tools.test.ts b/tests/responses/passthrough-deferred-tools.test.ts index 777737dc4f1..3e28ebb6b4e 100644 --- a/tests/responses/passthrough-deferred-tools.test.ts +++ b/tests/responses/passthrough-deferred-tools.test.ts @@ -153,3 +153,103 @@ describe("Responses passthrough deferred tool delivery by inbound wire", () => { } } }); + +// An explicit empty catalog is still a caller declaration — deny-all — even on wires whose +// absent or partial catalogs may be completed later by the client's own tool registry. +// The refusal must stay a deterministic catalog violation, never a retryable overload. +describe("explicit empty tool catalog is deny-all on deferred wires", () => { + for (const wire of ["chat", "anthropic"] as const) { + for (const stream of [true, false]) { + test(`${wire} ${stream ? "stream" : "buffered"}: tools:[] refuses an upstream Write`, async () => { + const upstream = mockResponsesUpstream("Write", stream); + saveConfig({ + port: 0, defaultProvider: "mock", + providers: { mock: { + adapter: "openai-responses", apiKey: "mock-key", authMode: "key", + baseUrl: `${upstream.server.url.toString().replace(/\/$/, "")}/v1`, allowPrivateNetwork: true, + } }, + } as OcxConfig); + const server = startServer(0); + try { + const body = wire === "anthropic" + ? { model: "mock/test-model", stream, max_tokens: 128, + messages: [{ role: "user", content: "write to todo" }], tools: [] } + : { model: "mock/test-model", stream, + messages: [{ role: "user", content: "write to todo" }], tools: [] }; + const result = await fetch(new URL(endpoints[wire], server.url), { + method: "POST", headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" }, + body: JSON.stringify(body), + }); + const text = await result.text(); + expect(upstream.seen).toHaveLength(1); + expect(text).toContain("undeclared client tool"); + expect(text).toContain("Write"); + if (wire === "anthropic") { + // Deterministic refusal: api_error identity, never a retryable overload. + expect(text).not.toContain("overloaded_error"); + expect(text).toContain("undeclared_tool_call"); + if (stream) { + expect(result.status).toBe(200); + expect(text).toContain("api_error"); + } else { + expect(result.status).toBe(502); + expect(JSON.parse(text).error.type).toBe("api_error"); + } + } else { + expect(text).toContain("undeclared_tool_call"); + if (stream) expect(result.status).toBe(200); + else expect(result.status).toBe(502); + } + } finally { + await server.stop(true); + upstream.server.stop(true); + } + }); + } + } +}); + +// Tool-search style discovery lands the provider's expanded catalog back in the request as +// `additional_tools`/`tool_search_output` input items. Those are THIS turn's declarations, so +// a call they name is registered, not invented — the refusal is only for a catalog that never +// authorized the name. +describe("registered deferred catalog input items authorize calls", () => { + for (const itemType of ["additional_tools", "tool_search_output"] as const) { + for (const stream of [true, false]) { + test(`responses ${stream ? "stream" : "buffered"}: Write declared via ${itemType}`, async () => { + const upstream = mockResponsesUpstream("Write", stream); + saveConfig({ + port: 0, defaultProvider: "mock", + providers: { mock: { + adapter: "openai-responses", apiKey: "mock-key", authMode: "key", + baseUrl: `${upstream.server.url.toString().replace(/\/$/, "")}/v1`, allowPrivateNetwork: true, + } }, + } as OcxConfig); + const server = startServer(0); + try { + const result = await fetch(new URL(endpoints.responses, server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "mock/test-model", stream, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "write to todo" }] }, + { type: itemType, tools: [{ type: "function", name: "Write", description: "Write a file", parameters: { type: "object", properties: { path: { type: "string" } } } }] }, + ], + tools: [{ type: "function", name: "Read", description: "Read a file", parameters: { type: "object", properties: { path: { type: "string" } } } }], + }), + }); + const text = await result.text(); + expect(upstream.seen).toHaveLength(1); + expect(result.status).toBe(200); + expect(text).not.toContain("undeclared client tool"); + expect(text).not.toContain('"error"'); + if (stream) expect(text).toContain("response.completed"); + else expect(JSON.parse(text).output[0].name).toBe("Write"); + } finally { + await server.stop(true); + upstream.server.stop(true); + } + }); + } + } +}); diff --git a/tests/responses/responses-undeclared-tool-guard-inbound.test.ts b/tests/responses/responses-undeclared-tool-guard-inbound.test.ts new file mode 100644 index 00000000000..20ca5059717 --- /dev/null +++ b/tests/responses/responses-undeclared-tool-guard-inbound.test.ts @@ -0,0 +1,246 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { handleResponses } from "../../src/server/responses"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import { handleClaudeMessages } from "../../src/server/claude-messages"; +import { UNDECLARED_TOOL_CALL_ERROR_CODE } from "../../src/server/responses-undeclared-tool-guard"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; + +let releaseSpendHome: (() => void) | undefined; +const takeSpendHome = (): void => { releaseSpendHome ??= acquireOwnedSpendHome(); }; +afterEach(() => { releaseSpendHome?.(); releaseSpendHome = undefined; }); + +function frame(type: string, payload: Record): string { + return `event: ${type}\ndata: ${JSON.stringify({ type, ...payload })}`; +} + +function readFrames(body: string) { + return body.trim().split(/\r?\n\r?\n/).filter(Boolean).map(block => { + const lines = block.split(/\r?\n/); + const data = lines.filter(line => line.startsWith("data:")).map(line => line.slice(5).trimStart()).join("\n"); + const event = lines.find(line => line.startsWith("event:"))?.slice(6).trim(); + const payload = data && data !== "[DONE]" ? JSON.parse(data) as Record : undefined; + return { event, data, payload }; + }); +} + +describe("native passthrough inbound tool catalogs", () => { + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + + const call = { + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "apply_patch", + arguments: "{}", + status: "completed", + }; + + async function post( + stream: boolean, + tools: unknown[] | undefined, + upstream: () => Response, + additionalTools?: unknown[], + requestConfig: OcxConfig = config, + history: unknown[] = [], + model = "fixture/deepseek-v4-flash", + previousResponseId?: string, + toolChoice?: unknown, + inboundWire?: "chat" | "anthropic", + ) { + const savedFetch = globalThis.fetch; + globalThis.fetch = (async () => upstream()) as typeof fetch; + try { + takeSpendHome(); + return await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model, + stream, + ...(previousResponseId ? { previous_response_id: previousResponseId } : {}), + input: [ + ...history, + { role: "user", content: [{ type: "input_text", text: "hi" }] }, + ...(additionalTools === undefined + ? [] + : [{ type: "additional_tools", role: "developer", tools: additionalTools }]), + ], + ...(tools === undefined ? {} : { tools }), + ...(toolChoice === undefined ? {} : { tool_choice: toolChoice }), + }), + }), requestConfig, { model: "", provider: "" }, { inboundWire }); + } finally { + globalThis.fetch = savedFetch; + } + } + + for (const inboundWire of ["chat", "anthropic"] as const) { + for (const stream of [false, true]) { + // Deferred-catalog wires relay an undeclared client call: the caller's own tool + // registry owns the full declaration boundary, not the partial translated catalog. + test(`${inboundWire} passthrough relays an undeclared Write call (${stream ? "streaming" : "JSON"})`, async () => { + const writeCall = { ...call, name: "Write" }; + const upstream = stream + ? () => new Response([ + frame("response.output_item.added", { output_index: 0, item: writeCall }), + frame("response.completed", { response: { id: "resp_1", status: "completed", output: [writeCall] } }), + ].join("\n\n") + "\n\n", { headers: { "content-type": "text/event-stream" } }) + : () => new Response( + JSON.stringify({ id: "resp_1", status: "completed", output: [writeCall] }), + { headers: { "content-type": "application/json" } }, + ); + const response = await post( + stream, + [{ type: "function", name: "Read", parameters: { type: "object" } }], + upstream, + undefined, + config, + [], + "fixture/deepseek-v4-flash", + undefined, + undefined, + inboundWire, + ); + + if (stream) { + const body = await response.text(); + const frames = readFrames(body); + expect(frames.some(item => item.payload?.type === "response.failed")).toBe(false); + expect(frames.some(item => item.payload?.type === "response.completed")).toBe(true); + } else { + expect(response.status).toBe(200); + expect(await response.text()).not.toContain("undeclared"); + } + }); + + // An explicit empty catalog is still a deny-all the caller declared; it is the one + // case a deferred wire cannot leave to a fuller client registry. + test(`${inboundWire} passthrough refuses undeclared Write under tools:[] (${stream ? "streaming" : "JSON"})`, async () => { + const writeCall = { ...call, name: "Write" }; + const upstream = stream + ? () => new Response([ + frame("response.output_item.added", { output_index: 0, item: writeCall }), + frame("response.completed", { response: { id: "resp_1", status: "completed", output: [writeCall] } }), + ].join("\n\n") + "\n\n", { headers: { "content-type": "text/event-stream" } }) + : () => new Response( + JSON.stringify({ id: "resp_1", status: "completed", output: [writeCall] }), + { headers: { "content-type": "application/json" } }, + ); + const response = await post( + stream, + [], + upstream, + undefined, + config, + [], + "fixture/deepseek-v4-flash", + undefined, + undefined, + inboundWire, + ); + + if (stream) { + // This direct internal call still returns Responses wire; endpoint projections are checked below. + const body = await response.text(); + expect(body).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + const frames = readFrames(body); + expect(frames.some(item => item.payload?.type === "response.failed")).toBe(true); + expect(frames.some(item => item.payload?.type === "response.completed")).toBe(false); + } else { + expect(response.status).toBe(502); + expect(await response.text()).toContain("undeclared"); + } + }); + } + } + + for (const surface of ["responses", "chat", "anthropic"] as const) { + test(`${surface} endpoint refuses the undeclared call in its own wire without a successful terminal`, async () => { + const savedFetch = globalThis.fetch; + let upstreamRequests = 0; + globalThis.fetch = (async () => { + upstreamRequests++; + const writeCall = { ...call, name: "Write" }; + return new Response([ + frame("response.output_item.added", { output_index: 0, item: writeCall }), + frame("response.completed", { response: { id: "resp_wire_refusal", status: "completed", output: [writeCall] } }), + ].join("\n\n") + "\n\n", { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + try { + takeSpendHome(); + const model = "fixture/deepseek-v4-flash"; + const schema = { type: "object" }; + // Deferred wires only fail closed on an explicit empty catalog; the Responses wire + // fails closed on any catalog the call's name is absent from. + const input = surface === "responses" + ? { input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }], + tools: [{ type: "function", name: "Read", parameters: schema }] } + : surface === "chat" + ? { messages: [{ role: "user", content: "hi" }], tools: [] } + : { max_tokens: 64, messages: [{ role: "user", content: "hi" }], tools: [] }; + const path = surface === "responses" ? "responses" : surface === "chat" ? "chat/completions" : "messages"; + const request = new Request(`http://localhost/v1/${path}`, { + method: "POST", headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" }, + body: JSON.stringify({ model, stream: true, ...input }), + }); + const context = { model: "", provider: "" }; + const response = surface === "responses" ? await handleResponses(request, config, context) + : surface === "chat" ? await handleChatCompletions(request, config, context) + : await handleClaudeMessages(request, config, context); + const body = await response.text(); + expect(upstreamRequests).toBeGreaterThan(0); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + const frames = readFrames(body); + const payloads = frames.flatMap(item => item.payload ? [item.payload] : []); + expect(payloads.some(item => item.type === "response.completed")).toBe(false); + if (surface === "responses") { + const failed = frames.filter(item => item.payload?.type === "response.failed"); + expect(failed).toHaveLength(1); + expect(failed[0]?.event).toBe("response.failed"); + expect(failed[0]?.payload).toMatchObject({ response: { status: "failed", error: { code: UNDECLARED_TOOL_CALL_ERROR_CODE } } }); + // [DONE] closes a canonical failure as well; it is not a successful Responses terminal. + expect(frames.at(-1)?.data).toBe("[DONE]"); + } else { + expect(frames.some(item => item.data === "[DONE]")).toBe(false); + const errors = frames.filter(item => typeof item.payload?.error === "object" && item.payload.error !== null); + expect(errors).toHaveLength(1); + expect(errors[0]?.payload).toMatchObject({ error: { message: expect.stringContaining("undeclared") } }); + if (surface === "chat") { + expect(errors[0]?.event).toBeUndefined(); + expect(errors[0]?.payload?.type).toBeUndefined(); + expect(typeof (errors[0]?.payload?.error as { type?: unknown }).type).toBe("string"); + // The deterministic refusal keeps its code on every translated wire. + expect(errors[0]?.payload?.error).toMatchObject({ code: UNDECLARED_TOOL_CALL_ERROR_CODE }); + const choices = payloads.flatMap(item => Array.isArray(item.choices) ? item.choices : []); + expect(choices.some(choice => choice.finish_reason != null)).toBe(false); + } else { + expect(errors[0]?.event).toBe("error"); + expect(errors[0]?.payload?.type).toBe("error"); + // A catalog violation is api_error + its code — never a retryable overloaded_error. + expect(errors[0]?.payload?.error).toMatchObject({ + type: "api_error", + code: UNDECLARED_TOOL_CALL_ERROR_CODE, + }); + expect(frames.some(item => item.event === "message_stop" || item.payload?.type === "message_stop")).toBe(false); + expect(payloads.some(item => item.type === "message_delta" + && (item.delta as { stop_reason?: unknown } | undefined)?.stop_reason != null)).toBe(false); + } + } + } finally { globalThis.fetch = savedFetch; } + }); + } + +});