feat(codex): set a default sub-agent model and reasoning effort

The Codex provider form gains a sub-agent model field and a reasoning
effort picker, written to `[agents] default_subagent_model` and
`default_subagent_reasoning_effort` in the provider's config.toml.
Clearing a value removes the key, and an `[agents]` table left empty is
removed with it. The backend already treated both keys as key fields, so
switching providers swaps them and leaves the rest of `[agents]` alone.

In Aggregation mode the two keys are not written, as with Claude Code:
the row names the default provider's model, so a main agent moved to
another provider's aggregated model would send its sub-agents back to
the default provider. Sub-agents follow the main model instead.

When the model is outside the provider's model mapping the form says so
and offers to add it: Codex can't get its context window otherwise, and
routing that converts to Chat or Anthropic replaces unmapped models with
the default one. The field is hidden for Grok Build, official cards and
the aggregation layout.
This commit is contained in:
Jason committed 2026-10-11 22:51:28 +08:00
1 parent d25e8d5425
commit 2ff37a76f4
12 files changed
+583 -2

No files matched your search

+42
View File
@@ -6028,6 +6028,48 @@ model_provider = "c"
.live_has_proxy_placeholder(&AppType::Codex));
}
/// 子代理模型:路由模式照写路由那家的;Stack 模式不写(子代理跟随主模型),只删这两个
/// 键,`[agents]` 里用户自己的设置不动;Stack 模型都移除后恢复。
#[tokio::test]
#[serial]
async fn codex_stack_mode_leaves_the_subagent_model_to_the_main_model() {
let _home = Home::new();
seed_codex("[agents]\nmax_threads = 4\n", None);
let [_, deepseek, zhipu] = codex_stack_rows();
let route = codex_native(
"a",
"https://a.example/v1",
"[agents]\ndefault_subagent_model = \"gpt-a-mini\"\ndefault_subagent_reasoning_effort = \"low\"\n",
None,
);
let state = state_with(AppType::Codex, &[route, deepseek, zhipu], "a").await;
enter(&state, &AppType::Codex, true).await.expect("enter");
let agents = |doc: &toml::Table| {
let agents = &doc["agents"];
let text = |key: &str| agents.get(key).and_then(|v| v.as_str()).map(str::to_string);
(
text("default_subagent_model"),
text("default_subagent_reasoning_effort"),
agents.get("max_threads").and_then(|v| v.as_integer()),
)
};
let routed = (
Some("gpt-a-mini".to_string()),
Some("low".to_string()),
Some(4),
);
assert_eq!(agents(&codex_doc()), routed);
set_codex_member(&state, "deepseek", true).await;
assert_eq!(agents(&codex_doc()), (None, None, Some(4)));
set_codex_member(&state, "deepseek", false).await;
assert_eq!(agents(&codex_doc()), routed);
exit(&state, &AppType::Codex).await.expect("exit");
assert_eq!(agents(&codex_doc()), routed);
}
fn set_classic_subagents(enabled: bool) {
crate::settings::update_settings(crate::settings::AppSettings {
codex_stack_classic_subagents: enabled,
@@ -545,7 +545,7 @@ pub(crate) fn plan(
let provider = target_provider(target);
let projection = provider.map(project).transpose()?;
let (top, nested, mut exclusive) = match (&projection, provider) {
let (top, mut nested, mut exclusive) = match (&projection, provider) {
(Some(projection), Some(provider)) => (
projection.top.clone(),
projection.nested.clone(),
@@ -622,6 +622,11 @@ pub(crate) fn plan(
Target::Proxy { stack, .. } => *stack,
Target::Direct(_) => &[],
};
// 聚合模式下子代理跟随主模型(用户在模型选择器里选的),和 Claude Code 一样不写:行里
// 是路由那家的模型名,主模型换成别家的 Stack 模型后,子代理会被悄悄发回路由那家。
if !stack.is_empty() {
nested.retain(|(path, _)| !is_subagent_key(path));
}
let stack_catalog = match (provider, &projection) {
(Some(provider), Some(projection)) => stack_catalog(provider, projection, stack, prepared)?,
_ => None,
@@ -717,6 +722,11 @@ pub(crate) fn settings_without_row_catalog(route: &Provider) -> Option<Value> {
/// 发布了 Stack 模型时不写进 `config.toml` 的全局键:Codex 拿它们覆盖目录里的每一行。
const STACK_SUNK_WINDOW_KEYS: &[&str] = &["model_context_window", "model_auto_compact_token_limit"];
/// `[agents]` 里的子代理模型和推理强度(`floor::CODEX_FLOOR_NESTED` 的前两项)。
fn is_subagent_key(path: &[String]) -> bool {
path.first().is_some_and(|table| table == "agents")
}
/// 发布了 Stack 模型时的合并目录(路由那家的行在前,Stack 里的在后);没有要发布的 Stack 模型,
/// 或者这次发布不了时为 `None`,目录照原来的规则算。
fn stack_catalog(
@@ -165,6 +165,13 @@ interface CodexFormFieldsProps {
codexModel?: string;
onModelChange?: (model: string) => void;
// Subagent defaults (config.toml `[agents]`). Codex only: Grok Build has no
// such keys, and the Stack layout omits them (subagents follow the main model).
subagentModel?: string;
onSubagentModelChange?: (model: string) => void;
subagentReasoningEffort?: string;
onSubagentReasoningEffortChange?: (effort: string) => void;
// API Format
// Note: wire_api is always "responses" for Codex; apiFormat controls proxy-layer conversion
apiFormat: CodexApiFormat;
@@ -275,6 +282,10 @@ const CODEX_REASONING_LEVELS = [
"ultra",
] as const;
// Select value standing for "no default_subagent_reasoning_effort" (Radix
// Select items can't use an empty string).
const SUBAGENT_EFFORT_INHERIT = "__inherit__";
// Trigger label for the picked levels, kept narrow for the catalog cell. Each
// run of three or more adjacent canonical levels collapses to "first → last"
// ("none, low → max" when minimal is skipped); a range never spans a gap, so
@@ -515,6 +526,10 @@ export function CodexFormFields({
onAutoSelectChange,
codexModel = "",
onModelChange,
subagentModel = "",
onSubagentModelChange,
subagentReasoningEffort = "",
onSubagentReasoningEffortChange,
apiFormat,
onApiFormatChange,
copilotApiFormat = "auto",
@@ -1029,6 +1044,49 @@ export function CodexFormFields({
variant,
]);
// 子代理模型不在模型映射里:Codex 拿不到它的窗口和推理档位;经路由转成 Chat / Anthropic
// 协议时还会被换成默认模型(转换路径只放行映射里的模型)。没有映射、走原生 Responses 的
// 供应商直接请求任意模型名,不提示。
const trimmedSubagentModel = subagentModel.trim();
const isSubagentModelOutsideCatalog =
!!trimmedSubagentModel &&
(catalogRows.length > 0 || isChatFormat || isAnthropicFormat) &&
!catalogRows.some((row) => row.model.trim() === trimmedSubagentModel);
const handleAddSubagentModelToCatalog = useCallback(() => {
if (!onCatalogModelsChange || !trimmedSubagentModel) return;
const rows = catalogRowsRef.current;
const added: CodexCatalogRow[] = [];
// 映射原本为空时默认模型也一起加进去,不然加完它就成了映射外的模型。
if (
rows.length === 0 &&
trimmedDefaultModel &&
trimmedDefaultModel !== trimmedSubagentModel
) {
added.push(
createCatalogRow({
model: trimmedDefaultModel,
displayName: trimmedDefaultModel,
}),
);
}
added.push(
createCatalogRow({
model: trimmedSubagentModel,
displayName: trimmedSubagentModel,
}),
);
commitCatalogRows([...rows, ...added]);
for (const row of added) fillCatalogRowMetadata(row.rowId, row.model);
}, [
catalogRowsRef,
commitCatalogRows,
fillCatalogRowMetadata,
onCatalogModelsChange,
trimmedDefaultModel,
trimmedSubagentModel,
]);
const renderCatalogActionButtons = (onAdd: () => void, addLabel: string) => (
<div className="flex gap-1">
<Button
@@ -1860,6 +1918,101 @@ export function CodexFormFields({
</div>
)}
{/* 子代理 —— config.toml 的 [agents] default_subagent_model / default_subagent_reasoning_effort。
留空删键,子代理跟随主模型;聚合模式下不写进 live。 */}
{category !== "official" && onSubagentModelChange && (
<div className="space-y-1.5">
<FormLabel htmlFor="codexSubagentModel">
{t("codexConfig.subagentModelLabel", {
defaultValue: "子代理模型",
})}
</FormLabel>
<div className="flex gap-1">
<Input
id="codexSubagentModel"
value={subagentModel}
onChange={(event) => onSubagentModelChange(event.target.value)}
placeholder={t("codexConfig.subagentModelPlaceholder", {
defaultValue: "留空则跟随主模型",
})}
className="flex-1"
/>
{defaultModelSuggestions.length > 0 && (
<ModelDropdown
models={defaultModelSuggestions}
onSelect={(id) => onSubagentModelChange(id)}
/>
)}
{onSubagentReasoningEffortChange && (
<Select
value={subagentReasoningEffort || SUBAGENT_EFFORT_INHERIT}
onValueChange={(value) =>
onSubagentReasoningEffortChange(
value === SUBAGENT_EFFORT_INHERIT ? "" : value,
)
}
>
<SelectTrigger
aria-label={t("codexConfig.subagentReasoningEffortLabel", {
defaultValue: "子代理推理强度",
})}
className="w-36 shrink-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={SUBAGENT_EFFORT_INHERIT}>
{t("codexConfig.subagentReasoningEffortDefault", {
defaultValue: "默认强度",
})}
</SelectItem>
{subagentReasoningEffort &&
!(CODEX_REASONING_LEVELS as readonly string[]).includes(
subagentReasoningEffort,
) && (
<SelectItem value={subagentReasoningEffort}>
{subagentReasoningEffort}
</SelectItem>
)}
{CODEX_REASONING_LEVELS.map((level) => (
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<p className="text-xs leading-relaxed text-fg-2">
{t("codexConfig.subagentModelHint", {
defaultValue:
"Codex 派出的子代理默认用的模型和推理强度。留空时子代理沿用主代理的;只填模型时用该模型自己的默认强度。聚合模式下不生效,子代理跟随主模型。",
})}
</p>
{isSubagentModelOutsideCatalog && (
<p className="flex flex-wrap items-center gap-x-2 text-xs leading-relaxed text-fg-2">
{t("codexConfig.subagentModelNotInCatalog", {
defaultValue:
"该模型不在模型映射中:Codex 拿不到它的上下文窗口;经路由转成 Chat / Anthropic 协议时会被换成默认模型。",
})}
{onCatalogModelsChange && (
<Button
type="button"
variant="link"
size="sm"
className="h-auto p-0 text-xs"
onClick={handleAddSubagentModelToCatalog}
>
{t("codexConfig.addToModelMapping", {
defaultValue: "加入映射",
})}
</Button>
)}
</p>
)}
</div>
)}
{/* 高级选项 —— 上游格式/模型映射/思考能力/自定义 UA;预设供应商通常无需展开 */}
{category !== "official" && (
<Collapsible
@@ -703,6 +703,8 @@ function ProviderFormFull({
codexApiKey,
codexBaseUrl,
codexModel,
codexSubagentModel,
codexSubagentReasoningEffort,
codexCatalogModels,
codexAuthError,
setCodexAuth,
@@ -711,6 +713,8 @@ function ProviderFormFull({
handleCodexApiKeyChange,
handleCodexBaseUrlChange,
handleCodexModelChange,
handleCodexSubagentModelChange,
handleCodexSubagentReasoningEffortChange,
handleCodexConfigChange: originalHandleCodexConfigChange,
resetCodexConfig,
} = useCodexConfigState({ initialData });
@@ -2668,6 +2672,12 @@ function ProviderFormFull({
onAutoSelectChange={setEndpointAutoSelect}
codexModel={codexModel}
onModelChange={handleCodexModelChange}
subagentModel={codexSubagentModel}
onSubagentModelChange={handleCodexSubagentModelChange}
subagentReasoningEffort={codexSubagentReasoningEffort}
onSubagentReasoningEffortChange={
handleCodexSubagentReasoningEffortChange
}
apiFormat={localCodexApiFormat}
onApiFormatChange={handleCodexApiFormatChange}
copilotApiFormat={effectiveCodexCopilotApiFormat}
@@ -1,9 +1,11 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import {
extractCodexAgentsString,
extractCodexBaseUrl,
extractCodexExperimentalBearerToken,
extractCodexModelName,
setCodexBaseUrl as setCodexBaseUrlInConfig,
setCodexAgentsString,
setCodexModelName as setCodexModelNameInConfig,
updateCodexExperimentalBearerToken,
} from "@/utils/providerConfigUtils";
@@ -276,6 +278,39 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
[setCodexConfig],
);
// 子代理的默认模型和推理强度(`[agents]` 表):直接从 TOML 读,改动实时写回,清空则删键
const codexSubagentModel = useMemo(
() => extractCodexAgentsString(codexConfig, "default_subagent_model") || "",
[codexConfig],
);
const codexSubagentReasoningEffort = useMemo(
() =>
extractCodexAgentsString(
codexConfig,
"default_subagent_reasoning_effort",
) || "",
[codexConfig],
);
const handleCodexSubagentModelChange = useCallback(
(model: string) => {
const sanitized = model.replace(/[\u0000-\u001f\u007f]/g, "").trim();
setCodexConfig((prev) =>
setCodexAgentsString(prev, "default_subagent_model", sanitized),
);
},
[setCodexConfig],
);
const handleCodexSubagentReasoningEffortChange = useCallback(
(effort: string) => {
setCodexConfig((prev) =>
setCodexAgentsString(prev, "default_subagent_reasoning_effort", effort),
);
},
[setCodexConfig],
);
// 处理 config 变化(同步 Base URL)
const handleCodexConfigChange = useCallback(
(value: string) => {
@@ -319,6 +354,8 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
codexApiKey,
codexBaseUrl,
codexModel,
codexSubagentModel,
codexSubagentReasoningEffort,
codexCatalogModels,
codexAuthError,
setCodexAuth,
@@ -327,6 +364,8 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
handleCodexApiKeyChange,
handleCodexBaseUrlChange,
handleCodexModelChange,
handleCodexSubagentModelChange,
handleCodexSubagentReasoningEffortChange,
handleCodexConfigChange,
resetCodexConfig,
getCodexAuthApiKey,
+6
View File
@@ -1914,6 +1914,12 @@
"defaultModelPlaceholder": "e.g. gpt-5.6-sol",
"defaultModelHint": "The model Codex requests by default; change it anytime without waiting for a preset update. If left empty while model mapping is configured, the first mapping row is used.",
"defaultModelNotInCatalog": "This model is not in the model mapping, so the Codex /model menu won't list it (direct requests still work).",
"subagentModelLabel": "Sub-agent Model",
"subagentModelPlaceholder": "Leave empty to follow the main model",
"subagentReasoningEffortLabel": "Sub-agent reasoning effort",
"subagentReasoningEffortDefault": "Default effort",
"subagentModelHint": "The model and reasoning effort Codex sub-agents use by default. When empty, sub-agents use the main agent's; with only a model set, they use that model's default effort. Not applied in Aggregation mode, where sub-agents follow the main model.",
"subagentModelNotInCatalog": "This model is not in the model mapping: Codex can't get its context window, and when routing converts requests to Chat / Anthropic it is replaced by the default model.",
"addToModelMapping": "Add to mapping",
"modelMappingTitle": "Model Mapping",
"modelMappingHint": "Generates Codex model_catalog_json so /model can show these third-party model names; entries are saved exactly as listed. Codex must be restarted to refresh the model list after changes.",
+6
View File
@@ -1914,6 +1914,12 @@
"defaultModelPlaceholder": "例: gpt-5.6-sol",
"defaultModelHint": "Codex がデフォルトでリクエストするモデルです。プリセットの更新を待たずにいつでも変更できます。空欄のままモデルマッピングを設定している場合は、マッピングの先頭行が使われます。",
"defaultModelNotInCatalog": "このモデルはモデルマッピングにないため、Codex の /model メニューには表示されません(直接リクエストは有効です)。",
"subagentModelLabel": "サブエージェントモデル",
"subagentModelPlaceholder": "空欄ならメインモデルに従う",
"subagentReasoningEffortLabel": "サブエージェントの推論強度",
"subagentReasoningEffortDefault": "デフォルト強度",
"subagentModelHint": "Codex が起動するサブエージェントのデフォルトのモデルと推論強度です。空欄ならメインエージェントと同じものを使い、モデルだけ指定するとそのモデルのデフォルト強度を使います。集約モードでは適用されず、サブエージェントはメインモデルに従います。",
"subagentModelNotInCatalog": "このモデルはモデルマッピングにありません。Codex はコンテキストウィンドウを取得できず、ルーティングで Chat / Anthropic 形式に変換する際はデフォルトモデルに置き換えられます。",
"addToModelMapping": "マッピングに追加",
"modelMappingTitle": "モデルマッピング",
"modelMappingHint": "Codex model_catalog_json を生成し、/model コマンドでこれらの第三者モデル名を表示できるようにします。リストの内容はそのまま保存されます。変更後、モデルリストを更新するには Codex の再起動が必要です。",
+6
View File
@@ -1909,6 +1909,12 @@
"defaultModelPlaceholder": "例如: gpt-5.6-sol",
"defaultModelHint": "Codex 預設請求的模型,隨時可改,無需等待軟體更新供應商範本。留空且設定了模型映射時,預設使用映射第一行。",
"defaultModelNotInCatalog": "該模型不在模型映射中,Codex 的 /model 選單不會列出它(直接請求仍然有效)。",
"subagentModelLabel": "子代理模型",
"subagentModelPlaceholder": "留空則跟隨主模型",
"subagentReasoningEffortLabel": "子代理推理強度",
"subagentReasoningEffortDefault": "預設強度",
"subagentModelHint": "Codex 派出的子代理預設使用的模型和推理強度。留空時子代理沿用主代理的;只填模型時使用該模型自己的預設強度。聚合模式下不生效,子代理跟隨主模型。",
"subagentModelNotInCatalog": "該模型不在模型映射中:Codex 取得不到它的上下文視窗;經路由轉成 Chat / Anthropic 協定時會被換成預設模型。",
"addToModelMapping": "加入映射",
"modelMappingTitle": "模型映射",
"modelMappingHint": "產生 Codex model_catalog_json,讓 /model 指令顯示這些第三方模型名;表中條目按填寫內容原樣儲存。修改後需要重新啟動 Codex 才能重新整理模型清單。",
+6
View File
@@ -1914,6 +1914,12 @@
"defaultModelPlaceholder": "例如: gpt-5.6-sol",
"defaultModelHint": "Codex 默认请求的模型,随时可改,无需等待预设更新。留空且配置了模型映射时,默认使用映射第一行。",
"defaultModelNotInCatalog": "该模型不在模型映射中,Codex 的 /model 菜单不会列出它(直接请求仍然有效)。",
"subagentModelLabel": "子代理模型",
"subagentModelPlaceholder": "留空则跟随主模型",
"subagentReasoningEffortLabel": "子代理推理强度",
"subagentReasoningEffortDefault": "默认强度",
"subagentModelHint": "Codex 派出的子代理默认用的模型和推理强度。留空时子代理沿用主代理的;只填模型时用该模型自己的默认强度。聚合模式下不生效,子代理跟随主模型。",
"subagentModelNotInCatalog": "该模型不在模型映射中:Codex 拿不到它的上下文窗口;经路由转成 Chat / Anthropic 协议时会被换成默认模型。",
"addToModelMapping": "加入映射",
"modelMappingTitle": "模型映射",
"modelMappingHint": "生成 Codex model_catalog_json,让 /model 命令显示这些第三方模型名;表中条目按填写内容原样保存。修改后需要重启 Codex 才能刷新模型列表。",
+106
View File
@@ -1394,6 +1394,112 @@ export const setCodexModelName = (
return finalizeTomlText(lines);
};
// ========== Codex [agents] subagent defaults ==========
// 子代理的默认模型和推理强度(Codex 的 `[agents]` 表)。后端把这两个键当关键字段,随供应商
// 切换;聚合模式下不写(子代理跟随主模型)。
export type CodexAgentsStringKey =
| "default_subagent_model"
| "default_subagent_reasoning_effort";
const CODEX_AGENTS_SECTION = "agents";
const tomlStringAssignmentPatterns = (key: string) => ({
doubleQuoted: new RegExp(
`^\\s*${key}\\s*=\\s*"((?:[^"\\\\\\r\\n]|\\\\.)*)"\\s*(?:#.*)?$`,
),
singleQuoted: new RegExp(`^\\s*${key}\\s*=\\s*'([^'\\r\\n]*)'\\s*(?:#.*)?$`),
anyValue: new RegExp(`^\\s*${key}\\s*=`),
});
export const extractCodexAgentsString = (
configText: string | undefined | null,
key: CodexAgentsStringKey,
): string | undefined => {
const text = normalizeTomlText(
typeof configText === "string" ? configText : "",
);
if (!text) return undefined;
try {
const value = (parseToml(text) as Record<string, any>)[
CODEX_AGENTS_SECTION
]?.[key];
return typeof value === "string" ? value : undefined;
} catch {
// 编辑中的 TOML 解析不了时按行在 [agents] 里找。
}
const lines = text.split("\n");
const range = getTomlSectionRange(lines, CODEX_AGENTS_SECTION);
if (!range) return undefined;
const patterns = tomlStringAssignmentPatterns(key);
for (let i = range.bodyStartIndex; i < range.bodyEndIndex; i += 1) {
const doubleQuoted = lines[i].match(patterns.doubleQuoted);
if (doubleQuoted) return unescapeTomlBasicString(doubleQuoted[1]);
const singleQuoted = lines[i].match(patterns.singleQuoted);
if (singleQuoted) return singleQuoted[1];
}
return undefined;
};
// 写入或删除 `[agents]` 里的一个字符串键;空值删除,删完表里没别的就连表头一起删。
// 用户把 agents 写成行内表或点号键时按行改不了:改完解析失败就原样返回。
export const setCodexAgentsString = (
configText: string,
key: CodexAgentsStringKey,
value: string,
): string => {
const trimmed = value.trim();
const normalizedText = normalizeTomlText(configText);
const lines = normalizedText ? normalizedText.split("\n") : [];
const range = getTomlSectionRange(lines, CODEX_AGENTS_SECTION);
const replacementLine = `${key} = ${tomlBasicString(trimmed)}`;
if (range) {
const lineIndex = findTomlLineInRange(
lines,
tomlStringAssignmentPatterns(key).anyValue,
range.bodyStartIndex,
range.bodyEndIndex,
);
if (!trimmed) {
if (lineIndex === -1) return normalizedText;
lines.splice(lineIndex, 1);
const bodyEmpty = lines
.slice(range.bodyStartIndex, range.bodyEndIndex - 1)
.every((line) => line.trim() === "");
if (bodyEmpty) {
lines.splice(
range.headerLineIndex,
range.bodyEndIndex - 1 - range.headerLineIndex,
);
}
} else if (lineIndex !== -1) {
lines[lineIndex] = replacementLine;
} else {
lines.splice(getTomlSectionInsertIndex(lines, range), 0, replacementLine);
}
} else {
if (!trimmed) return normalizedText;
if (lines.length > 0 && lines[lines.length - 1].trim() !== "") {
lines.push("");
}
lines.push(`[${CODEX_AGENTS_SECTION}]`, replacementLine, "");
}
const candidate = finalizeTomlText(lines);
try {
parseToml(normalizedText);
} catch {
return candidate;
}
try {
parseToml(candidate);
return candidate;
} catch {
return normalizedText;
}
};
// ========== Codex top-level integer field utils ==========
const tomlTopLevelIntPattern = (field: string) =>
@@ -0,0 +1,108 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { ComponentProps, PropsWithChildren } from "react";
import { useForm } from "react-hook-form";
import { describe, expect, it, vi } from "vitest";
import { CodexFormFields } from "@/components/providers/forms/CodexFormFields";
import { Form } from "@/components/ui/form";
vi.mock("@/hooks/useModelMetadataFill", () => ({
useModelMetadataFill: () => () => {},
}));
type CodexFormFieldsProps = ComponentProps<typeof CodexFormFields>;
const FormShell = ({ children }: PropsWithChildren) => {
const form = useForm();
return <Form {...form}>{children}</Form>;
};
const NOT_IN_CATALOG = /该模型不在模型映射中:Codex 拿不到它的上下文窗口/;
function renderCodexForm(overrides: Partial<CodexFormFieldsProps> = {}) {
const props: CodexFormFieldsProps = {
codexApiKey: "sk-test",
onApiKeyChange: vi.fn(),
category: "third_party",
shouldShowApiKeyLink: false,
websiteUrl: "",
codexBaseUrl: "https://api.example.com",
onBaseUrlChange: vi.fn(),
isFullUrl: false,
onFullUrlChange: vi.fn(),
isEndpointModalOpen: false,
onEndpointModalToggle: vi.fn(),
autoSelect: false,
onAutoSelectChange: vi.fn(),
codexModel: "gpt-a",
onModelChange: vi.fn(),
apiFormat: "openai_responses",
onApiFormatChange: vi.fn(),
anthropicAuthField: "ANTHROPIC_AUTH_TOKEN",
onAnthropicAuthFieldChange: vi.fn(),
impersonateClaudeCode: false,
onImpersonateClaudeCodeChange: vi.fn(),
maxOutputTokens: "",
onMaxOutputTokensChange: vi.fn(),
promptCacheRouting: "auto",
onPromptCacheRoutingChange: vi.fn(),
speedTestEndpoints: [],
customUserAgent: "",
onCustomUserAgentChange: vi.fn(),
localProxyHeadersOverride: "",
onLocalProxyHeadersOverrideChange: vi.fn(),
localProxyBodyOverride: "",
onLocalProxyBodyOverrideChange: vi.fn(),
shouldShowSpeedTest: false,
subagentModel: "",
onSubagentModelChange: vi.fn(),
subagentReasoningEffort: "",
onSubagentReasoningEffortChange: vi.fn(),
...overrides,
};
render(
<FormShell>
<CodexFormFields {...props} />
</FormShell>,
);
return props;
}
const subagentInput = () =>
document.getElementById("codexSubagentModel") as HTMLInputElement | null;
describe("CodexFormFields sub-agent defaults", () => {
it("writes the typed model through the change handler", () => {
const props = renderCodexForm();
fireEvent.change(subagentInput()!, { target: { value: "gpt-a-mini" } });
expect(props.onSubagentModelChange).toHaveBeenCalledWith("gpt-a-mini");
});
it("is hidden when the caller gives no handler (Grok Build)", () => {
renderCodexForm({ onSubagentModelChange: undefined });
expect(subagentInput()).toBeNull();
});
it("offers to add a model outside the mapping, with the default model when the mapping is empty", () => {
const onCatalogModelsChange = vi.fn();
renderCodexForm({
apiFormat: "openai_chat",
subagentModel: "k3-mini",
catalogModels: [],
onCatalogModelsChange,
});
expect(screen.getByText(NOT_IN_CATALOG)).toBeTruthy();
onCatalogModelsChange.mockClear();
fireEvent.click(screen.getByRole("button", { name: "加入映射" }));
const rows = onCatalogModelsChange.mock.calls.at(-1)?.[0] ?? [];
expect(rows.map((row: { model: string }) => row.model)).toEqual([
"gpt-a",
"k3-mini",
]);
});
it("stays quiet for a native Responses provider without a mapping", () => {
renderCodexForm({ subagentModel: "k3-mini", catalogModels: [] });
expect(screen.queryByText(NOT_IN_CATALOG)).toBeNull();
});
});
@@ -1,11 +1,13 @@
import { describe, expect, it } from "vitest";
import { parse as parseToml } from "smol-toml";
import {
extractCodexAgentsString,
extractCodexBaseUrl,
extractCodexExperimentalBearerToken,
extractCodexModelName,
extractCodexTopLevelInt,
removeCodexTopLevelField,
setCodexAgentsString,
setCodexBaseUrl,
setCodexModelName,
setCodexTopLevelInt,
@@ -456,3 +458,90 @@ describe("Codex TOML utils", () => {
expect(extractCodexExperimentalBearerToken(input)).toBe("top-level-key");
});
});
describe("Codex [agents] subagent defaults", () => {
const base = [
'model_provider = "custom"',
'model = "gpt-a"',
"",
"[model_providers.custom]",
'base_url = "https://a.example/v1"',
].join("\n");
it("adds an [agents] table and removes it again when cleared", () => {
const withModel = setCodexAgentsString(
base,
"default_subagent_model",
"gpt-a-mini",
);
expect(parseToml(withModel)).toMatchObject({
model: "gpt-a",
agents: { default_subagent_model: "gpt-a-mini" },
});
const withEffort = setCodexAgentsString(
withModel,
"default_subagent_reasoning_effort",
"low",
);
expect(
extractCodexAgentsString(withEffort, "default_subagent_reasoning_effort"),
).toBe("low");
const cleared = setCodexAgentsString(
setCodexAgentsString(withEffort, "default_subagent_model", ""),
"default_subagent_reasoning_effort",
"",
);
expect(cleared.trim()).toBe(base);
});
it("edits only its key inside an existing [agents] table", () => {
const input = [
base,
"",
"[agents]",
"max_threads = 4",
'default_subagent_model = "old"',
"",
"[agents.reviewer]",
'description = "review"',
].join("\n");
const updated = setCodexAgentsString(
input,
"default_subagent_model",
'k3"mini',
);
const parsed = parseToml(updated) as any;
expect(parsed.agents.default_subagent_model).toBe('k3"mini');
expect(parsed.agents.max_threads).toBe(4);
expect(parsed.agents.reviewer.description).toBe("review");
expect(extractCodexAgentsString(updated, "default_subagent_model")).toBe(
'k3"mini',
);
const cleared = setCodexAgentsString(updated, "default_subagent_model", "");
const afterClear = parseToml(cleared) as any;
expect(afterClear.agents.default_subagent_model).toBeUndefined();
expect(afterClear.agents.max_threads).toBe(4);
});
it("adds [agents] after an existing [agents.<role>] table", () => {
const input = `${base}\n\n[agents.reviewer]\ndescription = "review"\n`;
const updated = setCodexAgentsString(input, "default_subagent_model", "m");
const parsed = parseToml(updated) as any;
expect(parsed.agents.default_subagent_model).toBe("m");
expect(parsed.agents.reviewer.description).toBe("review");
});
it("leaves an inline agents table untouched instead of breaking the TOML", () => {
const input = `agents = { max_threads = 4 }\n${base}\n`;
expect(extractCodexAgentsString(input, "default_subagent_model")).toBe(
undefined,
);
const updated = setCodexAgentsString(input, "default_subagent_model", "m");
expect(() => parseToml(updated)).not.toThrow();
expect((parseToml(updated) as any).agents).toEqual({ max_threads: 4 });
});
});