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..917645092f 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. 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). + > **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..607c7d5a80 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 しません。`/v1/models` は認証あり・なしの両方で呼び出せると文書化されているため、入力したキーの有効性は証明できませんが、chat request は設定済みの 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) > のみを対象とします。ローカル利用では個人の [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..219cdaa8e9 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를 다운로드하거나 캐시하지 않습니다. `/v1/models`는 문서상 인증 여부와 관계없이 호출할 수 있어 입력한 키의 유효성을 증명할 수 없지만, chat request는 설정된 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)만 > 지원합니다. 로컬 사용에는 개인 [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..7389f79312 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,15 @@ 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 +исходными строками, поэтому каталог из десятков тысяч моделей не загружается и не кэшируется целиком. +`/v1/models` описан как вызываемый как с аутентификацией, так и без неё, поэтому не может подтвердить корректность введённого ключа, но chat-запросы всё равно аутентифицируются настроенным Bearer-ключом. +Индивидуальные 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..cb5a48a809 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 条原始记录,因此不会下载或 +缓存包含数万模型的完整目录。由于 `/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)。 > 本地使用可选择个人 [API 密钥](https://docs.baseten.co/organization/api-keys);共享或生产用途请使用具备 > **Call Model APIs** 权限的团队密钥。 diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 5065dca6da..539299ec6d 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 if (provider.parallelToolCalls === true) { + body.parallel_tool_calls = parsed.options.parallelToolCalls !== false; + } } if (parsed.stream) body.stream_options = { include_usage: true }; 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..1598997ebe 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1673,6 +1673,53 @@ 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, + // /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. + 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/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/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/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 new file mode 100644 index 0000000000..7316f02f8d --- /dev/null +++ b/tests/featherless-provider.test.ts @@ -0,0 +1,253 @@ +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, + apiKeyValidation: "unknown", + 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 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}` }); + + 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("unknown"); + }); + + 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).not.toHaveProperty("parallel_tool_calls"); + 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/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..2252220cff 100644 --- a/tests/parallel-tool-calls-optin.test.ts +++ b/tests/parallel-tool-calls-optin.test.ts @@ -31,16 +31,16 @@ 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", () => { + 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/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", 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"); } });