mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-30 02:14:43 +08:00
feat(codex): add default model field to provider form
Expose the top-level `model` key of config.toml as an editable field in the Codex provider form, so users can switch to newly released models (e.g. gpt-5.6) without waiting for a preset update — preset updates only affect newly added providers, existing ones keep their saved TOML. The field syncs bidirectionally with the TOML editor (mirroring the base-URL pattern), suggests models from the mapping catalog plus the provider's /models endpoint, and offers a one-click "add to mapping" action when the value is outside a non-empty catalog. Hidden for official providers. Details: - Save-time catalog sync now only backfills the first mapping row into the top-level model when the field was left empty, so the explicit field wins over the implicit row-0 sync - Escape model names (and base_url) with TOML basic-string escaping and strip control characters from field input: /models ids are remote data and unescaped interpolation could inject config.toml lines (e.g. a forged [mcp_servers.*] command) - The strict model-line matcher now recognizes escaped output, empty strings and single-quoted literals, keeping extract/set round-trips stable; deliberately no key-only loose matching (it would misfire on assignment-looking text inside multiline strings) - Invalidate the fetched model list whenever the request identity changes (base URL, full-URL toggle, API key, custom UA) and discard in-flight responses via a sequence guard, so the dropdown never shows a previous provider's models - i18n for zh/en/ja/zh-TW
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
@@ -70,6 +70,10 @@ interface CodexFormFieldsProps {
|
||||
autoSelect: boolean;
|
||||
onAutoSelectChange: (checked: boolean) => void;
|
||||
|
||||
// Default model (config.toml top-level `model`)
|
||||
codexModel?: string;
|
||||
onModelChange?: (model: string) => void;
|
||||
|
||||
// API Format
|
||||
// Note: wire_api is always "responses" for Codex; apiFormat controls proxy-layer conversion
|
||||
apiFormat: CodexApiFormat;
|
||||
@@ -166,6 +170,8 @@ export function CodexFormFields({
|
||||
onCustomEndpointsChange,
|
||||
autoSelect,
|
||||
onAutoSelectChange,
|
||||
codexModel = "",
|
||||
onModelChange,
|
||||
apiFormat,
|
||||
onApiFormatChange,
|
||||
anthropicAuthField,
|
||||
@@ -190,6 +196,15 @@ export function CodexFormFields({
|
||||
|
||||
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
|
||||
const [isFetchingModels, setIsFetchingModels] = useState(false);
|
||||
// 拉取请求序号:请求身份(Base URL / 完整地址开关 / API Key / 自定义 UA)
|
||||
// 一变即自增,清空旧列表并作废在途响应——/models 结果可能按 Key 的模型
|
||||
// 授权返回,换号后残留旧列表会误导选择
|
||||
const fetchModelsSeqRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetchModelsSeqRef.current += 1;
|
||||
setFetchedModels((prev) => (prev.length === 0 ? prev : []));
|
||||
}, [codexBaseUrl, isFullUrl, codexApiKey, customUserAgent]);
|
||||
// 思考能力随 Chat 格式显示(仅 Chat Completions 转换路径用得上);模型映射常驻
|
||||
//(填了才生成 catalog)。两者都已与「路由接管」概念解耦。
|
||||
const isChatFormat = apiFormat === "openai_chat";
|
||||
@@ -290,6 +305,7 @@ export function CodexFormFields({
|
||||
});
|
||||
return;
|
||||
}
|
||||
const seq = ++fetchModelsSeqRef.current;
|
||||
setIsFetchingModels(true);
|
||||
fetchModelsForConfig(
|
||||
codexBaseUrl,
|
||||
@@ -299,6 +315,7 @@ export function CodexFormFields({
|
||||
customUserAgent,
|
||||
)
|
||||
.then((models) => {
|
||||
if (seq !== fetchModelsSeqRef.current) return;
|
||||
setFetchedModels(models);
|
||||
if (models.length === 0) {
|
||||
toast.info(t("providerForm.fetchModelsEmpty"));
|
||||
@@ -309,6 +326,7 @@ export function CodexFormFields({
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (seq !== fetchModelsSeqRef.current) return;
|
||||
console.warn("[ModelFetch] Failed:", err);
|
||||
showFetchModelsError(err, t);
|
||||
})
|
||||
@@ -333,6 +351,47 @@ export function CodexFormFields({
|
||||
setCatalogRows((current) => current.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
// 默认模型下拉建议 = 模型映射的"实际请求模型"列 ∪ 拉取到的 /models 列表
|
||||
const defaultModelSuggestions = useMemo<FetchedModel[]>(() => {
|
||||
const seen = new Set<string>();
|
||||
const suggestions: FetchedModel[] = [];
|
||||
for (const row of catalogRows) {
|
||||
const id = row.model.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
suggestions.push({
|
||||
id,
|
||||
ownedBy: t("codexConfig.modelMappingTitle", {
|
||||
defaultValue: "模型映射",
|
||||
}),
|
||||
});
|
||||
}
|
||||
for (const model of fetchedModels) {
|
||||
if (seen.has(model.id)) continue;
|
||||
seen.add(model.id);
|
||||
suggestions.push(model);
|
||||
}
|
||||
return suggestions;
|
||||
}, [catalogRows, fetchedModels, t]);
|
||||
|
||||
// 填了映射时才提示"默认模型不在映射中"(无映射的供应商本来就直接请求任意模型名)
|
||||
const trimmedDefaultModel = codexModel.trim();
|
||||
const isDefaultModelOutsideCatalog =
|
||||
catalogRows.length > 0 &&
|
||||
!!trimmedDefaultModel &&
|
||||
!catalogRows.some((row) => row.model.trim() === trimmedDefaultModel);
|
||||
|
||||
const handleAddDefaultModelToCatalog = useCallback(() => {
|
||||
if (!onCatalogModelsChange || !trimmedDefaultModel) return;
|
||||
setCatalogRows((current) => [
|
||||
...current,
|
||||
createCatalogRow({
|
||||
model: trimmedDefaultModel,
|
||||
displayName: trimmedDefaultModel,
|
||||
}),
|
||||
]);
|
||||
}, [onCatalogModelsChange, trimmedDefaultModel]);
|
||||
|
||||
const renderCatalogActionButtons = (onAdd: () => void, addLabel: string) => (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
@@ -402,6 +461,73 @@ export function CodexFormFields({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 默认模型 —— config.toml 顶层 model,Codex 启动时默认请求的模型。
|
||||
实时写回 TOML;留空则删行(有映射时保存回退为映射第一行)。 */}
|
||||
{category !== "official" && onModelChange && (
|
||||
<div className="space-y-1.5">
|
||||
<FormLabel htmlFor="codexDefaultModel">
|
||||
{t("codexConfig.defaultModelLabel", { defaultValue: "默认模型" })}
|
||||
</FormLabel>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
id="codexDefaultModel"
|
||||
value={codexModel}
|
||||
onChange={(event) => onModelChange(event.target.value)}
|
||||
placeholder={t("codexConfig.defaultModelPlaceholder", {
|
||||
defaultValue: "例如: gpt-5.6",
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleFetchModels}
|
||||
disabled={isFetchingModels}
|
||||
className="shrink-0"
|
||||
title={t("providerForm.fetchModels")}
|
||||
>
|
||||
{isFetchingModels ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
{defaultModelSuggestions.length > 0 && (
|
||||
<ModelDropdown
|
||||
models={defaultModelSuggestions}
|
||||
onSelect={(id) => onModelChange(id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.defaultModelHint", {
|
||||
defaultValue:
|
||||
"Codex 默认请求的模型,随时可改,无需等待预设更新。留空且配置了模型映射时,默认使用映射第一行。",
|
||||
})}
|
||||
</p>
|
||||
{isDefaultModelOutsideCatalog && (
|
||||
<p className="flex flex-wrap items-center gap-x-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.defaultModelNotInCatalog", {
|
||||
defaultValue:
|
||||
"该模型不在模型映射中,Codex 的 /model 菜单不会列出它(直接请求仍然有效)。",
|
||||
})}
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-xs"
|
||||
onClick={handleAddDefaultModelToCatalog}
|
||||
>
|
||||
{t("codexConfig.addToModelMapping", {
|
||||
defaultValue: "加入映射",
|
||||
})}
|
||||
</Button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 高级选项 —— 上游格式/模型映射/思考能力/自定义 UA;预设供应商通常无需展开 */}
|
||||
{category !== "official" && (
|
||||
<Collapsible
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
codexApiFormatFromWireApi,
|
||||
extractCodexWireApi,
|
||||
setCodexWireApi,
|
||||
extractCodexModelName,
|
||||
setCodexModelName as setCodexModelNameInConfig,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { isNonNegativeDecimalString } from "@/types/usage";
|
||||
@@ -554,6 +555,7 @@ function ProviderFormFull({
|
||||
codexConfig,
|
||||
codexApiKey,
|
||||
codexBaseUrl,
|
||||
codexModel,
|
||||
codexCatalogModels,
|
||||
codexAuthError,
|
||||
setCodexAuth,
|
||||
@@ -561,6 +563,7 @@ function ProviderFormFull({
|
||||
setCodexCatalogModels,
|
||||
handleCodexApiKeyChange,
|
||||
handleCodexBaseUrlChange,
|
||||
handleCodexModelChange,
|
||||
handleCodexConfigChange: originalHandleCodexConfigChange,
|
||||
resetCodexConfig,
|
||||
} = useCodexConfigState({ initialData });
|
||||
@@ -1286,8 +1289,13 @@ function ProviderFormFull({
|
||||
category !== "official"
|
||||
? normalizeCodexCatalogModelsForSave(codexCatalogModels)
|
||||
: [];
|
||||
// Sync first catalog row's model into config.toml so Codex uses it as default
|
||||
if (normalizedCatalogModels.length > 0) {
|
||||
// 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,
|
||||
@@ -2168,6 +2176,8 @@ function ProviderFormFull({
|
||||
}
|
||||
autoSelect={endpointAutoSelect}
|
||||
onAutoSelectChange={setEndpointAutoSelect}
|
||||
codexModel={codexModel}
|
||||
onModelChange={handleCodexModelChange}
|
||||
apiFormat={localCodexApiFormat}
|
||||
onApiFormatChange={handleCodexApiFormatChange}
|
||||
anthropicAuthField={localCodexAnthropicAuthField}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
extractCodexBaseUrl,
|
||||
extractCodexExperimentalBearerToken,
|
||||
extractCodexModelName,
|
||||
setCodexBaseUrl as setCodexBaseUrlInConfig,
|
||||
setCodexModelName as setCodexModelNameInConfig,
|
||||
updateCodexExperimentalBearerToken,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { normalizeTomlText } from "@/utils/textNormalization";
|
||||
@@ -36,12 +38,14 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
const [codexConfig, setCodexConfigState] = useState("");
|
||||
const [codexApiKey, setCodexApiKey] = useState("");
|
||||
const [codexBaseUrl, setCodexBaseUrl] = useState("");
|
||||
const [codexModel, setCodexModel] = useState("");
|
||||
const [codexCatalogModels, setCodexCatalogModels] = useState<
|
||||
CodexCatalogModel[]
|
||||
>([]);
|
||||
const [codexAuthError, setCodexAuthError] = useState("");
|
||||
|
||||
const isUpdatingCodexBaseUrlRef = useRef(false);
|
||||
const isUpdatingCodexModelRef = useRef(false);
|
||||
|
||||
// 初始化 Codex 配置(编辑模式)
|
||||
useEffect(() => {
|
||||
@@ -133,6 +137,15 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
setCodexBaseUrl((prev) => (prev === extracted ? prev : extracted));
|
||||
}, [codexConfig]);
|
||||
|
||||
// 与 TOML 配置保持默认模型同步(顶层 model 键)
|
||||
useEffect(() => {
|
||||
if (isUpdatingCodexModelRef.current) {
|
||||
return;
|
||||
}
|
||||
const extracted = extractCodexModelName(codexConfig) || "";
|
||||
setCodexModel((prev) => (prev === extracted ? prev : extracted));
|
||||
}, [codexConfig]);
|
||||
|
||||
// 获取 API Key(从 auth JSON)
|
||||
const getCodexAuthApiKey = useCallback((authString: string): string => {
|
||||
try {
|
||||
@@ -226,6 +239,22 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
[setCodexConfig],
|
||||
);
|
||||
|
||||
// 处理默认模型变化(写回 TOML 顶层 model;清空则删掉该行,交回 Codex 内置默认)
|
||||
// 剥控制字符:值可能来自 /models 下拉(远端数据),换行等会破坏单行 TOML 语义
|
||||
const handleCodexModelChange = useCallback(
|
||||
(model: string) => {
|
||||
const sanitized = model.replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
||||
setCodexModel(sanitized);
|
||||
|
||||
isUpdatingCodexModelRef.current = true;
|
||||
setCodexConfig((prev) => setCodexModelNameInConfig(prev, sanitized));
|
||||
setTimeout(() => {
|
||||
isUpdatingCodexModelRef.current = false;
|
||||
}, 0);
|
||||
},
|
||||
[setCodexConfig],
|
||||
);
|
||||
|
||||
// 处理 config 变化(同步 Base URL)
|
||||
const handleCodexConfigChange = useCallback(
|
||||
(value: string) => {
|
||||
@@ -268,6 +297,7 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
codexConfig,
|
||||
codexApiKey,
|
||||
codexBaseUrl,
|
||||
codexModel,
|
||||
codexCatalogModels,
|
||||
codexAuthError,
|
||||
setCodexAuth,
|
||||
@@ -275,6 +305,7 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
setCodexCatalogModels,
|
||||
handleCodexApiKeyChange,
|
||||
handleCodexBaseUrlChange,
|
||||
handleCodexModelChange,
|
||||
handleCodexConfigChange,
|
||||
resetCodexConfig,
|
||||
getCodexAuthApiKey,
|
||||
|
||||
Reference in New Issue
Block a user