Merge remote-tracking branch 'origin/main' into pr-4887

# Conflicts:
#	src/lib/query/subscription.ts
This commit is contained in:
saladday
2026-07-14 02:56:31 -04:00
186 changed files with 22620 additions and 2471 deletions
+34 -8
View File
@@ -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 {
+35 -8
View File
@@ -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 顶层 modelCodex 启动时默认请求的模型。
实时写回 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_TOKENAuthorization",
})}
</SelectItem>
<SelectItem value="ANTHROPIC_API_KEY">
{t("codexConfig.anthropicAuthFieldApiKey", {
defaultValue: "ANTHROPIC_API_KEYx-api-key",
})}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.anthropicAuthFieldHint", {
defaultValue:
"选择网关接收 API Key 的请求头:ANTHROPIC_AUTH_TOKEN 发送 Authorization: BearerANTHROPIC_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
+96 -38
View File
@@ -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,