Skip to content

Commit 4e8e7d5

Browse files
committed
fix(providers): harden model discovery contract
1 parent b373ddd commit 4e8e7d5

11 files changed

Lines changed: 333 additions & 36 deletions

File tree

docs-site/src/content/docs/ja/reference/configuration.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ token の代わりに使えます。すべての候補は timing side channel
136136
| Field | Type | Meaning |
137137
| --- | --- | --- |
138138
| `adapter` | `string` | `openai-chat``openai-responses``anthropic``google``kiro``cursor``azure-openai`(または別名 `azure`)のいずれか。 |
139-
| `baseUrl` | `string` | 上流 API base URL。 |
139+
| `baseUrl` | `string` | 上流 API base URL。固定 endpoint を持つ大半の組み込み provider は一致しない URL を無視します。新しく追加された衝突保護付き API-key preset は、以前からある同名 custom provider の送信先を維持します。[固定プロバイダーのエンドポイント](#固定プロバイダーのエンドポイント)を参照してください。 |
140140
| `responsesPath?` | `string` | `key` 認証の `openai-responses` リクエストに使う任意の相対 resource path。`/` で始め、URL scheme、query、fragment を含めてはいけません。省略時は従来の `/v1/responses` URL 構築を維持します。 |
141141
| `disabled?` | `boolean` | 設定はディスクに残すがルーティングとモデル/カタログ一覧から除外します。 |
142142
| `apiKey?` | `string` | API キーまたはリクエスト時に解釈する `${ENV_VAR}` / `$ENV_VAR` 参照。 |
@@ -177,6 +177,29 @@ token の代わりに使えます。すべての候補は timing side channel
177177
| `desktopExecutor?` | `DesktopExecutorConfig` | **Cursor 専用。** 外部 computer-use/record-screen コマンド。フィールドは下で説明します。 |
178178
| `unsafeAllowNativeLocalExec?` | `boolean` | **Cursor アダプター専用。** Cursor サーバーが指示したローカル `read` / `write` / `delete` / `ls` / `grep` / `shell` / `fetch` 実行を許可する opt-in escape hatch。デフォルト `false` なのでリモート Cursor メッセージが Codex の承認と sandbox を迂回できません。下記 [Cursor プロバイダー](#cursor-プロバイダー-adapter-cursor) 参照。 |
179179

180+
### 固定プロバイダーのエンドポイント
181+
182+
ルーティングは、adapter がリクエストを受け取る前に provider の endpoint を解決します。大半の
183+
組み込み provider では、config の `baseUrl` より registry の endpoint が優先されます。この段階で
184+
設定 URL を維持するのは次の 4 種類です。
185+
186+
- override を明示的に許可する provider: `ollama``vllm``lm-studio``litellm`
187+
`qwen-cloud``alibaba-token-plan-intl`
188+
- registry endpoint が入力用 template の provider(`azure-openai``cloudflare-ai-gateway` など)。
189+
- 同名衝突を保護する新しい固定 API-key preset。以前からある同名 custom provider が別の送信先を
190+
指している場合、その送信先を維持し、key を新しい registry host へ送りません。
191+
- registry に存在せず、ユーザーが独自に定義した provider。
192+
193+
その後も adapter が解決済み URL を調整する場合があります。たとえば `kiro` adapter は canonical な
194+
`runtime.{region}.kiro.dev` host に対して、import した credential の API region を使います。adapter
195+
ごとの規則は [Adapters](/ja/reference/adapters/) を参照してください。
196+
197+
ルーティングが設定済み `baseUrl` を破棄すると opencodex は警告を出します。registry endpoint は完全に
198+
表示し、設定 URL は origin だけを表示します。path は credential 情報を含む可能性があるため記録しません。
199+
不要な `baseUrl` を削除するか、目的の URL と endpoint が一致する provider を選んでください。地域別
200+
サービスでは正しい項目を使ってください。`alibaba-token-plan` は北京に固定され、
201+
`alibaba-token-plan-intl` は国際 endpoint の override を許可します。
202+
180203
## Cursor プロバイダー(`adapter: "cursor"`
181204

182205
Cursor bridge は実験的です。`ocx login cursor` を実行したのち

docs-site/src/content/docs/ko/reference/configuration.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ token 대신 쓸 수 있습니다. 모든 후보는 timing side channel을 막
143143
| Field | Type | Meaning |
144144
| --- | --- | --- |
145145
| `adapter` | `string` | `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai`(또는 별칭 `azure`) 중 하나. |
146-
| `baseUrl` | `string` | 업스트림 API base URL. |
146+
| `baseUrl` | `string` | 업스트림 API base URL. 고정 endpoint를 쓰는 대부분의 기본 제공 provider는 일치하지 않는 URL을 무시합니다. 새로 승격된 충돌 보호 API-key preset은 기존의 같은 이름 custom provider 목적지를 유지합니다. [고정 프로바이더 엔드포인트](#고정-프로바이더-엔드포인트)를 참조하세요. |
147147
| `responsesPath?` | `string` | `key` 인증 `openai-responses` 요청에 사용할 선택적 상대 resource path. `/`로 시작해야 하며 URL scheme, query, fragment를 포함할 수 없습니다. 생략하면 기존 `/v1/responses` URL 구성을 유지합니다. |
148148
| `disabled?` | `boolean` | 설정은 디스크에 남기되 라우팅과 모델/카탈로그 목록에서 제외합니다. |
149149
| `apiKey?` | `string` | API 키 또는 요청 시점에 해석할 `${ENV_VAR}` / `$ENV_VAR` 참조. |
@@ -187,6 +187,29 @@ token 대신 쓸 수 있습니다. 모든 후보는 timing side channel을 막
187187
| `desktopExecutor?` | `DesktopExecutorConfig` | **Cursor 전용.** 외부 computer-use/record-screen 명령. 필드는 아래에 설명합니다. |
188188
| `unsafeAllowNativeLocalExec?` | `boolean` | **Cursor 어댑터 전용.** Cursor 서버가 지시한 로컬 `read` / `write` / `delete` / `ls` / `grep` / `shell` / `fetch` 실행을 허용하는 opt-in escape hatch. 기본 `false`라 원격 Cursor 메시지가 Codex 승인과 sandbox를 우회하지 못합니다. 아래 [Cursor 프로바이더](#cursor-프로바이더-adapter-cursor) 참조. |
189189

190+
### 고정 프로바이더 엔드포인트
191+
192+
라우팅은 adapter가 요청을 보기 전에 provider endpoint를 결정합니다. 대부분의 기본 제공 provider에서는
193+
config의 `baseUrl`보다 registry endpoint가 우선합니다. 이 단계에서 설정 URL을 유지하는 경우는 네 가지입니다.
194+
195+
- override를 명시적으로 허용하는 provider: `ollama`, `vllm`, `lm-studio`, `litellm`, `qwen-cloud`,
196+
`alibaba-token-plan-intl`.
197+
- registry endpoint가 사용자가 채우는 template인 provider(예: `azure-openai`,
198+
`cloudflare-ai-gateway`).
199+
- 이름 충돌을 보호하는 새 고정 API-key preset. 기존의 같은 이름 custom provider가 다른 목적지를
200+
가리키면 원래 목적지를 유지하며 key를 새 registry host로 보내지 않습니다.
201+
- registry에 없고 사용자가 직접 정의한 provider.
202+
203+
그 뒤에도 adapter가 결정된 URL을 조정할 수 있습니다. 예를 들어 `kiro` adapter는 canonical
204+
`runtime.{region}.kiro.dev` host에서 가져온 credential의 API region을 사용합니다. adapter별 규칙은
205+
[Adapters](/ko/reference/adapters/)를 참조하세요.
206+
207+
라우팅이 설정된 `baseUrl`을 버리면 opencodex가 경고를 출력합니다. registry endpoint는 전체를 표시하고
208+
설정 URL은 origin만 표시합니다. path는 credential 정보를 포함할 수 있으므로 기록하지 않습니다. 사용하지
209+
않는 `baseUrl`을 제거하거나 목적 URL과 endpoint가 일치하는 provider를 선택하세요. 지역별 서비스에서는
210+
올바른 항목을 사용해야 합니다. `alibaba-token-plan`은 베이징으로 고정되고,
211+
`alibaba-token-plan-intl`은 국제 endpoint override를 허용합니다.
212+
190213
## Cursor 프로바이더 (`adapter: "cursor"`)
191214

192215
Cursor bridge는 실험적입니다. `ocx login cursor`를 실행한 뒤

docs-site/src/content/docs/reference/configuration.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -474,15 +474,15 @@ example above, select `openrouter/anthropic-claude-sonnet-5` in Codex; OpenCodex
474474
Some providers expose very large or slow live model catalogs. Set `liveModels` to `false` when you
475475
want Codex to see only the models pinned in `models`:
476476

477+
When `liveModels` is `false` and `models` is empty or omitted, opencodex exposes no routed models
478+
for that provider.
479+
477480
Live discovery rejects a response before caching when it exceeds 4 MiB or 2,000 raw model rows.
478481
Built-in presets may lower either limit and filter mixed catalogs to chat-eligible rows. An
479482
oversized or malformed response follows the normal stale/configured fallback path, while
480483
ineligible rows are excluded. A valid result with zero eligible rows remains an authoritative
481484
empty catalog; OpenCodex never silently truncates an over-limit response.
482485

483-
When `liveModels` is `false` and `models` is empty or omitted, opencodex exposes no routed models
484-
for that provider.
485-
486486
Use `selectedModels` for a different purpose: discovery still runs, but only the selected ids are
487487
published to Codex's catalog and `/v1/models`. The dashboard's full model list remains available so
488488
the allowlist can be changed later.

docs-site/src/content/docs/ru/reference/configuration.md

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ x-opencodex-api-key: your-secret-token
163163
| Field | Type | Meaning |
164164
| --- | --- | --- |
165165
| `adapter` | `string` | Одно из `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (или алиас `azure`). |
166-
| `baseUrl` | `string` | Базовый URL вышестоящего API. |
166+
| `baseUrl` | `string` | Базовый URL вышестоящего API. Большинство встроенных провайдеров с фиксированными конечными точками игнорируют несовпадающий адрес; новые API-key preset с защитой от коллизий сохраняют назначение старого одноимённого custom provider. См. [Фиксированные конечные точки провайдеров](#фиксированные-конечные-точки-провайдеров). |
167167
| `responsesPath?` | `string` | Необязательный относительный путь ресурса для запросов `openai-responses` с аутентификацией `key`. Должен начинаться с `/` и не содержать схему URL, query или fragment. Если поле опущено, сохраняется прежнее построение URL `/v1/responses`. |
168168
| `disabled?` | `boolean` | Провайдер остаётся на диске, но исключается из маршрутизации и списков моделей/каталога. |
169169
| `apiKey?` | `string` | API-ключ или ссылка `${ENV_VAR}` / `$ENV_VAR`, разрешаемая в момент запроса. |
@@ -206,6 +206,31 @@ x-opencodex-api-key: your-secret-token
206206
| `desktopExecutor?` | `DesktopExecutorConfig` | **Только Cursor.** Внешние команды computer-use/record-screen; поля перечислены ниже. |
207207
| `unsafeAllowNativeLocalExec?` | `boolean` | **Только адаптер Cursor.** Явно включаемая лазейка для управляемого сервером Cursor локального выполнения `read` / `write` / `delete` / `ls` / `grep` / `shell` / `fetch`. По умолчанию `false`, поэтому удалённые сообщения Cursor не могут обойти одобрения и песочницу Codex. См. [Провайдер Cursor](#провайдер-cursor-adapter-cursor) ниже. |
208208

209+
### Фиксированные конечные точки провайдеров
210+
211+
Маршрутизация определяет конечную точку провайдера до того, как запрос увидит адаптер. Для
212+
большинства встроенных провайдеров адрес из registry имеет приоритет над `baseUrl` в конфигурации.
213+
Настроенный URL сохраняется на этом этапе в четырёх случаях:
214+
215+
- провайдер явно разрешает переопределение — `ollama`, `vllm`, `lm-studio`, `litellm`,
216+
`qwen-cloud` и `alibaba-token-plan-intl`;
217+
- конечная точка registry является заполняемым шаблоном, как у `azure-openai` и
218+
`cloudflare-ai-gateway`;
219+
- новый фиксированный API-key preset защищает коллизию имени: старый одноимённый custom provider,
220+
указывающий на другой адрес, сохраняет исходное назначение и не отправляет ключ новому host из registry;
221+
- провайдер определён пользователем и отсутствует в registry.
222+
223+
После этого адаптер всё ещё может изменить разрешённый URL. Например, адаптер `kiro` использует
224+
API-регион импортированных учётных данных для канонического host `runtime.{region}.kiro.dev`.
225+
Правила для каждого адаптера описаны в разделе [Адаптеры](/ru/reference/adapters/).
226+
227+
Если маршрутизация отбрасывает настроенный `baseUrl`, opencodex выводит предупреждение. Адрес
228+
registry показывается полностью, а настроенный адрес только как origin; путь скрывается, поскольку
229+
он может содержать данные учётных записей. Удалите неиспользуемый `baseUrl` либо выберите provider,
230+
чья конечная точка соответствует нужному URL. Для региональных сервисов используйте правильную
231+
запись: `alibaba-token-plan` закреплён за Пекином, а `alibaba-token-plan-intl` допускает международную
232+
конечную точку.
233+
209234
## Провайдер Cursor (`adapter: "cursor"`)
210235

211236
Мост Cursor экспериментален. После `ocx login cursor` добавьте или отредактируйте запись
@@ -281,15 +306,15 @@ HTTP). Stdio-записи также принимают `args?: string[]`, `env?
281306
Некоторые провайдеры отдают очень большие или медленные живые каталоги моделей. Установите
282307
`liveModels` в `false`, когда хотите, чтобы Codex видел только модели, закреплённые в `models`:
283308

309+
Когда `liveModels` равен `false`, а `models` пуст или опущен, opencodex не открывает для этого
310+
провайдера ни одной маршрутизируемой модели.
311+
284312
Живой discovery отклоняет ответ до кэширования, если он превышает 4 MiB или 2 000 исходных строк
285313
моделей. Встроенный preset может снизить эти лимиты и отфильтровать смешанный каталог до chat-моделей.
286314
Слишком большой или повреждённый ответ использует обычный stale/static fallback, а неподходящие
287315
строки исключаются. Корректный ответ без подходящих строк остаётся авторитетным пустым каталогом;
288316
ответ сверх лимита никогда не обрезается молча.
289317

290-
Когда `liveModels` равен `false`, а `models` пуст или опущен, opencodex не открывает для этого
291-
провайдера ни одной маршрутизируемой модели.
292-
293318
`selectedModels` служит другой цели: обнаружение по-прежнему выполняется, но в каталог Codex и
294319
`/v1/models` публикуются только выбранные id. Полный список моделей в дашборде остаётся
295320
доступным, поэтому allowlist можно изменить позже.

src/codex/catalog/provider-fetch.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,16 @@ function plainRecord(value: unknown): Record<string, unknown> | undefined {
185185
: undefined;
186186
}
187187

188-
function positiveFiniteNumber(...values: unknown[]): number | undefined {
189-
return values.find(value => typeof value === "number" && Number.isFinite(value) && value > 0) as number | undefined;
188+
const MODEL_DISCOVERY_METADATA_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
189+
190+
function positiveSafeInteger(...values: unknown[]): number | undefined {
191+
return values.find(value => typeof value === "number" && Number.isSafeInteger(value) && value > 0) as number | undefined;
192+
}
193+
194+
function normalizedMetadataString(raw: string, maxLength: number): string | undefined {
195+
if (raw.length > maxLength * 4 || MODEL_DISCOVERY_METADATA_CONTROL_CHARS.test(raw)) return undefined;
196+
const normalized = raw.trim().toLowerCase().replace(/\s+/g, "-").slice(0, maxLength);
197+
return normalized || undefined;
190198
}
191199

192200
function normalizedStringList(value: unknown, maxItems = 32, maxLength = 64): string[] | undefined {
@@ -196,8 +204,7 @@ function normalizedStringList(value: unknown, maxItems = 32, maxLength = 64): st
196204
for (let i = 0; i < value.length && i < maxInspectedItems; i += 1) {
197205
const raw = value[i];
198206
if (typeof raw !== "string") continue;
199-
if (raw.length > maxLength * 4) continue;
200-
const normalized = raw.trim().toLowerCase().replace(/\s+/g, "-").slice(0, maxLength);
207+
const normalized = normalizedMetadataString(raw, maxLength);
201208
if (normalized && !out.includes(normalized)) out.push(normalized);
202209
if (out.length >= maxItems) break;
203210
}
@@ -218,8 +225,9 @@ function modelCapabilities(item: ProviderModelsApiItem): string[] | undefined {
218225
if (!Object.hasOwn(capabilityFields, key)) continue;
219226
inspectedCapabilityFields += 1;
220227
if (inspectedCapabilityFields > 256 || out.size >= 32) break;
221-
if (key.length <= 256 && capabilityFields[key] === true) {
222-
out.add(key.trim().toLowerCase().replace(/\s+/g, "-").slice(0, 64));
228+
if (capabilityFields[key] === true) {
229+
const normalized = normalizedMetadataString(key, 64);
230+
if (normalized) out.add(normalized);
223231
}
224232
}
225233
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
258266
const capabilityRecord = plainRecord(metadata?.capabilities) ?? plainRecord(item.capabilities);
259267
const limits = plainRecord(metadata?.limits);
260268
const contextWindow =
261-
positiveFiniteNumber(
269+
positiveSafeInteger(
262270
limits?.max_context_length,
263271
item.context_length,
264272
item.context_size,
265273
item.max_model_len,
266274
item.max_context_length,
267275
);
268-
const maxInputTokens = positiveFiniteNumber(limits?.max_input_tokens, item.max_input_tokens);
276+
const maxInputTokens = positiveSafeInteger(limits?.max_input_tokens, item.max_input_tokens);
269277
const rawReasoningEfforts = capabilityRecord?.reasoning_effort ?? item.reasoning_efforts;
270278
const listedReasoningEfforts = normalizedStringList(rawReasoningEfforts, 8, 24);
271279
const reasoningEfforts = listedReasoningEfforts
@@ -290,7 +298,7 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
290298

291299
function boundedOwnedBy(value: unknown): string | undefined {
292300
if (typeof value !== "string" || value.length === 0 || value.length > 256) return undefined;
293-
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(value)) return undefined;
301+
if (MODEL_DISCOVERY_METADATA_CONTROL_CHARS.test(value)) return undefined;
294302
return value;
295303
}
296304

0 commit comments

Comments
 (0)