mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
fix: Codex model catalog WYSIWYG and config consolidation
- Remove mergeCodexDefaultCatalogModelForSave implicit injection (P1) The model mapping table is now the single source of truth; no hidden entries are prepended on save. - Sync first catalog row model into config.toml on save Ensures Codex default request model matches the table's first entry instead of retaining a stale template value. - Remove API Format selector from CodexFormFields (P3) wire_api is always 'responses'; the selector confused users into thinking they were changing the upstream protocol. Only the 'Needs Local Routing' toggle remains. - Add restart hint to model mapping i18n text (P2) model_catalog_json is loaded at Codex startup; users are now informed that a restart is needed after changes. - Unify write_codex_live_with_catalog helper (P4) Replaces three scattered prepare+write call sites in config.rs, provider/live.rs, and proxy.rs with a single entry point. - Clean up useCodexConfigState dead state (P3 follow-up) Remove codexModelName, codexContextWindow, codexAutoCompactLimit and their handlers/effects since no component consumes them after the UI consolidation.
This commit is contained in:
@@ -1,24 +1,23 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { toast } from "sonner";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { Download, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||
import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared";
|
||||
import { ApiKeySection, EndpointField, ModelDropdown } from "./shared";
|
||||
import {
|
||||
fetchModelsForConfig,
|
||||
showFetchModelsError,
|
||||
type FetchedModel,
|
||||
} from "@/lib/api/model-fetch";
|
||||
import type { CodexApiFormat, ProviderCategory } from "@/types";
|
||||
import type {
|
||||
CodexApiFormat,
|
||||
CodexCatalogModel,
|
||||
ProviderCategory,
|
||||
} from "@/types";
|
||||
|
||||
interface EndpointCandidate {
|
||||
url: string;
|
||||
@@ -48,18 +47,46 @@ interface CodexFormFieldsProps {
|
||||
onAutoSelectChange: (checked: boolean) => void;
|
||||
|
||||
// API Format
|
||||
// Note: wire_api is always "responses" for Codex; apiFormat controls proxy-layer conversion
|
||||
apiFormat: CodexApiFormat;
|
||||
onApiFormatChange: (format: CodexApiFormat) => void;
|
||||
|
||||
// Model Name
|
||||
shouldShowModelField?: boolean;
|
||||
modelName?: string;
|
||||
onModelNameChange?: (model: string) => void;
|
||||
// Model Catalog
|
||||
catalogModels?: CodexCatalogModel[];
|
||||
onCatalogModelsChange?: (models: CodexCatalogModel[]) => void;
|
||||
|
||||
// Speed Test Endpoints
|
||||
speedTestEndpoints: EndpointCandidate[];
|
||||
}
|
||||
|
||||
type CodexCatalogRow = CodexCatalogModel & { rowId: string };
|
||||
|
||||
function createCatalogRow(seed?: Partial<CodexCatalogModel>): CodexCatalogRow {
|
||||
return {
|
||||
rowId: crypto.randomUUID(),
|
||||
model: seed?.model ?? "",
|
||||
displayName: seed?.displayName ?? "",
|
||||
contextWindow: seed?.contextWindow ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
// Compares rows (with rowId) to incoming models (without) by data fields only,
|
||||
// so both sync effects can use the same equality definition.
|
||||
function catalogRowsMatchModels(
|
||||
rows: Array<Pick<CodexCatalogRow, "model" | "displayName" | "contextWindow">>,
|
||||
models: CodexCatalogModel[],
|
||||
): boolean {
|
||||
if (rows.length !== models.length) return false;
|
||||
return rows.every((row, i) => {
|
||||
const incoming = models[i];
|
||||
return (
|
||||
row.model === (incoming.model ?? "") &&
|
||||
(row.displayName ?? "") === (incoming.displayName ?? "") &&
|
||||
String(row.contextWindow ?? "") === String(incoming.contextWindow ?? "")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function CodexFormFields({
|
||||
providerId,
|
||||
codexApiKey,
|
||||
@@ -81,16 +108,46 @@ export function CodexFormFields({
|
||||
onAutoSelectChange,
|
||||
apiFormat,
|
||||
onApiFormatChange,
|
||||
shouldShowModelField = true,
|
||||
modelName = "",
|
||||
onModelNameChange,
|
||||
catalogModels = [],
|
||||
onCatalogModelsChange,
|
||||
speedTestEndpoints,
|
||||
}: CodexFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
|
||||
const [isFetchingModels, setIsFetchingModels] = useState(false);
|
||||
const isChatApiFormat = apiFormat === "openai_chat";
|
||||
const needsLocalRouting = apiFormat === "openai_chat";
|
||||
const canEditCatalog = Boolean(onCatalogModelsChange);
|
||||
|
||||
const [catalogRows, setCatalogRows] = useState<CodexCatalogRow[]>(() =>
|
||||
catalogModels.map((m) => createCatalogRow(m)),
|
||||
);
|
||||
|
||||
// 父 → 子:仅当 prop 数据真的变化(预设切换 / 编辑加载)时才重建 rowId;
|
||||
// 同 shape 时保留现有 rowId,避免编辑过程中焦点丢失。
|
||||
useEffect(() => {
|
||||
setCatalogRows((current) => {
|
||||
if (catalogRowsMatchModels(current, catalogModels)) return current;
|
||||
return catalogModels.map((m) => createCatalogRow(m));
|
||||
});
|
||||
}, [catalogModels]);
|
||||
|
||||
// 子 → 父:rowId 是视图层概念,不应进入持久化数据;剥离后再回传。
|
||||
useEffect(() => {
|
||||
if (!onCatalogModelsChange) return;
|
||||
if (catalogRowsMatchModels(catalogRows, catalogModels)) return;
|
||||
const next: CodexCatalogModel[] = catalogRows.map(
|
||||
({ rowId: _rowId, ...rest }) => rest,
|
||||
);
|
||||
onCatalogModelsChange(next);
|
||||
}, [catalogRows, catalogModels, onCatalogModelsChange]);
|
||||
|
||||
const handleLocalRoutingChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
onApiFormatChange(checked ? "openai_chat" : "openai_responses");
|
||||
},
|
||||
[onApiFormatChange],
|
||||
);
|
||||
|
||||
const handleFetchModels = useCallback(() => {
|
||||
if (!codexBaseUrl || !codexApiKey) {
|
||||
@@ -119,6 +176,54 @@ export function CodexFormFields({
|
||||
.finally(() => setIsFetchingModels(false));
|
||||
}, [codexBaseUrl, codexApiKey, isFullUrl, t]);
|
||||
|
||||
const handleAddCatalogRow = useCallback(() => {
|
||||
if (!onCatalogModelsChange) return;
|
||||
setCatalogRows((current) => [...current, createCatalogRow()]);
|
||||
}, [onCatalogModelsChange]);
|
||||
|
||||
const handleUpdateCatalogRow = useCallback(
|
||||
(index: number, patch: Partial<CodexCatalogModel>) => {
|
||||
setCatalogRows((current) =>
|
||||
current.map((row, i) => (i === index ? { ...row, ...patch } : row)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRemoveCatalogRow = useCallback((index: number) => {
|
||||
setCatalogRows((current) => current.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const renderCatalogActionButtons = (onAdd: () => void, addLabel: string) => (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFetchModels}
|
||||
disabled={isFetchingModels}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
{isFetchingModels ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("providerForm.fetchModels")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAdd}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Codex API Key 输入框 */}
|
||||
@@ -158,97 +263,168 @@ export function CodexFormFields({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Codex API 格式选择 */}
|
||||
{shouldShowSpeedTest && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="codexApiFormat">
|
||||
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={apiFormat}
|
||||
onValueChange={(value) =>
|
||||
onApiFormatChange(value as CodexApiFormat)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="codexApiFormat" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="openai_responses">
|
||||
{t("providerForm.codexApiFormatResponses", {
|
||||
defaultValue: "OpenAI Responses API (原生)",
|
||||
<div className="space-y-3 rounded-lg border border-border-default bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>
|
||||
{t("codexConfig.localRoutingToggle", {
|
||||
defaultValue: "需要本地路由映射",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_chat">
|
||||
{t("providerForm.codexApiFormatOpenAIChat", {
|
||||
defaultValue: "OpenAI Chat Completions (需开启路由)",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.codexApiFormatHint", {
|
||||
defaultValue:
|
||||
"选择供应商真实支持的 Codex API 格式;Chat Completions 会通过本地路由自动转换为 Responses。",
|
||||
})}
|
||||
</p>
|
||||
</FormLabel>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{needsLocalRouting
|
||||
? t("codexConfig.localRoutingOnHint", {
|
||||
defaultValue:
|
||||
"适合 DeepSeek、Kimi 等自定义模型或仅支持 Chat Completions 的供应商;使用时请保持本地路由开启。",
|
||||
})
|
||||
: t("codexConfig.localRoutingOffHint", {
|
||||
defaultValue:
|
||||
"适合供应商原生支持 OpenAI Responses API 且模型在 Codex 中可直接使用的场景,请求可由 Codex 直连供应商。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={needsLocalRouting}
|
||||
onCheckedChange={handleLocalRoutingChange}
|
||||
aria-label={t("codexConfig.localRoutingToggle", {
|
||||
defaultValue: "需要本地路由映射",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Codex Model Name 输入框 */}
|
||||
{shouldShowModelField && onModelNameChange && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="codexModelName"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{isChatApiFormat
|
||||
? t("codexConfig.upstreamModelName", {
|
||||
defaultValue: "上游模型名称",
|
||||
})
|
||||
: t("codexConfig.modelName", { defaultValue: "模型名称" })}
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFetchModels}
|
||||
disabled={isFetchingModels}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
{isFetchingModels ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{/* Codex 模型映射 —— 仅在本地路由 + 可编辑时显示 */}
|
||||
{needsLocalRouting && canEditCatalog && (
|
||||
<div className="space-y-4 rounded-lg border border-border-default p-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<FormLabel>
|
||||
{t("codexConfig.modelMappingTitle", {
|
||||
defaultValue: "模型映射",
|
||||
})}
|
||||
</FormLabel>
|
||||
{renderCatalogActionButtons(
|
||||
handleAddCatalogRow,
|
||||
t("codexConfig.addCatalogModel", {
|
||||
defaultValue: "添加模型",
|
||||
}),
|
||||
)}
|
||||
{t("providerForm.fetchModels")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.modelMappingHint", {
|
||||
defaultValue:
|
||||
"选择模型角色后,CC Switch 会自动生成 Codex 兼容路由;菜单显示名可以填 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<ModelInputWithFetch
|
||||
id="codexModelName"
|
||||
value={modelName}
|
||||
onChange={(v) => onModelNameChange!(v)}
|
||||
placeholder={t("codexConfig.modelNamePlaceholder", {
|
||||
defaultValue: "例如: gpt-5.4",
|
||||
})}
|
||||
fetchedModels={fetchedModels}
|
||||
isLoading={isFetchingModels}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isChatApiFormat
|
||||
? t("codexConfig.upstreamModelNameHint", {
|
||||
defaultValue:
|
||||
"Chat 格式下这里填写真实上游模型;接管时 Codex 本地会使用兼容模型,并由路由映射回该模型。",
|
||||
})
|
||||
: modelName.trim()
|
||||
? t("codexConfig.modelNameHint", {
|
||||
defaultValue: "指定使用的模型,将自动更新到 config.toml 中",
|
||||
})
|
||||
: t("providerForm.modelHint", {
|
||||
defaultValue: "💡 留空将使用供应商的默认模型",
|
||||
|
||||
{catalogRows.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{/* 列头:md+ 显示 */}
|
||||
<div className="hidden grid-cols-[1fr_1fr_140px_36px] gap-2 px-1 text-xs font-medium text-muted-foreground md:grid">
|
||||
<span>
|
||||
{t("codexConfig.catalogColumnDisplay", {
|
||||
defaultValue: "菜单显示名",
|
||||
})}
|
||||
</p>
|
||||
</span>
|
||||
<span>
|
||||
{t("codexConfig.catalogColumnModel", {
|
||||
defaultValue: "实际请求模型",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t("codexConfig.catalogColumnContext", {
|
||||
defaultValue: "上下文窗口",
|
||||
})}
|
||||
</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{catalogRows.map((row, index) => (
|
||||
<div
|
||||
key={row.rowId}
|
||||
className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_140px_36px]"
|
||||
>
|
||||
<Input
|
||||
value={row.displayName ?? ""}
|
||||
onChange={(event) =>
|
||||
handleUpdateCatalogRow(index, {
|
||||
displayName: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t(
|
||||
"codexConfig.catalogDisplayNamePlaceholder",
|
||||
{
|
||||
defaultValue: "例如: DeepSeek V4 Flash",
|
||||
},
|
||||
)}
|
||||
aria-label={t("codexConfig.catalogColumnDisplay", {
|
||||
defaultValue: "菜单显示名",
|
||||
})}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={row.model}
|
||||
onChange={(event) =>
|
||||
handleUpdateCatalogRow(index, {
|
||||
model: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t("codexConfig.catalogModelPlaceholder", {
|
||||
defaultValue: "例如: deepseek-v4-flash",
|
||||
})}
|
||||
aria-label={t("codexConfig.catalogColumnModel", {
|
||||
defaultValue: "实际请求模型",
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
{fetchedModels.length > 0 && (
|
||||
<ModelDropdown
|
||||
models={fetchedModels}
|
||||
onSelect={(id) =>
|
||||
handleUpdateCatalogRow(index, {
|
||||
model: id,
|
||||
displayName: row.displayName?.trim()
|
||||
? row.displayName
|
||||
: id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
inputMode="numeric"
|
||||
value={row.contextWindow ?? ""}
|
||||
onChange={(event) =>
|
||||
handleUpdateCatalogRow(index, {
|
||||
contextWindow: event.target.value.replace(/[^\d]/g, ""),
|
||||
})
|
||||
}
|
||||
placeholder={t("codexConfig.contextWindowPlaceholder", {
|
||||
defaultValue: "例如: 128000",
|
||||
})}
|
||||
aria-label={t("codexConfig.catalogColumnContext", {
|
||||
defaultValue: "上下文窗口",
|
||||
})}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemoveCatalogRow(index)}
|
||||
title={t("common.delete", { defaultValue: "删除" })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
ProviderTestConfig,
|
||||
ClaudeApiFormat,
|
||||
CodexApiFormat,
|
||||
CodexCatalogModel,
|
||||
ClaudeApiKeyField,
|
||||
} from "@/types";
|
||||
import {
|
||||
@@ -55,6 +56,7 @@ import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
||||
import {
|
||||
extractCodexWireApi,
|
||||
setCodexWireApi,
|
||||
setCodexModelName as setCodexModelNameInConfig,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { isNonNegativeDecimalString } from "@/types/usage";
|
||||
import { getCodexCustomTemplate } from "@/config/codexTemplates";
|
||||
@@ -142,6 +144,36 @@ const codexApiFormatFromWireApi = (
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeCodexCatalogModelsForSave = (
|
||||
models: CodexCatalogModel[],
|
||||
): CodexCatalogModel[] => {
|
||||
const seen = new Set<string>();
|
||||
const normalized: CodexCatalogModel[] = [];
|
||||
|
||||
for (const item of models) {
|
||||
const model = item.model.trim();
|
||||
if (!model || seen.has(model)) continue;
|
||||
seen.add(model);
|
||||
|
||||
const displayName = item.displayName?.trim();
|
||||
const rawContextWindow = String(item.contextWindow ?? "").replace(
|
||||
/[^\d]/g,
|
||||
"",
|
||||
);
|
||||
const contextWindow = rawContextWindow
|
||||
? Number.parseInt(rawContextWindow, 10)
|
||||
: undefined;
|
||||
|
||||
normalized.push({
|
||||
model,
|
||||
...(displayName ? { displayName } : {}),
|
||||
...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export interface ProviderFormProps {
|
||||
appId: AppId;
|
||||
providerId?: string;
|
||||
@@ -440,13 +472,13 @@ function ProviderFormFull({
|
||||
codexConfig,
|
||||
codexApiKey,
|
||||
codexBaseUrl,
|
||||
codexModelName,
|
||||
codexCatalogModels,
|
||||
codexAuthError,
|
||||
setCodexAuth,
|
||||
setCodexConfig,
|
||||
setCodexCatalogModels,
|
||||
handleCodexApiKeyChange,
|
||||
handleCodexBaseUrlChange,
|
||||
handleCodexModelNameChange,
|
||||
handleCodexConfigChange: originalHandleCodexConfigChange,
|
||||
resetCodexConfig,
|
||||
} = useCodexConfigState({ initialData });
|
||||
@@ -476,10 +508,6 @@ function ProviderFormFull({
|
||||
const handleCodexConfigChange = useCallback(
|
||||
(value: string) => {
|
||||
originalHandleCodexConfigChange(value);
|
||||
const nextFormat = codexApiFormatFromWireApi(extractCodexWireApi(value));
|
||||
if (nextFormat) {
|
||||
setLocalCodexApiFormat(nextFormat);
|
||||
}
|
||||
debouncedValidate(value);
|
||||
},
|
||||
[originalHandleCodexConfigChange, debouncedValidate],
|
||||
@@ -488,6 +516,7 @@ function ProviderFormFull({
|
||||
const handleCodexApiFormatChange = useCallback(
|
||||
(format: CodexApiFormat) => {
|
||||
setLocalCodexApiFormat(format);
|
||||
// wire_api is always "responses" for Codex; format controls proxy-layer conversion
|
||||
setCodexConfig((prev) => {
|
||||
const updated = setCodexWireApi(prev, "responses");
|
||||
debouncedValidate(updated);
|
||||
@@ -1109,14 +1138,32 @@ function ProviderFormFull({
|
||||
if (appId === "codex") {
|
||||
try {
|
||||
const authJson = JSON.parse(codexAuth);
|
||||
const normalizedCodexConfig =
|
||||
let normalizedCodexConfig =
|
||||
category !== "official" && (codexConfig ?? "").trim()
|
||||
? setCodexWireApi(codexConfig ?? "", "responses")
|
||||
: (codexConfig ?? "");
|
||||
const normalizedCatalogModels =
|
||||
category !== "official" && localCodexApiFormat === "openai_chat"
|
||||
? normalizeCodexCatalogModelsForSave(codexCatalogModels)
|
||||
: [];
|
||||
// Sync first catalog row's model into config.toml so Codex uses it as default
|
||||
if (normalizedCatalogModels.length > 0) {
|
||||
normalizedCodexConfig = setCodexModelNameInConfig(
|
||||
normalizedCodexConfig,
|
||||
normalizedCatalogModels[0].model,
|
||||
);
|
||||
}
|
||||
const configObj = {
|
||||
auth: authJson,
|
||||
config: normalizedCodexConfig,
|
||||
} as {
|
||||
auth: unknown;
|
||||
config: string;
|
||||
modelCatalog?: { models: CodexCatalogModel[] };
|
||||
};
|
||||
if (normalizedCatalogModels.length > 0) {
|
||||
configObj.modelCatalog = { models: normalizedCatalogModels };
|
||||
}
|
||||
settingsConfig = JSON.stringify(configObj);
|
||||
} catch (err) {
|
||||
settingsConfig = values.settingsConfig.trim();
|
||||
@@ -1937,9 +1984,8 @@ function ProviderFormFull({
|
||||
onAutoSelectChange={setEndpointAutoSelect}
|
||||
apiFormat={localCodexApiFormat}
|
||||
onApiFormatChange={handleCodexApiFormatChange}
|
||||
shouldShowModelField={category !== "official"}
|
||||
modelName={codexModelName}
|
||||
onModelNameChange={handleCodexModelNameChange}
|
||||
catalogModels={codexCatalogModels}
|
||||
onCatalogModelsChange={setCodexCatalogModels}
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,10 +2,9 @@ import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
extractCodexBaseUrl,
|
||||
setCodexBaseUrl as setCodexBaseUrlInConfig,
|
||||
extractCodexModelName,
|
||||
setCodexModelName as setCodexModelNameInConfig,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { normalizeTomlText } from "@/utils/textNormalization";
|
||||
import type { CodexCatalogModel } from "@/types";
|
||||
|
||||
interface UseCodexConfigStateProps {
|
||||
initialData?: {
|
||||
@@ -22,11 +21,12 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
const [codexConfig, setCodexConfigState] = useState("");
|
||||
const [codexApiKey, setCodexApiKey] = useState("");
|
||||
const [codexBaseUrl, setCodexBaseUrl] = useState("");
|
||||
const [codexModelName, setCodexModelName] = useState("");
|
||||
const [codexCatalogModels, setCodexCatalogModels] = useState<
|
||||
CodexCatalogModel[]
|
||||
>([]);
|
||||
const [codexAuthError, setCodexAuthError] = useState("");
|
||||
|
||||
const isUpdatingCodexBaseUrlRef = useRef(false);
|
||||
const isUpdatingCodexModelNameRef = useRef(false);
|
||||
|
||||
// 初始化 Codex 配置(编辑模式)
|
||||
useEffect(() => {
|
||||
@@ -45,18 +45,38 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
: "";
|
||||
setCodexConfigState(configStr);
|
||||
|
||||
const modelCatalog = (config as any).modelCatalog;
|
||||
const rawCatalogModels = Array.isArray(modelCatalog?.models)
|
||||
? modelCatalog.models
|
||||
: [];
|
||||
setCodexCatalogModels(
|
||||
rawCatalogModels
|
||||
.map((item: any) => ({
|
||||
model: typeof item?.model === "string" ? item.model : "",
|
||||
displayName:
|
||||
typeof item?.displayName === "string"
|
||||
? item.displayName
|
||||
: typeof item?.display_name === "string"
|
||||
? item.display_name
|
||||
: "",
|
||||
contextWindow:
|
||||
typeof item?.contextWindow === "string" ||
|
||||
typeof item?.contextWindow === "number"
|
||||
? item.contextWindow
|
||||
: typeof item?.context_window === "string" ||
|
||||
typeof item?.context_window === "number"
|
||||
? item.context_window
|
||||
: "",
|
||||
}))
|
||||
.filter((item: CodexCatalogModel) => item.model.trim()),
|
||||
);
|
||||
|
||||
// 提取 Base URL
|
||||
const initialBaseUrl = extractCodexBaseUrl(configStr);
|
||||
if (initialBaseUrl) {
|
||||
setCodexBaseUrl(initialBaseUrl);
|
||||
}
|
||||
|
||||
// 提取 Model Name
|
||||
const initialModelName = extractCodexModelName(configStr);
|
||||
if (initialModelName) {
|
||||
setCodexModelName(initialModelName);
|
||||
}
|
||||
|
||||
// 提取 API Key
|
||||
try {
|
||||
if (auth && typeof auth.OPENAI_API_KEY === "string") {
|
||||
@@ -77,15 +97,6 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
setCodexBaseUrl((prev) => (prev === extracted ? prev : extracted));
|
||||
}, [codexConfig]);
|
||||
|
||||
// 与 TOML 配置保持模型名称同步
|
||||
useEffect(() => {
|
||||
if (isUpdatingCodexModelNameRef.current) {
|
||||
return;
|
||||
}
|
||||
const extracted = extractCodexModelName(codexConfig) || "";
|
||||
setCodexModelName((prev) => (prev === extracted ? prev : extracted));
|
||||
}, [codexConfig]);
|
||||
|
||||
// 获取 API Key(从 auth JSON)
|
||||
const getCodexAuthApiKey = useCallback((authString: string): string => {
|
||||
try {
|
||||
@@ -170,22 +181,7 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
[setCodexConfig],
|
||||
);
|
||||
|
||||
// 处理 Codex Model Name 变化
|
||||
const handleCodexModelNameChange = useCallback(
|
||||
(modelName: string) => {
|
||||
const trimmed = modelName.trim();
|
||||
setCodexModelName(trimmed);
|
||||
|
||||
isUpdatingCodexModelNameRef.current = true;
|
||||
setCodexConfig((prev) => setCodexModelNameInConfig(prev, trimmed));
|
||||
setTimeout(() => {
|
||||
isUpdatingCodexModelNameRef.current = false;
|
||||
}, 0);
|
||||
},
|
||||
[setCodexConfig],
|
||||
);
|
||||
|
||||
// 处理 config 变化(同步 Base URL 和 Model Name)
|
||||
// 处理 config 变化(同步 Base URL)
|
||||
const handleCodexConfigChange = useCallback(
|
||||
(value: string) => {
|
||||
// 归一化中文/全角/弯引号,避免 TOML 解析报错
|
||||
@@ -198,35 +194,24 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
setCodexBaseUrl(extracted);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUpdatingCodexModelNameRef.current) {
|
||||
const extractedModel = extractCodexModelName(normalized) || "";
|
||||
if (extractedModel !== codexModelName) {
|
||||
setCodexModelName(extractedModel);
|
||||
}
|
||||
}
|
||||
},
|
||||
[setCodexConfig, codexBaseUrl, codexModelName],
|
||||
[setCodexConfig, codexBaseUrl],
|
||||
);
|
||||
|
||||
// 重置配置(用于预设切换)
|
||||
const resetCodexConfig = useCallback(
|
||||
(auth: Record<string, unknown>, config: string) => {
|
||||
(
|
||||
auth: Record<string, unknown>,
|
||||
config: string,
|
||||
modelCatalogModels: CodexCatalogModel[] = [],
|
||||
) => {
|
||||
const authString = JSON.stringify(auth, null, 2);
|
||||
setCodexAuth(authString);
|
||||
setCodexConfig(config);
|
||||
setCodexCatalogModels(modelCatalogModels);
|
||||
|
||||
const baseUrl = extractCodexBaseUrl(config);
|
||||
if (baseUrl) {
|
||||
setCodexBaseUrl(baseUrl);
|
||||
}
|
||||
|
||||
const modelName = extractCodexModelName(config);
|
||||
if (modelName) {
|
||||
setCodexModelName(modelName);
|
||||
} else {
|
||||
setCodexModelName("");
|
||||
}
|
||||
setCodexBaseUrl(baseUrl || "");
|
||||
|
||||
// 提取 API Key
|
||||
try {
|
||||
@@ -247,13 +232,13 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
codexConfig,
|
||||
codexApiKey,
|
||||
codexBaseUrl,
|
||||
codexModelName,
|
||||
codexCatalogModels,
|
||||
codexAuthError,
|
||||
setCodexAuth,
|
||||
setCodexConfig,
|
||||
setCodexCatalogModels,
|
||||
handleCodexApiKeyChange,
|
||||
handleCodexBaseUrlChange,
|
||||
handleCodexModelNameChange,
|
||||
handleCodexConfigChange,
|
||||
resetCodexConfig,
|
||||
getCodexAuthApiKey,
|
||||
|
||||
@@ -1080,12 +1080,30 @@
|
||||
"saveFailed": "Save failed: {{error}}",
|
||||
"modelNameHint": "Specify the model to use, will be auto-updated in config.toml",
|
||||
"modelName": "Model Name",
|
||||
"localRoutingToggle": "Needs Local Routing",
|
||||
"localRoutingOffHint": "Use this when the provider natively supports OpenAI Responses and the model can be used directly by Codex; Codex can connect to the provider without local routing.",
|
||||
"localRoutingOnHint": "Use this for custom models such as DeepSeek or Kimi, or providers that only support Chat Completions; keep local routing running while in use.",
|
||||
"upstreamModelName": "Upstream Model Name",
|
||||
"upstreamModelNameHint": "For Chat format, enter the real upstream model here; takeover writes a Codex-compatible local model and routing maps it back to this model.",
|
||||
"upstreamModelNameHint": "For Chat format, enter the real upstream model here; routing converts Codex Responses requests to Chat Completions while keeping this model.",
|
||||
"modelNamePlaceholder": "e.g., gpt-5-codex",
|
||||
"contextWindow1M": "1M Context Window",
|
||||
"contextWindow": "Context Window",
|
||||
"contextWindowPlaceholder": "e.g., 128000",
|
||||
"autoCompactLimit": "Auto Compact Limit",
|
||||
"autoCompactLimitHint": "Auto-compacts history when context reaches this token limit"
|
||||
"autoCompactLimitPlaceholder": "e.g., 90000",
|
||||
"autoCompactLimitHint": "Auto-compacts history when context reaches this token limit",
|
||||
"modelMetadataAdvanced": "Model Metadata Advanced Options",
|
||||
"modelMetadataAdvancedHint": "Overrides Codex metadata for unknown models; clearing a field removes the matching config.toml entry.",
|
||||
"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.",
|
||||
"addCatalogModel": "Add Model",
|
||||
"catalogDisplayName": "Display Name",
|
||||
"catalogDisplayNamePlaceholder": "e.g., DeepSeek V4 Flash",
|
||||
"catalogModelId": "Model ID",
|
||||
"catalogModelPlaceholder": "e.g., deepseek-v4-flash",
|
||||
"catalogColumnDisplay": "Menu Display Name",
|
||||
"catalogColumnModel": "Actual Request Model",
|
||||
"catalogColumnContext": "Context Window"
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "Environment Variables (.env)",
|
||||
|
||||
@@ -1080,12 +1080,30 @@
|
||||
"saveFailed": "保存に失敗しました: {{error}}",
|
||||
"modelNameHint": "使用するモデルを指定します。config.toml に自動更新されます",
|
||||
"modelName": "モデル名",
|
||||
"localRoutingToggle": "ローカルルーティングが必要",
|
||||
"localRoutingOffHint": "プロバイダーが OpenAI Responses API をネイティブにサポートし、Codex がモデルを直接使用できる場合に適しています。Codex はローカルルーティングなしで接続できます。",
|
||||
"localRoutingOnHint": "DeepSeek、Kimi などのカスタムモデル、または Chat Completions のみをサポートするプロバイダー向けです。利用中はローカルルーティングを起動したままにしてください。",
|
||||
"upstreamModelName": "上流モデル名",
|
||||
"upstreamModelNameHint": "Chat 形式ではここに実際の上流モデルを入力します。接管時は Codex 互換のローカルモデルを書き込み、ルーティングでこのモデルに戻します。",
|
||||
"upstreamModelNameHint": "Chat 形式ではここに実際の上流モデルを入力します。ルーティングで Codex の Responses リクエストを Chat Completions に変換し、このモデルを維持します。",
|
||||
"modelNamePlaceholder": "例: gpt-5-codex",
|
||||
"contextWindow1M": "1M コンテキストウィンドウ",
|
||||
"contextWindow": "コンテキストウィンドウ",
|
||||
"contextWindowPlaceholder": "例: 128000",
|
||||
"autoCompactLimit": "自動圧縮しきい値",
|
||||
"autoCompactLimitHint": "コンテキストトークン数がこのしきい値に達すると履歴を自動圧縮"
|
||||
"autoCompactLimitPlaceholder": "例: 90000",
|
||||
"autoCompactLimitHint": "コンテキストトークン数がこのしきい値に達すると履歴を自動圧縮",
|
||||
"modelMetadataAdvanced": "モデルメタデータ詳細オプション",
|
||||
"modelMetadataAdvancedHint": "未知のモデル向けに Codex メタデータを上書きします。空にすると対応する config.toml 設定を削除します。",
|
||||
"modelMappingTitle": "モデルマッピング",
|
||||
"modelMappingHint": "Codex model_catalog_json を生成し、/model コマンドでこれらの第三者モデル名を表示できるようにします。リストの内容はそのまま保存されます。変更後、モデルリストを更新するには Codex の再起動が必要です。",
|
||||
"addCatalogModel": "モデルを追加",
|
||||
"catalogDisplayName": "表示名",
|
||||
"catalogDisplayNamePlaceholder": "例: DeepSeek V4 Flash",
|
||||
"catalogModelId": "モデル ID",
|
||||
"catalogModelPlaceholder": "例: deepseek-v4-flash",
|
||||
"catalogColumnDisplay": "メニュー表示名",
|
||||
"catalogColumnModel": "実際のリクエストモデル",
|
||||
"catalogColumnContext": "コンテキストウィンドウ"
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "環境変数 (.env)",
|
||||
|
||||
@@ -1080,12 +1080,30 @@
|
||||
"saveFailed": "保存失败: {{error}}",
|
||||
"modelNameHint": "指定使用的模型,将自动更新到 config.toml 中",
|
||||
"modelName": "模型名称",
|
||||
"localRoutingToggle": "需要本地路由映射",
|
||||
"localRoutingOffHint": "适合供应商原生支持 OpenAI Responses API 且模型在 Codex 中可直接使用的场景,请求可由 Codex 直连供应商。",
|
||||
"localRoutingOnHint": "适合 DeepSeek、Kimi 等自定义模型或仅支持 Chat Completions 的供应商;使用时请保持本地路由开启。",
|
||||
"upstreamModelName": "上游模型名称",
|
||||
"upstreamModelNameHint": "Chat 格式下这里填写真实上游模型;接管时 Codex 本地会使用兼容模型,并由路由映射回该模型。",
|
||||
"upstreamModelNameHint": "Chat 格式下这里填写真实上游模型;路由会把 Codex 的 Responses 请求转换为 Chat Completions 并保持该模型。",
|
||||
"modelNamePlaceholder": "例如: gpt-5-codex",
|
||||
"contextWindow1M": "1M 上下文窗口",
|
||||
"contextWindow": "上下文窗口",
|
||||
"contextWindowPlaceholder": "例如: 128000",
|
||||
"autoCompactLimit": "压缩阈值",
|
||||
"autoCompactLimitHint": "上下文 token 数达到此阈值时自动压缩历史"
|
||||
"autoCompactLimitPlaceholder": "例如: 90000",
|
||||
"autoCompactLimitHint": "上下文 token 数达到此阈值时自动压缩历史",
|
||||
"modelMetadataAdvanced": "模型元数据高级选项",
|
||||
"modelMetadataAdvancedHint": "用于未知模型的 Codex 元数据覆盖;清空字段会从 config.toml 删除对应配置。",
|
||||
"modelMappingTitle": "模型映射",
|
||||
"modelMappingHint": "生成 Codex model_catalog_json,让 /model 命令显示这些第三方模型名;表中条目按填写内容原样保存。修改后需要重启 Codex 才能刷新模型列表。",
|
||||
"addCatalogModel": "添加模型",
|
||||
"catalogDisplayName": "显示名称",
|
||||
"catalogDisplayNamePlaceholder": "例如: DeepSeek V4 Flash",
|
||||
"catalogModelId": "模型 ID",
|
||||
"catalogModelPlaceholder": "例如: deepseek-v4-flash",
|
||||
"catalogColumnDisplay": "菜单显示名",
|
||||
"catalogColumnModel": "实际请求模型",
|
||||
"catalogColumnContext": "上下文窗口"
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "环境变量 (.env)",
|
||||
|
||||
@@ -209,6 +209,12 @@ export type ClaudeApiFormat =
|
||||
// - "openai_chat": OpenAI Chat Completions 格式,需要本地路由转换
|
||||
export type CodexApiFormat = "openai_responses" | "openai_chat";
|
||||
|
||||
export interface CodexCatalogModel {
|
||||
model: string;
|
||||
displayName?: string;
|
||||
contextWindow?: string | number;
|
||||
}
|
||||
|
||||
// Claude 认证字段类型
|
||||
export type ClaudeApiKeyField = "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user