From 97aced75cb588e51a22d17ab13521fe1de082b4f Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:23:44 +0200 Subject: [PATCH 1/3] fix(providers): surface OpenCode Zen short-window rate limits Document the observed ~15-20 RPM burst ceiling on opencode-zen (and cross-link it on opencode-free), and enrich opaque Zen 429s with guidance plus a parseable Retry-After so Codex clients can back off. --- .../src/content/docs/guides/providers.md | 9 ++ .../src/content/docs/ja/guides/providers.md | 3 + .../src/content/docs/ko/guides/providers.md | 3 + .../src/content/docs/ru/guides/providers.md | 8 ++ .../content/docs/zh-cn/guides/providers.md | 3 + src/providers/opencode-zen-rate-limit.ts | 72 ++++++++++++++ src/providers/registry.ts | 3 +- src/server/responses/core.ts | 11 ++- tests/opencode-zen-rate-limit.test.ts | 97 +++++++++++++++++++ 9 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 src/providers/opencode-zen-rate-limit.ts create mode 100644 tests/opencode-zen-rate-limit.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 07d0677e1e..2f0416e592 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -276,6 +276,15 @@ free-experimentation model. | Cloudflare AI Gateway | `https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic` | | …and more | opencode zen, Vercel AI Gateway, Venice, NanoGPT, Synthetic, Qianfan, Alibaba, Parallel, ZenMux, LiteLLM | +**OpenCode Zen** (`opencode-zen`) and the keyless **OpenCode Free** preset share +`https://opencode.ai/zen/v1`. Free models on that gateway often hit a short-window burst +limit around 15–20 requests/minute (community-measured; OpenCode does not publish RPM or +`Retry-After` / `X-RateLimit-*` headers). That is separate from the keyless desktop quota +OpenCode advertises (~200 Big Pickle/free-model requests per 5 hours on `opencode-free`). +When Zen returns a generic rate-limit 429 without backoff headers, opencodex adds provider +guidance to the client error and a synthetic `Retry-After` so Codex-shaped clients can wait. +Same-key wait-and-retry remains opt-in via [`retryOn429`](/reference/configuration/). + Most use the `openai-chat` adapter with a bearer key; a few that expose only an Anthropic-compatible endpoint (e.g. **Xiaomi MiMo**) use the `anthropic` adapter (`x-api-key`). Volcengine Agent Plan uses its native Responses endpoint through `openai-responses`. diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index da7e633170..f4c4aca2d9 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -203,6 +203,9 @@ Cline IDE/CLI のみで API からは使えません。`minimax/minimax-m2.5` | Cloudflare AI Gateway | `https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic` | | …その他多数 | opencode zen、Vercel AI Gateway、Venice、NanoGPT、Synthetic、Qianfan、Alibaba、Parallel、ZenMux、LiteLLM | +**OpenCode Zen**(`opencode-zen`)とキー不要の **OpenCode Free** プリセットは +`https://opencode.ai/zen/v1` を共有します。このゲートウェイ上の無料モデルは、しばしばおおよそ毎分 15–20 リクエストの短時間レート制限に当たります(コミュニティ計測。OpenCode は RPM を公表せず、`Retry-After` / `X-RateLimit-*` ヘッダーも返しません)。これはキー不要デスクトップ枠(`opencode-free` で Big Pickle/無料モデル約 200 回 / 5 時間)とは別です。Zen がバックオフヘッダーなしの汎用 429 を返した場合、opencodex はクライアント向けエラーに案内を足し、合成 `Retry-After` を付けます。同一キーの待機再試行は [`retryOn429`](/ja/reference/configuration/) でオプトインします。 + 大半は bearer キーと共に `openai-chat` アダプターを使い、Anthropic 互換エンドポイントのみを公開する一部 (例: **Xiaomi MiMo**)は `anthropic` アダプター(`x-api-key`)を使います。 Volcengine Agent Plan は `openai-responses` アダプターでネイティブ Responses エンドポイントを使用します。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index de93be7cf6..febc6cc252 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -203,6 +203,9 @@ Cline IDE/CLI에서만 제공되며 API로는 사용할 수 없습니다. `minim | Cloudflare AI Gateway | `https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic` | | …그 외 다수 | opencode zen, Vercel AI Gateway, Venice, NanoGPT, Synthetic, Qianfan, Alibaba, Parallel, ZenMux, LiteLLM | +**OpenCode Zen**(`opencode-zen`)과 키 없는 **OpenCode Free** 프리셋은 +`https://opencode.ai/zen/v1`을 공유합니다. 그 게이트웨이의 무료 모델은 종종 분당 약 15–20회 요청의 짧은 창 속도 제한에 걸립니다(커뮤니티 측정; OpenCode는 RPM을 공개하지 않으며 `Retry-After` / `X-RateLimit-*` 헤더도 보내지 않음). 이는 키 없는 데스크톱 할당량(`opencode-free`에서 약 5시간당 Big Pickle/무료 모델 200회)과 별개입니다. Zen이 백오프 헤더 없는 일반 429를 반환하면 opencodex는 클라이언트 오류에 안내를 더하고 합성 `Retry-After`를 붙입니다. 동일 키 대기 재시도는 [`retryOn429`](/ko/reference/configuration/)로 선택합니다. + 대부분은 bearer 키와 함께 `openai-chat` 어댑터를 사용하며, Anthropic 호환 엔드포인트만 노출하는 일부 (예: **Xiaomi MiMo**)는 `anthropic` 어댑터(`x-api-key`)를 사용합니다. Volcengine Agent Plan은 `openai-responses` 어댑터로 네이티브 Responses 엔드포인트를 사용합니다. diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 79125612cf..cf2a2bc0c3 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -214,6 +214,14 @@ opencodex поставляется с 76 встроенными пресетам | Cloudflare AI Gateway | `https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic` | | …и другие | opencode zen, Vercel AI Gateway, Venice, NanoGPT, Synthetic, Qianfan, Alibaba, Parallel, ZenMux, LiteLLM | +**OpenCode Zen** (`opencode-zen`) и бесключевой пресет **OpenCode Free** используют один +`https://opencode.ai/zen/v1`. Бесплатные модели на этом шлюзе часто упираются в короткое окно +примерно 15–20 запросов в минуту (оценка сообщества; OpenCode не публикует RPM и не отдаёт +`Retry-After` / `X-RateLimit-*`). Это отдельно от бесключевой десктопной квоты +(~200 запросов Big Pickle/бесплатных моделей за 5 часов на `opencode-free`). Когда Zen отвечает +общим 429 без заголовков отката, opencodex добавляет пояснение в ошибку клиента и синтетический +`Retry-After`. Повтор с тем же ключом по-прежнему включается через [`retryOn429`](/ru/reference/configuration/). + Большинство использует адаптер `openai-chat` с bearer-ключом; немногие провайдеры, предоставляющие только Anthropic-совместимую конечную точку (например, **Xiaomi MiMo**), используют адаптер `anthropic` (`x-api-key`). 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 e016f19468..518dfb1803 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -191,6 +191,9 @@ Cline IDE/CLI 中提供,不能通过 API 使用;`minimax/minimax-m2.5` 是 | Cloudflare AI Gateway | `https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic` | | ……以及更多 | opencode zen、Vercel AI Gateway、Venice、NanoGPT、Synthetic、Qianfan、Alibaba、Parallel、ZenMux、LiteLLM | +**OpenCode Zen**(`opencode-zen`)与免密钥的 **OpenCode Free** 预设共用 +`https://opencode.ai/zen/v1`。该网关上的免费模型常会触发约每分钟 15–20 次请求的短窗口限流(社区观测;OpenCode 未公布 RPM,也不返回 `Retry-After` / `X-RateLimit-*`)。这与免密钥桌面配额(`opencode-free` 上约每 5 小时 200 次 Big Pickle/免费模型请求)是分开的。当 Zen 返回无退避头的通用 429 时,opencodex 会在客户端错误中补充说明并附带合成的 `Retry-After`。同密钥等待重试仍可通过 [`retryOn429`](/zh-cn/reference/configuration/) 选择开启。 + 大多数使用带 bearer 密钥的 `openai-chat` adapter;少数仅暴露 Anthropic 兼容端点的提供商(例如 **Xiaomi MiMo**)使用 `anthropic` adapter(`x-api-key`)。 火山方舟 Agent Plan 通过 `openai-responses` adapter 使用原生 Responses 端点。 diff --git a/src/providers/opencode-zen-rate-limit.ts b/src/providers/opencode-zen-rate-limit.ts new file mode 100644 index 0000000000..136fb327b1 --- /dev/null +++ b/src/providers/opencode-zen-rate-limit.ts @@ -0,0 +1,72 @@ +/** + * OpenCode Zen short-window rate-limit guidance (#1145 / OCX-56). + * + * OpenCode's keyed and keyless Zen chat endpoints share `https://opencode.ai/zen/v1` + * and return opaque `429 Rate limit exceeded` bodies without `Retry-After` or + * `X-RateLimit-*` headers. Community request logs show a burst ceiling around + * 15–20 requests/minute on free models — distinct from the keyless desktop + * ~200 requests / 5h quota documented on `opencode-free`. + */ +import { registryEntryForProviderDestination } from "./registry"; + +const OPENCODE_ZEN_PROVIDER_IDS = new Set(["opencode-zen", "opencode-free"]); + +/** Observed free-model burst ceiling on Zen (not an official OpenCode figure). */ +export const OPENCODE_ZEN_OBSERVED_RPM_HINT = "roughly 15-20 requests per minute"; + +/** + * Synthetic client backoff when Zen omits Retry-After after a rate-limit 429. + * Longer than the generic 2s default so Codex-shaped clients do not immediately + * re-hammer a ~15-20 RPM window. + */ +export const OPENCODE_ZEN_SYNTHETIC_RETRY_AFTER_SEC = 15; + +const ENRICHMENT_MARKER = "15-20 requests per minute"; + +export function isOpenCodeZenRateLimitProvider(opts: { + providerName?: string; + baseUrl?: string; + adapter?: string; +}): boolean { + const name = opts.providerName?.trim(); + if (name && OPENCODE_ZEN_PROVIDER_IDS.has(name)) return true; + const baseUrl = opts.baseUrl?.trim(); + if (!baseUrl) return false; + const entry = registryEntryForProviderDestination({ + baseUrl, + adapter: opts.adapter?.trim() || "openai-chat", + authMode: "key", + }); + return entry !== undefined && OPENCODE_ZEN_PROVIDER_IDS.has(entry.id); +} + +/** + * Append actionable Zen rate-limit context to a generic upstream 429 message and + * embed a parseable `try again in Ns` hint so {@link resolveClientRetryAfter} + * surfaces a useful Retry-After when the gateway sent none. + */ +export function enrichOpenCodeZenRateLimitMessage( + message: string, + opts: { + status: number; + providerName?: string; + baseUrl?: string; + adapter?: string; + }, +): string { + if (opts.status !== 429) return message; + if (!isOpenCodeZenRateLimitProvider(opts)) return message; + if (!/rate\s*limit/i.test(message)) return message; + if (message.includes(ENRICHMENT_MARKER)) return message; + + const retryHint = /try again in \d/i.test(message) + ? "" + : ` Try again in ${OPENCODE_ZEN_SYNTHETIC_RETRY_AFTER_SEC}s.`; + return ( + `${message}` + + ` OpenCode Zen free-model traffic is often limited to ${OPENCODE_ZEN_OBSERVED_RPM_HINT}` + + ` (observed; OpenCode does not publish this RPM or rate-limit headers).` + + `${retryHint}` + + " Slow the request pace, or set providers.opencode-zen.retryOn429 for same-key backoff." + ); +} diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 8da2f91f87..056216bae3 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2037,6 +2037,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // continuations, or the gateway answers HTTP 400 (issues #950/#994). Mirror the DeepSeek // reasoning + thinking metadata so `opencode-zen/deepseek-v4-flash-free` — and the other // Zen DeepSeek thinking models — never serialize a bare tool-call turn. + note: "Keyed OpenCode Zen gateway. Free models on this tier are often short-window rate-limited at roughly 15-20 requests/minute (community-measured from opaque 429s; OpenCode does not publish RPM or Retry-After / X-RateLimit headers). Distinct from the keyless opencode-free desktop quota (~200 Big Pickle/free-model requests per 5 hours). Docs: https://opencode.ai/docs/zen/. Free-model prompts may be retained for training — do not send confidential material.", modelReasoningEfforts: Object.fromEntries( [...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS].map(id => [id, deepseekThinkingEffortsFor(id)]), ), @@ -2056,7 +2057,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ keyOptional: true, featured: true, liveModels: true, - note: "No key needed — public desktop tier. OpenCode currently advertises about 200 Big Pickle/free-model requests per 5 hours. Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.", + note: "No key needed — public desktop tier. OpenCode currently advertises about 200 Big Pickle/free-model requests per 5 hours. The same Zen gateway can also short-window rate-limit free models at roughly 15-20 requests/minute (opaque 429s with no Retry-After). Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.", dashboardUrl: "https://opencode.ai", staticHeaders: { "x-opencode-client": "desktop", diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index b289f7f999..5c228ddc16 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -35,6 +35,7 @@ import { import { isInjectionDebugEnabled } from "../../lib/debug-settings"; import { injectionDebugLog } from "../../lib/injection-debug-log"; import { resolveClientRetryAfter } from "../../lib/retry-after"; +import { enrichOpenCodeZenRateLimitMessage } from "../../providers/opencode-zen-rate-limit"; import { modelInList, namespacedToolName } from "../../types"; import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types"; import { @@ -3154,7 +3155,15 @@ async function handleResponsesInner( } // Upstreams occasionally echo request details in error bodies — scrub token-shaped // material before it reaches the client-facing error surface. - const message = `Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`; + const message = enrichOpenCodeZenRateLimitMessage( + `Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`, + { + status: upstreamResponse.status, + providerName: route.providerName, + baseUrl: route.provider.baseUrl, + adapter: route.provider.adapter, + }, + ); const retryAfter = resolveClientRetryAfter({ status: upstreamResponse.status, message, diff --git a/tests/opencode-zen-rate-limit.test.ts b/tests/opencode-zen-rate-limit.test.ts new file mode 100644 index 0000000000..02d66c22bf --- /dev/null +++ b/tests/opencode-zen-rate-limit.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "bun:test"; +import { PROVIDER_REGISTRY } from "../src/providers/registry"; +import { + OPENCODE_ZEN_OBSERVED_RPM_HINT, + OPENCODE_ZEN_SYNTHETIC_RETRY_AFTER_SEC, + enrichOpenCodeZenRateLimitMessage, + isOpenCodeZenRateLimitProvider, +} from "../src/providers/opencode-zen-rate-limit"; +import { resolveClientRetryAfter } from "../src/lib/retry-after"; +import { safeConfigDTO } from "../src/server/auth-cors"; +import type { OcxConfig } from "../src/types"; + +describe("opencode-zen rate-limit guidance (#1145)", () => { + test("registry note documents the observed short-window RPM", () => { + const entry = PROVIDER_REGISTRY.find(e => e.id === "opencode-zen"); + expect(entry?.note).toBeDefined(); + expect(entry!.note!.toLowerCase()).toContain("15-20"); + expect(entry!.note!.toLowerCase()).toContain("retry-after"); + expect(entry!.note!.toLowerCase()).toContain("opencode-free"); + }); + + test("opencode-free note cross-references the short-window RPM", () => { + const entry = PROVIDER_REGISTRY.find(e => e.id === "opencode-free"); + expect(entry?.note?.toLowerCase()).toContain("200"); + expect(entry?.note?.toLowerCase()).toContain("15-20"); + }); + + test("safeConfigDTO surfaces the registry note for a saved opencode-zen row", () => { + const dto = safeConfigDTO({ + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "opencode-zen", + providers: { + "opencode-zen": { + adapter: "openai-chat", + baseUrl: "https://opencode.ai/zen/v1", + authMode: "key", + apiKey: "zen-key", + }, + }, + } as OcxConfig) as { + providers: Record; + }; + + expect(dto.providers["opencode-zen"].note?.toLowerCase()).toContain("15-20"); + }); + + test("isOpenCodeZenRateLimitProvider matches zen, free, and destination aliases", () => { + expect(isOpenCodeZenRateLimitProvider({ providerName: "opencode-zen" })).toBe(true); + expect(isOpenCodeZenRateLimitProvider({ providerName: "opencode-free" })).toBe(true); + expect(isOpenCodeZenRateLimitProvider({ providerName: "openai" })).toBe(false); + expect(isOpenCodeZenRateLimitProvider({ + baseUrl: "https://opencode.ai/zen/v1", + adapter: "openai-chat", + })).toBe(true); + expect(isOpenCodeZenRateLimitProvider({ + baseUrl: "https://opencode.ai/zen/go/v1", + adapter: "openai-chat", + })).toBe(false); + }); + + test("enrichOpenCodeZenRateLimitMessage adds guidance and a parseable retry hint", () => { + const enriched = enrichOpenCodeZenRateLimitMessage( + "Provider error 429: Rate limit exceeded. Please try again later.", + { status: 429, providerName: "opencode-zen" }, + ); + expect(enriched).toContain(OPENCODE_ZEN_OBSERVED_RPM_HINT); + expect(enriched).toContain(`Try again in ${OPENCODE_ZEN_SYNTHETIC_RETRY_AFTER_SEC}s`); + expect(resolveClientRetryAfter({ + status: 429, + message: enriched, + })).toBe(String(OPENCODE_ZEN_SYNTHETIC_RETRY_AFTER_SEC)); + }); + + test("enrichOpenCodeZenRateLimitMessage is a no-op for other providers and non-429s", () => { + const other = "Provider error 429: Rate limit exceeded. Please try again later."; + expect(enrichOpenCodeZenRateLimitMessage(other, { + status: 429, + providerName: "openrouter", + })).toBe(other); + expect(enrichOpenCodeZenRateLimitMessage("Provider error 500: boom", { + status: 500, + providerName: "opencode-zen", + })).toBe("Provider error 500: boom"); + }); + + test("enrichOpenCodeZenRateLimitMessage does not double-append", () => { + const once = enrichOpenCodeZenRateLimitMessage( + "Provider error 429: Rate limit exceeded.", + { status: 429, providerName: "opencode-zen" }, + ); + expect(enrichOpenCodeZenRateLimitMessage(once, { + status: 429, + providerName: "opencode-zen", + })).toBe(once); + }); +}); From 6d7c26a633be7bd5107f550cc0b6725a8056d76b Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:31:43 +0200 Subject: [PATCH 2/3] fix(docs): state Zen rate-limit header absence as conditional CodeRabbit: Zen may omit Retry-After / X-RateLimit headers on generic 429s; synthetic backoff is only added when upstream omits Retry-After. --- docs-site/src/content/docs/guides/providers.md | 13 +++++++------ docs-site/src/content/docs/ja/guides/providers.md | 2 +- docs-site/src/content/docs/ko/guides/providers.md | 2 +- docs-site/src/content/docs/ru/guides/providers.md | 11 ++++++----- .../src/content/docs/zh-cn/guides/providers.md | 2 +- src/providers/opencode-zen-rate-limit.ts | 11 ++++++----- src/providers/registry.ts | 4 ++-- tests/opencode-zen-rate-limit.test.ts | 1 + 8 files changed, 25 insertions(+), 21 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 2f0416e592..07494f6a0e 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -278,12 +278,13 @@ free-experimentation model. **OpenCode Zen** (`opencode-zen`) and the keyless **OpenCode Free** preset share `https://opencode.ai/zen/v1`. Free models on that gateway often hit a short-window burst -limit around 15–20 requests/minute (community-measured; OpenCode does not publish RPM or -`Retry-After` / `X-RateLimit-*` headers). That is separate from the keyless desktop quota -OpenCode advertises (~200 Big Pickle/free-model requests per 5 hours on `opencode-free`). -When Zen returns a generic rate-limit 429 without backoff headers, opencodex adds provider -guidance to the client error and a synthetic `Retry-After` so Codex-shaped clients can wait. -Same-key wait-and-retry remains opt-in via [`retryOn429`](/reference/configuration/). +limit around 15–20 requests/minute (community-measured; OpenCode does not publish RPM). +Zen may return generic rate-limit 429 responses without `Retry-After` / `X-RateLimit-*` +headers. That is separate from the keyless desktop quota OpenCode advertises +(~200 Big Pickle/free-model requests per 5 hours on `opencode-free`). When Zen omits +`Retry-After` on such a 429, opencodex adds provider guidance to the client error and a +synthetic `Retry-After`; an upstream `Retry-After` still takes precedence. Same-key +wait-and-retry remains opt-in via [`retryOn429`](/reference/configuration/). Most use the `openai-chat` adapter with a bearer key; a few that expose only an Anthropic-compatible endpoint (e.g. **Xiaomi MiMo**) use the `anthropic` adapter (`x-api-key`). diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index f4c4aca2d9..7608ccbda7 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -204,7 +204,7 @@ Cline IDE/CLI のみで API からは使えません。`minimax/minimax-m2.5` | …その他多数 | opencode zen、Vercel AI Gateway、Venice、NanoGPT、Synthetic、Qianfan、Alibaba、Parallel、ZenMux、LiteLLM | **OpenCode Zen**(`opencode-zen`)とキー不要の **OpenCode Free** プリセットは -`https://opencode.ai/zen/v1` を共有します。このゲートウェイ上の無料モデルは、しばしばおおよそ毎分 15–20 リクエストの短時間レート制限に当たります(コミュニティ計測。OpenCode は RPM を公表せず、`Retry-After` / `X-RateLimit-*` ヘッダーも返しません)。これはキー不要デスクトップ枠(`opencode-free` で Big Pickle/無料モデル約 200 回 / 5 時間)とは別です。Zen がバックオフヘッダーなしの汎用 429 を返した場合、opencodex はクライアント向けエラーに案内を足し、合成 `Retry-After` を付けます。同一キーの待機再試行は [`retryOn429`](/ja/reference/configuration/) でオプトインします。 +`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/) でオプトインします。 大半は bearer キーと共に `openai-chat` アダプターを使い、Anthropic 互換エンドポイントのみを公開する一部 (例: **Xiaomi MiMo**)は `anthropic` アダプター(`x-api-key`)を使います。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index febc6cc252..de7c575bed 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -204,7 +204,7 @@ Cline IDE/CLI에서만 제공되며 API로는 사용할 수 없습니다. `minim | …그 외 다수 | opencode zen, Vercel AI Gateway, Venice, NanoGPT, Synthetic, Qianfan, Alibaba, Parallel, ZenMux, LiteLLM | **OpenCode Zen**(`opencode-zen`)과 키 없는 **OpenCode Free** 프리셋은 -`https://opencode.ai/zen/v1`을 공유합니다. 그 게이트웨이의 무료 모델은 종종 분당 약 15–20회 요청의 짧은 창 속도 제한에 걸립니다(커뮤니티 측정; OpenCode는 RPM을 공개하지 않으며 `Retry-After` / `X-RateLimit-*` 헤더도 보내지 않음). 이는 키 없는 데스크톱 할당량(`opencode-free`에서 약 5시간당 Big Pickle/무료 모델 200회)과 별개입니다. Zen이 백오프 헤더 없는 일반 429를 반환하면 opencodex는 클라이언트 오류에 안내를 더하고 합성 `Retry-After`를 붙입니다. 동일 키 대기 재시도는 [`retryOn429`](/ko/reference/configuration/)로 선택합니다. +`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/)로 선택합니다. 대부분은 bearer 키와 함께 `openai-chat` 어댑터를 사용하며, Anthropic 호환 엔드포인트만 노출하는 일부 (예: **Xiaomi MiMo**)는 `anthropic` 어댑터(`x-api-key`)를 사용합니다. diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index cf2a2bc0c3..6ab93356ed 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -216,11 +216,12 @@ opencodex поставляется с 76 встроенными пресетам **OpenCode Zen** (`opencode-zen`) и бесключевой пресет **OpenCode Free** используют один `https://opencode.ai/zen/v1`. Бесплатные модели на этом шлюзе часто упираются в короткое окно -примерно 15–20 запросов в минуту (оценка сообщества; OpenCode не публикует RPM и не отдаёт -`Retry-After` / `X-RateLimit-*`). Это отдельно от бесключевой десктопной квоты -(~200 запросов Big Pickle/бесплатных моделей за 5 часов на `opencode-free`). Когда Zen отвечает -общим 429 без заголовков отката, opencodex добавляет пояснение в ошибку клиента и синтетический -`Retry-After`. Повтор с тем же ключом по-прежнему включается через [`retryOn429`](/ru/reference/configuration/). +примерно 15–20 запросов в минуту (оценка сообщества; OpenCode не публикует RPM). +Zen может отвечать общими 429 без заголовков `Retry-After` / `X-RateLimit-*`. Это отдельно от +бесключевой десктопной квоты (~200 запросов Big Pickle/бесплатных моделей за 5 часов на +`opencode-free`). Когда Zen опускает `Retry-After` на таком 429, opencodex добавляет пояснение +в ошибку клиента и синтетический `Retry-After`; при наличии upstream `Retry-After` он имеет +приоритет. Повтор с тем же ключом по-прежнему включается через [`retryOn429`](/ru/reference/configuration/). Большинство использует адаптер `openai-chat` с bearer-ключом; немногие провайдеры, предоставляющие только Anthropic-совместимую конечную точку (например, **Xiaomi MiMo**), используют адаптер 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 518dfb1803..e51280066c 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -192,7 +192,7 @@ Cline IDE/CLI 中提供,不能通过 API 使用;`minimax/minimax-m2.5` 是 | ……以及更多 | opencode zen、Vercel AI Gateway、Venice、NanoGPT、Synthetic、Qianfan、Alibaba、Parallel、ZenMux、LiteLLM | **OpenCode Zen**(`opencode-zen`)与免密钥的 **OpenCode Free** 预设共用 -`https://opencode.ai/zen/v1`。该网关上的免费模型常会触发约每分钟 15–20 次请求的短窗口限流(社区观测;OpenCode 未公布 RPM,也不返回 `Retry-After` / `X-RateLimit-*`)。这与免密钥桌面配额(`opencode-free` 上约每 5 小时 200 次 Big Pickle/免费模型请求)是分开的。当 Zen 返回无退避头的通用 429 时,opencodex 会在客户端错误中补充说明并附带合成的 `Retry-After`。同密钥等待重试仍可通过 [`retryOn429`](/zh-cn/reference/configuration/) 选择开启。 +`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/) 选择开启。 大多数使用带 bearer 密钥的 `openai-chat` adapter;少数仅暴露 Anthropic 兼容端点的提供商(例如 **Xiaomi MiMo**)使用 `anthropic` adapter(`x-api-key`)。 火山方舟 Agent Plan 通过 `openai-responses` adapter 使用原生 Responses 端点。 diff --git a/src/providers/opencode-zen-rate-limit.ts b/src/providers/opencode-zen-rate-limit.ts index 136fb327b1..bcd5513e0d 100644 --- a/src/providers/opencode-zen-rate-limit.ts +++ b/src/providers/opencode-zen-rate-limit.ts @@ -1,10 +1,11 @@ /** * OpenCode Zen short-window rate-limit guidance (#1145 / OCX-56). * - * OpenCode's keyed and keyless Zen chat endpoints share `https://opencode.ai/zen/v1` - * and return opaque `429 Rate limit exceeded` bodies without `Retry-After` or - * `X-RateLimit-*` headers. Community request logs show a burst ceiling around - * 15–20 requests/minute on free models — distinct from the keyless desktop + * OpenCode's keyed and keyless Zen chat endpoints share `https://opencode.ai/zen/v1`. + * Free-model traffic can hit a short-window burst ceiling around 15–20 RPM + * (community-measured). Zen often answers with opaque `429 Rate limit exceeded` + * bodies and may omit `Retry-After` / `X-RateLimit-*`; when those headers are + * present they still take precedence. Distinct from the keyless desktop * ~200 requests / 5h quota documented on `opencode-free`. */ import { registryEntryForProviderDestination } from "./registry"; @@ -65,7 +66,7 @@ export function enrichOpenCodeZenRateLimitMessage( return ( `${message}` + ` OpenCode Zen free-model traffic is often limited to ${OPENCODE_ZEN_OBSERVED_RPM_HINT}` - + ` (observed; OpenCode does not publish this RPM or rate-limit headers).` + + ` (observed; OpenCode does not publish this RPM, and may omit rate-limit headers).` + `${retryHint}` + " Slow the request pace, or set providers.opencode-zen.retryOn429 for same-key backoff." ); diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 056216bae3..5856b6edf3 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2037,7 +2037,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // continuations, or the gateway answers HTTP 400 (issues #950/#994). Mirror the DeepSeek // reasoning + thinking metadata so `opencode-zen/deepseek-v4-flash-free` — and the other // Zen DeepSeek thinking models — never serialize a bare tool-call turn. - note: "Keyed OpenCode Zen gateway. Free models on this tier are often short-window rate-limited at roughly 15-20 requests/minute (community-measured from opaque 429s; OpenCode does not publish RPM or Retry-After / X-RateLimit headers). Distinct from the keyless opencode-free desktop quota (~200 Big Pickle/free-model requests per 5 hours). Docs: https://opencode.ai/docs/zen/. Free-model prompts may be retained for training — do not send confidential material.", + note: "Keyed OpenCode Zen gateway. Free models on this tier are often short-window rate-limited at roughly 15-20 requests/minute (community-measured; OpenCode does not publish RPM). Zen may return generic 429s without Retry-After / X-RateLimit headers; when Retry-After is omitted, opencodex adds a synthetic backoff hint (upstream Retry-After still wins). Distinct from the keyless opencode-free desktop quota (~200 Big Pickle/free-model requests per 5 hours). Docs: https://opencode.ai/docs/zen/. Free-model prompts may be retained for training — do not send confidential material.", modelReasoningEfforts: Object.fromEntries( [...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS].map(id => [id, deepseekThinkingEffortsFor(id)]), ), @@ -2057,7 +2057,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ keyOptional: true, featured: true, liveModels: true, - note: "No key needed — public desktop tier. OpenCode currently advertises about 200 Big Pickle/free-model requests per 5 hours. The same Zen gateway can also short-window rate-limit free models at roughly 15-20 requests/minute (opaque 429s with no Retry-After). Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.", + note: "No key needed — public desktop tier. OpenCode currently advertises about 200 Big Pickle/free-model requests per 5 hours. The same Zen gateway can also short-window rate-limit free models at roughly 15-20 requests/minute, and may return generic 429s without Retry-After (opencodex synthesizes backoff only when that header is omitted). Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.", dashboardUrl: "https://opencode.ai", staticHeaders: { "x-opencode-client": "desktop", diff --git a/tests/opencode-zen-rate-limit.test.ts b/tests/opencode-zen-rate-limit.test.ts index 02d66c22bf..826ba9b36f 100644 --- a/tests/opencode-zen-rate-limit.test.ts +++ b/tests/opencode-zen-rate-limit.test.ts @@ -16,6 +16,7 @@ describe("opencode-zen rate-limit guidance (#1145)", () => { expect(entry?.note).toBeDefined(); expect(entry!.note!.toLowerCase()).toContain("15-20"); expect(entry!.note!.toLowerCase()).toContain("retry-after"); + expect(entry!.note!.toLowerCase()).toMatch(/may return|when retry-after is omitted/); expect(entry!.note!.toLowerCase()).toContain("opencode-free"); }); From 15850a37349f3b5779e35f720353ab01306292d1 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:50:23 +0200 Subject: [PATCH 3/3] fix(providers): honor upstream Retry-After and scope retryOn429 tip Skip the synthetic 15s message when Zen already sent Retry-After, and only suggest retryOn429 on key-authenticated HTTP routes. --- src/providers/opencode-zen-rate-limit.ts | 33 +++++++++++++++-- src/server/responses/core.ts | 9 ++++- tests/opencode-zen-rate-limit.test.ts | 45 ++++++++++++++++++++++-- 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/providers/opencode-zen-rate-limit.ts b/src/providers/opencode-zen-rate-limit.ts index bcd5513e0d..2dda860812 100644 --- a/src/providers/opencode-zen-rate-limit.ts +++ b/src/providers/opencode-zen-rate-limit.ts @@ -8,6 +8,7 @@ * present they still take precedence. Distinct from the keyless desktop * ~200 requests / 5h quota documented on `opencode-free`. */ +import { validateClientRetryAfterHeader } from "../lib/retry-after"; import { registryEntryForProviderDestination } from "./registry"; const OPENCODE_ZEN_PROVIDER_IDS = new Set(["opencode-zen", "opencode-free"]); @@ -41,6 +42,20 @@ export function isOpenCodeZenRateLimitProvider(opts: { return entry !== undefined && OPENCODE_ZEN_PROVIDER_IDS.has(entry.id); } +/** + * Same-key `retryOn429` only applies on key-authenticated HTTP paths — not + * keyless `opencode-free` traffic and not custom `runTurn` transports. + */ +export function supportsOpenCodeZenRetryOn429Guidance(opts: { + authMode?: string; + hasApiKey?: boolean; + supportsHttpSameKeyRetry?: boolean; +}): boolean { + if (opts.supportsHttpSameKeyRetry === false) return false; + if (opts.authMode !== undefined && opts.authMode !== "key") return false; + return opts.hasApiKey === true; +} + /** * Append actionable Zen rate-limit context to a generic upstream 429 message and * embed a parseable `try again in Ns` hint so {@link resolveClientRetryAfter} @@ -53,6 +68,13 @@ export function enrichOpenCodeZenRateLimitMessage( providerName?: string; baseUrl?: string; adapter?: string; + authMode?: string; + hasApiKey?: boolean; + /** Upstream Retry-After header; when valid, skip the synthetic 15s text hint. */ + upstreamRetryAfter?: string | null; + /** False for custom `runTurn` transports outside the HTTP retry loop. */ + supportsHttpSameKeyRetry?: boolean; + now?: number; }, ): string { if (opts.status !== 429) return message; @@ -60,14 +82,21 @@ export function enrichOpenCodeZenRateLimitMessage( if (!/rate\s*limit/i.test(message)) return message; if (message.includes(ENRICHMENT_MARKER)) return message; - const retryHint = /try again in \d/i.test(message) + const upstreamRetry = validateClientRetryAfterHeader( + opts.upstreamRetryAfter, + opts.now ?? Date.now(), + ); + const retryHint = upstreamRetry || /try again in \d/i.test(message) ? "" : ` Try again in ${OPENCODE_ZEN_SYNTHETIC_RETRY_AFTER_SEC}s.`; + const paceHint = supportsOpenCodeZenRetryOn429Guidance(opts) + ? " Slow the request pace, or set providers.opencode-zen.retryOn429 for same-key backoff." + : " Slow the request pace."; return ( `${message}` + ` OpenCode Zen free-model traffic is often limited to ${OPENCODE_ZEN_OBSERVED_RPM_HINT}` + ` (observed; OpenCode does not publish this RPM, and may omit rate-limit headers).` + `${retryHint}` - + " Slow the request pace, or set providers.opencode-zen.retryOn429 for same-key backoff." + + paceHint ); } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 5c228ddc16..bdbf99f20b 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -3155,6 +3155,7 @@ async function handleResponsesInner( } // Upstreams occasionally echo request details in error bodies — scrub token-shaped // material before it reaches the client-facing error surface. + const upstreamRetryAfter = upstreamResponse.headers.get("retry-after"); const message = enrichOpenCodeZenRateLimitMessage( `Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`, { @@ -3162,12 +3163,18 @@ async function handleResponsesInner( providerName: route.providerName, baseUrl: route.provider.baseUrl, adapter: route.provider.adapter, + authMode: route.provider.authMode, + hasApiKey: Boolean(route.provider.apiKey?.trim()), + upstreamRetryAfter, + // This recovery path is the HTTP Responses wire; custom runTurn transports + // never reach enrichOpenCodeZenRateLimitMessage here. + supportsHttpSameKeyRetry: true, }, ); const retryAfter = resolveClientRetryAfter({ status: upstreamResponse.status, message, - upstreamRetryAfter: upstreamResponse.headers.get("retry-after"), + upstreamRetryAfter, }); return formatErrorResponse(upstreamResponse.status, "upstream_error", message, { ...(retryAfter !== undefined ? { retryAfter } : {}), diff --git a/tests/opencode-zen-rate-limit.test.ts b/tests/opencode-zen-rate-limit.test.ts index 826ba9b36f..ae56694c56 100644 --- a/tests/opencode-zen-rate-limit.test.ts +++ b/tests/opencode-zen-rate-limit.test.ts @@ -63,16 +63,56 @@ describe("opencode-zen rate-limit guidance (#1145)", () => { test("enrichOpenCodeZenRateLimitMessage adds guidance and a parseable retry hint", () => { const enriched = enrichOpenCodeZenRateLimitMessage( "Provider error 429: Rate limit exceeded. Please try again later.", - { status: 429, providerName: "opencode-zen" }, + { status: 429, providerName: "opencode-zen", hasApiKey: true }, ); expect(enriched).toContain(OPENCODE_ZEN_OBSERVED_RPM_HINT); expect(enriched).toContain(`Try again in ${OPENCODE_ZEN_SYNTHETIC_RETRY_AFTER_SEC}s`); + expect(enriched).toContain("retryOn429"); expect(resolveClientRetryAfter({ status: 429, message: enriched, })).toBe(String(OPENCODE_ZEN_SYNTHETIC_RETRY_AFTER_SEC)); }); + test("enrichOpenCodeZenRateLimitMessage skips synthetic hint when upstream Retry-After is set", () => { + const enriched = enrichOpenCodeZenRateLimitMessage( + "Provider error 429: Rate limit exceeded.", + { + status: 429, + providerName: "opencode-zen", + hasApiKey: true, + upstreamRetryAfter: "120", + }, + ); + expect(enriched).toContain(OPENCODE_ZEN_OBSERVED_RPM_HINT); + expect(enriched).not.toContain(`Try again in ${OPENCODE_ZEN_SYNTHETIC_RETRY_AFTER_SEC}s`); + expect(resolveClientRetryAfter({ + status: 429, + message: enriched, + upstreamRetryAfter: "120", + })).toBe("120"); + }); + + test("enrichOpenCodeZenRateLimitMessage omits retryOn429 tip on keyless or non-HTTP routes", () => { + const keyless = enrichOpenCodeZenRateLimitMessage( + "Provider error 429: Rate limit exceeded.", + { status: 429, providerName: "opencode-free", hasApiKey: false }, + ); + expect(keyless).toContain("Slow the request pace."); + expect(keyless).not.toContain("retryOn429"); + + const runTurn = enrichOpenCodeZenRateLimitMessage( + "Provider error 429: Rate limit exceeded.", + { + status: 429, + providerName: "opencode-zen", + hasApiKey: true, + supportsHttpSameKeyRetry: false, + }, + ); + expect(runTurn).not.toContain("retryOn429"); + }); + test("enrichOpenCodeZenRateLimitMessage is a no-op for other providers and non-429s", () => { const other = "Provider error 429: Rate limit exceeded. Please try again later."; expect(enrichOpenCodeZenRateLimitMessage(other, { @@ -88,11 +128,12 @@ describe("opencode-zen rate-limit guidance (#1145)", () => { test("enrichOpenCodeZenRateLimitMessage does not double-append", () => { const once = enrichOpenCodeZenRateLimitMessage( "Provider error 429: Rate limit exceeded.", - { status: 429, providerName: "opencode-zen" }, + { status: 429, providerName: "opencode-zen", hasApiKey: true }, ); expect(enrichOpenCodeZenRateLimitMessage(once, { status: 429, providerName: "opencode-zen", + hasApiKey: true, })).toBe(once); }); });