diff --git a/devlog/_plan/260920_meaning_preservation_batch/010_lane_a.md b/devlog/_plan/260920_meaning_preservation_batch/010_lane_a.md new file mode 100644 index 00000000000..a0892345d86 --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/010_lane_a.md @@ -0,0 +1,100 @@ +# Lane A — meaning preservation on the request path + +Status: OPEN. Branch `codex/260920-lane-a-meaning-preservation`, cut from `origin/dev` +`0613aaec17`. One branch, five ordered commits, one pull request against `dev`. + +## What each commit restores + +### #5211 — tool choice policy on the Chat Completions path + +Two constraints reached the parser and were dropped on the way out, both under a normal 200. + +A `tool_choice` of type `allowed_tools` is a record, is not `type: "function"`, and carries no +`function` member, so it fell past every branch of `toolChoiceToResponses` and `body.tool_choice` +was never assigned. Chat nests the subset under `allowed_tools` and names each entry under a +member keyed by its own type; the Responses shape `mapToolChoice` reads carries `mode` and +`tools` on the choice itself with a flat `name`. Both levels are now flattened. An entry that +cannot be named is refused rather than skipped, because skipping one widens the subset. + +`parallel_tool_calls` had three provider states and two branches, in two places. The unset state +is the default for every provider that never configured the knob, and it dropped the caller's own +explicit `false` — on the translated path and, from a second copy of the same branch, on the +native Chat passthrough. The decision now lives in `src/adapters/openai-chat/parallel-tool-calls.ts`, +which both builders read. An explicit `true` still omits the key, matching the configured opt-out. + +The passthrough half was found by adversarial review, not by the original report. + +### #5210 — tool declaration fields on the outbound adapters + +`strict` was kept deliberately by the Messages inbound and forwarded by the OpenAI Chat adapter, +and dropped by Anthropic — the target that defines it. It is now emitted when it is explicitly +`true`. An unstated `strict` stays absent, because the inbound records it as `false` and a +`false` on the wire cannot be told apart from silence. + +`allowed_callers` had no carrier at all. It now rides `OcxTool.allowedCallers` from the Messages +inbound, through the Responses tool schema — where an undeclared key is stripped, which is why it +never reached `buildTools` — to the Anthropic wire. The OpenAI Chat and Gemini builders have no +counterpart and refuse with a 400 rather than rebuild the declaration without the fence. The +unrestricted `["direct"]` default is not a restriction. + +Gemini's `functionCallingConfig.mode: "VALIDATED"` was plumbed to the wire compiler but only +reachable by matching a model name. A caller-declared strict tool now selects it in place of the +absent-choice default; `NONE`, `ANY` and a forced-name choice are never overwritten. + +### #5213 — developer message position, then role + +Delivered as two commits because they are two acceptance conditions. + +Position carries #5237 by Yum-wu with a `Co-authored-by` trailer. The upstream branch had the +right idea and a broken patch (a stray `];` and an assertion that put the deferred reminder +before the tool result), so the change was reimplemented and the attribution kept. One +destination already had chronological placement, keyed to a model id and a registry entry; that +is a property of prompt-prefix caching rather than of that destination, so it is now universal +and the model/registry test is gone. + +Role is separate. `developer` is part of the Chat Completions role set and is now forwarded as +sent. A destination that genuinely rejects it sets `foldDeveloperRoleToSystem`, which converts +the role in place and never moves the message, so the placement contract holds on both paths. + +### #5212 — inline document bytes + +Both inbound parsers reduced an attachment to its name before any adapter ran. +`OcxContentPart` gains a document member carrying the media type and the base64 payload; +Anthropic emits the document block, OpenAI Chat the file part, Gemini `inline_data`. + +Widening that union is the hazard, so the part also carries the marker every text-only consumer +already falls back to, which keeps a wire with no document representation byte-identical to +before. Six consumers needed more than the fallback: `ollama-native` and the Cursor tool-result +decoder would have read a nonexistent `imageUrl`, and the Kiro, Devin, Cursor and coding-agent +text serializers would have produced an empty turn. All were found by adversarial review. + +The untranslated-media refusal is narrowed only where a converter actually builds the part: +user content on the Chat projection, user and developer messages on the Responses one. A file in +a tool output, a system message or an assistant message is still refused. The scanner and the +decoder share one predicate, so a request cannot be exempted in one and reduced to a marker in +the other. + +## Known remaining gap + +Tool-result documents keep the #939 marker. The Responses tool-output vocabulary has no file +block and every adapter's tool-result path flattens to text, so carrying bytes there is a +separate change rather than a half-done one. + +## Union-defect check before push + +- File-size ratchet: `src/adapters/openai-chat.ts` was the only capped file in the touch set + (cap 822). The `parallel_tool_calls` decision moved to a sibling module and the file is 811 + lines. No cap was raised. +- `PROVIDER_CONFIG_FIELD_POLICY` in `src/server/auth-cors.ts` is + `satisfies Record`, so `foldDeveloperRoleToSystem` is classified + there and in `providerConfigSchema`. +- Every new test file is registered in both `scripts/test-layout/layout.json` and + `tests/fixtures/test-layout-expected.json`, which the layout guard asserts are equal. +- No count is restated: the provider reference tables gained a row rather than a number. + +## Verification + +Static source review plus exact-head hosted CI. Local suites, individual tests, typecheck, +build, install and live `ocx` execution were NOT RUN, per the lane constraints. Adversarial +source review ran on every commit and produced the passthrough, Kiro/Devin/Cursor/coding-agent, +role-aware-refusal and base64-predicate findings listed above. diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index d207a3bc003..3e8decdd978 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -628,4 +628,4 @@ Utilisez `"haiku"` comme valeur de remplacement pour le modèle. Dans `config.json`, `claudeCode.stabilizePromptCache: true` déplace les notices Claude reconnues en fin des instructions système vers un dernier message utilisateur sur les routes traduites. La valeur par défaut est `false`. Activez cette option seulement si ce changement de rôle convient à vos clients. Les exemples dans des blocs de code et le texte non reconnu sont conservés ; le transfert Anthropic natif reste inchangé. Sans métadonnées, la clé de cache suit les instructions stabilisées. Cette option ne crée pas une identité de conversation et ne garantit aucun succès du cache amont. -Sur la route Chat d’OpenCode Go pour `deepseek-v4.1-flash`, les rappels système traduits dans l’historique conservent automatiquement leur position et leur rôle system, après les résultats d’outils encore attendus. Ainsi, l’ajout de rappels ne réécrit pas le prompt système initial. Ce comportement s’applique avec ou sans `stabilizePromptCache` ; la conversion des autres modèles et destinations, ainsi que le transfert Anthropic natif, restent inchangés. La réutilisation du cache exige toujours une identité de session stable et un cache disponible en amont. Les changements des instructions ou outils antérieurs et la compaction de la conversation peuvent aussi affecter les succès du cache ; préserver l’ordre des rappels ne suffit pas à garantir sa réutilisation. +Sur toutes les routes Chat traduites, les rappels de l’historique conservent leur position dans la conversation, après les résultats d’outils encore attendus, et sont transmis avec le rôle `developer`. L’ajout d’un rappel ne réécrit donc pas le prompt système initial, et une instruction placée au milieu de la conversation n’arrive plus avant les tours qu’elle était censée suivre. Si le service en amont refuse le rôle `developer`, activez `foldDeveloperRoleToSystem` sur ce fournisseur : le rappel est alors envoyé en `system`, à la même position. Ce comportement s’applique avec ou sans `stabilizePromptCache` ; le transfert Anthropic natif reste inchangé. La réutilisation du cache exige toujours une identité de session stable et un cache disponible en amont. Les changements des instructions ou outils antérieurs et la compaction de la conversation peuvent aussi affecter les succès du cache ; préserver l’ordre des rappels ne suffit pas à garantir sa réutilisation. diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index a96d502b6c4..be5c9dcad1f 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -132,6 +132,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `noPenaltyModels?` | `string[]` | Modèles qui rejettent les pénalités presence/frequency. | | `noStructuredOutputModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette `response_format`. Seule une correspondance exacte du modèle demandé omet le champ ; la traduction à sortie structurée reste activée pour tous les autres modèles `openai-chat`. | | `noJsonSchemaModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette un `response_format` `json_schema` mais accepte encore `json_object`. Une telle requête est rétrogradée vers `json_object` au lieu d’être supprimée, donc un appelant qui demande du JSON en reçoit toujours. `noStructuredOutputModels` l’emporte quand un modèle figure dans les deux listes. Les préréglages `opencode go`, `opencode zen` et `opencode free` l’embarquent pour leurs routes DeepSeek. | +| `foldDeveloperRoleToSystem?` | `boolean` | Envoyer un message `developer` en `system` pour un fournisseur `openai-chat` dont le service en amont refuse le rôle `developer`. Dans les deux cas le message conserve sa position dans la conversation ; seul le rôle change. La valeur par défaut est `false`, donc le rôle standard de Chat Completions est transmis tel quel. | | `parallelToolCalls?` | `boolean` | Contrôler les appels d’outils parallèles. Pour `openai-chat`, ils sont activés par défaut ; `false` envoie explicitement `parallel_tool_calls: false`. Les autres adaptateurs ne les annoncent que lorsque la valeur vaut explicitement `true`. | | `terminalContinuationGuard?` | `boolean` | Active, pour un fournisseur `openai-chat`, une relance interne bornée lorsqu’un tour exploitable annonce une action puis s’arrête proprement sans appel d’outil. La valeur par défaut est `false`, et une valeur explicite `false` équivaut à l’absence du champ. Les tentatives de combinaison et les tours de compactage routés sont exclus ; les autres adaptateurs ignorent cette option. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Réparation SSE en aval désactivée par défaut pour les identifiants d'espace réservé exacts, les identifiants de terminal manquants et (avec `repairInvalidIds`) les identifiants message/reasoning manquant du préfixe canonique `msg_`/`rs_`. Les identifiants d’appel de fonction ne sont jamais réécrits. Le DeepSeek intégré active les deux derniers par défaut. | diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 89078ca1f6d..5f04d0f41b9 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -708,12 +708,14 @@ route. Pass `"haiku"` as the model placeholder. Set `claudeCode.stabilizePromptCache` to `true` in `config.json` to relocate supported trailing Claude harness notices from system instructions to a trailing user message on translated routes. The default is `false`. Enable it only when this role change is appropriate for your clients. It preserves fenced examples and unmatched text; native Anthropic passthrough is unchanged. The metadata-less prompt-cache key then follows stabilized instructions. This does not create conversation identity or guarantee upstream cache hits. -On OpenCode Go's `deepseek-v4.1-flash` Chat route, translated timeline system -reminders automatically retain their position and system role, after any pending -tool results. This prevents newly appended reminders from rewriting the leading -system prompt. It applies with or without `stabilizePromptCache`; other models -and destinations keep their existing conversion; native Anthropic passthrough -is unchanged. Cache reuse still requires stable session identity and upstream -cache availability. Changes to earlier instructions or tools, and conversation -compaction, can still affect cache hits; preserving reminder order alone does -not guarantee reuse. +On every translated Chat route, timeline reminders keep their position in the +conversation, after any pending tool results, and are forwarded with the +`developer` role. This prevents a newly appended reminder from rewriting the +leading system prompt, and stops a mid-conversation instruction from arriving +ahead of the turns it was written to follow. Set `foldDeveloperRoleToSystem` on +a provider whose upstream rejects the `developer` role; the reminder is then +sent as `system` in the same position. This applies with or without +`stabilizePromptCache`, and native Anthropic passthrough is unchanged. Cache +reuse still requires stable session identity and upstream cache availability. +Changes to earlier instructions or tools, and conversation compaction, can still +affect cache hits; preserving reminder order alone does not guarantee reuse. diff --git a/docs-site/src/content/docs/guides/pi.md b/docs-site/src/content/docs/guides/pi.md index d7b9e3c3edf..99f44144f9b 100644 --- a/docs-site/src/content/docs/guides/pi.md +++ b/docs-site/src/content/docs/guides/pi.md @@ -146,14 +146,17 @@ An explicit reasoning effort of `none` survives Chat conversion. Output limits a controls are preserved for generic API-key Responses targets; the canonical ChatGPT target still applies its own restrictions. This does not make all providers' controls equivalent. -**Audio and files need a native input wire that supports them.** OpenCodex does not yet have -a lossless audio/file carrier for translated requests. When Chat requires projection, or a -Responses request targets a translated adapter, recognized audio/file attachments return an -explicit error rather than succeeding without the attachment. File-ID-only images have the -same restriction because translated adapters cannot resolve those IDs. Convert the attachment -to text first, or use a native wire and model that support it. Native Chat and raw Responses -(including Azure) retain their existing behavior; this is not a promise of every model's -upstream media support. Video conversion limits remain adapter-specific. +**Audio and most file attachments need a native input wire that supports them.** A document +that carries its own base64 bytes in a user message is the exception: it survives translation +and reaches the Anthropic, OpenAI Chat and Google wires as a native document, file part and +inline data part. Everything else still returns an explicit error rather than succeeding +without the attachment — audio, a file-ID or remote reference the proxy cannot dereference, an +attachment in a tool output or a system message, and a document routed to a wire with no byte +carrier. File-ID-only images have the same restriction because translated adapters cannot +resolve those IDs. Convert the attachment to text first, or use a native wire and model that +support it. Native Chat and raw Responses (including Azure) retain their existing behavior; +this is not a promise of every model's upstream media support. Video conversion limits remain +adapter-specific. ## Schema status 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 4dfa380673f..b797f5a69e0 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -498,4 +498,4 @@ Anthropic バックエンドを明示すると意図的に失敗後停止しま `config.json` の `claudeCode.stabilizePromptCache` を `true` にすると、変換ルートのシステム指示末尾にある対応済み Claude 通知を最後のユーザーメッセージへ移します。既定値は `false` です。このロール変更が適切なクライアントでのみ有効にしてください。コードフェンス内の例と一致しない本文は保持され、Anthropic のネイティブ転送は変わりません。メタデータがない場合のキャッシュキーは安定化した指示から計算されます。会話 ID の生成やキャッシュヒットの保証は行いません。 -OpenCode Go の `deepseek-v4.1-flash` Chat ルートでは、変換されたタイムライン上のシステムリマインダーは、保留中のツール結果の後で位置と system ロールを自動的に維持します。これにより、新しいリマインダーを追加しても先頭のシステムプロンプトが書き換わりません。`stabilizePromptCache` の設定にかかわらず適用され、他のモデルや接続先の変換、および Anthropic のネイティブ転送は変わりません。キャッシュの再利用には、安定したセッション ID と上流キャッシュの利用可能性が引き続き必要です。過去の指示やツールの変更、会話の圧縮もキャッシュヒットに影響します。リマインダーの順序を保つだけで再利用が保証されるわけではありません。 +変換されたすべての Chat ルートで、タイムライン上のリマインダーは保留中のツール結果の後、会話内の元の位置を保ったまま `developer` ロールで転送されます。これにより、新しいリマインダーを追加しても先頭のシステムプロンプトが書き換わらず、会話の途中に置かれた指示がそれより前のターンの前に移動することもありません。上流が `developer` ロールを受け付けない場合は、そのプロバイダーに `foldDeveloperRoleToSystem` を設定してください。同じ位置のまま `system` として送信されます。`stabilizePromptCache` の設定にかかわらず適用され、Anthropic のネイティブ転送は変わりません。キャッシュの再利用には、安定したセッション ID と上流キャッシュの利用可能性が引き続き必要です。過去の指示やツールの変更、会話の圧縮もキャッシュヒットに影響します。リマインダーの順序を保つだけで再利用が保証されるわけではありません。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index a9fdaf8c813..20633fc2da4 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -125,6 +125,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `noPenaltyModels?` | `string[]` |存在/周波数ペナルティを拒否するモデル。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` エンドポイントが `response_format` を拒否する正確なモデル ID。要求モデルが項目と完全一致する場合だけフィールドを省略し、その他の `openai-chat` モデルでは structured-output 変換を維持します。 | | `noJsonSchemaModels?` | `string[]` | `openai-chat` エンドポイントが `json_schema` 形式は拒否しつつ `json_object` は受け入れる正確なモデル ID。この要求はフィールドを削除せず `json_object` に降格して送るため、JSON を求めた呼び出し側は散文ではなく JSON を受け取れます。両方の一覧に載るモデルでは `noStructuredOutputModels` が優先します。`opencode go` / `opencode zen` / `opencode free` プリセットが DeepSeek 経路に既定で載せます。 | +| `foldDeveloperRoleToSystem?` | `boolean` | 上流が `developer` ロールを受け付けない `openai-chat` プロバイダーで、`developer` メッセージを `system` として送ります。どちらの場合もメッセージは会話内の位置を保ち、変わるのはロールだけです。既定は `false` で、標準の Chat Completions ロールをそのまま転送します。 | | `parallelToolCalls?` | `boolean` |並列ツール呼び出しを切り替えます。 OpenAI Chat はデフォルトでオンになっています。非チャット アダプターは明示的な `true` でのみアドバタイズします。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` |正確なプレースホルダー ID、欠落している端末 ID、および(`repairInvalidIds` で)正規の `msg_`/`rs_` 接頭辞を欠く message/reasoning ID に対するダウンストリーム SSE 修復はデフォルトで無効になっています。関数呼び出し ID は決して書き換えられません。組み込み DeepSeek は最後の 2 つをデフォルトで有効にします。 | | `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 | 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 97ccbf35666..9dbce2e526d 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -563,4 +563,4 @@ Anthropic 백엔드를 명시하면 의도적으로 실패 후 중단해요. `config.json`에서 `claudeCode.stabilizePromptCache`를 `true`로 설정하면 번역 경로의 시스템 지시 끝에 붙은 지원 대상 Claude 알림을 마지막 사용자 메시지로 옮깁니다. 기본값은 `false`입니다. 사용하는 클라이언트에서 이 역할 변경을 허용할 때만 켜세요. 코드 펜스 안의 예제와 일치하지 않는 원문은 보존하며, Anthropic 원본 전달 경로는 바꾸지 않습니다. 메타데이터가 없는 요청의 캐시 키는 정리된 지시문을 기준으로 계산합니다. 대화 식별자를 만들거나 상위 서비스의 캐시 적중을 보장하는 기능은 아닙니다. -OpenCode Go의 `deepseek-v4.1-flash` Chat 경로에서는 변환된 타임라인 시스템 알림이 대기 중인 도구 결과 뒤에서 원래 위치와 system 역할을 자동으로 유지합니다. 따라서 새 알림을 추가해도 맨 앞의 시스템 프롬프트를 다시 쓰지 않습니다. `stabilizePromptCache` 설정과 관계없이 적용되며, 다른 모델과 대상의 변환 및 Anthropic 네이티브 전달은 기존 동작을 유지합니다. 캐시 재사용에는 안정적인 세션 식별자와 사용 가능한 상위 서비스 캐시가 여전히 필요합니다. 이전 지시나 도구의 변경, 대화 압축도 캐시 적중에 영향을 줄 수 있으며, 알림 순서를 유지하는 것만으로 재사용을 보장하지는 않습니다. +변환된 모든 Chat 경로에서 타임라인 알림은 대기 중인 도구 결과 뒤, 대화 안의 원래 위치를 그대로 유지하며 `developer` 역할로 전달됩니다. 덕분에 새 알림을 추가해도 맨 앞의 시스템 프롬프트를 다시 쓰지 않고, 대화 중간의 지시가 그 지시보다 앞선 턴으로 끌려가지도 않습니다. 상위 서비스가 `developer` 역할을 거부한다면 해당 공급자에 `foldDeveloperRoleToSystem`을 설정하세요. 그러면 같은 위치에서 `system`으로 보냅니다. `stabilizePromptCache` 설정과 관계없이 적용되며 Anthropic 네이티브 전달은 기존 동작을 유지합니다. 캐시 재사용에는 안정적인 세션 식별자와 사용 가능한 상위 서비스 캐시가 여전히 필요합니다. 이전 지시나 도구의 변경, 대화 압축도 캐시 적중에 영향을 줄 수 있으며, 알림 순서를 유지하는 것만으로 재사용을 보장하지는 않습니다. diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 55e1f4457d4..3731b1b3eb4 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -125,6 +125,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `noPenaltyModels?` | `string[]` | presence/frequency penalty를 허용하지 않는 모델입니다. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 엔드포인트가 `response_format`을 거부하는 정확한 모델 ID입니다. 요청 모델이 항목과 정확히 일치할 때만 필드를 생략하며, 그 외 `openai-chat` 모델에서는 structured-output 변환을 유지합니다. | | `noJsonSchemaModels?` | `string[]` | `openai-chat` 엔드포인트가 `json_schema` 형식은 거부하지만 `json_object`는 받는 정확한 모델 ID입니다. 이런 요청은 필드를 지우는 대신 `json_object`로 낮춰 보내므로, JSON을 요청한 클라이언트가 산문 대신 JSON을 받습니다. 한 모델이 두 목록에 모두 있으면 `noStructuredOutputModels`가 우선합니다. `opencode go`, `opencode zen`, `opencode free` 프리셋이 DeepSeek 경로에 기본으로 싣습니다. | +| `foldDeveloperRoleToSystem?` | `boolean` | 상위 서비스가 `developer` 역할을 거부하는 `openai-chat` 공급자에서 `developer` 메시지를 `system`으로 보냅니다. 어느 쪽이든 메시지는 대화 안의 원래 위치를 유지하며 역할만 바뀝니다. 기본값은 `false`이며, 표준 Chat Completions 역할을 받은 그대로 전달합니다. | | `parallelToolCalls?` | `boolean` | 병렬 도구 호출을 켜거나 끕니다. OpenAI Chat은 기본으로 켜져 있고, 비-chat 어댑터는 명시적으로 `true`일 때만 이를 노출합니다. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 기본값이 꺼진 downstream SSE 복구입니다. 정확한 자리표시자 id, 누락된 종료 id, 그리고(`repairInvalidIds`) 정규 `msg_`/`rs_` 접두사가 없는 message/reasoning id를 복구합니다. function-call id는 다시 쓰지 않습니다. 내장 DeepSeek은 마지막 두 가지를 기본으로 켭니다. | | `responsesSnapshotRepair?` | `boolean` | 기본값이 꺼진 클라이언트용 복구입니다. SSE와 JSON의 Responses 수명 주기에서 누락된 status, output, 도구 메타데이터를 채우며 raw 검사와 영속화는 변경하지 않습니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 29f66c70ffa..445e99df483 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -201,6 +201,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `noPenaltyModels?` | `string[]` | Models that reject presence/frequency penalties. | | `noStructuredOutputModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects `response_format`. Only an exact requested-model match omits the field; structured-output translation stays enabled for every other `openai-chat` model. | | `noJsonSchemaModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects a `json_schema` `response_format` but still accepts `json_object`. Such a request is downgraded to `json_object` instead of being dropped, so a caller asking for JSON still gets JSON. `noStructuredOutputModels` wins when a model is on both lists. The `opencode go`, `opencode zen`, and `opencode free` presets ship this for their DeepSeek routes. | +| `foldDeveloperRoleToSystem?` | `boolean` | Send a `developer` message as `system` on an `openai-chat` provider whose upstream rejects the `developer` role. The message keeps its position in the conversation either way; only the role changes. Defaults to `false`, so the standard Chat Completions role is forwarded as sent. | | `omitReasoningEffortWithToolsModels?` | `string[]` | Exact `openai-chat` model IDs that accept a reasoning-effort field on an ordinary turn but reject it once function tools are present. The model keeps its advertised effort ladder; OpenCodex omits the wire field for tool-bearing requests only and the upstream default applies. Narrower than `noReasoningModels`, which strips reasoning from every request and costs the model its picker entirely. | | `parallelToolCalls?` | `boolean` | Toggle parallel tool calls. OpenAI Chat defaults on; non-chat adapters advertise only on explicit `true`. | | `terminalContinuationGuard?` | `boolean` | Opt in an `openai-chat` provider to one bounded internal re-ask when an actionable turn announces work, then cleanly stops without a tool call. Defaults to `false`; explicit `false` behaves like omission. Combo attempts and routed compaction turns are excluded, and non-`openai-chat` adapters ignore this option. | 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 1ceea7da0e8..795a1a80d89 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -529,4 +529,4 @@ Responses `web_search_call` в парные блоки Anthropic `server_tool_us Параметр `claudeCode.stabilizePromptCache: true` в `config.json` переносит поддерживаемые уведомления Claude в конце системных инструкций в последнее пользовательское сообщение на маршрутах с преобразованием. По умолчанию он выключен (`false`). Включайте его только когда такое изменение роли допустимо для ваших клиентов. Примеры в блоках кода и нераспознанный текст сохраняются; нативная передача Anthropic не меняется. Без метаданных ключ кэша рассчитывается по стабилизированным инструкциям. Идентификатор разговора не создаётся, попадания в кэш не гарантируются. -На Chat-маршруте OpenCode Go для `deepseek-v4.1-flash` преобразованные системные напоминания в истории автоматически сохраняют свою позицию и роль system после ожидаемых результатов инструментов. Поэтому добавление новых напоминаний не переписывает начальный системный промпт. Это работает независимо от `stabilizePromptCache`; преобразование для других моделей и адресатов, а также нативная передача Anthropic остаются прежними. Для повторного использования кэша по-прежнему нужны стабильный идентификатор сессии и доступный кэш провайдера. Изменения прежних инструкций или инструментов и сжатие разговора также могут влиять на попадания в кэш; само сохранение порядка напоминаний не гарантирует повторного использования. +На всех преобразованных Chat-маршрутах напоминания в истории сохраняют свою позицию в разговоре — после ожидаемых результатов инструментов — и передаются с ролью `developer`. Поэтому добавление нового напоминания не переписывает начальный системный промпт, а инструкция из середины разговора не оказывается раньше тех ходов, после которых она была написана. Если вышестоящий сервис не принимает роль `developer`, задайте у этого провайдера `foldDeveloperRoleToSystem`: напоминание будет отправлено как `system` в той же позиции. Это работает независимо от `stabilizePromptCache`; нативная передача Anthropic остаётся прежней. Для повторного использования кэша по-прежнему нужны стабильный идентификатор сессии и доступный кэш провайдера. Изменения прежних инструкций или инструментов и сжатие разговора также могут влиять на попадания в кэш; само сохранение порядка напоминаний не гарантирует повторного использования. diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 21227ef1abd..1c73ea4571e 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -138,6 +138,7 @@ cross-route credential fallback не существует. Строки API GPT- | `noPenaltyModels?` | `string[]` | Модели, отвергающие penalty presence/frequency. | | `noStructuredOutputModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format`. Поле опускается только при точном совпадении запрошенной модели; для остальных моделей `openai-chat` преобразование structured output остаётся включённым. | | `noJsonSchemaModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format` типа `json_schema`, но принимает `json_object`. Такой запрос понижается до `json_object`, а не отбрасывается, поэтому вызывающая сторона всё равно получает JSON. Если модель есть в обоих списках, побеждает `noStructuredOutputModels`. Пресеты `opencode go`, `opencode zen` и `opencode free` включают это для своих маршрутов DeepSeek. | +| `foldDeveloperRoleToSystem?` | `boolean` | Отправлять сообщение `developer` как `system` для провайдера `openai-chat`, чей вышестоящий сервис не принимает роль `developer`. В обоих случаях сообщение сохраняет свою позицию в разговоре; меняется только роль. По умолчанию `false`, то есть стандартная роль Chat Completions передаётся как есть. | | `parallelToolCalls?` | `boolean` | Переключатель parallel tool call'ов. Для OpenAI Chat по умолчанию включено; не-chat adapter'ы рекламируют это только при явном `true`. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | По умолчанию выключенная downstream SSE-repair для exact placeholder-id, отсутствующих terminal-id и (с `repairInvalidIds`) message/reasoning id без канонического префикса `msg_`/`rs_`. Function-call id никогда не переписываются. Встроенный DeepSeek включает последние два по умолчанию. | | `responsesSnapshotRepair?` | `boolean` | По умолчанию выключенная клиентская repair для неполных lifecycle snapshot'ов Responses в SSE и JSON. Добавляет отсутствующие status, output и tool metadata, не меняя raw inspection и persistence. | diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index 4920210639a..24efef89db3 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -738,4 +738,4 @@ tutucusu olarak `"haiku"` iletin. `config.json` içindeki `claudeCode.stabilizePromptCache: true`, dönüştürülen rotalarda sistem talimatlarının sonundaki desteklenen Claude bildirimlerini son kullanıcı mesajına taşır. Varsayılan değer `false` olur. Yalnızca bu rol değişikliği istemcileriniz için uygunsa etkinleştirin. Kod bloklarındaki örnekler ve eşleşmeyen metin korunur; yerel Anthropic aktarımı değişmez. Meta veri yoksa önbellek anahtarı kararlı talimatlardan hesaplanır. Bu seçenek konuşma kimliği oluşturmaz veya üst hizmette önbellek isabeti garanti etmez. -OpenCode Go’nun `deepseek-v4.1-flash` Chat rotasında, dönüştürülen zaman çizelgesi sistem hatırlatmaları bekleyen araç sonuçlarından sonra konumlarını ve system rolünü otomatik olarak korur. Böylece yeni hatırlatmalar eklenmesi, baştaki sistem istemini yeniden yazmaz. Bu davranış `stabilizePromptCache` açık veya kapalıyken geçerlidir; diğer modellerin ve hedeflerin dönüşümü ile yerel Anthropic aktarımı değişmez. Önbelleğin yeniden kullanımı için kararlı bir oturum kimliği ve kullanılabilir üst hizmet önbelleği hâlâ gereklidir. Önceki talimatların veya araçların değişmesi ve konuşmanın sıkıştırılması da önbellek isabetini etkileyebilir; hatırlatma sırasını korumak tek başına yeniden kullanımı garanti etmez. +Dönüştürülen tüm Chat rotalarında zaman çizelgesi hatırlatmaları, bekleyen araç sonuçlarından sonra konuşmadaki konumlarını korur ve `developer` rolüyle iletilir. Böylece yeni bir hatırlatma eklenmesi baştaki sistem istemini yeniden yazmaz ve konuşmanın ortasındaki bir yönerge, izlemesi gereken turların önüne geçmez. Üst hizmet `developer` rolünü kabul etmiyorsa ilgili sağlayıcıda `foldDeveloperRoleToSystem` ayarını açın; hatırlatma aynı konumda `system` olarak gönderilir. Bu davranış `stabilizePromptCache` açık veya kapalıyken geçerlidir; yerel Anthropic aktarımı değişmez. Önbelleğin yeniden kullanımı için kararlı bir oturum kimliği ve kullanılabilir üst hizmet önbelleği hâlâ gereklidir. Önceki talimatların veya araçların değişmesi ve konuşmanın sıkıştırılması da önbellek isabetini etkileyebilir; hatırlatma sırasını korumak tek başına yeniden kullanımı garanti etmez. diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 57f5d867189..447f02b49d5 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -139,6 +139,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `noPenaltyModels?` | `string[]` | Varlık/frekans cezalarını reddeden modeller. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` uç noktası `response_format`'ı reddeden tam model kimlikleri. Yalnızca tam bir istenen model eşleşmesi alanı atlar; yapılandırılmış çıktı çevirisi diğer her `openai-chat` modeli için etkin kalır. | | `noJsonSchemaModels?` | `string[]` | `openai-chat` uç noktası `json_schema` biçimini reddeden ama `json_object` kabul eden tam model kimlikleri. Böyle bir istek atılmak yerine `json_object` seviyesine düşürülür, böylece JSON isteyen çağıran yine JSON alır. Bir model her iki listede de varsa `noStructuredOutputModels` kazanır. `opencode go`, `opencode zen` ve `opencode free` hazır ayarları bunu DeepSeek rotaları için getirir. | +| `foldDeveloperRoleToSystem?` | `boolean` | Üst hizmeti `developer` rolünü kabul etmeyen bir `openai-chat` sağlayıcısında `developer` mesajını `system` olarak gönderir. Her iki durumda da mesaj konuşmadaki konumunu korur; yalnızca rol değişir. Varsayılan `false` olduğundan standart Chat Completions rolü geldiği gibi iletilir. | | `parallelToolCalls?` | `boolean` | Paralel araç çağrılarını açıp kapatın. OpenAI Chat varsayılan olarak açıktır; sohbet harici adaptörler yalnızca açık `true` durumunda bildirir. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Tam yer tutucu kimlikleri, eksik terminal kimlikleri ve (`repairInvalidIds` ile) kurallı `msg_`/`rs_` öneki eksik olan mesaj/akıl yürütme kimlikleri için varsayılan olarak devre dışı bırakılmış aşağı akış SSE onarımı. Fonksiyon çağrısı kimlikleri asla yeniden yazılmaz. Yerleşik DeepSeek son ikisini varsayılan olarak etkinleştirir. | | `responsesSnapshotRepair?` | `boolean` | SSE ve JSON'daki seyrek Responses yaşam döngüsü anlık görüntüleri için varsayılan olarak devre dışı bırakılmış istemciye yönelik onarım. Ham inceleme ve kalıcılık değişmeden kalırken eksik kurallı durumu, çıktıyı ve araç meta verilerini doldurur. | 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 db814d3aef7..6c65903fa46 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 @@ -467,4 +467,4 @@ Claude 模型时自动加载。对于原生透传,这是正常现象;对于 在 `config.json` 中设置 `claudeCode.stabilizePromptCache: true`,可在转换路由上将系统指令末尾受支持的 Claude 提示移到最后一条用户消息。默认值为 `false`。仅在客户端允许这种角色变化时启用。代码围栏内的示例和不匹配的文本会保留,Anthropic 原生透传不变。没有元数据时,缓存键按稳定后的指令计算。该选项不会生成会话标识,也不保证上游缓存命中。 -在 OpenCode Go 的 `deepseek-v4.1-flash` Chat 路由上,转换后的时间线系统提醒会自动保留原有位置和 system 角色,并排在尚待返回的工具结果之后。因此,追加提醒不会重写开头的系统提示。无论 `stabilizePromptCache` 是否启用,该行为都会生效;其他模型、目标地址的转换方式以及 Anthropic 原生透传保持不变。缓存复用仍需要稳定的会话标识和可用的上游缓存。修改较早的指令或工具、压缩对话也可能影响缓存命中;仅保留提醒顺序并不保证缓存复用。 +在所有转换后的 Chat 路由上,时间线提醒都会保留在对话中的原有位置(排在尚待返回的工具结果之后),并以 `developer` 角色转发。因此,追加提醒不会重写开头的系统提示,对话中途的指令也不会被挪到它本应跟随的轮次之前。如果上游拒绝 `developer` 角色,请在该提供方上设置 `foldDeveloperRoleToSystem`,提醒会在同一位置以 `system` 发送。无论 `stabilizePromptCache` 是否启用,该行为都会生效;Anthropic 原生透传保持不变。缓存复用仍需要稳定的会话标识和可用的上游缓存。修改较早的指令或工具、压缩对话也可能影响缓存命中;仅保留提醒顺序并不保证缓存复用。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index e0e61490a1d..8fcfa10e6e8 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -125,6 +125,7 @@ selector,而不是分配一个新名称。 | `noPenaltyModels?` | `string[]` | 会拒绝 presence/frequency penalty 的模型。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 端点拒绝 `response_format` 的精确模型 ID。仅当请求模型与条目完全匹配时才省略该字段;其他 `openai-chat` 模型仍启用 structured-output 转换。 | | `noJsonSchemaModels?` | `string[]` | `openai-chat` 端点拒绝 `json_schema` 形式但仍接受 `json_object` 的精确模型 ID。这类请求会降级为 `json_object` 而不是被丢弃,因此请求 JSON 的调用方仍能拿到 JSON。同一模型同时出现在两个列表时,以 `noStructuredOutputModels` 为准。`opencode go`、`opencode zen`、`opencode free` 预设已为其 DeepSeek 路由内置该项。 | +| `foldDeveloperRoleToSystem?` | `boolean` | 对上游拒绝 `developer` 角色的 `openai-chat` 提供方,将 `developer` 消息作为 `system` 发送。无论哪种方式,消息都会保留在对话中的原有位置,只有角色改变。默认为 `false`,即按原样转发标准的 Chat Completions 角色。 | | `parallelToolCalls?` | `boolean` | 切换并行工具调用。OpenAI Chat 默认开启;非 chat 适配器只有显式 `true` 时才会声明支持。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 默认关闭的下游 SSE 修复,用于精确占位 id、缺失的终止 id,以及(`repairInvalidIds`)缺少规范 `msg_`/`rs_` 前缀的 message/reasoning id。function-call id 永远不会被重写。内置 DeepSeek 默认启用后两项。 | | `responsesSnapshotRepair?` | `boolean` | 默认关闭的客户端修复,用于补全 SSE 与 JSON 中稀疏 Responses 生命周期快照缺失的 status、output 和工具元数据;原始检查与持久化保持不变。 | diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index 493aeea9a5b..51231f1a38d 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -538,4 +538,4 @@ Claude 模型時自動載入。對於原生透傳,這是正常現象;對於 在 `config.json` 中設定 `claudeCode.stabilizePromptCache: true`,可在轉換路由上將系統指令末尾支援的 Claude 提示移到最後一則使用者訊息。預設值為 `false`。僅在用戶端允許這種角色變更時啟用。程式碼圍欄中的範例和不符合的文字會保留,Anthropic 原生轉送不變。沒有中繼資料時,快取鍵依穩定後的指令計算。此選項不會產生對話識別碼,也不保證上游快取命中。 -在 OpenCode Go 的 `deepseek-v4.1-flash` Chat 路由上,轉換後的時間線系統提醒會自動保留原有位置和 system 角色,並排在尚待傳回的工具結果之後。因此,新增提醒不會重寫開頭的系統提示。無論 `stabilizePromptCache` 是否啟用,此行為都會生效;其他模型、目標位址的轉換方式以及 Anthropic 原生轉送維持不變。快取重用仍需要穩定的工作階段識別碼和可用的上游快取。修改較早的指令或工具、壓縮對話也可能影響快取命中;僅保留提醒順序並不保證快取重用。 +在所有轉換後的 Chat 路由上,時間線提醒都會保留在對話中的原有位置(排在尚待傳回的工具結果之後),並以 `developer` 角色轉送。因此,新增提醒不會重寫開頭的系統提示,對話中途的指令也不會被移到它原本應跟隨的輪次之前。若上游拒絕 `developer` 角色,請在該提供者上設定 `foldDeveloperRoleToSystem`,提醒會在相同位置以 `system` 傳送。無論 `stabilizePromptCache` 是否啟用,此行為都會生效;Anthropic 原生轉送維持不變。快取重用仍需要穩定的工作階段識別碼和可用的上游快取。修改較早的指令或工具、壓縮對話也可能影響快取命中;僅保留提醒順序並不保證快取重用。 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 395ec06ec77..15e4ce53a37 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -99,6 +99,7 @@ ocx models provider openrouter on | `noPenaltyModels?` | `string[]` | 拒絕 presence/frequency penalty 的模型。 | | `noStructuredOutputModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `response_format` 的精確模型 ID。僅精確符合的請求模型會省略該欄位;structured-output 轉譯對其他每個 `openai-chat` 模型保持啟用。 | | `noJsonSchemaModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `json_schema` 形式但仍接受 `json_object` 的精確模型 ID。這類請求會降級為 `json_object` 而非被丟棄,因此要求 JSON 的呼叫端仍會拿到 JSON。同一模型同時列在兩份清單時,以 `noStructuredOutputModels` 為準。`opencode go`、`opencode zen`、`opencode free` 預設已為其 DeepSeek 路由內建。 | +| `foldDeveloperRoleToSystem?` | `boolean` | 對上游拒絕 `developer` 角色的 `openai-chat` 提供者,將 `developer` 訊息以 `system` 傳送。無論何者,訊息都會保留在對話中的原有位置,只有角色改變。預設為 `false`,亦即照原樣轉送標準的 Chat Completions 角色。 | | `parallelToolCalls?` | `boolean` | 切換平行工具呼叫。OpenAI Chat 預設開啟;非 chat adapter 僅在明確 `true` 時廣告。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | 預設停用的下游 SSE 修復,用於精確佔位 id 與缺失的終端 id。Function-call id 永不被重寫。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 僅限使用金鑰認證的 `openai-chat` 與 `openai-responses` 供應商。`authMode: "forward"` 的供應商(ChatGPT 帳號池)從不讀取此選項,維持預設重試次數。選擇性重試串流開始前的暫時性上游狀態(500、502、503、504、520、521、522):未設定時停用;只要有此物件即啟用,除非 `enabled: false`。涵蓋初始 `Responses` 請求、終止防護續接、原生 `/v1/chat/completions`,以及 429/帳號復原的重新擷取。`attempts` 是單一請求允許傳送至上游的總次數,包含第一次(1..10,預設 3);這是與連線重設復原共用的單一請求範圍預算,因此 `3` 表示最多只有三個實際請求會送達供應商。等待採固定 400 毫秒、上限 5 秒的指數退避,並遵循 `Retry-After`。此機制獨立於處理速率限制的 `retryOn429`;串流中的失敗絕不重播。 | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7522f69ddae..74ff3fe3868 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1567,6 +1567,11 @@ "management-google-tool-schema-policy.test.ts": "server", "codex-shim-destroyed-probe.test.ts": "codex-integration", "client-runtime.test.ts": "clients", + "chat-tool-choice-allowed-tools.test.ts": "responses", + "anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic", + "google-strict-tool-validated-mode.test.ts": "adapters/google", + "openai-chat-developer-position.test.ts": "adapters/openai", + "chat-inline-document-bytes.test.ts": "responses", "devin-output-budget.test.ts": "providers", "api-key-model-scope.test.ts": "server" }, diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index c90f8009a66..9e2e1ffb425 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -39,6 +39,16 @@ function toAnthropicContentPart(p: OcxContentPart): unknown { : { type: "image", source: { type: "url", url: p.imageUrl } }; } if (p.type === "video") return { type: "text", text: "[video]" }; + // The block the caller sent, rebuilt. A Messages-to-Messages route used to reduce it to its + // title before any adapter ran, so the model was told a document existed rather than given + // one, and the answer came back looking the same (#5212). + if (p.type === "document") { + return { + type: "document", + source: { type: "base64", media_type: p.mediaType, data: p.data }, + ...(p.filename !== undefined ? { title: p.filename } : {}), + }; + } return { type: "text", text: p.text }; } @@ -857,6 +867,12 @@ function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: ( name: toolNames.toWire(namespacedToolName(t.namespace, t.name)), description: t.description, input_schema: normalizeAnthropicInputSchema(t.parameters), + // Anthropic is the target that DEFINES both of these, and both were dropped while the + // OpenAI Chat adapter already forwarded strict (#5210). Only an explicit `true` is + // emitted: the Messages inbound records an absent strict as `false`, so a false here + // cannot be distinguished from silence and must not become an opt-out on the wire. + ...(t.strict === true ? { strict: true } : {}), + ...(t.allowedCallers !== undefined ? { allowed_callers: [...t.allowedCallers] } : {}), })); return converted; } diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts index 628341bb8d1..4d38ac071e1 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -332,7 +332,7 @@ function formatMessageForHistory(message: OcxMessage): string { if (message.role === "user") { const text = typeof message.content === "string" ? message.content - : message.content.map(p => (p.type === "text" ? p.text : `[${p.type}]`)).join("\n"); + : message.content.map(p => (p.type === "text" || p.type === "document" ? p.text : `[${p.type}]`)).join("\n"); return `USER:\n${text}`; } if (message.role === "assistant") { @@ -352,7 +352,7 @@ function formatMessageForHistory(message: OcxMessage): string { if (message.role === "toolResult") { const text = typeof message.content === "string" ? message.content - : message.content.map(p => (p.type === "text" ? p.text : "[image]")).join(""); + : message.content.map(p => (p.type === "text" || p.type === "document" ? p.text : "[image]")).join(""); const status = message.isError ? " (error)" : ""; return `TOOL RESULT (call_id: ${message.toolCallId})${status}:\n${text}`; } @@ -379,6 +379,8 @@ export function buildInputLines(message: OcxMessage): string[] { else if (part.type === "image") { const image = imagePart(part.imageUrl); if (image) content.push(image); + } else if (part.type === "document") { + content.push(textPart(part.text)); } else { content.push(textPart("[video]")); } @@ -402,7 +404,7 @@ export function buildSystemPrompt(parsed: OcxParsedRequest): string | undefined if (message.role !== "developer") continue; const text = typeof message.content === "string" ? message.content - : message.content.map(part => (part.type === "text" ? part.text : "")).join(""); + : message.content.map(part => (part.type === "text" || part.type === "document" ? part.text : "")).join(""); if (text.trim()) parts.push(text); } return parts.length > 0 ? parts.join("\n\n") : undefined; @@ -464,6 +466,8 @@ export function buildConversationInput(parsed: OcxParsedRequest, options: { maxH const image = imagePart(part.imageUrl); if (image) currentImageBlocks.push(image); else textParts.push("[image omitted: unsupported reference]"); + } else if (part.type === "document") { + textParts.push(part.text); } else { textParts.push("[video]"); } @@ -487,6 +491,7 @@ export function buildConversationInput(parsed: OcxParsedRequest, options: { maxH else segments.push("[image omitted: unsupported reference]"); continue; } + if (part.type === "document") { segments.push(part.text); continue; } segments.push("[video]"); } text = segments.join(""); diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index 35d54ebd463..c05263110f9 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -32,7 +32,7 @@ function canonicalCommandCodeModelId(modelId: string): string { /** Flatten tool-result content for the text-only wire output, keeping an `[image]` marker per image part in content order. */ function toolResultText(content: string | OcxContentPart[]): string { if (typeof content === "string") return content; - return content.map(part => (part.type === "text" ? part.text : "[image]")).join(""); + return content.map(part => (part.type === "text" || part.type === "document" ? part.text : "[image]")).join(""); } /** Best-effort media type from a remote https URL extension, e.g. image/png. */ @@ -149,6 +149,7 @@ function wireMessages(messages: OcxMessage[]): Array> { else for (const part of message.content) { if (part.type === "text") content.push({ type: "text", text: part.text }); else if (part.type === "image") content.push(wireImagePart(part.imageUrl)); + else if (part.type === "document") content.push({ type: "text", text: part.text }); else content.push({ type: "text", text: "[video]" }); } out.push({ role: "user", content }); diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 894cf3eae45..83e076dcced 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -741,7 +741,7 @@ function contentText(message: OcxMessage): string { if (typeof message.content === "string") return message.content; return message.content .map(part => { - if (part.type === "text") return part.text; + if (part.type === "text" || part.type === "document") return part.text; if (part.type === "thinking") return part.thinking; if (part.type === "image") return undefined; return undefined; @@ -754,7 +754,7 @@ function contentToText(content: OcxToolResultMessage["content"]): string { if (typeof content === "string") return content; return content .map(part => { - if (part.type === "text") return part.text; + if (part.type === "text" || part.type === "document") return part.text; if (part.type === "image") return CURSOR_VISION_IMAGE_HISTORY_MARKER; return undefined; }) @@ -767,7 +767,7 @@ function historyContentText(message: OcxMessage): string { if (message.role === "toolResult" || typeof message.content === "string") return contentText(message); return message.content .map(part => { - if (part.type === "text") return part.text; + if (part.type === "text" || part.type === "document") return part.text; if (part.type === "thinking") return part.thinking; if (part.type === "image") return CURSOR_VISION_IMAGE_HISTORY_MARKER; return undefined; @@ -836,6 +836,9 @@ function decodeResultParts(message: OcxToolResultMessage): DecodedResultPart[] | return content.map((part): DecodedResultPart => { if (part.type === "text") return { kind: "text", text: part.text }; if (part.type === "video") return { kind: "text", text: "[video]" }; + // Without this the document falls through to decodeInlineImage(part.imageUrl) below and is + // treated as an image it is not. + if (part.type === "document") return { kind: "text", text: part.text }; const decoded = decodeInlineImage(part.imageUrl); return decoded ? { kind: "image", ...decoded } : { kind: "undecodable" }; }); diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 065f971afa3..3c0e5faa802 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -251,6 +251,9 @@ function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): stri switch (part.type) { case "text": return part.text; + case "document": + // Cursor has no document carrier; the marker keeps the turn from serializing to nothing. + return part.text; case "thinking": return part.thinking; case "image": diff --git a/src/adapters/declaration-carrier.ts b/src/adapters/declaration-carrier.ts new file mode 100644 index 00000000000..a1f99739c97 --- /dev/null +++ b/src/adapters/declaration-carrier.ts @@ -0,0 +1,45 @@ +// Type-only: erased at compile time, so this does not create an import cycle with the registry. +import type { AdapterWire } from "./registry"; +import type { OcxMessage, OcxParsedRequest } from "../types"; +import { toolRestrictsCallers } from "../types"; + +/** + * Wires that can actually carry a constraint, as a default-deny allowlist. + * + * A per-adapter opt-in is the wrong shape for this: an adapter that never learned about a + * carrier rebuilds the declaration or the message without it and returns a normal completion, + * which is exactly the silent widening this batch exists to remove. Listing the wires that CAN + * carry it means a new wire refuses until someone teaches it, and adding a member to + * `AdapterWire` makes the omission visible here rather than at a customer's upstream. + */ +const CALLER_RESTRICTION_WIRES: ReadonlySet = new Set(["anthropic"]); +const INLINE_DOCUMENT_WIRES: ReadonlySet = new Set([ + "anthropic", + "openai-chat", + "google", +]); + +/** A fixed-vocabulary refusal, or `undefined` when this wire can hold everything the request carries. */ +export function unrepresentableDeclaration(parsed: OcxParsedRequest, wire: AdapterWire): string | undefined { + if (!CALLER_RESTRICTION_WIRES.has(wire) && parsed.context.tools?.some(toolRestrictsCallers)) { + // The name is deliberately absent: it is caller-controlled and would put client metadata + // into an error body. + return "OpenCodex cannot express tools[].allowed_callers on this route. " + + "Route the request to an Anthropic-protocol provider, or remove the caller restriction."; + } + if (!INLINE_DOCUMENT_WIRES.has(wire) && parsed.context.messages.some(carriesDocument)) { + return "OpenCodex cannot translate document input on this route. " + + "Use a native input wire that supports the attachment, or convert it to text first."; + } + return undefined; +} + +/** + * Typed structurally rather than as `OcxContentPart[]`: an assistant turn's content is + * `OcxAssistantContentPart[]`, which carries thinking and tool-call members and is not + * assignable to the user-content union. Only the discriminant is read here. + */ +function carriesDocument(message: OcxMessage): boolean { + const content: unknown = message.content; + return Array.isArray(content) && (content as ReadonlyArray<{ type: string }>).some(part => part.type === "document"); +} diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index c1c67bdd409..fe81f356ba4 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -331,6 +331,9 @@ function mapOcxContentToWire(content: string | OcxContentPart[] | undefined): st for (const part of content) { if (part.type === "text" && part.text) { out.push({ type: "text", text: part.text }); + } else if (part.type === "document") { + // No Devin document field; the marker keeps the turn from disappearing entirely. + out.push({ type: "text", text: part.text }); } else if (part.type === "image") { const m = part.imageUrl.match(/^data:([^;]+);base64,(.+)$/); if (m) out.push({ type: "image", mimeType: m[1]!, base64Data: m[2]! }); diff --git a/src/adapters/google-antigravity-wire.ts b/src/adapters/google-antigravity-wire.ts index a40703f8377..223d95293e4 100644 --- a/src/adapters/google-antigravity-wire.ts +++ b/src/adapters/google-antigravity-wire.ts @@ -45,8 +45,11 @@ function firstUserText(parsed: OcxParsedRequest): string | undefined { for (const msg of parsed.context.messages) { if (msg.role !== "user") continue; if (typeof msg.content === "string") return msg.content; - const first = (msg.content as OcxContentPart[]).find(p => p.type === "text" && typeof p.text === "string"); - if (first && first.type === "text") return first.text; + // A document part carries text too: ignoring it left a document-only opening turn with no + // anchor, which silently downgrades the deterministic session id to a random one. + const first = (msg.content as OcxContentPart[]) + .find(p => (p.type === "text" || p.type === "document") && typeof p.text === "string"); + if (first && (first.type === "text" || first.type === "document")) return first.text; } return undefined; } diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 1e8631ae645..a82c240a1d9 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -15,6 +15,7 @@ import type { OcxUsage, } from "../types"; import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../types"; +import type { OcxTool } from "../types"; import { contentPartsToText, parseDataUrl } from "./image"; import { getVertexAccessToken } from "../lib/gcp-adc"; import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http"; @@ -346,6 +347,13 @@ function messagesToGeminiFormat( parts.push(data ? { inline_data: { mime_type: data.mediaType, data: data.base64 } } : { text: `[video: ${p.videoUrl}]` }); continue; } + if (p.type === "document") { + // Gemini takes document bytes through the same inline_data part as images and + // video. The marker on the part is the fallback for wires without one, not this + // wire's best effort (#5212). + parts.push({ inline_data: { mime_type: p.mediaType, data: p.data } }); + continue; + } // Drop empty/malformed text instead of emitting `{ text: "" }` or a bare `{}` part. const textPart = geminiTextPart(p.text); if (textPart) parts.push(textPart); @@ -465,9 +473,7 @@ function messagesToGeminiFormat( function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined { if (!parsed.context.tools?.length) return undefined; - const tools = isAllowedToolChoice(parsed.options.toolChoice) - ? parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools)) - : parsed.context.tools; + const tools = advertisedGeminiTools(parsed); if (tools.length === 0) return undefined; return [{ functionDeclarations: tools.map(t => ({ @@ -478,19 +484,37 @@ function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined { }]; } +/** The declarations this request actually advertises, after any allowed-tools filter. */ +function advertisedGeminiTools(parsed: OcxParsedRequest): readonly OcxTool[] { + const declared = parsed.context.tools ?? []; + return isAllowedToolChoice(parsed.options.toolChoice) + ? declared.filter(toolChoiceToolPredicate(parsed.options.toolChoice, declared)) + : declared; +} + /** * Client tool_choice enforcement on the wire. The catalog nudge states the same contract in * prose, but without functionCallingConfig the model is free to ignore it. "auto" stays absent * so the common case is byte-identical. The allowedTools variant already filters the * declarations in toolsToGeminiFormat; only its "required" half needs a wire mode. + * + * A caller that declares strict tools is asking for its argument schemas to be enforced, and + * Gemini expresses that as VALIDATED. The mode existed and was plumbed end to end, but was only + * ever reachable by matching a model name, so a strict declaration arrived as an ordinary + * unvalidated AUTO turn and the response looked the same either way (#5210). VALIDATED replaces + * AUTO only: ANY and NONE are stronger constraints the caller asked for explicitly, and + * overwriting either of them would lose the choice this function exists to enforce. */ function toolChoiceToGeminiToolConfig(parsed: OcxParsedRequest): Record | undefined { const choice = parsed.options.toolChoice; - if (!choice || choice === "auto") return undefined; + const validated = advertisedGeminiTools(parsed).some(t => t.strict === true) + ? { functionCallingConfig: { mode: "VALIDATED" } } + : undefined; + if (!choice || choice === "auto") return validated; if (choice === "none") return { functionCallingConfig: { mode: "NONE" } }; if (choice === "required") return { functionCallingConfig: { mode: "ANY" } }; if (isAllowedToolChoice(choice)) { - return choice.mode === "required" ? { functionCallingConfig: { mode: "ANY" } } : undefined; + return choice.mode === "required" ? { functionCallingConfig: { mode: "ANY" } } : validated; } return { functionCallingConfig: { diff --git a/src/adapters/image.ts b/src/adapters/image.ts index 39c5d683e50..1a6569fb0f4 100644 --- a/src/adapters/image.ts +++ b/src/adapters/image.ts @@ -18,6 +18,9 @@ export function parseDataUrl(url: string): { mediaType: string; base64: string } */ export function contentPartsToText(content: string | OcxContentPart[]): string { if (typeof content === "string") return content; - const text = content.map(p => p.type === "text" ? p.text : p.type === "image" ? "[image]" : "[video]").join(""); + // A document carries its own marker, so this wire states the attachment instead of + // mislabelling it as a video. + const text = content.map(p => + p.type === "text" || p.type === "document" ? p.text : p.type === "image" ? "[image]" : "[video]").join(""); return text || "[image]"; } diff --git a/src/adapters/input-media-guard.ts b/src/adapters/input-media-guard.ts index ad8f468a95b..96c554782d1 100644 --- a/src/adapters/input-media-guard.ts +++ b/src/adapters/input-media-guard.ts @@ -1,31 +1,45 @@ import type { ProviderAdapter } from "./base"; import { untranslatedInputMediaMessage, untranslatedResponsesInputMedia } from "../responses/input-media"; +import { unrepresentableDeclaration } from "./declaration-carrier"; +import type { AdapterWire } from "./registry"; +import type { OcxParsedRequest } from "../types"; /** * Refuse unrepresentable input at the final translated-adapter boundary. The registry * applies this after wire resolution; Responses passthrough (including Azure) opts * out because it uses the original body rather than the lossy normalized content. + * + * Two of the three checks are wire-scoped, and that is the point. A constraint the normalized + * request CAN carry — a caller restriction on a tool, an attached document's bytes — still has + * to reach a wire that can express it. Leaving that to each adapter means an adapter that never + * learned about the carrier rebuilds without it and answers normally, so the allowlist in + * `declaration-carrier.ts` is default-deny and this is the one place every registered adapter + * passes through. */ -export function withInputMediaGuard(adapter: T): T { +export function withInputMediaGuard(adapter: T, wire: AdapterWire): T { + const refusal = (parsed: OcxParsedRequest): string | undefined => { + const kind = untranslatedResponsesInputMedia(parsed._rawBody); + return kind ? untranslatedInputMediaMessage(kind) : unrepresentableDeclaration(parsed, wire); + }; const build = adapter.buildRequest.bind(adapter); adapter.buildRequest = (parsed, incoming) => { - const kind = untranslatedResponsesInputMedia(parsed._rawBody); - if (kind) throw new Error(untranslatedInputMediaMessage(kind)); + const message = refusal(parsed); + if (message) throw new Error(message); return build(parsed, incoming); }; const runTurn = adapter.runTurn?.bind(adapter); if (runTurn) { adapter.runTurn = async (parsed, incoming, emit) => { - const kind = untranslatedResponsesInputMedia(parsed._rawBody); - if (kind) { + const message = refusal(parsed); + if (message) { emit({ type: "error", status: 400, errorType: "invalid_request_error", code: "unsupported_input_modality", retryable: false, - message: untranslatedInputMediaMessage(kind), + message, }); return; } @@ -37,9 +51,7 @@ export function withInputMediaGuard(adapter: T): T { if (localTerminal) { // This hook is outside the builder's error catch. Decline its success shortcut; // the ordinary buildRequest path then returns the established client-safe 400. - adapter.localTerminal = parsed => untranslatedResponsesInputMedia(parsed._rawBody) - ? undefined - : localTerminal(parsed); + adapter.localTerminal = parsed => refusal(parsed) ? undefined : localTerminal(parsed); } return adapter; } diff --git a/src/adapters/kiro-tool-fallback.ts b/src/adapters/kiro-tool-fallback.ts index 1164277368a..24c917f2853 100644 --- a/src/adapters/kiro-tool-fallback.ts +++ b/src/adapters/kiro-tool-fallback.ts @@ -9,7 +9,7 @@ function contentText(content: string | OcxContentPart[]): string { if (typeof content === "string") return content; return content .map(part => { - if (part.type === "text") return part.text; + if (part.type === "text" || part.type === "document") return part.text; if (part.type === "image") return `[image:${part.detail ?? "auto"}]`; return ""; }) diff --git a/src/adapters/kiro/usage.ts b/src/adapters/kiro/usage.ts index 5f417b57295..3bb8f2b0ffe 100644 --- a/src/adapters/kiro/usage.ts +++ b/src/adapters/kiro/usage.ts @@ -12,14 +12,15 @@ import type { KiroHistoryEntry } from "./wire"; export function userContentText(content: string | OcxContentPart[]): string { if (typeof content === "string") return content; - return content.map(p => (p.type === "text" ? p.text : "")).filter(Boolean).join("\n"); + // A document carries its own marker: dropping it built an empty user turn that Kiro rejects. + return content.map(p => (p.type === "text" || p.type === "document" ? p.text : "")).filter(Boolean).join("\n"); } export function usageContentText(content: string | OcxContentPart[]): string { if (typeof content === "string") return content; return content .map(p => { - if (p.type === "text") return p.text; + if (p.type === "text" || p.type === "document") return p.text; if (p.type === "image") return `[image:${p.detail ?? "auto"}]`; return ""; }) diff --git a/src/adapters/ollama-native.ts b/src/adapters/ollama-native.ts index c78f154d49d..726fe55ad49 100644 --- a/src/adapters/ollama-native.ts +++ b/src/adapters/ollama-native.ts @@ -262,6 +262,12 @@ function contentToNative( text += part.text; continue; } + // No Ollama document carrier: keep the marker rather than falling through to the image + // branch below, which would read a nonexistent imageUrl. + if (part.type === "document") { + text += part.text; + continue; + } // Ollama's native /api/chat message shape carries `images: string[]` and has no video // counterpart, so a video part is refused rather than silently dropped or mis-sent as an image. if (part.type === "video") throw new Error(`ollama-native cannot send video content in ${label}`); diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 633c693c7ff..fa024e0cccb 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1,4 +1,5 @@ import { hasShrinkableOpenAIChatImages, normalizeOpenAIChatImages } from "./openai-chat-images"; +import { chatParallelToolCallsWireValue } from "./openai-chat/parallel-tool-calls"; import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types"; import { modelInList } from "../types"; @@ -249,19 +250,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd } if (tools) { - if (provider.parallelToolCalls === false) { - // NIM documents the Boolean defaulting to false and kimi rejects true; pin the - // wire bit so Codex cannot opt in via request.options. Other opted-out providers - // omit the field by default so strict OpenAI-compatible hosts never see an - // unsupported knob, but a self-hosted gateway that DOES honor the field and keeps - // emitting parallel calls without it can opt in via pinParallelToolCallsFalse. - if (provider.baseUrl === "https://integrate.api.nvidia.com/v1" - || provider.pinParallelToolCallsFalse === true) { - body.parallel_tool_calls = false; - } - } else if (provider.parallelToolCalls === true) { - body.parallel_tool_calls = parsed.options.parallelToolCalls !== false; - } + const parallelToolCalls = chatParallelToolCallsWireValue(provider, parsed.options.parallelToolCalls); + if (parallelToolCalls !== undefined) body.parallel_tool_calls = parallelToolCalls; } if (parsed.stream) body.stream_options = { include_usage: true }; diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index 8e88dabc6b0..c30072e1ee6 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -5,9 +5,9 @@ import { contentPartsToText } from "../image"; import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "../empty-tool-output-annotation"; import { identifyRoutedModel } from "../identity"; import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "../tool-catalog-nudge"; -import { registryEntryForProviderDestination } from "../../providers/registry"; import { peekReasoningForCall } from "../../responses/reasoning-replay-cache"; -import type { OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall } from "../../types"; +import { inlineDocumentDataUrl } from "../../responses/inline-document"; +import type { OcxAssistantMessage, OcxContentPart, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall } from "../../types"; import { modelInList, namespacedToolName } from "../../types"; /** @@ -21,13 +21,6 @@ import { modelInList, namespacedToolName } from "../../types"; */ const VIDEO_UNSUPPORTED_MARKER = "[video omitted: the translated Chat route has no video mapping]"; -export function developerSystemText(message: OcxMessage): string | undefined { - if (message.role !== "developer") return undefined; - if (typeof message.content === "string") return message.content; - if (message.content.some(part => part.type === "image")) return undefined; - return message.content.map(part => (part as OcxTextContent).text).join(""); -} - /** * Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool" * content is text-only on every chat provider, so these ride in a follow-up user message instead of @@ -121,21 +114,29 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv }; const nativeOpenAI = isNativeOpenAIChatTarget(provider); - // Hoisting a newly appended reminder rewrites the reusable prompt prefix. - // Keep this compatibility exception on the destination/model tested with OCG. - const chronologicalSystem = parsed.modelId === "deepseek-v4.1-flash" - && registryEntryForProviderDestination(provider)?.id === "opencode-go"; + // `developer` is part of the Chat Completions role set, so it is forwarded as itself. The + // host test above used to decide the role too, which assumed every OpenAI-compatible gateway + // rejects a standard role until proven otherwise — including gateways that proxy OpenAI — + // and quietly gave the instruction `system` precedence instead (#5213). A destination that + // really does reject it records that with `foldDeveloperRoleToSystem`, which converts the + // role where the message already is and never moves it. + const developerWireRole = provider.foldDeveloperRoleToSystem === true ? "system" : "developer"; + // A developer message keeps the slot it arrived in. Hoisting its text into the leading + // system block moved a mid-conversation instruction ahead of every turn it was written to + // follow, and the caller saw an ordinary answer either way (#5213). The Claude inbound mints + // chronological developer items for exactly this reason (#4161), so the two halves of the + // route were working against each other on every host but api.openai.com. Placement is now + // uniform; which ROLE that slot carries is decided separately below. + // + // One destination already had the chronological behaviour, keyed to a model and a registry + // id, because hoisting a newly appended reminder rewrites the reusable prompt prefix. That is + // a property of prompt-prefix caching rather than of that destination, and it is now what + // every destination gets. const toolCatalogNudge = shouldInjectNonOpenAIToolCatalogNudge(provider) ? buildNonOpenAIToolCatalogNudgeForTools(context.tools, options.toolChoice) : undefined; - const developerSystemParts = nativeOpenAI || chronologicalSystem - ? [] - : context.messages - .map(developerSystemText) - .filter((part): part is string => part !== undefined && part.length > 0); const systemParts = [ ...(context.systemPrompt ?? []), - ...developerSystemParts, ...(toolCatalogNudge ? [toolCatalogNudge] : []), ]; if (systemParts.length > 0) { @@ -152,20 +153,23 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv case "developer": { const parts = typeof msg.content === "string" ? undefined : msg.content as OcxContentPart[]; const hasImages = parts?.some(p => p.type === "image") ?? false; + // A document has a structured counterpart on this wire, so it needs the parts array for + // the same reason an image does: flattening it to a string would drop the bytes. + const hasStructured = hasImages || (parts?.some(p => p.type === "document") ?? false); let chatMsg: Record; - if (msg.role === "developer" && !hasImages) { - if (!nativeOpenAI && !chronologicalSystem) break; + if (msg.role === "developer" && !hasStructured) { const text = typeof msg.content === "string" ? msg.content : parts!.map(p => (p as OcxTextContent).text).join(""); - // A non-text timeline part (video, for example) serializes to nothing here. - // The generic path drops such a message; the chronological exception must not - // turn it into an empty system message that some upstreams reject. + // A non-text timeline part (video, for example) serializes to nothing here. The + // generic user path drops such a message, and emitting a content-free system + // message instead is rejected by some upstreams. Native OpenAI keeps its existing + // empty-developer wire, which is a separate question from placement. if (!nativeOpenAI && text.length === 0) break; - chatMsg = { role: nativeOpenAI ? "developer" : "system", content: text }; + chatMsg = { role: developerWireRole, content: text }; } else if (typeof msg.content === "string") { chatMsg = { role: "user", content: msg.content }; - } else if (!hasImages) { + } else if (!hasStructured) { // A video part has no `text`, so joining it produced "" and the whole message // was dropped: a video-only or text-plus-video turn vanished silently. OpenAI's // Chat Completions wire has no video content part, so state the omission @@ -183,13 +187,30 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv if (p.type === "image") { return { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } }; } + // Chat Completions carries an attached document as a file part with inline bytes, + // the direct counterpart of the Anthropic document block the caller sent. + if (p.type === "document") { + return { + type: "file", + file: { + file_data: inlineDocumentDataUrl(p), + ...(p.filename !== undefined ? { filename: p.filename } : {}), + }, + }; + } // Previously this produced { type: "text", text: undefined } for a video // part — a malformed part, worse than a drop because it can fail upstream // schema validation. if (p.type === "video") return { type: "text", text: VIDEO_UNSUPPORTED_MARKER }; return { type: "text", text: (p as OcxTextContent).text }; }); - chatMsg = { role: "user", content: chatParts }; + // A developer message with images keeps the user-compatible shape it has always had on + // this wire. One carrying only a document has no such precedent, and demoting it would + // undo the role this adapter just finished preserving. + chatMsg = { + role: msg.role === "developer" && !hasImages ? developerWireRole : "user", + content: chatParts, + }; } if (pendingToolCalls.length > 0) deferredBarrierMessages.push(chatMsg); else out.push(chatMsg); diff --git a/src/adapters/openai-chat/parallel-tool-calls.ts b/src/adapters/openai-chat/parallel-tool-calls.ts new file mode 100644 index 00000000000..89d6e4326f2 --- /dev/null +++ b/src/adapters/openai-chat/parallel-tool-calls.ts @@ -0,0 +1,32 @@ +import type { OcxProviderConfig } from "../../types"; + +/** + * The `parallel_tool_calls` value for a translated Chat request, or `undefined` to omit the key. + * + * A provider has three states here, not two, and the third is the default for every provider that + * never configured the knob. While the call site only branched on the two configured states, a + * caller's own explicit `parallel_tool_calls: false` reached the parser, was carried on + * `options.parallelToolCalls`, and was then dropped on the way to the wire — the upstream stayed + * free to emit concurrent calls and the response looked entirely normal (#5211). + * + * Only an explicit request-side `false` is forwarded in the unset state. `true` is already the + * upstream default, so emitting it would introduce the knob to strict OpenAI-compatible hosts + * that have never had to accept it, which is the reason the configured opt-out below omits the + * key rather than sending `false`. + */ +export function chatParallelToolCallsWireValue( + provider: OcxProviderConfig, + requested: boolean | undefined, +): boolean | undefined { + if (provider.parallelToolCalls === false) { + // NIM documents the Boolean defaulting to false and kimi rejects true; pin the wire bit so + // Codex cannot opt in via request.options. Other opted-out providers omit the field, but a + // self-hosted gateway that DOES honor it and keeps emitting parallel calls without it can opt + // in via pinParallelToolCallsFalse. + const pinned = provider.baseUrl === "https://integrate.api.nvidia.com/v1" + || provider.pinParallelToolCallsFalse === true; + return pinned ? false : undefined; + } + if (provider.parallelToolCalls === true) return requested !== false; + return requested === false ? false : undefined; +} diff --git a/src/adapters/openai-chat/passthrough.ts b/src/adapters/openai-chat/passthrough.ts index f7682b7a62d..e653017bbcc 100644 --- a/src/adapters/openai-chat/passthrough.ts +++ b/src/adapters/openai-chat/passthrough.ts @@ -9,6 +9,7 @@ import { debugProviderDiagnostic } from "../../lib/debug"; import { isDebugEnabled } from "../../lib/debug-settings"; import { modelRecordValue } from "../../reasoning-effort"; import { modelInList, type OcxProviderConfig } from "../../types"; +import { chatParallelToolCallsWireValue } from "./parallel-tool-calls"; const CHAT_PASSTHROUGH_FIELDS = [ "audio", @@ -108,12 +109,12 @@ export function buildOpenAIChatPassthroughRequest( body.prompt_cache_key = rawBody.prompt_cache_key; } if (Array.isArray(rawBody.tools) && rawBody.tools.length > 0) { - if (provider.parallelToolCalls === true) { - body.parallel_tool_calls = rawBody.parallel_tool_calls !== false; - } else if (provider.parallelToolCalls === false - && (provider.baseUrl === "https://integrate.api.nvidia.com/v1" || provider.pinParallelToolCallsFalse === true)) { - body.parallel_tool_calls = false; - } + // Same three provider states as the translated path, and the same defect in the unset one: + // a caller's explicit false was dropped here too (#5211). The native route reads the bit off + // the raw request rather than the parsed options, since nothing projects this body. + const requested = typeof rawBody.parallel_tool_calls === "boolean" ? rawBody.parallel_tool_calls : undefined; + const parallelToolCalls = chatParallelToolCallsWireValue(provider, requested); + if (parallelToolCalls !== undefined) body.parallel_tool_calls = parallelToolCalls; } if (stream) { const callerOptions = rawBody.stream_options !== null diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts index 8d6e8bc63d7..f077f92c2c5 100644 --- a/src/adapters/registry.ts +++ b/src/adapters/registry.ts @@ -181,8 +181,9 @@ export function createRegisteredAdapter( const definition = getAdapterDefinition(provider.adapter); if (!definition) throw new Error(`Unknown adapter: ${provider.adapter}`); const adapter = definition.create(provider, context); - if (effectiveAdapterContract(provider.adapter).wire !== "openai-responses") { - withInputMediaGuard(adapter); + const wire = effectiveAdapterContract(provider.adapter).wire; + if (wire !== "openai-responses") { + withInputMediaGuard(adapter, wire); } const buildRequest = adapter.buildRequest.bind(adapter); adapter.buildRequest = (parsed, incoming) => { diff --git a/src/chat/inbound.ts b/src/chat/inbound.ts index b12761c8e4e..1669207c447 100644 --- a/src/chat/inbound.ts +++ b/src/chat/inbound.ts @@ -92,11 +92,32 @@ function userContentToBlocks(content: unknown): Rec[] { continue; } const videoUrl = videoUrlFromPart(raw); - if (videoUrl) blocks.push({ type: "input_video", video_url: videoUrl }); + if (videoUrl) { + blocks.push({ type: "input_video", video_url: videoUrl }); + continue; + } + const file = fileFromPart(raw); + if (file) blocks.push(file); } return blocks; } +/** + * A Chat Completions `file` part carrying inline bytes, as the Responses `input_file` block. + * + * Nothing here recognized the shape, so the part reached the end of the loop with no branch and + * was dropped in silence (#5212). A part with no inline bytes is still not translatable and is + * left to the untranslated-media refusal, which runs before this loop. + */ +function fileFromPart(part: Rec): Rec | null { + if (part.type !== "file" && part.type !== "input_file") return null; + const file = isRec(part.file) ? part.file : part; + const fileData = file.file_data; + if (typeof fileData !== "string" || fileData.length === 0) return null; + const filename = typeof file.filename === "string" && file.filename.length > 0 ? file.filename : undefined; + return { type: "input_file", file_data: fileData, ...(filename ? { filename } : {}) }; +} + /** * The assistant's prior thinking, as plaintext, from either Chat spelling. * @@ -218,11 +239,67 @@ function toolChoiceToResponses(choice: unknown, body: Rec): void { body.tool_choice = { type: "function", name }; return; } + if (choice.type === "allowed_tools") { + body.tool_choice = allowedToolsChoiceToResponses(choice); + return; + } if (isRec(choice.function) && typeof choice.function.name === "string") { body.tool_choice = { type: "function", name: choice.function.name }; } } +/** + * Chat Completions nests the subset under `allowed_tools`, Responses carries `mode`/`tools` + * on the choice itself, and each entry names its tool under a member keyed by its own type + * (`{"type":"function","function":{"name"}}`) rather than a flat `name`. Neither level lines up + * with `mapToolChoice`, so an unflattened choice fell past every branch and the caller's subset + * was dropped while the full catalogue was still advertised (#5211). + * + * An entry nobody can name is refused rather than skipped: dropping one widens the very subset + * the caller sent this field to narrow. + */ +function allowedToolsChoiceToResponses(choice: Rec): Rec { + const spec = isRec(choice.allowed_tools) ? choice.allowed_tools : choice; + if (!Array.isArray(spec.tools) || spec.tools.length === 0) { + throw new ChatCompletionsRequestError("tool_choice.allowed_tools requires a non-empty tools array"); + } + return { + type: "allowed_tools", + mode: spec.mode === "required" ? "required" : "auto", + tools: spec.tools.map(allowedToolEntryToResponses), + }; +} + +/** Hosted entries are named by their type alone; a function or custom entry must carry a name. */ +const HOSTED_ALLOWED_TOOL_TYPES = new Set([ + "web_search", + "web_search_preview", + "image_generation", + "image_gen", + "tool_search", +]); +const NAMED_ALLOWED_TOOL_TYPES = new Set(["function", "custom"]); + +function allowedToolEntryToResponses(raw: unknown): Rec { + if (!isRec(raw)) { + throw new ChatCompletionsRequestError("tool_choice.allowed_tools.tools entries must be objects"); + } + const type = typeof raw.type === "string" && raw.type.length > 0 ? raw.type : "function"; + if (!NAMED_ALLOWED_TOOL_TYPES.has(type) && !HOSTED_ALLOWED_TOOL_TYPES.has(type)) { + // An unknown selector kind is not a narrower subset, it is a subset nobody can evaluate. + throw new ChatCompletionsRequestError(`unsupported tool_choice.allowed_tools.tools entry type: ${type}`); + } + const nested = isRec(raw[type]) ? raw[type] as Rec : undefined; + const name = typeof raw.name === "string" && raw.name.length > 0 + ? raw.name + : nested !== undefined && typeof nested.name === "string" && nested.name.length > 0 + ? nested.name + : undefined; + if (name !== undefined) return { type, name }; + if (HOSTED_ALLOWED_TOOL_TYPES.has(type)) return { type }; + throw new ChatCompletionsRequestError("tool_choice.allowed_tools.tools entries require a name"); +} + function responseFormatToText(format: unknown): Rec | undefined { if (format === undefined) return undefined; if (!isRec(format)) throw new ChatCompletionsRequestError("response_format must be an object"); diff --git a/src/claude/inbound-content-options.ts b/src/claude/inbound-content-options.ts index 0b59a93073a..fbfe6ed503e 100644 --- a/src/claude/inbound-content-options.ts +++ b/src/claude/inbound-content-options.ts @@ -35,6 +35,12 @@ export function toolsToResponses(tools: unknown): Rec[] | undefined { // call, so carry the source intent instead of the destination default. A // non-boolean value is not a valid Anthropic opt-in and must not become one. strict: typeof raw.strict === "boolean" ? raw.strict : false, + // Anthropic restricts who may invoke a tool through allowed_callers. Nothing read it, + // so the restriction never reached the internal tool and every destination rebuilt the + // declaration without it while the request still succeeded (#5210). + ...(Array.isArray(raw.allowed_callers) + ? { allowed_callers: raw.allowed_callers.filter((c): c is string => typeof c === "string") } + : {}), }); continue; } diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index e3664bf01c2..2938c9b2a9d 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -19,6 +19,7 @@ import { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, f import { systemToInstructions, toolsToResponses, toolChoiceToResponses } from "./inbound-content-options"; import { stabilizeClaudeInstructionsForPromptCache } from "./inbound-cache-stabilize"; import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; +import { inlineDocumentMarker } from "../responses/inline-document"; import { createTranslatorBudget, type TranslatorBudget } from "../lib/translator-budget"; @@ -41,6 +42,26 @@ function imageBlockToInputImage(block: Rec): Rec | null { return null; } +function documentTitle(block: Rec): string | undefined { + return typeof block.title === "string" && block.title.length > 0 ? block.title : undefined; +} + +/** An Anthropic base64 document as the Responses `input_file` block that carries its bytes. */ +function documentBlockToInputFile(block: Rec): Rec | null { + const source = block.source; + if (!isRec(source) || source.type !== "base64") return null; + const mediaType = typeof source.media_type === "string" && source.media_type.length > 0 + ? source.media_type + : "application/octet-stream"; + if (typeof source.data !== "string" || source.data.length === 0) return null; + const title = documentTitle(block); + return { + type: "input_file", + file_data: `data:${mediaType};base64,${source.data}`, + ...(title !== undefined ? { filename: title } : {}), + }; +} + function toolResultOutput(block: Rec): string | Rec[] { const isError = block.is_error === true; const content = block.content; @@ -55,9 +76,11 @@ function toolResultOutput(block: Rec): string | Rec[] { const img = imageBlockToInputImage(item); if (img) out.push(img); } else if (item.type === "document") { - // Same marker as the user-message document case below: the model should see the - // attachment happened instead of an empty tool output. - out.push({ type: "input_text", text: `[document${typeof item.title === "string" ? `: ${item.title}` : ""}]` }); + // Tool output has no structured document carrier on this route — the Responses tool + // output vocabulary has no input_file block, and every adapter's tool-result path + // flattens to text — so this keeps the #939 marker. The user-message branch below is + // where bytes survive. Recorded as the remaining half of #5212. + out.push({ type: "input_text", text: inlineDocumentMarker(documentTitle(item)) }); } } if (isError) out.unshift({ type: "input_text", text: "[tool error]" }); @@ -208,9 +231,12 @@ function userMessageToItems(content: unknown, input: Rec[], elide: SkillElisionC break; } case "document": - // No Responses equivalent for raw document blocks; surface the title so the - // model at least sees the attachment happened. - pending.push({ type: "input_text", text: `[document${typeof raw.title === "string" ? `: ${raw.title}` : ""}]` }); + // A base64 document now rides the Responses input_file block, so a target with a + // counterpart receives the bytes instead of a sentence about them (#5212). Every other + // source is a reference this route cannot dereference, and keeps the marker #939 + // introduced — which is also what a target with no document representation still sees. + pending.push(documentBlockToInputFile(raw) + ?? { type: "input_text", text: inlineDocumentMarker(documentTitle(raw)) }); break; default: break; // thinking/redacted_thinking never appear in user messages; ignore unknowns diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 93edd25d0fc..abf7e5b0bb7 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -258,6 +258,7 @@ export const providerConfigSchema = z.object({ requiresAdjacentResponsesToolResults: z.boolean().optional(), requiresPairedResponsesToolResults: z.boolean().optional(), annotateEmptyToolOutputs: z.boolean().optional(), + foldDeveloperRoleToSystem: z.boolean().optional(), fastWire: fastWireSchema.nullable().optional(), supportsServiceTier: z.boolean().optional(), modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), diff --git a/src/responses/inline-document.ts b/src/responses/inline-document.ts new file mode 100644 index 00000000000..208d3f84d02 --- /dev/null +++ b/src/responses/inline-document.ts @@ -0,0 +1,58 @@ +import type { OcxDocumentContent } from "../types"; + +const DATA_URL = /^data:([^;,]+)((?:;[^;,]*)*),(.*)$/s; +const BASE64_PAYLOAD = /^[A-Za-z0-9+/]+={0,2}$/; + +/** The one marker vocabulary for an attached document, derived from what the part knows. */ +export function inlineDocumentMarker(filename: string | undefined): string { + return filename !== undefined && filename.length > 0 ? `[document: ${filename}]` : "[document]"; +} + +/** + * An inline document part from a `data:` URL, or `undefined` when there are no usable bytes. + * + * A reference with no payload — a `file_id`, a bare filename — is deliberately not a document: + * there is nothing to carry, and minting a part for it would claim an attachment the request + * never contained. Those keep the marker path they already had. + */ +export function inlineDocumentFromDataUrl( + fileData: string | undefined, + filename: string | undefined, +): OcxDocumentContent | undefined { + if (typeof fileData !== "string" || fileData.length === 0) return undefined; + const match = DATA_URL.exec(fileData); + if (!match) return undefined; + const mediaType = match[1]!; + const payload = match[3]!; + if (!hasBase64Parameter(match[2] ?? "") || !BASE64_PAYLOAD.test(payload)) return undefined; + return { + type: "document", + text: inlineDocumentMarker(filename), + mediaType, + data: payload, + ...(filename !== undefined && filename.length > 0 ? { filename } : {}), + }; +} + +/** The `data:` URL spelling of a document part, for wires whose counterpart takes one. */ +export function inlineDocumentDataUrl(part: OcxDocumentContent): string { + return `data:${part.mediaType};base64,${part.data}`; +} + +/** + * Whether this string is a `data:` URL this module would actually decode. + * + * The media-type scanner and this parser have to agree exactly. A looser scanner would exempt an + * attachment from the untranslated-media refusal that the parser then reduces to a marker, which + * is the silent drop the refusal exists to prevent. `;base64` must be a whole parameter token, + * not a substring of one: `;notbase64,` and `;x=base64,` are not base64 payloads. + */ +export function isInlineDocumentDataUrl(value: unknown): boolean { + if (typeof value !== "string" || value.length === 0) return false; + const match = DATA_URL.exec(value); + return match !== null && hasBase64Parameter(match[2] ?? "") && BASE64_PAYLOAD.test(match[3]!); +} + +function hasBase64Parameter(parameters: string): boolean { + return parameters.split(";").includes("base64"); +} diff --git a/src/responses/input-media.ts b/src/responses/input-media.ts index 43f8db583ec..be4909198e9 100644 --- a/src/responses/input-media.ts +++ b/src/responses/input-media.ts @@ -1,3 +1,5 @@ +import { isInlineDocumentDataUrl } from "./inline-document"; + /** Input kinds for which the normalized request has no lossless content carrier. */ export type UntranslatedInputMedia = "audio" | "file"; @@ -7,20 +9,47 @@ function isRecord(value: unknown): value is RecordValue { return value !== null && typeof value === "object" && !Array.isArray(value); } -function mediaKind(value: unknown): UntranslatedInputMedia | undefined { +/** + * Whether the position being scanned has a converter that builds a document part. + * + * Only user content does. A tool output, a system or assistant message, and a Chat `developer` + * message are all flattened to text by their converters, so exempting an attachment there would + * turn today's explicit refusal into the silent drop this scanner exists to prevent. + */ +type CarrierPosition = "user-content" | "flattened"; + +function mediaKind(value: unknown, position: CarrierPosition): UntranslatedInputMedia | undefined { if (!isRecord(value)) return undefined; if (value.type === "input_audio" || value.type === "audio") return "audio"; - if (value.type === "input_file" || value.type === "file" || value.type === "document") return "file"; + if (value.type === "input_file" || value.type === "file" || value.type === "document") { + // A document that carries its own bytes has a lossless carrier in user content, so refusing + // it there would reject the very request #5212 exists to preserve. A reference with no + // payload has none anywhere: translated adapters cannot dereference a file_id or a remote + // source. + return position === "user-content" && carriesInlineDocumentBytes(value) ? undefined : "file"; + } // A file-id-only image is not pixels: translated adapters cannot dereference it. if (value.type === "input_image" && typeof value.file_id === "string" && value.file_id.length > 0 && !(typeof value.image_url === "string" && value.image_url.length > 0)) return "file"; return undefined; } -function contentMedia(content: unknown): UntranslatedInputMedia | undefined { +/** Presence of a base64 payload only. No payload is read, decoded, copied or returned. */ +function carriesInlineDocumentBytes(value: RecordValue): boolean { + if (isInlineDocumentDataUrl(value.file_data)) return true; + // Chat Completions nests the payload under `file`. + if (isRecord(value.file) && isInlineDocumentDataUrl(value.file.file_data)) return true; + // Anthropic nests it under a base64 `source`. + return isRecord(value.source) + && value.source.type === "base64" + && typeof value.source.data === "string" + && value.source.data.length > 0; +} + +function contentMedia(content: unknown, position: CarrierPosition): UntranslatedInputMedia | undefined { if (!Array.isArray(content)) return undefined; for (const part of content) { - const kind = mediaKind(part); + const kind = mediaKind(part, position); if (kind) return kind; } return undefined; @@ -35,13 +64,16 @@ export function untranslatedResponsesInputMedia(body: unknown): UntranslatedInpu if (!isRecord(body) || !Array.isArray(body.input)) return undefined; for (const item of body.input) { if (!isRecord(item)) continue; - const direct = mediaKind(item); + const direct = mediaKind(item, "flattened"); if (direct) return direct; if (item.type === "function_call_output" || item.type === "custom_tool_call_output") { - const kind = contentMedia(item.output); + const kind = contentMedia(item.output, "flattened"); if (kind) return kind; } else if (item.type === "message" || item.type === undefined) { - const kind = contentMedia(item.content); + // `inputContentParts` runs for user and developer messages only; a system message is + // flattened to text by the parser. + const role = item.role; + const kind = contentMedia(item.content, role === "user" || role === "developer" ? "user-content" : "flattened"); if (kind) return kind; } } @@ -53,7 +85,9 @@ export function untranslatedChatInputMedia(body: unknown): UntranslatedInputMedi if (!isRecord(body) || !Array.isArray(body.messages)) return undefined; for (const message of body.messages) { if (!isRecord(message)) continue; - const kind = contentMedia(message.content); + // Only the `user` branch of the Chat projection builds content blocks. `system`, + // `developer`, `assistant` and `tool` all reduce their content to a string. + const kind = contentMedia(message.content, message.role === "user" ? "user-content" : "flattened"); if (kind) return kind; } return undefined; diff --git a/src/responses/parser-content.ts b/src/responses/parser-content.ts index 7675a42f7e8..00b174320ca 100644 --- a/src/responses/parser-content.ts +++ b/src/responses/parser-content.ts @@ -1,4 +1,5 @@ import type { OcxContentPart, OcxTextContent } from "../types"; +import { inlineDocumentFromDataUrl } from "./inline-document"; export function isObj(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); @@ -90,8 +91,13 @@ export function inputContentParts(blocks: unknown): string | OcxContentPart[] { if (fileId) { parts.push({ type: "text", text: `[file: ${fileId}]` }); } else if (fileData) { - // Inline file_data is often large base64. Preserve only its presence and name, never bytes. - parts.push({ type: "text", text: filename ? `[file: ${filename}]` : "[file: inline data]" }); + // Inline bytes used to be reduced to a name here, which meant no adapter could forward + // the attachment even to a target that has a representation for it, and a title-only + // forward came back as a confident answer about a document the model never saw (#5212). + // The marker survives on the part for every wire that still cannot carry one. Bytes with + // no declared media type are not decodable into a document and keep the marker they had. + const document = inlineDocumentFromDataUrl(fileData, filename); + parts.push(document ?? { type: "text", text: filename ? `[file: ${filename}]` : "[file: inline data]" }); } // A bare filename is not a file resource in the Responses schema, so omit it rather than // fabricating a "[file: ...]" marker for an attachment that was never sent. diff --git a/src/responses/parser-tools.ts b/src/responses/parser-tools.ts index 8812b0bbe66..763b061c8f6 100644 --- a/src/responses/parser-tools.ts +++ b/src/responses/parser-tools.ts @@ -61,6 +61,9 @@ export function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined parameters: normalizeParameters(t.parameters), }; if (t.strict !== undefined) tool.strict = t.strict as boolean; + if (Array.isArray(t.allowed_callers)) { + tool.allowedCallers = (t.allowed_callers as unknown[]).filter((c): c is string => typeof c === "string"); + } if (namespace) tool.namespace = namespace; out.push(tool); }; diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 396f2170b2e..51bb36f5245 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -245,7 +245,9 @@ export function parseRequest( case "system": { pendingReasoning.length = 0; const text = inputContentParts(msg.content); - const flat = typeof text === "string" ? text : text.map(p => (p.type === "text" ? p.text : "")).join(""); + const flat = typeof text === "string" + ? text + : text.map(p => (p.type === "text" || p.type === "document" ? p.text : "")).join(""); if (flat.length > 0) systemPrompt.push(flat); break; } diff --git a/src/responses/schema.ts b/src/responses/schema.ts index fced1a9e6e2..556bc9fd978 100644 --- a/src/responses/schema.ts +++ b/src/responses/schema.ts @@ -122,6 +122,9 @@ export const toolSchema = z.object({ description: z.string().optional(), parameters: z.record(z.string(), z.unknown()).optional(), strict: z.boolean().optional(), + // Unknown keys are stripped here, so a field the parser is expected to read has to be + // declared: an undeclared allowed_callers never reached buildTools at all (#5210). + allowed_callers: z.array(z.string()).optional(), }); const builtinToolSchema = z.object({ type: z.string() }).loose(); diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index c8f53c861ae..e5228d8272c 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1024,6 +1024,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { pinParallelToolCallsFalse: "editor", terminalContinuationGuard: "editor", openaiChatEofTolerance: "editor", + foldDeveloperRoleToSystem: "editor", promptCacheKey: "editor", chatServiceTier: "editor", responsesItemIdRepair: "editor", diff --git a/src/server/responses/input-admission.ts b/src/server/responses/input-admission.ts index ca11fd60eb5..950a3d90505 100644 --- a/src/server/responses/input-admission.ts +++ b/src/server/responses/input-admission.ts @@ -85,9 +85,19 @@ function imageTokens(imageUrl: string): number { function contentPartTokens(part: OcxContentPart, modelId: string): number { if (part.type === "image") return imageTokens(part.imageUrl); if (part.type === "video") return imageTokens(part.videoUrl); + // An inline document is a base64 payload, not a sentence: estimating it from its marker would + // admit a request whose real input is orders of magnitude larger. Counted arithmetically — + // rebuilding the data URL here would materialize a second request-sized string just to measure it. + if (part.type === "document") return base64PayloadTokens(part.data); return estimateTokens(part.text, modelId); } +function base64PayloadTokens(base64: string): number { + if (base64.length === 0) return 0; + const decoded = Math.floor((base64.length * 3) / 4); + return Math.max(1, Math.ceil(decoded / IMAGE_BYTES_PER_TOKEN)); +} + function contentTokens(content: string | readonly OcxContentPart[], modelId: string): number { if (typeof content === "string") return estimateTokens(content, modelId); let total = 0; diff --git a/src/types.ts b/src/types.ts index 93390ce6063..747fc17c756 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,7 @@ export { isAllowedToolChoice, toolChoiceToolPredicate, declaresCodeModeExec, + toolRestrictsCallers, NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES, } from "./types/tools"; @@ -43,6 +44,7 @@ export type { OcxToolResultMessage, OcxTextContent, OcxImageContent, + OcxDocumentContent, OcxContentPart, OcxThinkingContent, OcxToolCall, diff --git a/src/types/provider.ts b/src/types/provider.ts index e67041ee5dd..bbc3d6b5c35 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -816,6 +816,19 @@ export interface OcxProviderConfig { * incomplete JSON, missing arguments, and empty streams remain truncation errors. */ openaiChatEofTolerance?: boolean; + /** + * Opt-in: fold a `developer` message into a `system` message instead of forwarding the role. + * + * `developer` is part of the Chat Completions message role set, so forwarding it is the + * default. The role used to be decided by testing the base URL host against + * `api.openai.com`, which assumed every OpenAI-compatible gateway rejects a standard role + * until proven otherwise — including gateways that proxy OpenAI itself — and quietly gave the + * instruction `system` precedence instead (#5213). This flag exists for a destination that + * genuinely rejects the role, so the conversion is a recorded decision about that destination + * rather than an inference from its hostname. Position is unaffected either way: the message + * keeps its slot in the conversation. + */ + foldDeveloperRoleToSystem?: boolean; /** * Opt-in: forward `prompt_cache_key` to the upstream `/chat/completions` body. * OpenAI-specific extension; strict backends (Groq, Cerebras, etc.) reject unknown diff --git a/src/types/request.ts b/src/types/request.ts index 73bc671c8c2..7a73eaa5987 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -210,8 +210,34 @@ export interface OcxVideoContent { videoUrl: string; } -/** A user/developer message content part: text or native media. */ -export type OcxContentPart = OcxTextContent | OcxImageContent | OcxVideoContent; +/** + * An attached document carried as bytes rather than as a description of itself. + * + * Both inbound parsers used to reduce an attachment to a title before any adapter ran, so no + * adapter could forward one even to a target that has a representation for it, and the caller + * could not tell "the model read the document" from "the model was told a document existed" + * (#5212). + * + * `text` is that marker, derived once from what the part knows and kept on the part itself. + * Every text-only consumer in the tree reaches a `.text` fallback for a part it does not + * recognize, so carrying it here means a wire with no document representation still states the + * attachment instead of emitting `undefined` or a mislabelled `[video]`. Only the wires that + * have a counterpart read `data`. + */ +export interface OcxDocumentContent { + type: "document"; + /** `[document: name]` marker, for every wire with no document representation. */ + text: string; + /** IANA media type of the payload, for example `application/pdf`. */ + mediaType: string; + /** Base64 payload with no `data:` prefix. */ + data: string; + /** The document's own name: an Anthropic document title or a Chat file part's filename. */ + filename?: string; +} + +/** A user/developer message content part: text, native media, or an attached document. */ +export type OcxContentPart = OcxTextContent | OcxImageContent | OcxVideoContent | OcxDocumentContent; export interface OcxThinkingContent { type: "thinking"; diff --git a/src/types/tools.ts b/src/types/tools.ts index 83decd85b14..70d777d7491 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -3,6 +3,12 @@ export interface OcxTool { description: string; parameters: Record; strict?: boolean; + /** + * Anthropic `tools[*].allowed_callers`: which callers may invoke this tool. Carried rather + * than diagnosed, because rebuilding the declaration without it hands the model a tool the + * caller had restricted and returns a normal response (#5210). + */ + allowedCallers?: string[]; /** MCP namespace (e.g. "mcp__context7") for tools flattened out of a Responses "namespace" tool. */ namespace?: string; /** Freeform/custom tool (e.g. apply_patch): the model's call must be relayed as a custom_tool_call. */ @@ -31,6 +37,19 @@ export function namespacedToolName(namespace: string | undefined, name: string): return namespace ? `${namespace}__${name}` : name; } +/** + * Whether a declaration actually narrows who may call the tool. + * + * `["direct"]` is the state every unrestricted tool is already in, so treating it as a + * restriction would refuse ordinary traffic. Mirrors the `caller_mode` predicate in + * src/claude/compatibility.ts, which draws the same line. + */ +export function toolRestrictsCallers(tool: Pick): boolean { + const callers = tool.allowedCallers; + if (callers === undefined) return false; + return !(callers.length === 1 && callers[0] === "direct"); +} + /** * Dotted alias of a namespaced tool's wire name. Some routed providers (observed: muse-spark * via opencode-go) echo a namespaced tool call as "." instead of the flattened diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 28a464e3e03..a21be43a466 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -16,8 +16,9 @@ Shared parsing and streaming follow the [request-copy](../transports/byte-accoun Runtime adapter construction has one authority: `src/adapters/registry.ts`. -The OpenCode Go [chronological instruction exception](../providers/chat-compat.md#opencode-go-chronological-instructions) -uses the provider registry's destination identity inside the Chat adapter; it adds no adapter factory. +[Chronological instruction ordering](../providers/chat-compat.md#chronological-in-conversation-instructions) +is now uniform across destinations, so the Chat adapter no longer consults the provider registry's +destination identity for it; it adds no adapter factory. `src/server/adapter-resolve.ts` may resolve a provider/model onto an adapter id, but it does not maintain a second adapter factory inventory. The selected persisted/configured adapter id remains an untrusted string until the registry lookup succeeds. Unknown ids fail with the existing `Unknown adapter: ` error instead of widening configuration types around a closed compile-time union. @@ -196,8 +197,18 @@ variant; unrelated model families retain their existing suffix precedence. `src/responses/input-media.ts` inspects actual content blocks and typed tool-output arrays without parsing text or function arguments, copying attachment payloads, resolving file IDs, -or fetching URLs. Audio, files/documents and file-ID-only images have no lossless normalized -carrier. The scanner returns only an input-kind name, never client content. +or fetching URLs. Audio and file-ID-only images have no lossless normalized carrier, and +neither does a file or document reference that carries no bytes. The scanner returns only an +input-kind name, never client content. + +A document that carries its own base64 bytes is the one exception, and only in user or +developer message content: `src/responses/inline-document.ts` decodes it into the +`OcxDocumentContent` part, which the Anthropic, OpenAI Chat and Gemini wires emit as a native +document, file part and `inline_data` respectively. Every other position — tool output, system +and assistant content — is still refused, because those converters reduce their content to text +and exempting them would restore the silent drop the scanner exists to prevent. The scanner and +the decoder share one predicate so a request cannot be exempted here and reduced to a marker +there. `src/adapters/input-media-guard.ts` guards adapters created by the registry after effective wire selection. A translated `buildRequest` refuses these inputs through the existing 400 diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index e6171845efa..90dacb24366 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -51,9 +51,9 @@ separate. Coverage lives in `tests/server/audio-client.test.ts`, `tests/server/audio-dictation.test.ts` and `tests/server/live-call-bindings.test.ts`. Translated Claude timeline reminders use the Chat adapter's -[OpenCode Go instruction ordering](../providers/chat-compat.md#opencode-go-chronological-instructions) -on its exact supported route. This is separate from trailing-notice stabilization -and from native Chat message passthrough. +[chronological instruction ordering](../providers/chat-compat.md#chronological-in-conversation-instructions) +on every destination. This is separate from trailing-notice stabilization and from +native Chat message passthrough. Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](../transports/responses.md#passthrough-sse-stream-shapes-314). @@ -334,7 +334,11 @@ default and require an explicit `thinking:{type:"disabled"}` to stop. The native Chat path retains provider-native file/audio blocks. When a request instead needs Chat-to-Responses projection, `src/chat/inbound.ts` rejects recognized audio/file content -before it can become empty text, regardless of message role. Legacy `function`-role images +before it can become empty text. The one exception is a `file` part carrying inline base64 +bytes in a `user` message: that projection builds an `input_file` block and the bytes survive +to any wire with a counterpart. The same part in a `system`, `developer`, `assistant` or +`tool` message is still refused, because those branches flatten their content to a string. +Legacy `function`-role images also return an explicit error; their call/result pairing is not implemented by this projection. Modern `tool` images continue through the existing following-user carrier. These errors state an OpenCodex conversion limit, not a provider capability claim. Final Responses-to-adapter diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 0121fb76e40..de68ef9f7ae 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -15,9 +15,10 @@ the [bounded ingestion contract](transports/inventory.md#bounded-response-ingest | `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders receive no unsupported reasoning/thinking controls, while known ladders retain per-target resolution. | | `src/adapters/openai-responses.ts` | Native OpenAI/ChatGPT Responses passthrough. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | -| `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `tool-call-validation.ts`, `tool-schema.ts`, `errors.ts`). Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | -| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. | -| `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. | +| `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `tool-call-validation.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | +| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. | +| `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. A caller-declared strict tool selects `functionCallingConfig.mode: "VALIDATED"` in place of the absent-choice default; `NONE`, `ANY` and a forced-name choice are stronger constraints the caller asked for and are never overwritten. | +| `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog and JWT support RPCs remain outside inference-send accounting. | diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 6fe286bd76d..cf601215ed8 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -9,18 +9,27 @@ Native Codex Spark-specific request exceptions are absent. General Lite and name remain shared [Responses compatibility](../transports/responses.md#responses-httpsse), including other providers whose models happen to share a name fragment. -## OpenCode Go chronological instructions - -For the registry-recognized OpenCode Go Chat destination and exact model -`deepseek-v4.1-flash`, `src/adapters/openai-chat.ts` keeps text-only timeline -developer messages in place as system messages. Appending a reminder therefore -does not hoist new text into the leading system prompt and rewrite the existing -serialized message prefix. Pending tool results still precede deferred reminders. -The base system prompt, vision conversion and native OpenAI developer roles retain -their existing behavior; other Chat destinations and models retain leading-system -folding. This is independent of the Claude trailing-notice stabilization option -and does not guarantee upstream cache hits. Regression coverage is in -`tests/adapters/openai/openai-chat-system-order.test.ts`. +## Chronological in-conversation instructions + +`src/adapters/openai-chat/messages.ts` keeps a text-only timeline developer message in the +slot it arrived in, on every Chat destination and model. Appending a reminder therefore does +not hoist new text into the leading system prompt and rewrite the existing serialized message +prefix, and a mid-conversation instruction no longer moves ahead of the turns it was written +to follow. Pending tool results still precede deferred reminders. This was previously scoped +to the registry-recognized OpenCode Go destination and the exact model +`deepseek-v4.1-flash`, which made prompt-prefix stability read as a property of that one +destination. The base system prompt, vision conversion and native OpenAI developer roles +retain their existing behavior. This is independent of the Claude trailing-notice +stabilization option and does not guarantee upstream cache hits. Regression coverage is in +`tests/adapters/openai/openai-chat-system-order.test.ts` and +`tests/adapters/openai/openai-chat-developer-position.test.ts`. + +The role that slot carries is a separate decision. `developer` is part of the Chat Completions +message role set and is forwarded as itself on every destination. A destination that genuinely +rejects the role sets `foldDeveloperRoleToSystem`, which converts it in place and still never +moves the message. The role was previously decided by testing the base URL host against +`api.openai.com`, so every OpenAI-compatible gateway was assumed not to support a standard role +until proven otherwise, and the instruction silently lost `developer` precedence. Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. @@ -418,8 +427,9 @@ The final registered adapter also checks the original input under the [untranslated-media contract](../adapters/registry.md#untranslated-input-media). Audio/file attachments cannot succeed merely because the normalized representation retained a text marker: translated adapters refuse them, while native Responses retains the original body. -Chat conversion rejects recognized audio/file parts before projection; the native Chat wire -is unchanged. No audio/file transport or automatic URL fetch is added, and no client filename, +Chat conversion rejects recognized audio/file parts before projection, except a user-content +file part whose inline base64 bytes now have a lossless carrier; the native Chat wire is +unchanged. No audio transport and no automatic URL fetch is added, and no client filename, payload, URL or metadata is included in the new error messages. The shared coding-agent projection (CodeBuddy, Qoder) carries tool-result images as diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 3f047315203..7252130843a 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -8,7 +8,7 @@ namespace handling retain their provider contract; the bounded native scope live [the shared catalog](../catalog.md#shared-catalog). Cursor's direct adapter does not enter the OpenAI Chat serializer's -[OpenCode Go instruction ordering](chat-compat.md#opencode-go-chronological-instructions). +[chronological instruction ordering](chat-compat.md#chronological-in-conversation-instructions). Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. diff --git a/structure/runtime.md b/structure/runtime.md index 5c19599c4f5..9d7316cfc4e 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -30,9 +30,10 @@ OAuth refresh coordination follows the [refresh-lock identity contract](catalog. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Cursor's localized native-shell names follow the [routing-commentary guard contract](providers/cursor.md#cursor-native-exec). -Chat request serialization owns the destination-scoped -[OpenCode Go instruction ordering](providers/chat-compat.md#opencode-go-chronological-instructions); -it requires no runtime lifecycle change or new configuration option. +Chat request serialization owns +[chronological instruction ordering](providers/chat-compat.md#chronological-in-conversation-instructions) +and the developer wire role; it requires no runtime lifecycle change, and its one +configuration option is a per-provider role opt-out. Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index e81ec55dc57..ddfe0a022c2 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -12,8 +12,9 @@ The existing Responses transport is divided by responsibility in the The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Cursor's localized native-shell names follow the [routing-commentary guard contract](../providers/cursor.md#cursor-native-exec). -The Chat adapter's [OpenCode Go instruction ordering](../providers/chat-compat.md#opencode-go-chronological-instructions) -changes translated message placement only; endpoint selection and transport stay with their existing owners. +The Chat adapter's [chronological instruction ordering](../providers/chat-compat.md#chronological-in-conversation-instructions) +changes translated message placement and the developer wire role only; endpoint selection and transport +stay with their existing owners. Shared parsing and streaming follow the [request-copy](byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](responses.md#passthrough-sse-stream-shapes-314). diff --git a/tests/adapters/adapter-input-media-guard.test.ts b/tests/adapters/adapter-input-media-guard.test.ts index c63c137d52b..ff98aa347d8 100644 --- a/tests/adapters/adapter-input-media-guard.test.ts +++ b/tests/adapters/adapter-input-media-guard.test.ts @@ -8,13 +8,17 @@ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../sr import { createTestTranslatorBudget, withTestTranslatorBudget } from "../helpers/translator-budget"; const AUDIO = { type: "input_audio", audio_url: "data:audio/wav;base64,YWJj" }; -const FILE = { type: "input_file", filename: "private.pdf", file_data: "data:application/pdf;base64,JVBERi0=" }; +// A reference this route cannot dereference: there are no bytes to carry anywhere. +const FILE = { type: "input_file", filename: "private.pdf", file_id: "file-private" }; +// The same attachment with its bytes. User content has a carrier for it (#5212); no other +// position does, because every other converter reduces its content to text. +const INLINE_FILE = { type: "input_file", filename: "private.pdf", file_data: "data:application/pdf;base64,JVBERi0=" }; function request(content: unknown[]): OcxParsedRequest { return parseRequest({ model: "test-model", input: [{ type: "message", role: "user", content }] }); } -function fakeAdapter() { +function fakeAdapter(wire: "openai-chat" | "cursor" = "openai-chat") { const seen = { builds: 0, runs: 0, terminals: 0 }; const adapter: ProviderAdapter = { name: "stub", @@ -26,7 +30,7 @@ function fakeAdapter() { async runTurn(_parsed, _incoming, emit) { seen.runs++; emit({ type: "done", endTurn: true }); }, localTerminal() { seen.terminals++; return { reason: "already answered" }; }, }; - return { adapter: withInputMediaGuard(adapter), seen }; + return { adapter: withInputMediaGuard(adapter, wire), seen }; } describe("typed input media inspection", () => { @@ -39,6 +43,43 @@ describe("typed input media inspection", () => { for (const type of ["function_call_output", "custom_tool_call_output"]) { expect(untranslatedResponsesInputMedia({ input: [{ type, call_id: "call1", output: [AUDIO] }] })).toBe("audio"); expect(untranslatedResponsesInputMedia({ input: [{ type, call_id: "call1", output: [FILE] }] })).toBe("file"); + // Bytes do not help here: the tool-output converter flattens its content to text. + expect(untranslatedResponsesInputMedia({ input: [{ type, call_id: "call1", output: [INLINE_FILE] }] })).toBe("file"); + } + }); + + test("an inline document is permitted only where a converter carries it", () => { + expect(untranslatedResponsesInputMedia(request([INLINE_FILE])._rawBody)).toBeUndefined(); + for (const role of ["developer", "user"]) { + expect(untranslatedResponsesInputMedia({ input: [{ type: "message", role, content: [INLINE_FILE] }] })) + .toBeUndefined(); + } + for (const role of ["system", "assistant"]) { + expect(untranslatedResponsesInputMedia({ input: [{ type: "message", role, content: [INLINE_FILE] }] })) + .toBe("file"); + } + }); + + test("a document still has to reach a wire that can hold its bytes", () => { + const incoming = { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }; + const carrier = fakeAdapter("openai-chat"); + carrier.adapter.buildRequest(request([INLINE_FILE]), incoming); + expect(carrier.seen.builds).toBe(1); + + // Cursor rebuilds user content as text, so admitting the bytes there would put the request + // upstream with only the marker and return a normal completion. + const nonCarrier = fakeAdapter("cursor"); + let failure: unknown; + try { nonCarrier.adapter.buildRequest(request([INLINE_FILE]), incoming); } catch (error) { failure = error; } + expect((failure as Error).message).toContain("OpenCodex cannot translate document input"); + expect((failure as Error).message).not.toContain("private.pdf"); + expect(nonCarrier.seen.builds).toBe(0); + }); + + test("a base64 look-alike parameter is not an inline payload", () => { + for (const fileData of ["data:text/plain;notbase64,abc", "data:text/plain;x=base64,abc", "data:text/plain,abc"]) { + expect(untranslatedResponsesInputMedia(request([{ type: "input_file", file_data: fileData }])._rawBody)) + .toBe("file"); } }); diff --git a/tests/adapters/anthropic/anthropic-tool-declaration-constraints.test.ts b/tests/adapters/anthropic/anthropic-tool-declaration-constraints.test.ts new file mode 100644 index 00000000000..841fe1fa933 --- /dev/null +++ b/tests/adapters/anthropic/anthropic-tool-declaration-constraints.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test"; +import { createAnthropicAdapter } from "../../../src/adapters/anthropic"; +import { createRegisteredAdapter } from "../../../src/adapters/registry"; +import { anthropicToResponsesBody } from "../../../src/claude/inbound"; +import { parseRequest } from "../../../src/responses/parser"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; + +/** + * #5210. `strict` and `allowed_callers` are declaration fields Anthropic defines, and the + * Messages-to-Messages route rebuilt every tool from name, description and input_schema alone. + * The request succeeded, so a caller had no way to learn that the schema was no longer enforced + * or that the tool had been offered to a caller it was fenced off from. Each case below reads + * the request the adapter actually sends. + */ + +const anthropicProvider = { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + apiKey: "sk-x", + authMode: "apiKey", +} as unknown as OcxProviderConfig; + +function claudeTool(extra: Record): Record { + return { + name: "tool_a", + description: "Controlled tool.", + input_schema: { type: "object", properties: {} }, + ...extra, + }; +} + +function parsedFromClaude(tool: Record): OcxParsedRequest { + return parseRequest(anthropicToResponsesBody({ + model: "anthropic/claude-sonnet-4.5", + max_tokens: 64, + messages: [{ role: "user", content: "Call the tool." }], + tools: [tool], + })); +} + +async function anthropicTools(tool: Record): Promise>> { + const { body } = await createAnthropicAdapter(anthropicProvider).buildRequest(parsedFromClaude(tool)); + return (JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as { + tools: Array>; + }).tools; +} + +describe("anthropic tool declarations carry their caller-supplied constraints", () => { + test("an explicit strict:true survives the round trip", async () => { + const [tool] = await anthropicTools(claudeTool({ strict: true })); + expect(tool.strict).toBe(true); + expect(tool.name).toBe("tool_a"); + expect(tool.input_schema).toEqual({ type: "object", properties: {} }); + }); + + test("an unstated strict stays absent rather than becoming an opt-out", async () => { + const [tool] = await anthropicTools(claudeTool({})); + expect(tool).not.toHaveProperty("strict"); + const [explicitFalse] = await anthropicTools(claudeTool({ strict: false })); + expect(explicitFalse).not.toHaveProperty("strict"); + }); + + test("allowed_callers reaches the upstream instead of being rebuilt away", async () => { + const [tool] = await anthropicTools(claudeTool({ allowed_callers: ["code_execution_20260120"] })); + expect(tool.allowed_callers).toEqual(["code_execution_20260120"]); + }); + + test("a tool without allowed_callers gains no key", async () => { + const [tool] = await anthropicTools(claudeTool({})); + expect(tool).not.toHaveProperty("allowed_callers"); + }); +}); + +describe("wires without an allowed_callers counterpart refuse rather than widen", () => { + const restricted = claudeTool({ allowed_callers: ["code_execution_20260120"] }); + const unrestricted = claudeTool({ allowed_callers: ["direct"] }); + const incoming = { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }; + + // The refusal is default-deny at the single guard every registered adapter passes through, so + // a wire that never learned about the field cannot quietly rebuild the declaration without it. + test.each([ + ["openai-chat", { adapter: "openai-chat", baseUrl: "https://gateway.example.internal/v1", apiKey: "k" }], + ["google", { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "key" }], + ["cursor", { adapter: "cursor", baseUrl: "https://api2.cursor.sh", apiKey: "k" }], + ["devin", { adapter: "devin", baseUrl: "https://api.devin.ai", apiKey: "k" }], + ["ollama-native", { adapter: "ollama-native", baseUrl: "http://127.0.0.1:11434", keyOptional: true }], + ])("the %s wire refuses a caller-restricted declaration", async (_name, config) => { + const adapter = createRegisteredAdapter(config as unknown as OcxProviderConfig); + await expect(Promise.resolve().then(() => adapter.buildRequest(parsedFromClaude(restricted), incoming))) + .rejects.toThrow(/cannot express tools\[\]\.allowed_callers/); + }); + + test("the Anthropic wire is the one that carries it", async () => { + const adapter = createRegisteredAdapter(anthropicProvider); + const { body } = await adapter.buildRequest(parsedFromClaude(restricted), incoming); + const sent = JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as { + tools: Array>; + }; + expect(sent.tools[0]!.allowed_callers).toEqual(["code_execution_20260120"]); + }); + + test('the unrestricted ["direct"] default is not treated as a restriction', async () => { + const adapter = createRegisteredAdapter({ + adapter: "openai-chat", + baseUrl: "https://gateway.example.internal/v1", + apiKey: "k", + } as unknown as OcxProviderConfig); + // Registered adapters may wrap buildRequest in a promise; await rather than assume a shape. + const { body } = await adapter.buildRequest(parsedFromClaude(unrestricted), incoming); + const built = JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as { + tools: Array<{ function: { name: string } }>; + }; + expect(built.tools.map(tool => tool.function.name)).toEqual(["tool_a"]); + }); +}); diff --git a/tests/adapters/google/google-strict-tool-validated-mode.test.ts b/tests/adapters/google/google-strict-tool-validated-mode.test.ts new file mode 100644 index 00000000000..f508162ec60 --- /dev/null +++ b/tests/adapters/google/google-strict-tool-validated-mode.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { createGoogleAdapter } from "../../../src/adapters/google"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; + +/** + * #5210 case 3. Gemini expresses schema-enforced function calling as + * `functionCallingConfig.mode: "VALIDATED"`. The mode was plumbed all the way to the wire + * compiler but was only reachable by matching a model name, so a caller that declared strict + * tools got an ordinary AUTO turn and a normal answer. These assertions read the compiled + * request body, which is the only place the difference is visible. + */ + +const provider = { + adapter: "google", + baseUrl: "https://generativelanguage.googleapis.com", + apiKey: "key", +} as unknown as OcxProviderConfig; + +const STRICT_TOOLS = [ + { name: "get_weather", description: "", parameters: { type: "object", properties: {} }, strict: true }, + { name: "shot", description: "", parameters: { type: "object", properties: {} } }, +]; +const LOOSE_TOOLS = STRICT_TOOLS.map(({ strict: _strict, ...rest }) => rest); + +async function toolConfig(tools: unknown[], toolChoice?: unknown): Promise { + const parsed = { + modelId: "gemini-3-pro", + stream: false, + options: toolChoice === undefined ? {} : { toolChoice }, + context: { messages: [{ role: "user", content: "hi" }], tools }, + } as unknown as OcxParsedRequest; + const { body } = await createGoogleAdapter(provider).buildRequest(parsed); + return (JSON.parse(body) as Record).toolConfig; +} + +describe("strict tool declarations select Gemini VALIDATED function calling", () => { + test("a strict declaration turns the absent-choice default into VALIDATED", async () => { + expect(await toolConfig(STRICT_TOOLS)).toEqual({ functionCallingConfig: { mode: "VALIDATED" } }); + expect(await toolConfig(STRICT_TOOLS, "auto")).toEqual({ functionCallingConfig: { mode: "VALIDATED" } }); + }); + + test("an allowed-tools subset in auto mode keeps VALIDATED when the subset is strict", async () => { + expect(await toolConfig(STRICT_TOOLS, { allowedTools: ["get_weather"], mode: "auto" })) + .toEqual({ functionCallingConfig: { mode: "VALIDATED" } }); + }); + + test("a subset that excludes the strict tool does not claim validation", async () => { + expect(await toolConfig(STRICT_TOOLS, { allowedTools: ["shot"], mode: "auto" })).toBeUndefined(); + }); + + test("without a strict declaration the wire is unchanged", async () => { + expect(await toolConfig(LOOSE_TOOLS)).toBeUndefined(); + expect(await toolConfig(LOOSE_TOOLS, "auto")).toBeUndefined(); + expect(await toolConfig(LOOSE_TOOLS, { allowedTools: ["get_weather"], mode: "auto" })).toBeUndefined(); + }); + + test("a stronger caller-chosen mode is never overwritten by VALIDATED", async () => { + expect(await toolConfig(STRICT_TOOLS, "none")).toEqual({ functionCallingConfig: { mode: "NONE" } }); + expect(await toolConfig(STRICT_TOOLS, "required")).toEqual({ functionCallingConfig: { mode: "ANY" } }); + expect(await toolConfig(STRICT_TOOLS, { allowedTools: ["get_weather"], mode: "required" })) + .toEqual({ functionCallingConfig: { mode: "ANY" } }); + expect(await toolConfig(STRICT_TOOLS, { name: "get_weather" })) + .toEqual({ functionCallingConfig: { mode: "ANY", allowedFunctionNames: ["get_weather"] } }); + }); + + test("a request with no declared tools gains no toolConfig", async () => { + expect(await toolConfig([])).toBeUndefined(); + }); +}); diff --git a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts index df5e8afc5b7..41a61c21023 100644 --- a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts +++ b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts @@ -90,7 +90,7 @@ function assertWireInvariants(messages: ChatMsg[]): void { } describe("openai-chat dangling tool_calls hardening", () => { - test("T1 incident: developer guidance is hoisted while the real result reattaches to the original call", () => { + test("T1 incident: developer guidance lands after the result while the result reattaches to the original call", () => { const messages = wire([ user("hi"), assistantWithCalls([{ id: "call_x", name: "request_user_input" }]), @@ -100,14 +100,14 @@ describe("openai-chat dangling tool_calls hardening", () => { ]); assertWireInvariants(messages); const roles = messages.map(m => m.role); - expect(messages[0]).toEqual({ role: "system", content: "[injected guidance]" }); - // canonical history order: assistant, tool(real), user; no in-history system barrier + expect(messages[0]).toEqual({ role: "user", content: "hi" }); + // canonical history order: assistant, tool(real), then the barrier in its own slot (#5213) const aIdx = roles.indexOf("assistant"); expect(roles[aIdx + 1]).toBe("tool"); expect(messages[aIdx + 1].tool_call_id).toBe("call_x"); expect(messages[aIdx + 1].content).toBe('{"answers":{}}'); - expect(roles[aIdx + 2]).toBe("user"); - expect(messages.slice(1).some(m => m.role === "system")).toBe(false); + expect(messages[aIdx + 2]).toEqual({ role: "developer", content: "[injected guidance]" }); + expect(roles[aIdx + 3]).toBe("user"); // no synthetic result fabricated for an answered call expect(messages.some(m => typeof m.content === "string" && m.content.includes("no tool result was recorded"))).toBe(false); }); @@ -166,9 +166,9 @@ describe("openai-chat dangling tool_calls hardening", () => { expect(String(synth?.content)).toContain("no tool result was recorded"); const real = block.find(m => m.tool_call_id === "call_2"); expect(real?.content).toBe("img-ok"); - expect(messages[0]).toEqual({ role: "system", content: "barrier while call_1 pending" }); - expect(messages[aIdx + 3].role).toBe("assistant"); - expect(messages.slice(1).some(m => m.role === "system")).toBe(false); + expect(messages[0]).toEqual({ role: "user", content: "hi" }); + expect(messages[aIdx + 3]).toEqual({ role: "developer", content: "barrier while call_1 pending" }); + expect(messages[aIdx + 4].role).toBe("assistant"); }); test("T6 mismatched result while calls pending: round closes synthetically, then orphan pair", () => { @@ -183,9 +183,9 @@ describe("openai-chat dangling tool_calls hardening", () => { expect(messages[aIdx + 1].role).toBe("tool"); expect(messages[aIdx + 1].tool_call_id).toBe("call_p"); expect(String(messages[aIdx + 1].content)).toContain("no tool result was recorded"); - expect(messages[0]).toEqual({ role: "system", content: "deferred barrier" }); - expect(messages[aIdx + 2].role).toBe("assistant"); - expect(messages.slice(1).some(m => m.role === "system")).toBe(false); + expect(messages[0]).toEqual({ role: "user", content: "hi" }); + expect(messages[aIdx + 2]).toEqual({ role: "developer", content: "deferred barrier" }); + expect(messages[aIdx + 3].role).toBe("assistant"); const orphanIdx = messages.findIndex((m, i) => i > aIdx && m.role === "assistant"); expect(messages[orphanIdx].tool_calls?.[0].id).toBe("call_unknown"); expect(messages[orphanIdx + 1].tool_call_id).toBe("call_unknown"); diff --git a/tests/adapters/openai/openai-chat-developer-position.test.ts b/tests/adapters/openai/openai-chat-developer-position.test.ts new file mode 100644 index 00000000000..311d109b804 --- /dev/null +++ b/tests/adapters/openai/openai-chat-developer-position.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; + +/** + * #5213. A `developer` message used to keep its slot only when the provider base URL host was + * exactly `api.openai.com`. Everywhere else its text was appended to the system prompt and the + * message itself was skipped, so an instruction written to apply from the second turn onward + * arrived ahead of the first one. Both shapes return a normal completion, which is why these + * assertions read the serialized request body rather than the response. + */ + +const gateway: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://gateway.example.internal/v1", + apiKey: "k", +}; + +function wireMessages(provider: OcxProviderConfig): Array> { + const parsed = { + modelId: "local-model", + context: { + systemPrompt: ["base instructions"], + messages: [ + { role: "user", content: "First turn.", timestamp: 0 }, + { role: "developer", content: "Answer in exactly one sentence.", timestamp: 0 }, + { role: "user", content: "Second turn.", timestamp: 0 }, + ], + }, + stream: false, + options: {}, + } as unknown as OcxParsedRequest; + const request = createOpenAIChatAdapter(provider).buildRequest(parsed); + return (JSON.parse(request.body) as { messages: Array> }).messages; +} + +describe("developer message placement on the Chat wire", () => { + test("a non-OpenAI gateway keeps the instruction between the two turns", () => { + const messages = wireMessages(gateway); + expect(messages.map(message => message.role)).toEqual(["system", "user", "developer", "user"]); + expect(messages[0]).toEqual({ role: "system", content: "base instructions" }); + expect(messages[1]).toEqual({ role: "user", content: "First turn." }); + expect(messages[2]).toEqual({ role: "developer", content: "Answer in exactly one sentence." }); + expect(messages[3]).toEqual({ role: "user", content: "Second turn." }); + }); + + test("the leading system block no longer absorbs the instruction", () => { + expect(String(wireMessages(gateway)[0].content)).not.toContain("Answer in exactly one sentence."); + }); + + test("placement does not depend on the destination host", () => { + const hosts = [ + "https://openrouter.ai/api/v1", + "http://localhost:1234/v1", + "https://api.openai.com/v1", + ]; + for (const baseUrl of hosts) { + const messages = wireMessages({ ...gateway, baseUrl }); + expect(messages.map(message => message.role)).toEqual(["system", "user", "developer", "user"]); + expect(messages[2].content).toBe("Answer in exactly one sentence."); + expect(messages[3]).toEqual({ role: "user", content: "Second turn." }); + } + }); +}); + +describe("developer role on the Chat wire", () => { + test("the role is forwarded as itself rather than inferred from the hostname", () => { + for (const baseUrl of ["https://openrouter.ai/api/v1", "http://localhost:1234/v1", "https://api.openai.com/v1"]) { + expect(wireMessages({ ...gateway, baseUrl })[2]).toEqual({ + role: "developer", + content: "Answer in exactly one sentence.", + }); + } + }); + + test("a destination that rejects the role converts it without moving the message", () => { + const messages = wireMessages({ ...gateway, foldDeveloperRoleToSystem: true }); + expect(messages.map(message => message.role)).toEqual(["system", "user", "system", "user"]); + expect(messages[2]).toEqual({ role: "system", content: "Answer in exactly one sentence." }); + expect(String(messages[0].content)).not.toContain("Answer in exactly one sentence."); + }); + + test("the opt-out is off unless the operator sets it", () => { + expect(wireMessages({ ...gateway, foldDeveloperRoleToSystem: false })[2].role).toBe("developer"); + }); +}); diff --git a/tests/adapters/openai/openai-chat-system-order.test.ts b/tests/adapters/openai/openai-chat-system-order.test.ts index def3179d814..29511492412 100644 --- a/tests/adapters/openai/openai-chat-system-order.test.ts +++ b/tests/adapters/openai/openai-chat-system-order.test.ts @@ -21,7 +21,7 @@ function buildMessages(context: OcxParsedRequest["context"]): Array { - test("folds interleaved developer reminders into one leading system message", () => { + test("keeps interleaved developer reminders in their original slots", () => { const messages = buildMessages({ systemPrompt: ["base instructions"], messages: [ @@ -44,13 +44,15 @@ describe("openai-chat system message ordering", () => { expect(messages[0]).toEqual({ role: "system", - content: "base instructions\n\nfirst reminder\n\nsecond reminder", + content: "base instructions", }); - expect(messages.slice(1).map(message => message.role)).toEqual(["user", "assistant", "user"]); - expect(messages.slice(1).some(message => message.role === "system")).toBe(false); + expect(messages.map(message => message.role)) + .toEqual(["system", "user", "developer", "assistant", "developer", "user"]); + expect(messages[2]).toEqual({ role: "developer", content: "first reminder" }); + expect(messages[4]).toEqual({ role: "developer", content: "second reminder" }); }); - test("keeps tool calls and results adjacent when a developer reminder follows the call", () => { + test("defers a reminder past a pending tool result instead of hoisting it", () => { const messages = buildMessages({ messages: [ { role: "user", content: "inspect", timestamp: 0 }, @@ -72,9 +74,11 @@ describe("openai-chat system message ordering", () => { ], }); - expect(messages[0]).toEqual({ role: "system", content: "remember the policy" }); - expect(messages.map(message => message.role)).toEqual(["system", "user", "assistant", "tool"]); - expect(messages[3]).toMatchObject({ role: "tool", tool_call_id: "call_1" }); + // The reminder arrived while call_1 was open. Emitting it there would break tool-call + // adjacency, so it is released immediately after the result rather than moved to the front. + expect(messages.map(message => message.role)).toEqual(["user", "assistant", "tool", "developer"]); + expect(messages[2]).toMatchObject({ role: "tool", tool_call_id: "call_1" }); + expect(messages[3]).toEqual({ role: "developer", content: "remember the policy" }); }); test("keeps developer vision content as a user-compatible message in place", () => { @@ -104,7 +108,7 @@ describe("openai-chat system message ordering", () => { }); }); -describe("OpenCode Go DeepSeek chronological system messages", () => { +describe("chronological in-conversation system messages", () => { const model = "deepseek-v4.1-flash"; const ocg: OcxProviderConfig = { adapter: "openai-chat", @@ -141,10 +145,10 @@ describe("OpenCode Go DeepSeek chronological system messages", () => { { role: "system", content: "Synthetic reminder B." }, ], ocg, model, stabilize); expect(JSON.stringify(next.messages.slice(0, first.messages.length))).toBe(JSON.stringify(first.messages)); - expect(first.messages.map((message: { role: string }) => message.role)).toEqual(["system", "user", "assistant", "system"]); + expect(first.messages.map((message: { role: string }) => message.role)).toEqual(["system", "user", "assistant", "developer"]); expect(first.messages[0].content).not.toContain("Synthetic reminder A."); - expect(first.messages.at(-1)).toEqual({ role: "system", content: "Synthetic reminder A." }); - expect(next.messages.at(-1)).toEqual({ role: "system", content: "Synthetic reminder B." }); + expect(first.messages.at(-1)).toEqual({ role: "developer", content: "Synthetic reminder A." }); + expect(next.messages.at(-1)).toEqual({ role: "developer", content: "Synthetic reminder B." }); expect(next.tools).toEqual(first.tools); expect(next.model).toBe(model); expect(next.stream).toBe(true); @@ -161,14 +165,14 @@ describe("OpenCode Go DeepSeek chronological system messages", () => { expect(callIndex).toBeGreaterThan(0); expect(body.messages[callIndex].reasoning_content).toBe(" "); expect(body.messages[callIndex + 1]).toMatchObject({ role: "tool", tool_call_id: "call_fixture", content: "Fixture result." }); - expect(body.messages[callIndex + 2]).toEqual({ role: "system", content: "Reminder during pending tool." }); + expect(body.messages[callIndex + 2]).toEqual({ role: "developer", content: "Reminder during pending tool." }); }); test.each([ "https://opencode.ai/zen/go/v1/", "https://opencode.ai:443/zen/go/v1", - ])("matches the canonical destination %s", baseUrl => { - expect(build(history, { ...ocg, baseUrl }).messages.at(-1).role).toBe("system"); + ])("keeps the reminder last on the canonical OpenCode Go destination %s", baseUrl => { + expect(build(history, { ...ocg, baseUrl }).messages.at(-1).role).toBe("developer"); }); test.each([ @@ -177,22 +181,34 @@ describe("OpenCode Go DeepSeek chronological system messages", () => { "https://opencode.ai:444/zen/go/v1", "http://opencode.ai/zen/go/v1", "http://localhost:1234/v1", - ])("retains generic hoisting for other destinations: %s", baseUrl => { + ])("keeps the same chronological placement on other destinations: %s", baseUrl => { const messages = build(history, { ...ocg, baseUrl }).messages; - expect(messages[0].content).toContain("Synthetic reminder A."); - expect(messages.map((message: { role: string }) => message.role)).toEqual(["system", "user", "assistant"]); + expect(messages[0].content).not.toContain("Synthetic reminder A."); + expect(messages.map((message: { role: string }) => message.role)) + .toEqual(["system", "user", "assistant", "developer"]); + expect(messages.at(-1)).toEqual({ role: "developer", content: "Synthetic reminder A." }); }); - test("retains generic hoisting for other OCG models", () => { - expect(build(history, ocg, "kimi-k3").messages[0].content).toContain("Synthetic reminder A."); + test("placement no longer depends on the model either", () => { + const messages = build(history, ocg, "kimi-k3").messages; + expect(messages[0].content).not.toContain("Synthetic reminder A."); + expect(messages.at(-1)).toEqual({ role: "developer", content: "Synthetic reminder A." }); }); - test("retains native OpenAI developer roles", () => { + test("the native OpenAI wire is unchanged", () => { const messages = build(history, { ...ocg, baseUrl: "https://api.openai.com/v1" }).messages; expect(messages[0].content).not.toContain("Synthetic reminder A."); expect(messages.at(-1)).toEqual({ role: "developer", content: "Synthetic reminder A." }); }); + test("a destination that rejects the role folds it in place", () => { + const messages = build(history, { ...ocg, foldDeveloperRoleToSystem: true }).messages; + expect(messages.map((message: { role: string }) => message.role)) + .toEqual(["system", "user", "assistant", "system"]); + expect(messages[0].content).not.toContain("Synthetic reminder A."); + expect(messages.at(-1)).toEqual({ role: "system", content: "Synthetic reminder A." }); + }); + test("drops a non-text timeline message instead of emitting an empty system message", () => { const context = { messages: [ diff --git a/tests/claude-integration/claude-inbound.test.ts b/tests/claude-integration/claude-inbound.test.ts index 3db1a4a6275..5631ec1271a 100644 --- a/tests/claude-integration/claude-inbound.test.ts +++ b/tests/claude-integration/claude-inbound.test.ts @@ -4,6 +4,7 @@ import { AnthropicRequestError as LeafAnthropicRequestError } from "../../src/cl import { repoPath } from "../helpers/repo-root"; import { AnthropicRequestError, anthropicToResponsesBody, anthropicToResponsesTranslation, effortForThinkingBudget, extractOcxEffortDirective, resolveInboundModel } from "../../src/claude/inbound"; import { parseRequest } from "../../src/responses/parser"; +import { inlineDocumentMarker } from "../../src/responses/inline-document"; import { responsesRequestSchema } from "../../src/responses/schema"; import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; @@ -180,7 +181,7 @@ describe("claude inbound translation", () => { { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content }] }, ], }); - const marker = [{ type: "input_text", text: "[document: report.pdf]" }]; + const marker = [{ type: "input_text", text: inlineDocumentMarker("report.pdf") }]; expect(body.input).toEqual(carrier === "user" ? [{ type: "message", role: "user", content: marker }] : [ @@ -437,11 +438,11 @@ describe("claude inbound translation", () => { }) as any; expect(body.input[1].output).toEqual([ { type: "input_text", text: "3 pages" }, - { type: "input_text", text: "[document: report.pdf]" }, + { type: "input_text", text: inlineDocumentMarker("report.pdf") }, ]); // An untitled document still leaves a marker rather than the empty output that // read as "the tool returned nothing". - expect(body.input[3].output).toEqual([{ type: "input_text", text: "[document]" }]); + expect(body.input[3].output).toEqual([{ type: "input_text", text: inlineDocumentMarker(undefined) }]); expect(() => parseRequest(body)).not.toThrow(); }); diff --git a/tests/claude-integration/claude-source-envelope.test.ts b/tests/claude-integration/claude-source-envelope.test.ts index a78c9f1a152..a08b8f5e826 100644 --- a/tests/claude-integration/claude-source-envelope.test.ts +++ b/tests/claude-integration/claude-source-envelope.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { anthropicToResponsesBody } from "../../src/claude/inbound"; +import { inlineDocumentMarker } from "../../src/responses/inline-document"; describe("Claude source envelope boundaries", () => { test("nested tool results retain only bounded structured content", () => { @@ -17,7 +18,7 @@ describe("Claude source envelope boundaries", () => { expect(body.input.map((item: any) => item.type)).toEqual(["function_call", "function_call_output"]); expect(body.input[1].output).toEqual([ { type: "input_text", text: "ok" }, - { type: "input_text", text: "[document: report]" }, + { type: "input_text", text: inlineDocumentMarker("report") }, ]); expect(JSON.stringify(body)).not.toContain("secret-payload"); }); diff --git a/tests/codex-integration/parallel-tool-calls-optin.test.ts b/tests/codex-integration/parallel-tool-calls-optin.test.ts index 689fd207f80..d62920f9e04 100644 --- a/tests/codex-integration/parallel-tool-calls-optin.test.ts +++ b/tests/codex-integration/parallel-tool-calls-optin.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; +import { buildOpenAIChatPassthroughRequest, createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; import { applyProviderConfigHints, normalizeRoutedCatalogEntry } from "../../src/codex/catalog"; import { routeModel } from "../../src/router"; -import type { OcxConfig, OcxParsedRequest, OcxTool } from "../../src/types"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxTool } from "../../src/types"; const tools: OcxTool[] = [{ name: "shell", description: "run", parameters: { type: "object" } }]; @@ -62,6 +62,73 @@ describe("parallel tool calls provider opt-in (request body)", () => { }); }); +/** + * #5211 case 2. The provider knob has three states and the call site only branched on two, so + * the default state — a provider that never configured it — dropped the caller's own explicit + * `parallel_tool_calls: false` on the way to the wire while still answering normally. The + * assertions read the request body because a successful tool call cannot tell the difference. + */ +describe("caller-specified parallel_tool_calls on a provider that expresses no preference", () => { + const unsetProvider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://gateway.example.internal/v1", apiKey: "k" }; + + test("an explicit request-level false reaches the outbound request", () => { + const adapter = createOpenAIChatAdapter(unsetProvider); + const body = JSON.parse(adapter.buildRequest(parsedRequest({ parallelToolCalls: false })).body) as Record; + expect(body.parallel_tool_calls).toBe(false); + }); + + test("an explicit request-level true still omits the knob strict hosts never had to accept", () => { + const adapter = createOpenAIChatAdapter(unsetProvider); + const body = JSON.parse(adapter.buildRequest(parsedRequest({ parallelToolCalls: true })).body) as Record; + expect(body).not.toHaveProperty("parallel_tool_calls"); + }); + + test("a request that says nothing leaves the key absent", () => { + const adapter = createOpenAIChatAdapter(unsetProvider); + const body = JSON.parse(adapter.buildRequest(parsedRequest()).body) as Record; + expect(body).not.toHaveProperty("parallel_tool_calls"); + }); + + test("a toolless request never grows the key", () => { + const adapter = createOpenAIChatAdapter(unsetProvider); + const toolless = { ...parsedRequest({ parallelToolCalls: false }), context: { messages: [{ role: "user", content: "hi", timestamp: 0 }] } } as never; + const body = JSON.parse(adapter.buildRequest(toolless).body) as Record; + expect(body).not.toHaveProperty("parallel_tool_calls"); + }); + + // The native Chat route never projects the body, so it read the same three provider states + // from its own copy of the branch and lost the caller's false in exactly the same way. + describe("native Chat passthrough", () => { + function passthroughBody(provider: OcxProviderConfig, raw: Record): Record { + const request = buildOpenAIChatPassthroughRequest(provider, { + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "shell", parameters: { type: "object" } } }], + ...raw, + }, "grok-4.5", false); + return JSON.parse(request.body) as Record; + } + + test("an explicit request-level false reaches the outbound request", () => { + expect(passthroughBody(unsetProvider, { parallel_tool_calls: false }).parallel_tool_calls).toBe(false); + }); + + test("an explicit true and an absent value both leave the key off", () => { + expect(passthroughBody(unsetProvider, { parallel_tool_calls: true })).not.toHaveProperty("parallel_tool_calls"); + expect(passthroughBody(unsetProvider, {})).not.toHaveProperty("parallel_tool_calls"); + }); + + test("the configured states keep their existing wire values", () => { + const optedIn = { ...unsetProvider, parallelToolCalls: true }; + expect(passthroughBody(optedIn, {}).parallel_tool_calls).toBe(true); + expect(passthroughBody(optedIn, { parallel_tool_calls: false }).parallel_tool_calls).toBe(false); + const optedOut = { ...unsetProvider, parallelToolCalls: false }; + expect(passthroughBody(optedOut, { parallel_tool_calls: true })).not.toHaveProperty("parallel_tool_calls"); + expect(passthroughBody({ ...optedOut, pinParallelToolCallsFalse: true }, { parallel_tool_calls: true }).parallel_tool_calls) + .toBe(false); + }); + }); +}); + describe("stale persisted config backfill (router)", () => { test("persisted xai config without the flag inherits registry parallelToolCalls:true", () => { const config: OcxConfig = { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 308682ccebc..51a843c63ff 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1399,6 +1399,11 @@ "web-search-sidecar-429.test.ts": "web-search", "codex-shim-destroyed-probe.test.ts": "codex-integration", "client-runtime.test.ts": "clients", + "chat-tool-choice-allowed-tools.test.ts": "responses", + "anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic", + "google-strict-tool-validated-mode.test.ts": "adapters/google", + "openai-chat-developer-position.test.ts": "adapters/openai", + "chat-inline-document-bytes.test.ts": "responses", "devin-output-budget.test.ts": "providers", "api-key-model-scope.test.ts": "server" } diff --git a/tests/responses/chat-inline-document-bytes.test.ts b/tests/responses/chat-inline-document-bytes.test.ts new file mode 100644 index 00000000000..dd88fa05788 --- /dev/null +++ b/tests/responses/chat-inline-document-bytes.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test } from "bun:test"; +import { createAnthropicAdapter } from "../../src/adapters/anthropic"; +import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; +import { chatCompletionsToResponsesBody, ChatCompletionsRequestError } from "../../src/chat/inbound"; +import { anthropicToResponsesBody } from "../../src/claude/inbound"; +import { parseRequest } from "../../src/responses/parser"; +import { inlineDocumentDataUrl, inlineDocumentMarker } from "../../src/responses/inline-document"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../src/types"; + +/** + * #5212. Both inbound parsers reduced an attachment to its name before any adapter ran, so no + * adapter could forward one even to a target that has a representation for it. The caller could + * not tell "the model read the document" from "the model was told a document existed", which is + * why every assertion here reads the outbound request rather than the response. + */ + +const PDF_BYTES = "JVBERi0xLjQK"; +// Derived, not restated: the wire spelling is the module's to define, and a test that repeats it +// fails for the wrong reason the next time it changes. +const PDF_DATA_URL = inlineDocumentDataUrl({ + type: "document", + text: "", + mediaType: "application/pdf", + data: PDF_BYTES, +}); + +const chatProvider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://gateway.example.internal/v1", + apiKey: "k", +}; +const anthropicProvider = { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + apiKey: "sk-x", + authMode: "apiKey", +} as unknown as OcxProviderConfig; + +function chatRequest(part: unknown): Record { + return { + model: "mock/test-model", + messages: [{ role: "user", content: [part] }], + }; +} + +function claudeRequest(block: unknown): Record { + return { + model: "anthropic/claude-sonnet-4.5", + max_tokens: 64, + messages: [{ role: "user", content: [block] }], + }; +} + +const DOCUMENT_BLOCK = { + type: "document", + title: "spec", + source: { type: "base64", media_type: "application/pdf", data: PDF_BYTES }, +}; + +/** The user turn's content, which `inputContentParts` collapses to a string for lone text. */ +function parsedContent(body: Record): unknown { + const parsed = parseRequest(body as never); + const user = parsed.context.messages.find(message => message.role === "user"); + return user!.content; +} + +describe("inline document bytes survive the inbound parse", () => { + test("a Chat file part becomes a document carrying its bytes", () => { + const content = parsedContent(chatCompletionsToResponsesBody(chatRequest({ + type: "file", + file: { filename: "doc.pdf", file_data: PDF_DATA_URL }, + }))); + expect(content).toEqual([ + { + type: "document", + text: inlineDocumentMarker("doc.pdf"), + mediaType: "application/pdf", + data: PDF_BYTES, + filename: "doc.pdf", + }, + ]); + }); + + test("an Anthropic base64 document keeps its bytes and its title", () => { + const content = parsedContent(anthropicToResponsesBody(claudeRequest(DOCUMENT_BLOCK))); + expect(content).toEqual([ + { + type: "document", + text: inlineDocumentMarker("spec"), + mediaType: "application/pdf", + data: PDF_BYTES, + filename: "spec", + }, + ]); + }); + + test("a document with no usable bytes still reduces to the marker", () => { + const content = parsedContent(anthropicToResponsesBody(claudeRequest({ + type: "document", + title: "remote", + source: { type: "url", url: "https://example.com/doc.pdf" }, + }))); + expect(content).toBe(inlineDocumentMarker("remote")); + }); + + test("a reference with no payload is still refused rather than answered", () => { + expect(() => chatCompletionsToResponsesBody(chatRequest({ + type: "file", + file: { file_id: "file-123" }, + }))).toThrow(ChatCompletionsRequestError); + expect(() => chatCompletionsToResponsesBody(chatRequest({ + type: "input_audio", + input_audio: { data: "AA==", format: "wav" }, + }))).toThrow(ChatCompletionsRequestError); + }); +}); + +describe("inline document bytes reach a wire that can hold them", () => { + function chatOutbound(body: Record): Record { + const parsed = parseRequest(body as never) as OcxParsedRequest; + return JSON.parse(createOpenAIChatAdapter(chatProvider).buildRequest(parsed).body) as Record; + } + + test("an Anthropic document reaches the OpenAI Chat wire as a file part", () => { + const outbound = chatOutbound(anthropicToResponsesBody(claudeRequest(DOCUMENT_BLOCK))); + const messages = outbound.messages as Array<{ role: string; content: unknown }>; + expect(messages.at(-1)).toEqual({ + role: "user", + content: [{ type: "file", file: { file_data: PDF_DATA_URL, filename: "spec" } }], + }); + }); + + test("a Chat file part reaches the Anthropic wire as a document block", async () => { + const parsed = parseRequest(chatCompletionsToResponsesBody(chatRequest({ + type: "file", + file: { filename: "doc.pdf", file_data: PDF_DATA_URL }, + })) as never) as OcxParsedRequest; + const { body } = await createAnthropicAdapter(anthropicProvider).buildRequest(parsed); + const sent = JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as { + messages: Array<{ role: string; content: unknown }>; + }; + expect(sent.messages.at(-1)).toEqual({ + role: "user", + content: [{ + type: "document", + source: { type: "base64", media_type: "application/pdf", data: PDF_BYTES }, + title: "doc.pdf", + }], + }); + }); + + test("a document beside text keeps both on the Chat wire", () => { + const outbound = chatOutbound(anthropicToResponsesBody({ + model: "anthropic/claude-sonnet-4.5", + max_tokens: 64, + messages: [{ role: "user", content: [{ type: "text", text: "Summarize it." }, DOCUMENT_BLOCK] }], + })); + const messages = outbound.messages as Array<{ role: string; content: unknown[] }>; + expect(messages.at(-1)!.content).toEqual([ + { type: "text", text: "Summarize it." }, + { type: "file", file: { file_data: PDF_DATA_URL, filename: "spec" } }, + ]); + }); + + test("a developer turn carrying a document keeps its role", () => { + const parsed = { + modelId: "local-model", + context: { + messages: [{ + role: "developer", + content: [{ type: "document", text: inlineDocumentMarker("spec"), mediaType: "application/pdf", data: PDF_BYTES, filename: "spec" }], + timestamp: 0, + }], + }, + stream: false, + options: {}, + } as unknown as OcxParsedRequest; + const outbound = JSON.parse(createOpenAIChatAdapter(chatProvider).buildRequest(parsed).body) as { + messages: Array<{ role: string; content: unknown }>; + }; + expect(outbound.messages).toEqual([{ + role: "developer", + content: [{ type: "file", file: { file_data: PDF_DATA_URL, filename: "spec" } }], + }]); + }); +}); diff --git a/tests/responses/chat-media-translation.test.ts b/tests/responses/chat-media-translation.test.ts index a3042d4f3d7..d37c27ec871 100644 --- a/tests/responses/chat-media-translation.test.ts +++ b/tests/responses/chat-media-translation.test.ts @@ -16,9 +16,10 @@ const provider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https:// const media = [ { type: "input_audio", input_audio: { data: "YWJj", format: "wav" } }, { type: "input_audio", audio_url: "data:audio/wav;base64,YWJj" }, - { type: "file", file: { filename: "private.pdf", file_data: "data:application/pdf;base64,JVBERi0=" } }, { type: "input_file", file_id: "file-private" }, ]; +// An inline document is carried in user content and still has no carrier anywhere else (#5212). +const INLINE_FILE = { type: "file", file: { filename: "private.pdf", file_data: "data:application/pdf;base64,JVBERi0=" } }; function chat(part: unknown, role = "user") { return { model: "model", messages: [{ role, tool_call_id: "call1", content: [{ type: "text", text: "read this" }, part] }] }; @@ -34,9 +35,24 @@ describe("Chat media stays native or fails explicitly at translation", () => { } }); + test("an inline document is carried in user content and refused where nothing carries it", () => { + const translated = chatCompletionsToResponsesBody(chat(INLINE_FILE)); + expect(translated.input).toEqual([{ + type: "message", + role: "user", + content: [ + { type: "input_text", text: "read this" }, + { type: "input_file", file_data: "data:application/pdf;base64,JVBERi0=", filename: "private.pdf" }, + ], + }]); + for (const role of ["tool", "system", "assistant"]) { + expect(() => chatCompletionsToResponsesBody(chat(INLINE_FILE, role))).toThrow("OpenCodex cannot translate"); + } + }); + test("the native Chat route retains the caller's exact media blocks", () => { const route = { provider, providerName: "gateway", modelId: "model" } as RouteResult; - for (const part of media) { + for (const part of [...media, INLINE_FILE]) { const raw = chat(part); expect(isNativeChatRouteEligible(route, raw)).toBe(true); const wire = JSON.parse(buildOpenAIChatPassthroughRequest(provider, raw, "model", false).body); @@ -59,8 +75,9 @@ describe("Chat media stays native or fails explicitly at translation", () => { }); test("plain text mentioning an attachment is not treated as one", () => { - const out = chatCompletionsToResponsesBody({ model: "model", messages: [{ role: "user", content: JSON.stringify(media) }] }); - expect(out.input).toEqual([{ type: "message", role: "user", content: [{ type: "input_text", text: JSON.stringify(media) }] }]); + const text = JSON.stringify([...media, INLINE_FILE]); + const out = chatCompletionsToResponsesBody({ model: "model", messages: [{ role: "user", content: text }] }); + expect(out.input).toEqual([{ type: "message", role: "user", content: [{ type: "input_text", text }] }]); }); }); @@ -93,7 +110,7 @@ test("real HTTP translation refuses media before sending to the selected upstrea server = startServer(0); for (const part of [ { type: "input_audio", audio_url: "data:audio/wav;base64,YWJj" }, - { type: "input_file", filename: "private.pdf", file_data: "data:application/pdf;base64,JVBERi0=" }, + { type: "input_file", filename: "private.pdf", file_id: "file-private" }, ]) { const response = await fetch(new URL("/v1/responses", server.url), { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/tests/responses/chat-tool-choice-allowed-tools.test.ts b/tests/responses/chat-tool-choice-allowed-tools.test.ts new file mode 100644 index 00000000000..181aaa325b0 --- /dev/null +++ b/tests/responses/chat-tool-choice-allowed-tools.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test"; +import { chatCompletionsToResponsesBody, ChatCompletionsRequestError } from "../../src/chat/inbound"; +import { parseRequest } from "../../src/responses/parser"; +import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; + +/** + * #5211 case 1. A Chat Completions caller narrows the catalogue with + * `tool_choice.allowed_tools`, and the translated path used to let that object fall past every + * branch of `toolChoiceToResponses`: the request kept its full tool list, carried no tool choice + * at all, and still answered 200. Asserting on the translated body alone would not catch a + * later regression on the way out, so each case here follows the value into the request the + * adapter actually sends. + */ + +const CHAT_TOOLS = [ + { type: "function", function: { name: "tool_a", parameters: { type: "object", properties: {} } } }, + { type: "function", function: { name: "tool_b", parameters: { type: "object", properties: {} } } }, +]; + +function chatBody(toolChoice: unknown): Record { + return { + model: "mock/test-model", + messages: [{ role: "user", content: "Call only tool_b." }], + tools: CHAT_TOOLS, + tool_choice: toolChoice, + }; +} + +function outboundBody(toolChoice: unknown): Record { + const translated = chatCompletionsToResponsesBody(chatBody(toolChoice)); + const parsed = parseRequest(translated as never); + const adapter = createOpenAIChatAdapter({ + adapter: "openai-chat", + baseUrl: "https://gateway.example.internal/v1", + apiKey: "k", + }); + return JSON.parse(adapter.buildRequest(parsed as never).body) as Record; +} + +function outboundToolNames(body: Record): string[] { + return (body.tools as Array<{ function?: { name?: string } }>).map(tool => tool.function?.name ?? ""); +} + +describe("chat tool_choice allowed_tools reaches the outbound request", () => { + test("a single-tool subset narrows the tools the upstream is offered", () => { + const body = outboundBody({ + type: "allowed_tools", + allowed_tools: { mode: "required", tools: [{ type: "function", function: { name: "tool_b" } }] }, + }); + expect(outboundToolNames(body)).toEqual(["tool_b"]); + expect(body.tool_choice).toBe("required"); + }); + + test("a larger subset keeps every allowed tool and drops the rest", () => { + const body = outboundBody({ + type: "allowed_tools", + allowed_tools: { + mode: "auto", + tools: [ + { type: "function", function: { name: "tool_a" } }, + { type: "function", function: { name: "tool_b" } }, + ], + }, + }); + expect(outboundToolNames(body).sort()).toEqual(["tool_a", "tool_b"]); + expect(body.tool_choice).toBe("auto"); + }); + + test("the flat entry spelling and a flat choice object are both accepted", () => { + const nested = chatCompletionsToResponsesBody(chatBody({ + type: "allowed_tools", + allowed_tools: { mode: "required", tools: [{ type: "function", name: "tool_a" }] }, + })); + const flat = chatCompletionsToResponsesBody(chatBody({ + type: "allowed_tools", + mode: "required", + tools: [{ type: "function", name: "tool_a" }], + })); + expect(nested.tool_choice) + .toEqual({ type: "allowed_tools", mode: "required", tools: [{ type: "function", name: "tool_a" }] }); + expect(flat.tool_choice).toEqual(nested.tool_choice); + }); + + test("mode defaults to auto and hosted entries are named by their type", () => { + const translated = chatCompletionsToResponsesBody(chatBody({ + type: "allowed_tools", + allowed_tools: { tools: [{ type: "web_search" }] }, + })); + expect(translated.tool_choice) + .toEqual({ type: "allowed_tools", mode: "auto", tools: [{ type: "web_search" }] }); + }); + + test("an unnameable entry is refused rather than quietly widening the subset", () => { + expect(() => chatCompletionsToResponsesBody(chatBody({ + type: "allowed_tools", + allowed_tools: { mode: "required", tools: [{ type: "function", function: {} }] }, + }))).toThrow(ChatCompletionsRequestError); + expect(() => chatCompletionsToResponsesBody(chatBody({ + type: "allowed_tools", + allowed_tools: { mode: "required", tools: [] }, + }))).toThrow(ChatCompletionsRequestError); + }); + + test("a selector kind nobody can evaluate is refused", () => { + expect(() => chatCompletionsToResponsesBody(chatBody({ + type: "allowed_tools", + allowed_tools: { mode: "required", tools: [{ type: "mcp", name: "tool_a" }] }, + }))).toThrow(/unsupported tool_choice.allowed_tools.tools entry type/); + }); + + test("the existing named and string choices are unchanged", () => { + expect(chatCompletionsToResponsesBody(chatBody("required")).tool_choice).toBe("required"); + expect(chatCompletionsToResponsesBody(chatBody({ type: "function", function: { name: "tool_a" } })).tool_choice) + .toEqual({ type: "function", name: "tool_a" }); + }); +});