Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ account を削除しても mapping は保持され、同じ id を再追加す
| `models?` | `string[]` |シード/フォールバック モデルのリスト。 `liveModels: false` では、発見されたモデルはこれらのみです。 |
| `liveModels?` | `boolean` |開始/同期時にライブ カタログをフェッチします (デフォルトは `true`)。カスタムプロバイダーは `${baseUrl}/models` を使用します。組み込みはレジストリ URL とフィルターを使用する場合があります。 |
| `selectedModels?` | `string[]` |検出後のカタログ許可リスト。空でない場合は、それらの ID のみが公開されます。空または省略すると、検出されたすべてのモデルが公開されます。 |
| `retainModels?` | `string[]` | ライブディスカバリで省略された場合でもカタログに保持するモデルIDの許可リスト(未プロビジョニングのアカウントや未掲載モデルなど)。ここに記載されていない設定済みIDは、ディスカバリで省略された場合に引き続き除外(prune)されます。 |
| `contextWindow?` | `number` | アップストリームのメタデータが無い場合に使うプロバイダー全体のコンテキスト値。メタデータがある場合は上限として働き、より小さいライブ値をそのまま残します。Models ダッシュボードでは `providerContextCaps` とは別に設定します。 |
| `modelContextWindows?` | `Record<string, number>` | モデルごとのコンテキスト値および上限。`contextWindow` より優先され、ウィンドウが不明なら設定値を使い、より小さいライブメタデータがあればそちらが優先されます。 |
| `modelInputModalities?` | `Record<string, string[]>` | `["text"]` や `["text", "image"]` などのモデルごとの入力ヒント。 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정
| `models?` | `string[]` | 시드/폴백 모델 목록입니다. `liveModels: false`이면 이 목록만 발견된 모델로 취급합니다. |
| `liveModels?` | `boolean` | 시작 또는 동기화 시 라이브 카탈로그를 가져옵니다. 기본값은 `true`입니다. 사용자 지정 공급자는 `${baseUrl}/models`를 사용하고, 내장은 레지스트리 URL을 사용한 뒤 필터링할 수 있습니다. |
| `selectedModels?` | `string[]` | 발견 후 카탈로그 허용 목록입니다. 값이 비어 있지 않으면 그 id만 노출하고, 비어 있거나 생략하면 발견된 모델을 모두 노출합니다. |
| `retainModels?` | `string[]` | 라이브 발견에서 누락되더라도 카탈로그에 유지할 모델 ID 허용 목록입니다 (예: 미프로비저닝 계정 또는 미등록 모델). 여기에 나열되지 않은 구성 ID는 발견에서 누락될 때 계속 제외(prune)됩니다. |
| `contextWindow?` | `number` | 업스트림 메타데이터가 없을 때 쓰이는 공급자 전반의 컨텍스트 값입니다. 메타데이터가 있으면 상한으로 동작해 더 작은 라이브 값을 그대로 둡니다. Models 대시보드에서 `providerContextCaps`와 별도로 설정합니다. |
| `modelContextWindows?` | `Record<string, number>` | 모델별 컨텍스트 값이자 상한입니다. `contextWindow`보다 우선하며, 창 크기를 알 수 없으면 설정값을 쓰고 더 작은 라이브 메타데이터가 있으면 그쪽을 따릅니다. |
| `modelInputModalities?` | `Record<string, string[]>` | `["text"]` 또는 `["text", "image"]` 같은 모델별 입력 힌트입니다. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids.
| `models?` | `string[]` | Seed/fallback model list. With `liveModels: false`, these are the only discovered models. |
| `liveModels?` | `boolean` | Fetch the live catalog on start/sync (default `true`). Custom providers use `${baseUrl}/models`; built-ins may use a registry URL and filter. |
| `selectedModels?` | `string[]` | Catalog allowlist after discovery. Non-empty exposes only those ids; empty or omitted exposes all discovered models. |
| `retainModels?` | `string[]` | Provider-level allowlist of model IDs to retain when live discovery omits them (for example, unprovisioned accounts or unlisted models). Configured IDs not listed here continue to be pruned when omitted by discovery. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| `contextWindow?` | `number` | Provider-wide context fallback when upstream metadata is absent; otherwise a cap that retains smaller live metadata. The Models dashboard exposes this separately from `providerContextCaps`. |
| `modelContextWindows?` | `Record<string, number>` | Per-model context fallbacks/caps. These override `contextWindow`: an unknown window uses the configured value, while smaller live metadata remains authoritative. |
| `modelInputModalities?` | `Record<string, string[]>` | Per-model input hints such as `["text"]` or `["text", "image"]`. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ selector,而不是分配一个新名称。
| `models?` | `string[]` | 种子/回退模型列表。配合 `liveModels: false` 时,这些就是唯一发现到的模型。 |
| `liveModels?` | `boolean` | 启动/同步时获取实时目录(默认 `true`)。自定义提供者使用 `${baseUrl}/models`;内置项可能使用注册表 URL 并进行过滤。 |
| `selectedModels?` | `string[]` | 发现之后的目录允许列表。非空时只暴露这些 id;为空或省略时则暴露全部发现到的模型。 |
| `retainModels?` | `string[]` | 提供者级模型 ID 保留允许列表,当实时发现遗漏它们时仍予以保留(例如未开通账号或未列出模型)。未在此列出的配置模型在发现遗漏时继续被剪除(prune)。 |
| `contextWindow?` | `number` | 上游缺少元数据时使用的提供者级上下文数值;有元数据时作为上限,保留更小的实时数值。Models 面板中与 `providerContextCaps` 分开设置。 |
| `modelContextWindows?` | `Record<string, number>` | 按模型设置的上下文数值与上限。优先于 `contextWindow`:窗口未知时采用所配置的数值,而更小的实时元数据仍然优先。 |
| `modelInputModalities?` | `Record<string, string[]>` | 按模型设置的输入提示,例如 `["text"]` 或 `["text", "image"]`。 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ description: 供應商項目、認證、端點、模型目錄、配額、context
| `models?` | `string[]` | 播種/後備模型清單。在 `liveModels: false` 時,這些是唯一探索的模型。 |
| `liveModels?` | `boolean` | 在啟動/同步時擷取即時目錄(預設 `true`)。自訂供應商使用 `${baseUrl}/models`;內建可能使用 registry URL 並過濾。 |
| `selectedModels?` | `string[]` | 探索後的目錄允許清單。非空時僅暴露那些 id;空或省略時暴露所有探索的模型。 |
| `retainModels?` | `string[]` | 當即時探索遺漏模型時仍保留在目錄中的允許清單(例如未開通帳號或未列出模型)。未在此列出的配置模型在探索遺漏時維持剪除(prune)。 |
| `contextWindow?` | `number` | 供應商範圍的 Codex 可見 context 上限。較小的即時中繼資料被保留。 |
| `modelContextWindows?` | `Record<string, number>` | Per-model context 上限。這些覆寫 `contextWindow` 且永不提高較小的即時中繼資料。 |
| `modelInputModalities?` | `Record<string, string[]>` | Per-model 輸入提示,如 `["text"]` 或 `["text", "image"]`。 |
Expand Down
2 changes: 2 additions & 0 deletions src/codex/catalog/parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ export interface CatalogModel {
/** Optional provider-specific copy for the advertised Fast tier. */
fastTierDescription?: string;
supportsReasoningSummaries?: boolean;
/** Whether this model was retained in the catalog via retainModels without live discovery confirmation. */
retainedWithoutDiscovery?: boolean;
/**
* Codex tool calling mode for this routed model.
* "code_mode_only" (default) sets entry.tool_mode = "code_mode_only".
Expand Down
115 changes: 93 additions & 22 deletions src/codex/catalog/provider-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,8 @@ export function reconcileProviderFetchWarnings(generation: number): number {
if (generation <= lastWarningReconciledGeneration) return 0;
const removed = lastDropWarnSignature.size;
lastDropWarnSignature.clear();
retainedWithoutDiscoveryRefs.clear();
warnedRetained404Refs.clear();
lastWarningReconciledGeneration = generation;
return removed;
}
Expand Down Expand Up @@ -978,6 +980,42 @@ export function warnDroppedConfiguredIdsOnce(name: string, droppedConfiguredIds:
);
}

/**
* Model ids on each provider that `retainModels` kept in the catalog even though live
* discovery did not report them. Used by dispatch error paths to explain a later
* upstream 404 (model_not_found) instead of letting the operator blame the proxy.
*/
const retainedWithoutDiscoveryRefs = new Map<string, Set<string>>();
const warnedRetained404Refs = new Set<string>();

/**
* Emit a one-shot warning when a model retained via `retainModels` is rejected by the
* upstream (HTTP 404 / model_not_found). Only fires for models that were actually
* retained without live-discovery confirmation, and only once per provider/model.
*/
export function warnRetainedModel404Once(providerName: string, modelId: string): void {
const refs = retainedWithoutDiscoveryRefs.get(providerName);
if (!refs || !refs.has(modelId)) return;
const signature = `${providerName}/${modelId}`;
if (warnedRetained404Refs.has(signature)) return;
warnedRetained404Refs.add(signature);
console.warn(
`[opencodex] Model "${modelId}" on provider "${providerName}" is retained via retainModels but upstream returned 404/model_not_found; the account or project may not be provisioned for it. Remove it from retainModels if it should not be callable.`,
);
}

/** Test-only helper: check whether a provider model is currently tracked as retained without discovery. */
export function isRetainedModelWithoutDiscoveryForTests(providerName: string, modelId: string): boolean {
return retainedWithoutDiscoveryRefs.get(providerName)?.has(modelId) === true;
}

/** Test-only helper: reset retained model warning states and reconciled generation. */
export function resetRetainedModelWarningsForTests(): void {
retainedWithoutDiscoveryRefs.clear();
warnedRetained404Refs.clear();
lastWarningReconciledGeneration = 0;
}

/**
* Z.AI and Neuralwatt advertise GLM reasoning as a bare boolean, which would otherwise
* collapse to the four-tier default ladder that omits `max`. These two helpers name the
Expand Down Expand Up @@ -1201,20 +1239,48 @@ async function fetchProviderModelsWithAuth(
resolveAuth: ModelsAuthResolver,
): Promise<ProviderModelsResult> {
const { name, provider: prov, discovery, request } = captured;
const observed = (
models: CatalogModel[],
state: CatalogGatherProviderModelOutcome["state"],
): ProviderModelsResult => ({ models, outcome: { provider: name, state } });
// Capture before any credential refresh or outbound await. OAuth account changes clear this
// generation, so a request started with the former account cannot later publish its result.
const cacheGeneration = captureModelCacheGeneration(name);
const isCurrentCacheGeneration = () => isModelCacheGenerationCurrent(name, cacheGeneration);
function syncRetainedModelDiagnostics(
models: readonly CatalogModel[],
state: CatalogGatherProviderModelOutcome["state"],
): void {
if (state !== "authoritative") return;
if (prov.liveModels === false || !Array.isArray(prov.retainModels) || prov.retainModels.length === 0) {
retainedWithoutDiscoveryRefs.delete(name);
return;
}
const retainSet = new Set(prov.retainModels);
const retainedIds = models
.filter(m => m.retainedWithoutDiscovery === true && retainSet.has(m.id))
.map(m => m.id);
if (retainedIds.length > 0) {
retainedWithoutDiscoveryRefs.set(name, new Set(retainedIds));
} else {
retainedWithoutDiscoveryRefs.delete(name);
}
}
const observed = (
models: CatalogModel[],
state: CatalogGatherProviderModelOutcome["state"],
): ProviderModelsResult => {
if (isCurrentCacheGeneration()) {
syncRetainedModelDiagnostics(models, state);
}
return { models, outcome: { provider: name, state } };
};
if (prov.authMode === "forward") return observed([], "authoritative"); // ChatGPT backend has no /models
const seedVertexDefault = prov.adapter === "google"
&& prov.googleMode === "vertex"
&& (prov.models?.length ?? 0) === 0
&& Boolean(prov.defaultModel);
const configuredIds = seedVertexDefault && prov.defaultModel ? [prov.defaultModel] : (prov.models ?? []);
const configuredIds = Array.from(new Set([
...(seedVertexDefault && prov.defaultModel ? [prov.defaultModel] : []),
...(prov.models ?? []),
...(prov.retainModels ?? []),
]));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const configured: CatalogModel[] = configuredIds.map(id => ({
id,
provider: name,
Expand Down Expand Up @@ -1321,7 +1387,7 @@ async function fetchProviderModelsWithAuth(
return observed(withConfiguredRetention(configured), "degraded");
}
markProviderDiscoveryOk(name, liveResult.models.length);
return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
return observed(withConfiguredRetention(result, { warnDrops: true }), "authoritative");
}
if (isCurrentCacheGeneration()) {
markModelsFetchFailure(name);
Expand Down Expand Up @@ -1468,10 +1534,10 @@ async function fetchProviderModelsWithAuth(
provider: name,
// CCA only exposes a numeric thinking budget. Until the adapter owns an exact Codex
// effort-to-wire mapping for a newly discovered model, do not advertise a false ladder.
reasoningEfforts: [],
...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
...(model.inputModalities ? { inputModalities: model.inputModalities } : {}),
}, contextCap));
reasoningEfforts: [],
...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
...(model.inputModalities ? { inputModalities: model.inputModalities } : {}),
}, contextCap));
const forCache = withConfiguredRetention(live, { retainComboTargets: false });
if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
return observed(withConfiguredRetention(configured), "degraded");
Expand All @@ -1481,7 +1547,7 @@ async function fetchProviderModelsWithAuth(
cacheGeneration,
});
markProviderDiscoveryOk(name, live.length);
return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
return observed(withConfiguredRetention(live, { warnDrops: true }), "authoritative");
}
const extracted = extractProviderModelItems(bounded.value, discovery);
if (!extracted.ok) {
Expand Down Expand Up @@ -1512,15 +1578,15 @@ async function fetchProviderModelsWithAuth(
.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.
const liveModelCount = live.length;
// Dated-release aliases + configured retention (compat allow-list, combo targets,
// Vertex default). Cache without combo retention so a later gather re-applies the
// current capture's retain set on read (warm-cache OCX-111 / #1308).
const liveModelCount = live.length;
// Dated-release aliases + configured retention (compat allow-list, combo targets,
// Vertex default). Cache without combo retention so a later gather re-applies the
// current capture's retain set on read (warm-cache OCX-111 / #1308).
const forCache = withConfiguredRetention(live, { retainComboTargets: false });
const returned = withConfiguredRetention(forCache, { warnDrops: true });
const droppedConfiguredIds = configured
.map(model => model.id)
.filter(id => !returned.some(model => model.id === id));
const returned = withConfiguredRetention(live, { warnDrops: true });
const droppedConfiguredIds = configured
.map(model => model.id)
.filter(id => !returned.some(model => model.id === id));
if (returned.length === 0 && name !== OPENAI_API_PROVIDER_ID) {
console.warn(
`[opencodex] Provider model discovery for "${name}" returned an authoritative empty catalog; ${droppedConfiguredIds.length > 0 ? `dropping configured model ids: ${droppedConfiguredIds.join(", ")}` : "no models will be exposed"}.`,
Expand Down Expand Up @@ -1596,7 +1662,7 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: {
contextCap?: number;
seedVertexDefault?: boolean;
retainComboTargets?: boolean;
}): { models: CatalogModel[]; droppedConfiguredIds: string[] } {
}): { models: CatalogModel[]; droppedConfiguredIds: string[]; retainedConfiguredIds: string[] } {
const {
name,
provider: prov,
Expand All @@ -1609,6 +1675,8 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: {
const out = [...opts.models];
const present = new Set(out.map(model => model.id));
const droppedConfiguredIds: string[] = [];
const retainedConfiguredIds: string[] = [];
const providerRetainModels = Array.isArray(prov.retainModels) ? new Set(prov.retainModels) : undefined;
for (const candidate of configured) {
if (present.has(candidate.id)) continue;
const dated = out.find(live => isDatedVariantId(live.id, candidate.id));
Expand All @@ -1621,14 +1689,17 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: {
seedVertexDefault === true
|| shouldRetainConfiguredProviderModel(name, candidate.id)
|| (retainComboTargets && retainConfiguredModelIds?.has(candidate.id) === true)
|| (providerRetainModels?.has(candidate.id) === true)
) {
out.push(candidate);
const isRetainedFromConfig = providerRetainModels?.has(candidate.id) === true;
out.push(isRetainedFromConfig ? { ...candidate, retainedWithoutDiscovery: true } : candidate);
present.add(candidate.id);
if (isRetainedFromConfig) retainedConfiguredIds.push(candidate.id);
continue;
}
droppedConfiguredIds.push(candidate.id);
}
return { models: out, droppedConfiguredIds };
return { models: out, droppedConfiguredIds, retainedConfiguredIds };
}

export function filterCatalogVisibleModels(
Expand Down
14 changes: 14 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,9 @@ const providerConfigSchema = z.object({
.nullish()
.transform(value => value ?? undefined),
directGeminiWireRenames: z.boolean().optional(),
retainModels: z.array(z.string().min(1))
.transform(normalizeNonBlankStringArray)
.optional(),
noStructuredOutputModels: z.array(z.string().min(1))
.transform(normalizeNonBlankStringArray)
.optional(),
Expand Down Expand Up @@ -1213,6 +1216,17 @@ const configSchema = z.object({
message: structuredOutputOptOutError,
});
}
const retainModelsError = nonBlankStringArrayConfigError(
(provider as { retainModels?: unknown }).retainModels,
"retainModels",
);
if (retainModelsError) {
ctx.addIssue({
code: "custom",
path: ["providers", redactSecretString(name), "retainModels"],
message: retainModelsError,
});
}
if (Object.hasOwn(provider, "codexAccountMode") && provider.codexAccountMode !== undefined) {
// Persisted account mode is valid ONLY on the canonical built-in `openai` forward provider.
// Old openai-multi rows stay parseable (they never carry a mode) so startup can migrate them.
Expand Down
1 change: 1 addition & 0 deletions src/providers/model-rename-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ const MODEL_ID_LISTS = [
// their catalog instead of being renamed. OAuth reconciliation does not cover this
// field, so the rename has to.
"selectedModels",
"retainModels",
"noVisionModels",
"noReasoningModels",
"noTemperatureModels",
Expand Down
Loading
Loading