mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 19:45:34 +08:00
Merge remote-tracking branch 'origin/main' into pr-4887
# Conflicts: # src/lib/query/subscription.ts
This commit is contained in:
@@ -31,6 +31,7 @@ interface AddProviderDialogProps {
|
||||
providerKey?: string;
|
||||
suggestedDefaults?: OpenClawSuggestedDefaults;
|
||||
ensureClaudeDesktopOfficialSeed?: boolean;
|
||||
ensureCodexOfficialSeed?: boolean;
|
||||
},
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
@@ -60,14 +61,6 @@ export function AddProviderDialog({
|
||||
async (provider: UniversalProvider) => {
|
||||
try {
|
||||
await universalProvidersApi.upsert(provider);
|
||||
toast.success(
|
||||
t("universalProvider.addSuccess", {
|
||||
defaultValue: "统一供应商添加成功",
|
||||
}),
|
||||
);
|
||||
setUniversalFormOpen(false);
|
||||
setSelectedUniversalPreset(null);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[AddProviderDialog] Failed to save universal provider",
|
||||
@@ -78,7 +71,31 @@ export function AddProviderDialog({
|
||||
defaultValue: "统一供应商添加失败",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await universalProvidersApi.sync(provider.id);
|
||||
toast.success(
|
||||
t("universalProvider.addedAndSynced", {
|
||||
defaultValue: "统一供应商已添加并同步",
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[AddProviderDialog] Provider saved but sync failed",
|
||||
error,
|
||||
);
|
||||
toast.warning(
|
||||
t("universalProvider.addedButSyncFailed", {
|
||||
defaultValue: "统一供应商已添加,但同步失败",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setUniversalFormOpen(false);
|
||||
setSelectedUniversalPreset(null);
|
||||
onOpenChange(false);
|
||||
},
|
||||
[t, onOpenChange],
|
||||
);
|
||||
@@ -100,6 +117,7 @@ export function AddProviderDialog({
|
||||
providerKey?: string;
|
||||
suggestedDefaults?: OpenClawSuggestedDefaults;
|
||||
ensureClaudeDesktopOfficialSeed?: boolean;
|
||||
ensureCodexOfficialSeed?: boolean;
|
||||
} = {
|
||||
name: values.name.trim(),
|
||||
notes: values.notes?.trim() || undefined,
|
||||
@@ -121,6 +139,14 @@ export function AddProviderDialog({
|
||||
preset?.category === "official";
|
||||
}
|
||||
|
||||
if (appId === "codex" && values.presetId) {
|
||||
const presetIndex = parseInt(values.presetId.replace("codex-", ""));
|
||||
const preset = codexProviderPresets[presetIndex];
|
||||
providerData.ensureCodexOfficialSeed =
|
||||
values.presetCategory === "official" &&
|
||||
preset?.category === "official";
|
||||
}
|
||||
|
||||
// OpenCode/OpenClaw: pass providerKey for ID generation
|
||||
if (
|
||||
(appId === "opencode" || appId === "openclaw" || appId === "hermes") &&
|
||||
|
||||
@@ -174,7 +174,7 @@ export function EditProviderDialog({
|
||||
}, [
|
||||
open, // 修复:编辑保存后再次打开显示旧数据,依赖 open 确保每次打开时重新读取最新 provider 数据
|
||||
provider?.id, // 只依赖 ID,provider 对象更新不会触发重新计算
|
||||
provider?.meta, // 需要依赖 meta 以便正确初始化 testConfig
|
||||
provider?.meta, // 供应商元数据变化时重新初始化表单
|
||||
initialSettingsConfig,
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { HealthStatus } from "@/lib/api/model-test";
|
||||
import type { HealthStatus } from "@/lib/api/connectivity-check";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface HealthStatusIndicatorProps {
|
||||
|
||||
@@ -22,8 +22,10 @@ import {
|
||||
extractCodexBaseUrl,
|
||||
extractCodexExperimentalBearerToken,
|
||||
extractCodexWireApi,
|
||||
isCodexAnthropicWireApi,
|
||||
isCodexChatWireApi,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { supportsOfficialProxyTakeover } from "@/utils/providerCapabilities";
|
||||
import { useProviderHealth } from "@/lib/query/failover";
|
||||
import { useUsageQuery } from "@/lib/query/queries";
|
||||
|
||||
@@ -207,8 +209,14 @@ export function ProviderCard({
|
||||
// 并不兑现(绕过 UI 即可切换)→ 属虚保护,却以误伤 category 缺失的自定义供应商为代价。
|
||||
// 3) 预设导入的官方一定带 category="official",category 缺失的「真官方」现实中≈不存在。
|
||||
// 真官方就该有显式 category;手动新建官方应引导标注,而不是靠空字段猜。
|
||||
const supportsOfficialRouting = supportsOfficialProxyTakeover(
|
||||
appId,
|
||||
provider,
|
||||
);
|
||||
const isOfficialBlockedByProxy =
|
||||
isProxyTakeover && provider.category === "official";
|
||||
isProxyTakeover &&
|
||||
provider.category === "official" &&
|
||||
!supportsOfficialRouting;
|
||||
const isCopilot =
|
||||
provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT ||
|
||||
provider.meta?.usage_script?.templateType === "github_copilot";
|
||||
@@ -220,11 +228,16 @@ export function ProviderCard({
|
||||
provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH;
|
||||
const codexNeedsRouting = useMemo(() => {
|
||||
if (appId !== "codex" || provider.category === "official") return false;
|
||||
if (provider.meta?.apiFormat === "openai_chat") return true;
|
||||
if (
|
||||
provider.meta?.apiFormat === "openai_chat" ||
|
||||
provider.meta?.apiFormat === "anthropic"
|
||||
)
|
||||
return true;
|
||||
const config = (provider.settingsConfig as Record<string, any>)?.config;
|
||||
return (
|
||||
typeof config === "string" &&
|
||||
isCodexChatWireApi(extractCodexWireApi(config))
|
||||
(isCodexChatWireApi(extractCodexWireApi(config)) ||
|
||||
isCodexAnthropicWireApi(extractCodexWireApi(config)))
|
||||
);
|
||||
}, [
|
||||
appId,
|
||||
@@ -399,14 +412,28 @@ export function ProviderCard({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{appId === "codex" && provider.category === "official" && (
|
||||
<span className="inline-flex items-center rounded-md bg-slate-200 px-1.5 py-0.5 text-[10px] font-semibold text-slate-700 dark:bg-slate-700/60 dark:text-slate-200">
|
||||
{t("codex.noRoutingSupport", {
|
||||
defaultValue: "不支持路由",
|
||||
})}
|
||||
{appId === "codex" && supportsOfficialRouting && (
|
||||
<span className="inline-flex items-center rounded-md bg-sky-100 px-1.5 py-0.5 text-[10px] font-semibold text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">
|
||||
{isProxyTakeover
|
||||
? t("codex.officialRouting", {
|
||||
defaultValue: "官方账号路由",
|
||||
})
|
||||
: t("codex.nativeLogin", {
|
||||
defaultValue: "Codex 登录",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{appId === "codex" &&
|
||||
provider.category === "official" &&
|
||||
!supportsOfficialRouting && (
|
||||
<span className="inline-flex items-center rounded-md bg-slate-200 px-1.5 py-0.5 text-[10px] font-semibold text-slate-700 dark:bg-slate-700/60 dark:text-slate-200">
|
||||
{t("codex.noRoutingSupport", {
|
||||
defaultValue: "不支持路由",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isProxyRunning && isInFailoverQueue && health && (
|
||||
<ProviderHealthBadge
|
||||
consecutiveFailures={health.consecutive_failures}
|
||||
|
||||
@@ -126,6 +126,7 @@ interface ClaudeFormFieldsProps {
|
||||
defaultOpusModelName: string;
|
||||
defaultFableModel: string;
|
||||
defaultFableModelName: string;
|
||||
subagentModel: string;
|
||||
onModelChange: (field: ClaudeModelEnvField, value: string) => void;
|
||||
|
||||
// Speed Test Endpoints
|
||||
@@ -196,6 +197,7 @@ export function ClaudeFormFields({
|
||||
defaultOpusModelName,
|
||||
defaultFableModel,
|
||||
defaultFableModelName,
|
||||
subagentModel,
|
||||
onModelChange,
|
||||
speedTestEndpoints,
|
||||
apiFormat,
|
||||
@@ -221,6 +223,7 @@ export function ClaudeFormFields({
|
||||
defaultSonnetModel ||
|
||||
defaultOpusModel ||
|
||||
defaultFableModel ||
|
||||
subagentModel ||
|
||||
apiFormat !== "anthropic" ||
|
||||
apiKeyField !== "ANTHROPIC_AUTH_TOKEN" ||
|
||||
customUserAgent ||
|
||||
@@ -244,6 +247,7 @@ export function ClaudeFormFields({
|
||||
const [codexOauthModels, setCodexOauthModels] = useState<FetchedModel[]>([]);
|
||||
const [codexOauthModelsLoading, setCodexOauthModelsLoading] = useState(false);
|
||||
const codexOauthModelsRequestRef = useRef(0);
|
||||
const fallbackUsesOneM = hasClaudeOneMMarker(claudeModel);
|
||||
|
||||
// 通用模型获取(非 Copilot 供应商)
|
||||
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
|
||||
@@ -513,12 +517,12 @@ export function ClaudeFormFields({
|
||||
};
|
||||
|
||||
type ModelRoleRow = {
|
||||
role: "sonnet" | "opus" | "fable" | "haiku";
|
||||
role: "sonnet" | "opus" | "fable" | "haiku" | "subagent";
|
||||
label: string;
|
||||
model: string;
|
||||
displayName: string;
|
||||
displayName?: string;
|
||||
modelField: ClaudeModelEnvField;
|
||||
displayNameField: ClaudeModelEnvField;
|
||||
displayNameField?: ClaudeModelEnvField;
|
||||
inputId: string;
|
||||
supportsOneM: boolean;
|
||||
};
|
||||
@@ -564,6 +568,16 @@ export function ClaudeFormFields({
|
||||
inputId: "claudeDefaultHaikuModel",
|
||||
supportsOneM: false,
|
||||
},
|
||||
{
|
||||
role: "subagent",
|
||||
label: t("providerForm.modelRoleSubagent", {
|
||||
defaultValue: "Subagent",
|
||||
}),
|
||||
model: subagentModel,
|
||||
modelField: "CLAUDE_CODE_SUBAGENT_MODEL",
|
||||
inputId: "claudeCodeSubagentModel",
|
||||
supportsOneM: true,
|
||||
},
|
||||
];
|
||||
|
||||
const handleRoleModelChange = (row: ModelRoleRow, value: string) => {
|
||||
@@ -572,10 +586,10 @@ export function ClaudeFormFields({
|
||||
? value
|
||||
: stripClaudeOneMMarker(value);
|
||||
const nextModelBase = stripClaudeOneMMarker(normalizedValue).trim();
|
||||
const displayName = row.displayName.trim();
|
||||
const displayName = row.displayName?.trim() ?? "";
|
||||
const shouldSyncDisplayName = !displayName || displayName === oldModelBase;
|
||||
onModelChange(row.modelField, normalizedValue);
|
||||
if (shouldSyncDisplayName) {
|
||||
if (row.displayNameField && shouldSyncDisplayName) {
|
||||
onModelChange(row.displayNameField, nextModelBase);
|
||||
}
|
||||
};
|
||||
@@ -815,17 +829,20 @@ export function ClaudeFormFields({
|
||||
defaultSonnetModel ||
|
||||
defaultOpusModel ||
|
||||
defaultFableModel ||
|
||||
defaultHaikuModel;
|
||||
defaultHaikuModel ||
|
||||
subagentModel;
|
||||
if (value) {
|
||||
for (const row of modelRoleRows) {
|
||||
const roleValue = row.supportsOneM
|
||||
? value
|
||||
: stripClaudeOneMMarker(value);
|
||||
onModelChange(row.modelField, roleValue);
|
||||
onModelChange(
|
||||
row.displayNameField,
|
||||
stripClaudeOneMMarker(roleValue),
|
||||
);
|
||||
if (row.displayNameField) {
|
||||
onModelChange(
|
||||
row.displayNameField,
|
||||
stripClaudeOneMMarker(roleValue),
|
||||
);
|
||||
}
|
||||
}
|
||||
toast.success(
|
||||
t("providerForm.quickSetSuccess", {
|
||||
@@ -839,7 +856,8 @@ export function ClaudeFormFields({
|
||||
!defaultHaikuModel &&
|
||||
!defaultSonnetModel &&
|
||||
!defaultOpusModel &&
|
||||
!defaultFableModel
|
||||
!defaultFableModel &&
|
||||
!subagentModel
|
||||
}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
@@ -907,19 +925,30 @@ export function ClaudeFormFields({
|
||||
<div className="flex h-9 items-center rounded-md border border-input bg-muted px-3 text-sm font-medium text-muted-foreground">
|
||||
{row.label}
|
||||
</div>
|
||||
<Input
|
||||
value={row.displayName}
|
||||
onChange={(event) =>
|
||||
onModelChange(row.displayNameField, event.target.value)
|
||||
}
|
||||
placeholder={
|
||||
modelBase ||
|
||||
t("providerForm.modelDisplayNamePlaceholder", {
|
||||
defaultValue: "例如 DeepSeek V4 Pro",
|
||||
})
|
||||
}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{row.displayNameField ? (
|
||||
<Input
|
||||
value={row.displayName ?? ""}
|
||||
onChange={(event) =>
|
||||
onModelChange(
|
||||
row.displayNameField!,
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
placeholder={
|
||||
modelBase ||
|
||||
t("providerForm.modelDisplayNamePlaceholder", {
|
||||
defaultValue: "例如 DeepSeek V4 Pro",
|
||||
})
|
||||
}
|
||||
autoComplete="off"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-9 items-center rounded-md border border-input bg-muted px-3 text-sm text-muted-foreground">
|
||||
{t("providerForm.modelNoDisplayName", {
|
||||
defaultValue: "不显示在 /model 菜单",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{renderModelInput(
|
||||
row.inputId,
|
||||
modelBase,
|
||||
@@ -957,12 +986,35 @@ export function ClaudeFormFields({
|
||||
defaultValue: "默认兜底模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
{renderModelInput(
|
||||
"claudeModel",
|
||||
claudeModel,
|
||||
"ANTHROPIC_MODEL",
|
||||
t("providerForm.modelPlaceholder", { defaultValue: "" }),
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_minmax(0,104px)]">
|
||||
{renderModelInput(
|
||||
"claudeModel",
|
||||
stripClaudeOneMMarker(claudeModel),
|
||||
"ANTHROPIC_MODEL",
|
||||
t("providerForm.modelPlaceholder", { defaultValue: "" }),
|
||||
(value) =>
|
||||
onModelChange(
|
||||
"ANTHROPIC_MODEL",
|
||||
setClaudeOneMMarker(value, fallbackUsesOneM),
|
||||
),
|
||||
)}
|
||||
<label className="flex h-9 items-center gap-2 text-sm text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={fallbackUsesOneM}
|
||||
onCheckedChange={(checked) => {
|
||||
const base = stripClaudeOneMMarker(claudeModel).trim();
|
||||
if (!base) return;
|
||||
onModelChange(
|
||||
"ANTHROPIC_MODEL",
|
||||
setClaudeOneMMarker(base, checked === true),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{t("providerForm.modelOneMLabel", {
|
||||
defaultValue: "1M",
|
||||
})}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.fallbackModelHint", {
|
||||
defaultValue:
|
||||
|
||||
@@ -9,7 +9,7 @@ interface CodexCommonConfigModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
value: string;
|
||||
onSave: (value: string) => boolean;
|
||||
onSave: (value: string) => boolean | Promise<boolean>;
|
||||
error?: string;
|
||||
onExtract?: () => void;
|
||||
isExtracting?: boolean;
|
||||
@@ -58,8 +58,8 @@ export const CodexCommonConfigModal: React.FC<CodexCommonConfigModalProps> = ({
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (onSave(draftValue)) {
|
||||
const handleSave = async () => {
|
||||
if (await onSave(draftValue)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -22,11 +22,11 @@ interface CodexConfigEditorProps {
|
||||
|
||||
useCommonConfig: boolean;
|
||||
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
onCommonConfigToggle: (checked: boolean) => void | Promise<void>;
|
||||
|
||||
commonConfigSnippet: string;
|
||||
|
||||
onCommonConfigSnippetChange: (value: string) => boolean;
|
||||
onCommonConfigSnippetChange: (value: string) => boolean | Promise<boolean>;
|
||||
|
||||
onCommonConfigErrorClear: () => void;
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -36,9 +36,11 @@ import { CustomUserAgentField } from "./CustomUserAgentField";
|
||||
import { LocalProxyRequestOverridesField } from "./LocalProxyRequestOverridesField";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
ClaudeApiKeyField,
|
||||
CodexApiFormat,
|
||||
CodexCatalogModel,
|
||||
CodexChatReasoning,
|
||||
PromptCacheRoutingMode,
|
||||
ProviderCategory,
|
||||
} from "@/types";
|
||||
|
||||
@@ -69,12 +71,27 @@ 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;
|
||||
onApiFormatChange: (format: CodexApiFormat) => void;
|
||||
// Auth field for the Anthropic Messages upstream (only used when apiFormat === "anthropic")
|
||||
anthropicAuthField: ClaudeApiKeyField;
|
||||
onAnthropicAuthFieldChange: (value: ClaudeApiKeyField) => void;
|
||||
// Anthropic path: whether to emulate the Claude Code client
|
||||
impersonateClaudeCode: boolean;
|
||||
onImpersonateClaudeCodeChange: (value: boolean) => void;
|
||||
// Anthropic path: output ceiling override (empty string = use default). Digits only.
|
||||
maxOutputTokens: string;
|
||||
onMaxOutputTokensChange: (value: string) => void;
|
||||
codexChatReasoning?: CodexChatReasoning;
|
||||
onCodexChatReasoningChange?: (value: CodexChatReasoning) => void;
|
||||
promptCacheRouting: PromptCacheRoutingMode;
|
||||
onPromptCacheRoutingChange: (value: PromptCacheRoutingMode) => void;
|
||||
|
||||
// Model Catalog
|
||||
catalogModels?: CodexCatalogModel[];
|
||||
@@ -156,10 +173,20 @@ export function CodexFormFields({
|
||||
onCustomEndpointsChange,
|
||||
autoSelect,
|
||||
onAutoSelectChange,
|
||||
codexModel = "",
|
||||
onModelChange,
|
||||
apiFormat,
|
||||
onApiFormatChange,
|
||||
anthropicAuthField,
|
||||
onAnthropicAuthFieldChange,
|
||||
impersonateClaudeCode,
|
||||
onImpersonateClaudeCodeChange,
|
||||
maxOutputTokens,
|
||||
onMaxOutputTokensChange,
|
||||
codexChatReasoning = {},
|
||||
onCodexChatReasoningChange,
|
||||
promptCacheRouting,
|
||||
onPromptCacheRoutingChange,
|
||||
catalogModels = [],
|
||||
onCatalogModelsChange,
|
||||
speedTestEndpoints,
|
||||
@@ -174,9 +201,19 @@ 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";
|
||||
const isAnthropicFormat = apiFormat === "anthropic";
|
||||
const canEditCatalog = Boolean(onCatalogModelsChange);
|
||||
const canEditReasoning = Boolean(onCodexChatReasoningChange);
|
||||
const supportsThinking =
|
||||
@@ -194,8 +231,11 @@ export function CodexFormFields({
|
||||
hasRequestOverrides ||
|
||||
catalogModels.length > 0 ||
|
||||
apiFormat === "openai_responses" ||
|
||||
isAnthropicFormat ||
|
||||
supportsThinking ||
|
||||
supportsEffort;
|
||||
supportsEffort ||
|
||||
promptCacheRouting !== "auto" ||
|
||||
!!maxOutputTokens;
|
||||
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
|
||||
|
||||
// 预设/编辑加载填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
|
||||
@@ -271,6 +311,7 @@ export function CodexFormFields({
|
||||
});
|
||||
return;
|
||||
}
|
||||
const seq = ++fetchModelsSeqRef.current;
|
||||
setIsFetchingModels(true);
|
||||
fetchModelsForConfig(
|
||||
codexBaseUrl,
|
||||
@@ -280,6 +321,7 @@ export function CodexFormFields({
|
||||
customUserAgent,
|
||||
)
|
||||
.then((models) => {
|
||||
if (seq !== fetchModelsSeqRef.current) return;
|
||||
setFetchedModels(models);
|
||||
if (models.length === 0) {
|
||||
toast.info(t("providerForm.fetchModelsEmpty"));
|
||||
@@ -290,6 +332,7 @@ export function CodexFormFields({
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (seq !== fetchModelsSeqRef.current) return;
|
||||
console.warn("[ModelFetch] Failed:", err);
|
||||
showFetchModelsError(err, t);
|
||||
})
|
||||
@@ -314,6 +357,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
|
||||
@@ -383,6 +467,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
|
||||
@@ -449,15 +600,118 @@ export function CodexFormFields({
|
||||
defaultValue: "Responses(原生)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="anthropic">
|
||||
{t("codexConfig.upstreamFormatAnthropic", {
|
||||
defaultValue: "Anthropic Messages(需开启路由)",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.upstreamFormatHint", {
|
||||
defaultValue:
|
||||
"供应商原生是 Responses API 就选 Responses(直连,不转换格式);使用 Chat Completions 协议就选 Chat(需开启路由接管才能转换为 Chat Completions)。",
|
||||
"供应商原生是 Responses API 就选 Responses(直连,不转换格式);使用 Chat Completions 协议就选 Chat;供应商只提供原生 Anthropic Messages 协议就选 Anthropic Messages。Chat 与 Anthropic Messages 均需开启路由接管才能转换为 Responses。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isAnthropicFormat && (
|
||||
<div className="space-y-1.5">
|
||||
<FormLabel htmlFor="codex-anthropic-auth-field">
|
||||
{t("codexConfig.anthropicAuthFieldLabel", {
|
||||
defaultValue: "认证字段",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={anthropicAuthField}
|
||||
onValueChange={(value) =>
|
||||
onAnthropicAuthFieldChange(value as ClaudeApiKeyField)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="codex-anthropic-auth-field"
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ANTHROPIC_AUTH_TOKEN">
|
||||
{t("codexConfig.anthropicAuthFieldAuthToken", {
|
||||
defaultValue:
|
||||
"ANTHROPIC_AUTH_TOKEN(Authorization)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="ANTHROPIC_API_KEY">
|
||||
{t("codexConfig.anthropicAuthFieldApiKey", {
|
||||
defaultValue: "ANTHROPIC_API_KEY(x-api-key)",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.anthropicAuthFieldHint", {
|
||||
defaultValue:
|
||||
"选择网关接收 API Key 的请求头:ANTHROPIC_AUTH_TOKEN 发送 Authorization: Bearer;ANTHROPIC_API_KEY 发送 x-api-key。两者只发其一。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAnthropicFormat && (
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border-default pt-3">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>
|
||||
{t("codexConfig.impersonateClaudeCodeLabel", {
|
||||
defaultValue: "模拟 Claude Code 客户端",
|
||||
})}
|
||||
</FormLabel>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.impersonateClaudeCodeHint", {
|
||||
defaultValue:
|
||||
"网关或其上游限制只能通过 Claude Code 使用时开启:伪装 User-Agent、anthropic-beta、x-app 请求头,并在系统提示首行注入 Claude Code 身份。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={impersonateClaudeCode}
|
||||
onCheckedChange={onImpersonateClaudeCodeChange}
|
||||
aria-label={t("codexConfig.impersonateClaudeCodeLabel", {
|
||||
defaultValue: "模拟 Claude Code 客户端",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAnthropicFormat && (
|
||||
<div className="space-y-1.5 border-t border-border-default pt-3">
|
||||
<FormLabel htmlFor="codex-anthropic-max-output-tokens">
|
||||
{t("codexConfig.maxOutputTokensLabel", {
|
||||
defaultValue: "最大输出 tokens",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="codex-anthropic-max-output-tokens"
|
||||
type="number"
|
||||
min={1}
|
||||
inputMode="numeric"
|
||||
value={maxOutputTokens}
|
||||
onChange={(event) =>
|
||||
onMaxOutputTokensChange(
|
||||
event.target.value.replace(/[^\d]/g, ""),
|
||||
)
|
||||
}
|
||||
placeholder={t("codexConfig.maxOutputTokensPlaceholder", {
|
||||
defaultValue: "留空则使用默认 8192",
|
||||
})}
|
||||
/>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.maxOutputTokensHint", {
|
||||
defaultValue:
|
||||
"Codex 不会把 model_max_output_tokens 写进请求体,默认上限 8192 容易在长回答或深度思考时被截断(stop_reason=max_tokens)。此处设置会作为 Anthropic 的 max_tokens 覆盖请求值。请勿超过该模型/网关的真实输出上限,否则可能 400。留空使用默认 8192。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -468,6 +722,49 @@ export function CodexFormFields({
|
||||
shouldShowSpeedTest && "border-t border-border-default pt-3",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
{t("codexConfig.promptCacheRoutingLabel", {
|
||||
defaultValue: "提示词缓存路由",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={promptCacheRouting}
|
||||
onValueChange={(value) =>
|
||||
onPromptCacheRoutingChange(
|
||||
value as PromptCacheRoutingMode,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">
|
||||
{t("codexConfig.promptCacheRoutingAuto", {
|
||||
defaultValue: "自动(推荐)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="enabled">
|
||||
{t("codexConfig.promptCacheRoutingEnabled", {
|
||||
defaultValue: "开启",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="disabled">
|
||||
{t("codexConfig.promptCacheRoutingDisabled", {
|
||||
defaultValue: "关闭",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.promptCacheRoutingHint", {
|
||||
defaultValue:
|
||||
"自动模式仅对已确认兼容的上游发送 prompt_cache_key;开启可用于其他兼容网关,关闭可避免严格网关因未知字段返回 400。只使用客户端提供的稳定会话 ID。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<FormLabel>
|
||||
{t("codexConfig.reasoningGroupTitle", {
|
||||
|
||||
@@ -3,6 +3,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -23,6 +28,8 @@ import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getModelExtraFields,
|
||||
isKnownModelKey,
|
||||
OPENCODE_EXTRA_OPTION_DRAFT_PREFIX,
|
||||
OPENCODE_HEADER_DRAFT_PREFIX,
|
||||
} from "./helpers/opencodeFormUtils";
|
||||
import type { ProviderCategory, OpenCodeModel } from "@/types";
|
||||
|
||||
@@ -72,19 +79,23 @@ function ExtraOptionKeyInput({
|
||||
optionKey,
|
||||
onChange,
|
||||
placeholder,
|
||||
placeholderPrefixes = [OPENCODE_EXTRA_OPTION_DRAFT_PREFIX],
|
||||
}: {
|
||||
optionKey: string;
|
||||
onChange: (newKey: string) => void;
|
||||
onChange: (newKey: string) => boolean | void;
|
||||
placeholder?: string;
|
||||
placeholderPrefixes?: string[];
|
||||
}) {
|
||||
// For new options with placeholder keys like "option-123", show empty string
|
||||
const displayValue = optionKey.startsWith("option-") ? "" : optionKey;
|
||||
const isPlaceholderKey = placeholderPrefixes.some((prefix) =>
|
||||
optionKey.startsWith(prefix),
|
||||
);
|
||||
const displayValue = isPlaceholderKey ? "" : optionKey;
|
||||
const [localValue, setLocalValue] = useState(displayValue);
|
||||
|
||||
// Sync when external key changes
|
||||
useEffect(() => {
|
||||
setLocalValue(optionKey.startsWith("option-") ? "" : optionKey);
|
||||
}, [optionKey]);
|
||||
setLocalValue(isPlaceholderKey ? "" : optionKey);
|
||||
}, [isPlaceholderKey, optionKey]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
@@ -93,7 +104,10 @@ function ExtraOptionKeyInput({
|
||||
onBlur={() => {
|
||||
const trimmed = localValue.trim();
|
||||
if (trimmed && trimmed !== optionKey) {
|
||||
onChange(trimmed);
|
||||
const accepted = onChange(trimmed);
|
||||
if (accepted === false) {
|
||||
setLocalValue(displayValue);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
@@ -160,6 +174,10 @@ interface OpenCodeFormFieldsProps {
|
||||
baseUrl: string;
|
||||
onBaseUrlChange: (value: string) => void;
|
||||
|
||||
// Headers
|
||||
headers: Record<string, string>;
|
||||
onHeadersChange: (headers: Record<string, string>) => void;
|
||||
|
||||
// Models
|
||||
models: Record<string, OpenCodeModel>;
|
||||
onModelsChange: (models: Record<string, OpenCodeModel>) => void;
|
||||
@@ -181,6 +199,8 @@ export function OpenCodeFormFields({
|
||||
partnerPromotionKey,
|
||||
baseUrl,
|
||||
onBaseUrlChange,
|
||||
headers,
|
||||
onHeadersChange,
|
||||
models,
|
||||
onModelsChange,
|
||||
extraOptions,
|
||||
@@ -190,6 +210,15 @@ export function OpenCodeFormFields({
|
||||
|
||||
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
|
||||
const [isFetchingModels, setIsFetchingModels] = useState(false);
|
||||
const [extraOptionsOpen, setExtraOptionsOpen] = useState(
|
||||
() => Object.keys(extraOptions).length > 0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(extraOptions).length > 0) {
|
||||
setExtraOptionsOpen(true);
|
||||
}
|
||||
}, [extraOptions]);
|
||||
|
||||
const handleFetchModels = useCallback(() => {
|
||||
if (!baseUrl || !apiKey) {
|
||||
@@ -284,6 +313,77 @@ export function OpenCodeFormFields({
|
||||
});
|
||||
};
|
||||
|
||||
const handleModelLimitChange = (
|
||||
modelKey: string,
|
||||
limitKey: "context" | "output",
|
||||
value: string,
|
||||
) => {
|
||||
const model = models[modelKey];
|
||||
const nextLimit = { ...(model.limit || {}) };
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue === "") {
|
||||
delete nextLimit[limitKey];
|
||||
} else {
|
||||
const parsed = Number(trimmedValue);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return;
|
||||
nextLimit[limitKey] = Math.trunc(parsed);
|
||||
}
|
||||
|
||||
const nextModel = { ...model };
|
||||
if (Object.keys(nextLimit).length > 0) {
|
||||
nextModel.limit = nextLimit;
|
||||
} else {
|
||||
delete nextModel.limit;
|
||||
}
|
||||
|
||||
onModelsChange({
|
||||
...models,
|
||||
[modelKey]: nextModel,
|
||||
});
|
||||
};
|
||||
|
||||
// Header handlers
|
||||
const handleAddHeader = () => {
|
||||
const newKey = `${OPENCODE_HEADER_DRAFT_PREFIX}${Date.now()}`;
|
||||
onHeadersChange({
|
||||
...headers,
|
||||
[newKey]: "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveHeader = (key: string) => {
|
||||
const newHeaders = { ...headers };
|
||||
delete newHeaders[key];
|
||||
onHeadersChange(newHeaders);
|
||||
};
|
||||
|
||||
const handleHeaderKeyChange = (oldKey: string, newKey: string): boolean => {
|
||||
const trimmedKey = newKey.trim();
|
||||
if (!trimmedKey || oldKey === trimmedKey) return false;
|
||||
|
||||
const normalizedKey = trimmedKey.toLowerCase();
|
||||
const hasDuplicate = Object.keys(headers).some(
|
||||
(key) => key !== oldKey && key.toLowerCase() === normalizedKey,
|
||||
);
|
||||
if (hasDuplicate) return false;
|
||||
|
||||
const newHeaders: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key === oldKey) newHeaders[trimmedKey] = value;
|
||||
else newHeaders[key] = value;
|
||||
}
|
||||
onHeadersChange(newHeaders);
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleHeaderValueChange = (key: string, value: string) => {
|
||||
onHeadersChange({
|
||||
...headers,
|
||||
[key]: value,
|
||||
});
|
||||
};
|
||||
|
||||
// Model options handlers
|
||||
const handleAddModelOption = (modelKey: string) => {
|
||||
const model = models[modelKey];
|
||||
@@ -410,7 +510,7 @@ export function OpenCodeFormFields({
|
||||
|
||||
// Extra Options handlers
|
||||
const handleAddExtraOption = () => {
|
||||
const newKey = `option-${Date.now()}`;
|
||||
const newKey = `${OPENCODE_EXTRA_OPTION_DRAFT_PREFIX}${Date.now()}`;
|
||||
onExtraOptionsChange({
|
||||
...extraOptions,
|
||||
[newKey]: "",
|
||||
@@ -506,82 +606,193 @@ export function OpenCodeFormFields({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Extra Options Editor */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>
|
||||
{t("opencode.extraOptions", { defaultValue: "额外选项" })}
|
||||
</FormLabel>
|
||||
{/* Headers Editor */}
|
||||
<div className="space-y-2 border-l border-border-default pl-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="max-w-3xl space-y-1">
|
||||
<FormLabel>
|
||||
{t("opencode.headers", { defaultValue: "Headers" })}
|
||||
</FormLabel>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("opencode.headersHint", {
|
||||
defaultValue:
|
||||
"Optional HTTP headers sent with provider requests, such as HTTP-Referer or X-Title.",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddExtraOption}
|
||||
onClick={handleAddHeader}
|
||||
aria-label={t("opencode.addHeader", { defaultValue: "Add header" })}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("opencode.addExtraOption", { defaultValue: "添加" })}
|
||||
{t("opencode.addHeader", { defaultValue: "Add" })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{Object.keys(extraOptions).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
{t("opencode.noExtraOptions", {
|
||||
defaultValue: "暂无额外选项",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1 mb-1">
|
||||
<span className="flex-1">
|
||||
{t("opencode.extraOptionKey", { defaultValue: "键名" })}
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t("opencode.extraOptionValue", { defaultValue: "值" })}
|
||||
</span>
|
||||
<span className="w-9" />
|
||||
</div>
|
||||
{Object.entries(extraOptions).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<ExtraOptionKeyInput
|
||||
optionKey={key}
|
||||
onChange={(newKey) => handleExtraOptionKeyChange(key, newKey)}
|
||||
placeholder={t("opencode.extraOptionKeyPlaceholder", {
|
||||
defaultValue: "timeout",
|
||||
})}
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
handleExtraOptionValueChange(key, e.target.value)
|
||||
}
|
||||
placeholder={t("opencode.extraOptionValuePlaceholder", {
|
||||
defaultValue: "600000",
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveExtraOption(key)}
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="max-w-3xl">
|
||||
{Object.keys(headers).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-1">
|
||||
{t("opencode.noHeaders", {
|
||||
defaultValue: "No custom headers configured",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1 mb-1">
|
||||
<span className="flex-1">
|
||||
{t("opencode.headerName", { defaultValue: "Header" })}
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t("opencode.headerValue", { defaultValue: "Value" })}
|
||||
</span>
|
||||
<span className="w-9" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("opencode.extraOptionsHint", {
|
||||
defaultValue:
|
||||
"配置额外的 SDK 选项,如 timeout、setCacheKey 等。值会自动解析类型(数字、布尔值等)。",
|
||||
})}
|
||||
</p>
|
||||
{Object.entries(headers).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<ExtraOptionKeyInput
|
||||
optionKey={key}
|
||||
onChange={(newKey) => handleHeaderKeyChange(key, newKey)}
|
||||
placeholder={t("opencode.headerNamePlaceholder", {
|
||||
defaultValue: "X-Title",
|
||||
})}
|
||||
placeholderPrefixes={[OPENCODE_HEADER_DRAFT_PREFIX]}
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
handleHeaderValueChange(key, e.target.value)
|
||||
}
|
||||
placeholder={t("opencode.headerValuePlaceholder", {
|
||||
defaultValue: "CC Switch",
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveHeader(key)}
|
||||
aria-label={t("opencode.removeHeader", {
|
||||
defaultValue: "Remove header",
|
||||
})}
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extra Options Editor */}
|
||||
<Collapsible
|
||||
open={extraOptionsOpen}
|
||||
onOpenChange={setExtraOptionsOpen}
|
||||
className="space-y-2 border-l border-border-default pl-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 max-w-3xl flex-1 items-start gap-2 text-left"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
extraOptionsOpen && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
<span className="space-y-1">
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
{t("opencode.extraOptions", {
|
||||
defaultValue: "Extra SDK Options",
|
||||
})}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("opencode.extraOptionsHint", {
|
||||
defaultValue:
|
||||
"Advanced SDK options not exposed by the structured fields.",
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setExtraOptionsOpen(true);
|
||||
handleAddExtraOption();
|
||||
}}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("opencode.addExtraOption", { defaultValue: "Add" })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent className="max-w-3xl space-y-2">
|
||||
{Object.keys(extraOptions).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-1">
|
||||
{t("opencode.noExtraOptions", {
|
||||
defaultValue: "No extra SDK options configured",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1 mb-1">
|
||||
<span className="flex-1">
|
||||
{t("opencode.extraOptionKey", { defaultValue: "Key" })}
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t("opencode.extraOptionValue", { defaultValue: "Value" })}
|
||||
</span>
|
||||
<span className="w-9" />
|
||||
</div>
|
||||
{Object.entries(extraOptions).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<ExtraOptionKeyInput
|
||||
optionKey={key}
|
||||
onChange={(newKey) =>
|
||||
handleExtraOptionKeyChange(key, newKey)
|
||||
}
|
||||
placeholder={t("opencode.extraOptionKeyPlaceholder", {
|
||||
defaultValue: "timeout",
|
||||
})}
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
handleExtraOptionValueChange(key, e.target.value)
|
||||
}
|
||||
placeholder={t("opencode.extraOptionValuePlaceholder", {
|
||||
defaultValue: "600000",
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveExtraOption(key)}
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Models Editor */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -644,6 +855,9 @@ export function OpenCodeFormFields({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => toggleModelExpand(key)}
|
||||
aria-label={t("opencode.toggleModelDetails", {
|
||||
defaultValue: "Toggle model details",
|
||||
})}
|
||||
className="h-9 w-9 shrink-0"
|
||||
>
|
||||
<ChevronRight
|
||||
@@ -690,6 +904,67 @@ export function OpenCodeFormFields({
|
||||
{/* Expanded model details */}
|
||||
{expandedModels.has(key) && (
|
||||
<div className="ml-9 pl-4 border-l-2 border-muted space-y-3">
|
||||
{/* Token limits (model.limit) */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("opencode.modelLimits", {
|
||||
defaultValue: "Token Limits",
|
||||
})}
|
||||
</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<FormLabel
|
||||
htmlFor={`opencode-${key}-limit-context`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{t("opencode.limitContext", {
|
||||
defaultValue: "Context",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id={`opencode-${key}-limit-context`}
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={model.limit?.context ?? ""}
|
||||
onChange={(e) =>
|
||||
handleModelLimitChange(
|
||||
key,
|
||||
"context",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="1048576"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<FormLabel
|
||||
htmlFor={`opencode-${key}-limit-output`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{t("opencode.limitOutput", {
|
||||
defaultValue: "Output",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id={`opencode-${key}-limit-output`}
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={model.limit?.output ?? ""}
|
||||
onChange={(e) =>
|
||||
handleModelLimitChange(
|
||||
key,
|
||||
"output",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="131072"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Properties (extra fields like variants, cost) */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ChevronDown, ChevronRight, FlaskConical, Coins } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Coins } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProviderTestConfig } from "@/types";
|
||||
|
||||
export type PricingModelSourceOption = "inherit" | "request" | "response";
|
||||
|
||||
interface ProviderPricingConfig {
|
||||
@@ -23,170 +21,25 @@ interface ProviderPricingConfig {
|
||||
}
|
||||
|
||||
interface ProviderAdvancedConfigProps {
|
||||
testConfig: ProviderTestConfig;
|
||||
pricingConfig: ProviderPricingConfig;
|
||||
onTestConfigChange: (config: ProviderTestConfig) => void;
|
||||
onPricingConfigChange: (config: ProviderPricingConfig) => void;
|
||||
}
|
||||
|
||||
export function ProviderAdvancedConfig({
|
||||
testConfig,
|
||||
pricingConfig,
|
||||
onTestConfigChange,
|
||||
onPricingConfigChange,
|
||||
}: ProviderAdvancedConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
|
||||
const [isPricingConfigOpen, setIsPricingConfigOpen] = useState(
|
||||
pricingConfig.enabled,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsTestConfigOpen(testConfig.enabled);
|
||||
}, [testConfig.enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsPricingConfigOpen(pricingConfig.enabled);
|
||||
}, [pricingConfig.enabled]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border/50 bg-muted/20">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between p-4 hover:bg-muted/30 transition-colors"
|
||||
onClick={() => setIsTestConfigOpen(!isTestConfigOpen)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FlaskConical className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">
|
||||
{t("providerAdvanced.testConfig", {
|
||||
defaultValue: "连通检测配置",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Label
|
||||
htmlFor="test-config-enabled"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t("providerAdvanced.useCustomConfig", {
|
||||
defaultValue: "使用单独配置",
|
||||
})}
|
||||
</Label>
|
||||
<Switch
|
||||
id="test-config-enabled"
|
||||
checked={testConfig.enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
onTestConfigChange({ ...testConfig, enabled: checked });
|
||||
if (checked) setIsTestConfigOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isTestConfigOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
isTestConfigOpen
|
||||
? "max-h-[500px] opacity-100"
|
||||
: "max-h-0 opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="border-t border-border/50 p-4 space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("providerAdvanced.testConfigDesc", {
|
||||
defaultValue:
|
||||
"为此供应商配置单独的连通检测参数(超时/阈值/重试),不启用时使用全局配置。",
|
||||
})}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="test-timeout">
|
||||
{t("providerAdvanced.timeoutSecs", {
|
||||
defaultValue: "超时时间(秒)",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="test-timeout"
|
||||
type="number"
|
||||
min={1}
|
||||
max={60}
|
||||
value={testConfig.timeoutSecs || ""}
|
||||
onChange={(e) =>
|
||||
onTestConfigChange({
|
||||
...testConfig,
|
||||
timeoutSecs: e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="8"
|
||||
disabled={!testConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="degraded-threshold">
|
||||
{t("providerAdvanced.degradedThreshold", {
|
||||
defaultValue: "降级阈值(毫秒)",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="degraded-threshold"
|
||||
type="number"
|
||||
min={100}
|
||||
max={60000}
|
||||
value={testConfig.degradedThresholdMs || ""}
|
||||
onChange={(e) =>
|
||||
onTestConfigChange({
|
||||
...testConfig,
|
||||
degradedThresholdMs: e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="6000"
|
||||
disabled={!testConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-retries">
|
||||
{t("providerAdvanced.maxRetries", {
|
||||
defaultValue: "最大重试次数",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="max-retries"
|
||||
type="number"
|
||||
min={0}
|
||||
max={5}
|
||||
value={testConfig.maxRetries ?? ""}
|
||||
onChange={(e) =>
|
||||
onTestConfigChange({
|
||||
...testConfig,
|
||||
maxRetries: e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="1"
|
||||
disabled={!testConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 计费配置 */}
|
||||
<div className="rounded-lg border border-border/50 bg-muted/20">
|
||||
<button
|
||||
|
||||
@@ -17,11 +17,11 @@ import { useDarkMode } from "@/hooks/useDarkMode";
|
||||
import type {
|
||||
ProviderCategory,
|
||||
ProviderMeta,
|
||||
ProviderTestConfig,
|
||||
ClaudeApiFormat,
|
||||
CodexApiFormat,
|
||||
CodexCatalogModel,
|
||||
CodexChatReasoning,
|
||||
PromptCacheRoutingMode,
|
||||
ClaudeApiKeyField,
|
||||
} from "@/types";
|
||||
import {
|
||||
@@ -60,8 +60,10 @@ import {
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
||||
import {
|
||||
codexApiFormatFromWireApi,
|
||||
extractCodexWireApi,
|
||||
setCodexWireApi,
|
||||
extractCodexModelName,
|
||||
setCodexModelName as setCodexModelNameInConfig,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { isNonNegativeDecimalString } from "@/types/usage";
|
||||
@@ -131,25 +133,6 @@ type PresetEntry = {
|
||||
| HermesProviderPreset;
|
||||
};
|
||||
|
||||
const codexApiFormatFromWireApi = (
|
||||
wireApi: string | undefined,
|
||||
): CodexApiFormat | undefined => {
|
||||
switch (wireApi?.trim().toLowerCase()) {
|
||||
case "chat":
|
||||
case "chat_completions":
|
||||
case "chat-completions":
|
||||
case "openai_chat":
|
||||
case "openai-chat":
|
||||
return "openai_chat";
|
||||
case "responses":
|
||||
case "openai_responses":
|
||||
case "openai-responses":
|
||||
return "openai_responses";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeCodexCatalogModelsForSave = (
|
||||
models: CodexCatalogModel[],
|
||||
): CodexCatalogModel[] => {
|
||||
@@ -330,9 +313,6 @@ function ProviderFormFull({
|
||||
return initialData?.meta?.isFullUrl ?? false;
|
||||
});
|
||||
|
||||
const [testConfig, setTestConfig] = useState<ProviderTestConfig>(
|
||||
() => initialData?.meta?.testConfig ?? { enabled: false },
|
||||
);
|
||||
const [pricingConfig, setPricingConfig] = useState<{
|
||||
enabled: boolean;
|
||||
costMultiplier?: string;
|
||||
@@ -368,7 +348,6 @@ function ProviderFormFull({
|
||||
setLocalIsFullUrl(
|
||||
supportsFullUrl ? (initialData?.meta?.isFullUrl ?? false) : false,
|
||||
);
|
||||
setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });
|
||||
setPricingConfig({
|
||||
enabled:
|
||||
initialData?.meta?.costMultiplier !== undefined ||
|
||||
@@ -379,6 +358,7 @@ function ProviderFormFull({
|
||||
),
|
||||
});
|
||||
setCodexChatReasoning(initialData?.meta?.codexChatReasoning ?? {});
|
||||
setPromptCacheRouting(initialData?.meta?.promptCacheRouting ?? "auto");
|
||||
setCustomUserAgent(initialData?.meta?.customUserAgent ?? "");
|
||||
setLocalProxyHeadersOverride(
|
||||
formatRequestOverrideObject(
|
||||
@@ -489,6 +469,7 @@ function ProviderFormFull({
|
||||
defaultOpusModelName,
|
||||
defaultFableModel,
|
||||
defaultFableModelName,
|
||||
subagentModel,
|
||||
handleModelChange,
|
||||
} = useModelState({
|
||||
settingsConfig: form.getValues("settingsConfig"),
|
||||
@@ -550,6 +531,10 @@ function ProviderFormFull({
|
||||
useState<CodexChatReasoning>(
|
||||
() => initialData?.meta?.codexChatReasoning ?? {},
|
||||
);
|
||||
const [promptCacheRouting, setPromptCacheRouting] =
|
||||
useState<PromptCacheRoutingMode>(
|
||||
() => initialData?.meta?.promptCacheRouting ?? "auto",
|
||||
);
|
||||
const [customUserAgent, setCustomUserAgent] = useState<string>(
|
||||
() => initialData?.meta?.customUserAgent ?? "",
|
||||
);
|
||||
@@ -571,6 +556,7 @@ function ProviderFormFull({
|
||||
codexConfig,
|
||||
codexApiKey,
|
||||
codexBaseUrl,
|
||||
codexModel,
|
||||
codexCatalogModels,
|
||||
codexAuthError,
|
||||
setCodexAuth,
|
||||
@@ -578,6 +564,7 @@ function ProviderFormFull({
|
||||
setCodexCatalogModels,
|
||||
handleCodexApiKeyChange,
|
||||
handleCodexBaseUrlChange,
|
||||
handleCodexModelChange,
|
||||
handleCodexConfigChange: originalHandleCodexConfigChange,
|
||||
resetCodexConfig,
|
||||
} = useCodexConfigState({ initialData });
|
||||
@@ -585,19 +572,43 @@ function ProviderFormFull({
|
||||
const initialCodexApiFormat: CodexApiFormat =
|
||||
initialData?.meta?.apiFormat === "openai_chat"
|
||||
? "openai_chat"
|
||||
: initialData?.meta?.apiFormat === "openai_responses"
|
||||
? "openai_responses"
|
||||
: (codexApiFormatFromWireApi(
|
||||
extractCodexWireApi(
|
||||
typeof initialData?.settingsConfig?.config === "string"
|
||||
? initialData.settingsConfig.config
|
||||
: "",
|
||||
),
|
||||
) ?? "openai_responses");
|
||||
: initialData?.meta?.apiFormat === "anthropic"
|
||||
? "anthropic"
|
||||
: initialData?.meta?.apiFormat === "openai_responses"
|
||||
? "openai_responses"
|
||||
: (codexApiFormatFromWireApi(
|
||||
extractCodexWireApi(
|
||||
typeof initialData?.settingsConfig?.config === "string"
|
||||
? initialData.settingsConfig.config
|
||||
: "",
|
||||
),
|
||||
) ?? "openai_responses");
|
||||
|
||||
const [localCodexApiFormat, setLocalCodexApiFormat] =
|
||||
useState<CodexApiFormat>(initialCodexApiFormat);
|
||||
|
||||
// Auth-field choice for the Anthropic Messages upstream (defaults to the Bearer form)
|
||||
const initialCodexAnthropicAuthField: ClaudeApiKeyField =
|
||||
initialData?.meta?.apiKeyField === "ANTHROPIC_API_KEY"
|
||||
? "ANTHROPIC_API_KEY"
|
||||
: "ANTHROPIC_AUTH_TOKEN";
|
||||
const [localCodexAnthropicAuthField, setLocalCodexAnthropicAuthField] =
|
||||
useState<ClaudeApiKeyField>(initialCodexAnthropicAuthField);
|
||||
|
||||
// Emulate the Claude Code client: off by default, enabled only when the user explicitly turns it on (true)
|
||||
const [localCodexImpersonateClaudeCode, setLocalCodexImpersonateClaudeCode] =
|
||||
useState<boolean>(initialData?.meta?.impersonateClaudeCode === true);
|
||||
|
||||
// Codex → Anthropic output ceiling override (empty string = use the 8192 default).
|
||||
// Kept as a string so the numeric input can be cleared; parsed on save.
|
||||
const [localCodexMaxOutputTokens, setLocalCodexMaxOutputTokens] =
|
||||
useState<string>(
|
||||
typeof initialData?.meta?.maxOutputTokens === "number" &&
|
||||
initialData.meta.maxOutputTokens > 0
|
||||
? String(initialData.meta.maxOutputTokens)
|
||||
: "",
|
||||
);
|
||||
|
||||
const { configError: codexConfigError, debouncedValidate } =
|
||||
useCodexTomlValidation();
|
||||
|
||||
@@ -627,6 +638,7 @@ function ProviderFormFull({
|
||||
const template = getCodexCustomTemplate();
|
||||
resetCodexConfig(template.auth, template.config);
|
||||
setCodexChatReasoning({});
|
||||
setPromptCacheRouting("auto");
|
||||
}
|
||||
}, [appId, initialData, selectedPresetId, resetCodexConfig]);
|
||||
|
||||
@@ -1279,8 +1291,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,
|
||||
@@ -1475,6 +1492,13 @@ function ProviderFormFull({
|
||||
localCodexApiFormat === "openai_chat"
|
||||
? normalizeCodexChatReasoningForSave(codexChatReasoning)
|
||||
: undefined,
|
||||
promptCacheRouting:
|
||||
appId === "codex" &&
|
||||
category !== "official" &&
|
||||
localCodexApiFormat === "openai_chat" &&
|
||||
promptCacheRouting !== "auto"
|
||||
? promptCacheRouting
|
||||
: undefined,
|
||||
customUserAgent:
|
||||
(appId === "claude" || appId === "codex") && category !== "official"
|
||||
? customUserAgent.trim() || undefined
|
||||
@@ -1482,7 +1506,6 @@ function ProviderFormFull({
|
||||
localProxyRequestOverrides: shouldApplyLocalProxyRequestOverrides
|
||||
? overridesResult.overrides
|
||||
: undefined,
|
||||
testConfig: testConfig.enabled ? testConfig : undefined,
|
||||
costMultiplier: pricingConfig.enabled
|
||||
? pricingConfig.costMultiplier
|
||||
: undefined,
|
||||
@@ -1501,6 +1524,28 @@ function ProviderFormFull({
|
||||
category !== "official" &&
|
||||
localApiKeyField !== "ANTHROPIC_AUTH_TOKEN"
|
||||
? localApiKeyField
|
||||
: appId === "codex" &&
|
||||
category !== "official" &&
|
||||
localCodexApiFormat === "anthropic" &&
|
||||
localCodexAnthropicAuthField !== "ANTHROPIC_AUTH_TOKEN"
|
||||
? localCodexAnthropicAuthField
|
||||
: undefined,
|
||||
// Off by default; persist true only for codex+anthropic when the user explicitly enables it
|
||||
impersonateClaudeCode:
|
||||
appId === "codex" &&
|
||||
category !== "official" &&
|
||||
localCodexApiFormat === "anthropic" &&
|
||||
localCodexImpersonateClaudeCode
|
||||
? true
|
||||
: undefined,
|
||||
// Persist only for codex+anthropic when a positive value was entered
|
||||
maxOutputTokens:
|
||||
appId === "codex" &&
|
||||
category !== "official" &&
|
||||
localCodexApiFormat === "anthropic" &&
|
||||
localCodexMaxOutputTokens.trim() !== "" &&
|
||||
Number(localCodexMaxOutputTokens) > 0
|
||||
? Number(localCodexMaxOutputTokens)
|
||||
: undefined,
|
||||
isFullUrl:
|
||||
supportsFullUrl && category !== "official" && localIsFullUrl
|
||||
@@ -1620,6 +1665,7 @@ function ProviderFormFull({
|
||||
const template = getCodexCustomTemplate();
|
||||
resetCodexConfig(template.auth, template.config);
|
||||
setCodexChatReasoning({});
|
||||
setPromptCacheRouting("auto");
|
||||
setLocalCodexApiFormat(
|
||||
codexApiFormatFromWireApi(extractCodexWireApi(template.config)) ??
|
||||
"openai_responses",
|
||||
@@ -1661,6 +1707,7 @@ function ProviderFormFull({
|
||||
|
||||
resetCodexConfig(auth, config, preset.modelCatalog ?? []);
|
||||
setCodexChatReasoning(preset.codexChatReasoning ?? {});
|
||||
setPromptCacheRouting(preset.promptCacheRouting ?? "auto");
|
||||
setLocalCodexApiFormat(
|
||||
preset.apiFormat ??
|
||||
codexApiFormatFromWireApi(extractCodexWireApi(config)) ??
|
||||
@@ -2099,6 +2146,7 @@ function ProviderFormFull({
|
||||
defaultOpusModelName={defaultOpusModelName}
|
||||
defaultFableModel={defaultFableModel}
|
||||
defaultFableModelName={defaultFableModelName}
|
||||
subagentModel={subagentModel}
|
||||
onModelChange={handleModelChange}
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
apiFormat={localApiFormat}
|
||||
@@ -2138,10 +2186,20 @@ function ProviderFormFull({
|
||||
}
|
||||
autoSelect={endpointAutoSelect}
|
||||
onAutoSelectChange={setEndpointAutoSelect}
|
||||
codexModel={codexModel}
|
||||
onModelChange={handleCodexModelChange}
|
||||
apiFormat={localCodexApiFormat}
|
||||
onApiFormatChange={handleCodexApiFormatChange}
|
||||
anthropicAuthField={localCodexAnthropicAuthField}
|
||||
onAnthropicAuthFieldChange={setLocalCodexAnthropicAuthField}
|
||||
impersonateClaudeCode={localCodexImpersonateClaudeCode}
|
||||
onImpersonateClaudeCodeChange={setLocalCodexImpersonateClaudeCode}
|
||||
maxOutputTokens={localCodexMaxOutputTokens}
|
||||
onMaxOutputTokensChange={setLocalCodexMaxOutputTokens}
|
||||
codexChatReasoning={codexChatReasoning}
|
||||
onCodexChatReasoningChange={setCodexChatReasoning}
|
||||
promptCacheRouting={promptCacheRouting}
|
||||
onPromptCacheRoutingChange={setPromptCacheRouting}
|
||||
catalogModels={codexCatalogModels}
|
||||
onCatalogModelsChange={setCodexCatalogModels}
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
@@ -2196,6 +2254,8 @@ function ProviderFormFull({
|
||||
partnerPromotionKey={opencodePartnerPromotionKey}
|
||||
baseUrl={opencodeForm.opencodeBaseUrl}
|
||||
onBaseUrlChange={opencodeForm.handleOpencodeBaseUrlChange}
|
||||
headers={opencodeForm.opencodeHeaders}
|
||||
onHeadersChange={opencodeForm.handleOpencodeHeadersChange}
|
||||
models={opencodeForm.opencodeModels}
|
||||
onModelsChange={opencodeForm.handleOpencodeModelsChange}
|
||||
extraOptions={opencodeForm.opencodeExtraOptions}
|
||||
@@ -2421,9 +2481,7 @@ function ProviderFormFull({
|
||||
appId !== "openclaw" &&
|
||||
appId !== "hermes" && (
|
||||
<ProviderAdvancedConfig
|
||||
testConfig={testConfig}
|
||||
pricingConfig={pricingConfig}
|
||||
onTestConfigChange={setTestConfig}
|
||||
onPricingConfigChange={setPricingConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -51,6 +51,11 @@ export const OPENCODE_KNOWN_OPTION_KEYS = [
|
||||
"headers",
|
||||
] as const;
|
||||
|
||||
// Contains ":", which is not valid in an HTTP field name, so it cannot
|
||||
// collide with a legitimate custom header from an existing configuration.
|
||||
export const OPENCODE_HEADER_DRAFT_PREFIX = "draft-header:";
|
||||
export const OPENCODE_EXTRA_OPTION_DRAFT_PREFIX = "draft-option:";
|
||||
|
||||
export const OPENCLAW_DEFAULT_CONFIG = JSON.stringify(
|
||||
{
|
||||
baseUrl: "",
|
||||
|
||||
@@ -60,15 +60,12 @@ export function useApiKeyState({
|
||||
initialConfig || "{}",
|
||||
key.trim(),
|
||||
{
|
||||
// 最佳实践:仅在"新增模式"且"非官方类别"时补齐缺失字段
|
||||
// - 新增模式:selectedPresetId !== null
|
||||
// - 非官方类别:category !== undefined && category !== "official"
|
||||
// 最佳实践:仅在"非官方/非云厂商类别"时补齐缺失字段
|
||||
// - 官方类别:不创建字段(UI 也会禁用输入框)
|
||||
// - 未传入 category:不创建字段(避免意外行为)
|
||||
// - 云厂商类别:通常使用专用鉴权字段,不自动创建 Anthropic key
|
||||
// - 未传入 category:按历史导入/自定义 provider 处理,允许补齐
|
||||
createIfMissing:
|
||||
selectedPresetId !== null &&
|
||||
category !== undefined &&
|
||||
category !== "official",
|
||||
category !== "official" && category !== "cloud_provider",
|
||||
appType,
|
||||
apiKeyField,
|
||||
},
|
||||
@@ -90,10 +87,13 @@ export function useApiKeyState({
|
||||
(config: string, isEditMode: boolean) => {
|
||||
return (
|
||||
selectedPresetId !== null ||
|
||||
(isEditMode &&
|
||||
category !== "official" &&
|
||||
category !== "cloud_provider") ||
|
||||
(isEditMode && hasApiKeyField(config, appType))
|
||||
);
|
||||
},
|
||||
[selectedPresetId, appType],
|
||||
[selectedPresetId, category, appType],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
import {
|
||||
updateTomlCommonConfigSnippet,
|
||||
hasTomlCommonConfigSnippet,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { hasTomlCommonConfigSnippet } from "@/utils/providerConfigUtils";
|
||||
import { configApi } from "@/lib/api";
|
||||
import { normalizeTomlText } from "@/utils/textNormalization";
|
||||
|
||||
/**
|
||||
* 合并/剥离通用配置片段(走后端 toml_edit,保注释、保键序)。
|
||||
* 失败时返回原文本 + error,调用方据此决定是否回退 UI 状态。
|
||||
*/
|
||||
const applyTomlSnippet = async (
|
||||
configToml: string,
|
||||
snippetToml: string,
|
||||
enabled: boolean,
|
||||
): Promise<{ updatedConfig: string; error?: string }> => {
|
||||
try {
|
||||
const updatedConfig = await configApi.updateTomlCommonConfigSnippet(
|
||||
configToml,
|
||||
snippetToml,
|
||||
enabled,
|
||||
);
|
||||
return { updatedConfig };
|
||||
} catch (e) {
|
||||
return { updatedConfig: configToml, error: String(e) };
|
||||
}
|
||||
};
|
||||
|
||||
const LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
|
||||
const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config
|
||||
# Add your common TOML configuration here`;
|
||||
@@ -48,6 +66,26 @@ export function useCodexCommonConfig({
|
||||
const hasInitializedNewMode = useRef(false);
|
||||
// 用于跟踪编辑模式是否已初始化显式开关/预览
|
||||
const hasInitializedEditMode = useRef(false);
|
||||
// 后端 TOML 合并是异步的:连续操作(快速点开关、连点保存)可能乱序
|
||||
// 返回。每个写 config 的异步操作在发起时领取递增序号,结果落地前
|
||||
// 校验自己仍是最新一次;过期结果直接丢弃,保证最后一次操作胜出。
|
||||
const tomlOpSeqRef = useRef(0);
|
||||
// 镜像最新的 codexConfig:用户在编辑器手动改动走父组件 onChange,
|
||||
// 不经过本 hook(序号不变)。异步结果落地前还要校验发起时的 config
|
||||
// 基线未被外部改写,否则会覆盖用户在请求在飞期间的手动编辑。
|
||||
const latestCodexConfigRef = useRef(codexConfig);
|
||||
useEffect(() => {
|
||||
latestCodexConfigRef.current = codexConfig;
|
||||
}, [codexConfig]);
|
||||
|
||||
// 结果落地前的过期判定:hook 内有更新的操作(序号变了),或发起时的
|
||||
// config 基线已被外部改写(用户手动编辑为准)。任一成立即丢弃结果。
|
||||
const isTomlOpStale = useCallback(
|
||||
(seq: number, baseConfig: string) =>
|
||||
seq !== tomlOpSeqRef.current ||
|
||||
baseConfig !== latestCodexConfigRef.current,
|
||||
[],
|
||||
);
|
||||
|
||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
||||
useEffect(() => {
|
||||
@@ -168,25 +206,34 @@ export function useCodexCommonConfig({
|
||||
|
||||
// 如果应该启用通用配置但配置中还没有,则自动添加
|
||||
if (hasCommon && !inferredHasCommon && parsedSnippet.hasContent) {
|
||||
const { updatedConfig, error } = updateTomlCommonConfigSnippet(
|
||||
codexConfig,
|
||||
commonConfigSnippet,
|
||||
true,
|
||||
);
|
||||
if (error) {
|
||||
setCommonConfigError(error);
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const seq = ++tomlOpSeqRef.current;
|
||||
(async () => {
|
||||
const { updatedConfig, error } = await applyTomlSnippet(
|
||||
codexConfig,
|
||||
commonConfigSnippet,
|
||||
true,
|
||||
);
|
||||
if (cancelled || isTomlOpStale(seq, codexConfig)) {
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
setCommonConfigError(error);
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setCommonConfigError("");
|
||||
setUseCommonConfig(true);
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onConfigChange(updatedConfig);
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
return;
|
||||
setCommonConfigError("");
|
||||
setUseCommonConfig(true);
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onConfigChange(updatedConfig);
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
setCommonConfigError("");
|
||||
@@ -197,6 +244,7 @@ export function useCodexCommonConfig({
|
||||
initialData,
|
||||
initialEnabled,
|
||||
isLoading,
|
||||
isTomlOpStale,
|
||||
onConfigChange,
|
||||
parseCommonConfigSnippet,
|
||||
]);
|
||||
@@ -221,28 +269,39 @@ export function useCodexCommonConfig({
|
||||
return;
|
||||
}
|
||||
|
||||
const { updatedConfig, error } = updateTomlCommonConfigSnippet(
|
||||
codexConfig,
|
||||
commonConfigSnippet,
|
||||
true,
|
||||
);
|
||||
if (error) {
|
||||
setCommonConfigError(error);
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const seq = ++tomlOpSeqRef.current;
|
||||
(async () => {
|
||||
const { updatedConfig, error } = await applyTomlSnippet(
|
||||
codexConfig,
|
||||
commonConfigSnippet,
|
||||
true,
|
||||
);
|
||||
if (cancelled || isTomlOpStale(seq, codexConfig)) {
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
setCommonConfigError(error);
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setCommonConfigError("");
|
||||
setUseCommonConfig(true);
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onConfigChange(updatedConfig);
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
setCommonConfigError("");
|
||||
setUseCommonConfig(true);
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onConfigChange(updatedConfig);
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
initialData,
|
||||
commonConfigSnippet,
|
||||
isLoading,
|
||||
isTomlOpStale,
|
||||
codexConfig,
|
||||
onConfigChange,
|
||||
parseCommonConfigSnippet,
|
||||
@@ -250,7 +309,10 @@ export function useCodexCommonConfig({
|
||||
|
||||
// 处理通用配置开关
|
||||
const handleCommonConfigToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
async (checked: boolean) => {
|
||||
// 在同步校验之前领号:即使本次走同步早退分支,也要让更早发出、
|
||||
// 仍在飞的异步结果作废,避免它晚到后把开关翻回去。
|
||||
const seq = ++tomlOpSeqRef.current;
|
||||
const parsedSnippet = parseCommonConfigSnippet(commonConfigSnippet);
|
||||
if (parsedSnippet.error) {
|
||||
setCommonConfigError(parsedSnippet.error);
|
||||
@@ -267,12 +329,14 @@ export function useCodexCommonConfig({
|
||||
return;
|
||||
}
|
||||
|
||||
const { updatedConfig, error: snippetError } =
|
||||
updateTomlCommonConfigSnippet(
|
||||
codexConfig,
|
||||
commonConfigSnippet,
|
||||
checked,
|
||||
);
|
||||
const { updatedConfig, error: snippetError } = await applyTomlSnippet(
|
||||
codexConfig,
|
||||
commonConfigSnippet,
|
||||
checked,
|
||||
);
|
||||
if (isTomlOpStale(seq, codexConfig)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (snippetError) {
|
||||
setCommonConfigError(snippetError);
|
||||
@@ -293,6 +357,7 @@ export function useCodexCommonConfig({
|
||||
[
|
||||
codexConfig,
|
||||
commonConfigSnippet,
|
||||
isTomlOpStale,
|
||||
onConfigChange,
|
||||
parseCommonConfigSnippet,
|
||||
t,
|
||||
@@ -301,7 +366,10 @@ export function useCodexCommonConfig({
|
||||
|
||||
// 处理通用配置片段变化
|
||||
const handleCommonConfigSnippetChange = useCallback(
|
||||
(value: string): boolean => {
|
||||
async (value: string): Promise<boolean> => {
|
||||
// 与 handleCommonConfigToggle 同一套序号:连续保存或保存与开关
|
||||
// 交错时,只允许最后一次操作的结果落地。
|
||||
const seq = ++tomlOpSeqRef.current;
|
||||
const previousSnippet = commonConfigSnippet;
|
||||
|
||||
if (!value.trim()) {
|
||||
@@ -312,11 +380,14 @@ export function useCodexCommonConfig({
|
||||
let updatedConfig = codexConfig;
|
||||
|
||||
if (!previousParsed.error && previousParsed.hasContent) {
|
||||
const removeResult = updateTomlCommonConfigSnippet(
|
||||
const removeResult = await applyTomlSnippet(
|
||||
codexConfig,
|
||||
previousSnippet,
|
||||
false,
|
||||
);
|
||||
if (isTomlOpStale(seq, codexConfig)) {
|
||||
return false;
|
||||
}
|
||||
if (removeResult.error) {
|
||||
setCommonConfigError(removeResult.error);
|
||||
return false;
|
||||
@@ -352,11 +423,14 @@ export function useCodexCommonConfig({
|
||||
const previousParsed = parseCommonConfigSnippet(previousSnippet);
|
||||
|
||||
if (!previousParsed.error && previousParsed.hasContent) {
|
||||
const removeResult = updateTomlCommonConfigSnippet(
|
||||
const removeResult = await applyTomlSnippet(
|
||||
codexConfig,
|
||||
previousSnippet,
|
||||
false,
|
||||
);
|
||||
if (isTomlOpStale(seq, codexConfig)) {
|
||||
return false;
|
||||
}
|
||||
if (removeResult.error) {
|
||||
setCommonConfigError(removeResult.error);
|
||||
return false;
|
||||
@@ -364,11 +438,11 @@ export function useCodexCommonConfig({
|
||||
nextConfig = removeResult.updatedConfig;
|
||||
}
|
||||
|
||||
const addResult = updateTomlCommonConfigSnippet(
|
||||
nextConfig,
|
||||
value,
|
||||
true,
|
||||
);
|
||||
const addResult = await applyTomlSnippet(nextConfig, value, true);
|
||||
// nextConfig 派生自发起时的 codexConfig,基线校验仍对 codexConfig 做
|
||||
if (isTomlOpStale(seq, codexConfig)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (addResult.error) {
|
||||
setCommonConfigError(addResult.error);
|
||||
@@ -400,6 +474,7 @@ export function useCodexCommonConfig({
|
||||
[
|
||||
commonConfigSnippet,
|
||||
codexConfig,
|
||||
isTomlOpStale,
|
||||
onConfigChange,
|
||||
parseCommonConfigSnippet,
|
||||
t,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,7 +14,8 @@ export type ClaudeModelEnvField =
|
||||
| "ANTHROPIC_DEFAULT_OPUS_MODEL"
|
||||
| "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME"
|
||||
| "ANTHROPIC_DEFAULT_FABLE_MODEL"
|
||||
| "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME";
|
||||
| "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME"
|
||||
| "CLAUDE_CODE_SUBAGENT_MODEL";
|
||||
|
||||
export const CLAUDE_ONE_M_MARKER = "[1M]";
|
||||
|
||||
@@ -81,6 +82,10 @@ function parseModelsFromConfig(settingsConfig: string) {
|
||||
typeof env.ANTHROPIC_DEFAULT_FABLE_MODEL_NAME === "string"
|
||||
? env.ANTHROPIC_DEFAULT_FABLE_MODEL_NAME
|
||||
: stripClaudeOneMMarker(fable);
|
||||
const subagent =
|
||||
typeof env.CLAUDE_CODE_SUBAGENT_MODEL === "string"
|
||||
? env.CLAUDE_CODE_SUBAGENT_MODEL
|
||||
: "";
|
||||
|
||||
return {
|
||||
model,
|
||||
@@ -92,6 +97,7 @@ function parseModelsFromConfig(settingsConfig: string) {
|
||||
opusName,
|
||||
fable,
|
||||
fableName,
|
||||
subagent,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
@@ -104,6 +110,7 @@ function parseModelsFromConfig(settingsConfig: string) {
|
||||
opusName: "",
|
||||
fable: "",
|
||||
fableName: "",
|
||||
subagent: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -134,6 +141,7 @@ export function useModelState({
|
||||
const [defaultFableModelName, setDefaultFableModelName] = useState(
|
||||
initial.fableName,
|
||||
);
|
||||
const [subagentModel, setSubagentModel] = useState(initial.subagent);
|
||||
|
||||
const isUserEditingRef = useRef(false);
|
||||
const lastConfigRef = useRef(settingsConfig);
|
||||
@@ -164,6 +172,7 @@ export function useModelState({
|
||||
setDefaultOpusModelName(parsed.opusName);
|
||||
setDefaultFableModel(parsed.fable);
|
||||
setDefaultFableModelName(parsed.fableName);
|
||||
setSubagentModel(parsed.subagent);
|
||||
}, [settingsConfig]);
|
||||
|
||||
const handleModelChange = useCallback(
|
||||
@@ -186,6 +195,7 @@ export function useModelState({
|
||||
setDefaultFableModel(value);
|
||||
if (field === "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME")
|
||||
setDefaultFableModelName(value);
|
||||
if (field === "CLAUDE_CODE_SUBAGENT_MODEL") setSubagentModel(value);
|
||||
|
||||
try {
|
||||
const currentConfig = latestConfigRef.current
|
||||
@@ -233,6 +243,8 @@ export function useModelState({
|
||||
setDefaultFableModel,
|
||||
defaultFableModelName,
|
||||
setDefaultFableModelName,
|
||||
subagentModel,
|
||||
setSubagentModel,
|
||||
handleModelChange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { OpenCodeModel, OpenCodeProviderConfig } from "@/types";
|
||||
import {
|
||||
OPENCODE_DEFAULT_NPM,
|
||||
OPENCODE_DEFAULT_CONFIG,
|
||||
OPENCODE_EXTRA_OPTION_DRAFT_PREFIX,
|
||||
OPENCODE_HEADER_DRAFT_PREFIX,
|
||||
isKnownOpencodeOptionKey,
|
||||
parseOpencodeConfig,
|
||||
toOpencodeExtraOptions,
|
||||
@@ -24,11 +26,13 @@ export interface OpencodeFormState {
|
||||
opencodeNpm: string;
|
||||
opencodeApiKey: string;
|
||||
opencodeBaseUrl: string;
|
||||
opencodeHeaders: Record<string, string>;
|
||||
opencodeModels: Record<string, OpenCodeModel>;
|
||||
opencodeExtraOptions: Record<string, string>;
|
||||
handleOpencodeNpmChange: (npm: string) => void;
|
||||
handleOpencodeApiKeyChange: (apiKey: string) => void;
|
||||
handleOpencodeBaseUrlChange: (baseUrl: string) => void;
|
||||
handleOpencodeHeadersChange: (headers: Record<string, string>) => void;
|
||||
handleOpencodeModelsChange: (models: Record<string, OpenCodeModel>) => void;
|
||||
handleOpencodeExtraOptionsChange: (options: Record<string, string>) => void;
|
||||
resetOpencodeState: (config?: OpenCodeProviderConfig) => void;
|
||||
@@ -69,6 +73,16 @@ export function useOpencodeFormState({
|
||||
return typeof value === "string" ? value : "";
|
||||
});
|
||||
|
||||
const [opencodeHeaders, setOpencodeHeaders] = useState<
|
||||
Record<string, string>
|
||||
>(() => {
|
||||
if (appId !== "opencode") return {};
|
||||
const headers = initialOpencodeOptions.headers;
|
||||
return headers && typeof headers === "object"
|
||||
? (headers as Record<string, string>)
|
||||
: {};
|
||||
});
|
||||
|
||||
const [opencodeModels, setOpencodeModels] = useState<
|
||||
Record<string, OpenCodeModel>
|
||||
>(() => {
|
||||
@@ -128,6 +142,30 @@ export function useOpencodeFormState({
|
||||
[updateOpencodeSettings],
|
||||
);
|
||||
|
||||
const handleOpencodeHeadersChange = useCallback(
|
||||
(headers: Record<string, string>) => {
|
||||
setOpencodeHeaders(headers);
|
||||
updateOpencodeSettings((config) => {
|
||||
if (!config.options) config.options = {};
|
||||
|
||||
const nextHeaders: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
const trimmedKey = key.trim();
|
||||
if (trimmedKey && !key.startsWith(OPENCODE_HEADER_DRAFT_PREFIX)) {
|
||||
nextHeaders[trimmedKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(nextHeaders).length > 0) {
|
||||
config.options.headers = nextHeaders;
|
||||
} else {
|
||||
delete config.options.headers;
|
||||
}
|
||||
});
|
||||
},
|
||||
[updateOpencodeSettings],
|
||||
);
|
||||
|
||||
const handleOpencodeModelsChange = useCallback(
|
||||
(models: Record<string, OpenCodeModel>) => {
|
||||
setOpencodeModels(models);
|
||||
@@ -152,7 +190,7 @@ export function useOpencodeFormState({
|
||||
|
||||
for (const [k, v] of Object.entries(options)) {
|
||||
const trimmedKey = k.trim();
|
||||
if (trimmedKey && !trimmedKey.startsWith("option-")) {
|
||||
if (trimmedKey && !k.startsWith(OPENCODE_EXTRA_OPTION_DRAFT_PREFIX)) {
|
||||
try {
|
||||
config.options[trimmedKey] = JSON.parse(v);
|
||||
} catch {
|
||||
@@ -170,6 +208,7 @@ export function useOpencodeFormState({
|
||||
setOpencodeNpm(config?.npm || OPENCODE_DEFAULT_NPM);
|
||||
setOpencodeBaseUrl(config?.options?.baseURL || "");
|
||||
setOpencodeApiKey(config?.options?.apiKey || "");
|
||||
setOpencodeHeaders(config?.options?.headers || {});
|
||||
setOpencodeModels(config?.models || {});
|
||||
setOpencodeExtraOptions(toOpencodeExtraOptions(config?.options || {}));
|
||||
}, []);
|
||||
@@ -180,11 +219,13 @@ export function useOpencodeFormState({
|
||||
opencodeNpm,
|
||||
opencodeApiKey,
|
||||
opencodeBaseUrl,
|
||||
opencodeHeaders,
|
||||
opencodeModels,
|
||||
opencodeExtraOptions,
|
||||
handleOpencodeNpmChange,
|
||||
handleOpencodeApiKeyChange,
|
||||
handleOpencodeBaseUrlChange,
|
||||
handleOpencodeHeadersChange,
|
||||
handleOpencodeModelsChange,
|
||||
handleOpencodeExtraOptionsChange,
|
||||
resetOpencodeState,
|
||||
|
||||
Reference in New Issue
Block a user