diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 1e5af166a64..ea1114dcdaa 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -396,6 +396,8 @@ des indications propres au fournisseur ainsi qu'un `Retry-After` synthétique ; reste prioritaire. L'attente et la nouvelle tentative avec la même clé restent facultatives et s'activent avec [`retryOn429`](/fr/reference/configuration/). +Sur les routes officielles `https://opencode.ai/zen/v1` et `https://opencode.ai/inference/v1` du fournisseur canonique `opencode-zen` avec authentification par clé API, Gemini utilise automatiquement l’API native Google en mode `googleMode: ai-studio` (par défaut). Les identifiants Zen sont conservés, y compris Flash sans suffixe `-tiered`. Claude utilise automatiquement Anthropic Messages, quel que soit `googleMode`. Au chargement des configurations enregistrées, seules les anciennes surcharges Chat/Responses admissibles de ces modèles natifs sont ignorées, ainsi que les préférences admissibles de l’outil hébergé `image_generation` propres à Responses. Les fournisseurs personnalisés, les routes sans authentification par clé et les autres préférences restent inchangés. + **Le niveau sans clé `opencode-free` est actuellement fermé aux clients tiers.** Zen refuse toute requête qui arrive sans en-tête `x-opencode-session` et répond avec le type d'erreur `MissingSessionID` et le message « OpenCode's free tier can only be used in OpenCode ». Le contrôle porte uniquement sur la présence de diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 6345506e0b7..5fc5864481d 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -849,7 +849,7 @@ headers. That is separate from the keyless desktop quota OpenCode advertises synthetic `Retry-After`; an upstream `Retry-After` still takes precedence. Same-key wait-and-retry remains opt-in via [`retryOn429`](/reference/configuration/). -Gemini models on `opencode-zen` use Google's native API automatically when the provider uses an API key, the default `googleMode` (`ai-studio`), and either `https://opencode.ai/zen/v1` or `https://opencode.ai/inference/v1`. Use the model IDs listed by Zen; OpenCodex preserves them, including Flash IDs without a `-tiered` suffix. Older Gemini Chat/Responses overrides and Responses-only hosted-tool preferences are ignored when reading saved configurations. +Gemini models on `opencode-zen` use Google's native API automatically when the provider uses an API key, the default `googleMode` (`ai-studio`), and either `https://opencode.ai/zen/v1` or `https://opencode.ai/inference/v1`. Use the model IDs listed by Zen; OpenCodex preserves them, including Flash IDs without a `-tiered` suffix. Claude models on these API-key routes use Anthropic Messages automatically, regardless of `googleMode`. When loading saved configurations, OpenCodex ignores eligible older Gemini and Claude Chat/Responses overrides only for native models in the canonical keyed `opencode-zen` provider on these official Zen routes. It also ignores eligible Responses-only `image_generation` hosted-tool preferences; custom providers, non-key routes, and other preferences remain unchanged. **The keyless `opencode-free` tier is currently closed to third-party clients.** Zen refuses any request that arrives without an `x-opencode-session` header, answering with error type diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 3323995b0fc..f539c96b981 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -284,6 +284,8 @@ Chat リクエストは設定済みの Bearer キーを使います。公開一 **OpenCode Zen**(`opencode-zen`)とキー不要の **OpenCode Free** プリセットは `https://opencode.ai/zen/v1` を共有します。このゲートウェイ上の無料モデルは、しばしばおおよそ毎分 15–20 リクエストの短時間レート制限に当たります(コミュニティ計測。OpenCode は RPM を公表しません)。Zen は `Retry-After` / `X-RateLimit-*` ヘッダーなしの汎用 429 を返すことがあります。これはキー不要デスクトップ枠(`opencode-free` で Big Pickle/無料モデル約 200 回 / 5 時間)とは別です。Zen がそのような 429 で `Retry-After` を省略した場合、opencodex はクライアント向けエラーに案内を足し、合成 `Retry-After` を付けます(上流の `Retry-After` があればそれが優先されます)。同一キーの待機再試行は [`retryOn429`](/ja/reference/configuration/) でオプトインします。 +正規の `opencode-zen` プロバイダーで API キー認証と公式ルート `https://opencode.ai/zen/v1` または `https://opencode.ai/inference/v1` を使用すると、Gemini は既定の `googleMode: ai-studio` で Google ネイティブ API を自動的に使用します。Zen のモデル ID は、`-tiered` 接尾辞のない Flash ID もそのまま保持します。Claude は `googleMode` に関係なく Anthropic Messages を使用します。保存済み設定の読み込み時、これらのネイティブモデルの適格な旧 Chat/Responses オーバーライドと、適格な Responses 専用 `image_generation` ホストツール設定のみを無視します。カスタムプロバイダー、キー認証以外のルート、その他の設定は変更しません。 + **キー不要の `opencode-free` 枠は、現在サードパーティのクライアントに閉じられています。** Zen は `x-opencode-session` ヘッダーを伴わないリクエストをすべて拒否し、エラータイプ `MissingSessionID` と "OpenCode's free tier can only be used in OpenCode" というメッセージを返します。関門はヘッダーの有無だけを見るため、プロキシは値をでっち上げれば通過できますが、opencodex はそうしません。セッション識別子とバージョン付きの `opencode/` User-Agent を作って送ることは、自分が OpenCode クライアントであると主張することであり、OpenCode はこのキー不要の枠についてサードパーティ連携の契約を公開していません。その方法で得た HTTP 200 は許可ではなく、突破された関門にすぎません。そこで opencodex は回避せずに制限を報告します。`opencode-free` へのリクエストは、上流の関門を説明するエラーを返します。 同じモデルに至るサポートされた経路は、[opencode.ai/auth](https://opencode.ai/auth) で発行した OpenCode Zen API キーを使う **`opencode-zen`** プリセットです。OpenCode が後にキー不要の枠へのサードパーティ経路を公開すれば、opencodex もそれに従えます。それまでこのプリセットは制限を記録する役割を担います。上流の規約: [opencode.ai/docs/zen](https://opencode.ai/docs/zen/)。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index e4cd967e63c..71e0fcb6d1f 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -282,6 +282,8 @@ Chat 요청은 설정한 Bearer 키를 사용하며 공개 모델 목록은 키 **OpenCode Zen**(`opencode-zen`)과 키 없는 **OpenCode Free** 프리셋은 `https://opencode.ai/zen/v1`을 공유합니다. 그 게이트웨이의 무료 모델은 종종 분당 약 15–20회 요청의 짧은 창 속도 제한에 걸립니다(커뮤니티 측정; OpenCode는 RPM을 공개하지 않음). Zen은 `Retry-After` / `X-RateLimit-*` 헤더 없는 일반 429를 반환할 수 있습니다. 이는 키 없는 데스크톱 할당량(`opencode-free`에서 약 5시간당 Big Pickle/무료 모델 200회)과 별개입니다. Zen이 그런 429에서 `Retry-After`를 생략하면 opencodex는 클라이언트 오류에 안내를 더하고 합성 `Retry-After`를 붙입니다(업스트림 `Retry-After`가 있으면 그것이 우선). 동일 키 대기 재시도는 [`retryOn429`](/ko/reference/configuration/)로 선택합니다. +정식 `opencode-zen` 제공자에서 API 키 인증과 공식 경로 `https://opencode.ai/zen/v1` 또는 `https://opencode.ai/inference/v1`을 사용하면 Gemini는 기본 `googleMode: ai-studio`에서 Google 네이티브 API를 자동으로 사용합니다. Zen 모델 ID는 `-tiered` 접미사가 없는 Flash ID도 그대로 유지합니다. Claude는 `googleMode`와 관계없이 Anthropic Messages를 사용합니다. 저장된 설정을 읽을 때 이 네이티브 모델의 조건에 맞는 기존 Chat/Responses 재정의와 조건에 맞는 Responses 전용 `image_generation` 호스팅 도구 선호 설정만 무시합니다. 사용자 지정 제공자, 키 인증이 아닌 경로, 그 밖의 선호 설정은 그대로 유지합니다. + **키 없는 `opencode-free` 등급은 현재 서드파티 클라이언트에 닫혀 있습니다.** Zen은 `x-opencode-session` 헤더가 없는 요청을 모두 거부하고, 오류 타입 `MissingSessionID`와 "OpenCode's free tier can only be used in OpenCode" 메시지를 돌려줍니다. 관문은 헤더가 있는지만 보기 때문에 프록시가 값을 지어내 통과할 수도 있지만, opencodex는 그렇게 하지 않습니다. 세션 식별자와 버전이 붙은 `opencode/` User-Agent를 만들어 보내는 것은 자신이 OpenCode 클라이언트라고 주장하는 일이고, OpenCode는 이 키 없는 등급에 서드파티 연동 계약을 공개한 적이 없습니다. 그렇게 받아낸 HTTP 200은 허가가 아니라 뚫린 관문일 뿐입니다. 그래서 opencodex는 우회하지 않고 제한을 그대로 알립니다. `opencode-free`로 보낸 요청은 업스트림 관문을 설명하는 오류를 돌려받습니다. 같은 모델로 가는 지원 경로는 [opencode.ai/auth](https://opencode.ai/auth)에서 발급받은 OpenCode Zen API 키를 쓰는 **`opencode-zen`** 프리셋입니다. 나중에 OpenCode가 키 없는 등급의 서드파티 경로를 공개하면 opencodex도 따라갈 수 있고, 그때까지 이 프리셋은 제한을 기록해 두는 역할을 합니다. 업스트림 약관: [opencode.ai/docs/zen](https://opencode.ai/docs/zen/). diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 85d9ab24996..6234f0f3e46 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -305,6 +305,8 @@ Zen может отвечать общими 429 без заголовков `Re в ошибку клиента и синтетический `Retry-After`; при наличии upstream `Retry-After` он имеет приоритет. Повтор с тем же ключом по-прежнему включается через [`retryOn429`](/ru/reference/configuration/). +На официальных маршрутах `https://opencode.ai/zen/v1` и `https://opencode.ai/inference/v1` канонического провайдера `opencode-zen` с аутентификацией API-ключом Gemini автоматически использует нативный API Google в стандартном режиме `googleMode: ai-studio`. Идентификаторы моделей Zen сохраняются, включая Flash без суффикса `-tiered`. Claude использует Anthropic Messages независимо от `googleMode`. При загрузке сохранённой конфигурации игнорируются только допустимые старые переопределения Chat/Responses для этих нативных моделей и допустимые настройки размещённого инструмента `image_generation`, предназначенные только для Responses. Пользовательские провайдеры, маршруты без аутентификации ключом и остальные настройки не меняются. + **Бесключевой уровень `opencode-free` сейчас закрыт для сторонних клиентов.** Zen отклоняет любой запрос без заголовка `x-opencode-session`, возвращая тип ошибки `MissingSessionID` и сообщение «OpenCode's free tier can only be used in OpenCode». Проверяется только наличие заголовка, поэтому diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 1c18a2fc04e..e74ecfe739f 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -436,6 +436,8 @@ ekler; bir yukarı akış `Retry-After`'ı yine de önceliklidir. Aynı anahtarl bekle ve yeniden dene özelliği [`retryOn429`](/tr/reference/configuration/) aracılığıyla isteğe bağlı kalır. +Standart `opencode-zen` sağlayıcısının API anahtarıyla kimlik doğrulanan resmi `https://opencode.ai/zen/v1` ve `https://opencode.ai/inference/v1` yollarında Gemini, varsayılan `googleMode: ai-studio` modunda Google'ın yerel API'sini otomatik kullanır. Zen model kimlikleri, `-tiered` eki olmayan Flash kimlikleri dahil korunur. Claude, `googleMode` değerinden bağımsız olarak Anthropic Messages kullanır. Kaydedilmiş yapılandırmalar yüklenirken yalnızca bu yerel modellerin uygun eski Chat/Responses geçersiz kılmaları ve uygun, yalnızca Responses'a özgü `image_generation` barındırılan araç tercihleri yok sayılır. Özel sağlayıcılar, anahtar kimlik doğrulaması kullanmayan yollar ve diğer tercihler değişmez. + **Anahtarsız `opencode-free` katmanı şu anda üçüncü taraf istemcilere kapalıdır.** Zen, `x-opencode-session` başlığı olmadan gelen her isteği reddeder ve `MissingSessionID` hata tipiyle "OpenCode's free tier can only be used in OpenCode" mesajını döndürür. Kapıda diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index dbc1a20d9b9..c6ef72b88a3 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -281,6 +281,8 @@ MiniMax 和 MiniMax (CN) 的提供商卡片也会在配置的密钥有有效 Cod **OpenCode Zen**(`opencode-zen`)与免密钥的 **OpenCode Free** 预设共用 `https://opencode.ai/zen/v1`。该网关上的免费模型常会触发约每分钟 15–20 次请求的短窗口限流(社区观测;OpenCode 未公布 RPM)。Zen 可能返回不带 `Retry-After` / `X-RateLimit-*` 的通用 429。这与免密钥桌面配额(`opencode-free` 上约每 5 小时 200 次 Big Pickle/免费模型请求)是分开的。当这类 429 省略 `Retry-After` 时,opencodex 会在客户端错误中补充说明并附带合成的 `Retry-After`;若上游已提供 `Retry-After`,则仍以它为准。同密钥等待重试仍可通过 [`retryOn429`](/zh-cn/reference/configuration/) 选择开启。 +在规范 `opencode-zen` 提供商中,使用 API 密钥认证和官方路由 `https://opencode.ai/zen/v1` 或 `https://opencode.ai/inference/v1` 时,Gemini 会在默认 `googleMode: ai-studio` 下自动使用 Google 原生 API。Zen 模型 ID 保持不变,包括没有 `-tiered` 后缀的 Flash ID。Claude 使用 Anthropic Messages,不受 `googleMode` 影响。加载已保存的配置时,仅忽略这些原生模型符合条件的旧 Chat/Responses 覆盖设置,以及符合条件的 Responses 专用 `image_generation` 托管工具偏好。自定义提供商、非密钥认证路由及其他偏好保持不变。 + **免密钥的 `opencode-free` 层级目前对第三方客户端关闭。** Zen 会拒绝任何不带 `x-opencode-session` 头的请求,返回错误类型 `MissingSessionID` 和消息 "OpenCode's free tier can only be used in OpenCode"。这道关卡只检查该头是否存在,因此代理完全可以编一个值蒙混过去,但 opencodex 不这么做。伪造会话标识并附上带版本号的 `opencode/` User-Agent,等于声称自己就是 OpenCode 客户端,而 OpenCode 并未公布这一免密钥层级的第三方集成约定;用这种方式换来的 HTTP 200 是绕过了准入检查,而不是获得了许可。因此 opencodex 选择如实报告限制:发往 `opencode-free` 的请求会返回一条解释上游关卡的错误。 通往同一批模型的受支持路径,是使用 [opencode.ai/auth](https://opencode.ai/auth) 获取的 OpenCode Zen API 密钥、走带密钥的 **`opencode-zen`** 预设。若 OpenCode 之后公布了免密钥层级的第三方接入方式,opencodex 可以跟进;在此之前,这个预设的作用是记录该限制。上游条款:[opencode.ai/docs/zen](https://opencode.ai/docs/zen/)。 diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index c0d81d33176..4d0f008be09 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -345,6 +345,8 @@ Bearer 金鑰;公開模型清單無法驗證金鑰有效性。 error 加入 provider guidance 與 synthetic `Retry-After`;若上游有 `Retry-After`,仍以上游值為準。 same-key wait-and-retry 仍需透過 [`retryOn429`](/zh-tw/reference/configuration/) 明確 opt-in。 +在標準 `opencode-zen` 提供者中,使用 API 金鑰驗證及官方路由 `https://opencode.ai/zen/v1` 或 `https://opencode.ai/inference/v1` 時,Gemini 會在預設 `googleMode: ai-studio` 下自動使用 Google 原生 API。Zen 模型 ID 維持不變,包括沒有 `-tiered` 後綴的 Flash ID。Claude 使用 Anthropic Messages,不受 `googleMode` 影響。載入已儲存的設定時,僅忽略這些原生模型符合條件的舊 Chat/Responses 覆寫設定,以及符合條件的 Responses 專用 `image_generation` 託管工具偏好。自訂提供者、非金鑰驗證路由及其他偏好維持不變。 + **無 key 的 `opencode-free` tier 目前對第三方 client 關閉。** Zen 會拒絕任何沒有帶 `x-opencode-session` header 的 request,回傳 error type `MissingSessionID` 與訊息 "OpenCode's free tier can only be used in OpenCode"。這道 gate 只檢查該 header 是否存在,因此 proxy 大可捏造一個值通過,但 opencodex 不這麼做。 diff --git a/src/config/opencode-zen-upgrade.ts b/src/config/opencode-zen-upgrade.ts index 974e69ebe23..f3b1da60582 100644 --- a/src/config/opencode-zen-upgrade.ts +++ b/src/config/opencode-zen-upgrade.ts @@ -1,4 +1,4 @@ -import { openCodeZenBaseUrl } from "../types/opencode-zen"; +import { openCodeZenBaseUrl, openCodeZenKeyBaseUrl, type OpenCodeZenDestination } from "../types/opencode-zen"; import { declaredUnsupportedHostedTools, isHostedToolUnsupportedForModel } from "../responses/hosted-tool-policy"; function record(value: unknown): Record | undefined { @@ -6,8 +6,9 @@ function record(value: unknown): Record | undefined { ? value as Record : undefined; } -function isGeminiModel(model: string): boolean { - return model.trim().toLowerCase().startsWith("gemini-"); +function isNativeZenModel(model: string, provider: OpenCodeZenDestination): boolean { + const folded = model.trim().toLowerCase(); + return folded.startsWith("claude-") || (folded.startsWith("gemini-") && openCodeZenBaseUrl(provider) !== undefined); } /** Repair legacy Zen wire overrides and dependent preferences in file-backed reads only. */ @@ -15,7 +16,7 @@ export function sanitizeOpenCodeZenOverridesForLoad(parsed: unknown): void { const providers = record(record(parsed)?.providers); const provider = providers && Object.hasOwn(providers, "opencode-zen") ? record(providers["opencode-zen"]) : undefined; - if (!provider || !openCodeZenBaseUrl(provider)) return; + if (!provider || !openCodeZenKeyBaseUrl(provider)) return; const overrides = record(provider.modelAdapters) ?? {}; const preferences = record(provider.modelPreferHostedTools); let preferencesChanged = false; @@ -25,7 +26,7 @@ export function sanitizeOpenCodeZenOverridesForLoad(parsed: unknown): void { // Capture the old wire before deleting overrides. An explicit non-Responses entry // must not inherit a provider-wide Responses default for this migration. const oldAdapter = Object.hasOwn(overrides, model) ? overrides[model] : provider.adapter; - if (!isGeminiModel(model) || oldAdapter !== "openai-responses" + if (!isNativeZenModel(model, provider) || oldAdapter !== "openai-responses" || !Array.isArray(tools) || tools.length === 0 || tools.some(tool => tool !== "image_generation") || isHostedToolUnsupportedForModel(model, "image_generation", undefined, unsupported)) continue; delete preferences[model]; @@ -37,16 +38,16 @@ export function sanitizeOpenCodeZenOverridesForLoad(parsed: unknown): void { for (const [model, adapter] of Object.entries(overrides)) { // Retain invalid preference/wire pairs so repeated normalization cannot admit them. if (preferences && Object.hasOwn(preferences, model)) continue; - if (isGeminiModel(model) && (adapter === "openai-chat" || adapter === "openai-responses")) { + if (isNativeZenModel(model, provider) && (adapter === "openai-chat" || adapter === "openai-responses")) { delete overrides[model]; overridesChanged = true; } } if (overridesChanged) { if (Object.keys(overrides).length === 0) delete provider.modelAdapters; - console.warn("OpenCode Zen Gemini models now use the native Google API; obsolete Chat/Responses overrides were ignored while loading config.json."); + console.warn("OpenCode Zen Gemini and Claude models now use their native APIs; obsolete Chat/Responses overrides were ignored while loading config.json."); } if (preferencesChanged) { - console.warn("OpenCode Zen Gemini models now use the native Google API; legacy Responses-only hosted-tool preferences were ignored while loading config.json."); + console.warn("OpenCode Zen Gemini and Claude models now use their native APIs; legacy Responses-only hosted-tool preferences were ignored while loading config.json."); } } diff --git a/src/providers/opencode-zen-rate-limit.ts b/src/providers/opencode-zen-rate-limit.ts index 383c9f70ae6..788ab730b37 100644 --- a/src/providers/opencode-zen-rate-limit.ts +++ b/src/providers/opencode-zen-rate-limit.ts @@ -70,6 +70,9 @@ export function enrichOpenCodeZenRateLimitMessage( message: string, opts: { status: number; + /** Parsed upstream error type, when available. */ + upstreamErrorType?: string | null; + upstreamErrorCode?: string | null; providerName?: string; baseUrl?: string; adapter?: string; @@ -84,7 +87,9 @@ export function enrichOpenCodeZenRateLimitMessage( ): string { if (opts.status !== 429) return message; if (!isOpenCodeZenRateLimitProvider(opts)) return message; - if (!/rate\s*limit/i.test(message)) return message; + const errorText = `${opts.upstreamErrorType ?? ""} ${opts.upstreamErrorCode ?? ""} ${message}`.replace(/[_-]/g, " "); + if (/\b(?:quota|billing)\b|\busage\s+limit\b|\binsufficient\s+(?:credits?|balance|funds)\b|\b(?:credits?|balance)\s+(?:exhausted|depleted)\b/i.test(errorText)) return message; + if (opts.upstreamErrorType !== "rate_limit_error" && !/rate\s*limit/i.test(message)) return message; if (message.includes(ENRICHMENT_MARKER)) return message; const upstreamRetry = validateClientRetryAfterHeader( @@ -165,7 +170,7 @@ export function enrichOpenCodeZenFreeTierMessage( } /** - * Single entry point for Zen upstream-error guidance on the Responses wire: short-window + * Single entry point for Zen upstream-error guidance on Responses and native Messages: short-window * rate limits first, then the keyless free-tier admission refusal. Each layer is a no-op * outside its own case, so the composition is safe for every other upstream failure. */ diff --git a/src/server/messages-native.ts b/src/server/messages-native.ts index e67683f2578..eccee246fc8 100644 --- a/src/server/messages-native.ts +++ b/src/server/messages-native.ts @@ -44,6 +44,7 @@ import { classifyError } from "../lib/errors"; import { relaySseWithPayloadRewrite } from "./sse-payload-rewrite"; import { redactSecretString } from "../lib/redact"; import { resolveClientRetryAfter } from "../lib/retry-after"; +import { enrichOpenCodeZenUpstreamMessage } from "../providers/opencode-zen-rate-limit"; import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget"; import { applyReplayRefusalClientHeaders, @@ -845,7 +846,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) upstream.abort(); return fail(499, "Client cancelled request", "api_error"); } - return retainUpstreamMessagesRequestId(nativeMessagesErrorResponse(response, bodyText, finishLog), response.headers); + return retainUpstreamMessagesRequestId(nativeMessagesErrorResponse(response, bodyText, finishLog, route.providerName, activeProvider), response.headers); } const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; @@ -999,7 +1000,7 @@ function isNativeOverflowError(error: Rec, message: string): boolean { * Messages path, so a client sees the same contract on either lane: transient 5xx become 529 * with a retry hint, and a proxy-side replay refusal keeps its no-retry marking. */ -function nativeMessagesErrorResponse(response: Response, bodyText: string, finishLog: FinishLog): Response { +function nativeMessagesErrorResponse(response: Response, bodyText: string, finishLog: FinishLog, providerName: string, provider: OcxProviderConfig): Response { let upstreamType: string | undefined; let upstreamCode: string | undefined; let upstreamMessage: string | undefined; @@ -1013,8 +1014,20 @@ function nativeMessagesErrorResponse(response: Response, bodyText: string, finis } } catch { /* keep generic classification */ } const detail = formatAnthropicErrorBody(response.status, response.headers, bodyText); - const message = upstreamMessage + const rawMessage = upstreamMessage ?? (detail ? `Provider error ${response.status}: ${detail}` : `Provider error ${response.status}`); + const message = enrichOpenCodeZenUpstreamMessage(rawMessage, { + status: response.status, + providerName, + baseUrl: provider.baseUrl, + adapter: provider.adapter, + authMode: provider.authMode, + hasApiKey: Boolean(provider.apiKey?.trim()), + upstreamRetryAfter: response.headers.get("retry-after"), + upstreamErrorType: upstreamType, + upstreamErrorCode: upstreamCode, + supportsHttpSameKeyRetry: true, + }); const classified = classifyError( response.status, upstreamType ?? (response.status === 401 ? "authentication_error" diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index afb473ab583..75c5bbe9348 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -1877,6 +1877,8 @@ export async function prepareAdapterExchange( authMode: route.provider.authMode, hasApiKey: Boolean(route.provider.apiKey?.trim()), upstreamRetryAfter, + upstreamErrorType: normalized.type, + upstreamErrorCode: normalized.code, // This recovery path is the HTTP Responses wire; custom runTurn transports // never reach enrichOpenCodeZenUpstreamMessage here. supportsHttpSameKeyRetry: true, diff --git a/src/types/opencode-zen.ts b/src/types/opencode-zen.ts index 0452a7bc966..c3d7fedd18c 100644 --- a/src/types/opencode-zen.ts +++ b/src/types/opencode-zen.ts @@ -1,14 +1,14 @@ -/** Fixed-key OpenCode Zen destinations exposing the native Gemini endpoint. */ +/** Fixed-key OpenCode Zen destinations supporting native model protocols. */ export interface OpenCodeZenDestination { readonly baseUrl?: unknown; readonly authMode?: unknown; readonly googleMode?: unknown; } -export function openCodeZenBaseUrl(provider: OpenCodeZenDestination | undefined): string | undefined { +/** Match the official Zen destination and API-key authentication. */ +export function openCodeZenKeyBaseUrl(provider: OpenCodeZenDestination | undefined): string | undefined { if (!provider || typeof provider.baseUrl !== "string") return undefined; if (provider.authMode !== undefined && provider.authMode !== "key") return undefined; - if (provider.googleMode !== undefined && provider.googleMode !== "ai-studio") return undefined; try { const url = new URL(provider.baseUrl.trim()); const pathname = url.pathname.replace(/\/+$/, ""); @@ -20,6 +20,12 @@ export function openCodeZenBaseUrl(provider: OpenCodeZenDestination | undefined) } } +/** Gemini's native endpoint applies only in AI Studio mode. */ +export function openCodeZenBaseUrl(provider: OpenCodeZenDestination | undefined): string | undefined { + if (provider?.googleMode !== undefined && provider.googleMode !== "ai-studio") return undefined; + return openCodeZenKeyBaseUrl(provider); +} + /** Keep the selected destination; Zen omits AI Studio's /v1beta segment. */ export function openCodeZenGeminiEndpoint(provider: OpenCodeZenDestination, modelId: string): string | undefined { const baseUrl = openCodeZenBaseUrl(provider); diff --git a/src/types/wire.ts b/src/types/wire.ts index c00c010e854..6c43fb9b5cc 100644 --- a/src/types/wire.ts +++ b/src/types/wire.ts @@ -1,5 +1,5 @@ import { isOpenCodeGoGeminiDestination, OPENCODE_GO_BASE_URL, OPENCODE_GO_MODEL_WIRES, OPENCODE_GO_LEGACY_NAME_PINS, type OpenCodeGoDestination } from "./opencode-go"; -import { openCodeZenBaseUrl, type OpenCodeZenDestination } from "./opencode-zen"; +import { openCodeZenBaseUrl, openCodeZenKeyBaseUrl, type OpenCodeZenDestination } from "./opencode-zen"; /** * Accepted values for the per-provider upstream HTTP-version pin (#1668). Shared by the @@ -106,7 +106,9 @@ const CREDENTIAL_PINNED_ADAPTERS: Readonly> | undefined { if (Object.hasOwn(CREDENTIAL_PINNED_ADAPTERS, providerName)) return CREDENTIAL_PINNED_ADAPTERS[providerName]; - if (providerName === "opencode-zen" && openCodeZenBaseUrl(provider)) return { "gemini-": "google" }; + if (providerName === "opencode-zen" && openCodeZenKeyBaseUrl(provider)) { + return openCodeZenBaseUrl(provider) ? { "gemini-": "google", "claude-": "anthropic" } : { "claude-": "anthropic" }; + } if (!provider || !Object.hasOwn(WIRE_ADAPTER_PIN_PREFIXES, providerName)) return undefined; const rule = WIRE_ADAPTER_PIN_PREFIXES[providerName]!; return normalizedEndpoint(provider.baseUrl) === rule.endpoint ? rule.prefixes : undefined; diff --git a/structure/config.md b/structure/config.md index 34dbbefb924..e00cfec7524 100644 --- a/structure/config.md +++ b/structure/config.md @@ -434,7 +434,7 @@ capture/route time and explicit false or empty declarations retain their field-s `src/config/provider-validation.ts` owns the pure provider payload checks shared by persisted config, CLI writes, and management DTO validation. `src/config.ts` imports those checks for Zod refinement and re-exports them as a compatibility facade; it must not grow a second copy. Validation error text, -ordering, and cross-field rules are part of the write/load contract: management requests and hand-edited `config.json` accept and reject the same provider shapes. Before validation, `src/config/opencode-wire-upgrade.ts` shares the Zen upgrade in `src/config/opencode-zen-upgrade.ts` and normalizes saved Chat/Responses overrides for newly pinned Go Gemini and Qwen models, including previously valid Responses-only hosted-tool preferences. Go uses validation's historical fixed-destination and key-auth rules; its older Anthropic pins and overridable Responses defaults retain normal validation. Runtime and diagnostics-backed reads share this upgrade, preserving unrelated providers, accounts and routing. Reads warn without rewriting the file; explicit edits persist the normalized configuration, and new writes still reject incompatible overrides and preferences. Unsupported authentication modes and invalid override values retain normal validation. +ordering, and cross-field rules are part of the write/load contract: management requests and hand-edited `config.json` accept and reject the same provider shapes. Before validation, `src/config/opencode-wire-upgrade.ts` shares the Zen Gemini and Claude upgrade in `src/config/opencode-zen-upgrade.ts` and normalizes saved Chat/Responses overrides for newly pinned Go Gemini and Qwen models, including previously valid Responses-only hosted-tool preferences. Go uses validation's historical fixed-destination and key-auth rules; its older Anthropic pins and overridable Responses defaults retain normal validation. Runtime and diagnostics-backed reads share this upgrade, preserving unrelated providers, accounts and routing. Reads warn without rewriting the file; explicit edits persist the normalized configuration, and new writes still reject incompatible overrides and preferences. Unsupported authentication modes and invalid override values retain normal validation. Provider `projectContext` accepts `"off"` or `"on"` only for native `command-code`; `src/config/schema/leaf-validators.ts` rejects other adapters on load, while `src/config/provider-validation.ts` and `src/server/auth-cors.ts` reject them on management writes. This editor-owned outbound-file field follows [provider and adapter selection](providers-and-adapters.md). The Google tool-schema policy uses a closed enum at this boundary. Unknown values fail config load, diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index f7d0c65fd3e..6b682b88ded 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -389,7 +389,7 @@ managed-client steps already applied to the body (alias/modelMap resolution, `oc directives). The caller-forward passthrough is decided earlier, on the caller's own credential, and returns before this point; the two branches share no credential and no header. The body sent is `envelope.freshBody()` when a source envelope exists, otherwise the ingress's own body. -`src/server/messages-native.ts` is imported lazily, only for an eligible route. +`src/server/messages-native.ts` is imported lazily, only for an eligible route. Native Messages errors reuse the shared OpenCode Zen guidance before resolving client retry headers. A Zen rate-limit 429, identified by message or explicit `rate_limit_error` type, without an upstream retry hint receives the same 15-second hint as the translated path; quota/billing and spent-usage-limit errors remain unchanged and valid upstream retry headers retain precedence. Both native Messages and `src/server/responses/adapter-dispatch.ts` forward parsed error types and codes to the shared guidance. Configured admission-key model scope also applies to separately billed Advisor models before managed native key or OAuth dispatch. `src/server/messages-native-scope.ts` resolves active diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 28ed67a9c70..215c8970652 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -563,7 +563,7 @@ same way. `src/types/wire.ts` owns accepted wire enumerations such as the per-pr config load, management writes and fetch, so no boundary accepts a value another rejects. It also hard-pins OpenCode Go's Anthropic models across runtime and static policy resolution, even with Chat/Responses overrides. The existing `opencode-go` preset pins saved URLs to its canonical endpoint; its inference alias supplies destination metadata and affinity to differently named rows. `src/types/opencode-go.ts` shares exact native pins (including Qwen3.7 Plus/3.8 Max/3.8 Flash) and overridable Responses defaults (including GPT 6 Luna); new native pins use the pinned canonical Go key destination even in pure policy captures of saved rows, while the five legacy name pins retain compatibility; unknown models retain Chat. `tests/providers/opencode-go-model-protocols.test.ts` covers resolution and dispatch. Every `dsh-account` model is pinned to the `dsh-account` adapter whatever `baseUrl` says, because that adapter is the [DSH account token's destination boundary](providers/dsh-account.md#destinations). -For canonical `opencode-zen`, `src/types/wire.ts` pins `gemini-*` to `google`, including when a Chat/Responses override is configured. `src/types/opencode-zen.ts` shares the destination check with `src/adapters/google.ts`: only `https://opencode.ai/zen/v1` and `https://opencode.ai/inference/v1` qualify, with key auth and AI Studio mode (both may be unset), no URL credentials, query or fragment, and optional trailing slashes. The adapter appends `/models/:generateContent` or `:streamGenerateContent?alt=sse` to the selected base, sends `x-goog-api-key`, and skips AI Studio's `-tiered` model renames. The pin does not apply to `opencode-free`, OpenCode Go, custom destinations, OAuth/forward auth, Vertex or Cloud Code Assist. +For canonical `opencode-zen`, `src/types/wire.ts` pins `gemini-*` to `google` and `claude-*` to `anthropic`, including when a Chat/Responses override is configured. `src/types/opencode-zen.ts` shares the destination check with `src/adapters/google.ts`: only `https://opencode.ai/zen/v1` and `https://opencode.ai/inference/v1` qualify, with key auth (which may be unset), no URL credentials, query or fragment, and optional trailing slashes. Claude uses the existing Anthropic Messages adapter and does not depend on the Google mode setting. Zen rate-limit guidance in `src/providers/opencode-zen-rate-limit.ts` accepts an explicit `rate_limit_error` even without matching message text, excludes quota/billing and spent-usage-limit failures using structured type/code and message cues, and preserves valid upstream retry headers. The Google adapter appends `/models/:generateContent` or `:streamGenerateContent?alt=sse` to the selected base, sends `x-goog-api-key`, and skips AI Studio's `-tiered` model renames. These pins do not apply to `opencode-free`, OpenCode Go, custom destinations or OAuth/forward auth. The Gemini pin additionally requires AI Studio mode (which may be unset), excluding Vertex and Cloud Code Assist. `src/types/config.ts` declares the optional per-phase `memoryModels` setting; `src/types/request.ts` carries the selected phase through combo handoffs without changing the diff --git a/tests/claude-integration/messages-native.test.ts b/tests/claude-integration/messages-native.test.ts index 583f21667e7..a2e5e65357b 100644 --- a/tests/claude-integration/messages-native.test.ts +++ b/tests/claude-integration/messages-native.test.ts @@ -5,7 +5,7 @@ * key) instead of the Responses replay. Everything here is a local fixture: no real credential * or service is reached. */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -397,6 +397,57 @@ describe("managed native Messages", () => { expect(seen.map(entry => entry.headers.get("x-api-key"))).toEqual(["fixture-key-alpha", "fixture-key-beta"]); }); + for (const { providerName, upstreamRetryAfter, expectedRetryAfter, upstreamMessage = "Rate limit exceeded", upstreamCode, expectedGuidance = providerName === "opencode-zen" } of [ + { providerName: "opencode-zen", upstreamRetryAfter: undefined, expectedRetryAfter: "15" }, + { providerName: "opencode-zen", upstreamRetryAfter: "37", expectedRetryAfter: "37" }, + { providerName: "anth", upstreamRetryAfter: undefined, expectedRetryAfter: "2" }, + { providerName: "opencode-zen", upstreamRetryAfter: undefined, expectedRetryAfter: "15", upstreamMessage: "Too Many Requests" }, + { providerName: "opencode-zen", upstreamRetryAfter: undefined, expectedRetryAfter: "2", upstreamMessage: "The usage limit has been reached", expectedGuidance: false }, + { providerName: "opencode-zen", upstreamRetryAfter: undefined, expectedRetryAfter: "2", upstreamCode: "insufficient_quota", expectedGuidance: false }, + ]) test(`native ${providerName} 429 preserves pacing policy with Retry-After ${upstreamRetryAfter ?? "absent"} and ${upstreamMessage}`, async () => { + const config = fixtureConfig(1); + if (providerName !== "anth") { + config.providers[providerName] = config.providers.anth!; + delete config.providers.anth; + config.defaultProvider = providerName; + config.providers[providerName]!.baseUrl = "https://opencode.ai/zen/v1"; + } + saveConfig(config); + // The Zen preset pins its public URL; intercept fetch so no external service is contacted. + const fetchMock = spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const request = new Request(input, init); + seen.push({ path: new URL(request.url).pathname, headers: request.headers, body: await request.json() as Record }); + return Response.json({ type: "error", error: { type: "rate_limit_error", code: upstreamCode, message: upstreamMessage } }, + { status: 429, headers: upstreamRetryAfter ? { "retry-after": upstreamRetryAfter } : {} }); + }); + let result: Awaited>; + try { + result = await send(config, { ...SOURCE_BODY, model: `${providerName}/claude-x`, stream: false }); + } finally { + fetchMock.mockRestore(); + } + const { requestId, response, text } = result; + expect(response.status).toBe(429); + expect(response.headers.get("retry-after")).toBe(expectedRetryAfter); + expect(seen).toHaveLength(1); + expect(seen[0]!.path).toBe(providerName === "opencode-zen" ? "/zen/v1/messages" : "/v1/messages"); + const row = rowFor(requestId); + expect(row.protocolTrace?.mode).toBe("native"); + const envelope = JSON.parse(text); + expect(envelope).toMatchObject({ type: "error", error: { type: "rate_limit_error" } }); + const message = envelope.error.message; + if (expectedGuidance) { + expect(message).toContain("15-20 requests per minute"); + expect(message).toContain("Slow the request pace"); + expect(message).toContain("providers.opencode-zen.retryOn429"); + if (upstreamRetryAfter) expect(message).not.toContain("Try again in 15s"); + else expect(message).toContain("Try again in 15s"); + } else { + expect(message).toBe(upstreamMessage); + } + expect(row.upstreamError).toBe(message); + expect(text).not.toContain("fixture-key-alpha"); + }); test("an upstream error answers in Anthropic shape without the key", async () => { const config = fixtureConfig(startUpstream(() => Response.json( { type: "error", error: { type: "invalid_request_error", message: "bad fixture" } }, { status: 400 }))); diff --git a/tests/config/config-opencode-zen-upgrade.test.ts b/tests/config/config-opencode-zen-upgrade.test.ts index bda564bc285..02f5b46b6af 100644 --- a/tests/config/config-opencode-zen-upgrade.test.ts +++ b/tests/config/config-opencode-zen-upgrade.test.ts @@ -7,8 +7,32 @@ import { handleConfigCommand } from "../../src/cli/config-command"; import { flushConfigDirHardeningAndReaps } from "../../src/config/paths"; import { createTempHome } from "../helpers/temp-home"; +test("Claude saved overrides and Responses-only preferences migrate without changing unrelated models", () => { + const raw = candidate({ "claude-sonnet-5-5": "openai-chat", "claude-opus-5-5": "openai-responses", "qwen-model": "openai-chat" }, { + modelPreferHostedTools: { "claude-opus-5-5": ["image_generation"] }, + }); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(raw.providers["opencode-zen"].modelAdapters).toEqual({ "qwen-model": "openai-chat" }); + expect(raw.providers["opencode-zen"].modelPreferHostedTools).toBeUndefined(); + expect(validateConfigCandidate(raw).ok).toBe(true); + } finally { warn.mockRestore(); } +}); + +test.each(["vertex", "cloud-code-assist"])("Claude migration preserves Google mode %s and Gemini overrides", googleMode => { + const raw = candidate({ "claude-opus-5-5": "openai-responses", "gemini-3.7-flash": "openai-responses" }, { googleMode }); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(raw.providers["opencode-zen"].modelAdapters).toEqual({ "gemini-3.7-flash": "openai-responses" }); + expect(raw.providers["opencode-zen"].googleMode).toBe(googleMode); + expect(validateConfigCandidate(raw).ok).toBe(true); + } finally { warn.mockRestore(); } +}); + const BASES = ["https://opencode.ai/zen/v1", "https://opencode.ai/inference/v1"]; -const WARNING = "OpenCode Zen Gemini models now use the native Google API; obsolete Chat/Responses overrides were ignored while loading config.json."; +const WARNING = "OpenCode Zen Gemini and Claude models now use their native APIs; obsolete Chat/Responses overrides were ignored while loading config.json."; function candidate(modelAdapters: unknown, overrides: Record = {}) { return { diff --git a/tests/providers/opencode-zen-gemini-wire.test.ts b/tests/providers/opencode-zen-gemini-wire.test.ts index fb5dd4cdc59..ac4b043612e 100644 --- a/tests/providers/opencode-zen-gemini-wire.test.ts +++ b/tests/providers/opencode-zen-gemini-wire.test.ts @@ -45,6 +45,16 @@ function expectWire(providerName: string, provider: OcxProviderConfig, modelId: } describe("OpenCode Zen Gemini destination and wire policy", () => { + test("Claude uses Messages across runtime, static and Fast policy", () => { + for (const baseUrl of BASES) for (const model of ["claude-sonnet-5-5", "claude-opus-5-5", "claude-haiku-5-5", "Claude-future"]) { + for (const googleMode of [undefined, "ai-studio", "vertex", "cloud-code-assist"] as const) for (const adapter of ["openai-chat", "openai-responses"] as const) { + expectWire("opencode-zen", zen({ baseUrl, googleMode, modelAdapters: { [model]: adapter } }), model, "anthropic"); + } + expectWire("renamed-zen", zen({ baseUrl }), model, "openai-chat"); + expectWire("opencode-zen", zen({ baseUrl: "https://custom.example.test/v1" }), model, "openai-chat"); + expectWire("opencode-zen", zen({ baseUrl, authMode: "oauth" }), model, "openai-chat"); + } + }); test.each(BASES)("pins Gemini models at %s across runtime, static and Fast policy", baseUrl => { for (const model of MODELS) for (const override of ["openai-chat", "openai-responses"]) { const provider = zen({ baseUrl, modelAdapters: { [model]: override }, supportsServiceTier: true }); @@ -103,13 +113,13 @@ describe("OpenCode Zen Gemini destination and wire policy", () => { const authority = captureFastPolicyAuthority("opencode-zen", provider, true); provider.baseUrl = "https://custom.example.test/v1"; expect(Object.isFrozen(prefixes)).toBe(true); - expect(prefixes).toEqual({ "gemini-": "google" }); + expect(prefixes).toEqual({ "gemini-": "google", "claude-": "anthropic" }); expect(resolveFastPolicy(authority, "gemini-3.7-flash").adapter).toBe("google"); expect(fastPolicyForModel(provider, "gemini-3.7-flash", "opencode-zen").adapter).toBe("openai-responses"); }); test("Zen siblings and other provider identities retain their existing wires", () => { - for (const model of ["claude-sonnet-5-5", "gpt-6.1-sol", "deepseek-v4-flash", "not-gemini-3.7-flash"]) { + for (const model of ["gpt-6.1-sol", "deepseek-v4-flash", "not-gemini-3.7-flash"]) { expect(openCodeZenGeminiEndpoint(zen(), model)).toBeUndefined(); expectWire("opencode-zen", zen(), model, "openai-chat"); expectWire("opencode-zen", zen({ modelAdapters: { [model]: "openai-responses" } }), model, "openai-responses"); @@ -171,6 +181,29 @@ describe("OpenCode Zen Gemini Responses translation", () => { globalThis.fetch = originalFetch; }); + test("translated Claude uses parsed rate-limit types and preserves upstream retry hints", async () => { + releaseSpendHome = acquireOwnedSpendHome(); + for (const retryAfter of [undefined, "37"]) { + let sends = 0; + globalThis.fetch = (async () => { + sends++; + return Response.json({ type: "error", error: { type: "rate_limit_error", message: "Too Many Requests" } }, + { status: 429, headers: retryAfter ? { "retry-after": retryAfter } : {} }); + }) as typeof fetch; + const config = { providers: { "opencode-zen": zen() } } as unknown as OcxConfig; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "opencode-zen/claude-sonnet-5-5", input: "hello", stream: false }), + }), config, { model: "", provider: "" }, { inboundWire: "responses" }); + const text = await response.text(); + expect(response.status, text).toBe(429); + expect(response.headers.get("retry-after")).toBe(retryAfter ?? "15"); + expect(text).toContain("15-20 requests per minute"); + expect(text.includes("Try again in 15s")).toBe(!retryAfter); + expect(sends).toBe(1); + } + }); + // Routing canonicalizes the fixed Zen preset to /zen/v1; both accepted endpoint // spellings are exercised without that registry normalization in the adapter cases above. for (const baseUrl of [BASES[0]!]) for (const stream of [true, false]) { diff --git a/tests/providers/opencode-zen-rate-limit.test.ts b/tests/providers/opencode-zen-rate-limit.test.ts index e8e90d94162..5857f822c02 100644 --- a/tests/providers/opencode-zen-rate-limit.test.ts +++ b/tests/providers/opencode-zen-rate-limit.test.ts @@ -78,6 +78,47 @@ describe("opencode-zen rate-limit guidance (#1145)", () => { })).toBe(String(OPENCODE_ZEN_SYNTHETIC_RETRY_AFTER_SEC)); }); + test("explicit rate-limit types receive guidance without matching message text", () => { + const enriched = enrichOpenCodeZenUpstreamMessage("Too Many Requests", { + status: 429, providerName: "opencode-zen", upstreamErrorType: "rate_limit_error", + }); + expect(enriched).toContain("Try again in 15s"); + expect(resolveClientRetryAfter({ status: 429, message: enriched })).toBe("15"); + }); + + test("rate limits mentioning a plan or load balancer still receive guidance", () => { + for (const message of ["Rate limit exceeded for credit-based plan", "load balancer rate limit", "balanced throughput"]) { + expect(enrichOpenCodeZenUpstreamMessage(message, { + status: 429, providerName: "opencode-zen", upstreamErrorType: "rate_limit_error", + })).toContain("Try again in 15s"); + } + }); + + test("quota errors never receive short-window pacing guidance", () => { + for (const [upstreamErrorType, message] of [ + ["insufficient_quota", "Rate limit exceeded"], + ["rate_limit_error", "Monthly quota exceeded; rate limit exceeded"], + ["insufficient_quota", "Too Many Requests"], + ["rate_limit_error", "Rate limit exceeded: insufficient credit"], + ["rate_limit_error", "Rate limit exceeded: balance exhausted"], + ["billing_error", "Too Many Requests"], + ["rate_limit_error", "The usage limit has been reached"], + ["usage_limit_reached", "Too Many Requests"], + ]) { + expect(enrichOpenCodeZenUpstreamMessage(message!, { + status: 429, providerName: "opencode-zen", upstreamErrorType, + })).toBe(message!); + } + }); + + test("structured quota and billing codes suppress pacing guidance", () => { + for (const upstreamErrorCode of ["insufficient_quota", "billing_hard_limit_reached", "usage_limit_reached"]) { + expect(enrichOpenCodeZenUpstreamMessage("Rate limit exceeded", { + status: 429, providerName: "opencode-zen", upstreamErrorType: "rate_limit_error", upstreamErrorCode, + })).toBe("Rate limit exceeded"); + } + }); + test("enrichOpenCodeZenRateLimitMessage skips synthetic hint when upstream Retry-After is set", () => { const enriched = enrichOpenCodeZenRateLimitMessage( "Provider error 429: Rate limit exceeded.",