feat(provider): add auth field selector for Claude providers (AUTH_TOKEN / API_KEY)

Allow users to choose between ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY
when creating or editing custom Claude providers, persisted in meta.apiKeyField.
This commit is contained in:
Jason
2026-02-23 19:15:31 +08:00
Unverified
parent 65ccabd5ad
commit 58b7dc32b3
9 changed files with 155 additions and 16 deletions
+5
View File
@@ -235,6 +235,11 @@ pub struct ProviderMeta {
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
pub api_format: Option<String>,
/// Claude 认证字段名(仅 Claude 供应商使用)
/// - "ANTHROPIC_AUTH_TOKEN" (默认): 大多数第三方/聚合供应商
/// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
#[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")]
pub api_key_field: Option<String>,
}
impl ProviderManager {
@@ -10,7 +10,11 @@ import {
} from "@/components/ui/select";
import EndpointSpeedTest from "./EndpointSpeedTest";
import { ApiKeySection, EndpointField } from "./shared";
import type { ProviderCategory, ClaudeApiFormat } from "@/types";
import type {
ProviderCategory,
ClaudeApiFormat,
ClaudeApiKeyField,
} from "@/types";
import type { TemplateValueConfig } from "@/config/claudeProviderPresets";
interface EndpointCandidate {
@@ -68,6 +72,10 @@ interface ClaudeFormFieldsProps {
// API Format (for third-party providers that use OpenAI Chat Completions format)
apiFormat: ClaudeApiFormat;
onApiFormatChange: (format: ClaudeApiFormat) => void;
// Auth Key Field (ANTHROPIC_AUTH_TOKEN vs ANTHROPIC_API_KEY)
apiKeyField: ClaudeApiKeyField;
onApiKeyFieldChange: (field: ClaudeApiKeyField) => void;
}
export function ClaudeFormFields({
@@ -102,6 +110,8 @@ export function ClaudeFormFields({
speedTestEndpoints,
apiFormat,
onApiFormatChange,
apiKeyField,
onApiKeyFieldChange,
}: ClaudeFormFieldsProps) {
const { t } = useTranslation();
@@ -219,6 +229,41 @@ export function ClaudeFormFields({
</div>
)}
{/* 认证字段选择(仅非官方供应商显示) */}
{shouldShowModelSelector && (
<div className="space-y-2">
<FormLabel htmlFor="apiKeyField">
{t("providerForm.authField", { defaultValue: "认证字段" })}
</FormLabel>
<Select
value={apiKeyField}
onValueChange={(v) => onApiKeyFieldChange(v as ClaudeApiKeyField)}
>
<SelectTrigger id="apiKeyField" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ANTHROPIC_AUTH_TOKEN">
{t("providerForm.authFieldAuthToken", {
defaultValue: "Auth Token (默认)",
})}
</SelectItem>
<SelectItem value="ANTHROPIC_API_KEY">
{t("providerForm.authFieldApiKey", {
defaultValue: "API Key",
})}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("providerForm.authFieldHint", {
defaultValue:
"大多数第三方供应商使用 Auth Token;少数供应商需要 API Key",
})}
</p>
</div>
)}
{/* 模型选择器 */}
{shouldShowModelSelector && (
<div className="space-y-3">
@@ -14,6 +14,7 @@ import type {
ProviderTestConfig,
ProviderProxyConfig,
ClaudeApiFormat,
ClaudeApiKeyField,
} from "@/types";
import {
providerPresets,
@@ -240,6 +241,55 @@ export function ProviderForm({
mode: "onSubmit",
});
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
if (appId !== "claude") return "anthropic";
return initialData?.meta?.apiFormat ?? "anthropic";
});
const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => {
setLocalApiFormat(format);
}, []);
const [localApiKeyField, setLocalApiKeyField] = useState<ClaudeApiKeyField>(
() => {
if (appId !== "claude") return "ANTHROPIC_AUTH_TOKEN";
if (initialData?.meta?.apiKeyField) return initialData.meta.apiKeyField;
try {
const config = initialData?.settingsConfig;
if (
config?.env &&
(config.env as Record<string, unknown>).ANTHROPIC_API_KEY !==
undefined
)
return "ANTHROPIC_API_KEY";
} catch {}
return "ANTHROPIC_AUTH_TOKEN";
},
);
const handleApiKeyFieldChange = useCallback(
(field: ClaudeApiKeyField) => {
setLocalApiKeyField(field);
try {
const config = JSON.parse(form.getValues("settingsConfig") || "{}") as {
env?: Record<string, unknown>;
};
const env = (config.env ?? {}) as Record<string, unknown>;
const oldField =
field === "ANTHROPIC_API_KEY"
? "ANTHROPIC_AUTH_TOKEN"
: "ANTHROPIC_API_KEY";
if (oldField in env) {
env[field] = env[oldField];
delete env[oldField];
config.env = env;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
}
} catch {}
},
[form],
);
const {
apiKey,
handleApiKeyChange,
@@ -250,6 +300,7 @@ export function ProviderForm({
selectedPresetId,
category,
appType: appId,
apiKeyField: appId === "claude" ? localApiKeyField : undefined,
});
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
@@ -273,15 +324,6 @@ export function ProviderForm({
onConfigChange: (config) => form.setValue("settingsConfig", config),
});
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
if (appId !== "claude") return "anthropic";
return initialData?.meta?.apiFormat ?? "anthropic";
});
const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => {
setLocalApiFormat(format);
}, []);
const {
codexAuth,
codexConfig,
@@ -775,6 +817,10 @@ export function ProviderForm({
appId === "claude" && category !== "official"
? localApiFormat
: undefined,
apiKeyField:
appId === "claude" && category !== "official"
? localApiKeyField
: undefined,
};
onSubmit(payload);
@@ -1012,6 +1058,12 @@ export function ProviderForm({
setLocalApiFormat("anthropic");
}
if (preset.apiKeyField) {
setLocalApiKeyField(preset.apiKeyField);
} else {
setLocalApiKeyField("ANTHROPIC_AUTH_TOKEN");
}
form.reset({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
@@ -1216,6 +1268,8 @@ export function ProviderForm({
speedTestEndpoints={speedTestEndpoints}
apiFormat={localApiFormat}
onApiFormatChange={handleApiFormatChange}
apiKeyField={localApiKeyField}
onApiKeyFieldChange={handleApiKeyFieldChange}
/>
)}
@@ -12,6 +12,7 @@ interface UseApiKeyStateProps {
selectedPresetId: string | null;
category?: ProviderCategory;
appType?: string;
apiKeyField?: string;
}
/**
@@ -24,6 +25,7 @@ export function useApiKeyState({
selectedPresetId,
category,
appType,
apiKeyField,
}: UseApiKeyStateProps) {
const [apiKey, setApiKey] = useState(() => {
if (initialConfig) {
@@ -58,7 +60,7 @@ export function useApiKeyState({
initialConfig || "{}",
key.trim(),
{
// 最佳实践:仅在新增模式”且“非官方类别时补齐缺失字段
// 最佳实践:仅在"新增模式"且"非官方类别"时补齐缺失字段
// - 新增模式:selectedPresetId !== null
// - 非官方类别:category !== undefined && category !== "official"
// - 官方类别:不创建字段(UI 也会禁用输入框)
@@ -68,12 +70,20 @@ export function useApiKeyState({
category !== undefined &&
category !== "official",
appType,
apiKeyField,
},
);
onConfigChange(configString);
},
[initialConfig, selectedPresetId, category, appType, onConfigChange],
[
initialConfig,
selectedPresetId,
category,
appType,
apiKeyField,
onConfigChange,
],
);
const showApiKey = useCallback(
+4
View File
@@ -655,6 +655,10 @@
"apiFormatHint": "Select the input format for the provider's API",
"apiFormatAnthropic": "Anthropic Messages (Native)",
"apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)",
"authField": "Auth Field",
"authFieldAuthToken": "Auth Token (Default)",
"authFieldApiKey": "API Key",
"authFieldHint": "Most third-party providers use Auth Token; a few require API Key",
"anthropicDefaultHaikuModel": "Default Haiku Model",
"anthropicDefaultSonnetModel": "Default Sonnet Model",
"anthropicDefaultOpusModel": "Default Opus Model",
+4
View File
@@ -655,6 +655,10 @@
"apiFormatHint": "プロバイダー API の入力フォーマットを選択",
"apiFormatAnthropic": "Anthropic Messages(ネイティブ)",
"apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)",
"authField": "認証フィールド",
"authFieldAuthToken": "Auth Token(デフォルト)",
"authFieldApiKey": "API Key",
"authFieldHint": "ほとんどのサードパーティプロバイダーは Auth Token を使用します。一部は API Key が必要です",
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
"anthropicDefaultSonnetModel": "既定 Sonnet モデル",
"anthropicDefaultOpusModel": "既定 Opus モデル",
+4
View File
@@ -655,6 +655,10 @@
"apiFormatHint": "选择供应商 API 的输入格式",
"apiFormatAnthropic": "Anthropic Messages (原生)",
"apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)",
"authField": "认证字段",
"authFieldAuthToken": "Auth Token (默认)",
"authFieldApiKey": "API Key",
"authFieldHint": "大多数第三方供应商使用 Auth Token;少数供应商需要 API Key",
"anthropicDefaultHaikuModel": "Haiku 默认模型",
"anthropicDefaultSonnetModel": "Sonnet 默认模型",
"anthropicDefaultOpusModel": "Opus 默认模型",
+9
View File
@@ -145,6 +145,10 @@ export interface ProviderMeta {
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
apiFormat?: "anthropic" | "openai_chat";
// Claude 认证字段名(仅 Claude 供应商使用)
// - "ANTHROPIC_AUTH_TOKEN" (默认): 大多数第三方/聚合供应商
// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
apiKeyField?: "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
}
// Skill 同步方式
@@ -155,6 +159,11 @@ export type SkillSyncMethod = "auto" | "symlink" | "copy";
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
export type ClaudeApiFormat = "anthropic" | "openai_chat";
// Claude 认证字段类型
// - "ANTHROPIC_AUTH_TOKEN": 大多数第三方/聚合供应商使用(默认)
// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
export type ClaudeApiKeyField = "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
// 主页面显示的应用配置
export interface VisibleApps {
claude: boolean;
+8 -4
View File
@@ -135,9 +135,13 @@ export const hasApiKeyField = (
export const setApiKeyInConfig = (
jsonString: string,
apiKey: string,
options: { createIfMissing?: boolean; appType?: string } = {},
options: {
createIfMissing?: boolean;
appType?: string;
apiKeyField?: string;
} = {},
): string => {
const { createIfMissing = false, appType } = options;
const { createIfMissing = false, appType, apiKeyField } = options;
try {
const config = JSON.parse(jsonString);
if (!config.env) {
@@ -170,13 +174,13 @@ export const setApiKeyInConfig = (
return JSON.stringify(config, null, 2);
}
// Claude API Key (优先写入已存在的字段;若两者均不存在且允许创建,则默认创建 AUTH_TOKEN 字段)
// Claude API Key (优先写入已存在的字段;若两者均不存在且允许创建,则使用 apiKeyField 指定的字段)
if ("ANTHROPIC_AUTH_TOKEN" in env) {
env.ANTHROPIC_AUTH_TOKEN = apiKey;
} else if ("ANTHROPIC_API_KEY" in env) {
env.ANTHROPIC_API_KEY = apiKey;
} else if (createIfMissing) {
env.ANTHROPIC_AUTH_TOKEN = apiKey;
env[apiKeyField ?? "ANTHROPIC_AUTH_TOKEN"] = apiKey;
} else {
return jsonString;
}