From 5749a174821e12a310c79bf78e96f7665c546521 Mon Sep 17 00:00:00 2001 From: codingbo Date: Sat, 26 Sep 2026 12:22:13 +0900 Subject: [PATCH 01/11] fix(clients): use authoritative output limit in client config export (#5891) Squashed carry of #5891. Closes #5828 Co-authored-by: codingbo --- .../src/content/docs/fr/guides/opencode.md | 5 +- docs-site/src/content/docs/fr/guides/pi.md | 6 +- docs-site/src/content/docs/guides/opencode.md | 8 +-- docs-site/src/content/docs/guides/pi.md | 6 +- .../src/content/docs/ja/guides/opencode.md | 2 +- docs-site/src/content/docs/ja/guides/pi.md | 4 +- .../src/content/docs/ko/guides/opencode.md | 6 +- docs-site/src/content/docs/ko/guides/pi.md | 6 +- .../src/content/docs/ru/guides/opencode.md | 6 +- docs-site/src/content/docs/ru/guides/pi.md | 6 +- .../src/content/docs/tr/guides/opencode.md | 7 +-- docs-site/src/content/docs/tr/guides/pi.md | 8 +-- .../src/content/docs/zh-cn/guides/opencode.md | 2 +- docs-site/src/content/docs/zh-cn/guides/pi.md | 4 +- .../src/content/docs/zh-tw/guides/opencode.md | 2 +- docs-site/src/content/docs/zh-tw/guides/pi.md | 4 +- src/clients/config-export.ts | 6 +- src/clients/config-export/constants.ts | 13 +--- src/clients/config-export/contracts.ts | 2 + src/clients/config-export/model-metadata.ts | 12 +++- src/clients/config-export/omp.ts | 2 +- structure/clients/integrations.md | 7 +++ tests/config/client-config-export.test.ts | 59 +++++++++++++++---- tests/providers/opencode-cli.test.ts | 2 +- 24 files changed, 95 insertions(+), 90 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/opencode.md b/docs-site/src/content/docs/fr/guides/opencode.md index 6bb2da7e290..a645fb882b4 100644 --- a/docs-site/src/content/docs/fr/guides/opencode.md +++ b/docs-site/src/content/docs/fr/guides/opencode.md @@ -131,10 +131,7 @@ Rien à annuler — aucun fichier de configuration généré n'est écrit sous ` `limit.context` n’est écrit que lorsque le catalogue fournit une fenêtre de contexte faisant autorité. Dans le cas contraire, le bloc `limit` entier est omis et opencode conserve ses propres valeurs par défaut. -Le schéma d’opencode rejette un bloc `limit` qui contient `context` sans `output`. Comme le catalogue ne fournit -aucune limite de sortie faisant autorité par modèle, opencodex émet également un budget `output` de `32000`, limité -à la fenêtre de contexte afin qu’un modèle à petit contexte ne reçoive jamais `output > context`. Cette valeur sert -uniquement à satisfaire le schéma ; elle ne prétend pas représenter la véritable limite d’un modèle particulier. +La limite de sortie utilise le maximum connu du modèle dans le catalogue ou les métadonnées générées. La valeur `32000` ne sert que de repli si cette limite est inconnue. La limite est toujours plafonnée à la fenêtre de contexte, et les limites connues inférieures à `32000` sont conservées. Le bloc fournisseur `opencodex` est régénéré à chaque lancement, donc des ajustements par modèle y sont apportés ne survivra pas. Conservez plutôt les entrées personnalisées sous votre propre clé de fournisseur. diff --git a/docs-site/src/content/docs/fr/guides/pi.md b/docs-site/src/content/docs/fr/guides/pi.md index 14acc0db356..8afed1dd5a8 100644 --- a/docs-site/src/content/docs/fr/guides/pi.md +++ b/docs-site/src/content/docs/fr/guides/pi.md @@ -37,7 +37,7 @@ d’exportation de la variable d’environnement et le nombre de modèles dotés "name": "Claude Opus 5 (anthropic)", "input": ["text"], "contextWindow": 200000, - "maxTokens": 32000 + "maxTokens": 128000 } ] } @@ -106,9 +106,7 @@ refuse de démarrer sans jeton — voir [Accès à distance](/fr/reference/confi faisant autorité. Dans le cas contraire, les deux champs sont omis pour ce modèle et Pi applique ses propres valeurs par défaut ; `ocx export` affiche le nombre de lignes concernées. -`maxTokens` est un budget de `32000` destiné à satisfaire le schéma. Il est plafonné à la fenêtre de contexte, de sorte qu’un -modèle doté d’un petit contexte ne reçoive jamais davantage de sortie que de contexte. Cette valeur ne constitue pas une affirmation sur la -limite maximale réelle d’un modèle donné. +La limite de sortie utilise le maximum connu du modèle dans le catalogue ou les métadonnées générées. La valeur `32000` ne sert que de repli si cette limite est inconnue. La limite est toujours plafonnée à la fenêtre de contexte, et les limites connues inférieures à `32000` sont conservées. Le champ `cost` est volontairement absent. Il exige les quatre champs de prix, alors qu’OpenCodex ne possède aucune donnée tarifaire pour les modèles routés ; émettre des zéros reviendrait à affirmer que tous les diff --git a/docs-site/src/content/docs/guides/opencode.md b/docs-site/src/content/docs/guides/opencode.md index eedc991d50e..09af3339c78 100644 --- a/docs-site/src/content/docs/guides/opencode.md +++ b/docs-site/src/content/docs/guides/opencode.md @@ -61,7 +61,7 @@ the metadata the proxy reports at `GET /api/models`: ```json "gpt-5.6-luna": { "name": "gpt-5.6-luna (native)", - "limit": { "context": 272000, "output": 32000 }, + "limit": { "context": 272000, "output": 128000 }, "attachment": true, "modalities": { "input": ["text", "image"], "output": ["text"] } } @@ -185,11 +185,7 @@ Nothing to undo — no generated config file is written under `~/.opencodex`. Ru `limit.context` is written only when the catalog reports an authoritative context window; when it does not, the whole `limit` block is omitted and opencode keeps its own defaults. -opencode's schema rejects a `limit` block carrying `context` without `output`, and the catalog has -no authoritative per-model output field, so an `output` budget of `32000` is emitted alongside it, -clamped down to the context window so a small-context model is never given `output > context`. -That figure exists to satisfy the schema — it is not a claim about any specific model's true -maximum. +Output limits use the model’s known maximum from catalog or generated metadata. Only unknown limits fall back to `32000`. The output limit is always clamped to the context window, including known limits below `32000`. The `opencodex` provider block is regenerated on every launch, so per-model tweaks made inside it will not survive. Keep custom entries under a provider key of your own instead. diff --git a/docs-site/src/content/docs/guides/pi.md b/docs-site/src/content/docs/guides/pi.md index b5a92212c88..03b1886a8e0 100644 --- a/docs-site/src/content/docs/guides/pi.md +++ b/docs-site/src/content/docs/guides/pi.md @@ -37,7 +37,7 @@ export line, and how many models carry authoritative context limits. "name": "Claude Opus 5 (anthropic)", "input": ["text"], "contextWindow": 200000, - "maxTokens": 32000 + "maxTokens": 128000 } ] } @@ -106,9 +106,7 @@ refuses to start without a token — see [Remote access](/reference/configuratio window. When it does not, both fields are omitted for that model and Pi applies its own defaults; `ocx export` prints how many rows fell into that case. -`maxTokens` is a schema-satisfying budget of `32000`, clamped down to the context window so a -small-context model is never given more output than context. It is not a claim about any specific -model's true maximum. +Output limits use the model’s known maximum from catalog or generated metadata. Only unknown limits fall back to `32000`. The output limit is always clamped to the context window, including known limits below `32000`. Two fields are deliberately absent. `cost` requires all four price fields and opencodex has no price data for routed models — emitting zeros would assert that every model is free. diff --git a/docs-site/src/content/docs/ja/guides/opencode.md b/docs-site/src/content/docs/ja/guides/opencode.md index 478c6b490b7..b0a428584ef 100644 --- a/docs-site/src/content/docs/ja/guides/opencode.md +++ b/docs-site/src/content/docs/ja/guides/opencode.md @@ -96,7 +96,7 @@ export OPENCODEX_OPENCODE_API_KEY= `limit.context` は、カタログが権限のあるコンテキスト ウィンドウを報告する場合にのみ書き込まれます。そうでない場合、`limit` ブロック全体が省略され、opencode は独自のデフォルトを保持します。 -opencode のスキーマは、`output` のない `context` を含む `limit` ブロックを拒否し、カタログにはモデルごとに権限のある出力フィールドがないため、`32000` の `output` バジェットが一緒に出力され、コンテキスト ウィンドウに固定されるため、コンテキストの小さいモデルには `output > context` が与えられません。この数値はスキーマを満たすために存在します。これは、特定のモデルの真の最大値について主張するものではありません。 +出力上限にはカタログまたは生成メタデータの既知のモデル上限を使用します。不明な場合のみ `32000` を使用します。出力上限は常にコンテキストウィンドウ以下に制限され、`32000` 未満の既知の上限も維持されます。 `opencodex` プロバイダー ブロックは起動のたびに再生成されるため、内部で行われたモデルごとの調整は存続しません。代わりに、独自のプロバイダー キーの下にカスタム エントリを保持します。 diff --git a/docs-site/src/content/docs/ja/guides/pi.md b/docs-site/src/content/docs/ja/guides/pi.md index 37980f9745a..50a48cf71b5 100644 --- a/docs-site/src/content/docs/ja/guides/pi.md +++ b/docs-site/src/content/docs/ja/guides/pi.md @@ -33,7 +33,7 @@ ocx export --client pi "name": "Claude Opus 5 (anthropic)", "input": ["text"], "contextWindow": 200000, - "maxTokens": 32000 + "maxTokens": 128000 } ] } @@ -89,7 +89,7 @@ export OPENCODEX_API_KEY= `contextWindow` および `maxTokens` は、カタログが権限のあるコンテキスト ウィンドウを報告する場合にのみ発行されます。そうでない場合、そのモデルでは両方のフィールドが省略され、Pi は独自のデフォルトを適用します。 `ocx export` は、そのケースに該当する行数を出力します。 -`maxTokens` は、`32000` のスキーマを満たすバジェットであり、コンテキスト ウィンドウに固定されているため、小さなコンテキスト モデルにはコンテキストを超える出力が与えられません。これは、特定のモデルの真の最大値について主張するものではありません。 +出力上限にはカタログまたは生成メタデータの既知のモデル上限を使用します。不明な場合のみ `32000` を使用します。出力上限は常にコンテキストウィンドウ以下に制限され、`32000` 未満の既知の上限も維持されます。 2 つのフィールドは意図的に省略されています。 `cost` には 4 つの価格フィールドがすべて必要ですが、opencodex にはルーティング モデルの価格データがありません。ゼロを出力すると、すべてのモデルが無料であると主張されます。 `reasoning` は Pi のブール値ですが、カタログにはエフォート ラダーが記載されており、一方をもう一方にマッピングするのは推測になります。 diff --git a/docs-site/src/content/docs/ko/guides/opencode.md b/docs-site/src/content/docs/ko/guides/opencode.md index cceebf5c90a..f1ea7cafbbf 100644 --- a/docs-site/src/content/docs/ko/guides/opencode.md +++ b/docs-site/src/content/docs/ko/guides/opencode.md @@ -135,11 +135,7 @@ export OPENCODEX_OPENCODE_API_KEY= 카탈로그가 공식 context window를 보고할 때만 `limit.context`를 씁니다. 그렇지 않으면 `limit` 블록 전체를 생략하고 opencode는 자체 기본값을 유지합니다. -opencode의 스키마는 `output` 없이 `context`만 있는 `limit` 블록을 거부합니다. 카탈로그에는 -모델별로 공인된 `output` 필드가 없으므로, 이를 맞추기 위해 `32000`의 `output` -예산을 함께 내보내고, 작은 context 모델에 `output > context`가 되지 않도록 context -window에 맞춰 낮춥니다. 그 수치는 스키마를 만족시키기 위한 값일 뿐이며, 어떤 특정 -모델의 실제 최대치를 뜻하지는 않습니다. +출력 한도는 카탈로그 또는 생성된 메타데이터의 알려진 모델 한도를 사용합니다. 알 수 없는 경우에만 `32000`을 사용합니다. 출력 한도는 항상 context window 이하로 제한되며, `32000`보다 작은 알려진 한도도 유지됩니다. `opencodex` provider 블록은 실행할 때마다 다시 생성되므로, 그 안에서 한 모델별 조정은 유지되지 않습니다. 대신 사용자만의 provider 키 아래에 사용자 정의 항목을 diff --git a/docs-site/src/content/docs/ko/guides/pi.md b/docs-site/src/content/docs/ko/guides/pi.md index 3de309f7701..deaf1a6ab67 100644 --- a/docs-site/src/content/docs/ko/guides/pi.md +++ b/docs-site/src/content/docs/ko/guides/pi.md @@ -37,7 +37,7 @@ ocx export --client pi "name": "Claude Opus 5 (anthropic)", "input": ["text"], "contextWindow": 200000, - "maxTokens": 32000 + "maxTokens": 128000 } ] } @@ -107,9 +107,7 @@ export OPENCODEX_API_KEY= 출력됩니다. 그렇지 않으면 두 필드 모두 해당 모델에서 생략되고, Pi는 자체 기본값을 적용합니다. `ocx export`는 그 경우가 몇 줄이었는지도 함께 출력합니다. -`maxTokens`는 스키마를 만족시키기 위한 `32000` 예산이며, context window보다 더 크게 -잡히지 않도록 아래로 잘립니다. 즉, 작은 context 모델에 그보다 많은 출력을 주겠다는 -의미가 아닙니다. +출력 한도는 카탈로그 또는 생성된 메타데이터의 알려진 모델 한도를 사용합니다. 알 수 없는 경우에만 `32000`을 사용합니다. 출력 한도는 항상 context window 이하로 제한되며, `32000`보다 작은 알려진 한도도 유지됩니다. 의도적으로 빠진 필드도 두 개 있습니다. `cost`는 네 개의 가격 필드가 모두 있어야 하는데, opencodex는 라우팅된 모델의 가격 데이터를 갖고 있지 않습니다. 0을 넣으면 diff --git a/docs-site/src/content/docs/ru/guides/opencode.md b/docs-site/src/content/docs/ru/guides/opencode.md index 512eb64581e..12a1b04ea10 100644 --- a/docs-site/src/content/docs/ru/guides/opencode.md +++ b/docs-site/src/content/docs/ru/guides/opencode.md @@ -134,11 +134,7 @@ Loopback-привязка (`127.0.0.1`, по умолчанию) не требу `limit.context` записывается только тогда, когда каталог сообщает авторитетное контекстное окно; если нет, весь блок `limit` опускается, и opencode использует собственные значения по умолчанию. -Схема opencode отвергает блок `limit`, в котором есть `context`, но нет `output`, а в каталоге нет -авторитетного per-model поля output, поэтому рядом записывается `output` с бюджетом `32000`, -ограниченным сверху значением context window, чтобы у модели с маленьким контекстом никогда не -получалось `output > context`. Эта цифра существует только для удовлетворения схемы — она не -утверждает ничего о реальном максимуме какой-либо модели. +Лимит вывода берётся из каталога или сгенерированных метаданных модели. Значение `32000` используется только при неизвестном лимите. Лимит всегда ограничен окном контекста; известные значения ниже `32000` сохраняются. Блок провайдера `opencodex` пересобирается при каждом запуске, поэтому внесённые вами правки внутри него не сохранятся. Для пользовательских записей держите отдельный provider key. diff --git a/docs-site/src/content/docs/ru/guides/pi.md b/docs-site/src/content/docs/ru/guides/pi.md index c93286ea752..6ca5fdd3cc6 100644 --- a/docs-site/src/content/docs/ru/guides/pi.md +++ b/docs-site/src/content/docs/ru/guides/pi.md @@ -37,7 +37,7 @@ ocx export --client pi "name": "Claude Opus 5 (anthropic)", "input": ["text"], "contextWindow": 200000, - "maxTokens": 32000 + "maxTokens": 128000 } ] } @@ -108,9 +108,7 @@ export OPENCODEX_API_KEY= контекстное окно. Если его нет, оба поля для этой модели опускаются, и Pi использует собственные значения по умолчанию; `ocx export` печатает, сколько строк попали в эту категорию. -`maxTokens` — это удовлетворяющий схеме бюджет `32000`, ограниченный сверху контекстным окном, -чтобы у модели с маленьким контекстом никогда не было больше output, чем сам context. Это не -утверждение о реальном максимуме какой-либо модели. +Лимит вывода берётся из каталога или сгенерированных метаданных модели. Значение `32000` используется только при неизвестном лимите. Лимит всегда ограничен окном контекста; известные значения ниже `32000` сохраняются. Два поля намеренно отсутствуют. `cost` требует всех четырёх ценовых полей, а у opencodex нет данных о ценах для маршрутизируемых моделей — вывести нули означало бы заявить, что каждая модель diff --git a/docs-site/src/content/docs/tr/guides/opencode.md b/docs-site/src/content/docs/tr/guides/opencode.md index 4989bb5ea51..05f6f256a30 100644 --- a/docs-site/src/content/docs/tr/guides/opencode.md +++ b/docs-site/src/content/docs/tr/guides/opencode.md @@ -148,12 +148,7 @@ yapılandırmanızı tam olarak eskisi gibi okur. yazılır; bildirmediğinde tüm `limit` bloğu atlanır ve opencode kendi varsayılanlarını korur. -opencode'un şeması `output` olmadan `context` taşıyan bir `limit` bloğunu -reddeder ve kataloğun yetkili bir model başına çıktı alanı yoktur; bu nedenle -yanında `32000`'lik bir `output` bütçesi yayınlanır ve küçük bağlamlı bir modele -asla `output > context` verilmemesi için bağlam penceresine doğru sabitlenir. Bu -rakam şemayı karşılamak için vardır — belirli bir modelin gerçek maksimumu -hakkında bir iddia değildir. +Çıktı sınırı, katalogdaki veya oluşturulan meta verilerdeki bilinen model sınırını kullanır. Yalnızca sınır bilinmiyorsa `32000` kullanılır. Çıktı sınırı her zaman bağlam penceresiyle sınırlanır; `32000` altındaki bilinen sınırlar da korunur. `opencodex` sağlayıcı bloğu her başlatmada yeniden oluşturulur, bu nedenle içinde yapılan model başına ince ayarlar hayatta kalmaz. Bunun yerine özel diff --git a/docs-site/src/content/docs/tr/guides/pi.md b/docs-site/src/content/docs/tr/guides/pi.md index e04cd9326e4..a91cb1c372b 100644 --- a/docs-site/src/content/docs/tr/guides/pi.md +++ b/docs-site/src/content/docs/tr/guides/pi.md @@ -41,7 +41,7 @@ export line, and how many models carry authoritative context limits. "name": "Claude Opus 5 (anthropic)", "input": ["text"], "contextWindow": 200000, - "maxTokens": 32000 + "maxTokens": 128000 } ] } @@ -126,11 +126,7 @@ window. When it does not, both fields are omitted for that model and Pi applies its own defaults; `ocx export` prints how many rows fell into that case. -`maxTokens` is a schema-satisfying budget of `32000`, clamped down to the -context window so a -small-context model is never given more output than context. It is not a claim -about any specific -model's true maximum. +Çıktı sınırı, katalogdaki veya oluşturulan meta verilerdeki bilinen model sınırını kullanır. Yalnızca sınır bilinmiyorsa `32000` kullanılır. Çıktı sınırı her zaman bağlam penceresiyle sınırlanır; `32000` altındaki bilinen sınırlar da korunur. Two fields are deliberately absent. `cost` requires all four price fields and opencodex has no diff --git a/docs-site/src/content/docs/zh-cn/guides/opencode.md b/docs-site/src/content/docs/zh-cn/guides/opencode.md index 8a969cbb400..1210e7ea5f8 100644 --- a/docs-site/src/content/docs/zh-cn/guides/opencode.md +++ b/docs-site/src/content/docs/zh-cn/guides/opencode.md @@ -96,7 +96,7 @@ loopback 绑定(`127.0.0.1`,默认值)不会进行任何认证,所以 `{ 只有当目录报告了权威的 context window 时,才会写入 `limit.context`;如果没有报告,整个 `limit` block 会被省略,opencode 则继续使用自己的默认值。 -opencode 的 schema 会拒绝一个包含 `context` 但不包含 `output` 的 `limit` block,而目录没有按模型粒度提供权威的 output 字段,因此会同时写入一个 `32000` 的 `output` budget,并将其钳制到 context window 以内,确保不会给小 context 模型分配 `output > context`。这个数值只是为了满足 schema - 它并不是对任何具体模型真实上限的声明。 +输出上限使用目录或生成元数据中已知的模型上限,仅在未知时回退到 `32000`。输出上限始终不超过 context window;已知低于 `32000` 的上限也会保留。 `opencodex` provider block 会在每次启动时重新生成,所以在其中做的逐模型调整不会保留。若要自定义条目,请把它们放到你自己的 provider key 下。 diff --git a/docs-site/src/content/docs/zh-cn/guides/pi.md b/docs-site/src/content/docs/zh-cn/guides/pi.md index 8358cc82673..a0132b23200 100644 --- a/docs-site/src/content/docs/zh-cn/guides/pi.md +++ b/docs-site/src/content/docs/zh-cn/guides/pi.md @@ -33,7 +33,7 @@ ocx export --client pi "name": "Claude Opus 5 (anthropic)", "input": ["text"], "contextWindow": 200000, - "maxTokens": 32000 + "maxTokens": 128000 } ] } @@ -89,7 +89,7 @@ export OPENCODEX_API_KEY= 只有当目录报告了权威的上下文窗口时,`contextWindow` 和 `maxTokens` 才会被输出。如果没有报告,这两个字段就会在该模型上省略,Pi 会应用自己的默认值;`ocx export` 会打印有多少行落入了这种情况。 -`maxTokens` 是一个满足 schema 的 `32000` 预算,并会向下钳制到上下文窗口,因此不会给小上下文模型分配超过其上下文容量的输出。它并不声称某个具体模型的真实最大值。 +输出上限使用目录或生成元数据中已知的模型上限,仅在未知时回退到 `32000`。输出上限始终不超过 context window;已知低于 `32000` 的上限也会保留。 有两个字段是刻意省略的。`cost` 需要全部四个价格字段,而 opencodex 没有已路由模型的价格数据 - 如果输出 0,会等于断言所有模型都是免费的。`reasoning` 在 Pi 里是一个布尔值,而目录里是一个 effort 层级,把二者互相映射只能是猜测。 diff --git a/docs-site/src/content/docs/zh-tw/guides/opencode.md b/docs-site/src/content/docs/zh-tw/guides/opencode.md index 662491952f5..4ac25254e51 100644 --- a/docs-site/src/content/docs/zh-tw/guides/opencode.md +++ b/docs-site/src/content/docs/zh-tw/guides/opencode.md @@ -101,7 +101,7 @@ Loopback 範例: 只有在目錄回報具權威性的 context window 時,才會寫入 `limit.context`;若沒有,會省略整個 `limit` 區塊,opencode 沿用自己的預設值。 -opencode 的 schema 會拒絕只有 `context`、沒有 `output` 的 `limit` 區塊,而目錄又沒有具權威性的 per-model output 欄位,因此會一併發出 `output` 預算 `32000`,並向下 clamp 到 context window,避免小 context 模型出現 `output > context`。這個數字是為了滿足 schema——並非宣稱任何特定模型的真實上限。 +輸出上限使用目錄或產生的中繼資料中已知的模型上限,僅在未知時回退到 `32000`。輸出上限始終不超過 context window;已知低於 `32000` 的上限也會保留。 `opencodex` provider 區塊每次啟動都會重新產生,因此在裡面做的 per-model 調整不會保留。請把自訂項目放在你自己的 provider 鍵底下。 diff --git a/docs-site/src/content/docs/zh-tw/guides/pi.md b/docs-site/src/content/docs/zh-tw/guides/pi.md index 09e057ae95d..4a39ade9bdc 100644 --- a/docs-site/src/content/docs/zh-tw/guides/pi.md +++ b/docs-site/src/content/docs/zh-tw/guides/pi.md @@ -33,7 +33,7 @@ ocx export --client pi "name": "Claude Opus 5 (anthropic)", "input": ["text"], "contextWindow": 200000, - "maxTokens": 32000 + "maxTokens": 128000 } ] } @@ -91,7 +91,7 @@ export OPENCODEX_API_KEY= `contextWindow` 與 `maxTokens` 僅在目錄回報權威上下文窗口時發出。若未回報,該模型的兩個欄位都會省略,Pi 會套用自身預設值;`ocx export` 會印出有多少列屬於該情況。 -`maxTokens` 是滿足 schema 的 `32000` 預算,並限制在不超過上下文窗口,使得小上下文模型永遠不會被給予超過上下文的輸出量。它並非對任何特定模型真實最大值的聲明。 +輸出上限使用目錄或產生的中繼資料中已知的模型上限,僅在未知時回退到 `32000`。輸出上限始終不超過 context window;已知低於 `32000` 的上限也會保留。 有兩個欄位刻意省略。`cost` 需要全部四個價格欄位,而 opencodex 對路由模型沒有價格資料 — 發出零值會斷言每個模型都是免費的。`reasoning` 在 Pi 中是 boolean,而目錄帶有 effort 階梯,將兩者互相映射會是猜測。 diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index b282d08aadb..51fb364bde0 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -722,7 +722,7 @@ export function opencodeProviderBlocks( const entry: OpencodeModelEntry = { name: exportModelLabel(model) }; const context = authoritativeContextWindow(model.contextWindow); if (context !== undefined) { - entry.limit = { context, output: outputBudgetFor(context) }; + entry.limit = { context, output: outputBudgetFor(context, model) }; } // `attachment` / `modalities` are fields of opencode's V1 model schema — the shape its // published config.json defines and the one its loader reads (verified against opencode @@ -1004,7 +1004,7 @@ function buildPiClientConfig(ctx: ExportContext, options: PiExportOptions = {}): const context = authoritativeContextWindow(model.contextWindow); if (context !== undefined) { entry.contextWindow = context; - entry.maxTokens = outputBudgetFor(context); + entry.maxTokens = outputBudgetFor(context, model); } models.push(entry); } @@ -1140,7 +1140,7 @@ function buildGajaeClientConfig(ctx: ExportContext): GajaeGeneratedConfig { const context = authoritativeContextWindow(model.contextWindow); if (context !== undefined) { entry.contextWindow = context; - entry.maxTokens = outputBudgetFor(context); + entry.maxTokens = outputBudgetFor(context, model); } models.push(entry); } diff --git a/src/clients/config-export/constants.ts b/src/clients/config-export/constants.ts index 7f2df038fc9..5e272cec4e3 100644 --- a/src/clients/config-export/constants.ts +++ b/src/clients/config-export/constants.ts @@ -45,18 +45,7 @@ export const GAJAE_API_KEY_ENV = "OPENCODEX_GAJAE_API_KEY"; /** Pi's wire-dialect selector for an OpenAI-compatible endpoint. */ export const PI_API_DIALECT = "openai-completions"; -/** - * opencode's config schema rejects a `limit` block that carries `context` without - * `output`, but CatalogModel has no authoritative per-model output field. Dropping - * `limit` entirely would also throw away the authoritative context window we DO have, - * so the block is emitted with this budget standing in for the missing half. - * - * The value matches REASONING_MAX_TOKENS_CEILING in src/adapters/anthropic.ts — the - * project's existing "safe ceiling across current models" figure. It is a ceiling for - * schema validity, NOT a claim about any specific model's true maximum, and it is - * clamped to the context window so a small-context model can never be emitted with - * output > context. Pi's `maxTokens` uses the same stand-in and the same clamp. - */ +/** Fallback only when the model has no known output limit; always clamped to context. */ export const SCHEMA_REQUIRED_OUTPUT_BUDGET = 32_000; /** Deterministic loopback default for exported provider-block helpers in tests. */ diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts index 5ed26261805..6744150d7a7 100644 --- a/src/clients/config-export/contracts.ts +++ b/src/clients/config-export/contracts.ts @@ -37,6 +37,7 @@ export interface OpencodeCatalogModel { provider?: string; id?: string; contextWindow?: number; + maxTokens?: number; displayName?: string; /** * Declared input modalities, carried verbatim from `/api/models`. Serialized as opencode's @@ -71,6 +72,7 @@ export interface ExportModel { native?: boolean; displayName?: string; contextWindow?: number; + maxTokens?: number; inputModalities?: string[]; /** Optional effort ladder exported only to clients that support it. */ reasoningEfforts?: string[]; diff --git a/src/clients/config-export/model-metadata.ts b/src/clients/config-export/model-metadata.ts index 5c69af92caa..fe00bddb6fd 100644 --- a/src/clients/config-export/model-metadata.ts +++ b/src/clients/config-export/model-metadata.ts @@ -1,4 +1,5 @@ // Shared client export model metadata. +import { getModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; import { SCHEMA_REQUIRED_OUTPUT_BUDGET } from "./constants"; import { expandFastExportModels } from "./fast-models"; import type { OpencodeCatalogModel, ExportModel, ExportClientId, ManagedContribution } from "./contracts"; @@ -18,9 +19,14 @@ export function authoritativeContextWindow(contextWindow: number | undefined): n return undefined; } -/** Schema-required output budget for a known context window. */ -export function outputBudgetFor(context: number): number { - return Math.min(SCHEMA_REQUIRED_OUTPUT_BUDGET, context); +/** Known model output limit, with a schema-required fallback, clamped to context. */ +export function outputBudgetFor(context: number, model: OpencodeCatalogModel): number { + const provider = model.provider ?? ""; + const metadata = getModelMetadata(resolveMetadataProvider(provider) ?? provider, model.id ?? model.namespaced); + const limit = authoritativeContextWindow(model.maxTokens) + ?? authoritativeContextWindow(metadata?.maxTokens) + ?? SCHEMA_REQUIRED_OUTPUT_BUDGET; + return Math.min(limit, context); } /** diff --git a/src/clients/config-export/omp.ts b/src/clients/config-export/omp.ts index e31d9bc59f4..61d986a0437 100644 --- a/src/clients/config-export/omp.ts +++ b/src/clients/config-export/omp.ts @@ -67,7 +67,7 @@ export function buildOmpClientConfig(ctx: ExportContext): OmpGeneratedConfig { const context = authoritativeContextWindow(model.contextWindow); if (context !== undefined) { entry.contextWindow = context; - entry.maxTokens = outputBudgetFor(context); + entry.maxTokens = outputBudgetFor(context, model); } const efforts = ompEfforts(model); if (efforts.length > 0) { diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index a15b853da50..40ec0a18a6c 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -156,6 +156,13 @@ authoritative. Existing client configs receive the entries on export or managed save refreshes enabled native clients and already-owned file integrations when the running proxy port is available; otherwise the operator refreshes the integration or client catalog explicitly. +## Model output limits + +OpenCode, Pi-family clients, OMP and Gajae export the explicit model `maxTokens` when valid, +otherwise the generated metadata limit for the provider and model ID (including provider aliases). +Only unknown limits fall back to 32000. Every output limit is clamped to the authoritative +context window; absent context still omits both limits. Fast rows preserve these limits. + ## Model input capability exports All registered integrations consume the shared catalog, including [Anthropic seed image metadata](../runtime.md#capability-aware-image-admission), through their existing schema-specific exports: diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index 332f6b19d08..0b6fd2686a1 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -9,7 +9,6 @@ import { OPENCODE_API_KEY_ENV, OPENCODE_API_KEY_ENV_REF, LOOPBACK_API_KEY_PLACEHOLDER, - SCHEMA_REQUIRED_OUTPUT_BUDGET, buildClientConfig, buildClientContribution, buildClientConfigText, @@ -65,15 +64,15 @@ function cfg(extra?: Partial): OcxConfig { } /** - * Captured from `buildOpencodeProviderBlockFromCatalog` BEFORE the serializer moved to + * Based on `buildOpencodeProviderBlockFromCatalog` before the serializer moved to * src/clients/config-export.ts, for the fixture above at port 10100 / 127.0.0.1. Inlined * rather than read from a file so the assertion survives without scratch state. * - * The relocated builder must reproduce this byte-for-byte; the client-config path adds a + * Updated for authoritative output limits (#5828). The builder reproduces this; the client-config path adds a * dedupe+sort precondition, so it is compared entry-by-entry against the same truth. */ const GOLDEN_OPENCODE_BLOCK = JSON.parse( - '{"npm":"@ai-sdk/openai-compatible","name":"OpenCodex","options":{"baseURL":"http://127.0.0.1:10100/v1","apiKey":"{env:OPENCODEX_OPENCODE_API_KEY}"},"models":{"gpt-5.6-luna":{"name":"gpt-5.6-luna (native)","limit":{"context":272000,"output":32000}},"anthropic/claude-opus-5":{"name":"Claude Opus 5 (anthropic)","limit":{"context":200000,"output":32000}},"custom/no-context":{"name":"no-context (custom)"},"tiny/small-ctx":{"name":"small-ctx (tiny)","limit":{"context":8000,"output":8000}}}}', + '{"npm":"@ai-sdk/openai-compatible","name":"OpenCodex","options":{"baseURL":"http://127.0.0.1:10100/v1","apiKey":"{env:OPENCODEX_OPENCODE_API_KEY}"},"models":{"gpt-5.6-luna":{"name":"gpt-5.6-luna (native)","limit":{"context":272000,"output":128000}},"anthropic/claude-opus-5":{"name":"Claude Opus 5 (anthropic)","limit":{"context":200000,"output":128000}},"custom/no-context":{"name":"no-context (custom)"},"tiny/small-ctx":{"name":"small-ctx (tiny)","limit":{"context":8000,"output":8000}}}}', ) as { npm: string; name: string; @@ -141,7 +140,7 @@ describe("split config-export public facade", () => { describe("relocated OpenCode serializer (accept criterion 1)", () => { - test("the moved builder reproduces the pre-refactor golden byte-for-byte", () => { + test("the builder reproduces the updated golden byte-for-byte", () => { const block = buildOpencodeProviderBlockFromCatalog(10100, FIXTURE, "127.0.0.1"); expect(JSON.stringify(block)).toBe(JSON.stringify(GOLDEN_OPENCODE_BLOCK)); }); @@ -397,11 +396,11 @@ describe("Pi serializer (accept criterion 2)", () => { expect(entry).toEqual({ id: "custom/no-context", name: "no-context (custom)", input: ["text"] }); }); - test("maxTokens uses the schema budget and clamps to a smaller context window", () => { + test("maxTokens uses the known output limit and clamps to a smaller context window", () => { const models = piConfig().providers.opencodex!.models; const large = models.find(model => model.id === "gpt-5.6-luna")!; expect(large.contextWindow).toBe(272_000); - expect(large.maxTokens).toBe(SCHEMA_REQUIRED_OUTPUT_BUDGET); + expect(large.maxTokens).toBe(128_000); const small = models.find(model => model.id === "tiny/small-ctx")!; expect(small.contextWindow).toBe(8_000); expect(small.maxTokens).toBe(8_000); @@ -879,7 +878,7 @@ describe("EXPORT_CLIENTS registry", () => { "name": "Claude Opus 5 (anthropic)", "limit": { "context": 200000, - "output": 32000 + "output": 128000 } }, "custom/no-context": { @@ -889,7 +888,7 @@ describe("EXPORT_CLIENTS registry", () => { "name": "gpt-5.6-luna (native)", "limit": { "context": 272000, - "output": 32000 + "output": 128000 } }, "tiny/small-ctx": { @@ -915,7 +914,7 @@ describe("EXPORT_CLIENTS registry", () => { "name": "Claude Opus 5 (anthropic)", "limit": { "context": 200000, - "output": 32000 + "output": 128000 } }, "custom/no-context": { @@ -925,7 +924,7 @@ describe("EXPORT_CLIENTS registry", () => { "name": "gpt-5.6-luna (native)", "limit": { "context": 272000, - "output": 32000 + "output": 128000 } }, "tiny/small-ctx": { @@ -963,7 +962,7 @@ describe("EXPORT_CLIENTS registry", () => { "text" ], "contextWindow": 200000, - "maxTokens": 32000 + "maxTokens": 128000 }, { "id": "custom/no-context", @@ -979,7 +978,7 @@ describe("EXPORT_CLIENTS registry", () => { "text" ], "contextWindow": 272000, - "maxTokens": 32000 + "maxTokens": 128000 }, { "id": "tiny/small-ctx", @@ -1157,3 +1156,37 @@ test("renamed CommandCode gathered effort tables reach DSH and ZCode exports", a isolated.restore(); } }); + +describe("authoritative client output limits (#5828)", () => { + for (const [provider, id, contextWindow, expected] of [ + ["anthropic", "claude-opus-5", 200_000, 128_000], + ["anthropic-apikey", "claude-opus-5", 200_000, 128_000], + ["xai", "grok-4.20-0309-reasoning", 2_000_000, 30_000], + ["anthropic", "claude-opus-5", 8_000, 8_000], + ["custom", "unknown", 200_000, 32_000], + ["custom", "unknown", 8_000, 8_000], + ] as const) { + test(`${provider}/${id} with context ${contextWindow} exports ${expected}`, () => { + const model = { provider, id, namespaced: `${provider}/${id}`, contextWindow }; + const context = ctx({ models: [model] }); + const block = opencodeConfig(context).provider.opencodex!; + expect(block.models[model.namespaced]!.limit!.output).toBe(expected); + for (const client of ["pi", "omp", "gajae"] as const) { + const config = buildClientConfig(client, context) as PiGeneratedConfig; + expect(config.providers.opencodex!.models[0]!.maxTokens).toBe(expected); + } + }); + } +}); + +test("explicit output metadata takes precedence and survives Fast expansion", () => { + for (const maxTokens of [60_000, 300_000, 0, NaN, Infinity]) { + const model: ExportModel = { provider: "anthropic", id: "claude-opus-5", namespaced: "anthropic/claude-opus-5", contextWindow: 200_000, maxTokens, fastRowAvailable: true }; + const expected = maxTokens > 0 && Number.isFinite(maxTokens) ? Math.min(maxTokens, 200_000) : 128_000; + for (const client of ["pi", "omp", "gajae"] as const) { + const config = buildClientConfig(client, ctx({ models: [model] })) as PiGeneratedConfig; + expect(config.providers.opencodex!.models).toHaveLength(2); + for (const entry of config.providers.opencodex!.models) expect(entry.maxTokens).toBe(expected); + } + } +}); diff --git a/tests/providers/opencode-cli.test.ts b/tests/providers/opencode-cli.test.ts index 671d00a9155..c9327f50798 100644 --- a/tests/providers/opencode-cli.test.ts +++ b/tests/providers/opencode-cli.test.ts @@ -131,7 +131,7 @@ describe("ocx opencode provider block", () => { test("native slugs pick up authoritative context windows from the resolver", () => { const block = buildOpencodeProviderBlock(10100, ["gpt-5.6-luna", "unknown-native"], [], slug => slug === "gpt-5.6-luna" ? 1_000_000 : undefined); - expect(block.models["gpt-5.6-luna"]?.limit).toEqual({ context: 1_000_000, output: SCHEMA_REQUIRED_OUTPUT_BUDGET }); + expect(block.models["gpt-5.6-luna"]?.limit).toEqual({ context: 1_000_000, output: 128_000 }); expect(block.models["unknown-native"]?.limit).toBeUndefined(); }); From b50f81424077f0eedd31127b8ff440e56b3548d3 Mon Sep 17 00:00:00 2001 From: codingbo Date: Sat, 26 Sep 2026 12:22:13 +0900 Subject: [PATCH 02/11] fix(clients): include access.type in ZCode provider_config.json export (#5889) Squashed carry of #5889. Closes #5861 Co-authored-by: codingbo --- src/clients/config-export/zcode-store.ts | 4 ++-- tests/clients/integrations-current-store.test.ts | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/clients/config-export/zcode-store.ts b/src/clients/config-export/zcode-store.ts index 5ba585e0193..cf824b02e24 100644 --- a/src/clients/config-export/zcode-store.ts +++ b/src/clients/config-export/zcode-store.ts @@ -42,7 +42,7 @@ export interface ZcodeStoreProviderRule { providerName: string; config: { group: string; - access: { apiKey: string }; + access: { type: "api-key"; apiKey: string }; api: { type: string; baseUrl: string }; personalModelIds: string[]; modelOrder: string[]; @@ -113,7 +113,7 @@ export function buildZcodeStoreProviderRule(ctx: ExportContext): ZcodeStoreProvi providerName: ZCODE_STORE_PROVIDER_NAME, config: { group: ZCODE_STORE_PROVIDER_GROUP, - access: { apiKey: LOOPBACK_API_KEY_PLACEHOLDER }, + access: { type: "api-key", apiKey: LOOPBACK_API_KEY_PLACEHOLDER }, api: { type: ZCODE_STORE_API_TYPE, baseUrl: `${ctx.baseUrl.replace(/\/v1\/?$/, "")}/v1` }, personalModelIds: ids, /* diff --git a/tests/clients/integrations-current-store.test.ts b/tests/clients/integrations-current-store.test.ts index 089c8e1de93..8d862bde92d 100644 --- a/tests/clients/integrations-current-store.test.ts +++ b/tests/clients/integrations-current-store.test.ts @@ -8,6 +8,7 @@ import { ZCODE_STORE_PROVIDER_RULES_PATH, ZCODE_STORE_SCHEMA_VERSION, buildZcodeStoreProviderRule, + type ZcodeStoreProviderRule, type ExportModel, } from "../../src/clients/config-export"; import { formatSelectorConjunction } from "../../src/integrations/merge"; @@ -136,6 +137,8 @@ describe("writing the provider store the client reads", () => { // so the base URL is the one `ocx export` composes rather than a literal. expect(readPath(readStore(), OUR_PROVIDER_RULE)) .toEqual(buildZcodeStoreProviderRule(exportContextOf(input()))); + const writtenRule = readPath(readStore(), OUR_PROVIDER_RULE) as ZcodeStoreProviderRule; + expect(writtenRule.config.access.type).toBe("api-key"); expect(readPath(readStore(), ourModelRule("anthropic/claude-opus-4-8"))) .toMatchObject({ config: { properties: { contextWindow: 200_000 } } }); // And nothing was written to the file it stopped reading. From bbeb75b99b2e88a76105830704b82724b8576986 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Sat, 26 Sep 2026 12:22:13 +0900 Subject: [PATCH 03/11] fix(anthropic): keep the caller's cache_control when an image block is rewritten (#5867) Squashed carry of #5867. Co-authored-by: Vadym O --- src/adapters/anthropic-image-guard.ts | 14 +++++++++++++- src/adapters/anthropic-image-normalize.ts | 5 +++-- .../anthropic/anthropic-image-guard.test.ts | 11 +++++++++++ .../anthropic/anthropic-image-normalize.test.ts | 17 +++++++++++++++++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/adapters/anthropic-image-guard.ts b/src/adapters/anthropic-image-guard.ts index 1def336b491..30179a01f3a 100644 --- a/src/adapters/anthropic-image-guard.ts +++ b/src/adapters/anthropic-image-guard.ts @@ -170,8 +170,20 @@ export function collectImageRefs(messages: unknown[]): ImageBlockRef[] { return refs; } +/** + * A rebuilt block must carry the caller's prompt-cache breakpoint. `cache_control` is a + * SIBLING of `type` on the original block, so replacing that block with a fresh object + * literal silently deletes it — the prefix is then re-written instead of read, which in a + * screenshot-driven session re-writes the whole conversation tail every turn. Only + * `cache_control` is carried over: an image block's `source` must never land on a text block. + */ +export function cacheControlOf(ref: ImageBlockRef): { cache_control?: unknown } { + const cacheControl = (ref.container[ref.index] as { cache_control?: unknown })?.cache_control; + return cacheControl === undefined ? {} : { cache_control: cacheControl }; +} + function textify(ref: ImageBlockRef, text: string): void { - ref.container[ref.index] = { type: "text", text }; + ref.container[ref.index] = { type: "text", text, ...cacheControlOf(ref) }; } /** diff --git a/src/adapters/anthropic-image-normalize.ts b/src/adapters/anthropic-image-normalize.ts index 747994718aa..d9506f6e90c 100644 --- a/src/adapters/anthropic-image-normalize.ts +++ b/src/adapters/anthropic-image-normalize.ts @@ -15,6 +15,7 @@ */ import { + cacheControlOf, collectImageRefs, sniffImageDimensions, TOTAL_IMAGE_BASE64_BUDGET, @@ -75,11 +76,11 @@ function mediaTypeOf(ref: ImageBlockRef): string { } function textify(ref: ImageBlockRef, text: string): void { - ref.container[ref.index] = { type: "text", text }; + ref.container[ref.index] = { type: "text", text, ...cacheControlOf(ref) }; } function replaceImage(ref: ImageBlockRef, data: string, mediaType: string): void { - ref.container[ref.index] = { type: "image", source: { type: "base64", media_type: mediaType, data } }; + ref.container[ref.index] = { type: "image", source: { type: "base64", media_type: mediaType, data }, ...cacheControlOf(ref) }; } function initialPosition(newestFirstIndex: number, bias: number): number { diff --git a/tests/adapters/anthropic/anthropic-image-guard.test.ts b/tests/adapters/anthropic/anthropic-image-guard.test.ts index 8858d5521ec..07f6e4d6422 100644 --- a/tests/adapters/anthropic/anthropic-image-guard.test.ts +++ b/tests/adapters/anthropic/anthropic-image-guard.test.ts @@ -149,6 +149,17 @@ describe("enforceAnthropicImageLimits", () => { expect(content[1].type).toBe("image"); }); + test("a cache_control breakpoint survives textification", () => { + const huge = { ...imageBlock(HUGE), cache_control: { type: "ephemeral", ttl: "1h" } }; + const messages = [userMsg([huge, imageBlock(SMALL)])]; + enforceAnthropicImageLimits(messages); + const content = (messages[0] as { content: Array> }).content; + expect(content[0].type).toBe("text"); + expect(content[0].cache_control).toEqual({ type: "ephemeral", ttl: "1h" }); + // The image block's own properties must NOT ride along onto a text block. + expect(content[0].source).toBeUndefined(); + }); + test("C4: >100 small images trimmed to 100", () => { const messages = [userMsg(Array.from({ length: 110 }, () => imageBlock(SMALL)))]; enforceAnthropicImageLimits(messages); diff --git a/tests/adapters/anthropic/anthropic-image-normalize.test.ts b/tests/adapters/anthropic/anthropic-image-normalize.test.ts index a57123f8d0c..7b22fb26276 100644 --- a/tests/adapters/anthropic/anthropic-image-normalize.test.ts +++ b/tests/adapters/anthropic/anthropic-image-normalize.test.ts @@ -280,6 +280,23 @@ describe("normalizeAnthropicImages — real Bun.Image path", () => { expect(block.text).toContain("undecodable"); }); + test("a cache_control breakpoint survives both re-encoding and textification", async () => { + // Claude Code's rolling cache breakpoint can sit on any block, including an image. + // Losing it re-writes the prefix instead of reading it. + const resized = { ...imageBlock(await realPngBase64(4000, 3000)), cache_control: { type: "ephemeral" } }; + const garbage = { ...imageBlock(Buffer.from("this is not an image at all").toString("base64")), cache_control: { type: "ephemeral", ttl: "1h" } }; + const messages = [userMsg([resized, garbage])]; + await normalizeAnthropicImages(messages); + const content = contentOf(messages) as unknown as Array>; + expect(content[0].type).toBe("image"); + expect((content[0].source as { data: string }).data).not.toBe((resized.source as { data: string }).data); + expect(content[0].cache_control).toEqual({ type: "ephemeral" }); + expect(content[1].type).toBe("text"); + expect(content[1].cache_control).toEqual({ type: "ephemeral", ttl: "1h" }); + // The image block's own properties must NOT ride along onto a text block. + expect(content[1].source).toBeUndefined(); + }); + test("N6b: sniffable-but-truncated PNG is caught by pass-through validation and textified", async () => { // Real PNG cut short: header (dimensions) survives sniffing, pixel data is gone. const whole = Buffer.from(await realPngBase64(400, 300), "base64"); From 9ce40e9bc093b3cb46fb85aa442db214978f8f63 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Sat, 26 Sep 2026 12:22:14 +0900 Subject: [PATCH 04/11] fix(claude): state strict on a translated json_schema instead of letting it default (#5885) Squashed carry of #5885. Co-authored-by: Vadym O --- src/adapters/anthropic-output-schema.ts | 32 +++++++++ src/claude/inbound-model-options.ts | 15 +++- .../claude-integration/claude-inbound.test.ts | 68 ++++++++++++++++++- 3 files changed, 110 insertions(+), 5 deletions(-) diff --git a/src/adapters/anthropic-output-schema.ts b/src/adapters/anthropic-output-schema.ts index 4d2040d5c5d..26adbc7d3b1 100644 --- a/src/adapters/anthropic-output-schema.ts +++ b/src/adapters/anthropic-output-schema.ts @@ -135,3 +135,35 @@ export function isAnthropicOutputSchema(schema: Record): boolea return false; } } + +/** + * Does every object in this schema list ALL of its properties as required? + * + * OpenAI's structured-output strict mode demands exactly that, and rejects anything else with + * `'required' is required to be supplied and to be an array including every key in properties`. + * Anthropic has no such rule, so a caller's legal optional field makes an otherwise identical + * schema a 400 on one vendor and fine on the other. + * + * A caller that marks a field optional means it. Rewriting `required` to satisfy strict mode + * would silently change the contract the caller asked for, so the only honest answer is to stop + * claiming strict for these schemas -- the schema is still sent and still honoured as guidance. + */ +export function satisfiesOpenAiStrictSchema(value: unknown): boolean { + if (Array.isArray(value)) return value.every(satisfiesOpenAiStrictSchema); + if (!value || typeof value !== "object") return true; + const node = value as Record; + // `allOf` is not supported under strict Structured Outputs at all, wherever it appears. + if ("allOf" in node) return false; + const properties = node.properties; + if (isRecord(properties)) { + // An object node must list every property in `required` AND close itself to extras. The + // caller's schema is forwarded verbatim -- `isAnthropicOutputSchema` normalizes a CLONE for + // its own acceptance check -- so an object that never said `additionalProperties: false` + // reaches the wire without it and is refused, however complete its `required` is. + if (node.additionalProperties !== false) return false; + const keys = Object.keys(properties); + const required = Array.isArray(node.required) ? node.required : []; + if (keys.some(key => !required.includes(key))) return false; + } + return Object.values(node).every(satisfiesOpenAiStrictSchema); +} diff --git a/src/claude/inbound-model-options.ts b/src/claude/inbound-model-options.ts index c3eabdf476f..b0e9b0f2781 100644 --- a/src/claude/inbound-model-options.ts +++ b/src/claude/inbound-model-options.ts @@ -1,5 +1,5 @@ import type { OcxClaudeCodeConfig } from "../types"; -import { isAnthropicOutputSchema } from "../adapters/anthropic-output-schema"; +import { isAnthropicOutputSchema, satisfiesOpenAiStrictSchema } from "../adapters/anthropic-output-schema"; import { resolveAlias } from "./alias"; import { stripOneMillionMarker } from "./context-windows"; import { isUnresolvedDesktop3pAlias, resolveDesktop3pAlias } from "./desktop-3p"; @@ -104,7 +104,18 @@ export function formatFromOutputConfig(outputConfig: unknown): Rec | undefined { || !isRec(format.schema) || !isAnthropicOutputSchema(format.schema) ) return undefined; - return { type: "json_schema", name: "response", schema: format.schema }; + // `strict` is stated rather than left to the destination's default. A schema with an optional + // property is legal to Anthropic and a hard 400 under OpenAI strict mode ("'required' ... an + // array including every key in properties"), which takes down every structured-output turn on + // a route whose canonical target is unavailable. Satisfying strict by adding the missing keys + // to `required` would change the caller's contract, so the optionality is preserved and the + // strict claim is dropped instead. + return { + type: "json_schema", + name: "response", + schema: format.schema, + strict: satisfiesOpenAiStrictSchema(format.schema), + }; } /** diff --git a/tests/claude-integration/claude-inbound.test.ts b/tests/claude-integration/claude-inbound.test.ts index 512c5ed7cd0..66551c0da4c 100644 --- a/tests/claude-integration/claude-inbound.test.ts +++ b/tests/claude-integration/claude-inbound.test.ts @@ -273,8 +273,70 @@ describe("claude inbound translation", () => { output_config: { format: { type: "json_schema", schema } }, }); - expect(body.text).toEqual({ format: { type: "json_schema", name: "response", schema } }); - expect(parseRequest(body).options.textFormat).toEqual({ type: "json_schema", name: "response", schema }); + expect(body.text).toEqual({ format: { type: "json_schema", name: "response", schema, strict: true } }); + expect(parseRequest(body).options.textFormat).toEqual({ type: "json_schema", name: "response", schema, strict: true }); + }); + + test("an optional property drops the strict claim instead of rewriting required", () => { + const optional = { + type: "object", + properties: { answer: { type: "string" }, note: { type: "string" } }, + required: ["answer"], + additionalProperties: false, + }; + const body = anthropicToResponsesBody({ + model: "claude-sonnet-5", + max_tokens: 256, + messages: [{ role: "user", content: "Return JSON" }], + output_config: { format: { type: "json_schema", schema: optional } }, + }); + + // OpenAI strict mode 400s on a schema whose `required` omits any property; Anthropic allows + // it. Say strict: false rather than leave the destination's default to decide -- and leave + // `required` exactly as the caller wrote it. + expect(body.text).toEqual({ format: { type: "json_schema", name: "response", schema: optional, strict: false } }); + expect((body.text as { format: { schema: { required: string[] } } }).format.schema.required).toEqual(["answer"]); + expect(parseRequest(body).options.textFormat?.strict).toBe(false); + }); + + test("an open object drops the strict claim even when every property is required", () => { + // `isAnthropicOutputSchema` normalizes a CLONE, so an object that never stated + // `additionalProperties: false` is forwarded verbatim and refused by strict mode however + // complete its `required` is. + const open = { + type: "object", + properties: { answer: { type: "string" } }, + required: ["answer"], + }; + const body = anthropicToResponsesBody({ + model: "claude-sonnet-5", + max_tokens: 256, + messages: [{ role: "user", content: "Return JSON" }], + output_config: { format: { type: "json_schema", schema: open } }, + }); + + expect(body.text).toEqual({ format: { type: "json_schema", name: "response", schema: open, strict: false } }); + expect(parseRequest(body).options.textFormat?.strict).toBe(false); + }); + + test("allOf drops the strict claim; strict Structured Outputs does not support it", () => { + const composed = { + type: "object", + properties: { + answer: { allOf: [{ type: "string" }, { type: "string", minLength: 1 }] }, + }, + required: ["answer"], + additionalProperties: false, + }; + const body = anthropicToResponsesBody({ + model: "claude-sonnet-5", + max_tokens: 256, + messages: [{ role: "user", content: "Return JSON" }], + output_config: { format: { type: "json_schema", schema: composed } }, + }); + + expect(body.text).toEqual({ format: { type: "json_schema", name: "response", schema: composed, strict: false } }); + expect(parseRequest(body).options.textFormat?.strict).toBe(false); }); test("structured output rejects unsupported schemas and preserves root references", () => { @@ -300,7 +362,7 @@ describe("claude inbound translation", () => { expect(invalid.text).toBeUndefined(); expect(referenced.text).toEqual({ - format: { type: "json_schema", name: "response", schema: refSchema }, + format: { type: "json_schema", name: "response", schema: refSchema, strict: false }, }); }); From 722a74762a591fd7590e013154ca82d9473f42a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9A=B0=EC=B2=A0?= Date: Sat, 26 Sep 2026 12:22:14 +0900 Subject: [PATCH 05/11] fix(cli): report refused Codex shim installation as failure (#5888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed carry of #5888. Co-authored-by: 정우철 --- .../docs/ko/reference/cli/lifecycle.md | 2 + .../content/docs/reference/cli/lifecycle.md | 3 ++ src/cli/dispatch.ts | 6 ++- structure/runtime.md | 2 +- .../codex-shim-readiness.test.ts | 48 ++++++++++++++++++- 5 files changed, 57 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 00459d04060..337f284f4cc 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -353,6 +353,8 @@ Windows에서 Task Scheduler 항목을 만들려면 권한 상승이 필요합 PATH 위의 스크립트 기반 `codex` 런처를 가벼운 자동 시작 스크립트로 감쌉니다. 정확한 실행 파일 호출을 깨지 않도록 실제 `codex.exe` 대상은 손대지 않습니다. +설치가 거부되거나 설치 후 shim이 비정상이면 명령은 실패 종료 코드를 반환하고 대시보드는 실패 사유를 표시합니다. +이미 설치된 정상 shim은 성공으로 처리합니다. Windows에서 `codex.exe`만 제공되는 설치는 자동 시작에 `ocx service install`을 사용하세요. 설치나 복구를 확정하기 전에 OpenCodex는 서비스 시작을 우회한 상태에서 저장된 런처를 `--version`으로 실행합니다. 런처가 `codex`를 shim으로 다시 해석해 재귀하거나, 0이 아닌 코드로 diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 1bb6250d49e..9bfe95036dc 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -585,6 +585,9 @@ deleted as an unsafe best-effort rollback. Wrap a script-based `codex` launcher on PATH with a lightweight autostart script. Real `codex.exe` targets are left untouched to avoid breaking exact executable invocations. +If installation is refused or the resulting shim is unhealthy, the command exits nonzero and +the dashboard reports the failure reason. A healthy existing shim still counts as success. +For Windows installations that expose only `codex.exe`, use `ocx service install` for autostart. Before an install or repair is committed, OpenCodex runs the saved launcher with `--version` while service startup is bypassed. It refuses the change and rolls back when the launcher resolves diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 66a933949a4..9df115fa24b 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -692,13 +692,15 @@ const commandRunners: Record = { switch (deps.args[1]) { case "install": { const r = installCodexShim(); + const { healthy, summary } = diagnoseCodexShim(); const { collectCodexShimReadinessWarnings } = await import("./codex-shim-readiness"); - const warnings = diagnoseCodexShim().healthy + const warnings = healthy ? collectCodexShimReadinessWarnings() : []; console.log(`${r.installed && warnings.length === 0 ? "✅ " : "⚠️ "}${r.message}`); for (const warning of warnings) console.warn(` ${warning}`); - break; + if (!healthy) console.error(`Codex shim installation is unhealthy: ${summary}`); + return healthy ? 0 : 1; } case "status": console.log(codexShimStatus()); diff --git a/structure/runtime.md b/structure/runtime.md index b7041a30555..a5070b013cc 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -77,7 +77,7 @@ verified matching processes regardless of the advisory freshness result. ## Hub management dashboard address -When hub management ingress is enabled, `src/cli/dispatch.ts` opens the dashboard on the literal IPv4 loopback address and configured ingress port, matching the listener in `src/server/index.ts`. Other dashboard address selection is unchanged. +`src/cli/dispatch.ts` makes `codex-shim install` exit nonzero when the resulting shim is unhealthy or installation is refused, printing the diagnostic summary alongside the original installer message. An already-installed healthy shim succeeds. When hub management ingress is enabled, `src/cli/dispatch.ts` opens the dashboard on the literal IPv4 loopback address and configured ingress port, matching the listener in `src/server/index.ts`. Other dashboard address selection is unchanged. ## Codex desktop process membership diff --git a/tests/codex-integration/codex-shim-readiness.test.ts b/tests/codex-integration/codex-shim-readiness.test.ts index b6b4e04e9bd..5637819d54c 100644 --- a/tests/codex-integration/codex-shim-readiness.test.ts +++ b/tests/codex-integration/codex-shim-readiness.test.ts @@ -4,6 +4,7 @@ import { chmodSync, mkdirSync, mkdtempSync, + readFileSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -30,6 +31,29 @@ const ready = { }; describe("Codex shim install readiness", () => { + test("a refused install exits unsuccessfully and preserves its reason", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-shim-refused-")); + const binDir = join(root, "bin"); + mkdirSync(binDir); + mkdirSync(join(root, "codex-home")); + mkdirSync(join(root, "ocx-home")); + if (process.platform === "win32") writeFileSync(join(binDir, "codex.exe"), "fixture executable"); + try { + const result = spawnSync(process.execPath, [cliPath, "codex-shim", "install"], { + cwd: repoRoot, + env: { ...process.env, CODEX_HOME: join(root, "codex-home"), + OPENCODEX_HOME: join(root, "ocx-home"), PATH: process.platform === "win32" + ? `${binDir}${delimiter}${join(process.env.SystemRoot ?? "C:\\Windows", "System32")}` : binDir }, + encoding: "utf8", timeout: SHIM_INSTALL_CHILD_MS, + }); + expect(result.error).toBeUndefined(); + if (!result.stdout) throw new Error(result.stderr); + expect(result.status).toBe(1); + expect(result.stdout).toContain(process.platform === "win32" + ? "Refusing to rename a real .exe" : "Could not find a codex executable"); + } finally { removeTreeWithRetry(root); } + }, SHIM_INSTALL_CASE_MS); + test("keeps a clean install green for native and managed routing", () => { expect(codexShimReadinessWarnings(ready)).toEqual([]); expect(codexShimReadinessWarnings({ @@ -186,8 +210,30 @@ describe("Codex shim install readiness", () => { expect(result.status).toBe(0); expect(result.stdout).toStartWith("⚠️ Codex autostart shim installed"); expect(result.stderr).toContain("Codex routing could not be verified"); + // A healthy no-op reports installed:false internally but must still exit successfully. + const repeat = spawnSync(process.execPath, [cliPath, "codex-shim", "install"], { + cwd: repoRoot, + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome, + PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}` }, + encoding: "utf8", timeout: SHIM_INSTALL_CHILD_MS, killSignal: "SIGKILL", + }); + expect(repeat.error).toBeUndefined(); + expect(repeat.status).toBe(0); + expect(repeat.stdout).toContain("already installed"); + expect(repeat.stderr).toContain("Codex routing could not be verified"); + // Keep the marker and backing file, but break the launch-time ensure contract. + writeFileSync(codex, readFileSync(codex, "utf8").replaceAll("ensure", "broken")); + const damaged = spawnSync(process.execPath, [cliPath, "codex-shim", "install"], { + cwd: repoRoot, + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome, + PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}` }, + encoding: "utf8", timeout: SHIM_INSTALL_CHILD_MS, killSignal: "SIGKILL", + }); + expect(damaged.error).toBeUndefined(); + expect(damaged.status).toBe(1); + expect(damaged.stderr).toContain("unhealthy"); } finally { removeTreeWithRetry(root); } - }, SHIM_INSTALL_CASE_MS); + }, SHIM_INSTALL_CASE_MS * 3); }); From 076c66d72b0d26717e6d38afc99a59269b49fe96 Mon Sep 17 00:00:00 2001 From: codingbo Date: Sat, 26 Sep 2026 12:22:15 +0900 Subject: [PATCH 06/11] fix(providers): sanitize Claude Code Artifact tool schema for DeepSeek (#5892) Squashed carry of #5892. Closes #5658 Co-authored-by: codingbo --- .../src/content/docs/reference/adapters.md | 8 +++ scripts/test-layout/layout.json | 1 + .../openai-chat/deepseek-artifact-schema.ts | 45 ++++++++++++++++ src/adapters/openai-chat/tool-schema.ts | 8 ++- structure/providers/chat-compat.md | 5 ++ tests/fixtures/test-layout-expected.json | 1 + .../deepseek-artifact-tool-schema.test.ts | 51 +++++++++++++++++++ 7 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 src/adapters/openai-chat/deepseek-artifact-schema.ts create mode 100644 tests/providers/deepseek-artifact-tool-schema.test.ts diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index bb589664631..3ce14054e60 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -572,3 +572,11 @@ or a permission grant. Unmarked clients retain their existing behavior. This repair runs before the separate provider `responsesSnapshotRepair` option and does not enable that broader lifecycle repair. Existing tool-search, custom-tool, function-completion and undeclared-tool handling keep their established order. + +### DeepSeek and Claude Code Artifact + +For the official DeepSeek Chat Completions endpoint, opencodex relaxes regex and +`anyOf` constraints in Claude Code’s built-in `Artifact` tool schema to avoid +schema-validation HTTP 400 errors. Strict mode is omitted for this tool. Fields +defined only inside an `anyOf` are no longer constrained by that union; the tool +must validate its inputs. Other tools and providers retain their existing behavior. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 47991be1119..cb5f2fb6c10 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -168,6 +168,7 @@ } }, "explicit": { + "deepseek-artifact-tool-schema.test.ts": "providers", "openai-chat-serialized-tool-call-scaling.test.ts": "adapters/openai", "coding-agent-json-lines-scaling.test.ts": "providers", "usage-snapshot-digest-reuse.test.ts": "usage", diff --git a/src/adapters/openai-chat/deepseek-artifact-schema.ts b/src/adapters/openai-chat/deepseek-artifact-schema.ts new file mode 100644 index 00000000000..4eccaa627b8 --- /dev/null +++ b/src/adapters/openai-chat/deepseek-artifact-schema.ts @@ -0,0 +1,45 @@ +import type { OcxProviderConfig } from "../../types"; + +export function isDeepseekArtifactTarget(provider: OcxProviderConfig, name: string, namespace?: string): boolean { + if (name !== "Artifact" || namespace) return false; + try { + return new URL(provider.baseUrl).hostname === "api.deepseek.com"; + } catch { + return false; + } +} + +const NAME_BAGS = new Set(["properties", "$defs", "definitions", "patternProperties", "dependentSchemas"]); +const SCHEMA_CHILDREN = new Set(["items", "additionalProperties", "additionalItems", "allOf", "oneOf", "not", "if", "then", "else", "contains", "propertyNames", "prefixItems", "unevaluatedItems", "unevaluatedProperties"]); + +/** DeepSeek rejects Artifact regex/union constraints. Relax them only on this built-in tool. + * Walk schema positions, preserving property names and literal defaults/examples verbatim. + * An explicit stack avoids recursion on caller-controlled schema nesting. + */ +export function relaxDeepseekArtifactSchema(schema: unknown): unknown { + let result: unknown; + const pending: Array<{ value: unknown; names?: boolean; assign: (value: unknown) => void }> = [ + { value: schema, assign: value => { result = value; } }, + ]; + while (pending.length) { + const { value, names, assign } = pending.pop()!; + if (!value || typeof value !== "object") { assign(value); continue; } + if (Array.isArray(value)) { + const out: unknown[] = new Array(value.length); + assign(out); + value.forEach((child, index) => pending.push({ value: child, assign: next => { out[index] = next; } })); + continue; + } + const out: Record = Object.create(null); + assign(out); + for (const [key, child] of Object.entries(value)) { + if (!names && (key === "pattern" || key === "anyOf")) continue; + if (names || NAME_BAGS.has(key) || SCHEMA_CHILDREN.has(key)) { + pending.push({ value: child, names: !names && NAME_BAGS.has(key), assign: next => { out[key] = next; } }); + } else { + out[key] = child; + } + } + } + return result; +} diff --git a/src/adapters/openai-chat/tool-schema.ts b/src/adapters/openai-chat/tool-schema.ts index 641014a0066..9cc6f959051 100644 --- a/src/adapters/openai-chat/tool-schema.ts +++ b/src/adapters/openai-chat/tool-schema.ts @@ -1,3 +1,4 @@ +import { isDeepseekArtifactTarget, relaxDeepseekArtifactSchema } from "./deepseek-artifact-schema"; import { isNativeOpenAIChatTarget } from "./wire"; import { createOpenAIChatToolNameRegistry, type OpenAIChatToolNameRegistry } from "./tool-name-registry"; import { isXaiSchemaTarget, lookupLocalJsonPointer, normalizeXaiToolParameters } from "../xai-tool-schema"; @@ -548,7 +549,10 @@ export function toolsToChatFormat( : moonshotTarget ? normalizeMoonshotToolParameters(t.parameters, moonshotInlineByteBudget) : ensureRootObjectType(t.parameters); - const parameters = stripUnicodePropertyPatterns(stripResponsesOnlyEncryptedMarker(normalized)); + const deepseekArtifact = isDeepseekArtifactTarget(provider, t.name, t.namespace); + const parameters = stripUnicodePropertyPatterns(stripResponsesOnlyEncryptedMarker( + deepseekArtifact ? relaxDeepseekArtifactSchema(normalized) : normalized, + )); if (parameters === undefined) return []; return [{ @@ -557,7 +561,7 @@ export function toolsToChatFormat( name: registry.alias(t), ...(t.description ? { description: t.description } : {}), parameters, - ...(t.strict !== undefined ? { strict: t.strict } : {}), + ...(t.strict !== undefined && !deepseekArtifact ? { strict: t.strict } : {}), }, }]; }); diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 02d928fcd4a..49f286fa842 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -593,3 +593,8 @@ Canonical Responses identity sanitation and narrowly scoped pre-output combo rec Upstream API-key usage follows the [physical-attempt account attribution contract](../dashboard-and-usage.md#upstream-key-account-attribution), independently of subscription quota observations. Unicode pattern normalization uses [copy-on-write traversal](../transports/byte-accounting.md#unicode-pattern-normalization) while preserving the existing schema and wire semantics. + +## DeepSeek Artifact schema compatibility + +`src/adapters/openai-chat/tool-schema.ts` omits schema `pattern` and `anyOf` constraints and strict mode for unnamespaced `Artifact` tools on `api.deepseek.com`. Surrounding properties and required fields remain; union-only nodes become unconstrained, so tool execution must validate inputs. +Property names, literal defaults/examples, and caller schemas are preserved; other tools and hosts retain existing normalization. `tests/providers/deepseek-artifact-tool-schema.test.ts` checks the serialized request. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 990cf779de9..322d08c17f0 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,5 @@ { + "deepseek-artifact-tool-schema.test.ts": "providers", "openai-chat-serialized-tool-call-scaling.test.ts": "adapters/openai", "coding-agent-json-lines-scaling.test.ts": "providers", "usage-snapshot-digest-reuse.test.ts": "usage", diff --git a/tests/providers/deepseek-artifact-tool-schema.test.ts b/tests/providers/deepseek-artifact-tool-schema.test.ts new file mode 100644 index 00000000000..af01c71fe59 --- /dev/null +++ b/tests/providers/deepseek-artifact-tool-schema.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; +import type { OcxParsedRequest } from "../../src/types"; + +const schema = { + type: "object", + properties: { + id: { type: "string", pattern: "^(?!reserved)[a-z]+$" }, + content: { anyOf: [{ type: "string" }, { type: "object", additionalProperties: true }] }, + files: { type: "array", items: { type: "string", pattern: "^[a-z]+$" } }, + pattern: { type: "string" }, + anyOf: { type: "string" }, + metadata: { type: "object", default: { pattern: "literal", anyOf: [1, 2] } }, + }, + required: ["id", "content"], +}; + +function build(baseUrl = "https://api.deepseek.com/v1", name = "Artifact", namespace?: string) { + const parsed: OcxParsedRequest = { + modelId: "deepseek-chat", + context: { + messages: [{ role: "user", content: "Create an artifact", timestamp: 0 }], + tools: [{ name, ...(namespace ? { namespace } : {}), parameters: schema, strict: true }], + }, + stream: false, + options: {}, + }; + return JSON.parse(createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl, apiKey: "test-key" }) + .buildRequest(parsed).body).tools[0].function; +} + +describe("DeepSeek built-in Artifact schema", () => { + test("relaxes regex and union constraints in the outbound function without mutating input", () => { + const original = structuredClone(schema); + const fn = build(); + expect(fn.name).toBe("Artifact"); + expect(fn.parameters).toEqual({ + ...schema, + properties: { ...schema.properties, id: { type: "string" }, content: {}, files: { type: "array", items: { type: "string" } } }, + }); + expect(fn.strict).toBeUndefined(); + expect(schema).toEqual(original); + }); + + test("leaves other targets and tools unchanged", () => { + for (const fn of [build("https://api.openai.com/v1"), build("https://api.deepseek.com.example.test/v1"), build(undefined, "Other"), build(undefined, "Artifact", "mcp")]) { + expect(fn.parameters).toEqual(schema); + expect(fn.strict).toBe(true); + } + }); +}); From fa255e0d812b82297ecf34a0e8d749c77b5af13b Mon Sep 17 00:00:00 2001 From: pjhsion Date: Sat, 26 Sep 2026 12:22:15 +0900 Subject: [PATCH 07/11] fix(codex): canonicalize junction-backed default Codex home (#5865) Squashed carry of #5865. Closes #5864 Co-authored-by: pjhsion --- src/codex/home.ts | 24 ++++++++++++++++--- .../codex-integration/codex-home-wsl.test.ts | 21 +++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/codex/home.ts b/src/codex/home.ts index 49eb0a0be0a..870dd629069 100644 --- a/src/codex/home.ts +++ b/src/codex/home.ts @@ -132,17 +132,35 @@ export function findWslWindowsCodexHome(deps: CodexHomeDeps = {}): string | null return candidates.length === 1 ? candidates[0]! : null; } +function canonicalExistingCodexHome(path: string, deps: CodexHomeDeps): string { + const stat = deps.statSync ?? statSync; + try { + if (!stat(path).isDirectory()) return path; + // Use the portable resolver here rather than realpathSync.native. The + // Windows Bun standalone runtime can reject a valid junction through the + // native resolver, while the effective home still needs to be physical + // before callers open auth.json or native-profile state. + return (deps.realpathSync ?? realpathSync)(path); + } catch { + // Preserve the existing lexical-path behavior for missing or unreadable + // homes; callers that require the directory still fail at their boundary. + return path; + } +} + export function defaultCodexHome(deps: CodexHomeDeps = {}): string { const home = (deps.homedir ?? homedir)(); const defaultHome = join(home, ".codex"); + const canonicalDefaultHome = canonicalExistingCodexHome(defaultHome, deps); // A local ~/.codex that Codex is already using is the user's Codex home even before // config.toml exists (a fresh install: login writes auth.json, first use writes // sessions/ and history.jsonl). A local directory with none of that state is not // evidence of a local Codex: before #5441 such a home let WSL discovery pick the // Windows home, and existing WSL users who run against that Windows home must not - // be moved to an empty local one on upgrade. - if (localCodexHomeIsDirectory(defaultHome, deps) && localCodexHomeInUse(defaultHome, deps)) return defaultHome; - return findWslWindowsCodexHome(deps) ?? defaultHome; + // be moved to an empty local one on upgrade. Return the canonical path so a + // Windows junction cannot leak into later credential and profile writes. + if (localCodexHomeIsDirectory(defaultHome, deps) && localCodexHomeInUse(defaultHome, deps)) return canonicalDefaultHome; + return findWslWindowsCodexHome(deps) ?? canonicalDefaultHome; } function localCodexHomeInUse(home: string, deps: CodexHomeDeps): boolean { diff --git a/tests/codex-integration/codex-home-wsl.test.ts b/tests/codex-integration/codex-home-wsl.test.ts index f2de8acb6b7..75733559782 100644 --- a/tests/codex-integration/codex-home-wsl.test.ts +++ b/tests/codex-integration/codex-home-wsl.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { mkdtempSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { defaultCodexHome, wslAutomountRoot, listWslWindowsCodexHomes } from "../../src/codex/home"; @@ -42,6 +43,24 @@ describe("wsl.conf automount root", () => { expect(wslAutomountRoot({ wslConf: "[automount]\nenabled = true\n" })).toBe("/mnt"); }); + test.skipIf(process.platform !== "win32")("canonicalizes a junction-backed default Codex home", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-codex-home-junction-")); + const target = join(root, "codex-target"); + const link = join(root, ".codex"); + try { + mkdirSync(target); + writeFileSync(join(target, "auth.json"), "{}\n", { encoding: "utf8" }); + const result = spawnSync("cmd.exe", ["/d", "/c", "mklink", "/J", link, target], { + encoding: "utf8", + windowsHide: true, + }); + expect(result.status).toBe(0); + expect(defaultCodexHome({ homedir: () => root, env: {} })).toBe(realpathSync(target)); + } finally { + removeTreeWithRetry(root); + } + }); + test("parses a custom root with quotes, comments, and trailing slashes", () => { expect(wslAutomountRoot({ wslConf: "[automount]\nroot = /custom\n" })).toBe("/custom"); expect(wslAutomountRoot({ wslConf: "[automount]\nroot = \"/custom/\" # comment\n" })).toBe("/custom"); From 56845b96541f238b933a82c8f849eb3d371e349f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 26 Sep 2026 12:22:42 +0900 Subject: [PATCH 08/11] fix(integration): require a supplied required array for strict and keep the shim exit note within the runtime doc budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amends #5885 (strict needs a supplied required array, with a regression case) and #5888 (shim exit note moved into the shim install paragraph of structure/runtime.md, which sits at its 600-line budget). Co-authored-by: Vadym O Co-authored-by: 정우철 --- src/adapters/anthropic-output-schema.ts | 5 ++++- structure/runtime.md | 4 ++-- tests/claude-integration/claude-inbound.test.ts | 13 +++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/adapters/anthropic-output-schema.ts b/src/adapters/anthropic-output-schema.ts index 26adbc7d3b1..b7952668e84 100644 --- a/src/adapters/anthropic-output-schema.ts +++ b/src/adapters/anthropic-output-schema.ts @@ -161,8 +161,11 @@ export function satisfiesOpenAiStrictSchema(value: unknown): boolean { // its own acceptance check -- so an object that never said `additionalProperties: false` // reaches the wire without it and is refused, however complete its `required` is. if (node.additionalProperties !== false) return false; + // Strict mode also requires `required` to be supplied at all, even for an empty + // `properties` map, so a missing array is not the same as an empty one. + if (!Array.isArray(node.required)) return false; const keys = Object.keys(properties); - const required = Array.isArray(node.required) ? node.required : []; + const required: unknown[] = node.required; if (keys.some(key => !required.includes(key))) return false; } return Object.values(node).every(satisfiesOpenAiStrictSchema); diff --git a/structure/runtime.md b/structure/runtime.md index a5070b013cc..4cd7bd689ce 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -77,7 +77,7 @@ verified matching processes regardless of the advisory freshness result. ## Hub management dashboard address -`src/cli/dispatch.ts` makes `codex-shim install` exit nonzero when the resulting shim is unhealthy or installation is refused, printing the diagnostic summary alongside the original installer message. An already-installed healthy shim succeeds. When hub management ingress is enabled, `src/cli/dispatch.ts` opens the dashboard on the literal IPv4 loopback address and configured ingress port, matching the listener in `src/server/index.ts`. Other dashboard address selection is unchanged. +When hub management ingress is enabled, `src/cli/dispatch.ts` opens the dashboard on the literal IPv4 loopback address and configured ingress port, matching the listener in `src/server/index.ts`. Other dashboard address selection is unchanged. ## Codex desktop process membership @@ -314,7 +314,7 @@ diagnostics only for a confirmed candidate and never reads adjacent auth state. Unix install-probe cleanup refusals retain their fail-closed behavior and report a bounded diagnostic suffix: a fixed probe phase, allowlisted native error/signal, and bounded exit status. Metadata contents, launcher paths and raw child errors never enter that suffix. Diagnostic -classification does not grant process ownership or change rollback/termination policy. +classification does not grant process ownership or change rollback/termination policy. An explicit `codex-shim install` (`src/cli/dispatch.ts`) exits nonzero when installation is refused or the resulting shim is unhealthy, printing the diagnostic summary; an already-installed healthy shim succeeds. Codex CLI update inspection is split from mutation. `system codex-cli-update check` makes no package-registry request and reads bounded provenance evidence for the configured launcher candidate, npm ownership layout, diff --git a/tests/claude-integration/claude-inbound.test.ts b/tests/claude-integration/claude-inbound.test.ts index 66551c0da4c..8b08851eef4 100644 --- a/tests/claude-integration/claude-inbound.test.ts +++ b/tests/claude-integration/claude-inbound.test.ts @@ -339,6 +339,19 @@ describe("claude inbound translation", () => { expect(parseRequest(body).options.textFormat?.strict).toBe(false); }); + test("an object with no required array drops the strict claim", () => { + // Strict mode requires `required` to be supplied, even for an empty property map. + const bare = { type: "object", properties: {}, additionalProperties: false }; + const body = anthropicToResponsesBody({ + model: "claude-sonnet-5", + max_tokens: 256, + messages: [{ role: "user", content: "Return JSON" }], + output_config: { format: { type: "json_schema", schema: bare } }, + }); + + expect(parseRequest(body).options.textFormat?.strict).toBe(false); + }); + test("structured output rejects unsupported schemas and preserves root references", () => { const base = { model: "claude-sonnet-5", From cbc62084f382b21aeb1fdae40ab9da954ef0382b Mon Sep 17 00:00:00 2001 From: Vadevious Date: Sat, 26 Sep 2026 12:39:31 +0900 Subject: [PATCH 09/11] fix(openai-chat): recover empty MiMo freeform calls (#5869) Squashed carry of #5869. Co-authored-by: Vadevious --- .../src/content/docs/guides/providers.md | 8 +- src/adapters/openai-chat.ts | 4 +- .../serialized-tool-call-content.ts | 59 ++++- .../ADR-5548-serialized-tool-call-content.md | 1 + structure/providers/chat-compat.md | 5 + ...-chat-serialized-tool-call-content.test.ts | 225 ++++++++++++++++++ .../responses-chat-tool-call-content.test.ts | 13 +- 7 files changed, 302 insertions(+), 13 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 13857b86e2f..1ce2a0b374b 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -565,7 +565,13 @@ when a stable upstream session is required. **MiMo tool-call echoes.** On OpenCode Go and other Chat Completions routes, a bare `` block is hidden when it duplicates one structured call to the same tool with the same effective input. If the input differs or several calls could explain -the block, the markup remains visible. Tool execution still uses the structured call. +the block, the markup remains visible. For `mimo-v2` and dotted MiMo V2 model IDs such as +`mimo-v2.6-pro`, if the gateway instead sends exactly one empty `{}` call for a declared +freeform tool and puts its input in a standalone bare block, opencodex restores that +input to the call and hides the block. Hyphenated IDs such as `mimo-v2-pro` and +`mimo-v2-omni` are outside this recovery rule. Prose, quoted +examples, ordinary functions, and ambiguous responses remain unchanged. This also handles +MiMo's malformed ` ({ wireName: call.name, restoredName: toolNames.restore(call.name), argumentsText: call.args, freeformTool: freeformTools.get(call.name) })), toolCallContent.current()); + const references = reconcileStructuredToolCalls(calls.map(call => ({ wireName: call.name, restoredName: toolNames.restore(call.name), argumentsText: call.args, freeformTool: freeformTools.get(call.name) })), toolCallContent.current(), !toolCallContent.releasedAnswerText && /(?:^|[/-])mimo-v2(?:\.|$)/i.test(lastRequestedModelId ?? "")); calls.forEach((call, index) => { call.args = references[index]!.argumentsText; }); yield* toolCallContent.drain(references); for (const call of calls) { @@ -803,7 +803,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd events.push({ type: "tool_call_start", id, name: toolNames.restore(name) }, delta, { type: "tool_call_end" }); } } - const references = reconcileStructuredToolCalls(structuredCalls, answerText); + const references = reconcileStructuredToolCalls(structuredCalls, answerText, /(?:^|[/-])mimo-v2(?:\.|$)/i.test(lastRequestedModelId ?? "")); structuredCalls.forEach((call, index) => { call.delta.arguments = references[index]!.argumentsText; }); reconcileSerializedToolCallEvents(events, contentStart, contentEnd, references, budget); const stopReason = stopReasonFor(choice.finish_reason); diff --git a/src/adapters/openai-chat/serialized-tool-call-content.ts b/src/adapters/openai-chat/serialized-tool-call-content.ts index d31a339c125..48a7bca52e2 100644 --- a/src/adapters/openai-chat/serialized-tool-call-content.ts +++ b/src/adapters/openai-chat/serialized-tool-call-content.ts @@ -46,6 +46,7 @@ export interface StructuredToolCallReference { names: ReadonlySet; argumentsText: string; freeformTool?: FreeformToolIdentity; + recoveredParameterWrapper?: boolean; } const BLOCK_HEADER = /\s*\r\n]+)>/y; @@ -222,6 +223,7 @@ function deferredContinuationBreak(text: string, context: TextContext): RegExp | * original order. */ export class SerializedToolCallContentBuffer { + releasedAnswerText = false; private text = ""; private bytes = 0; private hasOpenTag = false; @@ -235,6 +237,12 @@ export class SerializedToolCallContentBuffer { constructor(private readonly budget: TranslatorBudget) {} + /** Remembers visible text released before a later structured call can be reconciled. */ + private observeText(events: AdapterEvent[]): AdapterEvent[] { + if (events.some(event => event.type === "text_delta" && event.text.trim() !== "")) this.releasedAnswerText = true; + return events; + } + /** Measures an append exactly even when the runtime prices isolated UTF-16 surrogates differently. */ private appendedByteLength(delta: string): number { let bytes = Buffer.byteLength(delta); @@ -324,16 +332,16 @@ export class SerializedToolCallContentBuffer { // A delta that alone passes the bound is delivered as text rather than retained. if (Buffer.byteLength(delta) > MAX_HELD_BYTES) { this.context = contextAfter(delta, this.context); - return [...released, ...textEvents(delta)]; + return this.observeText([...released, ...textEvents(delta)]); } - return [...released, ...textEvents(this.ingest(delta))]; + return this.observeText([...released, ...textEvents(this.ingest(delta))]); } const text = this.ingest(delta); // Checked after ingest too: one delta can open a block and already carry more than a bound. if (this.hasOpenTag && (this.bytes > MAX_HELD_BYTES || (this.sawCloser && !this.openAfterCloser && this.trailingProseChars > MAX_TRAILING_CHARS))) { - return [...textEvents(text), ...this.drain([])]; + return this.observeText([...textEvents(text), ...this.drain([])]); } - return textEvents(text); + return this.observeText(textEvents(text)); } /** Exposes held text as evidence for narrowly repairing duplicated argument prefixes. */ @@ -386,7 +394,7 @@ export class SerializedToolCallContentBuffer { this.context = contextAfter(this.text, this.context); this.queued = []; this.replace("", false); - return out; + return this.observeText(out); } /** Text-only drain for callers that never queued an event. */ @@ -475,7 +483,10 @@ function duplicatedSerializedToolCallRanges( const body = freeformBody(call.body); return structuredCalls.some(structured => { const input = structured.names.has(call.name) ? inputFromArguments(structured.argumentsText, structured.freeformTool) : undefined; - return input !== undefined && freeformBody(input) === body; + if (input === undefined) return false; + const normalized = freeformBody(input); + return normalized === body || (structured.recoveredParameterWrapper === true + && body.startsWith("\s*\r\n]+>/.test(block.body) + && !(hasParameterOpener && /^/.test(wrappedBody)) + && !body.includes("")) { + references[0] = { ...references[0]!, argumentsText: JSON.stringify({ input: body }), + recoveredParameterWrapper: hasParameterOpener }; + } + } + } return reduceUnambiguousDoubledInput(references, serializedText); } +/** An empty JSON object means the structured freeform call supplied no input. */ +function emptyObjectArguments(text: string): boolean { + try { + const parsed: unknown = JSON.parse(text); + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + && Object.keys(parsed).length === 0; + } catch { + return false; + } +} + /** * Applies the doubled-input reduction across the batch. The doubled shape is valid JSON, so the * prefix repair returns it untouched, and the reduction only ever rewrites a call the repair left diff --git a/structure/decisions/ADR-5548-serialized-tool-call-content.md b/structure/decisions/ADR-5548-serialized-tool-call-content.md index 5693306cc97..253c3c1e796 100644 --- a/structure/decisions/ADR-5548-serialized-tool-call-content.md +++ b/structure/decisions/ADR-5548-serialized-tool-call-content.md @@ -11,3 +11,4 @@ - Why: Agreement between both representations is deterministic and avoids changing ordinary commentary, mismatched markup, or unrelated providers' valid text. - Consequences: Matching calls no longer appear twice; an exact pair of immediately adjacent identical blocks with one doubled structured input is reduced to one call; same-name/different-body examples remain visible; the small held region is translator-budgeted and emits heartbeats while held; terminal failures retain held text without dispatching tools; malformed concatenated arguments are repaired only for proven duplicate shapes. - Follow-up (260924): the streaming hold is bounded (8 KiB of prose after a closed block, 4 MiB total); past a bound held text is released unsuppressed. See structure/providers/chat-compat.md. +- Follow-up (260925): MiMo V2 models with the exact `mimo-v2` ID or dotted IDs (`mimo-v2.*`) can send `{}` for a declared freeform call while placing its code only in a standalone bare block, sometimes after a malformed `` close it, so a body can still carry lite If the gateway also prefixes the structured call's JSON arguments with the same freeform body, the adapter keeps the JSON suffix only when the block body, prefix, and wrapper's `input` value all agree. Mismatched markup and arguments remain byte-exact. +MiMo V2 Chat IDs of `mimo-v2` or `mimo-v2.*` can send `{}` for a freeform call and put +its input in one bare block; hyphenated IDs (`mimo-v2-pro`, `mimo-v2-omni`) are excluded. +With no other text, one call and an exact wire-tool match, the adapter restores `input` and removes the block. +Prose, fences, ordinary functions, multiple calls/blocks, and nonempty arguments remain inert. +A malformed ` { + const script = "const r = await tools.exec_command({cmd:'pwd'}); text(r.output);"; + const block = `${script}\n`; + const adapter = withTestTranslatorBudget(createOpenAIChatAdapter(provider)); + adapter.buildRequest({ modelId: model, stream: mode === "streamed", options: {}, context: { + messages: [{ role: "user", content: "Use exec", timestamp: 0 }], tools: [execTool], + } }); + const events: AdapterEvent[] = []; + if (mode === "buffered") { + events.push(...await adapter.parseResponse!(Response.json({ + choices: [{ message: { content: block, tool_calls: [ + { id: "call_exec", function: { name: "exec", arguments: emptyArguments } }, + ] }, finish_reason: "tool_calls" }], + }), createTestTranslatorBudget())); + } else { + const frames = [ + { choices: [{ delta: { content: block.slice(0, 24) } }] }, + { choices: [{ delta: { content: block.slice(24) } }] }, + { choices: [{ delta: { tool_calls: [{ index: 0, id: "call_exec", function: { name: "exec", arguments: emptyArguments } }] } }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]; + const body = frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n"; + for await (const event of adapter.parseStream(new Response(body))) if (event.type !== "heartbeat") events.push(event); + } + expect(events.filter(event => event.type === "text_delta")).toEqual([]); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([ + { type: "tool_call_delta", arguments: JSON.stringify({ input: script }) }, + ]); + }, +); + +test.each([ + { mode: "buffered", openerSuffix: "", label: "inline" }, + { mode: "streamed", openerSuffix: "", label: "inline" }, + { mode: "buffered", openerSuffix: "\n", label: "newline" }, + { mode: "streamed", openerSuffix: "\n", label: "newline" }, +] as const)( + "$mode MiMo response restores a freeform call inside a malformed parameter wrapper ($label)", + async ({ mode, openerSuffix }) => { + const script = "const r = await tools.exec_command({cmd:'pwd'}); text(r.output);"; + const block = ``; + const adapter = withTestTranslatorBudget(createOpenAIChatAdapter(provider)); + adapter.buildRequest({ modelId: "mimo-v2.6-pro", stream: mode === "streamed", options: {}, context: { + messages: [{ role: "user", content: "Use exec", timestamp: 0 }], tools: [execTool], + } }); + const events: AdapterEvent[] = []; + if (mode === "buffered") { + events.push(...await adapter.parseResponse!(Response.json({ + choices: [{ message: { content: block, tool_calls: [ + { id: "call_exec", function: { name: "exec", arguments: "{}" } }, + ] }, finish_reason: "tool_calls" }], + }), createTestTranslatorBudget())); + } else { + const frames = [ + { choices: [{ delta: { content: block.slice(0, 24) } }] }, + { choices: [{ delta: { content: block.slice(24) } }] }, + { choices: [{ delta: { tool_calls: [{ index: 0, id: "call_exec", function: { name: "exec", arguments: "{}" } }] } }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]; + const body = frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n"; + for await (const event of adapter.parseStream(new Response(body))) if (event.type !== "heartbeat") events.push(event); + } + expect(events.filter(event => event.type === "text_delta")).toEqual([]); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([ + { type: "tool_call_delta", arguments: JSON.stringify({ input: script }) }, + ]); + }, +); + +test.each(["text([1,2].map(n=>n*2));", "text(2>1);"] as const)( + "MiMo parameter wrapper recovers JavaScript containing an operator: %s", + async script => { + const block = ``; + const adapter = createOpenAIChatAdapter(provider); + adapter.buildRequest({ modelId: "mimo-v2.6-pro", stream: false, options: {}, context: { + messages: [{ role: "user", content: "Use exec", timestamp: 0 }], tools: [execTool], + } }); + const events = await adapter.parseResponse!(Response.json({ + choices: [{ message: { content: block, tool_calls: [ + { id: "call_exec", function: { name: "exec", arguments: "{}" } }, + ] }, finish_reason: "tool_calls" }], + }), createTestTranslatorBudget()); + expect(events.filter(event => event.type === "text_delta")).toEqual([]); + expect(events.find(event => event.type === "tool_call_delta")).toEqual({ + type: "tool_call_delta", arguments: JSON.stringify({ input: script }), + }); + }, +); + +test.each([ + { label: "a different model", model: "other-model", tools: [execTool], content: "text(1)", calls: 1 }, + ...(["mimo-v2-pro", "mimo-v2-omni", "mimo-v2-flash"] as const).map(model => ({ + label: `the hyphenated ${model} model`, model, tools: [execTool], + content: "text(1)", calls: 1, + })), + { label: "an ordinary function", model: "mimo-v2.6-pro", tools: [{ ...execTool, freeform: false }], content: "text(1)", calls: 1 }, + { label: "a fenced example", model: "mimo-v2.6-pro", tools: [execTool], content: "```xml\ntext(1)\n```", calls: 1 }, + { label: "preceding prose", model: "mimo-v2.6-pro", tools: [execTool], content: "Example only:\ntext(1)", calls: 1 }, + { label: "following prose", model: "mimo-v2.6-pro", tools: [execTool], content: "text(1)\nExample only", calls: 1 }, + { label: "two possible blocks", model: "mimo-v2.6-pro", tools: [execTool], content: "text(1)\ntext(2)", calls: 1 }, + { label: "an unclosed block followed by a second block", model: "mimo-v2.6-pro", tools: [execTool], content: "first\nsecond", calls: 1 }, + { label: "two empty calls", model: "mimo-v2.6-pro", tools: [execTool], content: "text(1)", calls: 2 }, + { label: "nested parameter opener", model: "mimo-v2.6-pro", tools: [execTool], content: "", calls: 1 }, + { label: "named parameter wrapper", model: "mimo-v2.6-pro", tools: [execTool], content: "text(1);", calls: 1 }, +])("MiMo empty-input recovery leaves $label unchanged", async ({ model, tools, content, calls }) => { + const adapter = createOpenAIChatAdapter(provider); + adapter.buildRequest({ modelId: model, stream: false, options: {}, context: { + messages: [{ role: "user", content: "ping", timestamp: 0 }], tools, + } }); + const events = await adapter.parseResponse!(Response.json({ + choices: [{ message: { content, tool_calls: Array.from({ length: calls }, (_, index) => ({ + id: `call_${index}`, function: { name: "exec", arguments: "{}" }, + })) }, finish_reason: "tool_calls" }], + }), createTestTranslatorBudget()); + expect(events.filter(event => event.type === "text_delta").map(event => event.text).join("")).toBe(content); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual( + Array.from({ length: calls }, () => ({ type: "tool_call_delta", arguments: "{}" })), + ); +}); + +test.each(["other-model", "mimo-v2-pro", "mimo-v2-omni", "mimo-v2-flash"])( + "streamed %s response leaves an empty freeform call unchanged", async model => { + const block = "text(1)"; + const adapter = withTestTranslatorBudget(createOpenAIChatAdapter(provider)); + adapter.buildRequest({ modelId: model, stream: true, options: {}, context: { + messages: [{ role: "user", content: "ping", timestamp: 0 }], tools: [execTool], + } }); + const frames = [ + { choices: [{ delta: { content: block } }] }, + { choices: [{ delta: { tool_calls: [{ index: 0, id: "call_exec", function: { name: "exec", arguments: "{}" } }] } }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]; + const body = frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n"; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(body))) if (event.type !== "heartbeat") events.push(event); + expect(events.filter(event => event.type === "text_delta").map(event => event.text).join("")).toBe(block); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([{ type: "tool_call_delta", arguments: "{}" }]); + }, +); + +test("streamed MiMo leaves an unclosed block followed by another block inert", async () => { + const first = "first\n"; + const second = "second"; + const adapter = withTestTranslatorBudget(createOpenAIChatAdapter(provider)); + adapter.buildRequest({ modelId: "mimo-v2.6-pro", stream: true, options: {}, context: { + messages: [{ role: "user", content: "Use exec", timestamp: 0 }], tools: [execTool], + } }); + const frames = [ + { choices: [{ delta: { content: first } }] }, + { choices: [{ delta: { content: second } }] }, + { choices: [{ delta: { tool_calls: [{ index: 0, id: "call_exec", function: { name: "exec", arguments: "{}" } }] } }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]; + const body = frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n"; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(body))) if (event.type !== "heartbeat") events.push(event); + expect(events.filter(event => event.type === "text_delta").map(event => event.text).join("")).toBe(first + second); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([{ type: "tool_call_delta", arguments: "{}" }]); +}); + +test("MiMo empty-input recovery requires the exact wire tool name", async () => { + const block = "text(1)"; + const adapter = createOpenAIChatAdapter(provider); + adapter.buildRequest({ modelId: "mimo-v2.6-pro", stream: false, options: {}, context: { + messages: [{ role: "user", content: "ping", timestamp: 0 }], + tools: [{ ...execTool, namespace: "functions" }], + } }); + const events = await adapter.parseResponse!(Response.json({ + choices: [{ message: { content: block, tool_calls: [ + { id: "call_exec", function: { name: "functions__exec", arguments: "{}" } }, + ] }, finish_reason: "tool_calls" }], + }), createTestTranslatorBudget()); + expect(events.filter(event => event.type === "text_delta")).toEqual([{ type: "text_delta", text: block }]); + expect(events.find(event => event.type === "tool_call_delta")).toEqual({ type: "tool_call_delta", arguments: "{}" }); +}); + +test("streamed MiMo recovery stays disabled after an earlier block was released", async () => { + const first = "text(1)"; + const last = "text(2)"; + const content = first + "\n" + "x".repeat(8200) + "\n" + last; + const adapter = withTestTranslatorBudget(createOpenAIChatAdapter(provider)); + adapter.buildRequest({ modelId: "mimo-v2.6-pro", stream: true, options: {}, context: { + messages: [{ role: "user", content: "ping", timestamp: 0 }], tools: [execTool], + } }); + const frames = [ + { choices: [{ delta: { content: first } }] }, + { choices: [{ delta: { content: "\n" + "x".repeat(8200) + "\n" } }] }, + { choices: [{ delta: { content: last } }] }, + { choices: [{ delta: { tool_calls: [{ index: 0, id: "call_exec", function: { name: "exec", arguments: "{}" } }] } }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]; + const body = frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n"; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(body))) if (event.type !== "heartbeat") events.push(event); + expect(events.filter(event => event.type === "text_delta").map(event => event.text).join("")).toBe(content); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([{ type: "tool_call_delta", arguments: "{}" }]); +}); + +test("streamed MiMo recovery stays disabled when reasoning releases a partial header", async () => { + const partial = " `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n"; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(body))) if (event.type !== "heartbeat") events.push(event); + expect(events.filter(event => event.type === "text_delta").map(event => event.text).join("")).toBe(partial + "\n" + block); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([{ type: "tool_call_delta", arguments: "{}" }]); +}); + test("buffered Chat responses reconcile matching serialized and structured tool calls", async () => { const script = "text('ok');"; const content = `Running it.\n${script}\n`; diff --git a/tests/responses/responses-chat-tool-call-content.test.ts b/tests/responses/responses-chat-tool-call-content.test.ts index b1fb550cdb3..f550b6fd947 100644 --- a/tests/responses/responses-chat-tool-call-content.test.ts +++ b/tests/responses/responses-chat-tool-call-content.test.ts @@ -10,16 +10,19 @@ afterEach(() => { releaseSpendHome = undefined; }); +/** Checks the dispatched freeform input and visible text from a streamed Chat reply. */ async function checkEchoedToolCall( repeated: boolean, trailingNewline = false, newlineJoinedInput = false, rawFreeform = false, + emptyStructuredArguments = false, + parameterPrefix = false, ): Promise { const savedFetch = globalThis.fetch; const script = "const result = await tools.exec_command({cmd: \"pwd\"});\ntext(result.output);"; - const leaked = `${script}${rawFreeform ? "" : "\n"}`; - const commentary = "I'll run it now.\n"; + const leaked = `${parameterPrefix ? "`; + const commentary = emptyStructuredArguments ? "" : "I'll run it now.\n"; const content = commentary + leaked + (repeated ? leaked : "") + (trailingNewline ? "\n" : ""); const split = commentary.length + 5; const frames = [ @@ -33,7 +36,7 @@ async function checkEchoedToolCall( id: "call_exec", function: { name: "exec", - arguments: rawFreeform ? script : repeated + arguments: emptyStructuredArguments ? "{}" : rawFreeform ? script : repeated ? JSON.stringify({ input: script + (newlineJoinedInput ? "\n" : "") + script }) : script + JSON.stringify({ input: script }), }, @@ -103,3 +106,7 @@ test("/v1/responses suppresses one echoed block with raw freeform arguments", () test("/v1/responses suppresses two echoed blocks with doubled input", () => checkEchoedToolCall(true)); test("/v1/responses suppresses trailing newline and repairs newline-joined doubled input", () => checkEchoedToolCall(true, true, true)); +test("/v1/responses restores MiMo freeform input from the echoed block when structured arguments are empty", () => + checkEchoedToolCall(false, false, false, false, true)); +test("/v1/responses restores MiMo freeform input from a malformed parameter wrapper", () => + checkEchoedToolCall(false, false, false, false, true, true)); From 3db2df96e1cef3faf5cf33d50db0bdae8aa432d0 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 26 Sep 2026 12:42:14 +0900 Subject: [PATCH 10/11] fix(integration): keep structure/providers/chat-compat.md within its 600-line budget #5869 and #5892 each added a note to chat-compat.md; together they took it to 605 lines. Both notes keep their text; the DeepSeek note drops its heading and the MiMo note joins shorter lines. Co-authored-by: codingbo Co-authored-by: Vadevious --- structure/providers/chat-compat.md | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index fa46921d9dc..71002ea5869 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -339,10 +339,8 @@ If the gateway also prefixes the structured call's JSON arguments with the same freeform body, the adapter keeps the JSON suffix only when the block body, prefix, and wrapper's `input` value all agree. Mismatched markup and arguments remain byte-exact. MiMo V2 Chat IDs of `mimo-v2` or `mimo-v2.*` can send `{}` for a freeform call and put -its input in one bare block; hyphenated IDs (`mimo-v2-pro`, `mimo-v2-omni`) are excluded. -With no other text, one call and an exact wire-tool match, the adapter restores `input` and removes the block. -Prose, fences, ordinary functions, multiple calls/blocks, and nonempty arguments remain inert. -A malformed ` Date: Sat, 26 Sep 2026 12:57:08 +0900 Subject: [PATCH 11/11] fix(integration): carry the catalog output limit into client exports and keep root unions non-strict Addresses two Codex review findings on #5901: - #5891 reads model.maxTokens, but toExportModel and the opencode launcher catalog never copied a catalog row's maxOutputTokens, so real exports ignored it. Both now carry it. - #5885 claimed strict for a root anyOf/oneOf schema; strict Structured Outputs requires an object root, so strict is now false unless schema.type is "object". Co-authored-by: codingbo Co-authored-by: Vadym O --- scripts/test-layout/layout.json | 1 + src/claude/inbound-model-options.ts | 3 +- src/cli/opencode.ts | 3 ++ src/server/management/model-rows.ts | 1 + structure/clients/integrations.md | 5 ++-- .../claude-integration/claude-inbound.test.ts | 12 ++++++++ .../client-config-export-output-limit.test.ts | 30 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 8 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 tests/config/client-config-export-output-limit.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index cb5f2fb6c10..9d7ae054aa0 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -169,6 +169,7 @@ }, "explicit": { "deepseek-artifact-tool-schema.test.ts": "providers", + "client-config-export-output-limit.test.ts": "config", "openai-chat-serialized-tool-call-scaling.test.ts": "adapters/openai", "coding-agent-json-lines-scaling.test.ts": "providers", "usage-snapshot-digest-reuse.test.ts": "usage", diff --git a/src/claude/inbound-model-options.ts b/src/claude/inbound-model-options.ts index b0e9b0f2781..3441d275d0d 100644 --- a/src/claude/inbound-model-options.ts +++ b/src/claude/inbound-model-options.ts @@ -114,7 +114,8 @@ export function formatFromOutputConfig(outputConfig: unknown): Rec | undefined { type: "json_schema", name: "response", schema: format.schema, - strict: satisfiesOpenAiStrictSchema(format.schema), + // Strict Structured Outputs also needs an object at the root; a root anyOf/oneOf is refused. + strict: format.schema.type === "object" && satisfiesOpenAiStrictSchema(format.schema), }; } diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index 0f1a562738d..e39b438a10b 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -101,6 +101,8 @@ export interface OpencodeProxyModelRow { displayName?: string; displayNameSource?: "operator" | "provider" | "fallback"; contextWindow?: number; + /** Authoritative output limit (CatalogModel.maxOutputTokens); optional. */ + maxOutputTokens?: number; /** Declared input modalities from `/api/models`; carried into opencode model capabilities. */ inputModalities?: string[]; /** Declared effort ladder from `/api/models`; carried into opencode model variants. */ @@ -427,6 +429,7 @@ export function opencodeCatalogFromProxyRows( provider: row.provider, id: row.id, contextWindow: row.contextWindow, + ...(typeof row.maxOutputTokens === "number" ? { maxTokens: row.maxOutputTokens } : {}), displayName: row.displayNameSource === "fallback" ? undefined : row.displayName, ...(Array.isArray(row.inputModalities) && row.inputModalities.length > 0 ? { inputModalities: [...row.inputModalities] } diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index a78d9ca3bbc..a2964680b73 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -241,6 +241,7 @@ export function toExportModel(row: ManagementModelRow): ExportModel { ...(row.native ? { native: true } : {}), ...(row.displayName && row.displayNameSource !== "fallback" ? { displayName: row.displayName } : {}), ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}), + ...(row.maxOutputTokens !== undefined ? { maxTokens: row.maxOutputTokens } : {}), ...(row.inputModalities ? { inputModalities: row.inputModalities } : {}), ...(row.reasoningEfforts ? { reasoningEfforts: row.reasoningEfforts } : {}), ...(row.defaultReasoningEffort ? { defaultReasoningEffort: row.defaultReasoningEffort } : {}), diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 40ec0a18a6c..7fa2693795e 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -158,8 +158,9 @@ is available; otherwise the operator refreshes the integration or client catalog ## Model output limits -OpenCode, Pi-family clients, OMP and Gajae export the explicit model `maxTokens` when valid, -otherwise the generated metadata limit for the provider and model ID (including provider aliases). +OpenCode, Pi-family clients, OMP and Gajae export the explicit model `maxTokens` when valid (a catalog +row's `maxOutputTokens`, carried by `toExportModel` in `src/server/management/model-rows.ts` and by +`opencodeCatalogFromProxyRows` in `src/cli/opencode.ts`), otherwise the generated metadata limit for the provider and model ID (including provider aliases). Only unknown limits fall back to 32000. Every output limit is clamped to the authoritative context window; absent context still omits both limits. Fast rows preserve these limits. diff --git a/tests/claude-integration/claude-inbound.test.ts b/tests/claude-integration/claude-inbound.test.ts index 8b08851eef4..bdc0a867589 100644 --- a/tests/claude-integration/claude-inbound.test.ts +++ b/tests/claude-integration/claude-inbound.test.ts @@ -352,6 +352,18 @@ describe("claude inbound translation", () => { expect(parseRequest(body).options.textFormat?.strict).toBe(false); }); + test("a root union drops the strict claim; strict mode needs an object root", () => { + const union = { anyOf: [{ type: "object", properties: {}, required: [], additionalProperties: false }] }; + const body = anthropicToResponsesBody({ + model: "claude-sonnet-5", + max_tokens: 256, + messages: [{ role: "user", content: "Return JSON" }], + output_config: { format: { type: "json_schema", schema: union } }, + }); + + expect(parseRequest(body).options.textFormat?.strict).toBe(false); + }); + test("structured output rejects unsupported schemas and preserves root references", () => { const base = { model: "claude-sonnet-5", diff --git a/tests/config/client-config-export-output-limit.test.ts b/tests/config/client-config-export-output-limit.test.ts new file mode 100644 index 00000000000..5a4c6414e28 --- /dev/null +++ b/tests/config/client-config-export-output-limit.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { outputBudgetFor } from "../../src/clients/config-export/model-metadata"; +import { toExportModel } from "../../src/server/management/model-rows"; +import { opencodeCatalogFromProxyRows } from "../../src/cli/opencode"; +import type { OcxConfig } from "../../src/types"; + +// A catalog row's own maxOutputTokens is the authoritative output limit for client exports. +// Generated metadata and the 32000 stand-in are only fallbacks when the catalog says nothing. +describe("client export output limit comes from the catalog row first", () => { + test("toExportModel carries maxOutputTokens as maxTokens", () => { + const model = toExportModel({ + namespaced: "acme/writer", provider: "acme", id: "writer", contextWindow: 400_000, maxOutputTokens: 64_000, + }); + expect(model.maxTokens).toBe(64_000); + expect(outputBudgetFor(400_000, model)).toBe(64_000); + expect(toExportModel({ namespaced: "acme/plain", provider: "acme", id: "plain" })).not.toHaveProperty("maxTokens"); + }); + + test("the opencode launcher catalog carries maxOutputTokens from /api/models", () => { + const config = { providers: { acme: { adapter: "openai-chat", baseUrl: "https://acme.example/v1" } } } as unknown as OcxConfig; + const [model] = opencodeCatalogFromProxyRows([ + { namespaced: "acme/writer", provider: "acme", id: "writer", contextWindow: 400_000, maxOutputTokens: 64_000 }, + ], config); + expect(model?.maxTokens).toBe(64_000); + }); + + test("a catalog limit above the context window is still clamped", () => { + expect(outputBudgetFor(8_000, { provider: "acme", id: "tiny", maxTokens: 64_000 })).toBe(8_000); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 322d08c17f0..f982e5d4daa 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,6 @@ { "deepseek-artifact-tool-schema.test.ts": "providers", + "client-config-export-output-limit.test.ts": "config", "openai-chat-serialized-tool-call-scaling.test.ts": "adapters/openai", "coding-agent-json-lines-scaling.test.ts": "providers", "usage-snapshot-digest-reuse.test.ts": "usage",