Skip to content

Commit a366387

Browse files
committed
fix(providers): harden model discovery contract
1 parent 283cbd8 commit a366387

11 files changed

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

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

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

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

207230
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
@@ -493,15 +493,15 @@ example above, select `openrouter/anthropic-claude-sonnet-5` in Codex; OpenCodex
493493
Some providers expose very large or slow live model catalogs. Set `liveModels` to `false` when you
494494
want Codex to see only the models pinned in `models`:
495495

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

502-
When `liveModels` is `false` and `models` is empty or omitted, opencodex exposes no routed models
503-
for that provider.
504-
505505
Use `selectedModels` for a different purpose: discovery still runs, but only the selected ids are
506506
published to Codex's catalog and `/v1/models`. The dashboard's full model list remains available so
507507
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
@@ -181,7 +181,7 @@ x-opencodex-api-key: your-secret-token
181181
| Field | Type | Meaning |
182182
| --- | --- | --- |
183183
| `adapter` | `string` | Одно из `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (или алиас `azure`). |
184-
| `baseUrl` | `string` | Базовый URL вышестоящего API. |
184+
| `baseUrl` | `string` | Базовый URL вышестоящего API. Большинство встроенных провайдеров с фиксированными конечными точками игнорируют несовпадающий адрес; новые API-key preset с защитой от коллизий сохраняют назначение старого одноимённого custom provider. См. [Фиксированные конечные точки провайдеров](#фиксированные-конечные-точки-провайдеров). |
185185
| `responsesPath?` | `string` | Необязательный относительный путь ресурса для запросов `openai-responses` с аутентификацией `key`. Должен начинаться с `/` и не содержать схему URL, query или fragment. Если поле опущено, сохраняется прежнее построение URL `/v1/responses`. |
186186
| `disabled?` | `boolean` | Провайдер остаётся на диске, но исключается из маршрутизации и списков моделей/каталога. |
187187
| `apiKey?` | `string` | API-ключ или ссылка `${ENV_VAR}` / `$ENV_VAR`, разрешаемая в момент запроса. |
@@ -224,6 +224,31 @@ x-opencodex-api-key: your-secret-token
224224
| `desktopExecutor?` | `DesktopExecutorConfig` | **Только Cursor.** Внешние команды computer-use/record-screen; поля перечислены ниже. |
225225
| `unsafeAllowNativeLocalExec?` | `boolean` | **Только адаптер Cursor.** Явно включаемая лазейка для управляемого сервером Cursor локального выполнения `read` / `write` / `delete` / `ls` / `grep` / `shell` / `fetch`. По умолчанию `false`, поэтому удалённые сообщения Cursor не могут обойти одобрения и песочницу Codex. См. [Провайдер Cursor](#провайдер-cursor-adapter-cursor) ниже. |
226226

227+
### Фиксированные конечные точки провайдеров
228+
229+
Маршрутизация определяет конечную точку провайдера до того, как запрос увидит адаптер. Для
230+
большинства встроенных провайдеров адрес из registry имеет приоритет над `baseUrl` в конфигурации.
231+
Настроенный URL сохраняется на этом этапе в четырёх случаях:
232+
233+
- провайдер явно разрешает переопределение — `ollama`, `vllm`, `lm-studio`, `litellm`,
234+
`qwen-cloud` и `alibaba-token-plan-intl`;
235+
- конечная точка registry является заполняемым шаблоном, как у `azure-openai` и
236+
`cloudflare-ai-gateway`;
237+
- новый фиксированный API-key preset защищает коллизию имени: старый одноимённый custom provider,
238+
указывающий на другой адрес, сохраняет исходное назначение и не отправляет ключ новому host из registry;
239+
- провайдер определён пользователем и отсутствует в registry.
240+
241+
После этого адаптер всё ещё может изменить разрешённый URL. Например, адаптер `kiro` использует
242+
API-регион импортированных учётных данных для канонического host `runtime.{region}.kiro.dev`.
243+
Правила для каждого адаптера описаны в разделе [Адаптеры](/ru/reference/adapters/).
244+
245+
Если маршрутизация отбрасывает настроенный `baseUrl`, opencodex выводит предупреждение. Адрес
246+
registry показывается полностью, а настроенный адрес только как origin; путь скрывается, поскольку
247+
он может содержать данные учётных записей. Удалите неиспользуемый `baseUrl` либо выберите provider,
248+
чья конечная точка соответствует нужному URL. Для региональных сервисов используйте правильную
249+
запись: `alibaba-token-plan` закреплён за Пекином, а `alibaba-token-plan-intl` допускает международную
250+
конечную точку.
251+
227252
## Провайдер Cursor (`adapter: "cursor"`)
228253

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

327+
Когда `liveModels` равен `false`, а `models` пуст или опущен, opencodex не открывает для этого
328+
провайдера ни одной маршрутизируемой модели.
329+
302330
Живой discovery отклоняет ответ до кэширования, если он превышает 4 MiB или 2 000 исходных строк
303331
моделей. Встроенный preset может снизить эти лимиты и отфильтровать смешанный каталог до chat-моделей.
304332
Слишком большой или повреждённый ответ использует обычный stale/static fallback, а неподходящие
305333
строки исключаются. Корректный ответ без подходящих строк остаётся авторитетным пустым каталогом;
306334
ответ сверх лимита никогда не обрезается молча.
307335

308-
Когда `liveModels` равен `false`, а `models` пуст или опущен, opencodex не открывает для этого
309-
провайдера ни одной маршрутизируемой модели.
310-
311336
`selectedModels` служит другой цели: обнаружение по-прежнему выполняется, но в каталог Codex и
312337
`/v1/models` публикуются только выбранные id. Полный список моделей в дашборде остаётся
313338
доступным, поэтому 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)