diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index fa39fef46..f9bdcb466 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 69 built-in registry presets or `custom` to type a base +1. **Pick a provider** — choose one of the 71 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 5fce1c79c..2ac8c2169 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -190,7 +190,7 @@ selectors, then retry. Signing in from a machine with no existing `kiro-cli` ses ## 3. API-key catalog -opencodex ships 69 built-in presets: 58 key-based, seven OAuth, three local, and one default +opencodex ships 71 built-in presets: 60 key-based, seven 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, and Command Code's public catalog reports keys as unverifiable. Notable entries: @@ -227,6 +227,8 @@ free-experimentation model. | Hyperbolic | `https://api.hyperbolic.xyz/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | | Command Code | `https://api.commandcode.ai/provider/v1` | +| SambaNova Cloud | `https://api.sambanova.ai/v1` | +| Nebius Token Factory | `https://api.tokenfactory.nebius.com/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` | @@ -282,6 +284,18 @@ instead of a false positive. Chat requests use the configured Bearer key; API ac Provider plan, and CLI auth bridging for Go/Pro subscriptions is not yet available. Create keys at [Command Code Studio](https://commandcode.ai/studio/). +**SambaNova Cloud discovery.** The preset reads SambaNova Cloud's public `/v1/models` list from the fixed API +host, preserves provider-native ids, and caps discovery at 128 KiB and 128 raw rows. Chat requests +use the configured Bearer key and disable parallel function calls, which SambaNova does not yet +support. Private SambaStudio deployment endpoints are out of scope. Create keys in +[SambaNova Cloud](https://cloud.sambanova.ai/apis). + +**Nebius Token Factory discovery.** The preset requests the authenticated verbose model catalog and +keeps only rows whose architecture produces text, excluding embedding and image-generation models. +It preserves slash-containing native ids plus reported context and input-modality metadata, and caps +discovery at 512 KiB and 512 raw rows. Dedicated deployment hosts are out of scope. Create keys in +[Nebius Token Factory](https://tokenfactory.nebius.com). + > **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 41b7e8343..0ca023715 100644 --- a/docs-site/src/content/docs/ja/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ja/getting-started/quickstart.md @@ -13,8 +13,7 @@ ocx init `ocx init` では次の手順を説明します。 -1. **プロバイダーを選択してください** — 69 の組み込みレジストリ プリセットのいずれか、または `custom` を選択してベースを入力します -URLとアダプター。 +1. **プロバイダーを選択してください** — 71 個の組み込みレジストリプリセットのいずれか、または `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 d55a8a843..2dd4a13a3 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -119,7 +119,7 @@ Kiro のログインには Kiro CLI が必要です。Unix では `curl -fsSL ht ## 3. API キーカタログ -opencodex には組み込みプリセットが 69 個含まれています。キー方式 58、OAuth 7、ローカル 3、 +opencodex には組み込みプリセットが 71 個含まれています。キー方式 60、OAuth 7、ローカル 3、 デフォルト ChatGPT 転送プリセット 1 です。ダッシュボードの **Add provider** ピッカーはキー発行ページを開き、 入力したキーを検証した後保存します(検証はプロバイダー固有で、Command Code の公開カタログはキーを 検証不能として報告します)。主な項目は以下のとおりです: @@ -156,6 +156,8 @@ Cline IDE/CLI のみで API からは使えません。`minimax/minimax-m2.5` | Hyperbolic | `https://api.hyperbolic.xyz/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | | Command Code | `https://api.commandcode.ai/provider/v1` | +| SambaNova Cloud | `https://api.sambanova.ai/v1` | +| Nebius Token Factory | `https://api.tokenfactory.nebius.com/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` | @@ -205,6 +207,18 @@ Provider API ホストから読み、スラッシュを含むネイティブモ API アクセスには Provider プランが必要で、Go/Pro サブスクリプション向けの CLI 認証ブリッジはまだ利用できません。 キーは [Command Code Studio](https://commandcode.ai/studio/) で作成します。 +**SambaNova Cloud の discovery:** preset は固定 API ホスト上の SambaNova Cloud の公開 `/v1/models` 一覧を読み、 +プロバイダー固有の ID を保持し、discovery を 128 KiB と raw 128 行に制限します。chat リクエストは +設定済み Bearer キーを使い、SambaNova がまだ対応していない並列 function call は無効にします。 +非公開の SambaStudio deployment endpoint は対象外です。キーは +[SambaNova Cloud](https://cloud.sambanova.ai/apis) で作成します。 + +**Nebius Token Factory の discovery:** preset は認証付きの verbose モデルカタログを取得し、architecture +が text を出力する行だけを残して embedding と image-generation モデルを除外します。スラッシュを含む +ネイティブ ID と、報告された context / input modality metadata を保持し、discovery を 512 KiB と raw +512 行に制限します。dedicated deployment のホストは対象外です。キーは +[Nebius Token Factory](https://tokenfactory.nebius.com) で作成します。 + > **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 9692a44e7..2f79bd740 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. **프로바이더 선택** — 내장 레지스트리 프리셋 69개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. +1. **프로바이더 선택** — 내장 레지스트리 프리셋 71개 중 하나를 고르거나 `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 ce1609aba..e3fa939c0 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -119,7 +119,7 @@ Kiro 로그인에는 Kiro CLI가 필요합니다. Unix에서는 `curl -fsSL http ## 3. API 키 카탈로그 -opencodex에는 빌트인 프리셋이 69개 들어 있습니다. 키 방식 58개, OAuth 7개, 로컬 3개, +opencodex에는 빌트인 프리셋이 71개 들어 있습니다. 키 방식 60개, OAuth 7개, 로컬 3개, 기본 ChatGPT 포워드 프리셋 1개입니다. 대시보드의 **Add provider** 선택기는 키 발급 페이지를 열고, 입력한 키를 검증한 뒤 저장합니다(검증은 프로바이더별로 다르며, Command Code의 공개 카탈로그는 키를 검증 불가로 보고합니다). 주요 항목은 다음과 같습니다: @@ -157,6 +157,8 @@ Cline IDE/CLI에서만 제공되며 API로는 사용할 수 없습니다. `minim | Hyperbolic | `https://api.hyperbolic.xyz/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | | Command Code | `https://api.commandcode.ai/provider/v1` | +| SambaNova Cloud | `https://api.sambanova.ai/v1` | +| Nebius Token Factory | `https://api.tokenfactory.nebius.com/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` | @@ -205,6 +207,17 @@ Bearer API 키를 사용합니다. registry가 소유하는 DeepInfra 모델 목 필요하고 Go/Pro 구독자용 CLI 인증 브리지는 아직 제공되지 않습니다. 키는 [Command Code Studio](https://commandcode.ai/studio/)에서 생성합니다. +**SambaNova Cloud 검색:** 프리셋은 고정 API 호스트의 SambaNova Cloud 공개 `/v1/models` 목록을 읽고, 프로바이더 +네이티브 ID를 보존하며 discovery를 128 KiB와 raw 행 128개로 제한합니다. chat 요청은 설정된 Bearer +키를 사용하고 SambaNova가 아직 지원하지 않는 병렬 function call은 비활성화합니다. 비공개 SambaStudio +deployment 엔드포인트는 범위에서 제외합니다. 키는 [SambaNova Cloud](https://cloud.sambanova.ai/apis)에서 생성합니다. + +**Nebius Token Factory 검색:** 프리셋은 인증된 verbose 모델 카탈로그를 요청하고 architecture가 text를 +출력하는 행만 유지해 embedding 및 image-generation 모델을 제외합니다. 슬래시가 포함된 네이티브 ID와 +보고된 context 및 input modality metadata를 보존하며 discovery를 512 KiB와 raw 행 512개로 제한합니다. +dedicated deployment 호스트는 범위에서 제외합니다. 키는 +[Nebius Token Factory](https://tokenfactory.nebius.com)에서 생성합니다. + > **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 ee54838ea..701d78d2a 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. **Выбор провайдера** — выберите один из 69 встроенных пресетов реестра или `custom`, чтобы +1. **Выбор провайдера** — выберите один из 71 встроенного пресета реестра или `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 2ef2dfdb1..7bd170853 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -126,7 +126,7 @@ OAuth-провайдеры, чьи учётные данные содержат ## 3. Каталог API-ключей -opencodex поставляется с 69 встроенными пресетами: 58 на основе ключей, семь OAuth, три локальных и +opencodex поставляется с 71 встроенным пресетом: 60 на основе ключей, семь OAuth, три локальных и один пресет ChatGPT-форварда по умолчанию. Селектор **Add provider** в дашборде открывает страницу выдачи ключей провайдера, проверяет ключ и сохраняет его; проверка зависит от провайдера, а публичный каталог Command Code сообщает ключ как непроверенный. Наиболее заметные записи: @@ -163,6 +163,8 @@ opencodex поставляется с 69 встроенными пресетам | Hyperbolic | `https://api.hyperbolic.xyz/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | | Command Code | `https://api.commandcode.ai/provider/v1` | +| SambaNova Cloud | `https://api.sambanova.ai/v1` | +| Nebius Token Factory | `https://api.tokenfactory.nebius.com/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` | @@ -214,6 +216,18 @@ endpoint в него не входят. Ключи создаются в [Hyperb настроенный bearer-ключ; для доступа к API требуется план Provider, а CLI-мост аутентификации для подписок Go/Pro пока недоступен. Ключи создаются в [Command Code Studio](https://commandcode.ai/studio/). +**Discovery для SambaNova Cloud.** Пресет читает общедоступный список SambaNova Cloud `/v1/models` на +фиксированном API-хосте, сохраняет нативные id провайдера и ограничивает discovery размером 128 KiB +и 128 исходными строками. Chat-запросы используют настроенный Bearer-ключ; параллельные вызовы +функций отключены, поскольку SambaNova пока их не поддерживает. Частные endpoint развёртываний +SambaStudio не входят в область пресета. Ключи создаются в [SambaNova Cloud](https://cloud.sambanova.ai/apis). + +**Discovery для Nebius Token Factory.** Пресет запрашивает аутентифицированный verbose-каталог и +оставляет только модели, architecture которых выдаёт текст, исключая embedding и image-generation. +Он сохраняет нативные id со знаком `/`, а также заявленные context и input-modality metadata, и +ограничивает discovery размером 512 KiB и 512 исходными строками. Хосты dedicated deployment не +входят в область пресета. Ключи создаются в [Nebius Token Factory](https://tokenfactory.nebius.com). + > **Область 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 0544b8f63..ce0b52ac6 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 的 69 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 +1. **选择 provider** — 从内置 registry 的 71 个预设中选择一个,或选择 `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 2680857dc..674dcdc91 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -111,7 +111,7 @@ Kiro 登录需要 Kiro CLI:Unix 使用 `curl -fsSL https://cli.kiro.dev/instal ## 3. API 密钥目录 -opencodex 内置 69 个预设:58 个密钥预设、7 个 OAuth 预设、3 个本地预设,以及 1 个默认的 +opencodex 内置 71 个预设:60 个密钥预设、7 个 OAuth 预设、3 个本地预设,以及 1 个默认的 ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提供商的控制台,验证并保存密钥。 验证因提供商而异,Command Code 的公开目录会将密钥报告为无法验证。主要条目包括: @@ -147,6 +147,8 @@ Cline IDE/CLI 中提供,不能通过 API 使用;`minimax/minimax-m2.5` 是 | Hyperbolic | `https://api.hyperbolic.xyz/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | | Command Code | `https://api.commandcode.ai/provider/v1` | +| SambaNova Cloud | `https://api.sambanova.ai/v1` | +| Nebius Token Factory | `https://api.tokenfactory.nebius.com/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` | @@ -191,6 +193,16 @@ image、audio 和 GPU 端点不在范围内。密钥可在 [Hyperbolic](https:// bearer 密钥;API 访问需要 Provider 套餐,Go/Pro 订阅用户的 CLI 认证桥接尚不可用。 密钥可在 [Command Code Studio](https://commandcode.ai/studio/) 创建。 +**SambaNova Cloud 发现:**该预设从固定 API 主机读取 SambaNova Cloud 的公开 `/v1/models` 列表,保留提供商原生 +模型 id,并将发现限制为 128 KiB 和 128 条原始记录。chat 请求使用已配置的 Bearer 密钥;由于 +SambaNova 尚不支持并行 function call,该能力会被禁用。私有 SambaStudio 部署端点不在范围内。 +密钥可在 [SambaNova Cloud](https://cloud.sambanova.ai/apis) 创建。 + +**Nebius Token Factory 发现:**该预设请求需要鉴权的 verbose 模型目录,仅保留 architecture 输出 text +的记录,从而排除 embedding 和 image-generation 模型。它保留含 `/` 的原生模型 id、上游报告的 context +和 input modality metadata,并将发现限制为 512 KiB 和 512 条原始记录。dedicated deployment 主机不在 +范围内。密钥可在 [Nebius Token Factory](https://tokenfactory.nebius.com) 创建。 + > **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 5422e1db5..bac4b3b97 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -358,6 +358,17 @@ function modelInputModalities( value === "text" || value === "image" || value === "audio" )); if (explicit && explicit.length > 0) return explicit; + const architecture = plainRecord(item.architecture); + const architectureModality = typeof architecture?.modality === "string" + ? normalizedMetadataString(architecture.modality, 64) + : undefined; + if (architectureModality?.includes("->")) { + const [rawInput] = architectureModality.split("->"); + const inferred = rawInput + ?.split("+") + .filter(value => value === "text" || value === "image" || value === "audio"); + if (inferred && 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")) { return ["text", "image"]; diff --git a/src/providers/free-directory.ts b/src/providers/free-directory.ts index 9d96063dd..6ae2c81f2 100644 --- a/src/providers/free-directory.ts +++ b/src/providers/free-directory.ts @@ -99,7 +99,7 @@ const CONNECTABLE: Record = { ovhcloud: openAi("https://oai.endpoints.kepler.ai.cloud.ovh.net/v1", "https://console.ovhcloud.com", { keyOptional: true, modelsUrl: "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/models" }), pollinations: openAi("https://gen.pollinations.ai/v1", "https://pollinations.ai", { keyOptional: true }), reka: openAi("https://api.reka.ai/v1", "https://platform.reka.ai"), - sambanova: openAi("https://api.sambanova.ai/v1", "https://cloud.sambanova.ai/apis", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.sambanova.ai/cloud/docs/api-reference/models" }), + sambanova: openAi("https://api.sambanova.ai/v1", "https://cloud.sambanova.ai/apis", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.sambanova.ai/docs/en/get-started/api-keys-urls", modelsUrl: "https://api.sambanova.ai/v1/models", lastVerified: "2026-08-01" }), sparkdesk: openAi("https://spark-api-open.xf-yun.com/v1", "https://console.xfyun.cn", { verification: "official", documentationUrl: "https://www.xfyun.cn/doc/spark/HTTP%E8%B0%83%E7%94%A8%E6%96%87%E6%A1%A3.html" }), agnes: openAi("https://apihub.agnes-ai.com/v1", "https://agnes-ai.com"), ainative: openAi("https://api.ainative.studio/api/v1", "https://ainative.studio", { modelsUrl: "https://api.ainative.studio/api/v1/models" }), @@ -132,7 +132,7 @@ const CONNECTABLE: Record = { hyperbolic: openAi("https://api.hyperbolic.xyz/v1", "https://app.hyperbolic.xyz/settings", { verification: "official" }), longcat: openAi("https://api.longcat.chat/openai/v1", "https://longcat.chat", { verification: "official", discovery: "static", liveModels: false, models: ["LongCat-2.0"] }), monsterapi: openAi("https://api.monsterapi.ai/v1", "https://monsterapi.ai", { verification: "official" }), - nebius: openAi("https://api.tokenfactory.nebius.com/v1", "https://studio.nebius.com", { verification: "official" }), + nebius: openAi("https://api.tokenfactory.nebius.com/v1", "https://tokenfactory.nebius.com", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.tokenfactory.nebius.com/quickstart", modelsUrl: "https://api.tokenfactory.nebius.com/v1/models?verbose=true", lastVerified: "2026-08-01" }), novita: openAi("https://api.novita.ai/openai/v1", "https://novita.ai/settings/key-management", { supportLevel: "supported", verification: "official", modelsUrl: "https://api.novita.ai/openai/v1/models" }), nscale: openAi("https://inference.api.nscale.com/v1", "https://console.nscale.com", { verification: "official" }), nvidia: openAi("https://integrate.api.nvidia.com/v1", "https://build.nvidia.com", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.api.nvidia.com/nim/reference/llm-apis" }), diff --git a/src/providers/model-discovery.ts b/src/providers/model-discovery.ts index dc679315d..d2bb5bbd8 100644 --- a/src/providers/model-discovery.ts +++ b/src/providers/model-discovery.ts @@ -2,6 +2,7 @@ import type { OcxProviderConfig } from "../types"; import { getProviderRegistryEntry, providerMatchesRegistryTransport, + registryEntryForProviderDestination, type ProviderModelDiscoveryFilter, type ProviderModelDiscoveryPredicate, type ProviderModelDiscoveryScalar, @@ -124,9 +125,14 @@ export function resolveProviderModelDiscovery( providerName: string, provider: Pick & Partial>, ): ResolvedProviderModelDiscovery { - const entry = providerMatchesRegistryTransport(providerName, provider) - ? getProviderRegistryEntry(providerName) - : undefined; + // The dashboard permits a canonical preset to be saved under a different name. Recover its + // registry-owned discovery policy by transport in that case. The destination helper is limited + // to exact fixed-key baseUrl + adapter matches, so custom endpoints, OAuth rows, templates, and + // overridable destinations cannot acquire another provider's discovery URL or filter. + const namedEntry = getProviderRegistryEntry(providerName); + const entry = namedEntry + ? (providerMatchesRegistryTransport(providerName, provider) ? namedEntry : undefined) + : registryEntryForProviderDestination(provider); const spec = entry?.modelDiscovery; return { ...(spec ? { spec } : {}), diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 91d3ad07d..18d14618c 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1199,6 +1199,53 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // 401 UNAUTHORIZED without a Bearer key. Primary source: https://commandcode.ai/docs/provider. note: "Command Code Provider API (OpenAI-compatible); API access requires the Provider plan. CLI auth bridging for Go/Pro subscriptions is not yet available. Docs: https://commandcode.ai/docs/provider.", }, + { + id: "sambanova", + label: "SambaNova Cloud", + baseUrl: "https://api.sambanova.ai/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://cloud.sambanova.ai/apis", + liveModels: true, + preserveCustomDestination: true, + apiKeyValidation: "unknown", + // SambaNova documents this request field but does not yet support parallel function calls. + parallelToolCalls: false, + // The public catalog does not report a trustworthy per-model reasoning contract. + reasoningEfforts: [], + modelDiscovery: { + path: "models", + maxResponseBytes: 128 * 1024, + maxModels: 128, + }, + note: "SambaNova Cloud text-generation models only; private SambaStudio deployment endpoints are outside this preset.", + }, + { + id: "nebius", + label: "Nebius Token Factory", + baseUrl: "https://api.tokenfactory.nebius.com/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://tokenfactory.nebius.com", + liveModels: true, + preserveCustomDestination: true, + // The public tools guide documents single function selection, not parallel tool calls. + parallelToolCalls: false, + // Missing reasoning metadata must not promote a model to Codex's full fallback ladder. + reasoningEfforts: [], + modelDiscovery: { + path: "models", + query: { verbose: "true" }, + maxResponseBytes: 512 * 1024, + maxModels: 512, + filter: { + // Token Factory's verbose catalog also contains embeddings and image generation. + // Keep text- and vision-input rows whose output modality is text. + allOf: [{ path: ["architecture", "modality"], containsAny: ["->text"] }], + }, + }, + note: "Shared Token Factory text-output inference only; live discovery excludes embedding and image-generation rows.", + }, // 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/fixtures/nebius-models.json b/tests/fixtures/nebius-models.json new file mode 100644 index 000000000..fa2c3bd1f --- /dev/null +++ b/tests/fixtures/nebius-models.json @@ -0,0 +1,51 @@ +{ + "object": "list", + "data": [ + { + "id": "meta-llama/Meta-Llama-3.1-8B-Instruct-fast", + "name": "Meta-Llama-3.1-8B-Instruct (fast)", + "object": "model", + "owned_by": "system", + "context_length": 131072, + "architecture": { + "modality": "text->text", + "tokenizer": "Other" + }, + "supported_features": ["function-calling", "json-mode"] + }, + { + "id": "Qwen/Qwen3-VL-235B-A22B-Instruct", + "name": "Qwen3 VL 235B Instruct", + "object": "model", + "owned_by": "system", + "context_length": 262144, + "architecture": { + "modality": "text+image->text", + "tokenizer": "Qwen" + }, + "supported_features": ["function-calling"] + }, + { + "id": "BAAI/bge-en-icl", + "name": "BGE English ICL", + "object": "model", + "owned_by": "system", + "context_length": 8192, + "architecture": { + "modality": "text->embedding", + "tokenizer": "Bert" + } + }, + { + "id": "black-forest-labs/FLUX.1-dev", + "name": "FLUX.1 Dev", + "object": "model", + "owned_by": "system", + "context_length": 512, + "architecture": { + "modality": "text->image", + "tokenizer": "Other" + } + } + ] +} diff --git a/tests/fixtures/sambanova-models.json b/tests/fixtures/sambanova-models.json new file mode 100644 index 000000000..cd6b7362c --- /dev/null +++ b/tests/fixtures/sambanova-models.json @@ -0,0 +1,21 @@ +{ + "object": "list", + "data": [ + { + "id": "Meta-Llama-3.3-70B-Instruct", + "object": "model", + "owned_by": "sambanova", + "context_length": 131072, + "max_completion_tokens": 3072, + "sn_metadata": {} + }, + { + "id": "gpt-oss-120b", + "object": "model", + "owned_by": "sambanova", + "context_length": 131072, + "max_completion_tokens": 131072, + "sn_metadata": {} + } + ] +} diff --git a/tests/provider-model-discovery-contract.test.ts b/tests/provider-model-discovery-contract.test.ts index 80dd02fa3..105f1adb2 100644 --- a/tests/provider-model-discovery-contract.test.ts +++ b/tests/provider-model-discovery-contract.test.ts @@ -13,7 +13,11 @@ import { readBoundedDiscoveryJson, resolveProviderModelDiscovery, } from "../src/providers/model-discovery"; -import { PROVIDER_REGISTRY, type ProviderModelDiscoverySpec } from "../src/providers/registry"; +import { + PROVIDER_REGISTRY, + registryEntryForProviderDestination, + type ProviderModelDiscoverySpec, +} from "../src/providers/registry"; import { routeModel } from "../src/router"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; @@ -104,6 +108,18 @@ describe("registry-owned provider model discovery", () => { } }); + test("keeps discovery-bearing fixed key destinations unambiguous for renamed presets", () => { + for (const entry of PROVIDER_REGISTRY) { + if (!entry.modelDiscovery || entry.authKind !== "key") continue; + if (entry.allowBaseUrlOverride || /\{[^}]*\}/.test(entry.baseUrl)) continue; + expect(registryEntryForProviderDestination({ + adapter: entry.adapter, + baseUrl: entry.baseUrl, + authMode: "key", + })?.id).toBe(entry.id); + } + }); + test("derives an alternate path and query only for the canonical destination", async () => { await withTogetherDiscovery({ path: "catalog", @@ -112,6 +128,14 @@ describe("registry-owned provider model discovery", () => { const canonical = buildModelsRequest(togetherConfig().providers.together!, "secret", "together"); expect(canonical.url).toBe("https://api.together.xyz/v1/catalog?capability=chat&limit=100"); + const renamedCanonical = buildModelsRequest( + togetherConfig().providers.together!, + "secret", + "together-team", + ); + expect(renamedCanonical.url) + .toBe("https://api.together.xyz/v1/catalog?capability=chat&limit=100"); + const collidingCustom: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://custom.example/v9", @@ -233,6 +257,23 @@ describe("registry-owned provider model discovery", () => { })).toEqual({}); }); + test("infers Codex-safe input modalities from bounded architecture metadata", () => { + expect(catalogHintsFromModelsApiItem("example", { + id: "vision-chat", + architecture: { modality: "text+image->text" }, + })).toEqual({ inputModalities: ["text", "image"] }); + + expect(catalogHintsFromModelsApiItem("example", { + id: "unknown-input", + architecture: { modality: "text+video->text" }, + })).toEqual({ inputModalities: ["text"] }); + + expect(catalogHintsFromModelsApiItem("example", { + id: "controlled", + architecture: { modality: "text+im\u0000age->text" }, + })).toEqual({}); + }); + test("drops untrusted metadata tokens containing control characters", () => { expect(catalogHintsFromModelsApiItem("example", { id: "controlled", diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index a1be64d29..63c5d15ed 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", "deepinfra", "hyperbolic", "baseten", "commandcode", "together", "fireworks", "firepass", "moonshot", + "deepseek", "cerebras", "deepinfra", "hyperbolic", "baseten", "commandcode", "sambanova", "nebius", "together", "fireworks", "firepass", "moonshot", "huggingface", "nvidia", "venice", "zai", "zhipu-bigmodel", "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 new file mode 100644 index 000000000..e5ce340e2 --- /dev/null +++ b/tests/sambanova-nebius-provider.test.ts @@ -0,0 +1,371 @@ +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 { 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 { FREE_PROVIDER_DIRECTORY } from "../src/providers/free-directory"; +import { resolveProviderModelDiscovery } from "../src/providers/model-discovery"; +import { PROVIDER_REGISTRY, type ProviderRegistryEntry } 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 SAMBANOVA_FIXTURE = readFileSync(join(import.meta.dir, "fixtures/sambanova-models.json"), "utf8"); +const NEBIUS_FIXTURE = readFileSync(join(import.meta.dir, "fixtures/nebius-models.json"), "utf8"); +const originalFetch = globalThis.fetch; + +type ProviderId = "sambanova" | "nebius"; + +const PROVIDERS: Record = { + sambanova: { + baseUrl: "https://api.sambanova.ai/v1", + dashboardUrl: "https://cloud.sambanova.ai/apis", + fixture: SAMBANOVA_FIXTURE, + key: "sambanova-test-key", + modelsUrl: "https://api.sambanova.ai/v1/models", + }, + nebius: { + baseUrl: "https://api.tokenfactory.nebius.com/v1", + dashboardUrl: "https://tokenfactory.nebius.com", + fixture: NEBIUS_FIXTURE, + key: "nebius-test-key", + modelsUrl: "https://api.tokenfactory.nebius.com/v1/models?verbose=true", + }, +}; + +afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache("sambanova"); + clearModelCache("nebius"); + clearModelCache("nebius-team"); +}); + +function registryEntry(id: ProviderId): ProviderRegistryEntry { + const entry = PROVIDER_REGISTRY.find(row => row.id === id); + if (!entry) throw new Error(`missing ${id} registry entry`); + return entry; +} + +function providerConfig(id: ProviderId, overrides: Partial = {}): OcxConfig { + const provider = PROVIDERS[id]; + return { + port: 10100, + defaultProvider: id, + providers: { + [id]: { + adapter: "openai-chat", + baseUrl: provider.baseUrl, + authMode: "key", + apiKey: provider.key, + liveModels: true, + // Discovery stays fixture-only; this avoids platform-specific public-DNS classification. + allowPrivateNetwork: true, + ...overrides, + }, + }, + }; +} + +describe("SambaNova and Nebius providers", () => { + test("registers fixed OpenAI transports with bounded text-output discovery", () => { + expect(registryEntry("sambanova")).toMatchObject({ + id: "sambanova", + label: "SambaNova Cloud", + adapter: "openai-chat", + baseUrl: PROVIDERS.sambanova.baseUrl, + authKind: "key", + dashboardUrl: PROVIDERS.sambanova.dashboardUrl, + liveModels: true, + preserveCustomDestination: true, + apiKeyValidation: "unknown", + parallelToolCalls: false, + reasoningEfforts: [], + modelDiscovery: { + path: "models", + maxResponseBytes: 131_072, + maxModels: 128, + }, + }); + expect(registryEntry("sambanova").note).toContain("SambaStudio"); + + expect(registryEntry("nebius")).toMatchObject({ + id: "nebius", + label: "Nebius Token Factory", + adapter: "openai-chat", + baseUrl: PROVIDERS.nebius.baseUrl, + authKind: "key", + dashboardUrl: PROVIDERS.nebius.dashboardUrl, + liveModels: true, + preserveCustomDestination: true, + parallelToolCalls: false, + reasoningEfforts: [], + modelDiscovery: { + path: "models", + query: { verbose: "true" }, + maxResponseBytes: 524_288, + maxModels: 512, + filter: { + allOf: [{ path: ["architecture", "modality"], containsAny: ["->text"] }], + }, + }, + }); + expect(registryEntry("nebius").note).toContain("embedding and image-generation rows"); + expect(FREE_PROVIDER_DIRECTORY.find(row => row.id === "nebius")).toMatchObject({ + modelsUrl: PROVIDERS.nebius.modelsUrl, + }); + }); + + test("derives CLI and dashboard presets without persisting registry trust policy", () => { + expect(buildInitProviders()).toEqual(deriveInitProviders()); + + for (const id of ["sambanova", "nebius"] as const) { + const entry = registryEntry(id); + const provider = PROVIDERS[id]; + expect(KEY_LOGIN_PROVIDERS[id]).toMatchObject({ + adapter: "openai-chat", + baseUrl: provider.baseUrl, + dashboardUrl: provider.dashboardUrl, + liveModels: true, + ...(id === "sambanova" ? { apiKeyValidation: "unknown" } : {}), + }); + expect(buildInitProviders().find(row => row.id === id)).toMatchObject({ + kind: "key", + adapter: "openai-chat", + baseUrl: provider.baseUrl, + }); + expect(deriveProviderPresets().find(row => row.id === id)).toMatchObject({ + auth: "key", + dashboardUrl: provider.dashboardUrl, + }); + + const seed = providerConfigSeed(entry); + expect(seed).toMatchObject({ + adapter: "openai-chat", + baseUrl: provider.baseUrl, + authMode: "key", + liveModels: true, + parallelToolCalls: false, + reasoningEfforts: [], + }); + expect(seed).not.toHaveProperty("apiKeyValidation"); + expect(seed).not.toHaveProperty("modelDiscovery"); + expect(seed).not.toHaveProperty("preserveCustomDestination"); + expect(KEY_LOGIN_PROVIDERS[id]).not.toHaveProperty("modelDiscovery"); + expect(KEY_LOGIN_PROVIDERS[id]).not.toHaveProperty("preserveCustomDestination"); + } + }); + + test("builds each registry-owned models request and validates the authenticated Nebius catalog", async () => { + for (const id of ["sambanova", "nebius"] as const) { + const provider = PROVIDERS[id]; + expect(buildModelsRequest(providerConfig(id).providers[id]!, provider.key, id)).toEqual({ + url: provider.modelsUrl, + headers: { Authorization: `Bearer ${provider.key}` }, + }); + } + + const provider = PROVIDERS.nebius; + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe(provider.modelsUrl); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${provider.key}`); + expect(init?.redirect).toBe("error"); + return new Response(provider.fixture, { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + expect(await validateApiKey("nebius", KEY_LOGIN_PROVIDERS.nebius!, provider.key)).toBe(true); + }); + + test("does not treat SambaNova's public model catalog as proof that a key is valid", async () => { + let fetchCalled = false; + globalThis.fetch = (async () => { + fetchCalled = true; + return new Response(SAMBANOVA_FIXTURE, { status: 200 }); + }) as typeof fetch; + + expect(await validateApiKey( + "sambanova", + KEY_LOGIN_PROVIDERS.sambanova!, + PROVIDERS.sambanova.key, + )).toBe("unknown"); + expect(fetchCalled).toBe(false); + }); + + test("filters Nebius mixed rows and preserves model metadata and native ids", async () => { + globalThis.fetch = (async (input, init) => { + const url = String(input); + expect(new Headers(init?.headers).get("authorization")).toBeTruthy(); + expect(init?.redirect).toBe("manual"); + if (url === PROVIDERS.sambanova.modelsUrl) { + return new Response(SAMBANOVA_FIXTURE, { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (url === PROVIDERS.nebius.modelsUrl) { + return new Response(NEBIUS_FIXTURE, { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`unexpected discovery URL: ${url}`); + }) as typeof fetch; + + const config = withStubbedProviderFetch({ + port: 10100, + defaultProvider: "sambanova", + providers: { + sambanova: providerConfig("sambanova").providers.sambanova!, + nebius: providerConfig("nebius").providers.nebius!, + }, + } satisfies OcxConfig); + const models = await gatherRoutedModels(config); + const sambanovaModels = models.filter(row => row.provider === "sambanova"); + const nebiusModels = models.filter(row => row.provider === "nebius"); + + expect(sambanovaModels.map(row => row.id)).toEqual([ + "gpt-oss-120b", + "Meta-Llama-3.3-70B-Instruct", + ]); + expect(sambanovaModels[1]).toMatchObject({ + owned_by: "sambanova", + contextWindow: 131_072, + reasoningEfforts: [], + }); + expect(nebiusModels.map(row => row.id)).toEqual([ + "meta-llama/Meta-Llama-3.1-8B-Instruct-fast", + "Qwen/Qwen3-VL-235B-A22B-Instruct", + ]); + expect(nebiusModels[0]).toMatchObject({ + owned_by: "system", + contextWindow: 131_072, + inputModalities: ["text"], + capabilities: ["function-calling", "json-mode"], + reasoningEfforts: [], + }); + expect(nebiusModels[1]).toMatchObject({ + contextWindow: 262_144, + inputModalities: ["text", "image"], + }); + + const selectors = [ + ["sambanova", "Meta-Llama-3.3-70B-Instruct"], + ["nebius", "meta-llama/Meta-Llama-3.1-8B-Instruct-fast"], + ] as const; + for (const [providerId, modelId] of selectors) { + expect(routeModel(config, `${providerId}/${modelId}`).modelId).toBe(modelId); + expect(routeModel(config, routedSlug(providerId, modelId)).modelId).toBe(modelId); + } + }); + + test("keeps Nebius query and text-output filtering when the preset is renamed", async () => { + const provider = PROVIDERS.nebius; + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe(provider.modelsUrl); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${provider.key}`); + expect(init?.redirect).toBe("manual"); + return new Response(provider.fixture, { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const renamed = "nebius-team"; + const config = withStubbedProviderFetch({ + port: 10100, + defaultProvider: renamed, + providers: { + [renamed]: providerConfig("nebius").providers.nebius!, + }, + } satisfies OcxConfig); + const models = await gatherRoutedModels(config); + expect(models.filter(row => row.provider === renamed).map(row => row.id)).toEqual([ + "meta-llama/Meta-Llama-3.1-8B-Instruct-fast", + "Qwen/Qwen3-VL-235B-A22B-Instruct", + ]); + }); + + test("routes tool requests to the fixed hosts without claiming parallel tool calls", () => { + const cases = [ + ["sambanova", "Meta-Llama-3.3-70B-Instruct"], + ["nebius", "meta-llama/Meta-Llama-3.1-8B-Instruct-fast"], + ] as const; + + for (const [providerId, modelId] of cases) { + const route = routeModel(providerConfig(providerId), `${providerId}/${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(`${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("reasoning_effort"); + } + }); + + test("does not retarget older same-named custom providers or adapters", () => { + for (const id of ["sambanova", "nebius"] as const) { + const customConfig = providerConfig(id, { baseUrl: "https://custom.example/v1" }); + const route = routeModel(customConfig, `${id}/custom-model`); + expect(route.provider).toMatchObject({ + adapter: "openai-chat", + baseUrl: "https://custom.example/v1", + authMode: "key", + }); + expect(buildModelsRequest(customConfig.providers[id]!, "custom-key", id)).toEqual({ + url: "https://custom.example/v1/models", + headers: { Authorization: "Bearer custom-key" }, + }); + + const customAdapter = routeModel(providerConfig(id, { + adapter: "anthropic", + baseUrl: "https://custom.example/anthropic", + }), `${id}/custom-model`); + expect(customAdapter.provider).toMatchObject({ + adapter: "anthropic", + baseUrl: "https://custom.example/anthropic", + authMode: "key", + }); + } + + const crossPreset = providerConfig("sambanova", { + baseUrl: PROVIDERS.nebius.baseUrl, + }).providers.sambanova!; + expect(resolveProviderModelDiscovery("sambanova", crossPreset).spec).toBeUndefined(); + expect(buildModelsRequest(crossPreset, "custom-key", "sambanova")).toEqual({ + url: `${PROVIDERS.nebius.baseUrl}/models`, + headers: { Authorization: "Bearer custom-key" }, + }); + }); +});