feat: add model name configuration support for Codex and fix Gemini model handling

- Add visual model name input field for Codex providers
  - Add model name extraction and update utilities in providerConfigUtils
  - Implement model name state management in useCodexConfigState hook
  - Add conditional model field rendering in CodexFormFields (non-official only)
  - Integrate model name sync with TOML config in ProviderForm

- Fix Gemini deeplink model injection bug
  - Correct environment variable name from GOOGLE_GEMINI_MODEL to GEMINI_MODEL
  - Add test cases for Gemini model injection (with/without model)
  - All tests passing (9/9)

- Fix Gemini model field binding in edit mode
  - Add geminiModel state to useGeminiConfigState hook
  - Extract model value during initialization and reset
  - Sync model field with geminiEnv state to prevent data loss on submit
  - Fix missing model value display when editing Gemini providers

Changes:
  - 6 files changed, 245 insertions(+), 13 deletions(-)
This commit is contained in:
YoVinchen
2025-11-19 02:06:31 +08:00
parent ec04132303
commit a87daebe14
6 changed files with 245 additions and 13 deletions

View File

@@ -302,7 +302,7 @@ requires_openai_auth = true
// Add model if provided // Add model if provided
if let Some(model) = &request.model { if let Some(model) = &request.model {
env.insert("GOOGLE_GEMINI_MODEL".to_string(), json!(model)); env.insert("GEMINI_MODEL".to_string(), json!(model));
} }
json!({ "env": env }) json!({ "env": env })
@@ -400,4 +400,58 @@ mod tests {
.to_string() .to_string()
.contains("must be http or https")); .contains("must be http or https"));
} }
#[test]
fn test_build_gemini_provider_with_model() {
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: "gemini".to_string(),
name: "Test Gemini".to_string(),
homepage: "https://example.com".to_string(),
endpoint: "https://api.example.com".to_string(),
api_key: "test-api-key".to_string(),
model: Some("gemini-2.0-flash".to_string()),
notes: None,
};
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
// Verify provider basic info
assert_eq!(provider.name, "Test Gemini");
assert_eq!(
provider.website_url,
Some("https://example.com".to_string())
);
// Verify settings_config structure
let env = provider.settings_config["env"].as_object().unwrap();
assert_eq!(env["GEMINI_API_KEY"], "test-api-key");
assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com");
assert_eq!(env["GEMINI_MODEL"], "gemini-2.0-flash");
}
#[test]
fn test_build_gemini_provider_without_model() {
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: "gemini".to_string(),
name: "Test Gemini".to_string(),
homepage: "https://example.com".to_string(),
endpoint: "https://api.example.com".to_string(),
api_key: "test-api-key".to_string(),
model: None,
notes: None,
};
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
// Verify settings_config structure
let env = provider.settings_config["env"].as_object().unwrap();
assert_eq!(env["GEMINI_API_KEY"], "test-api-key");
assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com");
// Model should not be present
assert!(env.get("GEMINI_MODEL").is_none());
}
} }

View File

@@ -26,6 +26,11 @@ interface CodexFormFieldsProps {
onEndpointModalToggle: (open: boolean) => void; onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange?: (endpoints: string[]) => void; onCustomEndpointsChange?: (endpoints: string[]) => void;
// Model Name
shouldShowModelField?: boolean;
modelName?: string;
onModelNameChange?: (model: string) => void;
// Speed Test Endpoints // Speed Test Endpoints
speedTestEndpoints: EndpointCandidate[]; speedTestEndpoints: EndpointCandidate[];
} }
@@ -45,6 +50,9 @@ export function CodexFormFields({
isEndpointModalOpen, isEndpointModalOpen,
onEndpointModalToggle, onEndpointModalToggle,
onCustomEndpointsChange, onCustomEndpointsChange,
shouldShowModelField = true,
modelName = "",
onModelNameChange,
speedTestEndpoints, speedTestEndpoints,
}: CodexFormFieldsProps) { }: CodexFormFieldsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -85,6 +93,33 @@ export function CodexFormFields({
/> />
)} )}
{/* Codex Model Name 输入框 */}
{shouldShowModelField && onModelNameChange && (
<div className="space-y-2">
<label
htmlFor="codexModelName"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
{t("codexConfig.modelName", { defaultValue: "模型名称" })}
</label>
<input
id="codexModelName"
type="text"
value={modelName}
onChange={(e) => onModelNameChange(e.target.value)}
placeholder={t("codexConfig.modelNamePlaceholder", {
defaultValue: "例如: gpt-5-codex",
})}
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t("codexConfig.modelNameHint", {
defaultValue: "指定使用的模型,将自动更新到 config.toml 中",
})}
</p>
</div>
)}
{/* 端点测速弹窗 - Codex */} {/* 端点测速弹窗 - Codex */}
{shouldShowSpeedTest && isEndpointModalOpen && ( {shouldShowSpeedTest && isEndpointModalOpen && (
<EndpointSpeedTest <EndpointSpeedTest

View File

@@ -202,10 +202,12 @@ export function ProviderForm({
codexConfig, codexConfig,
codexApiKey, codexApiKey,
codexBaseUrl, codexBaseUrl,
codexModelName,
codexAuthError, codexAuthError,
setCodexAuth, setCodexAuth,
handleCodexApiKeyChange, handleCodexApiKeyChange,
handleCodexBaseUrlChange, handleCodexBaseUrlChange,
handleCodexModelNameChange,
handleCodexConfigChange: originalHandleCodexConfigChange, handleCodexConfigChange: originalHandleCodexConfigChange,
resetCodexConfig, resetCodexConfig,
} = useCodexConfigState({ initialData }); } = useCodexConfigState({ initialData });
@@ -315,12 +317,14 @@ export function ProviderForm({
const { const {
geminiEnv, geminiEnv,
geminiConfig, geminiConfig,
geminiModel,
envError, envError,
configError: geminiConfigError, configError: geminiConfigError,
handleGeminiEnvChange, handleGeminiEnvChange,
handleGeminiConfigChange, handleGeminiConfigChange,
resetGeminiConfig, resetGeminiConfig,
envStringToObj, envStringToObj,
envObjToString,
} = useGeminiConfigState({ } = useGeminiConfigState({
initialData: appId === "gemini" ? initialData : undefined, initialData: appId === "gemini" ? initialData : undefined,
}); });
@@ -685,6 +689,9 @@ export function ProviderForm({
onCustomEndpointsChange={ onCustomEndpointsChange={
isEditMode ? undefined : setDraftCustomEndpoints isEditMode ? undefined : setDraftCustomEndpoints
} }
shouldShowModelField={category !== "official"}
modelName={codexModelName}
onModelNameChange={handleCodexModelNameChange}
speedTestEndpoints={speedTestEndpoints} speedTestEndpoints={speedTestEndpoints}
/> />
)} )}
@@ -711,17 +718,19 @@ export function ProviderForm({
onEndpointModalToggle={setIsEndpointModalOpen} onEndpointModalToggle={setIsEndpointModalOpen}
onCustomEndpointsChange={setDraftCustomEndpoints} onCustomEndpointsChange={setDraftCustomEndpoints}
shouldShowModelField={true} shouldShowModelField={true}
model={ model={geminiModel}
form.watch("settingsConfig")
? JSON.parse(form.watch("settingsConfig") || "{}")?.env
?.GEMINI_MODEL || ""
: ""
}
onModelChange={(model) => { onModelChange={(model) => {
// 同时更新 form.settingsConfig 和 geminiEnv
const config = JSON.parse(form.watch("settingsConfig") || "{}"); const config = JSON.parse(form.watch("settingsConfig") || "{}");
if (!config.env) config.env = {}; if (!config.env) config.env = {};
config.env.GEMINI_MODEL = model; config.env.GEMINI_MODEL = model;
form.setValue("settingsConfig", JSON.stringify(config, null, 2)); form.setValue("settingsConfig", JSON.stringify(config, null, 2));
// 同步更新 geminiEnv确保提交时不丢失
const envObj = envStringToObj(geminiEnv);
envObj.GEMINI_MODEL = model.trim();
const newEnv = envObjToString(envObj);
handleGeminiEnvChange(newEnv);
}} }}
speedTestEndpoints={speedTestEndpoints} speedTestEndpoints={speedTestEndpoints}
/> />

View File

@@ -2,6 +2,8 @@ import { useState, useCallback, useEffect, useRef } from "react";
import { import {
extractCodexBaseUrl, extractCodexBaseUrl,
setCodexBaseUrl as setCodexBaseUrlInConfig, setCodexBaseUrl as setCodexBaseUrlInConfig,
extractCodexModelName,
setCodexModelName as setCodexModelNameInConfig,
} from "@/utils/providerConfigUtils"; } from "@/utils/providerConfigUtils";
import { normalizeTomlText } from "@/utils/textNormalization"; import { normalizeTomlText } from "@/utils/textNormalization";
@@ -20,9 +22,11 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
const [codexConfig, setCodexConfigState] = useState(""); const [codexConfig, setCodexConfigState] = useState("");
const [codexApiKey, setCodexApiKey] = useState(""); const [codexApiKey, setCodexApiKey] = useState("");
const [codexBaseUrl, setCodexBaseUrl] = useState(""); const [codexBaseUrl, setCodexBaseUrl] = useState("");
const [codexModelName, setCodexModelName] = useState("");
const [codexAuthError, setCodexAuthError] = useState(""); const [codexAuthError, setCodexAuthError] = useState("");
const isUpdatingCodexBaseUrlRef = useRef(false); const isUpdatingCodexBaseUrlRef = useRef(false);
const isUpdatingCodexModelNameRef = useRef(false);
// 初始化 Codex 配置(编辑模式) // 初始化 Codex 配置(编辑模式)
useEffect(() => { useEffect(() => {
@@ -47,6 +51,12 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
setCodexBaseUrl(initialBaseUrl); setCodexBaseUrl(initialBaseUrl);
} }
// 提取 Model Name
const initialModelName = extractCodexModelName(configStr);
if (initialModelName) {
setCodexModelName(initialModelName);
}
// 提取 API Key // 提取 API Key
try { try {
if (auth && typeof auth.OPENAI_API_KEY === "string") { if (auth && typeof auth.OPENAI_API_KEY === "string") {
@@ -69,6 +79,17 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
} }
}, [codexConfig, codexBaseUrl]); }, [codexConfig, codexBaseUrl]);
// 与 TOML 配置保持模型名称同步
useEffect(() => {
if (isUpdatingCodexModelNameRef.current) {
return;
}
const extracted = extractCodexModelName(codexConfig) || "";
if (extracted !== codexModelName) {
setCodexModelName(extracted);
}
}, [codexConfig, codexModelName]);
// 获取 API Key从 auth JSON // 获取 API Key从 auth JSON
const getCodexAuthApiKey = useCallback((authString: string): string => { const getCodexAuthApiKey = useCallback((authString: string): string => {
try { try {
@@ -157,7 +178,26 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
[setCodexConfig], [setCodexConfig],
); );
// 处理 config 变化(同步 Base URL // 处理 Codex Model Name 变化
const handleCodexModelNameChange = useCallback(
(modelName: string) => {
const trimmed = modelName.trim();
setCodexModelName(trimmed);
if (!trimmed) {
return;
}
isUpdatingCodexModelNameRef.current = true;
setCodexConfig((prev) => setCodexModelNameInConfig(prev, trimmed));
setTimeout(() => {
isUpdatingCodexModelNameRef.current = false;
}, 0);
},
[setCodexConfig],
);
// 处理 config 变化(同步 Base URL 和 Model Name
const handleCodexConfigChange = useCallback( const handleCodexConfigChange = useCallback(
(value: string) => { (value: string) => {
// 归一化中文/全角/弯引号,避免 TOML 解析报错 // 归一化中文/全角/弯引号,避免 TOML 解析报错
@@ -170,8 +210,15 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
setCodexBaseUrl(extracted); setCodexBaseUrl(extracted);
} }
} }
if (!isUpdatingCodexModelNameRef.current) {
const extractedModel = extractCodexModelName(normalized) || "";
if (extractedModel !== codexModelName) {
setCodexModelName(extractedModel);
}
}
}, },
[setCodexConfig, codexBaseUrl], [setCodexConfig, codexBaseUrl, codexModelName],
); );
// 重置配置(用于预设切换) // 重置配置(用于预设切换)
@@ -186,6 +233,13 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
setCodexBaseUrl(baseUrl); setCodexBaseUrl(baseUrl);
} }
const modelName = extractCodexModelName(config);
if (modelName) {
setCodexModelName(modelName);
} else {
setCodexModelName("");
}
// 提取 API Key // 提取 API Key
try { try {
if (auth && typeof auth.OPENAI_API_KEY === "string") { if (auth && typeof auth.OPENAI_API_KEY === "string") {
@@ -205,11 +259,13 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
codexConfig, codexConfig,
codexApiKey, codexApiKey,
codexBaseUrl, codexBaseUrl,
codexModelName,
codexAuthError, codexAuthError,
setCodexAuth, setCodexAuth,
setCodexConfig, setCodexConfig,
handleCodexApiKeyChange, handleCodexApiKeyChange,
handleCodexBaseUrlChange, handleCodexBaseUrlChange,
handleCodexModelNameChange,
handleCodexConfigChange, handleCodexConfigChange,
resetCodexConfig, resetCodexConfig,
getCodexAuthApiKey, getCodexAuthApiKey,

View File

@@ -17,6 +17,7 @@ export function useGeminiConfigState({
const [geminiConfig, setGeminiConfigState] = useState(""); const [geminiConfig, setGeminiConfigState] = useState("");
const [geminiApiKey, setGeminiApiKey] = useState(""); const [geminiApiKey, setGeminiApiKey] = useState("");
const [geminiBaseUrl, setGeminiBaseUrl] = useState(""); const [geminiBaseUrl, setGeminiBaseUrl] = useState("");
const [geminiModel, setGeminiModel] = useState("");
const [envError, setEnvError] = useState(""); const [envError, setEnvError] = useState("");
const [configError, setConfigError] = useState(""); const [configError, setConfigError] = useState("");
@@ -72,21 +73,25 @@ export function useGeminiConfigState({
const configObj = (config as any).config || {}; const configObj = (config as any).config || {};
setGeminiConfigState(JSON.stringify(configObj, null, 2)); setGeminiConfigState(JSON.stringify(configObj, null, 2));
// 提取 API KeyBase URL // 提取 API KeyBase URL 和 Model
if (typeof env.GEMINI_API_KEY === "string") { if (typeof env.GEMINI_API_KEY === "string") {
setGeminiApiKey(env.GEMINI_API_KEY); setGeminiApiKey(env.GEMINI_API_KEY);
} }
if (typeof env.GOOGLE_GEMINI_BASE_URL === "string") { if (typeof env.GOOGLE_GEMINI_BASE_URL === "string") {
setGeminiBaseUrl(env.GOOGLE_GEMINI_BASE_URL); setGeminiBaseUrl(env.GOOGLE_GEMINI_BASE_URL);
} }
if (typeof env.GEMINI_MODEL === "string") {
setGeminiModel(env.GEMINI_MODEL);
}
} }
}, [initialData, envObjToString]); }, [initialData, envObjToString]);
// 从 geminiEnv 中提取并同步 API KeyBase URL // 从 geminiEnv 中提取并同步 API KeyBase URL 和 Model
useEffect(() => { useEffect(() => {
const envObj = envStringToObj(geminiEnv); const envObj = envStringToObj(geminiEnv);
const extractedKey = envObj.GEMINI_API_KEY || ""; const extractedKey = envObj.GEMINI_API_KEY || "";
const extractedBaseUrl = envObj.GOOGLE_GEMINI_BASE_URL || ""; const extractedBaseUrl = envObj.GOOGLE_GEMINI_BASE_URL || "";
const extractedModel = envObj.GEMINI_MODEL || "";
if (extractedKey !== geminiApiKey) { if (extractedKey !== geminiApiKey) {
setGeminiApiKey(extractedKey); setGeminiApiKey(extractedKey);
@@ -94,7 +99,10 @@ export function useGeminiConfigState({
if (extractedBaseUrl !== geminiBaseUrl) { if (extractedBaseUrl !== geminiBaseUrl) {
setGeminiBaseUrl(extractedBaseUrl); setGeminiBaseUrl(extractedBaseUrl);
} }
}, [geminiEnv, envStringToObj]); if (extractedModel !== geminiModel) {
setGeminiModel(extractedModel);
}
}, [geminiEnv, envStringToObj, geminiApiKey, geminiBaseUrl, geminiModel]);
// 验证 Gemini Config JSON // 验证 Gemini Config JSON
const validateGeminiConfig = useCallback((value: string): string => { const validateGeminiConfig = useCallback((value: string): string => {
@@ -181,7 +189,7 @@ export function useGeminiConfigState({
setGeminiEnv(envString); setGeminiEnv(envString);
setGeminiConfig(configString); setGeminiConfig(configString);
// 提取 API KeyBase URL // 提取 API KeyBase URL 和 Model
if (typeof env.GEMINI_API_KEY === "string") { if (typeof env.GEMINI_API_KEY === "string") {
setGeminiApiKey(env.GEMINI_API_KEY); setGeminiApiKey(env.GEMINI_API_KEY);
} else { } else {
@@ -193,6 +201,12 @@ export function useGeminiConfigState({
} else { } else {
setGeminiBaseUrl(""); setGeminiBaseUrl("");
} }
if (typeof env.GEMINI_MODEL === "string") {
setGeminiModel(env.GEMINI_MODEL);
} else {
setGeminiModel("");
}
}, },
[envObjToString, setGeminiEnv, setGeminiConfig], [envObjToString, setGeminiEnv, setGeminiConfig],
); );
@@ -202,6 +216,7 @@ export function useGeminiConfigState({
geminiConfig, geminiConfig,
geminiApiKey, geminiApiKey,
geminiBaseUrl, geminiBaseUrl,
geminiModel,
envError, envError,
configError, configError,
setGeminiEnv, setGeminiEnv,

View File

@@ -467,3 +467,66 @@ export const setCodexBaseUrl = (
: normalizedText; : normalizedText;
return `${prefix}${replacementLine}\n`; return `${prefix}${replacementLine}\n`;
}; };
// ========== Codex model name utils ==========
// 从 Codex 的 TOML 配置文本中提取 model 字段(支持单/双引号)
export const extractCodexModelName = (
configText: string | undefined | null,
): string | undefined => {
try {
const raw = typeof configText === "string" ? configText : "";
// 归一化中文/全角引号,避免正则提取失败
const text = normalizeQuotes(raw);
if (!text) return undefined;
// 匹配 model = "xxx" 或 model = 'xxx'
const m = text.match(/^model\s*=\s*(['"])([^'"]+)\1/m);
return m && m[2] ? m[2] : undefined;
} catch {
return undefined;
}
};
// 在 Codex 的 TOML 配置文本中写入或更新 model 字段
export const setCodexModelName = (
configText: string,
modelName: string,
): string => {
const trimmed = modelName.trim();
if (!trimmed) {
return configText;
}
// 归一化原文本中的引号(既能匹配,也能输出稳定格式)
const normalizedText = normalizeQuotes(configText);
const replacementLine = `model = "${trimmed}"`;
const pattern = /^model\s*=\s*["']([^"']+)["']/m;
if (pattern.test(normalizedText)) {
return normalizedText.replace(pattern, replacementLine);
}
// 如果不存在 model 字段,尝试在 model_provider 之后插入
// 如果 model_provider 也不存在,则插入到开头
const providerPattern = /^model_provider\s*=\s*["'][^"']+["']/m;
const match = normalizedText.match(providerPattern);
if (match && match.index !== undefined) {
// 在 model_provider 行之后插入
const endOfLine = normalizedText.indexOf("\n", match.index);
if (endOfLine !== -1) {
return (
normalizedText.slice(0, endOfLine + 1) +
replacementLine +
"\n" +
normalizedText.slice(endOfLine + 1)
);
}
}
// 在文件开头插入
const lines = normalizedText.split("\n");
return `${replacementLine}\n${lines.join("\n")}`;
};