From b373dddb308bb0b52ee4be3767d92ecbf6cec888 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Mon, 27 Jul 2026 20:31:15 -0700 Subject: [PATCH 1/3] feat(providers): add bounded model discovery contract --- .../docs/ja/reference/configuration.md | 7 +- .../docs/ko/reference/configuration.md | 7 +- .../content/docs/reference/configuration.md | 15 +- .../docs/ru/reference/configuration.md | 8 +- .../docs/zh-cn/reference/configuration.md | 13 +- src/codex/catalog/parsing.ts | 2 + src/codex/catalog/provider-fetch.ts | 207 ++++++++--- src/oauth/index.ts | 13 +- src/oauth/key-providers.ts | 23 +- src/oauth/login-cli.ts | 2 +- src/providers/derive.ts | 4 +- src/providers/model-discovery.ts | 343 ++++++++++++++++++ src/providers/registry.ts | 88 +++++ src/router.ts | 8 +- src/server/auth-cors.ts | 6 +- src/server/management/provider-routes.ts | 39 +- src/server/management/shared.ts | 4 +- structure/01_runtime.md | 9 + tests/codex-catalog.test.ts | 5 +- tests/fixtures/provider-model-discovery.json | 22 ++ .../provider-model-discovery-contract.test.ts | 283 +++++++++++++++ tests/umans-provider.test.ts | 4 +- 22 files changed, 1017 insertions(+), 95 deletions(-) create mode 100644 src/providers/model-discovery.ts create mode 100644 tests/fixtures/provider-model-discovery.json create mode 100644 tests/provider-model-discovery-contract.test.ts diff --git a/docs-site/src/content/docs/ja/reference/configuration.md b/docs-site/src/content/docs/ja/reference/configuration.md index 2cf4d114a..606c69740 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -144,7 +144,7 @@ token の代わりに使えます。すべての候補は timing side channel | `apiKeyPool?` | `ApiKeyPoolEntry[]` | 複数キーを納める pool。`apiKey` はアクティブ項目を反映します。各項目には `id`、`key`、選択 `label`、選択数値 `addedAt` があります。 | | `defaultModel?` | `string` | 明示的なモデルなしでこのプロバイダーを選んだときに使うモデル。 | | `models?` | `string[]` | seed/fallback モデル一覧。`liveModels` が `false` ならここにあるモデルだけが発見されます。 | -| `liveModels?` | `boolean` | 起動/同期時にプロバイダーのリアルタイム `/models` カタログを取得します(デフォルト `true`)。`false` なら設定された `models` だけを使います。 | +| `liveModels?` | `boolean` | 起動/同期時にプロバイダーのライブモデルカタログを取得します(デフォルト `true`)。組み込み preset は registry の信頼済み URL・クエリ・フィルターを使用でき、カスタム provider は `${baseUrl}/models` がデフォルトです。`false` なら設定された `models` だけを使います。 | | `selectedModels?` | `string[]` | モデル発見後に適用するカタログ allowlist。空でなければその id だけを Codex に公開し、空または省略なら発見したモデルをすべて公開します。 | | `contextWindow?` | `number` | ルーティングカタログ項目に表示するプロバイダー単位の context-window cap。リアルタイム metadata がより小さければそのままにします。 | | `modelContextWindows?` | `Record` | モデル別 context-window cap。一致するモデルでは `contextWindow` より優先し、より小さいリアルタイム metadata を上げません。 | @@ -232,6 +232,11 @@ Codex の承認と sandbox ルールを迂回する Cursor ネイティブロー 一部のプロバイダーはリアルタイムモデルカタログが非常に大きいか遅いです。Codex に `models` で固定したモデルだけ 見せるには `liveModels` を `false` に設定してください。 +ライブ discovery は 4 MiB または 2,000 件の生モデル行を超える応答をキャッシュ前に拒否します。 +組み込み preset は上限をさらに下げ、混在カタログをチャット対応行だけに絞れます。超過または +不正な応答は stale/static fallback に戻り、不適格な行は除外されます。有効な応答に適格な行が +なければ権威ある空カタログとなり、上限超過の応答が暗黙に切り詰められることはありません。 + `liveModels` が `false` で `models` が空または省略されると opencodex はそのプロバイダーのルーティング モデルを 1 つも公開しません。 diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index f91e3d116..4fcf65472 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -151,7 +151,7 @@ token 대신 쓸 수 있습니다. 모든 후보는 timing side channel을 막 | `apiKeyPool?` | `ApiKeyPoolEntry[]` | 여러 키를 담는 pool. `apiKey`는 활성 항목을 반영합니다. 각 항목에는 `id`, `key`, 선택 `label`, 선택 숫자 `addedAt`이 있습니다. | | `defaultModel?` | `string` | 명시적인 모델 없이 이 프로바이더를 선택했을 때 쓸 모델. | | `models?` | `string[]` | seed/fallback 모델 목록. `liveModels`가 `false`이면 여기 있는 모델만 발견됩니다. | -| `liveModels?` | `boolean` | 시작/동기화 시 프로바이더의 실시간 `/models` 카탈로그를 가져옵니다(기본 `true`). `false`이면 설정된 `models`만 사용합니다. | +| `liveModels?` | `boolean` | 시작/동기화 시 프로바이더의 실시간 모델 카탈로그를 가져옵니다(기본 `true`). 기본 제공 preset은 registry의 신뢰된 URL·쿼리·필터를 사용할 수 있고, 사용자 지정 provider는 `${baseUrl}/models`가 기본입니다. `false`이면 설정된 `models`만 사용합니다. | | `selectedModels?` | `string[]` | 모델 발견 뒤 적용할 카탈로그 allowlist. 비어 있지 않으면 해당 id만 Codex에 노출하고, 비어 있거나 생략하면 발견한 모델을 모두 노출합니다. | | `contextWindow?` | `number` | 라우팅 카탈로그 항목에 표시할 프로바이더 단위 context-window cap. 실시간 metadata가 더 작으면 그대로 둡니다. | | `modelContextWindows?` | `Record` | 모델별 context-window cap. 일치하는 모델에서는 `contextWindow`보다 우선하며 더 작은 실시간 metadata를 올리지 않습니다. | @@ -259,6 +259,11 @@ OpenRouter에서 같은 모델을 제공하는 endpoint마다 prompt cache 지 일부 프로바이더는 실시간 모델 카탈로그가 매우 크거나 느립니다. Codex에 `models`로 고정한 모델만 보이게 하려면 `liveModels`를 `false`로 설정하세요. +실시간 discovery 응답이 4 MiB 또는 원시 모델 행 2,000개를 넘으면 캐시 전에 거부됩니다. 기본 제공 +preset은 이 한도를 더 낮추고 혼합 카탈로그를 채팅 가능 행으로 필터링할 수 있습니다. 한도 초과 또는 +손상된 응답은 stale/static fallback을 사용하고, 부적격 행은 제외됩니다. 유효한 응답에 적격 행이 +없으면 권위 있는 빈 카탈로그가 되며, 한도 초과 응답을 조용히 잘라 사용하지 않습니다. + `liveModels`가 `false`이고 `models`가 비어 있거나 생략되면 opencodex는 해당 프로바이더의 라우팅 모델을 하나도 노출하지 않습니다. diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 01e2aa6f3..194ef8d5c 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -252,7 +252,7 @@ or bind the forward explicitly to loopback (`ssh -L 127.0.0.1:20100:localhost:10 | Field | Type | Meaning | | --- | --- | --- | | `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (or alias `azure`). | -| `baseUrl` | `string` | Upstream API base URL. Built-in providers with a fixed endpoint ignore it — see [Fixed provider endpoints](#fixed-provider-endpoints). | +| `baseUrl` | `string` | Upstream API base URL. Most built-in fixed endpoints ignore a mismatch; newly promoted collision-safe key presets preserve an older same-named custom destination. See [Fixed provider endpoints](#fixed-provider-endpoints). | | `responsesPath?` | `string` | Optional relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no URL scheme, query, or fragment. When omitted, the adapter keeps its legacy `/v1/responses` URL construction. | | `disabled?` | `boolean` | Keep the provider on disk but exclude it from routing and model/catalog listings. | | `apiKey?` | `string` | API key, or an `${ENV_VAR}` / `$ENV_VAR` reference resolved at request time. | @@ -260,7 +260,7 @@ or bind the forward explicitly to loopback (`ssh -L 127.0.0.1:20100:localhost:10 | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Multi-key pool. `apiKey` mirrors the active entry; each item has `id`, `key`, optional `label`, and optional numeric `addedAt`. | | `defaultModel?` | `string` | Model used when this provider is selected without an explicit model. | | `models?` | `string[]` | Seed/fallback model list. When `liveModels` is `false`, these are the only discovered models. | -| `liveModels?` | `boolean` | Fetch the provider's live `/models` catalog on start/sync (default `true`). Set `false` to use only configured `models`. | +| `liveModels?` | `boolean` | Fetch the provider's live model catalog on start/sync (default `true`). Built-in presets may use a trusted registry URL/query/filter; custom providers default to `${baseUrl}/models`. Set `false` to use only configured `models`. | | `selectedModels?` | `string[]` | Catalog allowlist applied after discovery. A non-empty list exposes only those ids to Codex; empty/omitted exposes all discovered models. | | `contextWindow?` | `number` | Provider-wide Codex-visible context-window cap for routed catalog entries. Live metadata below this value is kept. | | `modelContextWindows?` | `Record` | Model-specific context-window caps. These override `contextWindow` for matching model ids and never raise smaller live metadata. | @@ -304,13 +304,16 @@ or bind the forward explicitly to loopback (`ssh -L 127.0.0.1:20100:localhost:10 ### Fixed provider endpoints Routing resolves a provider's endpoint before any adapter sees it, and for most built-in -providers the registry's own endpoint wins over a `baseUrl` in your config. Three kinds of entry +providers the registry's own endpoint wins over a `baseUrl` in your config. Four kinds of entry keep the configured URL at this stage: - providers that opt into an override — `ollama`, `vllm`, `lm-studio`, `litellm`, `qwen-cloud` and `alibaba-token-plan-intl`; - providers whose registry endpoint is a template you fill in, such as `azure-openai` and `cloudflare-ai-gateway`; +- newly promoted fixed API-key presets that protect name collisions: if an older same-named + custom provider points somewhere else, it remains custom instead of sending that key to the new + registry host; - providers you define yourself, which are not in the registry at all. Adapters may adjust the resolved URL afterward. The `kiro` adapter, for example, follows the API @@ -471,6 +474,12 @@ example above, select `openrouter/anthropic-claude-sonnet-5` in Codex; OpenCodex Some providers expose very large or slow live model catalogs. Set `liveModels` to `false` when you want Codex to see only the models pinned in `models`: +Live discovery rejects a response before caching when it exceeds 4 MiB or 2,000 raw model rows. +Built-in presets may lower either limit and filter mixed catalogs to chat-eligible rows. An +oversized or malformed response follows the normal stale/configured fallback path, while +ineligible rows are excluded. A valid result with zero eligible rows remains an authoritative +empty catalog; OpenCodex never silently truncates an over-limit response. + When `liveModels` is `false` and `models` is empty or omitted, opencodex exposes no routed models for that provider. diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index c6b674e49..171c0816f 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -171,7 +171,7 @@ x-opencodex-api-key: your-secret-token | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Пул из нескольких ключей. `apiKey` отражает активную запись; каждый элемент содержит `id`, `key`, необязательный `label` и необязательное числовое `addedAt`. | | `defaultModel?` | `string` | Модель, используемая при выборе этого провайдера без явной модели. | | `models?` | `string[]` | Список seed/fallback-моделей. Когда `liveModels` равен `false`, обнаруживаются только эти модели. | -| `liveModels?` | `boolean` | Получать живой каталог `/models` провайдера при старте/синхронизации (по умолчанию `true`). Установите `false`, чтобы использовать только настроенные `models`. | +| `liveModels?` | `boolean` | Получать живой каталог моделей при старте/синхронизации (по умолчанию `true`). Встроенные preset могут использовать доверенные URL, query и фильтры из registry; пользовательские provider по умолчанию запрашивают `${baseUrl}/models`. Установите `false`, чтобы использовать только настроенные `models`. | | `selectedModels?` | `string[]` | Allowlist каталога, применяемый после обнаружения. Непустой список открывает Codex только эти id; пустой или опущенный открывает все обнаруженные модели. | | `contextWindow?` | `number` | Видимый Codex лимит контекстного окна на уровне провайдера для маршрутизируемых записей каталога. Живые метаданные ниже этого значения сохраняются. | | `modelContextWindows?` | `Record` | Лимиты контекстного окна для конкретных моделей. Они перекрывают `contextWindow` для совпадающих id моделей и никогда не повышают меньшие живые метаданные. | @@ -281,6 +281,12 @@ HTTP). Stdio-записи также принимают `args?: string[]`, `env? Некоторые провайдеры отдают очень большие или медленные живые каталоги моделей. Установите `liveModels` в `false`, когда хотите, чтобы Codex видел только модели, закреплённые в `models`: +Живой discovery отклоняет ответ до кэширования, если он превышает 4 MiB или 2 000 исходных строк +моделей. Встроенный preset может снизить эти лимиты и отфильтровать смешанный каталог до chat-моделей. +Слишком большой или повреждённый ответ использует обычный stale/static fallback, а неподходящие +строки исключаются. Корректный ответ без подходящих строк остаётся авторитетным пустым каталогом; +ответ сверх лимита никогда не обрезается молча. + Когда `liveModels` равен `false`, а `models` пуст или опущен, opencodex не открывает для этого провайдера ни одной маршрутизируемой модели. diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index da12eab6d..76b8ea704 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -135,7 +135,7 @@ x-opencodex-api-key: your-secret-token | Field | Type | 含义 | | --- | --- | --- | | `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`azure-openai`(或别名 `azure`)之一。 | -| `baseUrl` | `string` | 上游 API base URL。端点固定的内置 provider 会忽略它 —— 见[固定的 provider 端点](#固定的-provider-端点)。 | +| `baseUrl` | `string` | 上游 API base URL。大多数固定端点的内置 provider 会忽略不一致的地址;新加入且启用冲突保护的 API-key preset 会保留旧有同名 custom provider 的目标。见[固定的 provider 端点](#固定的-provider-端点)。 | | `responsesPath?` | `string` | `key` 认证的 `openai-responses` 请求可选相对 resource path。必须以 `/` 开头,且不得包含 URL scheme、query 或 fragment。省略时保留原有的 `/v1/responses` URL 构造。 | | `disabled?` | `boolean` | 配置保留在磁盘上,但从路由和模型/目录列表排除。 | | `apiKey?` | `string` | API key,或在请求时解析的 `${ENV_VAR}` / `$ENV_VAR` 引用。 | @@ -143,7 +143,7 @@ x-opencodex-api-key: your-secret-token | `apiKeyPool?` | `ApiKeyPoolEntry[]` | 多 key pool。`apiKey` 映射当前活动条目;每项包含 `id`、`key`、可选 `label` 和可选数字 `addedAt`。 | | `defaultModel?` | `string` | 选中该 provider 但未指定明确模型时使用的模型。 | | `models?` | `string[]` | seed/fallback 模型列表。`liveModels` 为 `false` 时,只会发现这些模型。 | -| `liveModels?` | `boolean` | 启动/同步时获取 provider 的实时 `/models` 目录(默认 `true`)。设为 `false` 时只使用配置的 `models`。 | +| `liveModels?` | `boolean` | 启动/同步时获取 provider 的实时模型目录(默认 `true`)。内置 preset 可使用 registry 中受信任的 URL、查询参数和过滤规则;自定义 provider 默认请求 `${baseUrl}/models`。设为 `false` 时只使用配置的 `models`。 | | `selectedModels?` | `string[]` | 模型发现后应用的目录 allowlist。非空时只向 Codex 暴露这些 id;为空或省略时暴露所有发现的模型。 | | `contextWindow?` | `number` | 路由目录条目的 provider 级 Codex 可见 context-window cap。实时 metadata 更小时保留实时值。 | | `modelContextWindows?` | `Record` | 模型级 context-window cap。匹配模型时优先于 `contextWindow`,且不会抬高更小的实时 metadata。 | @@ -181,11 +181,13 @@ x-opencodex-api-key: your-secret-token ### 固定的 provider 端点 路由会在任何 adapter 介入之前解析 provider 的端点;对大多数内置 provider 而言,registry 自带的端点 -优先于你在配置里写的 `baseUrl`。在这一步保留配置 URL 的只有三类: +优先于你在配置里写的 `baseUrl`。在这一步保留配置 URL 的有四类: - 显式开启覆盖的 provider —— `ollama`、`vllm`、`lm-studio`、`litellm`、`qwen-cloud` 和 `alibaba-token-plan-intl`; - registry 端点本身是待填模板的 provider,例如 `azure-openai` 和 `cloudflare-ai-gateway`; +- 新加入并启用同名冲突保护的固定 API-key preset:若旧有同名 custom provider 指向其他地址, + 它会继续作为 custom provider 使用原目标,不会把 key 发往新 registry host; - 你自己定义的 provider,它们根本不在 registry 中。 之后 adapter 仍可能调整已解析的 URL。例如 `kiro` adapter 在 host 为标准 @@ -268,6 +270,11 @@ MCP、屏幕录制和 computer-use 使用独立的 `mcpServers` / `desktopExecut 部分 provider 的实时模型目录非常大或很慢。若只想让 Codex 看到 `models` 中固定的模型,请把 `liveModels` 设为 `false`。 +实时发现响应超过 4 MiB 或包含超过 2,000 条原始模型记录时,会在缓存前被拒绝。内置 preset +还可以降低这些上限,并把混合目录过滤为可用于聊天的模型。超限或格式错误的响应会沿用陈旧缓存/ +静态配置回退,不合格的记录则被排除。若合法响应中没有合格记录,结果仍是权威的空目录;超限响应 +不会被静默截断。 + 当 `liveModels` 为 `false` 且 `models` 为空或省略时,opencodex 不会为该 provider 暴露任何 路由模型。 diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 96a4c0f26..432ace821 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -110,6 +110,8 @@ export interface CatalogModel { /** Whether Codex may send Responses text.verbosity for this routed model. */ supportsVerbosity?: boolean; supportsReasoningSummaries?: boolean; + /** Normalized upstream capability names retained for management/API consumers (#485 follow-up). */ + capabilities?: string[]; } export type RawEntry = Record; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 7ad985eef..e8d2882c9 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -24,7 +24,7 @@ import { modelInList } from "../../types"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "../../generated/jawcode-model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; -import { getProviderRegistryEntry } from "../../providers/registry"; +import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry"; import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec"; import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; @@ -41,6 +41,13 @@ import { import type { NormalizedComboConfig } from "../../combos/types"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { redactSecretString } from "../../lib/redact"; +import { + extractProviderModelItems, + readBoundedDiscoveryJson, + resolveProviderModelDiscovery, + type ModelDiscoveryResponseFailure, + type ProviderModelsApiItem, +} from "../../providers/model-discovery"; import upstreamModelsSnapshot from "../data/upstream-models.json"; @@ -50,27 +57,6 @@ import { disabledNativeSlugs, hasComboTargets, nativeInputModalities, nativeOpen import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation"; import type { ComboCatalogOmission } from "./aggregation"; -export type ProviderModelsApiItem = { - id: string; - owned_by?: string; - context_length?: number; - max_model_len?: number; - metadata?: { - capabilities?: Record; - limits?: Record; - }; -}; - -export function isProviderModelsApiItems(value: unknown): value is ProviderModelsApiItem[] { - return Array.isArray(value) && value.every(item => - item !== null - && typeof item === "object" - && !Array.isArray(item) - && typeof (item as { id?: unknown }).id === "string" - && (item as { id: string }).id.trim().length > 0 - ); -} - export function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined { const configured = modelRecordValue(prov.modelContextWindows, id) ?? prov.contextWindow; return typeof configured === "number" && configured > 0 ? configured : undefined; @@ -193,31 +179,121 @@ export function isGlm52ModelId(id: string): boolean { return normalized === "glm-5.2" || normalized === "glm-5.2[1m]"; } +function plainRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +function positiveFiniteNumber(...values: unknown[]): number | undefined { + return values.find(value => typeof value === "number" && Number.isFinite(value) && value > 0) as number | undefined; +} + +function normalizedStringList(value: unknown, maxItems = 32, maxLength = 64): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const out: string[] = []; + const maxInspectedItems = Math.max(maxItems * 8, maxItems); + for (let i = 0; i < value.length && i < maxInspectedItems; i += 1) { + const raw = value[i]; + if (typeof raw !== "string") continue; + if (raw.length > maxLength * 4) continue; + const normalized = raw.trim().toLowerCase().replace(/\s+/g, "-").slice(0, maxLength); + if (normalized && !out.includes(normalized)) out.push(normalized); + if (out.length >= maxItems) break; + } + return out.length > 0 ? out : undefined; +} + +function modelCapabilities(item: ProviderModelsApiItem): string[] | undefined { + const metadata = plainRecord(item.metadata); + const metadataCapabilities = metadata?.capabilities; + const capabilityRecord = plainRecord(metadataCapabilities) ?? plainRecord(item.capabilities); + 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); + } + const capabilityFields = capabilityRecord ?? {}; + let inspectedCapabilityFields = 0; + for (const key in capabilityFields) { + if (!Object.hasOwn(capabilityFields, key)) continue; + inspectedCapabilityFields += 1; + if (inspectedCapabilityFields > 256 || out.size >= 32) break; + if (key.length <= 256 && capabilityFields[key] === true) { + out.add(key.trim().toLowerCase().replace(/\s+/g, "-").slice(0, 64)); + } + } + for (const field of ["supports_tools", "supports_tool_calling", "supports_function_calling"] as const) { + if (item[field] === true) out.add("tools"); + } + for (const field of ["supports_reasoning", "reasoning"] as const) { + if (item[field] === true) out.add("reasoning"); + } + return out.size > 0 ? [...out].filter(Boolean).slice(0, 32) : undefined; +} + +function modelInputModalities( + item: ProviderModelsApiItem, + capabilities: readonly string[] | undefined, +): string[] | undefined { + const metadata = plainRecord(item.metadata); + const capabilityRecord = plainRecord(metadata?.capabilities) ?? plainRecord(item.capabilities); + const explicit = normalizedStringList( + item.input_modalities + ?? item.modalities + ?? metadata?.input_modalities + ?? capabilityRecord?.input_modalities, + 8, + 24, + )?.filter(value => value === "text" || value === "image" || value === "audio" || value === "video"); + if (explicit && explicit.length > 0) return explicit; + if (capabilityRecord?.vision === false) return ["text"]; + if (capabilityRecord?.vision === true || capabilities?.some(value => value === "vision" || value === "image-input")) { + return ["text", "image"]; + } + return undefined; +} + export function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial { - const capabilities = item.metadata?.capabilities; - const limits = item.metadata?.limits; + const metadata = plainRecord(item.metadata); + const capabilityRecord = plainRecord(metadata?.capabilities) ?? plainRecord(item.capabilities); + const limits = plainRecord(metadata?.limits); const contextWindow = - typeof limits?.max_context_length === "number" ? limits.max_context_length - : typeof item.context_length === "number" ? item.context_length - : typeof item.max_model_len === "number" ? item.max_model_len - : undefined; - const reasoningEfforts = capabilities && typeof capabilities.reasoning_effort === "boolean" - ? (capabilities.reasoning_effort - ? ((providerName === "neuralwatt" || providerName === "zai") && isGlm52ModelId(item.id) - ? ["low", "medium", "high", "xhigh", "max"] - : ["low", "medium", "high", "xhigh"]) - : []) - : undefined; - const inputModalities = capabilities && typeof capabilities.vision === "boolean" - ? (capabilities.vision ? ["text", "image"] : ["text"]) - : undefined; + positiveFiniteNumber( + limits?.max_context_length, + item.context_length, + item.context_size, + item.max_model_len, + item.max_context_length, + ); + const maxInputTokens = positiveFiniteNumber(limits?.max_input_tokens, item.max_input_tokens); + const rawReasoningEfforts = capabilityRecord?.reasoning_effort ?? item.reasoning_efforts; + const listedReasoningEfforts = normalizedStringList(rawReasoningEfforts, 8, 24); + const reasoningEfforts = listedReasoningEfforts + ? sanitizeCodexReasoningEfforts(listedReasoningEfforts) + : typeof rawReasoningEfforts === "boolean" + ? (rawReasoningEfforts + ? ((providerName === "neuralwatt" || providerName === "zai") && isGlm52ModelId(item.id) + ? ["low", "medium", "high", "xhigh", "max"] + : ["low", "medium", "high", "xhigh"]) + : []) + : undefined; + const capabilities = modelCapabilities(item); + const inputModalities = modelInputModalities(item, capabilities); return { ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}), + ...(maxInputTokens && maxInputTokens > 0 ? { maxInputTokens } : {}), ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), ...(inputModalities ? { inputModalities } : {}), + ...(capabilities ? { capabilities } : {}), }; } +function boundedOwnedBy(value: unknown): string | undefined { + if (typeof value !== "string" || value.length === 0 || value.length > 256) return undefined; + if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(value)) return undefined; + return value; +} + export async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs: number, contextCap?: number): Promise { if (prov.authMode === "forward") return []; // ChatGPT backend has no /models const apiKey = await resolveModelsAuthToken(name, prov); @@ -299,6 +375,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, const stale = getStaleCached(name); return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : failedDiscoveryConfigured; } + const discovery = resolveProviderModelDiscovery(name, prov); const { url, headers } = buildModelsRequest(prov, apiKey, name); const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com") ? "vertex-aiplatform" @@ -336,7 +413,11 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, return models; } - const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) }); + const res = await fetch(url, { + headers, + redirect: "error", + signal: AbortSignal.timeout(8000), + }); if (!res.ok) { const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status }); if (shouldLog) { @@ -350,15 +431,14 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, const contentType = ( res.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() || "missing" ).slice(0, 80); - const body = await res.text(); - let json: unknown; - try { - json = JSON.parse(body) as unknown; - } catch { + const bounded = await readBoundedDiscoveryJson(res, discovery.maxResponseBytes); + if (!bounded.ok) { const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); - const diagnostic = contentType === "application/json" || contentType.endsWith("+json") - ? "returned invalid JSON in a 2xx response" - : "returned a non-JSON 2xx response"; + const diagnostic = bounded.reason === "response_too_large" + ? `exceeded the ${discovery.maxResponseBytes}-byte response limit` + : contentType === "application/json" || contentType.endsWith("+json") + ? "returned invalid JSON in a 2xx response" + : "returned a non-JSON 2xx response"; if (shouldLog) { console.warn( `[opencodex] Provider model discovery for "${name}" ${diagnostic} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, @@ -366,25 +446,32 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, } return models; } - const data = json !== null && typeof json === "object" && !Array.isArray(json) - ? (json as { data?: unknown }).data - : undefined; - if (!isProviderModelsApiItems(data)) { + const extracted = extractProviderModelItems(bounded.value, discovery); + if (!extracted.ok) { const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); + const diagnostic: Record = { + response_too_large: "returned an oversized 2xx response", + invalid_json: "returned invalid JSON in a 2xx response", + invalid_shape: "returned malformed 2xx data", + too_many_models: `exceeded the ${discovery.maxModels}-row model limit`, + }; if (shouldLog) { console.warn( - `[opencodex] Provider model discovery for "${name}" returned malformed 2xx data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, + `[opencodex] Provider model discovery for "${name}" ${diagnostic[extracted.reason]} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, ); } return models; } - const items = data; - const live = items.map(m => applyProviderConfigHints(name, prov, { - id: m.id, - provider: name, - owned_by: m.owned_by, - ...catalogHintsFromModelsApiItem(name, m), - }, contextCap)) + const items = extracted.items; + const live = items.map(m => { + const ownedBy = boundedOwnedBy(m.owned_by); + return applyProviderConfigHints(name, prov, { + id: m.id, + provider: name, + ...(ownedBy ? { owned_by: ownedBy } : {}), + ...catalogHintsFromModelsApiItem(name, m), + }, contextCap); + }) .filter(m => shouldExposeProviderModel(name, m.id)); // Capture the count BEFORE the alias/configured augmentation below pushes extra rows into // `live`; otherwise configured entries would be reported as discovered ones. @@ -596,7 +683,7 @@ export function augmentRoutedModelsWithRegistryOpenAiApiRows( config: OcxConfig, ): CatalogModel[] { const configured = config.providers[OPENAI_API_PROVIDER_ID]; - if (!configured || configured.disabled === true) return models; + if (!configured || configured.disabled === true || !providerMatchesRegistryTransport(OPENAI_API_PROVIDER_ID, configured)) return models; const entry = getProviderRegistryEntry(OPENAI_API_PROVIDER_ID); if (!entry?.models) return models; diff --git a/src/oauth/index.ts b/src/oauth/index.ts index c6dca4bc0..20e73b1a2 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -15,6 +15,7 @@ import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUr import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive"; import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys"; import { effectiveGoogleMode, getProviderRegistryEntry } from "../providers/registry"; +import { resolveProviderModelDiscoveryUrl } from "../providers/model-discovery"; import { resolveProviderTransport } from "../providers/xai-transport"; import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect"; import { logOAuthEvent } from "./log"; @@ -480,13 +481,19 @@ export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | und providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(providerName) : undefined, ); const headers: Record = { ...(effectiveProvider.headers ?? {}) }; + const discoveryUrl = (defaultUrl: string): string => resolveProviderModelDiscoveryUrl( + providerName, + prov, + effectiveProvider.baseUrl, + defaultUrl, + ); if (effectiveGoogleMode(providerName, effectiveProvider) === "ai-studio") { // Generative Language API: API key goes in x-goog-api-key (never Authorization: Bearer), // models live under /v1beta (v1 misses preview models), and pageSize maxes at 1000 — // enough to list everything without a pageToken loop. Vertex/antigravity keep the // generic branch (they fall back to their static model lists). if (apiKey) headers["x-goog-api-key"] = apiKey; - return { url: `${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`, headers }; + return { url: discoveryUrl(`${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`), headers }; } if (effectiveProvider.adapter === "anthropic") { const base = effectiveProvider.baseUrl.replace(/\/v1\/?$/, ""); @@ -498,10 +505,10 @@ export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | und if (effectiveProvider.apiKeyTransport === "bearer") headers["Authorization"] = `Bearer ${apiKey}`; else headers["x-api-key"] = apiKey; } - return { url: `${base}/v1/models?limit=1000`, headers }; + return { url: discoveryUrl(`${base}/v1/models?limit=1000`), headers }; } if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; - return { url: `${effectiveProvider.baseUrl}/models`, headers }; + return { url: discoveryUrl(`${effectiveProvider.baseUrl}/models`), headers }; } /** diff --git a/src/oauth/key-providers.ts b/src/oauth/key-providers.ts index f33223678..2168f5cc5 100644 --- a/src/oauth/key-providers.ts +++ b/src/oauth/key-providers.ts @@ -1,5 +1,6 @@ import type { OcxProviderConfig } from "../types"; import { deriveKeyLoginMap, enrichProviderFromRegistry, type DerivedKeyLoginProvider } from "../providers/derive"; +import { resolveProviderModelDiscoveryUrl } from "../providers/model-discovery"; /** * API-key "login" providers: not OAuth — the flow opens the provider's dashboard so the user can @@ -45,7 +46,11 @@ function anthropicKeyValidationHeaders(provider: Pick { +export async function validateApiKey( + providerName: string, + provider: KeyLoginProvider, + key: string, +): Promise { try { if (provider.adapter === "anthropic") { const base = provider.baseUrl.replace(/\/v1\/?$/, ""); @@ -57,6 +62,7 @@ export async function validateApiKey(provider: KeyLoginProvider, key: string): P max_tokens: 1, messages: [{ role: "user", content: "ping" }], }), + redirect: "error", signal: AbortSignal.timeout(8000), }); if (res.ok) return true; @@ -69,6 +75,7 @@ export async function validateApiKey(provider: KeyLoginProvider, key: string): P // documented x-goog-api-key header instead (pageSize=1 — validation only needs a 200). const res = await fetch(`${provider.baseUrl}/v1beta/models?pageSize=1`, { headers: { "x-goog-api-key": key }, + redirect: "error", signal: AbortSignal.timeout(8000), }); if (res.ok) return true; @@ -76,8 +83,20 @@ export async function validateApiKey(provider: KeyLoginProvider, key: string): P return "unknown"; } - const res = await fetch(`${provider.baseUrl}/models`, { + const configuredProvider: OcxProviderConfig = { + adapter: provider.adapter, + baseUrl: provider.baseUrl, + authMode: "key", + }; + const modelsUrl = resolveProviderModelDiscoveryUrl( + providerName, + configuredProvider, + provider.baseUrl, + `${provider.baseUrl}/models`, + ); + const res = await fetch(modelsUrl, { headers: { Authorization: `Bearer ${key}` }, + redirect: "error", signal: AbortSignal.timeout(8000), }); if (res.ok) return true; diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index cf225b9ce..4575ea805 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -127,7 +127,7 @@ async function handleKeyLogin(name: string): Promise { process.exit(1); } process.stdout.write(" validating… "); - const valid = await validateApiKey({ ...def, baseUrl }, key); + const valid = await validateApiKey(name, { ...def, baseUrl }, key); console.log(valid === true ? "valid ✅" : valid === false ? "INVALID ❌" : "couldn't validate (may still work)"); if (valid === false) { console.error("Provider rejected the key. Not saved."); diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 5a44d7e7a..7ca8fa5cc 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -1,5 +1,5 @@ import type { CodexAccountMode, OcxProviderConfig } from "../types"; -import { PROVIDER_REGISTRY, type ProviderRegistryEntry } from "./registry"; +import { PROVIDER_REGISTRY, providerMatchesRegistryTransport, type ProviderRegistryEntry } from "./registry"; export interface DerivedKeyLoginProvider { label: string; @@ -223,7 +223,7 @@ export function deriveProviderPresets(): DerivedProviderPreset[] { export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig): void { const entry = PROVIDER_REGISTRY.find(row => row.id === name); - if (!entry) return; + if (!entry || !providerMatchesRegistryTransport(name, prov)) return; const seed = providerConfigSeed(entry); if (prov.apiKeyTransport === undefined && seed.apiKeyTransport !== undefined) prov.apiKeyTransport = seed.apiKeyTransport; if (!prov.defaultModel && seed.defaultModel) prov.defaultModel = seed.defaultModel; diff --git a/src/providers/model-discovery.ts b/src/providers/model-discovery.ts new file mode 100644 index 000000000..2f3fbe0cf --- /dev/null +++ b/src/providers/model-discovery.ts @@ -0,0 +1,343 @@ +import type { OcxProviderConfig } from "../types"; +import { + getProviderRegistryEntry, + providerMatchesRegistryTransport, + type ProviderModelDiscoveryFilter, + type ProviderModelDiscoveryPredicate, + type ProviderModelDiscoveryScalar, + type ProviderModelDiscoverySpec, +} from "./registry"; + +/** Hard process-wide limits. Registry entries may lower, but never raise, these ceilings. */ +export const MODEL_DISCOVERY_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; +export const MODEL_DISCOVERY_MAX_MODELS = 2_000; +export const MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH = 1_024; +const MODEL_DISCOVERY_MAX_FILTER_VALUES = 256; +const MODEL_DISCOVERY_MAX_FILTER_STRING_LENGTH = 1_024; +const MODEL_DISCOVERY_MODEL_ID_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/; + +export interface ResolvedProviderModelDiscovery { + spec?: ProviderModelDiscoverySpec; + maxResponseBytes: number; + maxModels: number; +} + +export type ProviderModelsApiItem = Record & { id: string }; + +export type ModelDiscoveryResponseFailure = + | "response_too_large" + | "invalid_json" + | "invalid_shape" + | "too_many_models"; + +export type BoundedDiscoveryJsonResult = + | { ok: true; value: unknown } + | { ok: false; reason: "response_too_large" | "invalid_json" }; + +export type ProviderModelItemsResult = + | { ok: true; items: ProviderModelsApiItem[]; rawCount: number } + | { ok: false; reason: "invalid_shape" | "too_many_models" }; + +export type ModelEnvelopeRowsResult = + | { ok: true; rows: unknown[] } + | { ok: false; reason: "invalid_shape" | "too_many_models" }; + +function positiveIntegerAtMost(value: number | undefined, hardLimit: number): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return hardLimit; + return Math.min(Math.floor(value), hardLimit); +} + +function discoveryPredicateError(predicate: ProviderModelDiscoveryPredicate): string | null { + if (!Array.isArray(predicate.path) || predicate.path.length === 0 || predicate.path.length > 8) { + return "predicate path must contain 1-8 segments"; + } + if (predicate.path.some(segment => typeof segment !== "string" || !segment.trim() || segment.length > 64)) { + return "predicate path segments must be nonblank strings up to 64 characters"; + } + const values = "equalsAny" in predicate + ? predicate.equalsAny + : "containsAny" in predicate + ? predicate.containsAny + : predicate.containsAll; + if (!Array.isArray(values) || values.length === 0 || values.length > 32) { + return "predicate values must contain 1-32 scalars"; + } + if (values.some(value => ( + (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") + || (typeof value === "string" && (!value.trim() || value.length > 128)) + || (typeof value === "number" && !Number.isFinite(value)) + ))) { + return "predicate values must be finite booleans/numbers or nonblank strings up to 128 characters"; + } + return null; +} + +/** Static registry validation used by parity tests; discovery metadata never comes from config. */ +export function providerModelDiscoverySpecError(spec: ProviderModelDiscoverySpec): string | null { + if (spec.url && spec.path) return "url and path are mutually exclusive"; + if (spec.url !== undefined) { + try { + const parsed = new URL(spec.url); + if (parsed.protocol !== "https:") return "absolute discovery url must use https"; + if (parsed.username || parsed.password || parsed.hash) return "absolute discovery url must not contain credentials or a fragment"; + } catch { + return "absolute discovery url must be valid"; + } + } + if (spec.path !== undefined) { + const path = spec.path.trim(); + if (!path || path.length > 512) return "discovery path must be 1-512 characters"; + if (/^[a-z][a-z\d+.-]*:/i.test(path) || path.startsWith("//") || path.includes("?") || path.includes("#")) { + return "discovery path must be a query-free relative/origin path"; + } + } + const queryEntries = Object.entries(spec.query ?? {}); + if (queryEntries.length > 32) return "discovery query may contain at most 32 entries"; + if (queryEntries.some(([key, value]) => !key.trim() || key.length > 128 || typeof value !== "string" || value.length > 512)) { + return "discovery query keys/values exceed their bounds"; + } + for (const [field, value, hardLimit] of [ + ["maxResponseBytes", spec.maxResponseBytes, MODEL_DISCOVERY_MAX_RESPONSE_BYTES], + ["maxModels", spec.maxModels, MODEL_DISCOVERY_MAX_MODELS], + ] as const) { + if (value !== undefined && (!Number.isInteger(value) || value <= 0 || value > hardLimit)) { + return `${field} must be a positive integer no greater than ${hardLimit}`; + } + } + for (const [group, predicates] of Object.entries(spec.filter ?? {})) { + if (!Array.isArray(predicates) || predicates.length === 0 || predicates.length > 32) { + return `${group} must contain 1-32 predicates`; + } + for (const predicate of predicates) { + const error = discoveryPredicateError(predicate); + if (error) return `${group}: ${error}`; + } + } + return null; +} + +export function resolveProviderModelDiscovery( + providerName: string, + provider: Pick & Partial>, +): ResolvedProviderModelDiscovery { + const entry = providerMatchesRegistryTransport(providerName, provider) + ? getProviderRegistryEntry(providerName) + : undefined; + const spec = entry?.modelDiscovery; + return { + ...(spec ? { spec } : {}), + maxResponseBytes: positiveIntegerAtMost(spec?.maxResponseBytes, MODEL_DISCOVERY_MAX_RESPONSE_BYTES), + maxModels: positiveIntegerAtMost(spec?.maxModels, MODEL_DISCOVERY_MAX_MODELS), + }; +} + +function appendDiscoveryQuery(url: URL, query: Readonly> | undefined): URL { + for (const [key, value] of Object.entries(query ?? {})) url.searchParams.set(key, value); + return url; +} + +/** Apply a registry-owned URL/path/query policy to the adapter's normal discovery endpoint. */ +export function resolveProviderModelDiscoveryUrl( + providerName: string, + configuredProvider: Pick & Partial>, + effectiveBaseUrl: string, + defaultUrl: string, +): string { + const { spec } = resolveProviderModelDiscovery(providerName, configuredProvider); + if (!spec) return defaultUrl; + + let resolved: URL; + if (spec.url) { + resolved = new URL(spec.url); + } else if (spec.path) { + const base = new URL(effectiveBaseUrl.endsWith("/") ? effectiveBaseUrl : `${effectiveBaseUrl}/`); + resolved = spec.path.startsWith("/") + ? new URL(spec.path, base.origin) + : new URL(spec.path, base); + } else { + resolved = new URL(defaultUrl); + } + return appendDiscoveryQuery(resolved, spec.query).toString(); +} + +function cancelWithoutWaiting(reader: ReadableStreamDefaultReader, reason: unknown): void { + try { + void reader.cancel(reason).catch(() => undefined); + } catch { + // A non-conforming stream may throw synchronously from cancel(). + } +} + +/** Read a discovery response under a strict byte ceiling before JSON.parse can allocate freely. */ +export async function readBoundedDiscoveryJson( + response: Response, + maxResponseBytes: number, +): Promise { + const limit = positiveIntegerAtMost(maxResponseBytes, MODEL_DISCOVERY_MAX_RESPONSE_BYTES); + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > limit) { + try { + void response.body?.cancel(new DOMException("Model discovery response is too large", "QuotaExceededError")) + .catch(() => undefined); + } catch { + // Best-effort cancellation only. + } + return { ok: false, reason: "response_too_large" }; + } + + if (!response.body) return { ok: false, reason: "invalid_json" }; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (!value || value.byteLength === 0) continue; + if (value.byteLength > limit - total) { + cancelWithoutWaiting( + reader, + new DOMException("Model discovery response is too large", "QuotaExceededError"), + ); + return { ok: false, reason: "response_too_large" }; + } + chunks.push(value); + total += value.byteLength; + } + } finally { + try { + reader.releaseLock(); + } catch { + // Cancellation may keep the lock briefly. + } + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + return { ok: true, value: JSON.parse(text) as unknown }; + } catch { + return { ok: false, reason: "invalid_json" }; + } +} + +function valueAtPath(item: Record, path: readonly string[]): unknown { + let current: unknown = item; + for (const segment of path) { + if (current === null || typeof current !== "object" || Array.isArray(current)) return undefined; + current = (current as Record)[segment]; + } + return current; +} + +function comparableScalar(value: unknown, caseInsensitive: boolean): ProviderModelDiscoveryScalar | undefined { + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") return undefined; + if (typeof value === "string" && value.length > MODEL_DISCOVERY_MAX_FILTER_STRING_LENGTH) return undefined; + return caseInsensitive && typeof value === "string" ? value.toLowerCase() : value; +} + +function comparableNeedles( + values: readonly ProviderModelDiscoveryScalar[], + caseInsensitive: boolean, +): ProviderModelDiscoveryScalar[] { + return values.map(value => caseInsensitive && typeof value === "string" ? value.toLowerCase() : value); +} + +function predicateMatches(item: ProviderModelsApiItem, predicate: ProviderModelDiscoveryPredicate): boolean { + const caseInsensitive = predicate.caseInsensitive === true; + const raw = valueAtPath(item, predicate.path); + if ("equalsAny" in predicate) { + const value = comparableScalar(raw, caseInsensitive); + return value !== undefined && comparableNeedles(predicate.equalsAny, caseInsensitive).includes(value); + } + + const collection = Array.isArray(raw); + const values: ProviderModelDiscoveryScalar[] = []; + if (collection) { + for (let i = 0; i < raw.length && i < MODEL_DISCOVERY_MAX_FILTER_VALUES; i += 1) { + const value = comparableScalar(raw[i], caseInsensitive); + if (value !== undefined) values.push(value); + } + } else if (typeof raw === "string") { + values.push(caseInsensitive ? raw.toLowerCase() : raw); + } + const needles = comparableNeedles( + "containsAny" in predicate ? predicate.containsAny : predicate.containsAll, + caseInsensitive, + ); + if ("containsAny" in predicate) { + return needles.some(needle => values.some(value => ( + !collection && typeof value === "string" && typeof needle === "string" ? value.includes(needle) : value === needle + ))); + } + return needles.every(needle => values.some(value => ( + !collection && typeof value === "string" && typeof needle === "string" ? value.includes(needle) : value === needle + ))); +} + +export function providerModelMatchesDiscoveryFilter( + item: ProviderModelsApiItem, + filter: ProviderModelDiscoveryFilter | undefined, +): boolean { + if (!filter) return true; + if (filter.allOf && !filter.allOf.every(predicate => predicateMatches(item, predicate))) return false; + if (filter.anyOf && filter.anyOf.length > 0 && !filter.anyOf.some(predicate => predicateMatches(item, predicate))) return false; + if (filter.noneOf?.some(predicate => predicateMatches(item, predicate))) return false; + return true; +} + +/** Extract one allowlisted array envelope while enforcing the raw-row ceiling. */ +export function extractModelEnvelopeRows( + value: unknown, + maxModels: number, + envelopeKeys: readonly string[], +): ModelEnvelopeRowsResult { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return { ok: false, reason: "invalid_shape" }; + } + const record = value as Record; + const rows = envelopeKeys.map(key => record[key]).find(Array.isArray); + if (!rows) return { ok: false, reason: "invalid_shape" }; + const limit = positiveIntegerAtMost(maxModels, MODEL_DISCOVERY_MAX_MODELS); + if (rows.length > limit) return { ok: false, reason: "too_many_models" }; + return { ok: true, rows }; +} + +/** Validate, bound, deduplicate, and declaratively filter an OpenAI `{data:[...]}` envelope. */ +export function extractProviderModelItems( + value: unknown, + discovery: ResolvedProviderModelDiscovery, +): ProviderModelItemsResult { + const envelope = extractModelEnvelopeRows(value, discovery.maxModels, ["data"]); + if (!envelope.ok) return envelope; + const data = envelope.rows; + + const items: ProviderModelsApiItem[] = []; + const seen = new Set(); + for (const raw of data) { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + return { ok: false, reason: "invalid_shape" }; + } + const id = (raw as { id?: unknown }).id; + if (typeof id !== "string") return { ok: false, reason: "invalid_shape" }; + const normalizedId = id.trim(); + if ( + !normalizedId + || normalizedId !== id + || normalizedId.length > MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH + || MODEL_DISCOVERY_MODEL_ID_CONTROL_CHARS.test(normalizedId) + ) { + return { ok: false, reason: "invalid_shape" }; + } + const item = raw as ProviderModelsApiItem; + if (!providerModelMatchesDiscoveryFilter(item, discovery.spec?.filter) || seen.has(normalizedId)) continue; + seen.add(normalizedId); + items.push(item); + } + return { ok: true, items, rawCount: data.length }; +} diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 7fea6644f..9771ccc77 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -17,6 +17,53 @@ import { export type ProviderAuthKind = "forward" | "oauth" | "key" | "local"; export type MetadataModelIdNormalize = "case-insensitive"; +export type ProviderModelDiscoveryScalar = string | number | boolean; + +export type ProviderModelDiscoveryPredicate = + | { + path: readonly string[]; + equalsAny: readonly ProviderModelDiscoveryScalar[]; + caseInsensitive?: boolean; + } + | { + path: readonly string[]; + containsAny: readonly ProviderModelDiscoveryScalar[]; + caseInsensitive?: boolean; + } + | { + path: readonly string[]; + containsAll: readonly ProviderModelDiscoveryScalar[]; + caseInsensitive?: boolean; + }; + +export interface ProviderModelDiscoveryFilter { + /** Every predicate must match. */ + allOf?: readonly ProviderModelDiscoveryPredicate[]; + /** At least one predicate must match. */ + anyOf?: readonly ProviderModelDiscoveryPredicate[]; + /** No predicate may match. */ + noneOf?: readonly ProviderModelDiscoveryPredicate[]; +} + +/** + * Trusted live-model discovery policy. This metadata is registry-only: it must never be copied + * into config.json, where a same-named custom provider could otherwise redirect a stored key. + */ +export interface ProviderModelDiscoverySpec { + /** Registry-owned absolute endpoint. Mutually exclusive with `path`. */ + url?: string; + /** Resource path relative to baseUrl; a leading slash resolves from the origin. */ + path?: string; + /** Query parameters applied to the resolved discovery URL. */ + query?: Readonly>; + /** Declarative eligibility rules evaluated against each untrusted model row. */ + filter?: ProviderModelDiscoveryFilter; + /** Optional lower byte ceiling; the process-wide hard ceiling still wins. */ + maxResponseBytes?: number; + /** Optional lower raw-row ceiling; the process-wide hard ceiling still wins. */ + maxModels?: number; +} + export interface ProviderRegistryEntry { id: string; label: string; @@ -35,6 +82,11 @@ export interface ProviderRegistryEntry { */ freeTier?: boolean; allowBaseUrlOverride?: boolean; + /** + * Do not claim an existing same-named key provider whose fixed destination differs from this + * preset. Enable for newly promoted ids so an older custom key cannot be silently retargeted. + */ + preserveCustomDestination?: boolean; /** * Optional endpoint picker for providers with multiple official hosts * (e.g. Qwen Cloud token plan vs pay-as-you-go). Requires `allowBaseUrlOverride` @@ -51,6 +103,7 @@ export interface ProviderRegistryEntry { defaultModel?: string; models?: string[]; liveModels?: boolean; + modelDiscovery?: ProviderModelDiscoverySpec; contextWindow?: number; modelContextWindows?: Record; modelInputModalities?: Record; @@ -1121,6 +1174,41 @@ export function getProviderRegistryEntry(id: string): ProviderRegistryEntry | un return PROVIDER_REGISTRY.find(entry => entry.id === id); } +function normalizedProviderEndpoint(value: string): string { + const trimmed = value.trim(); + try { + const parsed = new URL(trimmed); + parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/"; + return parsed.toString().replace(/\/$/, ""); + } catch { + return trimmed.replace(/\/+$/, ""); + } +} + +/** + * Whether registry transport defaults own this configured row. + * + * OAuth/forward providers stay pinned because their credentials must never be sent to an + * arbitrary same-named host. Existing key presets keep their historical pinning behavior; a new + * preset can opt into collision preservation, in which case its fixed endpoint owns only rows + * that still match that destination. + */ +export function providerMatchesRegistryTransport( + id: string, + provider: Pick & Partial>, +): boolean { + const entry = getProviderRegistryEntry(id); + if (!entry) return false; + if (entry.authKind !== "key" || entry.preserveCustomDestination !== true) return true; + // The opt-in is intentionally limited to fixed key destinations. Fail closed if a future + // registry edit combines it with an override/template despite the registry parity tests. + if (entry.allowBaseUrlOverride || /\{[^}]*\}/.test(entry.baseUrl)) return false; + if (typeof provider.baseUrl !== "string") return false; + if (provider.adapter !== entry.adapter) return false; + if (provider.authMode !== undefined && provider.authMode !== "key") return false; + return normalizedProviderEndpoint(provider.baseUrl) === normalizedProviderEndpoint(entry.baseUrl); +} + /** * Effective Codex account mode for a provider. For canonical `openai`, a valid persisted * `codexAccountMode` on the provider config wins and a missing/invalid value defaults to diff --git a/src/router.ts b/src/router.ts index 6dc331f4d..7df016236 100644 --- a/src/router.ts +++ b/src/router.ts @@ -3,7 +3,7 @@ import { preservesPhysicalComboProvider, tryPickComboModel, type ComboPick } fro import { hasOwnProvider, resolveEnvValue } from "./config"; import { assertProviderDestinationAllowed } from "./lib/destination-policy"; import { redactSecretString, redactUrlForLog } from "./lib/redact"; -import { PROVIDER_REGISTRY, providerCodexAccountMode } from "./providers/registry"; +import { PROVIDER_REGISTRY, providerCodexAccountMode, providerMatchesRegistryTransport } from "./providers/registry"; import { LEGACY_CHATGPT_PROVIDER_ID, LEGACY_OPENAI_MULTI_PROVIDER_ID, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers"; import { decodeRoutedModelId, encodeRoutedModelId } from "./providers/slug-codec"; import { getStaleCached } from "./codex/model-cache"; @@ -40,7 +40,9 @@ const MODEL_PROVIDER_PATTERNS: Array<{ providerNames: string[]; prefixes: string export function knownModelIdsForProvider(provName: string, prov: OcxProviderConfig): string[] { const ids = new Set(); for (const id of prov.models ?? []) ids.add(id); - const registry = PROVIDER_REGISTRY.find(entry => entry.id === provName); + const registry = providerMatchesRegistryTransport(provName, prov) + ? PROVIDER_REGISTRY.find(entry => entry.id === provName) + : undefined; for (const id of registry?.models ?? []) ids.add(id); // Registry model-keyed hint maps double as known native ids (e.g. NVIDIA carries no // static models list but names `moonshotai/kimi-k2.6` in its effort/window maps). @@ -192,7 +194,7 @@ function usableResolvedApiKey(apiKey: string | undefined): string | undefined { function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig { const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName); - if (!registryEntry) { + if (!registryEntry || !providerMatchesRegistryTransport(providerName, provider)) { assertProviderDestinationAllowed(providerName, provider); return { ...provider, apiKey: usableResolvedApiKey(provider.apiKey) }; } diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index b067f7130..9f3b83771 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -12,7 +12,7 @@ import { reasoningSummaryDeliveryRecordConfigError, } from "../config"; import { providerDestinationConfigError } from "../lib/destination-policy"; -import { getProviderRegistryEntry, providerCodexAccountMode } from "../providers/registry"; +import { getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport } from "../providers/registry"; import { providerConfigSeed } from "../providers/derive"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { openRouterRoutingConfigError } from "../providers/openrouter-routing"; @@ -347,7 +347,9 @@ export function safeConfigDTO(config: OcxConfig): unknown { ] as const) { copyIfDefined(dto, provider, key); } - const registryNote = getProviderRegistryEntry(name)?.note; + const registryNote = providerMatchesRegistryTransport(name, provider) + ? getProviderRegistryEntry(name)?.note + : undefined; if (typeof registryNote === "string" && registryNote.trim()) dto.note = registryNote; const codexAccountMode = providerCodexAccountMode(name, provider); if (codexAccountMode) dto.codexAccountMode = codexAccountMode; diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 53d17ff10..833bea4ff 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -26,6 +26,11 @@ import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets } from "../../providers/derive"; import { providerCodexAccountMode } from "../../providers/registry"; +import { + extractModelEnvelopeRows, + readBoundedDiscoveryJson, + resolveProviderModelDiscovery, +} from "../../providers/model-discovery"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; @@ -332,23 +337,41 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise null) as { data?: unknown; models?: unknown } | null; + const bounded = await readBoundedDiscoveryJson(res, discovery.maxResponseBytes); + if (!bounded.ok) { + return jsonResponse({ + ok: false, + latencyMs, + error: bounded.reason === "response_too_large" + ? `upstream /models exceeded the ${discovery.maxResponseBytes}-byte response limit` + : "upstream /models returned invalid JSON", + }); + } // OpenAI-style lists use { data: [...] }; Google's /v1beta/models (the other shape // buildModelsRequest can produce) returns { models: [...] }. - const list = json && typeof json === "object" && !Array.isArray(json) - ? (Array.isArray(json.data) ? json.data : Array.isArray(json.models) ? json.models : undefined) - : undefined; - if (!Array.isArray(list)) { - return jsonResponse({ ok: false, latencyMs, error: "upstream /models returned an unexpected shape" }); + const envelope = extractModelEnvelopeRows(bounded.value, discovery.maxModels, ["data", "models"]); + if (!envelope.ok) { + return jsonResponse({ + ok: false, + latencyMs, + error: envelope.reason === "too_many_models" + ? `upstream /models exceeded the ${discovery.maxModels}-row model limit` + : "upstream /models returned an unexpected shape", + }); } - const models = list.length; + const models = envelope.rows.length; return jsonResponse({ ok: true, latencyMs, diff --git a/src/server/management/shared.ts b/src/server/management/shared.ts index 8451dd16f..6c6f20282 100644 --- a/src/server/management/shared.ts +++ b/src/server/management/shared.ts @@ -37,7 +37,7 @@ import { readUsageEntries } from "../../usage/log"; import { getUsageDebugLogEntries } from "../../usage/debug"; import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary"; import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; -import { getProviderRegistryEntry } from "../../providers/registry"; +import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry"; import { getDebugLogEntries } from "../../lib/debug-log-buffer"; import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log"; import { @@ -201,7 +201,7 @@ export async function fetchGrokCandidateModels(config: OcxConfig): Promise { }); }; try { - const rows = await gatherRoutedModels(openAiApiCatalogConfig({ liveModels: true })); + // This test exercises the catalog fetch/augmentation path, not DNS destination + // classification. Keep it hermetic on NAT64 hosts whose resolver also returns + // 64:ff9b::/96 answers for api.openai.com. + const rows = await gatherRoutedModels(openAiApiCatalogConfig({ liveModels: true, allowPrivateNetwork: true })); const apiRows = rows.filter(row => row.provider === "openai-apikey"); expect(calls).toEqual(["https://api.openai.com/v1/models"]); expect(apiRows.map(row => row.id)).toEqual([...exactIds].sort()); diff --git a/tests/fixtures/provider-model-discovery.json b/tests/fixtures/provider-model-discovery.json new file mode 100644 index 000000000..eedd7cbdf --- /dev/null +++ b/tests/fixtures/provider-model-discovery.json @@ -0,0 +1,22 @@ +{ + "data": [ + { + "id": "acme/chat-pro", + "type": "chat", + "context_size": 262144, + "max_input_tokens": 250000, + "input_modalities": ["text", "image"], + "supported_features": ["tools", "reasoning"] + }, + { + "id": "acme/embed-small", + "type": "embedding", + "context_length": 8192 + }, + { + "id": "acme/video-gen", + "type": "video", + "capabilities": ["video-generation"] + } + ] +} diff --git a/tests/provider-model-discovery-contract.test.ts b/tests/provider-model-discovery-contract.test.ts new file mode 100644 index 000000000..b3596d457 --- /dev/null +++ b/tests/provider-model-discovery-contract.test.ts @@ -0,0 +1,283 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { gatherRoutedModels } from "../src/codex/catalog"; +import { clearModelCache } from "../src/codex/model-cache"; +import { buildModelsRequest } from "../src/oauth"; +import { KEY_LOGIN_PROVIDERS, validateApiKey } from "../src/oauth/key-providers"; +import { deriveKeyLoginMap, providerConfigSeed } from "../src/providers/derive"; +import { + extractProviderModelItems, + providerModelDiscoverySpecError, + readBoundedDiscoveryJson, + resolveProviderModelDiscovery, +} from "../src/providers/model-discovery"; +import { PROVIDER_REGISTRY, type ProviderModelDiscoverySpec } from "../src/providers/registry"; +import { routeModel } from "../src/router"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +const FIXTURE = readFileSync(join(import.meta.dir, "fixtures/provider-model-discovery.json"), "utf8"); +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache("together"); +}); + +function togetherEntry() { + const entry = PROVIDER_REGISTRY.find(row => row.id === "together"); + if (!entry) throw new Error("missing together registry entry"); + return entry; +} + +async function withTogetherDiscovery( + spec: ProviderModelDiscoverySpec, + run: () => Promise | T, +): Promise { + const entry = togetherEntry(); + const original = entry.modelDiscovery; + const originalPreserveCustomDestination = entry.preserveCustomDestination; + entry.modelDiscovery = spec; + entry.preserveCustomDestination = true; + try { + return await run(); + } finally { + if (original === undefined) delete entry.modelDiscovery; + else entry.modelDiscovery = original; + if (originalPreserveCustomDestination === undefined) delete entry.preserveCustomDestination; + else entry.preserveCustomDestination = originalPreserveCustomDestination; + clearModelCache("together"); + } +} + +function togetherConfig(overrides: Partial = {}): OcxConfig { + return { + port: 10100, + defaultProvider: "together", + providers: { + together: { + adapter: "openai-chat", + baseUrl: "https://api.together.xyz/v1", + authMode: "key", + apiKey: "together-test-key", + ...overrides, + }, + }, + }; +} + +describe("registry-owned provider model discovery", () => { + test("keeps every registry discovery contract inside static safety bounds", () => { + for (const entry of PROVIDER_REGISTRY) { + if (!entry.modelDiscovery) continue; + expect(providerModelDiscoverySpecError(entry.modelDiscovery)).toBeNull(); + } + expect(providerModelDiscoverySpecError({ url: "http://insecure.example/models" })) + .toContain("https"); + expect(providerModelDiscoverySpecError({ path: "models?unbounded=true" })) + .toContain("query-free"); + }); + + test("limits collision preservation to fixed API-key destinations", () => { + for (const entry of PROVIDER_REGISTRY) { + if (entry.preserveCustomDestination !== true) continue; + expect(entry.authKind).toBe("key"); + expect(entry.allowBaseUrlOverride).not.toBe(true); + expect(entry.baseUrl).not.toMatch(/\{[^}]*\}/); + } + }); + + test("derives an alternate path and query only for the canonical destination", async () => { + await withTogetherDiscovery({ + path: "catalog", + query: { capability: "chat", limit: "100" }, + }, () => { + const canonical = buildModelsRequest(togetherConfig().providers.together!, "secret", "together"); + expect(canonical.url).toBe("https://api.together.xyz/v1/catalog?capability=chat&limit=100"); + + const collidingCustom: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://custom.example/v9", + authMode: "key", + }; + const custom = buildModelsRequest(collidingCustom, "secret", "together"); + expect(custom.url).toBe("https://custom.example/v9/models"); + expect(custom.headers.Authorization).toBe("Bearer secret"); + }); + }); + + test("uses the registry discovery URL for API-key validation without following redirects", async () => { + await withTogetherDiscovery({ + path: "catalog", + query: { capability: "chat", limit: "1" }, + }, async () => { + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe("https://api.together.xyz/v1/catalog?capability=chat&limit=1"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer secret"); + expect(init?.redirect).toBe("error"); + return Response.json({ data: [] }); + }) as typeof fetch; + + expect(await validateApiKey("together", KEY_LOGIN_PROVIDERS.together!, "secret")).toBe(true); + }); + }); + + test("does not persist trusted discovery or collision policy into provider config", async () => { + await withTogetherDiscovery({ path: "catalog", query: { capability: "chat" } }, () => { + const entry = togetherEntry(); + expect(providerConfigSeed(entry)).not.toHaveProperty("modelDiscovery"); + expect(providerConfigSeed(entry)).not.toHaveProperty("preserveCustomDestination"); + expect(deriveKeyLoginMap().together).not.toHaveProperty("modelDiscovery"); + expect(deriveKeyLoginMap().together).not.toHaveProperty("preserveCustomDestination"); + }); + }); + + test("filters mixed catalogs and retains bounded representative metadata", async () => { + await withTogetherDiscovery({ + filter: { + anyOf: [{ path: ["type"], equalsAny: ["chat"], caseInsensitive: true }], + }, + }, async () => { + globalThis.fetch = (async (_input, init) => { + expect(init?.redirect).toBe("error"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer together-test-key"); + return new Response(FIXTURE, { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const models = await gatherRoutedModels(togetherConfig()); + const together = models.filter(model => model.provider === "together"); + expect(together).toHaveLength(1); + expect(together[0]).toMatchObject({ + id: "acme/chat-pro", + contextWindow: 262_144, + maxInputTokens: 250_000, + inputModalities: ["text", "image"], + capabilities: ["tools", "reasoning"], + }); + }); + }); + + test("rejects an over-limit raw catalog instead of truncating or caching it", async () => { + await withTogetherDiscovery({ maxModels: 2 }, async () => { + const warning = spyOn(console, "warn").mockImplementation(() => {}); + globalThis.fetch = (async () => Response.json({ + data: [{ id: "one" }, { id: "two" }, { id: "three" }], + })) as typeof fetch; + try { + const models = await gatherRoutedModels(togetherConfig({ models: ["safe-fallback"] })); + expect(models.filter(model => model.provider === "together").map(model => model.id)) + .toEqual(["safe-fallback"]); + expect(warning.mock.calls.flat().join(" ")).toContain("2-row model limit"); + } finally { + warning.mockRestore(); + } + }); + }); + + test("rejects a missing-or-lying Content-Length body as soon as streamed bytes exceed the cap", async () => { + let cancelled = false; + const response = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(40)); + controller.enqueue(new Uint8Array(40)); + }, + cancel() { + cancelled = true; + }, + })); + + const result = await readBoundedDiscoveryJson(response, 64); + expect(result).toEqual({ ok: false, reason: "response_too_large" }); + expect(cancelled).toBe(true); + }); + + test("rejects invalid UTF-8 before JSON parsing", async () => { + const invalidUtf8Json = new Uint8Array([ + 0x7b, 0x22, 0x78, 0x22, 0x3a, 0x22, 0xc3, 0x28, 0x22, 0x7d, + ]); + expect(await readBoundedDiscoveryJson(new Response(invalidUtf8Json), 64)) + .toEqual({ ok: false, reason: "invalid_json" }); + }); + + test("deduplicates eligible rows only after enforcing the raw-row ceiling", () => { + const discovery = resolveProviderModelDiscovery("unknown-provider", { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + }); + const result = extractProviderModelItems({ + data: [ + { id: "chat-a", kind: "chat" }, + { id: "chat-a", kind: "chat" }, + { id: "embed-a", kind: "embedding" }, + ], + }, { + ...discovery, + maxModels: 3, + spec: { filter: { anyOf: [{ path: ["kind"], equalsAny: ["chat"] }] } }, + }); + expect(result).toEqual({ + ok: true, + rawCount: 3, + items: [{ id: "chat-a", kind: "chat" }], + }); + }); + + test("rejects control characters and outer whitespace in provider-native model ids", () => { + const discovery = resolveProviderModelDiscovery("unknown-provider", { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + }); + for (const id of [" padded/model", "model\nwith-control"]) { + expect(extractProviderModelItems({ data: [{ id }] }, discovery)) + .toEqual({ ok: false, reason: "invalid_shape" }); + } + }); +}); + +describe("same-named custom provider preservation", () => { + test("keeps an opted-in fixed key-provider collision on its configured destination and adapter", async () => { + await withTogetherDiscovery({}, () => { + for (const baseUrl of ["https://custom.example/anthropic", "https://api.together.xyz/v1"]) { + const routed = routeModel({ + port: 10100, + defaultProvider: "together", + providers: { + together: { + adapter: "anthropic", + baseUrl, + authMode: "key", + apiKey: "custom-key", + }, + }, + }, "together/custom-model"); + + expect(routed.provider).toMatchObject({ + adapter: "anthropic", + baseUrl, + authMode: "key", + apiKey: "custom-key", + }); + } + }); + }); + + test("continues pinning OAuth credentials to the canonical registry destination", () => { + const routed = routeModel({ + port: 10100, + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://custom.example/v1", + authMode: "oauth", + }, + }, + }, "xai/grok-4.5"); + + expect(routed.provider.baseUrl).toBe("https://api.x.ai/v1"); + expect(routed.provider.authMode).toBe("oauth"); + }); +}); diff --git a/tests/umans-provider.test.ts b/tests/umans-provider.test.ts index 36f2faa4a..0ed6df040 100644 --- a/tests/umans-provider.test.ts +++ b/tests/umans-provider.test.ts @@ -214,7 +214,7 @@ describe("Umans provider", () => { return new Response("{}", { status: 200 }); }) as typeof fetch; - const valid = await validateApiKey(KEY_LOGIN_PROVIDERS.umans, "sk-umans-valid"); + const valid = await validateApiKey("umans", KEY_LOGIN_PROVIDERS.umans, "sk-umans-valid"); const headers = new Headers(seenInit?.headers); const body = JSON.parse(String(seenInit?.body)) as Record; @@ -235,7 +235,7 @@ describe("Umans provider", () => { return new Response("{}", { status: 200 }); }) as typeof fetch; - const valid = await validateApiKey({ + const valid = await validateApiKey("umans", { ...KEY_LOGIN_PROVIDERS.umans, apiKeyTransport: "bearer", }, "sk-umans-valid"); From 4e8e7d598361ede3f7a34c10d26ec940f1b5fb5c Mon Sep 17 00:00:00 2001 From: olddonkey Date: Tue, 28 Jul 2026 18:35:22 -0700 Subject: [PATCH 2/3] fix(providers): harden model discovery contract --- .../docs/ja/reference/configuration.md | 25 +++++- .../docs/ko/reference/configuration.md | 25 +++++- .../content/docs/reference/configuration.md | 6 +- .../docs/ru/reference/configuration.md | 33 +++++++- src/codex/catalog/provider-fetch.ts | 26 ++++-- src/oauth/index.ts | 21 ++++- src/providers/registry.ts | 33 ++++++-- src/server/management/provider-routes.ts | 35 ++++++-- structure/01_runtime.md | 5 +- tests/provider-connection-test.test.ts | 76 +++++++++++++++++ .../provider-model-discovery-contract.test.ts | 84 +++++++++++++++++++ 11 files changed, 333 insertions(+), 36 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/configuration.md b/docs-site/src/content/docs/ja/reference/configuration.md index 606c69740..288f30d24 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -136,7 +136,7 @@ token の代わりに使えます。すべての候補は timing side channel | Field | Type | Meaning | | --- | --- | --- | | `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`azure-openai`(または別名 `azure`)のいずれか。 | -| `baseUrl` | `string` | 上流 API base URL。 | +| `baseUrl` | `string` | 上流 API base URL。固定 endpoint を持つ大半の組み込み provider は一致しない URL を無視します。新しく追加された衝突保護付き API-key preset は、以前からある同名 custom provider の送信先を維持します。[固定プロバイダーのエンドポイント](#固定プロバイダーのエンドポイント)を参照してください。 | | `responsesPath?` | `string` | `key` 認証の `openai-responses` リクエストに使う任意の相対 resource path。`/` で始め、URL scheme、query、fragment を含めてはいけません。省略時は従来の `/v1/responses` URL 構築を維持します。 | | `disabled?` | `boolean` | 設定はディスクに残すがルーティングとモデル/カタログ一覧から除外します。 | | `apiKey?` | `string` | API キーまたはリクエスト時に解釈する `${ENV_VAR}` / `$ENV_VAR` 参照。 | @@ -177,6 +177,29 @@ token の代わりに使えます。すべての候補は timing side channel | `desktopExecutor?` | `DesktopExecutorConfig` | **Cursor 専用。** 外部 computer-use/record-screen コマンド。フィールドは下で説明します。 | | `unsafeAllowNativeLocalExec?` | `boolean` | **Cursor アダプター専用。** Cursor サーバーが指示したローカル `read` / `write` / `delete` / `ls` / `grep` / `shell` / `fetch` 実行を許可する opt-in escape hatch。デフォルト `false` なのでリモート Cursor メッセージが Codex の承認と sandbox を迂回できません。下記 [Cursor プロバイダー](#cursor-プロバイダー-adapter-cursor) 参照。 | +### 固定プロバイダーのエンドポイント + +ルーティングは、adapter がリクエストを受け取る前に provider の endpoint を解決します。大半の +組み込み provider では、config の `baseUrl` より registry の endpoint が優先されます。この段階で +設定 URL を維持するのは次の 4 種類です。 + +- override を明示的に許可する provider: `ollama`、`vllm`、`lm-studio`、`litellm`、 + `qwen-cloud`、`alibaba-token-plan-intl`。 +- registry endpoint が入力用 template の provider(`azure-openai`、`cloudflare-ai-gateway` など)。 +- 同名衝突を保護する新しい固定 API-key preset。以前からある同名 custom provider が別の送信先を + 指している場合、その送信先を維持し、key を新しい registry host へ送りません。 +- registry に存在せず、ユーザーが独自に定義した provider。 + +その後も adapter が解決済み URL を調整する場合があります。たとえば `kiro` adapter は canonical な +`runtime.{region}.kiro.dev` host に対して、import した credential の API region を使います。adapter +ごとの規則は [Adapters](/ja/reference/adapters/) を参照してください。 + +ルーティングが設定済み `baseUrl` を破棄すると opencodex は警告を出します。registry endpoint は完全に +表示し、設定 URL は origin だけを表示します。path は credential 情報を含む可能性があるため記録しません。 +不要な `baseUrl` を削除するか、目的の URL と endpoint が一致する provider を選んでください。地域別 +サービスでは正しい項目を使ってください。`alibaba-token-plan` は北京に固定され、 +`alibaba-token-plan-intl` は国際 endpoint の override を許可します。 + ## Cursor プロバイダー(`adapter: "cursor"`) Cursor bridge は実験的です。`ocx login cursor` を実行したのち diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index 4fcf65472..06b035103 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -143,7 +143,7 @@ token 대신 쓸 수 있습니다. 모든 후보는 timing side channel을 막 | Field | Type | Meaning | | --- | --- | --- | | `adapter` | `string` | `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai`(또는 별칭 `azure`) 중 하나. | -| `baseUrl` | `string` | 업스트림 API base URL. | +| `baseUrl` | `string` | 업스트림 API base URL. 고정 endpoint를 쓰는 대부분의 기본 제공 provider는 일치하지 않는 URL을 무시합니다. 새로 승격된 충돌 보호 API-key preset은 기존의 같은 이름 custom provider 목적지를 유지합니다. [고정 프로바이더 엔드포인트](#고정-프로바이더-엔드포인트)를 참조하세요. | | `responsesPath?` | `string` | `key` 인증 `openai-responses` 요청에 사용할 선택적 상대 resource path. `/`로 시작해야 하며 URL scheme, query, fragment를 포함할 수 없습니다. 생략하면 기존 `/v1/responses` URL 구성을 유지합니다. | | `disabled?` | `boolean` | 설정은 디스크에 남기되 라우팅과 모델/카탈로그 목록에서 제외합니다. | | `apiKey?` | `string` | API 키 또는 요청 시점에 해석할 `${ENV_VAR}` / `$ENV_VAR` 참조. | @@ -187,6 +187,29 @@ token 대신 쓸 수 있습니다. 모든 후보는 timing side channel을 막 | `desktopExecutor?` | `DesktopExecutorConfig` | **Cursor 전용.** 외부 computer-use/record-screen 명령. 필드는 아래에 설명합니다. | | `unsafeAllowNativeLocalExec?` | `boolean` | **Cursor 어댑터 전용.** Cursor 서버가 지시한 로컬 `read` / `write` / `delete` / `ls` / `grep` / `shell` / `fetch` 실행을 허용하는 opt-in escape hatch. 기본 `false`라 원격 Cursor 메시지가 Codex 승인과 sandbox를 우회하지 못합니다. 아래 [Cursor 프로바이더](#cursor-프로바이더-adapter-cursor) 참조. | +### 고정 프로바이더 엔드포인트 + +라우팅은 adapter가 요청을 보기 전에 provider endpoint를 결정합니다. 대부분의 기본 제공 provider에서는 +config의 `baseUrl`보다 registry endpoint가 우선합니다. 이 단계에서 설정 URL을 유지하는 경우는 네 가지입니다. + +- override를 명시적으로 허용하는 provider: `ollama`, `vllm`, `lm-studio`, `litellm`, `qwen-cloud`, + `alibaba-token-plan-intl`. +- registry endpoint가 사용자가 채우는 template인 provider(예: `azure-openai`, + `cloudflare-ai-gateway`). +- 이름 충돌을 보호하는 새 고정 API-key preset. 기존의 같은 이름 custom provider가 다른 목적지를 + 가리키면 원래 목적지를 유지하며 key를 새 registry host로 보내지 않습니다. +- registry에 없고 사용자가 직접 정의한 provider. + +그 뒤에도 adapter가 결정된 URL을 조정할 수 있습니다. 예를 들어 `kiro` adapter는 canonical +`runtime.{region}.kiro.dev` host에서 가져온 credential의 API region을 사용합니다. adapter별 규칙은 +[Adapters](/ko/reference/adapters/)를 참조하세요. + +라우팅이 설정된 `baseUrl`을 버리면 opencodex가 경고를 출력합니다. registry endpoint는 전체를 표시하고 +설정 URL은 origin만 표시합니다. path는 credential 정보를 포함할 수 있으므로 기록하지 않습니다. 사용하지 +않는 `baseUrl`을 제거하거나 목적 URL과 endpoint가 일치하는 provider를 선택하세요. 지역별 서비스에서는 +올바른 항목을 사용해야 합니다. `alibaba-token-plan`은 베이징으로 고정되고, +`alibaba-token-plan-intl`은 국제 endpoint override를 허용합니다. + ## Cursor 프로바이더 (`adapter: "cursor"`) Cursor bridge는 실험적입니다. `ocx login cursor`를 실행한 뒤 diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 194ef8d5c..6574f30e0 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -474,15 +474,15 @@ example above, select `openrouter/anthropic-claude-sonnet-5` in Codex; OpenCodex Some providers expose very large or slow live model catalogs. Set `liveModels` to `false` when you want Codex to see only the models pinned in `models`: +When `liveModels` is `false` and `models` is empty or omitted, opencodex exposes no routed models +for that provider. + Live discovery rejects a response before caching when it exceeds 4 MiB or 2,000 raw model rows. Built-in presets may lower either limit and filter mixed catalogs to chat-eligible rows. An oversized or malformed response follows the normal stale/configured fallback path, while ineligible rows are excluded. A valid result with zero eligible rows remains an authoritative empty catalog; OpenCodex never silently truncates an over-limit response. -When `liveModels` is `false` and `models` is empty or omitted, opencodex exposes no routed models -for that provider. - Use `selectedModels` for a different purpose: discovery still runs, but only the selected ids are published to Codex's catalog and `/v1/models`. The dashboard's full model list remains available so the allowlist can be changed later. diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index 171c0816f..2fdd7ea59 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -163,7 +163,7 @@ x-opencodex-api-key: your-secret-token | Field | Type | Meaning | | --- | --- | --- | | `adapter` | `string` | Одно из `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (или алиас `azure`). | -| `baseUrl` | `string` | Базовый URL вышестоящего API. | +| `baseUrl` | `string` | Базовый URL вышестоящего API. Большинство встроенных провайдеров с фиксированными конечными точками игнорируют несовпадающий адрес; новые API-key preset с защитой от коллизий сохраняют назначение старого одноимённого custom provider. См. [Фиксированные конечные точки провайдеров](#фиксированные-конечные-точки-провайдеров). | | `responsesPath?` | `string` | Необязательный относительный путь ресурса для запросов `openai-responses` с аутентификацией `key`. Должен начинаться с `/` и не содержать схему URL, query или fragment. Если поле опущено, сохраняется прежнее построение URL `/v1/responses`. | | `disabled?` | `boolean` | Провайдер остаётся на диске, но исключается из маршрутизации и списков моделей/каталога. | | `apiKey?` | `string` | API-ключ или ссылка `${ENV_VAR}` / `$ENV_VAR`, разрешаемая в момент запроса. | @@ -206,6 +206,31 @@ x-opencodex-api-key: your-secret-token | `desktopExecutor?` | `DesktopExecutorConfig` | **Только Cursor.** Внешние команды computer-use/record-screen; поля перечислены ниже. | | `unsafeAllowNativeLocalExec?` | `boolean` | **Только адаптер Cursor.** Явно включаемая лазейка для управляемого сервером Cursor локального выполнения `read` / `write` / `delete` / `ls` / `grep` / `shell` / `fetch`. По умолчанию `false`, поэтому удалённые сообщения Cursor не могут обойти одобрения и песочницу Codex. См. [Провайдер Cursor](#провайдер-cursor-adapter-cursor) ниже. | +### Фиксированные конечные точки провайдеров + +Маршрутизация определяет конечную точку провайдера до того, как запрос увидит адаптер. Для +большинства встроенных провайдеров адрес из registry имеет приоритет над `baseUrl` в конфигурации. +Настроенный URL сохраняется на этом этапе в четырёх случаях: + +- провайдер явно разрешает переопределение — `ollama`, `vllm`, `lm-studio`, `litellm`, + `qwen-cloud` и `alibaba-token-plan-intl`; +- конечная точка registry является заполняемым шаблоном, как у `azure-openai` и + `cloudflare-ai-gateway`; +- новый фиксированный API-key preset защищает коллизию имени: старый одноимённый custom provider, + указывающий на другой адрес, сохраняет исходное назначение и не отправляет ключ новому host из registry; +- провайдер определён пользователем и отсутствует в registry. + +После этого адаптер всё ещё может изменить разрешённый URL. Например, адаптер `kiro` использует +API-регион импортированных учётных данных для канонического host `runtime.{region}.kiro.dev`. +Правила для каждого адаптера описаны в разделе [Адаптеры](/ru/reference/adapters/). + +Если маршрутизация отбрасывает настроенный `baseUrl`, opencodex выводит предупреждение. Адрес +registry показывается полностью, а настроенный адрес только как origin; путь скрывается, поскольку +он может содержать данные учётных записей. Удалите неиспользуемый `baseUrl` либо выберите provider, +чья конечная точка соответствует нужному URL. Для региональных сервисов используйте правильную +запись: `alibaba-token-plan` закреплён за Пекином, а `alibaba-token-plan-intl` допускает международную +конечную точку. + ## Провайдер Cursor (`adapter: "cursor"`) Мост Cursor экспериментален. После `ocx login cursor` добавьте или отредактируйте запись @@ -281,15 +306,15 @@ HTTP). Stdio-записи также принимают `args?: string[]`, `env? Некоторые провайдеры отдают очень большие или медленные живые каталоги моделей. Установите `liveModels` в `false`, когда хотите, чтобы Codex видел только модели, закреплённые в `models`: +Когда `liveModels` равен `false`, а `models` пуст или опущен, opencodex не открывает для этого +провайдера ни одной маршрутизируемой модели. + Живой discovery отклоняет ответ до кэширования, если он превышает 4 MiB или 2 000 исходных строк моделей. Встроенный preset может снизить эти лимиты и отфильтровать смешанный каталог до chat-моделей. Слишком большой или повреждённый ответ использует обычный stale/static fallback, а неподходящие строки исключаются. Корректный ответ без подходящих строк остаётся авторитетным пустым каталогом; ответ сверх лимита никогда не обрезается молча. -Когда `liveModels` равен `false`, а `models` пуст или опущен, opencodex не открывает для этого -провайдера ни одной маршрутизируемой модели. - `selectedModels` служит другой цели: обнаружение по-прежнему выполняется, но в каталог Codex и `/v1/models` публикуются только выбранные id. Полный список моделей в дашборде остаётся доступным, поэтому allowlist можно изменить позже. diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index e8d2882c9..a4d07afa8 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -185,8 +185,16 @@ function plainRecord(value: unknown): Record | undefined { : undefined; } -function positiveFiniteNumber(...values: unknown[]): number | undefined { - return values.find(value => typeof value === "number" && Number.isFinite(value) && value > 0) as number | undefined; +const MODEL_DISCOVERY_METADATA_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/; + +function positiveSafeInteger(...values: unknown[]): number | undefined { + return values.find(value => typeof value === "number" && Number.isSafeInteger(value) && value > 0) as number | undefined; +} + +function normalizedMetadataString(raw: string, maxLength: number): string | undefined { + if (raw.length > maxLength * 4 || MODEL_DISCOVERY_METADATA_CONTROL_CHARS.test(raw)) return undefined; + const normalized = raw.trim().toLowerCase().replace(/\s+/g, "-").slice(0, maxLength); + return normalized || undefined; } function normalizedStringList(value: unknown, maxItems = 32, maxLength = 64): string[] | undefined { @@ -196,8 +204,7 @@ function normalizedStringList(value: unknown, maxItems = 32, maxLength = 64): st for (let i = 0; i < value.length && i < maxInspectedItems; i += 1) { const raw = value[i]; if (typeof raw !== "string") continue; - if (raw.length > maxLength * 4) continue; - const normalized = raw.trim().toLowerCase().replace(/\s+/g, "-").slice(0, maxLength); + const normalized = normalizedMetadataString(raw, maxLength); if (normalized && !out.includes(normalized)) out.push(normalized); if (out.length >= maxItems) break; } @@ -218,8 +225,9 @@ function modelCapabilities(item: ProviderModelsApiItem): string[] | undefined { if (!Object.hasOwn(capabilityFields, key)) continue; inspectedCapabilityFields += 1; if (inspectedCapabilityFields > 256 || out.size >= 32) break; - if (key.length <= 256 && capabilityFields[key] === true) { - out.add(key.trim().toLowerCase().replace(/\s+/g, "-").slice(0, 64)); + if (capabilityFields[key] === true) { + const normalized = normalizedMetadataString(key, 64); + if (normalized) out.add(normalized); } } for (const field of ["supports_tools", "supports_tool_calling", "supports_function_calling"] as const) { @@ -258,14 +266,14 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid const capabilityRecord = plainRecord(metadata?.capabilities) ?? plainRecord(item.capabilities); const limits = plainRecord(metadata?.limits); const contextWindow = - positiveFiniteNumber( + positiveSafeInteger( limits?.max_context_length, item.context_length, item.context_size, item.max_model_len, item.max_context_length, ); - const maxInputTokens = positiveFiniteNumber(limits?.max_input_tokens, item.max_input_tokens); + const maxInputTokens = positiveSafeInteger(limits?.max_input_tokens, item.max_input_tokens); const rawReasoningEfforts = capabilityRecord?.reasoning_effort ?? item.reasoning_efforts; const listedReasoningEfforts = normalizedStringList(rawReasoningEfforts, 8, 24); const reasoningEfforts = listedReasoningEfforts @@ -290,7 +298,7 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid function boundedOwnedBy(value: unknown): string | undefined { if (typeof value !== "string" || value.length === 0 || value.length > 256) return undefined; - if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(value)) return undefined; + if (MODEL_DISCOVERY_METADATA_CONTROL_CHARS.test(value)) return undefined; return value; } diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 20e73b1a2..32e8b91f4 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -14,7 +14,7 @@ import { loginCursor, refreshCursorToken } from "./cursor"; import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot"; import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive"; import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys"; -import { effectiveGoogleMode, getProviderRegistryEntry } from "../providers/registry"; +import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../providers/registry"; import { resolveProviderModelDiscoveryUrl } from "../providers/model-discovery"; import { resolveProviderTransport } from "../providers/xai-transport"; import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect"; @@ -462,6 +462,22 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf return resolveEnvValue(prov.apiKey); } +function modelDiscoveryTransportSeed(providerName: string, prov: OcxProviderConfig): OcxProviderConfig { + const entry = getProviderRegistryEntry(providerName); + if ( + prov.authMode !== "oauth" + || entry?.authKind !== "oauth" + || entry.allowBaseUrlOverride === true + || /\{[^}]*\}/.test(entry.baseUrl) + || !providerMatchesRegistryTransport(providerName, prov) + ) { + return prov; + } + // Normal routing pins fixed OAuth presets before adapter-specific transport resolution. + // Discovery must do the same so a stale or modified config baseUrl never receives a token. + return { ...prov, adapter: entry.adapter, baseUrl: entry.baseUrl }; +} + /** * Provider-correct `GET /models` request (URL + headers), so both model-listing paths fetch the * LIVE catalog correctly per adapter. Anthropic is the special case: its endpoint is `/v1/models` @@ -474,9 +490,10 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf * response. */ export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | undefined, providerName = ""): { url: string; headers: Record } { + const transportSeed = modelDiscoveryTransportSeed(providerName, prov); const effectiveProvider = resolveProviderTransport( providerName, - prov, + transportSeed, undefined, providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(providerName) : undefined, ); diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 9771ccc77..0de97cc53 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -45,15 +45,7 @@ export interface ProviderModelDiscoveryFilter { noneOf?: readonly ProviderModelDiscoveryPredicate[]; } -/** - * Trusted live-model discovery policy. This metadata is registry-only: it must never be copied - * into config.json, where a same-named custom provider could otherwise redirect a stored key. - */ -export interface ProviderModelDiscoverySpec { - /** Registry-owned absolute endpoint. Mutually exclusive with `path`. */ - url?: string; - /** Resource path relative to baseUrl; a leading slash resolves from the origin. */ - path?: string; +interface ProviderModelDiscoverySharedSpec { /** Query parameters applied to the resolved discovery URL. */ query?: Readonly>; /** Declarative eligibility rules evaluated against each untrusted model row. */ @@ -64,6 +56,29 @@ export interface ProviderModelDiscoverySpec { maxModels?: number; } +type ProviderModelDiscoveryLocation = + | { + /** Registry-owned absolute endpoint. Mutually exclusive with `path`. */ + url: string; + path?: never; + } + | { + /** Resource path relative to baseUrl; query strings and fragments are disallowed. */ + path: string; + url?: never; + } + | { + /** Keep the adapter-derived default discovery endpoint. */ + url?: never; + path?: never; + }; + +/** + * Trusted live-model discovery policy. This metadata is registry-only: it must never be copied + * into config.json, where a same-named custom provider could otherwise redirect a stored key. + */ +export type ProviderModelDiscoverySpec = ProviderModelDiscoverySharedSpec & ProviderModelDiscoveryLocation; + export interface ProviderRegistryEntry { id: string; label: string; diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 833bea4ff..e0bfc30f0 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -28,6 +28,7 @@ import { deriveProviderPresets } from "../../providers/derive"; import { providerCodexAccountMode } from "../../providers/registry"; import { extractModelEnvelopeRows, + extractProviderModelItems, readBoundedDiscoveryJson, resolveProviderModelDiscovery, } from "../../providers/model-discovery"; @@ -340,6 +341,17 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise undefined); + } catch { + // Best-effort release for non-conforming response streams. + } return jsonResponse({ ok: false, latencyMs, error: `upstream /models returned ${res.status}` }); } const bounded = await readBoundedDiscoveryJson(res, discovery.maxResponseBytes); @@ -359,19 +376,25 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise + : undefined; + const extracted = Array.isArray(record?.data) + ? extractProviderModelItems(bounded.value, discovery) + : extractModelEnvelopeRows(bounded.value, discovery.maxModels, ["models"]); + if (!extracted.ok) { return jsonResponse({ ok: false, latencyMs, - error: envelope.reason === "too_many_models" + error: extracted.reason === "too_many_models" ? `upstream /models exceeded the ${discovery.maxModels}-row model limit` : "upstream /models returned an unexpected shape", }); } - const models = envelope.rows.length; + const models = "items" in extracted ? extracted.items.length : extracted.rows.length; return jsonResponse({ ok: true, latencyMs, diff --git a/structure/01_runtime.md b/structure/01_runtime.md index c273fc337..dde66fd1e 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -81,4 +81,7 @@ config. A response is rejected before caching when it exceeds 4 MiB, contains mo rows, has a malformed OpenAI list envelope, or includes an invalid model id. Tests use fixtures and must never depend on live provider endpoints. Newly promoted fixed key presets opt into `preserveCustomDestination`, so an older same-named custom provider keeps its configured adapter, -destination, and key boundary instead of being silently canonicalized onto the new host. +destination, and key boundary instead of being silently canonicalized onto the new host. Fixed +OAuth presets resolve discovery against the same canonical registry transport as normal routing +before any adapter-specific transport override, so a stale configured `baseUrl` cannot receive an +OAuth bearer token. diff --git a/tests/provider-connection-test.test.ts b/tests/provider-connection-test.test.ts index f4bf64775..9e89fe413 100644 --- a/tests/provider-connection-test.test.ts +++ b/tests/provider-connection-test.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { handleManagementAPI } from "../src/server/management-api"; import { saveConfig } from "../src/config"; +import { PROVIDER_REGISTRY } from "../src/providers/registry"; import type { OcxConfig } from "../src/types"; const TEST_DIR = join(tmpdir(), "ocx-conn-test"); @@ -78,6 +79,38 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { expect(String(body.error)).toContain("401"); }); + test("releases a rejected upstream response body", async () => { + let cancelled = false; + globalThis.fetch = (async () => new Response(new ReadableStream({ + cancel() { + cancelled = true; + }, + }), { status: 401 })) as typeof fetch; + const config = baseConfig({ + fake: { adapter: "openai-chat", baseUrl: "https://api.example.test/v1", apiKey: "sk-fake" }, + }); + const { body } = await probe(config, "fake"); + expect(body.ok).toBe(false); + expect(cancelled).toBe(true); + }); + + test("blocks an unsafe discovery destination before sending provider headers", async () => { + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return Response.json({ data: [] }); + }) as typeof fetch; + const config = baseConfig({ + blocked: { adapter: "openai-chat", baseUrl: "https://api.example.test/v1", apiKey: "sk-secret" }, + }); + config.providers.blocked!.baseUrl = "http://127.0.0.1:8080/v1"; + + const { body } = await probe(config, "blocked"); + expect(body.ok).toBe(false); + expect(String(body.error)).toContain("destination policy"); + expect(fetches).toBe(0); + }); + test("disabled providers fail fast without touching the network", async () => { let fetches = 0; globalThis.fetch = (async () => { fetches++; return new Response("{}", { status: 200 }); }) as typeof fetch; @@ -117,6 +150,37 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { expect(body.models).toBe(2); }); + test("reports only eligible deduplicated models from a registry discovery contract", async () => { + const entry = PROVIDER_REGISTRY.find(row => row.id === "together"); + if (!entry) throw new Error("missing together registry entry"); + const original = entry.modelDiscovery; + entry.modelDiscovery = { + filter: { anyOf: [{ path: ["type"], equalsAny: ["chat"] }] }, + }; + try { + globalThis.fetch = (async () => Response.json({ + data: [ + { id: "chat-model", type: "chat" }, + { id: "chat-model", type: "chat" }, + { id: "embedding-model", type: "embedding" }, + ], + })) as typeof fetch; + const config = baseConfig({ + together: { + adapter: "openai-chat", + baseUrl: "https://api.together.xyz/v1", + apiKey: "sk-live", + }, + }); + const { body } = await probe(config, "together"); + expect(body.ok).toBe(true); + expect(body.models).toBe(1); + } finally { + if (original === undefined) delete entry.modelDiscovery; + else entry.modelDiscovery = original; + } + }); + test("Google's models-array response shape is accepted (x-goog-api-key path)", async () => { let requestedUrl = ""; globalThis.fetch = (async (input: RequestInfo | URL) => { @@ -148,6 +212,18 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { expect(String(body.error)).toContain("unexpected shape"); }); + test("a malformed model row fails the probe like authoritative discovery", async () => { + globalThis.fetch = (async () => Response.json({ + data: [{ id: "valid" }, { id: " padded" }], + })) as typeof fetch; + const config = baseConfig({ + weird: { adapter: "openai-chat", baseUrl: "https://api.example.test/v1", apiKey: "sk-x" }, + }); + const { body } = await probe(config, "weird"); + expect(body.ok).toBe(false); + expect(String(body.error)).toContain("unexpected shape"); + }); + test("unknown provider is a 404", async () => { const config = baseConfig({ real: { adapter: "openai-chat", baseUrl: "https://api.example.test/v1", apiKey: "sk-x" }, diff --git a/tests/provider-model-discovery-contract.test.ts b/tests/provider-model-discovery-contract.test.ts index b3596d457..40471726b 100644 --- a/tests/provider-model-discovery-contract.test.ts +++ b/tests/provider-model-discovery-contract.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { gatherRoutedModels } from "../src/codex/catalog"; +import { catalogHintsFromModelsApiItem } from "../src/codex/catalog/provider-fetch"; import { clearModelCache } from "../src/codex/model-cache"; import { buildModelsRequest } from "../src/oauth"; import { KEY_LOGIN_PROVIDERS, validateApiKey } from "../src/oauth/key-providers"; @@ -50,6 +51,23 @@ async function withTogetherDiscovery( } } +async function withRegistryDiscovery( + providerId: string, + spec: ProviderModelDiscoverySpec, + run: () => Promise | T, +): Promise { + const entry = PROVIDER_REGISTRY.find(row => row.id === providerId); + if (!entry) throw new Error(`missing ${providerId} registry entry`); + const original = entry.modelDiscovery; + entry.modelDiscovery = spec; + try { + return await run(); + } finally { + if (original === undefined) delete entry.modelDiscovery; + else entry.modelDiscovery = original; + } +} + function togetherConfig(overrides: Partial = {}): OcxConfig { return { port: 10100, @@ -76,6 +94,11 @@ describe("registry-owned provider model discovery", () => { .toContain("https"); expect(providerModelDiscoverySpecError({ path: "models?unbounded=true" })) .toContain("query-free"); + expect(providerModelDiscoverySpecError({ + url: "https://api.example.test/models", + path: "models", + } as unknown as ProviderModelDiscoverySpec)).toContain("mutually exclusive"); + expect(providerModelDiscoverySpecError({ maxModels: 25 })).toBeNull(); }); test("limits collision preservation to fixed API-key destinations", () => { @@ -122,6 +145,36 @@ describe("registry-owned provider model discovery", () => { }); }); + test("pins fixed OAuth discovery before resolving relative and default endpoints", async () => { + const staleConfig: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://untrusted.example/v9", + authMode: "oauth", + }; + + await withRegistryDiscovery("anthropic", { path: "catalog" }, () => { + const relative = buildModelsRequest(staleConfig, "oauth-token", "anthropic"); + expect(relative.url).toBe("https://api.anthropic.com/catalog"); + expect(relative.headers.Authorization).toBe("Bearer oauth-token"); + }); + + await withRegistryDiscovery("anthropic", { maxModels: 25 }, () => { + const defaultEndpoint = buildModelsRequest(staleConfig, "oauth-token", "anthropic"); + expect(defaultEndpoint.url).toBe("https://api.anthropic.com/v1/models?limit=1000"); + }); + }); + + test("keeps adapter-specific OAuth transport resolution after registry pinning", async () => { + await withRegistryDiscovery("xai", { path: "catalog" }, () => { + const request = buildModelsRequest({ + adapter: "openai-chat", + baseUrl: "https://untrusted.example/v9", + authMode: "oauth", + }, "oauth-token", "xai"); + expect(request.url).toBe("https://cli-chat-proxy.grok.com/v1/catalog"); + }); + }); + test("does not persist trusted discovery or collision policy into provider config", async () => { await withTogetherDiscovery({ path: "catalog", query: { capability: "chat" } }, () => { const entry = togetherEntry(); @@ -160,6 +213,37 @@ describe("registry-owned provider model discovery", () => { }); }); + test("accepts only positive safe-integer token limits from live metadata", () => { + expect(catalogHintsFromModelsApiItem("example", { + id: "fractional", + context_size: 1_000, + max_input_tokens: 0.5, + })).toEqual({ contextWindow: 1_000 }); + + expect(catalogHintsFromModelsApiItem("example", { + id: "unsafe", + context_size: Number.MAX_SAFE_INTEGER + 1, + max_input_tokens: Number.MAX_SAFE_INTEGER + 1, + })).toEqual({}); + }); + + test("drops untrusted metadata tokens containing control characters", () => { + expect(catalogHintsFromModelsApiItem("example", { + id: "controlled", + capabilities: { + "to\u0000ols": true, + "\u001b[31mreasoning": true, + vision: true, + }, + input_modalities: ["te\u0000xt"], + reasoning_efforts: ["h\u2028igh", "low"], + })).toEqual({ + reasoningEfforts: ["low"], + inputModalities: ["text", "image"], + capabilities: ["vision"], + }); + }); + test("rejects an over-limit raw catalog instead of truncating or caching it", async () => { await withTogetherDiscovery({ maxModels: 2 }, async () => { const warning = spyOn(console, "warn").mockImplementation(() => {}); From 92ab57df3f2b3bae51989d30af25527cee739636 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Tue, 28 Jul 2026 21:58:19 -0700 Subject: [PATCH 3/3] docs(providers): clarify discovery contains predicates --- src/providers/registry.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 0de97cc53..61c9eb058 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -27,11 +27,16 @@ export type ProviderModelDiscoveryPredicate = } | { path: readonly string[]; + /** + * A string-valued upstream target uses substring matching; an array-valued target uses + * exact element matching. Use `equalsAny` when the string must match in full. + */ containsAny: readonly ProviderModelDiscoveryScalar[]; caseInsensitive?: boolean; } | { path: readonly string[]; + /** Uses the same string-substring and array-element semantics as `containsAny`. */ containsAll: readonly ProviderModelDiscoveryScalar[]; caseInsensitive?: boolean; };