Skip to content
Open
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
39 changes: 39 additions & 0 deletions src-tauri/src/codex_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1543,6 +1543,14 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCa
continue;
};

let enabled = model_config
.get("enabled")
.and_then(|value| value.as_bool())
.unwrap_or(true);
if !enabled {
continue;
}

if !seen.insert(model.to_string()) {
continue;
}
Expand Down Expand Up @@ -10333,6 +10341,37 @@ openai_base_url = "http://127.0.0.1:15721/v1"
);
}

#[test]
fn codex_model_catalog_skips_models_disabled_in_provider_catalog() {
let settings = json!({
"modelCatalog": {
"models": [
{ "model": "deepseek-v4-flash" },
{ "model": "kimi-k2", "enabled": false }
]
}
});
let specs = codex_catalog_model_specs(&settings, "");
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].model, "deepseek-v4-flash");

let catalog = codex_model_catalog_from_specs(
&specs,
&json!({}),
CodexCatalogToolProfile::ProxyChat,
128_000,
);
let models = catalog
.get("models")
.and_then(Value::as_array)
.expect("models should be an array");
assert_eq!(models.len(), 1);
assert_eq!(
models[0].get("slug").and_then(Value::as_str),
Some("deepseek-v4-flash")
);
}

#[test]
fn codex_model_catalog_uses_provider_models_and_context() {
let template = json!({
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/codex_desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,9 @@ fn codex_model_entries_from_catalog_value(catalog: &Value) -> (Vec<String>, Vec<
let mut projected = Vec::new();

for entry in entries {
if entry.get("enabled").and_then(Value::as_bool) == Some(false) {
continue;
}
let Some(model_name) = codex_model_name(entry) else {
continue;
};
Expand Down
12 changes: 10 additions & 2 deletions src/components/codex/CodexRouterWorkspacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ import {
normalizeCodexSpawnAgentModels,
normalizeSpawnAgentCandidateSelection,
readCodexModelCatalog,
readRawCodexModelCatalogModels,
reorderSpawnAgentCandidates,
validateSpawnAgentCandidates,
type CodexCatalogModel,
Expand Down Expand Up @@ -450,6 +451,7 @@ type ProxyListenDraftValidation =

type CodexCatalogModelDraft = {
model: string;
enabled?: boolean;
upstreamModel?: string;
upstream_model?: string;
displayName?: string;
Expand Down Expand Up @@ -691,7 +693,11 @@ function providerWithFetchedModelCatalog(
provider: Provider,
fetchedModels: FetchedModel[],
): Provider {
const currentCatalog = readCodexModelCatalog(provider);
const visibleCatalog = readCodexModelCatalog(provider);
const currentCatalog = {
models: readRawCodexModelCatalogModels(provider),
spawnAgentModels: visibleCatalog.spawnAgentModels,
};
const fetchConfig = getProviderModelFetchConfig(provider);
const models = currentCatalog.models.map((model) => {
const id = model.model?.trim();
Expand Down Expand Up @@ -719,6 +725,7 @@ function providerWithFetchedModelCatalog(
: {}),
...(model.vision !== undefined ? { vision: model.vision } : {}),
...(model.sortIndex !== undefined ? { sortIndex: model.sortIndex } : {}),
...(model.enabled !== undefined ? { enabled: model.enabled } : {}),
// 模型目录刷新必须保留已有 reasoning 声明(用户手动声明的档位/能力)。
// 否则 /models 拉取重建会把声明清空,导致档位消失(K3/Qwen 均受影响)。
...(model.reasoning ? { reasoning: model.reasoning } : {}),
Expand Down Expand Up @@ -802,7 +809,7 @@ function providerWithFetchedModelCatalog(
models,
spawnAgentModels: normalizeCodexSpawnAgentModels(
currentCatalog.spawnAgentModels,
models,
models.filter((model) => model.enabled !== false),
),
},
},
Expand Down Expand Up @@ -1292,6 +1299,7 @@ function catalogDraftFromSourceModel(
);
return {
model: id,
...(source?.enabled !== undefined ? { enabled: source.enabled } : {}),
...(upstreamModel && upstreamModel !== id ? { upstreamModel } : {}),
...(displayName ? { displayName } : {}),
...(contextWindow ? { contextWindow } : {}),
Expand Down
44 changes: 28 additions & 16 deletions src/components/providers/forms/CodexFormFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ function createCatalogRow(seed?: Partial<CodexCatalogModel>): CodexCatalogRow {
return {
rowId: crypto.randomUUID(),
model: seed?.model ?? "",
...(seed?.enabled !== undefined ? { enabled: seed.enabled } : {}),
upstreamModel: seed?.upstreamModel ?? seed?.upstream_model ?? "",
displayName: seed?.displayName ?? "",
contextWindow: seed?.contextWindow ?? "",
Expand Down Expand Up @@ -515,6 +516,7 @@ function catalogRowsMatchModels(
Pick<
CodexCatalogRow,
| "model"
| "enabled"
| "upstreamModel"
| "upstream_model"
| "displayName"
Expand All @@ -533,6 +535,7 @@ function catalogRowsMatchModels(
const incoming = models[i];
return (
row.model === (incoming.model ?? "") &&
(row.enabled ?? true) === (incoming.enabled ?? true) &&
catalogRowUpstreamModel(row) === catalogRowUpstreamModel(incoming) &&
(row.displayName ?? "") === (incoming.displayName ?? "") &&
String(row.contextWindow ?? "") ===
Expand Down Expand Up @@ -1213,8 +1216,8 @@ export function CodexFormFields({
const planModelListAction = codexPlanModelListAction(planFetchSource);
const isCatalogOnlyPlan = isCodexCatalogOnlyPlanModelFetch(planFetchSource);
if (isCatalogOnlyPlan) {
const hasModelCatalog = catalogRowsRef.current.some((row) =>
row.model.trim(),
const hasModelCatalog = catalogRowsRef.current.some(
(row) => row.enabled !== false && row.model.trim(),
);
const message = codexCatalogOnlyPlanModelFetchMessage(
hasModelCatalog,
Expand Down Expand Up @@ -1337,7 +1340,9 @@ export function CodexFormFields({
const models = Array.from(
new Set(
[
...catalogRowsRef.current.map((row) => catalogRowUpstreamModel(row)),
...catalogRowsRef.current
.filter((row) => row.enabled !== false)
.map((row) => catalogRowUpstreamModel(row)),
...fetchedModels.map((model) => model.id.trim()),
].filter(Boolean),
),
Expand Down Expand Up @@ -2724,8 +2729,8 @@ export function CodexFormFields({
{/* 列头:md+ 显示 */}
<div className="hidden grid-cols-[88px_1fr_1fr_1fr_132px_76px_36px] gap-2 px-1 text-xs font-medium text-muted-foreground md:grid">
<span>
{t("codexConfig.keepCatalogModelColumn", {
defaultValue: "保留",
{t("codexConfig.enableCatalogModelColumn", {
defaultValue: "启用",
})}
</span>
<span>
Expand Down Expand Up @@ -2782,27 +2787,34 @@ export function CodexFormFields({
return (
<div
key={row.rowId}
className="grid grid-cols-1 gap-2 rounded-md border border-transparent p-1 md:grid-cols-[88px_1fr_1fr_1fr_132px_76px_36px]"
className={cn(
"grid grid-cols-1 gap-2 rounded-md border border-transparent p-1 md:grid-cols-[88px_1fr_1fr_1fr_132px_76px_36px]",
row.enabled === false && "opacity-60",
)}
>
<label className="flex h-9 items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
className="h-4 w-4 rounded border-border-default"
checked
checked={row.enabled !== false}
onChange={(event) => {
if (!event.target.checked) {
handleRemoveCatalogRow(index);
}
handleUpdateCatalogRow(index, {
enabled: event.target.checked,
});
}}
aria-label={t("codexConfig.keepCatalogModel", {
aria-label={t("codexConfig.enableCatalogModel", {
model: row.model || row.displayName || "",
defaultValue: `保留 ${row.model || row.displayName || "这个模型"}`,
defaultValue: `启用 ${row.model || row.displayName || "这个模型"}`,
})}
/>
<span className="md:hidden">
{t("codexConfig.keepCatalogModelColumn", {
defaultValue: "保留",
})}
<span>
{row.enabled === false
? t("codexConfig.enableCatalogModelDisabled", {
defaultValue: "未启用",
})
: t("codexConfig.enableCatalogModelColumn", {
defaultValue: "启用",
})}
</span>
</label>
<Input
Expand Down
37 changes: 29 additions & 8 deletions src/components/providers/forms/ProviderForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ export const normalizeCodexCatalogModelsForSave = (

normalized.push({
model,
...(item.enabled === false ? { enabled: false } : {}),
...(upstreamModel && upstreamModel !== model ? { upstreamModel } : {}),
...(displayName ? { displayName } : {}),
...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
Expand All @@ -280,6 +281,7 @@ const normalizeCodexSpawnAgentModelsForSave = (
catalogModels: CodexCatalogModel[],
): string[] => {
const catalogModelIds = catalogModels
.filter((item) => item.enabled !== false)
.map((item) => item.model.trim())
.filter(Boolean);
const availableModels = new Set(catalogModelIds);
Expand Down Expand Up @@ -1635,17 +1637,36 @@ function ProviderFormFull({
normalizedCatalogModels,
)
: [];
const enabledCatalogModels = normalizedCatalogModels.filter(
(item) => item.enabled !== false,
);
const currentDefaultModel = extractCodexModelName(
normalizedCodexConfig,
)?.trim();
// The default-model field writes the top-level `model` into the TOML
// as the user types; only when it was left empty fall back to the
// first catalog row so "fill mapping only" keeps its old behavior.
if (
normalizedCatalogModels.length > 0 &&
!extractCodexModelName(normalizedCodexConfig)
) {
normalizedCodexConfig = setCodexModelNameInConfig(
normalizedCodexConfig,
normalizedCatalogModels[0].model,
// first enabled catalog row so "fill mapping only" keeps its old
// behavior. A model disabled in the catalog is never kept as default.
if (enabledCatalogModels.length > 0) {
const firstEnabledModel = enabledCatalogModels[0].model;
const defaultModelDisabled = normalizedCatalogModels.some(
(item) =>
item.enabled === false && item.model === currentDefaultModel,
);
if (!currentDefaultModel) {
normalizedCodexConfig = setCodexModelNameInConfig(
normalizedCodexConfig,
firstEnabledModel,
);
} else if (defaultModelDisabled) {
normalizedCodexConfig = setCodexModelNameInConfig(
normalizedCodexConfig,
firstEnabledModel,
);
toast.info(
`默认模型 ${currentDefaultModel} 已停用,已自动改用 ${firstEnabledModel}。`,
);
}
}
const configObj = {
auth: authJson,
Expand Down
3 changes: 3 additions & 0 deletions src/components/providers/forms/hooks/useCodexConfigState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,12 @@ function extractCodexCatalogModels(modelCatalog: any): CodexCatalogModel[] {
item?.reasoning && typeof item.reasoning === "object"
? item.reasoning
: undefined;
const enabled =
typeof item?.enabled === "boolean" ? item.enabled : undefined;

return {
model: typeof item?.model === "string" ? item.model : "",
...(enabled !== undefined ? { enabled } : {}),
...(upstreamModel ? { upstreamModel } : {}),
...(displayName ? { displayName } : {}),
...(contextWindow ? { contextWindow } : {}),
Expand Down
8 changes: 5 additions & 3 deletions src/lib/codexMultiRouterSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
readWizardCodexOAuthAccountId,
resolveWizardModelNameCollisions,
} from "@/lib/codexMultiRouterWizard";
import { readCodexModelCatalog } from "@/utils/codexSpawnAgentCandidates";
import { readRawCodexModelCatalogModels } from "@/utils/codexSpawnAgentCandidates";

// MultiRouter 同步返回写回后的 plan,以及需要用户人工补选的子 Agent 候选删减。
export interface CodexMultiRouterPlanSyncResult {
Expand Down Expand Up @@ -124,8 +124,8 @@ function catalogModelUpstreamId(model: CodexCatalogModel): string {
function readStrictProviderCatalogModels(
provider: Provider,
): CodexCatalogModel[] {
return readCodexModelCatalog(provider)
.models.map((model) => {
return readRawCodexModelCatalogModels(provider)
.map((model) => {
const id = model.model?.trim();
if (!id) return null;
return {
Expand Down Expand Up @@ -158,6 +158,7 @@ function readStrictProviderCatalogModels(
? { supports_image: model.supports_image }
: {}),
...(model.vision !== undefined ? { vision: model.vision } : {}),
...(model.enabled !== undefined ? { enabled: model.enabled } : {}),
...(model.reasoning ? { reasoning: model.reasoning } : {}),
} satisfies CodexCatalogModel;
})
Expand Down Expand Up @@ -225,6 +226,7 @@ function buildSyncedRouteModels(
planCatalogByModel,
);
return targetModels
.filter((sourceModel) => sourceModel.enabled !== false)
.map((sourceModel) => {
const upstream = catalogModelUpstreamId(sourceModel);
const existingVisible = visibleByUpstream.get(upstream);
Expand Down
35 changes: 26 additions & 9 deletions src/lib/codexMultiRouterWizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,18 +194,31 @@ function isWizardNativeCodexAuthSource(provider: Provider): boolean {
// 读取 Codex provider 的真实持久化模型目录;缺失或结构异常时返回空目录,不能伪造 OAuth 模型权限。
export function readWizardModelCatalog(
provider: Provider,
options: { enabledOnly?: boolean } = { enabledOnly: true },
): CodexCatalogModel[] {
const models = provider.settingsConfig?.modelCatalog?.models;
if (!Array.isArray(models)) {
return [];
}
return models.filter(
(model): model is CodexCatalogModel =>
typeof model === "object" &&
model !== null &&
typeof (model as CodexCatalogModel).model === "string" &&
Boolean((model as CodexCatalogModel).model.trim()),
);
return models
.filter(
(model): model is CodexCatalogModel =>
typeof model === "object" &&
model !== null &&
typeof (model as CodexCatalogModel).model === "string" &&
Boolean((model as CodexCatalogModel).model.trim()),
)
.filter(
(model) =>
(options.enabledOnly ?? true) === false || model.enabled !== false,
);
}

// 持久化/合并路径需要保留停用行;这些路径单独读取原始目录,避免刷新后丢失用户保留的停用模型。
export function readRawWizardModelCatalog(
provider: Provider,
): CodexCatalogModel[] {
return readWizardModelCatalog(provider, { enabledOnly: false });
}

// 判断 provider 是否是 MultiRouter 方案;向导只把普通 provider 当作上游模型源。
Expand Down Expand Up @@ -396,7 +409,7 @@ export function mergeFetchedModelsIntoWizardProvider(
fetchedModels: FetchedModel[],
options: MergeFetchedWizardModelsOptions = {},
): Provider {
const existingModels = readWizardModelCatalog(provider);
const existingModels = readRawWizardModelCatalog(provider);
const byModel = new Map<string, CodexCatalogModel>();
const byFetchedModel = new Map<string, string>();
for (const model of existingModels) {
Expand Down Expand Up @@ -448,7 +461,11 @@ export function mergeFetchedModelsIntoWizardProvider(
});
}
const models = Array.from(byModel.values());
const allowedModels = new Set(models.map((model) => model.model));
const allowedModels = new Set(
models
.filter((model) => model.enabled !== false)
.map((model) => model.model),
);
const rawSpawnAgentModels =
provider.settingsConfig?.modelCatalog?.spawnAgentModels;
const spawnAgentModels = Array.isArray(rawSpawnAgentModels)
Expand Down
Loading
Loading