From 8c1ce5b0c45121ef555f70707a49461c1347bb3b Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sat, 8 Aug 2026 13:22:44 -0700 Subject: [PATCH 1/5] feat(providers): add Featherless preset --- .../docs/getting-started/quickstart.md | 2 +- .../src/content/docs/guides/providers.md | 11 +- .../docs/ja/getting-started/quickstart.md | 2 +- .../src/content/docs/ja/guides/providers.md | 10 +- .../docs/ko/getting-started/quickstart.md | 2 +- .../src/content/docs/ko/guides/providers.md | 10 +- .../docs/ru/getting-started/quickstart.md | 2 +- .../src/content/docs/ru/guides/providers.md | 11 +- .../docs/zh-cn/getting-started/quickstart.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 9 +- src/codex/catalog/provider-fetch.ts | 12 +- src/providers/registry.ts | 44 +++ tests/featherless-provider.test.ts | 259 ++++++++++++++++++ tests/fixtures/featherless-models.json | 80 ++++++ tests/provider-registry-parity.test.ts | 2 +- 15 files changed, 444 insertions(+), 14 deletions(-) create mode 100644 tests/featherless-provider.test.ts create mode 100644 tests/fixtures/featherless-models.json diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index a30f0b2950..d77e7ec232 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` walks you through: -1. **Pick a provider** — choose one of the 77 built-in registry presets or `custom` to type a base +1. **Pick a provider** — choose one of the 78 built-in registry presets or `custom` to type a base URL and adapter. 2. **API key** — paste a key, or reference an environment variable like `${ANTHROPIC_API_KEY}`. 3. **Default model** — for key, local, and custom providers, accept the preset or enter a model id. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 4ec6eb5361..b470f723ac 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -217,7 +217,7 @@ selectors, then retry. Signing in from a machine with no existing `kiro-cli` ses ## 3. API-key catalog -opencodex ships 77 built-in presets: 65 key-based, eight OAuth, three local, and one default +opencodex ships 78 built-in presets: 66 key-based, eight OAuth, three local, and one default ChatGPT-forward preset. The dashboard's **Add provider** picker opens a key provider's dashboard, validates the key, and stores it; validation is provider-specific. Notable entries: @@ -260,6 +260,7 @@ free-experimentation model. | Nebius Token Factory | `https://api.tokenfactory.nebius.com/v1` | | DigitalOcean Serverless Inference | `https://inference.do-ai.run/v1` | | Scaleway Generative APIs | `https://api.scaleway.ai/v1` | +| Featherless AI | `https://api.featherless.ai/v1` | | Together | `https://api.together.xyz/v1` | | Fireworks | `https://api.fireworks.ai/inference/v1` | | Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` | @@ -368,6 +369,14 @@ rows. It uses the default Project's shared endpoint; project-qualified URLs and deployments require a custom provider. Create an API key in the [Scaleway console](https://console.scaleway.com/generative-api). +**Featherless discovery.** The preset authenticates against the fixed OpenAI-compatible host and +requests only the first 100 popular models filtered upstream to chat and the current plan. Registry +rules then fail closed unless each row independently reports plan availability, no Hugging Face +gate, and `features.tool_use: true`. Discovery is capped at 128 KiB and 100 raw rows, so the service's +tens-of-thousands-model catalog is never downloaded or cached in full. Featherless terms reserve +individual plans for interactive/prototyping use; arbitrary applications require a Scale plan. +Create a key in the [Featherless dashboard](https://featherless.ai/account/api-keys). + > **Baseten scope:** The preset covers Baseten's shared [Model APIs](https://docs.baseten.co/inference/model-apis/overview) > only. Use a personal [API key](https://docs.baseten.co/organization/api-keys) for local use, or a team key > with **Call Model APIs** access for shared/production use. Dedicated Truss `predict` endpoints use different diff --git a/docs-site/src/content/docs/ja/getting-started/quickstart.md b/docs-site/src/content/docs/ja/getting-started/quickstart.md index c83c08a96f..1c2e6e3493 100644 --- a/docs-site/src/content/docs/ja/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ja/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` では次の手順を説明します。 -1. **プロバイダーを選択してください** — 77 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。 +1. **プロバイダーを選択してください** — 78 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。 2. **API キー** — キーを貼り付けるか、`${ANTHROPIC_API_KEY}` のような環境変数を参照します。 3. **デフォルト モデル** — キー、ローカル、カスタム プロバイダーの場合は、プリセットを受け入れるか、モデル ID を入力します。 4. **プロキシ ポート** — デフォルトは `10100` です。 diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index a8f4ae2011..1f5a77a2e5 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -144,7 +144,7 @@ Kiro のログインには Kiro CLI が必要です。Unix では `curl -fsSL ht ## 3. API キーカタログ -opencodex には組み込みプリセットが 77 個含まれています。キー方式 65、OAuth 8、ローカル 3、 +opencodex には組み込みプリセットが 78 個含まれています。キー方式 66、OAuth 8、ローカル 3、 デフォルト ChatGPT 転送プリセット 1 です。ダッシュボードの **Add provider** ピッカーはキー発行ページを開き、 入力したキーを検証した後保存します(検証はプロバイダー固有です)。主な項目は以下のとおりです: @@ -187,6 +187,7 @@ Cline IDE/CLI のみで API からは使えません。`minimax/minimax-m2.5` | Nebius Token Factory | `https://api.tokenfactory.nebius.com/v1` | | DigitalOcean Serverless Inference | `https://inference.do-ai.run/v1` | | Scaleway Generative APIs | `https://api.scaleway.ai/v1` | +| Featherless AI | `https://api.featherless.ai/v1` | | Together | `https://api.together.xyz/v1` | | Fireworks | `https://api.fireworks.ai/inference/v1` | | Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` | @@ -279,6 +280,13 @@ id は fail closed で除外し、discovery を 128 KiB と raw 128 行に制限 endpoint を使用します。Project id 付き URL と dedicated deployment は custom provider で設定してください。 API キーは [Scaleway console](https://console.scaleway.com/generative-api) で作成します。 +**Featherless の discovery:** 固定の OpenAI 互換ホストで認証し、chat と現在の plan に絞った人気順の +先頭 100 model だけを取得します。各 row が plan で利用可能、Hugging Face gate なし、かつ +`features.tool_use: true` と独立して報告しない限り fail closed で除外します。discovery は 128 KiB と +raw 100 行が上限で、数万件の catalog 全体を download / cache しません。個人 plan は interactive / prototype +用途に限られ、任意の application には Scale plan が必要です。キーは +[Featherless dashboard](https://featherless.ai/account/api-keys) で作成します。 + > **Baseten の対象範囲:** このプリセットは Baseten の共有 [Model APIs](https://docs.baseten.co/inference/model-apis/overview) > のみを対象とします。ローカル利用では個人の [API キー](https://docs.baseten.co/organization/api-keys)を、 > 共有/本番利用では **Call Model APIs** 権限を持つチームキーを使用してください。専用 Truss `predict` diff --git a/docs-site/src/content/docs/ko/getting-started/quickstart.md b/docs-site/src/content/docs/ko/getting-started/quickstart.md index 102b03791f..bebbb30ddf 100644 --- a/docs-site/src/content/docs/ko/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ko/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init`은 다음 과정을 안내합니다: -1. **프로바이더 선택** — 내장 레지스트리 프리셋 77개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. +1. **프로바이더 선택** — 내장 레지스트리 프리셋 78개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. 2. **API 키** — 키를 붙여넣거나 `${ANTHROPIC_API_KEY}` 같은 환경 변수를 참조합니다. 3. **기본 모델** — 키, 로컬, custom 프로바이더에서는 프리셋을 그대로 쓰거나 모델 ID를 직접 입력합니다. 4. **프록시 포트** — 기본값은 `10100`입니다. diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 78623f0e6b..49a3527bfe 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -143,7 +143,7 @@ Kiro 로그인에는 Kiro CLI가 필요합니다. Unix에서는 `curl -fsSL http ## 3. API 키 카탈로그 -opencodex에는 빌트인 프리셋이 77개 들어 있습니다. 키 방식 65개, OAuth 8개, 로컬 3개, +opencodex에는 빌트인 프리셋이 78개 들어 있습니다. 키 방식 66개, OAuth 8개, 로컬 3개, 기본 ChatGPT 포워드 프리셋 1개입니다. 대시보드의 **Add provider** 선택기는 키 발급 페이지를 열고, 입력한 키를 검증한 뒤 저장합니다(검증은 프로바이더별로 다릅니다). 주요 항목은 다음과 같습니다: @@ -187,6 +187,7 @@ Cline IDE/CLI에서만 제공되며 API로는 사용할 수 없습니다. `minim | Nebius Token Factory | `https://api.tokenfactory.nebius.com/v1` | | DigitalOcean Serverless Inference | `https://inference.do-ai.run/v1` | | Scaleway Generative APIs | `https://api.scaleway.ai/v1` | +| Featherless AI | `https://api.featherless.ai/v1` | | Together | `https://api.together.xyz/v1` | | Fireworks | `https://api.fireworks.ai/inference/v1` | | Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` | @@ -277,6 +278,13 @@ discovery를 256 KiB와 raw 행 256개로 제한합니다. agent 전용 및 dedi Project ID가 포함된 URL과 dedicated deployment는 custom provider로 설정하세요. API 키는 [Scaleway console](https://console.scaleway.com/generative-api)에서 생성합니다. +**Featherless 검색:** 고정된 OpenAI 호환 호스트에서 인증하고, chat 및 현재 plan으로 필터링한 인기 모델의 +첫 100개만 요청합니다. 각 행이 plan 사용 가능, Hugging Face gate 없음, `features.tool_use: true`를 +독립적으로 보고하지 않으면 fail closed로 제외합니다. 검색은 128 KiB와 raw 100행으로 제한되어 수만 개의 +전체 catalog를 다운로드하거나 캐시하지 않습니다. 개인 plan은 interactive/prototype 용도로 제한되며 임의의 +application에는 Scale plan이 필요합니다. 키는 +[Featherless dashboard](https://featherless.ai/account/api-keys)에서 생성합니다. + > **Baseten 범위:** 이 프리셋은 Baseten의 공유 [Model APIs](https://docs.baseten.co/inference/model-apis/overview)만 > 지원합니다. 로컬 사용에는 개인 [API 키](https://docs.baseten.co/organization/api-keys)를, 공유/프로덕션 > 사용에는 **Call Model APIs** 권한이 있는 팀 키를 사용하세요. 전용 Truss `predict` 엔드포인트는 diff --git a/docs-site/src/content/docs/ru/getting-started/quickstart.md b/docs-site/src/content/docs/ru/getting-started/quickstart.md index b4fc1b3645..ec7dc20dc8 100644 --- a/docs-site/src/content/docs/ru/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ru/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` проведёт вас по следующим шагам: -1. **Выбор провайдера** — выберите один из 77 встроенных пресетов реестра или `custom`, чтобы +1. **Выбор провайдера** — выберите один из 78 встроенных пресетов реестра или `custom`, чтобы ввести базовый URL и адаптер вручную. 2. **API-ключ** — вставьте ключ или сошлитесь на переменную окружения вида `${ANTHROPIC_API_KEY}`. 3. **Модель по умолчанию** — для провайдеров с ключом, локальных и `custom` примите значение из diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 8b61458ce9..3de7948cc4 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -154,7 +154,7 @@ OAuth-провайдеры, чьи учётные данные содержат ## 3. Каталог API-ключей -opencodex поставляется с 77 встроенными пресетами: 65 на основе ключей, восемь OAuth, три локальных и +opencodex поставляется с 78 встроенными пресетами: 66 на основе ключей, восемь OAuth, три локальных и один пресет ChatGPT-форварда по умолчанию. Селектор **Add provider** в дашборде открывает страницу выдачи ключей провайдера, проверяет ключ и сохраняет его; проверка зависит от провайдера. Наиболее заметные записи: @@ -198,6 +198,7 @@ opencodex поставляется с 77 встроенными пресетам | Nebius Token Factory | `https://api.tokenfactory.nebius.com/v1` | | DigitalOcean Serverless Inference | `https://inference.do-ai.run/v1` | | Scaleway Generative APIs | `https://api.scaleway.ai/v1` | +| Featherless AI | `https://api.featherless.ai/v1` | | Together | `https://api.together.xyz/v1` | | Fireworks | `https://api.fireworks.ai/inference/v1` | | Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` | @@ -303,6 +304,14 @@ embedding-, transcription- и прочие media-model id исключаются Project ID и dedicated deployment настраиваются как custom provider. API-ключ создаётся в [консоли Scaleway](https://console.scaleway.com/generative-api). +**Discovery для Featherless.** Пресет проходит аутентификацию на фиксированном OpenAI-совместимом +хосте и запрашивает только первые 100 популярных моделей, отфильтрованных по chat и текущему plan. +Затем registry по принципу fail closed требует, чтобы каждая строка отдельно подтверждала доступность +по plan, отсутствие Hugging Face gate и `features.tool_use: true`. Discovery ограничен 128 KiB и 100 +исходными строками, поэтому каталог из десятков тысяч моделей не загружается и не кэшируется целиком. +Индивидуальные plan предназначены для interactive/prototype; произвольные приложения требуют Scale +plan. Ключ создаётся в [дашборде Featherless](https://featherless.ai/account/api-keys). + > **Область Baseten:** пресет поддерживает только общие [Model APIs](https://docs.baseten.co/inference/model-apis/overview) > Baseten. Для локальной работы используйте личный [API-ключ](https://docs.baseten.co/organization/api-keys), > а для общего/промышленного использования — командный ключ с правом **Call Model APIs**. Выделенные конечные точки Truss `predict` используют другие хосты и diff --git a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md index a817e743b4..107012b999 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` 会引导你完成: -1. **选择 provider** — 从内置 registry 的 77 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 +1. **选择 provider** — 从内置 registry 的 78 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 2. **API key** — 粘贴一个 key,或引用一个环境变量,例如 `${ANTHROPIC_API_KEY}`。 3. **默认模型** — 对于 key、本地和 custom provider,接受预设值或输入模型 id。 4. **代理端口** — 默认为 `10100`。 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 6611f818a9..0adb12c9bd 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -132,7 +132,7 @@ Kiro 登录需要 Kiro CLI:Unix 使用 `curl -fsSL https://cli.kiro.dev/instal ## 3. API 密钥目录 -opencodex 内置 77 个预设:65 个密钥预设、8 个 OAuth 预设、3 个本地预设,以及 1 个默认的 +opencodex 内置 78 个预设:66 个密钥预设、8 个 OAuth 预设、3 个本地预设,以及 1 个默认的 ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提供商的控制台,验证并保存密钥。 验证因提供商而异。主要条目包括: @@ -175,6 +175,7 @@ Cline IDE/CLI 中提供,不能通过 API 使用;`minimax/minimax-m2.5` 是 | Nebius Token Factory | `https://api.tokenfactory.nebius.com/v1` | | DigitalOcean Serverless Inference | `https://inference.do-ai.run/v1` | | Scaleway Generative APIs | `https://api.scaleway.ai/v1` | +| Featherless AI | `https://api.featherless.ai/v1` | | Together | `https://api.together.xyz/v1` | | Fireworks | `https://api.fireworks.ai/inference/v1` | | Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` | @@ -257,6 +258,12 @@ Responses-only、embedding 和 media-generation 模型 id 会按 fail closed 原 dedicated deployment 需要配置为 custom provider。API 密钥可在 [Scaleway 控制台](https://console.scaleway.com/generative-api) 创建。 +**Featherless 发现:**该预设在固定的 OpenAI 兼容主机上鉴权,只请求按 chat 和当前 plan 过滤后的热门 +模型第一页,最多 100 条。registry 随后按 fail closed 原则要求每条记录分别报告当前 plan 可用、无需 +Hugging Face gate,且 `features.tool_use: true`。发现上限为 128 KiB 和 100 条原始记录,因此不会下载或 +缓存包含数万模型的完整目录。个人 plan 仅适用于 interactive/prototype 用途;任意 application 需要使用 +Scale plan。密钥可在 [Featherless dashboard](https://featherless.ai/account/api-keys) 创建。 + > **Baseten 范围:**该预设仅覆盖 Baseten 的共享 [Model APIs](https://docs.baseten.co/inference/model-apis/overview)。 > 本地使用可选择个人 [API 密钥](https://docs.baseten.co/organization/api-keys);共享或生产用途请使用具备 > **Call Model APIs** 权限的团队密钥。 diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 5314ecacec..1327197a60 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -886,7 +886,9 @@ function normalizedStringList(value: unknown, maxItems = 32, maxLength = 64): st function modelCapabilities(item: ProviderModelsApiItem): string[] | undefined { const metadata = plainRecord(item.metadata); const metadataCapabilities = metadata?.capabilities; - const capabilityRecord = plainRecord(metadataCapabilities) ?? plainRecord(item.capabilities); + const capabilityRecord = plainRecord(metadataCapabilities) + ?? plainRecord(item.capabilities) + ?? plainRecord(item.features); const out = new Set(); for (const list of [item.capabilities, item.features, item.supported_features, metadataCapabilities]) { for (const capability of normalizedStringList(list) ?? []) out.add(capability); @@ -916,7 +918,9 @@ function modelInputModalities( capabilities: readonly string[] | undefined, ): string[] | undefined { const metadata = plainRecord(item.metadata); - const capabilityRecord = plainRecord(metadata?.capabilities) ?? plainRecord(item.capabilities); + const capabilityRecord = plainRecord(metadata?.capabilities) + ?? plainRecord(item.capabilities) + ?? plainRecord(item.features); const explicit = normalizedStringList( item.input_modalities ?? item.modalities @@ -943,7 +947,9 @@ function modelInputModalities( if (inferred.length > 0) return [...new Set(inferred)]; } if (capabilityRecord?.vision === false) return ["text"]; - if (capabilityRecord?.vision === true || capabilities?.some(value => value === "vision" || value === "image-input")) { + if (capabilityRecord?.vision === true || capabilities?.some(value => ( + value === "vision" || value === "image-input" || value === "image_input" + ))) { return ["text", "image"]; } return undefined; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index b3f8d9519d..2914023981 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1673,6 +1673,50 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ }, note: "Shared Generative APIs Serverless Chat Completions only; project-qualified and dedicated deployment hosts require a custom provider.", }, + { + // Primary sources checked 2026-08-08: + // - https://featherless.ai/docs/api-overview-and-common-options documents the fixed + // OpenAI-compatible base URL, Bearer keys, and Chat Completions. + // - https://featherless.ai/docs/api-reference-models documents authenticated plan filtering, + // chat capability filtering, popularity sorting, pagination, and per-row tool metadata. + // - https://featherless.ai/legal/terms-of-service identifies Featherless as a Delaware LLC, + // covers developers building on its APIs, and reserves arbitrary applications for Scale + // plans. Maintainer: @olddonkey; no affiliation with Featherless. + id: "featherless", + label: "Featherless AI", + baseUrl: "https://api.featherless.ai/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://featherless.ai/account/api-keys", + liveModels: true, + preserveCustomDestination: true, + // Featherless documents tool calling, but not a provider-wide parallel tool-call contract. + parallelToolCalls: false, + // Reasoning controls use model-specific chat_template_kwargs, not OpenAI reasoning_effort. + reasoningEfforts: [], + modelDiscovery: { + path: "models", + query: { + available_on_current_plan: "true", + capabilities: "chat", + page: "1", + per_page: "100", + sort: "-popularity", + }, + maxResponseBytes: 128 * 1024, + maxModels: 100, + filter: { + // Treat server-side filters as a size optimization, not an authority boundary. A row must + // independently prove plan availability, no separate Hugging Face gate, and tool support. + allOf: [ + { path: ["available_on_current_plan"], equalsAny: [true] }, + { path: ["is_gated"], equalsAny: [false] }, + { path: ["features", "tool_use"], equalsAny: [true] }, + ], + }, + }, + note: "Authenticated first page of popular chat models only; live discovery admits at most 100 plan-available, ungated rows whose metadata explicitly reports tool use.", + }, // FREEZE 2026-07-10: exact serverless ids remain auth-gated/unverified. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. { id: "together", label: "Together", baseUrl: "https://api.together.xyz/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://api.together.xyz/settings/api-keys" }, { id: "fireworks", label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys" }, diff --git a/tests/featherless-provider.test.ts b/tests/featherless-provider.test.ts new file mode 100644 index 0000000000..e48013ce06 --- /dev/null +++ b/tests/featherless-provider.test.ts @@ -0,0 +1,259 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; +import { gatherRoutedModels } from "../src/codex/catalog"; +import { catalogHintsFromModelsApiItem } from "../src/codex/catalog/provider-fetch"; +import { clearModelCache } from "../src/codex/model-cache"; +import { buildInitProviders } from "../src/cli/init"; +import { buildModelsRequest } from "../src/oauth"; +import { KEY_LOGIN_PROVIDERS, validateApiKey } from "../src/oauth/key-providers"; +import { + deriveInitProviders, + deriveProviderPresets, + providerConfigSeed, +} from "../src/providers/derive"; +import { resolveProviderModelDiscovery } from "../src/providers/model-discovery"; +import { PROVIDER_REGISTRY } from "../src/providers/registry"; +import { routedSlug } from "../src/providers/slug-codec"; +import { routeModel } from "../src/router"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; +import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; + +const FEATHERLESS_FIXTURE = readFileSync(join(import.meta.dir, "fixtures/featherless-models.json"), "utf8"); +const BASE_URL = "https://api.featherless.ai/v1"; +const TEST_KEY = "featherless-test-key"; +const DISCOVERY_QUERY = { + available_on_current_plan: "true", + capabilities: "chat", + page: "1", + per_page: "100", + sort: "-popularity", +} as const; +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache("featherless"); +}); + +function registryEntry() { + const entry = PROVIDER_REGISTRY.find(row => row.id === "featherless"); + if (!entry) throw new Error("missing featherless registry entry"); + return entry; +} + +function providerConfig(overrides: Partial = {}): OcxConfig { + return { + port: 10100, + defaultProvider: "featherless", + providers: { + featherless: { + adapter: "openai-chat", + baseUrl: BASE_URL, + authMode: "key", + apiKey: TEST_KEY, + liveModels: true, + ...overrides, + }, + }, + }; +} + +function expectDiscoveryUrl(input: RequestInfo | URL): void { + const url = new URL(String(input)); + expect(`${url.origin}${url.pathname}`).toBe(`${BASE_URL}/models`); + expect(Object.fromEntries(url.searchParams)).toEqual(DISCOVERY_QUERY); +} + +describe("Featherless provider", () => { + test("registers a bounded authenticated first-page discovery policy", () => { + expect(registryEntry()).toMatchObject({ + id: "featherless", + label: "Featherless AI", + adapter: "openai-chat", + baseUrl: BASE_URL, + authKind: "key", + dashboardUrl: "https://featherless.ai/account/api-keys", + liveModels: true, + preserveCustomDestination: true, + parallelToolCalls: false, + reasoningEfforts: [], + modelDiscovery: { + path: "models", + query: DISCOVERY_QUERY, + maxResponseBytes: 131_072, + maxModels: 100, + filter: { + allOf: [ + { path: ["available_on_current_plan"], equalsAny: [true] }, + { path: ["is_gated"], equalsAny: [false] }, + { path: ["features", "tool_use"], equalsAny: [true] }, + ], + }, + }, + }); + expect(registryEntry().note).toContain("at most 100"); + }); + + test("derives CLI and dashboard presets without persisting registry trust policy", () => { + const entry = registryEntry(); + expect(buildInitProviders()).toEqual(deriveInitProviders()); + expect(KEY_LOGIN_PROVIDERS.featherless).toMatchObject({ + adapter: "openai-chat", + baseUrl: BASE_URL, + dashboardUrl: entry.dashboardUrl, + liveModels: true, + reasoningEfforts: [], + }); + expect(buildInitProviders().find(row => row.id === "featherless")).toMatchObject({ + kind: "key", + adapter: "openai-chat", + baseUrl: BASE_URL, + }); + expect(deriveProviderPresets().find(row => row.id === "featherless")).toMatchObject({ + auth: "key", + dashboardUrl: entry.dashboardUrl, + }); + + const seed = providerConfigSeed(entry); + expect(seed).toMatchObject({ + adapter: "openai-chat", + baseUrl: BASE_URL, + authMode: "key", + liveModels: true, + parallelToolCalls: false, + reasoningEfforts: [], + }); + expect(seed).not.toHaveProperty("modelDiscovery"); + expect(seed).not.toHaveProperty("preserveCustomDestination"); + expect(KEY_LOGIN_PROVIDERS.featherless).not.toHaveProperty("modelDiscovery"); + expect(KEY_LOGIN_PROVIDERS.featherless).not.toHaveProperty("preserveCustomDestination"); + }); + + test("uses the documented Bearer catalog as a real key-validation boundary", async () => { + const request = buildModelsRequest(providerConfig().providers.featherless!, TEST_KEY, "featherless"); + expectDiscoveryUrl(request.url); + expect(request.headers).toEqual({ Authorization: `Bearer ${TEST_KEY}` }); + + let status = 200; + globalThis.fetch = (async (input, init) => { + expectDiscoveryUrl(input); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${TEST_KEY}`); + expect(init?.redirect).toBe("error"); + return new Response(status === 200 ? FEATHERLESS_FIXTURE : JSON.stringify({ error: "unauthorized" }), { status }); + }) as typeof fetch; + + expect(await validateApiKey("featherless", KEY_LOGIN_PROVIDERS.featherless!, TEST_KEY)).toBe(true); + status = 401; + expect(await validateApiKey("featherless", KEY_LOGIN_PROVIDERS.featherless!, TEST_KEY)).toBe(false); + }); + + test("reads bounded boolean feature metadata without trusting it for admission by itself", () => { + expect(catalogHintsFromModelsApiItem("featherless", { + id: "example/vision-tool-model", + context_length: 32_768, + features: { tool_use: true, image_input: true }, + })).toEqual({ + contextWindow: 32_768, + inputModalities: ["text", "image"], + capabilities: ["tool_use", "image_input"], + }); + }); + + test("keeps only plan-available ungated tool rows and preserves native slash ids", async () => { + globalThis.fetch = (async (input, init) => { + expectDiscoveryUrl(input); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${TEST_KEY}`); + expect(init?.redirect).toBe("manual"); + return new Response(FEATHERLESS_FIXTURE, { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const config = withStubbedProviderFetch(providerConfig()); + const models = (await gatherRoutedModels(config)).filter(row => row.provider === "featherless"); + expect(models.map(row => row.id).sort()).toEqual([ + "alpindale/magnum-72b-v1", + "example/sparse-plan-tool-model", + "Qwen/Qwen3-8B", + ].sort()); + const qwen = models.find(row => row.id === "Qwen/Qwen3-8B"); + const sparse = models.find(row => row.id === "example/sparse-plan-tool-model"); + expect(qwen).toMatchObject({ + owned_by: "Feather", + contextWindow: 32_768, + capabilities: ["tool_use"], + reasoningEfforts: [], + }); + expect(qwen).not.toHaveProperty("parallelToolCalls"); + expect(sparse).toMatchObject({ + owned_by: "fixture", + capabilities: ["tool_use"], + reasoningEfforts: [], + }); + expect(sparse).not.toHaveProperty("contextWindow"); + expect(sparse).not.toHaveProperty("inputModalities"); + + for (const modelId of models.map(row => row.id)) { + expect(routeModel(config, `featherless/${modelId}`).modelId).toBe(modelId); + expect(routeModel(config, routedSlug("featherless", modelId)).modelId).toBe(modelId); + } + }); + + test("routes tool requests without unsupported reasoning or parallel fields", () => { + const modelId = "Qwen/Qwen3-8B"; + const route = routeModel(providerConfig(), `featherless/${modelId}`); + const request = createOpenAIChatAdapter(route.provider).buildRequest({ + modelId: route.modelId, + context: { + messages: [{ role: "user", content: "ping", timestamp: 0 }], + tools: [{ + name: "ping", + description: "Return pong", + parameters: { type: "object", properties: {} }, + }], + }, + stream: true, + options: { reasoning: "high" }, + }); + const body = JSON.parse(String(request.body)) as Record; + + expect(request.url).toBe(`${BASE_URL}/chat/completions`); + expect(request.headers.Authorization).toBe(`Bearer ${TEST_KEY}`); + expect(body.model).toBe(modelId); + expect(body.parallel_tool_calls).toBe(false); + expect(body).not.toHaveProperty("reasoning_effort"); + }); + + test("does not retarget an older same-named custom provider or adapter", () => { + const customConfig = providerConfig({ baseUrl: "https://custom.example/v1" }); + const route = routeModel(customConfig, "featherless/custom-model"); + expect(route.provider).toMatchObject({ + adapter: "openai-chat", + baseUrl: "https://custom.example/v1", + authMode: "key", + }); + expect(resolveProviderModelDiscovery("featherless", customConfig.providers.featherless!).spec).toBeUndefined(); + expect(buildModelsRequest(customConfig.providers.featherless!, "custom-key", "featherless")).toEqual({ + url: "https://custom.example/v1/models", + headers: { Authorization: "Bearer custom-key" }, + }); + + const nearMissConfig = providerConfig({ baseUrl: "https://api.featherless.ai/v2" }); + expect( + resolveProviderModelDiscovery("featherless", nearMissConfig.providers.featherless!).spec, + ).toBeUndefined(); + + const customAdapter = routeModel(providerConfig({ + adapter: "anthropic", + baseUrl: "https://custom.example/anthropic", + }), "featherless/custom-model"); + expect(customAdapter.provider).toMatchObject({ + adapter: "anthropic", + baseUrl: "https://custom.example/anthropic", + authMode: "key", + }); + }); +}); diff --git a/tests/fixtures/featherless-models.json b/tests/fixtures/featherless-models.json new file mode 100644 index 0000000000..88fd4de319 --- /dev/null +++ b/tests/fixtures/featherless-models.json @@ -0,0 +1,80 @@ +{ + "data": [ + { + "id": "alpindale/magnum-72b-v1", + "object": "model", + "owned_by": "Feather", + "context_length": 32768, + "concurrency_cost": 4, + "is_gated": false, + "available_on_current_plan": true, + "features": { + "tool_use": true + } + }, + { + "id": "Qwen/Qwen3-8B", + "object": "model", + "owned_by": "Feather", + "context_length": 32768, + "concurrency_cost": 1, + "is_gated": false, + "available_on_current_plan": true, + "features": { + "tool_use": true + } + }, + { + "id": "example/sparse-plan-tool-model", + "object": "model", + "owned_by": "fixture", + "context_length": null, + "concurrency_cost": null, + "is_gated": false, + "available_on_current_plan": true, + "features": { + "tool_use": true + } + }, + { + "id": "vanta-research/atom-v1-preview-12b", + "object": "model", + "owned_by": "Feather", + "context_length": 32768, + "is_gated": false, + "available_on_current_plan": true, + "features": { + "image_input": true + } + }, + { + "id": "huihui-ai/Qwen3-32B-abliterated", + "object": "model", + "owned_by": "Feather", + "context_length": 32768, + "is_gated": true, + "available_on_current_plan": true, + "features": { + "tool_use": true + } + }, + { + "id": "example/plan-unavailable-tool-model", + "object": "model", + "owned_by": "Feather", + "context_length": 16384, + "is_gated": false, + "available_on_current_plan": false, + "features": { + "tool_use": true + } + } + ], + "pagination": { + "current_page": 1, + "per_page": 100, + "total_items": 683, + "total_pages": 7 + }, + "total": 683 +} diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index f8656cec41..049f051570 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -31,7 +31,7 @@ function nativeTemplate(): Record { const EXPECTED_KEY_PROVIDER_IDS = [ "anthropic-apikey", "openai-apikey", "umans", "opencode-go", "neuralwatt", "openrouter", "cline-pass", "cline", "orcarouter", "bizrouter", "groq", "google", "google-vertex", "azure-openai", - "deepseek", "cerebras", "chutes", "deepinfra", "hyperbolic", "nscale", "vultr", "baseten", "commandcode", "sambanova", "nebius", "digitalocean", "scaleway", "together", "fireworks", "firepass", "moonshot", + "deepseek", "cerebras", "chutes", "deepinfra", "hyperbolic", "nscale", "vultr", "baseten", "commandcode", "sambanova", "nebius", "digitalocean", "scaleway", "featherless", "together", "fireworks", "firepass", "moonshot", "huggingface", "nvidia", "venice", "zai", "zhipu-bigmodel", "zhipu-bigmodel-coding", "nanogpt", "synthetic", "siliconflow", "qwen-cloud", "tencent-coding-plan", "volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral", "minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway", From b9492c517c12e09eb0f7f03e412cf571a9c0e449 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 10 Aug 2026 06:56:17 +0200 Subject: [PATCH 2/5] fix(providers): rebase on dev and harden Featherless key validation Rebase onto current dev, correct standalone preset counts to 77/65, and set apiKeyValidation to unknown because /v1/models is documented as callable with or without authentication. --- docs-site/src/content/docs/guides/providers.md | 2 +- src/providers/registry.ts | 3 +++ tests/featherless-provider.test.ts | 16 +++++----------- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index b470f723ac..917645092f 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -373,7 +373,7 @@ deployments require a custom provider. Create an API key in the requests only the first 100 popular models filtered upstream to chat and the current plan. Registry rules then fail closed unless each row independently reports plan availability, no Hugging Face gate, and `features.tool_use: true`. Discovery is capped at 128 KiB and 100 raw rows, so the service's -tens-of-thousands-model catalog is never downloaded or cached in full. Featherless terms reserve +tens-of-thousands-model catalog is never downloaded or cached in full. Because `/v1/models` is documented as callable with or without authentication, it cannot prove a supplied key is valid; chat requests still use the configured Bearer key. Featherless terms reserve individual plans for interactive/prototyping use; arbitrary applications require a Scale plan. Create a key in the [Featherless dashboard](https://featherless.ai/account/api-keys). diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 2914023981..1598997ebe 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1690,6 +1690,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ dashboardUrl: "https://featherless.ai/account/api-keys", liveModels: true, preserveCustomDestination: true, + // /v1/models is documented as callable authenticated or unauthenticated, so a 2xx catalog + // response cannot prove the supplied Bearer key is valid. + apiKeyValidation: "unknown", // Featherless documents tool calling, but not a provider-wide parallel tool-call contract. parallelToolCalls: false, // Reasoning controls use model-specific chat_template_kwargs, not OpenAI reasoning_effort. diff --git a/tests/featherless-provider.test.ts b/tests/featherless-provider.test.ts index e48013ce06..38515e5155 100644 --- a/tests/featherless-provider.test.ts +++ b/tests/featherless-provider.test.ts @@ -77,6 +77,7 @@ describe("Featherless provider", () => { dashboardUrl: "https://featherless.ai/account/api-keys", liveModels: true, preserveCustomDestination: true, + apiKeyValidation: "unknown", parallelToolCalls: false, reasoningEfforts: [], modelDiscovery: { @@ -131,22 +132,15 @@ describe("Featherless provider", () => { expect(KEY_LOGIN_PROVIDERS.featherless).not.toHaveProperty("preserveCustomDestination"); }); - test("uses the documented Bearer catalog as a real key-validation boundary", async () => { + test("uses the documented Bearer endpoint without treating its catalog as key proof", async () => { const request = buildModelsRequest(providerConfig().providers.featherless!, TEST_KEY, "featherless"); expectDiscoveryUrl(request.url); expect(request.headers).toEqual({ Authorization: `Bearer ${TEST_KEY}` }); - let status = 200; - globalThis.fetch = (async (input, init) => { - expectDiscoveryUrl(input); - expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${TEST_KEY}`); - expect(init?.redirect).toBe("error"); - return new Response(status === 200 ? FEATHERLESS_FIXTURE : JSON.stringify({ error: "unauthorized" }), { status }); + globalThis.fetch = (async () => { + throw new Error("catalog validation must not fetch"); }) as typeof fetch; - - expect(await validateApiKey("featherless", KEY_LOGIN_PROVIDERS.featherless!, TEST_KEY)).toBe(true); - status = 401; - expect(await validateApiKey("featherless", KEY_LOGIN_PROVIDERS.featherless!, TEST_KEY)).toBe(false); + expect(await validateApiKey("featherless", KEY_LOGIN_PROVIDERS.featherless!, TEST_KEY)).toBe("unknown"); }); test("reads bounded boolean feature metadata without trusting it for admission by itself", () => { From bd2d250bb584181ca18feafbc4362a15f7ed73c8 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:11:26 +0200 Subject: [PATCH 3/5] docs(providers): sync Featherless key-validation notes in locales Add the /v1/models authentication caveat to ja/ko/ru/zh-cn provider guides so localized docs match the English Featherless discovery section. --- 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 | 1 + docs-site/src/content/docs/zh-cn/guides/providers.md | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 1f5a77a2e5..607c7d5a80 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -283,7 +283,7 @@ API キーは [Scaleway console](https://console.scaleway.com/generative-api) **Featherless の discovery:** 固定の OpenAI 互換ホストで認証し、chat と現在の plan に絞った人気順の 先頭 100 model だけを取得します。各 row が plan で利用可能、Hugging Face gate なし、かつ `features.tool_use: true` と独立して報告しない限り fail closed で除外します。discovery は 128 KiB と -raw 100 行が上限で、数万件の catalog 全体を download / cache しません。個人 plan は interactive / prototype +raw 100 行が上限で、数万件の catalog 全体を download / cache しません。`/v1/models` は認証あり・なしの両方で呼び出せると文書化されているため、入力したキーの有効性は証明できませんが、chat request は設定済みの Bearer キーで認証されます。個人 plan は interactive / prototype 用途に限られ、任意の application には Scale plan が必要です。キーは [Featherless dashboard](https://featherless.ai/account/api-keys) で作成します。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 49a3527bfe..219cdaa8e9 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -281,7 +281,7 @@ Project ID가 포함된 URL과 dedicated deployment는 custom provider로 설정 **Featherless 검색:** 고정된 OpenAI 호환 호스트에서 인증하고, chat 및 현재 plan으로 필터링한 인기 모델의 첫 100개만 요청합니다. 각 행이 plan 사용 가능, Hugging Face gate 없음, `features.tool_use: true`를 독립적으로 보고하지 않으면 fail closed로 제외합니다. 검색은 128 KiB와 raw 100행으로 제한되어 수만 개의 -전체 catalog를 다운로드하거나 캐시하지 않습니다. 개인 plan은 interactive/prototype 용도로 제한되며 임의의 +전체 catalog를 다운로드하거나 캐시하지 않습니다. `/v1/models`는 문서상 인증 여부와 관계없이 호출할 수 있어 입력한 키의 유효성을 증명할 수 없지만, chat request는 설정된 Bearer 키로 인증됩니다. 개인 plan은 interactive/prototype 용도로 제한되며 임의의 application에는 Scale plan이 필요합니다. 키는 [Featherless dashboard](https://featherless.ai/account/api-keys)에서 생성합니다. diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 3de7948cc4..7389f79312 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -309,6 +309,7 @@ Project ID и dedicated deployment настраиваются как custom prov Затем registry по принципу fail closed требует, чтобы каждая строка отдельно подтверждала доступность по plan, отсутствие Hugging Face gate и `features.tool_use: true`. Discovery ограничен 128 KiB и 100 исходными строками, поэтому каталог из десятков тысяч моделей не загружается и не кэшируется целиком. +`/v1/models` описан как вызываемый как с аутентификацией, так и без неё, поэтому не может подтвердить корректность введённого ключа, но chat-запросы всё равно аутентифицируются настроенным Bearer-ключом. Индивидуальные plan предназначены для interactive/prototype; произвольные приложения требуют Scale plan. Ключ создаётся в [дашборде Featherless](https://featherless.ai/account/api-keys). 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 0adb12c9bd..cb5a48a809 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -261,7 +261,7 @@ dedicated deployment 需要配置为 custom provider。API 密钥可在 **Featherless 发现:**该预设在固定的 OpenAI 兼容主机上鉴权,只请求按 chat 和当前 plan 过滤后的热门 模型第一页,最多 100 条。registry 随后按 fail closed 原则要求每条记录分别报告当前 plan 可用、无需 Hugging Face gate,且 `features.tool_use: true`。发现上限为 128 KiB 和 100 条原始记录,因此不会下载或 -缓存包含数万模型的完整目录。个人 plan 仅适用于 interactive/prototype 用途;任意 application 需要使用 +缓存包含数万模型的完整目录。由于 `/v1/models` 在文档中可带或不带鉴权调用,它无法证明输入的密钥有效;chat 请求仍会使用已配置的 Bearer 密钥认证。个人 plan 仅适用于 interactive/prototype 用途;任意 application 需要使用 Scale plan。密钥可在 [Featherless dashboard](https://featherless.ai/account/api-keys) 创建。 > **Baseten 范围:**该预设仅覆盖 Baseten 的共享 [Model APIs](https://docs.baseten.co/inference/model-apis/overview)。 From 61c09984643dedb29c0131e19614b126b2c9b972 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:27:26 +0200 Subject: [PATCH 4/5] Omit parallel_tool_calls when provider opts out; pin false for NIM only. Featherless and other parallel-disabled OpenAI-compatible hosts should not receive an unsupported parallel_tool_calls field. NVIDIA NIM still sends explicit false because kimi rejects true and the registry documents that pin. --- src/adapters/openai-chat.ts | 13 ++++++++++--- tests/chutes-provider.test.ts | 2 +- tests/digitalocean-scaleway-provider.test.ts | 2 +- tests/featherless-provider.test.ts | 2 +- tests/nscale-vultr-provider.test.ts | 2 +- tests/parallel-tool-calls-optin.test.ts | 4 ++-- tests/sambanova-nebius-provider.test.ts | 2 +- 7 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 5065dca6da..327edb5d80 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -829,9 +829,16 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd } if (tools) { - body.parallel_tool_calls = provider.parallelToolCalls === false - ? false - : parsed.options.parallelToolCalls !== false; + if (provider.parallelToolCalls === false) { + // NIM documents the Boolean defaulting to false and kimi rejects true; pin the + // wire bit so Codex cannot opt in via request.options. Other opted-out providers + // omit the field so strict OpenAI-compatible hosts never see an unsupported knob. + if (provider.baseUrl === "https://integrate.api.nvidia.com/v1") { + body.parallel_tool_calls = false; + } + } else { + body.parallel_tool_calls = parsed.options.parallelToolCalls !== false; + } } if (parsed.stream) body.stream_options = { include_usage: true }; diff --git a/tests/chutes-provider.test.ts b/tests/chutes-provider.test.ts index 226d5fe86c..b7e945023e 100644 --- a/tests/chutes-provider.test.ts +++ b/tests/chutes-provider.test.ts @@ -197,7 +197,7 @@ describe("Chutes provider", () => { expect(request.url).toBe(`${BASE_URL}/chat/completions`); expect(request.headers.Authorization).toBe(`Bearer ${TEST_KEY}`); expect(body.model).toBe(modelId); - expect(body.parallel_tool_calls).toBe(false); + expect(body).not.toHaveProperty("parallel_tool_calls"); expect(body).not.toHaveProperty("reasoning_effort"); }); diff --git a/tests/digitalocean-scaleway-provider.test.ts b/tests/digitalocean-scaleway-provider.test.ts index fa36c02c9a..c87433414e 100644 --- a/tests/digitalocean-scaleway-provider.test.ts +++ b/tests/digitalocean-scaleway-provider.test.ts @@ -305,7 +305,7 @@ describe("DigitalOcean and Scaleway providers", () => { expect(request.url).toBe(`${PROVIDERS[providerId].baseUrl}/chat/completions`); expect(request.headers.Authorization).toBe(`Bearer ${PROVIDERS[providerId].key}`); expect(body.model).toBe(modelId); - expect(body.parallel_tool_calls).toBe(false); + expect(body).not.toHaveProperty("parallel_tool_calls"); expect(body).not.toHaveProperty("reasoning_effort"); } }); diff --git a/tests/featherless-provider.test.ts b/tests/featherless-provider.test.ts index 38515e5155..7316f02f8d 100644 --- a/tests/featherless-provider.test.ts +++ b/tests/featherless-provider.test.ts @@ -217,7 +217,7 @@ describe("Featherless provider", () => { expect(request.url).toBe(`${BASE_URL}/chat/completions`); expect(request.headers.Authorization).toBe(`Bearer ${TEST_KEY}`); expect(body.model).toBe(modelId); - expect(body.parallel_tool_calls).toBe(false); + expect(body).not.toHaveProperty("parallel_tool_calls"); expect(body).not.toHaveProperty("reasoning_effort"); }); diff --git a/tests/nscale-vultr-provider.test.ts b/tests/nscale-vultr-provider.test.ts index 5ea6b3dae5..37042629d9 100644 --- a/tests/nscale-vultr-provider.test.ts +++ b/tests/nscale-vultr-provider.test.ts @@ -228,7 +228,7 @@ describe("Nscale and Vultr provider presets", () => { expect(request.headers.Authorization).toBe(`Bearer ${API_KEY}`); expect(body.model).toBe(modelId); expect(body.tools).toBeArray(); - expect(body.parallel_tool_calls).toBe(false); + expect(body).not.toHaveProperty("parallel_tool_calls"); expect(body).not.toHaveProperty("reasoning_effort"); } }); diff --git a/tests/parallel-tool-calls-optin.test.ts b/tests/parallel-tool-calls-optin.test.ts index 667f27014b..5d397a6224 100644 --- a/tests/parallel-tool-calls-optin.test.ts +++ b/tests/parallel-tool-calls-optin.test.ts @@ -37,10 +37,10 @@ describe("parallel tool calls provider opt-in (request body)", () => { expect(body.parallel_tool_calls).toBe(true); }); - test("explicit provider false overrides even a permissive request bit", () => { + test("explicit provider false overrides even a permissive request bit by omitting parallel_tool_calls", () => { const adapter = createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", apiKey: "k", parallelToolCalls: false }); const body = JSON.parse(adapter.buildRequest(parsedRequest({ parallelToolCalls: true })).body) as Record; - expect(body.parallel_tool_calls).toBe(false); + expect(body).not.toHaveProperty("parallel_tool_calls"); }); }); diff --git a/tests/sambanova-nebius-provider.test.ts b/tests/sambanova-nebius-provider.test.ts index 4193e7332c..9313daf9e5 100644 --- a/tests/sambanova-nebius-provider.test.ts +++ b/tests/sambanova-nebius-provider.test.ts @@ -336,7 +336,7 @@ describe("SambaNova and Nebius providers", () => { expect(request.url).toBe(`${PROVIDERS[providerId].baseUrl}/chat/completions`); expect(request.headers.Authorization).toBe(`Bearer ${PROVIDERS[providerId].key}`); expect(body.model).toBe(modelId); - expect(body.parallel_tool_calls).toBe(false); + expect(body).not.toHaveProperty("parallel_tool_calls"); expect(body).not.toHaveProperty("reasoning_effort"); } }); From 92618d2fbfb83e4c078b200c6d73e679367eae83 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:40:50 +0200 Subject: [PATCH 5/5] Emit parallel_tool_calls only when provider explicitly opts in. The openai-chat adapter now sends parallel_tool_calls only for providers with parallelToolCalls: true. Opted-out and unset providers omit the field; NIM still pins false. --- src/adapters/openai-chat.ts | 2 +- tests/commandcode-provider.test.ts | 6 +++--- tests/parallel-tool-calls-optin.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 327edb5d80..539299ec6d 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -836,7 +836,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd if (provider.baseUrl === "https://integrate.api.nvidia.com/v1") { body.parallel_tool_calls = false; } - } else { + } else if (provider.parallelToolCalls === true) { body.parallel_tool_calls = parsed.options.parallelToolCalls !== false; } } diff --git a/tests/commandcode-provider.test.ts b/tests/commandcode-provider.test.ts index c1daadbc78..695b3954d0 100644 --- a/tests/commandcode-provider.test.ts +++ b/tests/commandcode-provider.test.ts @@ -148,8 +148,8 @@ describe("Command Code provider", () => { commandcodeConfig(), "commandcode/deepseek/deepseek-v4-flash", ); - // No explicit parallel-tool-calls claim is persisted for Command Code; the openai-chat - // adapter applies its default-on wire behavior (see below) unless a provider opts out. + // Command Code does not opt into parallel_tool_calls at the provider level, so the + // openai-chat adapter omits the field unless parallelToolCalls is explicitly true. expect(route.provider.parallelToolCalls).toBeUndefined(); expect(route.modelId).toBe("deepseek/deepseek-v4-flash"); @@ -167,7 +167,7 @@ describe("Command Code provider", () => { expect(request.url).toBe("https://api.commandcode.ai/provider/v1/chat/completions"); expect(request.headers.Authorization).toBe("Bearer cmd-test-key"); expect(body.model).toBe("deepseek/deepseek-v4-flash"); - expect(body.parallel_tool_calls).toBe(true); + expect(body).not.toHaveProperty("parallel_tool_calls"); }); test("discovers the live catalog with context windows and preserves slash ids", async () => { diff --git a/tests/parallel-tool-calls-optin.test.ts b/tests/parallel-tool-calls-optin.test.ts index 5d397a6224..2252220cff 100644 --- a/tests/parallel-tool-calls-optin.test.ts +++ b/tests/parallel-tool-calls-optin.test.ts @@ -31,10 +31,10 @@ describe("parallel tool calls provider opt-in (request body)", () => { expect(body.parallel_tool_calls).toBe(false); }); - test("default chat provider (no flag, zai-like) now sends parallel_tool_calls:true (default-on)", () => { + test("default chat provider without explicit opt-in omits parallel_tool_calls", () => { const adapter = createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl: "https://api.z.ai/api/coding/paas/v4", apiKey: "k" }); const body = JSON.parse(adapter.buildRequest(parsedRequest()).body) as Record; - expect(body.parallel_tool_calls).toBe(true); + expect(body).not.toHaveProperty("parallel_tool_calls"); }); test("explicit provider false overrides even a permissive request bit by omitting parallel_tool_calls", () => {