diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 6ed7553957..0b36b95a92 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -335,6 +335,21 @@ 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/). +**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 +l'en-tête : un proxy pourrait donc le franchir en inventant une valeur, ce que opencodex ne fait pas. +Fabriquer un identifiant de session et un User-Agent versionné `opencode/` revient à se déclarer +client OpenCode, alors qu'OpenCode ne publie aucun contrat d'intégration tierce pour ce niveau sans clé ; +une réponse HTTP 200 obtenue ainsi est un contrôle d'admission contourné, pas une autorisation. opencodex +signale donc la restriction au lieu de la contourner : une requête vers `opencode-free` renvoie une erreur +qui explique le blocage en amont. + +La voie prise en charge vers les mêmes modèles est le fournisseur **`opencode-zen`** avec une clé d'API +OpenCode Zen obtenue sur [opencode.ai/auth](https://opencode.ai/auth). Si OpenCode publie plus tard un accès +tiers pour le niveau sans clé, opencodex pourra le suivre ; d'ici là, le préréglage sert à documenter la +restriction. Conditions en amont : [opencode.ai/docs/zen](https://opencode.ai/docs/zen/). + La plupart utilisent l'adaptateur `openai-chat` avec une clé Bearer ; quelques fournisseurs qui n'exposent qu'un point de terminaison compatible Anthropic, comme **Xiaomi MiMo**, emploient l'adaptateur `anthropic` (`x-api-key`). Volcengine Agent Plan utilise son point de terminaison Responses natif par `openai-responses`. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 277e9d1370..d519c4f3dd 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -432,6 +432,22 @@ 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/). +**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 +`MissingSessionID` and the message "OpenCode's free tier can only be used in OpenCode". Presence +of the header is the entire gate, so a proxy could pass it by inventing a value — opencodex does +not. Minting a session identifier and a versioned `opencode/` User-Agent is a claim to +*be* the OpenCode client, and OpenCode publishes no third-party integration contract for this +keyless tier; an HTTP 200 obtained that way is a bypassed admission check rather than +permission. opencodex therefore reports the restriction instead of working around it: a request +to `opencode-free` returns an error explaining the upstream gate and pointing here. + +The supported route to the same models is the keyed **`opencode-zen`** provider with an OpenCode +Zen API key from [opencode.ai/auth](https://opencode.ai/auth). If OpenCode later publishes a +supported third-party path for the keyless tier, opencodex can follow it; until then the preset +stays as documentation of the restriction. Upstream terms: +[opencode.ai/docs/zen](https://opencode.ai/docs/zen/). + 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 db0bdb87b2..346c3552fa 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -231,6 +231,10 @@ Cline IDE/CLI のみで API からは使えません。`minimax/minimax-m2.5` **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-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/)。 + 大半は 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 268e6e0cf1..20b5b4fe77 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -231,6 +231,10 @@ Cline IDE/CLI에서만 제공되며 API로는 사용할 수 없습니다. `minim **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-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/). + 대부분은 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 80f00c0d63..39e0cb4dd3 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -250,6 +250,20 @@ Zen может отвечать общими 429 без заголовков `Re в ошибку клиента и синтетический `Retry-After`; при наличии upstream `Retry-After` он имеет приоритет. Повтор с тем же ключом по-прежнему включается через [`retryOn429`](/ru/reference/configuration/). +**Бесключевой уровень `opencode-free` сейчас закрыт для сторонних клиентов.** Zen отклоняет любой +запрос без заголовка `x-opencode-session`, возвращая тип ошибки `MissingSessionID` и сообщение +«OpenCode's free tier can only be used in OpenCode». Проверяется только наличие заголовка, поэтому +прокси мог бы пройти её, выдумав значение, — opencodex так не делает. Подделать идентификатор сессии +и версионный User-Agent `opencode/` значит объявить себя клиентом OpenCode, а OpenCode не +публикует договор о стороннем подключении к этому бесключевому уровню; полученный так HTTP 200 — +это обойдённая проверка допуска, а не разрешение. Поэтому opencodex сообщает об ограничении вместо +обхода: запрос к `opencode-free` возвращает ошибку с объяснением. + +Поддерживаемый путь к тем же моделям — провайдер **`opencode-zen`** с ключом OpenCode Zen API, +полученным на [opencode.ai/auth](https://opencode.ai/auth). Если OpenCode позже опубликует сторонний +путь для бесключевого уровня, opencodex сможет его использовать; до тех пор пресет документирует +ограничение. Условия вышестоящего сервиса: [opencode.ai/docs/zen](https://opencode.ai/docs/zen/). + Большинство использует адаптер `openai-chat` с bearer-ключом; немногие провайдеры, предоставляющие только Anthropic-совместимую конечную точку (например, **Xiaomi MiMo**), используют адаптер `anthropic` (`x-api-key`). diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 5943758e5a..fde08cd29e 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -380,6 +380,23 @@ 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. +**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 +yalnızca başlığın varlığı denetlenir; yani bir proxy uydurma bir değerle geçebilirdi, +opencodex bunu yapmaz. Bir oturum kimliği ile sürüm taşıyan `opencode/` +User-Agent üretmek, kendini OpenCode istemcisi ilan etmek demektir ve OpenCode bu +anahtarsız katman için üçüncü taraf entegrasyon sözleşmesi yayımlamamıştır; böyle elde +edilen bir HTTP 200, izin değil atlatılmış bir kabul denetimidir. Bu yüzden opencodex +kısıtlamayı aşmak yerine bildirir: `opencode-free` sağlayıcısına giden bir istek, yukarı +akıştaki kapıyı açıklayan bir hata döndürür. + +Aynı modellere giden desteklenen yol, [opencode.ai/auth](https://opencode.ai/auth) +üzerinden alınan bir OpenCode Zen API anahtarıyla kullanılan anahtarlı +**`opencode-zen`** sağlayıcısıdır. OpenCode ileride anahtarsız katman için desteklenen +bir üçüncü taraf yolu yayımlarsa opencodex bunu izleyebilir; o zamana kadar önayar +kısıtlamayı belgeler. Yukarı akış koşulları: [opencode.ai/docs/zen](https://opencode.ai/docs/zen/). + Çoğu bir taşıyıcı anahtarla `openai-chat` adaptörünü kullanır; yalnızca Anthropic uyumlu bir uç nokta sunan birkaç tanesi (örneğin **Xiaomi MiMo**) `anthropic` adaptörünü (`x-api-key`) kullanır. Volcengine Agent Plan, 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 fdaad97fb9..7f1e9f1205 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -223,6 +223,10 @@ Cline IDE/CLI 中提供,不能通过 API 使用;`minimax/minimax-m2.5` 是 **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-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/)。 + 大多数使用带 bearer 密钥的 `openai-chat` adapter;少数仅暴露 Anthropic 兼容端点的提供商(例如 **Xiaomi MiMo**)使用 `anthropic` adapter(`x-api-key`)。 火山方舟 Agent Plan 通过 `openai-responses` adapter 使用原生 Responses 端点。 内置 DeepSeek preset 同样会让 `deepseek-v4-flash` 使用原生 Responses 端点,并保留上游 SSE 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 a1b4483cf3..1b189cd91b 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -293,6 +293,18 @@ IDE/CLI,不透過 API;`minimax/minimax-m2.5` 是文件列出的 API 免費 error 加入 provider guidance 與 synthetic `Retry-After`;若上游有 `Retry-After`,仍以上游值為準。 same-key wait-and-retry 仍需透過 [`retryOn429`](/zh-tw/reference/configuration/) 明確 opt-in。 +**無 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 不這麼做。 +偽造 session identifier 並附上帶版本號的 `opencode/` User-Agent,等於宣稱自己就是 OpenCode +client,而 OpenCode 並未公布這個 keyless tier 的第三方整合合約;用這種方式取得的 HTTP 200 是繞過 +admission check,而不是取得授權。因此 opencodex 選擇如實回報限制:送往 `opencode-free` 的 request 會 +收到一則說明上游 gate 的 error。 + +通往同一批模型的受支援路徑,是使用 [opencode.ai/auth](https://opencode.ai/auth) 取得的 OpenCode Zen API +key,走帶 key 的 **`opencode-zen`** preset。若 OpenCode 日後公布 keyless tier 的第三方路徑,opencodex 可以 +跟進;在此之前,這個 preset 的作用是記錄該限制。上游條款:[opencode.ai/docs/zen](https://opencode.ai/docs/zen/)。 + 大多數 provider 使用帶 bearer key 的 `openai-chat` adapter;少數只提供 Anthropic-compatible endpoint 的 provider,例如 **Xiaomi MiMo**,使用 `anthropic` adapter(`x-api-key`)。Volcengine Agent Plan 透過 `openai-responses` 使用原生 Responses endpoint。內建 DeepSeek preset 也會把 `deepseek-v4-flash` 路由到 diff --git a/src/providers/opencode-zen-rate-limit.ts b/src/providers/opencode-zen-rate-limit.ts index 2dda860812..c4dbb10319 100644 --- a/src/providers/opencode-zen-rate-limit.ts +++ b/src/providers/opencode-zen-rate-limit.ts @@ -7,6 +7,11 @@ * 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`. + * + * The same module also owns the keyless free-tier admission explanation (#4121): + * Zen rejects a request that carries no `x-opencode-session` header with + * `MissingSessionID` / "OpenCode's free tier can only be used in OpenCode". + * opencodex does not synthesize that header — see {@link enrichOpenCodeZenFreeTierMessage}. */ import { validateClientRetryAfterHeader } from "../lib/retry-after"; import { registryEntryForProviderDestination } from "./registry"; @@ -100,3 +105,73 @@ export function enrichOpenCodeZenRateLimitMessage( + paceHint ); } + +/** + * Zen's keyless free tier admits only OpenCode's own client. A request without an + * `x-opencode-session` header is refused with error type `MissingSessionID` and the + * message "OpenCode's free tier can only be used in OpenCode" (#4121). + * + * Presence of the header is the whole gate — any value clears it — so opencodex could + * pass by minting one. It does not. Fabricating a session identifier and a versioned + * `opencode/` User-Agent is a claim to *be* the OpenCode client, and no upstream + * contract authorizes a third-party agent to make it; an HTTP 200 obtained that way is a + * bypassed admission check, not permission. Until OpenCode publishes a third-party + * integration path for this exact keyless tier, the supported route is the keyed + * `opencode-zen` provider. + * + * Two markers are matched because the two request surfaces expose different parts of the + * upstream envelope: the Responses path forwards the bounded raw body (which carries the + * `MissingSessionID` type), while the native Chat path forwards only the parsed message. + */ +const OPENCODE_ZEN_FREE_TIER_LOCK_IN = /MissingSessionID|free tier can only be used in OpenCode/i; + +/** Idempotence marker — the appended guidance must not stack across enrichment layers. */ +const FREE_TIER_ENRICHMENT_MARKER = "does not send a fabricated OpenCode session header"; + +/** True when an upstream error body is Zen's keyless free-tier admission refusal. */ +export function isOpenCodeZenFreeTierLockIn(message: string, upstreamErrorType?: string | null): boolean { + if (upstreamErrorType && OPENCODE_ZEN_FREE_TIER_LOCK_IN.test(upstreamErrorType)) return true; + return OPENCODE_ZEN_FREE_TIER_LOCK_IN.test(message); +} + +/** + * Replace a raw `MissingSessionID` passthrough with an explanation of the upstream + * restriction and the supported alternative. No-op for every other provider and every + * other error, and idempotent so layered enrichment cannot append it twice. + */ +export function enrichOpenCodeZenFreeTierMessage( + message: string, + opts: { + providerName?: string; + baseUrl?: string; + adapter?: string; + /** Upstream `error.type`, when the caller parsed one out of the envelope. */ + upstreamErrorType?: string | null; + }, +): string { + if (message.includes(FREE_TIER_ENRICHMENT_MARKER)) return message; + if (!isOpenCodeZenFreeTierLockIn(message, opts.upstreamErrorType)) return message; + if (!isOpenCodeZenRateLimitProvider(opts)) return message; + return ( + `${message}` + + " OpenCode Zen's keyless free tier admits only OpenCode's own client: it refuses any" + + " request that arrives without an x-opencode-session header." + + ` opencodex ${FREE_TIER_ENRICHMENT_MARKER}, because presenting itself as the OpenCode` + + " client is a claim no upstream contract supports." + + " Use the keyed opencode-zen provider with an OpenCode Zen API key" + + " (https://opencode.ai/auth), or route this model through another provider." + + " Upstream terms: https://opencode.ai/docs/zen/." + ); +} + +/** + * Single entry point for Zen upstream-error guidance on the Responses wire: 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. + */ +export function enrichOpenCodeZenUpstreamMessage( + message: string, + opts: Parameters[1] & { upstreamErrorType?: string | null }, +): string { + return enrichOpenCodeZenFreeTierMessage(enrichOpenCodeZenRateLimitMessage(message, opts), opts); +} diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 9f03b6e11e..d4c6c542d0 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -3018,7 +3018,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, 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.", + note: "No key needed, but OpenCode now gates this tier to its own client: Zen refuses any request that arrives without an x-opencode-session header (error type MissingSessionID, \"OpenCode's free tier can only be used in OpenCode\"). opencodex does not mint that header or claim an OpenCode client identity, because no upstream contract authorizes a third-party agent to present itself as OpenCode. Until OpenCode publishes a third-party integration path for the keyless tier, use the keyed opencode-zen provider instead (https://opencode.ai/auth). Quota figures for when the tier admitted a request: OpenCode advertises about 200 Big Pickle/free-model requests per 5 hours, and the same Zen gateway can 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: { // Zen answers a bare runtime User-Agent (Bun/x.y.z) more aggressively than a client diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index 9abb99683e..d08582249e 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -40,6 +40,7 @@ import { } from "../providers/key-failover"; import { fastPolicyForModel } from "../providers/service-tier"; import { providerApiKeySelectionIsCurrent, resolveCurrentProviderApiKeyTransport } from "../providers/api-key-selection"; +import { enrichOpenCodeZenFreeTierMessage } from "../providers/opencode-zen-rate-limit"; import type { OcxProviderTransport } from "../providers/xai-transport"; import type { RouteResult } from "../router"; import type { OcxConfig, OcxProviderConfig } from "../types"; @@ -438,12 +439,20 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio && (isCyberPolicyCode(upstreamCode) || isCyberPolicyMessage(upstreamMessage)) ? upstreamMessage : detail ? `Provider error ${response.status}: ${detail}` : `Provider error ${response.status}`; + // Zen's keyless free tier refuses the request outright rather than rate-limiting it, and + // the raw `MissingSessionID` tells a user nothing about why or what to do (#4121). + const clientMessage = enrichOpenCodeZenFreeTierMessage(message, { + providerName: route.providerName, + baseUrl: route.provider.baseUrl, + adapter: route.provider.adapter, + upstreamErrorType: upstreamType, + }); const classified = classifyError( response.status, upstreamType ?? (response.status === 401 ? "authentication_error" : response.status === 429 ? "rate_limit_error" : response.status >= 500 ? "server_error" : "invalid_request_error"), - message, + clientMessage, ); if (isCyberPolicyCode(upstreamCode) || classified.code === CYBER_POLICY_ERROR_CODE) { classified.code = CYBER_POLICY_ERROR_CODE; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 73be9a86dd..986d76f5dd 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -102,7 +102,7 @@ import { } from "../../lib/errors"; import { injectionDebugLog } from "../../lib/injection-debug-log"; import { resolveClientRetryAfter } from "../../lib/retry-after"; -import { enrichOpenCodeZenRateLimitMessage } from "../../providers/opencode-zen-rate-limit"; +import { enrichOpenCodeZenUpstreamMessage } from "../../providers/opencode-zen-rate-limit"; import { CODE_MODE_EXEC_TOOL_NAME, modelInList, namespacedToolName } from "../../types"; import type { AdapterEvent, @@ -7489,7 +7489,7 @@ async function handleResponsesInner( const message = normalized.cyberPolicy ? normalized.message ?? (isCyberPolicyCode(normalized.code) ? CYBER_POLICY_FALLBACK_MESSAGE : normalized.safeText) - : enrichOpenCodeZenRateLimitMessage( + : enrichOpenCodeZenUpstreamMessage( `Provider error ${upstreamResponse.status}: ${normalized.safeText}`, { status: upstreamResponse.status, @@ -7500,7 +7500,7 @@ async function handleResponsesInner( hasApiKey: Boolean(route.provider.apiKey?.trim()), upstreamRetryAfter, // This recovery path is the HTTP Responses wire; custom runTurn transports - // never reach enrichOpenCodeZenRateLimitMessage here. + // never reach enrichOpenCodeZenUpstreamMessage here. supportsHttpSameKeyRetry: true, }, ); diff --git a/tests/providers/opencode-zen-rate-limit.test.ts b/tests/providers/opencode-zen-rate-limit.test.ts index 5678a35b77..8751082c2a 100644 --- a/tests/providers/opencode-zen-rate-limit.test.ts +++ b/tests/providers/opencode-zen-rate-limit.test.ts @@ -3,7 +3,10 @@ import { PROVIDER_REGISTRY } from "../../src/providers/registry"; import { OPENCODE_ZEN_OBSERVED_RPM_HINT, OPENCODE_ZEN_SYNTHETIC_RETRY_AFTER_SEC, + enrichOpenCodeZenFreeTierMessage, enrichOpenCodeZenRateLimitMessage, + enrichOpenCodeZenUpstreamMessage, + isOpenCodeZenFreeTierLockIn, isOpenCodeZenRateLimitProvider, } from "../../src/providers/opencode-zen-rate-limit"; import { resolveClientRetryAfter } from "../../src/lib/retry-after"; @@ -137,3 +140,93 @@ describe("opencode-zen rate-limit guidance (#1145)", () => { })).toBe(once); }); }); + +/** + * Zen closed the keyless tier to non-OpenCode clients. The gate is the mere presence of + * `x-opencode-session`, so opencodex could pass it by inventing a value; it does not, and the + * user-facing failure has to say that rather than leaking `MissingSessionID` through. + */ +describe("opencode-free keyless tier lock-in (#4121)", () => { + /** Verbatim upstream body from the issue, as the Responses wire forwards it. */ + const RAW_UPSTREAM = String.raw`{"type":"error","error":{"type":"MissingSessionID","message":"Error from provider (Console): OpenCode's free tier can only be used in OpenCode"}}`; + const FREE_TIER_ROUTE = { + providerName: "opencode-free", + baseUrl: "https://opencode.ai/zen/v1", + adapter: "openai-chat", + }; + + test("registry note states the gate, the refusal to impersonate, and the keyed alternative", () => { + const note = PROVIDER_REGISTRY.find(e => e.id === "opencode-free")?.note?.toLowerCase(); + expect(note).toBeDefined(); + expect(note).toContain("x-opencode-session"); + expect(note).toContain("missingsessionid"); + expect(note).toContain("opencode-zen"); + // The quota figures the preset already documented must survive the rewrite. + expect(note).toContain("200"); + expect(note).toContain("15-20"); + }); + + test("isOpenCodeZenFreeTierLockIn recognises both the raw body and the parsed error type", () => { + expect(isOpenCodeZenFreeTierLockIn(RAW_UPSTREAM)).toBe(true); + // Native Chat parses the envelope, so the marker survives only in `error.type`. + expect(isOpenCodeZenFreeTierLockIn( + "Provider error 400: Error from provider (Console)", + "MissingSessionID", + )).toBe(true); + expect(isOpenCodeZenFreeTierLockIn( + "Provider error 400: OpenCode's free tier can only be used in OpenCode", + )).toBe(true); + expect(isOpenCodeZenFreeTierLockIn("Provider error 500: boom")).toBe(false); + expect(isOpenCodeZenFreeTierLockIn("Provider error 500: boom", "server_error")).toBe(false); + }); + + test("the client error explains the gate and names the supported keyed route", () => { + const enriched = enrichOpenCodeZenFreeTierMessage( + `Provider error 400: ${RAW_UPSTREAM}`, + FREE_TIER_ROUTE, + ); + expect(enriched).toContain("x-opencode-session"); + expect(enriched).toContain("opencode-zen"); + expect(enriched).toContain("https://opencode.ai/auth"); + expect(enriched).toContain("https://opencode.ai/docs/zen/"); + // The user is told opencodex declines to impersonate, not that the request merely failed. + expect(enriched).toContain("does not send a fabricated OpenCode session header"); + }); + + test("enrichment is scoped to Zen destinations and to this error", () => { + expect(enrichOpenCodeZenFreeTierMessage(`Provider error 400: ${RAW_UPSTREAM}`, { + providerName: "openrouter", + })).toBe(`Provider error 400: ${RAW_UPSTREAM}`); + expect(enrichOpenCodeZenFreeTierMessage("Provider error 500: boom", FREE_TIER_ROUTE)) + .toBe("Provider error 500: boom"); + // Destination match, not just the preset id: a custom row pointed at the same gateway. + expect(enrichOpenCodeZenFreeTierMessage( + `Provider error 400: ${RAW_UPSTREAM}`, + { baseUrl: "https://opencode.ai/zen/v1", adapter: "openai-chat" }, + )).toContain("opencode-zen"); + }); + + test("enrichment does not double-append across layers", () => { + const once = enrichOpenCodeZenFreeTierMessage( + `Provider error 400: ${RAW_UPSTREAM}`, + FREE_TIER_ROUTE, + ); + expect(enrichOpenCodeZenFreeTierMessage(once, FREE_TIER_ROUTE)).toBe(once); + }); + + test("enrichOpenCodeZenUpstreamMessage keeps the 429 guidance and adds the lock-in case", () => { + const rateLimited = enrichOpenCodeZenUpstreamMessage( + "Provider error 429: Rate limit exceeded.", + { status: 429, providerName: "opencode-zen", hasApiKey: true }, + ); + expect(rateLimited).toContain(OPENCODE_ZEN_OBSERVED_RPM_HINT); + expect(rateLimited).not.toContain("x-opencode-session"); + + const lockedOut = enrichOpenCodeZenUpstreamMessage( + `Provider error 400: ${RAW_UPSTREAM}`, + { status: 400, ...FREE_TIER_ROUTE }, + ); + expect(lockedOut).toContain("x-opencode-session"); + expect(lockedOut).not.toContain(OPENCODE_ZEN_OBSERVED_RPM_HINT); + }); +});