mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-04-23 09:29:13 +08:00
Compare commits
7 Commits
codex/clau
...
68da178811
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68da178811 | ||
|
|
3b3d1cd0ba | ||
|
|
3fa3558bd4 | ||
|
|
788c221d39 | ||
|
|
a86f22422b | ||
|
|
6427ab2128 | ||
|
|
87b08ce242 |
@@ -242,9 +242,14 @@ pub struct ProviderMeta {
|
||||
/// - "openai_responses": OpenAI Responses API 格式,需要转换
|
||||
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
|
||||
pub api_format: Option<String>,
|
||||
/// Claude 认证字段名("ANTHROPIC_AUTH_TOKEN" 或 "ANTHROPIC_API_KEY")
|
||||
/// 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>,
|
||||
/// 是否将 base_url 视为完整 API 端点(不拼接 endpoint 路径)
|
||||
#[serde(rename = "isFullUrl", skip_serializing_if = "Option::is_none")]
|
||||
pub is_full_url: Option<bool>,
|
||||
/// Prompt cache key for OpenAI-compatible endpoints.
|
||||
/// When set, injected into converted requests to improve cache hit rate.
|
||||
/// If not set, provider ID is used automatically during format conversion.
|
||||
|
||||
@@ -792,21 +792,61 @@ impl RequestForwarder {
|
||||
// 检查是否需要格式转换
|
||||
let needs_transform = adapter.needs_transform(provider);
|
||||
|
||||
let effective_endpoint =
|
||||
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" {
|
||||
// 根据 api_format 选择目标端点
|
||||
let api_format = super::providers::get_claude_api_format(provider);
|
||||
if api_format == "openai_responses" {
|
||||
"/v1/responses"
|
||||
// 检查 isFullUrl 模式:直接使用 base_url 作为完整 API 端点
|
||||
let is_full_url = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.is_full_url)
|
||||
.unwrap_or(false);
|
||||
|
||||
let url = if is_full_url {
|
||||
// 全链接模式:直接使用 base_url,将客户端 query 追加
|
||||
let query = endpoint.split_once('?').map(|(_, q)| q);
|
||||
match query {
|
||||
Some(q) if !q.is_empty() => {
|
||||
if base_url.contains('?') {
|
||||
format!("{base_url}&{q}")
|
||||
} else {
|
||||
format!("{base_url}?{q}")
|
||||
}
|
||||
}
|
||||
_ => base_url.clone(),
|
||||
}
|
||||
} else {
|
||||
// 正常模式:endpoint 可能带 query string,需拆分路径和参数
|
||||
let effective_endpoint: String = if needs_transform && adapter.name() == "Claude" {
|
||||
let (path, query) = match endpoint.split_once('?') {
|
||||
Some((p, q)) => (p, Some(q)),
|
||||
None => (endpoint, None),
|
||||
};
|
||||
if path == "/v1/messages" || path == "/claude/v1/messages" {
|
||||
// 转换到 OpenAI 兼容端点时剥离 beta=true(Anthropic 专有参数)
|
||||
let api_format = super::providers::get_claude_api_format(provider);
|
||||
let target_path = if api_format == "openai_responses" {
|
||||
"/v1/responses"
|
||||
} else {
|
||||
"/v1/chat/completions"
|
||||
};
|
||||
let filtered = query.map(|q| {
|
||||
q.split('&')
|
||||
.filter(|p| !p.starts_with("beta="))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
});
|
||||
match filtered.as_deref() {
|
||||
Some(q) if !q.is_empty() => format!("{target_path}?{q}"),
|
||||
_ => target_path.to_string(),
|
||||
}
|
||||
} else {
|
||||
"/v1/chat/completions"
|
||||
endpoint.to_string()
|
||||
}
|
||||
} else {
|
||||
endpoint
|
||||
endpoint.to_string()
|
||||
};
|
||||
|
||||
// 使用适配器构建 URL
|
||||
let url = adapter.build_url(&base_url, effective_endpoint);
|
||||
// 使用适配器构建 URL
|
||||
adapter.build_url(&base_url, &effective_endpoint)
|
||||
};
|
||||
|
||||
// 应用模型映射(独立于格式转换)
|
||||
let (mapped_body, _original_model, _mapped_model) =
|
||||
|
||||
@@ -61,12 +61,19 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
|
||||
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退)
|
||||
pub async fn handle_messages(
|
||||
State(state): State<ProxyState>,
|
||||
uri: axum::http::Uri,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
|
||||
|
||||
// 提取完整路径+query(如 "/v1/messages?beta=true"),透传客户端 URI
|
||||
let endpoint = uri
|
||||
.path_and_query()
|
||||
.map(|pq| pq.as_str())
|
||||
.unwrap_or(uri.path());
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
.and_then(|s| s.as_bool())
|
||||
@@ -77,7 +84,7 @@ pub async fn handle_messages(
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Claude,
|
||||
"/v1/messages",
|
||||
endpoint,
|
||||
body.clone(),
|
||||
headers,
|
||||
ctx.get_providers(),
|
||||
|
||||
@@ -261,6 +261,8 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
//
|
||||
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
||||
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
||||
//
|
||||
// ?beta=true 不再由代理注入,而是由客户端自身发送并透传。
|
||||
|
||||
let mut base = format!(
|
||||
"{}/{}",
|
||||
@@ -273,19 +275,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
base = base.replace("/v1/v1", "/v1");
|
||||
}
|
||||
|
||||
// 为 Claude 原生 /v1/messages 端点添加 ?beta=true 参数
|
||||
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
|
||||
// 注意:不要为 OpenAI Chat Completions (/v1/chat/completions) 添加此参数
|
||||
// 当 apiFormat="openai_chat" 时,请求会转发到 /v1/chat/completions,
|
||||
// 但该端点是 OpenAI 标准,不支持 ?beta=true 参数
|
||||
if endpoint.contains("/v1/messages")
|
||||
&& !endpoint.contains("/v1/chat/completions")
|
||||
&& !endpoint.contains('?')
|
||||
{
|
||||
format!("{base}?beta=true")
|
||||
} else {
|
||||
base
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
@@ -522,23 +512,21 @@ mod tests {
|
||||
#[test]
|
||||
fn test_build_url_anthropic() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// /v1/messages 端点会自动添加 ?beta=true 参数
|
||||
// ?beta=true 不再由代理注入,而是由客户端自身发送并透传
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_openrouter() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// /v1/messages 端点会自动添加 ?beta=true 参数
|
||||
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
|
||||
assert_eq!(url, "https://openrouter.ai/api/v1/messages?beta=true");
|
||||
assert_eq!(url, "https://openrouter.ai/api/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_no_beta_for_other_endpoints() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// 非 /v1/messages 端点不添加 ?beta=true
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/complete");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/complete");
|
||||
}
|
||||
@@ -546,16 +534,14 @@ mod tests {
|
||||
#[test]
|
||||
fn test_build_url_preserve_existing_query() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// 已有查询参数时不重复添加
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
|
||||
// 客户端携带的 query 参数原样透传
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?beta=true");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_no_beta_for_openai_chat_completions() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// OpenAI Chat Completions 端点不添加 ?beta=true
|
||||
// 这是 Nvidia 等 apiFormat="openai_chat" 供应商使用的端点
|
||||
let url = adapter.build_url("https://integrate.api.nvidia.com", "/v1/chat/completions");
|
||||
assert_eq!(url, "https://integrate.api.nvidia.com/v1/chat/completions");
|
||||
}
|
||||
|
||||
@@ -313,8 +313,19 @@ impl StreamCheckService {
|
||||
|
||||
let is_openai_chat = api_format == "openai_chat";
|
||||
|
||||
// URL: /v1/chat/completions for openai_chat, /v1/messages?beta=true for anthropic
|
||||
let url = if is_openai_chat {
|
||||
let is_full_url = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.is_full_url)
|
||||
.unwrap_or(false);
|
||||
|
||||
// URL rules:
|
||||
// - full URL mode: use base_url as-is
|
||||
// - openai_chat: /v1/chat/completions
|
||||
// - anthropic: /v1/messages?beta=true
|
||||
let url = if is_full_url {
|
||||
base_url.to_string()
|
||||
} else if is_openai_chat {
|
||||
if base.ends_with("/v1") {
|
||||
format!("{base}/chat/completions")
|
||||
} else {
|
||||
|
||||
@@ -661,11 +661,8 @@ fn validate_artifact_size_limit(artifact_name: &str, size: u64) -> Result<(), Ap
|
||||
let max_mb = MAX_SYNC_ARTIFACT_BYTES / 1024 / 1024;
|
||||
return Err(localized(
|
||||
"webdav.sync.artifact_too_large",
|
||||
format!("artifact {artifact_name} 超过下载上限({} MB)", max_mb),
|
||||
format!(
|
||||
"Artifact {artifact_name} exceeds download limit ({} MB)",
|
||||
max_mb
|
||||
),
|
||||
format!("artifact {artifact_name} 超过下载上限({max_mb} MB)"),
|
||||
format!("Artifact {artifact_name} exceeds download limit ({max_mb} MB)"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -350,8 +350,8 @@ fn copy_entry_with_total_limit<R: Read, W: Write>(
|
||||
let max_mb = max_total_bytes / 1024 / 1024;
|
||||
return Err(localized(
|
||||
"webdav.sync.skills_zip_too_large",
|
||||
format!("skills.zip 解压后体积超过上限({} MB)", max_mb),
|
||||
format!("skills.zip extracted size exceeds limit ({} MB)", max_mb),
|
||||
format!("skills.zip 解压后体积超过上限({max_mb} MB)"),
|
||||
format!("skills.zip extracted size exceeds limit ({max_mb} MB)"),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ function App() {
|
||||
deleteProvider,
|
||||
saveUsageScript,
|
||||
setAsDefaultModel,
|
||||
} = useProviderActions(activeApp);
|
||||
} = useProviderActions(activeApp, isProxyRunning);
|
||||
|
||||
const disableOmoMutation = useDisableCurrentOmo();
|
||||
const handleDisableOmo = () => {
|
||||
|
||||
@@ -73,9 +73,13 @@ interface ClaudeFormFieldsProps {
|
||||
apiFormat: ClaudeApiFormat;
|
||||
onApiFormatChange: (format: ClaudeApiFormat) => void;
|
||||
|
||||
// Auth Field (ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY)
|
||||
// Auth Key Field (ANTHROPIC_AUTH_TOKEN vs ANTHROPIC_API_KEY)
|
||||
apiKeyField: ClaudeApiKeyField;
|
||||
onApiKeyFieldChange: (field: ClaudeApiKeyField) => void;
|
||||
|
||||
// Full URL mode
|
||||
isFullUrl: boolean;
|
||||
onFullUrlChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function ClaudeFormFields({
|
||||
@@ -112,6 +116,8 @@ export function ClaudeFormFields({
|
||||
onApiFormatChange,
|
||||
apiKeyField,
|
||||
onApiKeyFieldChange,
|
||||
isFullUrl,
|
||||
onFullUrlChange,
|
||||
}: ClaudeFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -181,6 +187,9 @@ export function ClaudeFormFields({
|
||||
: t("providerForm.apiHint")
|
||||
}
|
||||
onManageClick={() => onEndpointModalToggle(true)}
|
||||
showFullUrlToggle={true}
|
||||
isFullUrl={isFullUrl}
|
||||
onFullUrlChange={onFullUrlChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -236,17 +245,17 @@ export function ClaudeFormFields({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 认证字段选择器 */}
|
||||
{/* 认证字段选择(仅非官方供应商显示) */}
|
||||
{shouldShowModelSelector && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
<FormLabel htmlFor="apiKeyField">
|
||||
{t("providerForm.authField", { defaultValue: "认证字段" })}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={apiKeyField}
|
||||
onValueChange={(v) => onApiKeyFieldChange(v as ClaudeApiKeyField)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id="apiKeyField" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -197,6 +197,9 @@ export function ProviderForm({
|
||||
setDraftCustomEndpoints([]);
|
||||
}
|
||||
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
|
||||
setLocalIsFullUrl(
|
||||
appId === "claude" ? (initialData?.meta?.isFullUrl ?? false) : false,
|
||||
);
|
||||
setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });
|
||||
setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false });
|
||||
setPricingConfig({
|
||||
@@ -245,6 +248,20 @@ export function ProviderForm({
|
||||
[form],
|
||||
);
|
||||
|
||||
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
|
||||
if (appId !== "claude") return "anthropic";
|
||||
return initialData?.meta?.apiFormat ?? "anthropic";
|
||||
});
|
||||
|
||||
const [localIsFullUrl, setLocalIsFullUrl] = useState<boolean>(() => {
|
||||
if (appId !== "claude") return false;
|
||||
return initialData?.meta?.isFullUrl ?? false;
|
||||
});
|
||||
|
||||
const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => {
|
||||
setLocalApiFormat(format);
|
||||
}, []);
|
||||
|
||||
const [localApiKeyField, setLocalApiKeyField] = useState<ClaudeApiKeyField>(
|
||||
() => {
|
||||
if (appId !== "claude") return "ANTHROPIC_AUTH_TOKEN";
|
||||
@@ -256,7 +273,6 @@ export function ProviderForm({
|
||||
return "ANTHROPIC_AUTH_TOKEN";
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
apiKey,
|
||||
handleApiKeyChange,
|
||||
@@ -291,15 +307,6 @@ export function ProviderForm({
|
||||
onConfigChange: handleSettingsConfigChange,
|
||||
});
|
||||
|
||||
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
|
||||
if (appId !== "claude") return "anthropic";
|
||||
return initialData?.meta?.apiFormat ?? "anthropic";
|
||||
});
|
||||
|
||||
const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => {
|
||||
setLocalApiFormat(format);
|
||||
}, []);
|
||||
|
||||
const handleApiKeyFieldChange = useCallback(
|
||||
(field: ClaudeApiKeyField) => {
|
||||
const prev = localApiKeyField;
|
||||
@@ -323,7 +330,6 @@ export function ProviderForm({
|
||||
},
|
||||
[localApiKeyField, form, handleSettingsConfigChange],
|
||||
);
|
||||
|
||||
const {
|
||||
codexAuth,
|
||||
codexConfig,
|
||||
@@ -888,6 +894,10 @@ export function ProviderForm({
|
||||
localApiKeyField !== "ANTHROPIC_AUTH_TOKEN"
|
||||
? localApiKeyField
|
||||
: undefined,
|
||||
isFullUrl:
|
||||
appId === "claude" && category !== "official" && localIsFullUrl
|
||||
? true
|
||||
: undefined,
|
||||
};
|
||||
|
||||
onSubmit(payload);
|
||||
@@ -1335,6 +1345,8 @@ export function ProviderForm({
|
||||
onApiFormatChange={handleApiFormatChange}
|
||||
apiKeyField={localApiKeyField}
|
||||
onApiKeyFieldChange={handleApiKeyFieldChange}
|
||||
isFullUrl={localIsFullUrl}
|
||||
onFullUrlChange={setLocalIsFullUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export function useApiKeyState({
|
||||
}: UseApiKeyStateProps) {
|
||||
const [apiKey, setApiKey] = useState(() => {
|
||||
if (initialConfig) {
|
||||
return getApiKeyFromConfig(initialConfig, appType);
|
||||
return getApiKeyFromConfig(initialConfig, appType, apiKeyField);
|
||||
}
|
||||
return "";
|
||||
});
|
||||
@@ -46,11 +46,11 @@ export function useApiKeyState({
|
||||
}
|
||||
|
||||
// 从配置中提取 API Key(如果不存在则返回空字符串)
|
||||
const extracted = getApiKeyFromConfig(initialConfig, appType);
|
||||
const extracted = getApiKeyFromConfig(initialConfig, appType, apiKeyField);
|
||||
if (extracted !== apiKey) {
|
||||
setApiKey(extracted);
|
||||
}
|
||||
}, [initialConfig, appType, apiKey]);
|
||||
}, [initialConfig, appType, apiKeyField, apiKey]);
|
||||
|
||||
const handleApiKeyChange = useCallback(
|
||||
(key: string) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Zap } from "lucide-react";
|
||||
import { Zap, Link2 } from "lucide-react";
|
||||
|
||||
interface EndpointFieldProps {
|
||||
id: string;
|
||||
@@ -13,6 +13,9 @@ interface EndpointFieldProps {
|
||||
showManageButton?: boolean;
|
||||
onManageClick?: () => void;
|
||||
manageButtonLabel?: string;
|
||||
showFullUrlToggle?: boolean;
|
||||
isFullUrl?: boolean;
|
||||
onFullUrlChange?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function EndpointField({
|
||||
@@ -25,6 +28,9 @@ export function EndpointField({
|
||||
showManageButton = true,
|
||||
onManageClick,
|
||||
manageButtonLabel,
|
||||
showFullUrlToggle = false,
|
||||
isFullUrl = false,
|
||||
onFullUrlChange,
|
||||
}: EndpointFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -55,6 +61,35 @@ export function EndpointField({
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{showFullUrlToggle && onFullUrlChange && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFullUrlChange(!isFullUrl)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-md border transition-colors ${
|
||||
isFullUrl
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/30"
|
||||
}`}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{isFullUrl
|
||||
? t("providerForm.fullUrlEnabled", {
|
||||
defaultValue: "完整 URL 模式",
|
||||
})
|
||||
: t("providerForm.fullUrlDisabled", {
|
||||
defaultValue: "标记为完整 URL",
|
||||
})}
|
||||
</button>
|
||||
{isFullUrl && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("providerForm.fullUrlHint", {
|
||||
defaultValue: "代理将直接使用此 URL,不拼接路径",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hint ? (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">{hint}</p>
|
||||
|
||||
@@ -23,7 +23,7 @@ import { openclawKeys } from "@/hooks/useOpenClaw";
|
||||
* Hook for managing provider actions (add, update, delete, switch)
|
||||
* Extracts business logic from App.tsx
|
||||
*/
|
||||
export function useProviderActions(activeApp: AppId) {
|
||||
export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -139,6 +139,23 @@ export function useProviderActions(activeApp: AppId) {
|
||||
// 切换供应商
|
||||
const switchProvider = useCallback(
|
||||
async (provider: Provider) => {
|
||||
// 阻断逻辑:需要代理的供应商在代理未运行时不允许切换
|
||||
if (
|
||||
activeApp === "claude" &&
|
||||
provider.category !== "official" &&
|
||||
(provider.meta?.isFullUrl ||
|
||||
provider.meta?.apiFormat === "openai_chat" ||
|
||||
provider.meta?.apiFormat === "openai_responses") &&
|
||||
!isProxyRunning
|
||||
) {
|
||||
toast.warning(
|
||||
t("notifications.proxyRequiredForSwitch", {
|
||||
defaultValue: "此供应商需要代理服务,请先启动代理",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await switchProviderMutation.mutateAsync(provider.id);
|
||||
await syncClaudePlugin(provider);
|
||||
@@ -192,7 +209,7 @@ export function useProviderActions(activeApp: AppId) {
|
||||
// 错误提示由 mutation 处理
|
||||
}
|
||||
},
|
||||
[switchProviderMutation, syncClaudePlugin, activeApp, t],
|
||||
[switchProviderMutation, syncClaudePlugin, activeApp, isProxyRunning, t],
|
||||
);
|
||||
|
||||
// 删除供应商
|
||||
|
||||
@@ -173,6 +173,7 @@
|
||||
"settingsSaveFailed": "Failed to save settings: {{error}}",
|
||||
"openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be enabled",
|
||||
"openAIFormatHint": "This provider uses OpenAI-compatible format and requires the proxy service to be enabled",
|
||||
"proxyRequiredForSwitch": "This provider requires the proxy service. Please start the proxy first",
|
||||
"openLinkFailed": "Failed to open link",
|
||||
"openclawModelsRegistered": "Models have been registered to /model list",
|
||||
"openclawDefaultModelSet": "Set as default model",
|
||||
@@ -736,6 +737,9 @@
|
||||
"anthropicReasoningModel": "Reasoning Model (Thinking)",
|
||||
"apiFormat": "API Format",
|
||||
"apiFormatHint": "Select the input format for the provider's API",
|
||||
"fullUrlEnabled": "Full URL Mode",
|
||||
"fullUrlDisabled": "Mark as Full URL",
|
||||
"fullUrlHint": "Proxy will use this URL as-is",
|
||||
"apiFormatAnthropic": "Anthropic Messages (Native)",
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)",
|
||||
"apiFormatOpenAIResponses": "OpenAI Responses API (Requires proxy)",
|
||||
|
||||
@@ -173,6 +173,7 @@
|
||||
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}",
|
||||
"openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です",
|
||||
"openAIFormatHint": "このプロバイダーは OpenAI 互換フォーマットを使用しており、プロキシサービスの有効化が必要です",
|
||||
"proxyRequiredForSwitch": "このプロバイダーにはプロキシが必要です。先にプロキシを起動してください",
|
||||
"openLinkFailed": "リンクを開けませんでした",
|
||||
"openclawModelsRegistered": "モデルが /model リストに登録されました",
|
||||
"openclawDefaultModelSet": "デフォルトモデルに設定しました",
|
||||
@@ -736,6 +737,9 @@
|
||||
"anthropicReasoningModel": "推論モデル(Thinking)",
|
||||
"apiFormat": "API フォーマット",
|
||||
"apiFormatHint": "プロバイダー API の入力フォーマットを選択",
|
||||
"fullUrlEnabled": "フル URL モード",
|
||||
"fullUrlDisabled": "フル URL として設定",
|
||||
"fullUrlHint": "プロキシはこの URL をそのまま使用",
|
||||
"apiFormatAnthropic": "Anthropic Messages(ネイティブ)",
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)",
|
||||
"apiFormatOpenAIResponses": "OpenAI Responses API(プロキシが必要)",
|
||||
|
||||
@@ -173,6 +173,7 @@
|
||||
"settingsSaveFailed": "保存设置失败:{{error}}",
|
||||
"openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用",
|
||||
"openAIFormatHint": "此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用",
|
||||
"proxyRequiredForSwitch": "此供应商需要代理服务,请先启动代理",
|
||||
"openLinkFailed": "链接打开失败",
|
||||
"openclawModelsRegistered": "模型已注册到 /model 列表",
|
||||
"openclawDefaultModelSet": "已设为默认模型",
|
||||
@@ -736,6 +737,9 @@
|
||||
"anthropicReasoningModel": "推理模型 (Thinking)",
|
||||
"apiFormat": "API 格式",
|
||||
"apiFormatHint": "选择供应商 API 的输入格式",
|
||||
"fullUrlEnabled": "完整 URL 模式",
|
||||
"fullUrlDisabled": "标记为完整 URL",
|
||||
"fullUrlHint": "代理将直接使用此 URL,不拼接路径",
|
||||
"apiFormatAnthropic": "Anthropic Messages (原生)",
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)",
|
||||
"apiFormatOpenAIResponses": "OpenAI Responses API (需开启代理)",
|
||||
|
||||
@@ -151,6 +151,8 @@ export interface ProviderMeta {
|
||||
apiFormat?: "anthropic" | "openai_chat" | "openai_responses";
|
||||
// Claude 认证字段名
|
||||
apiKeyField?: ClaudeApiKeyField;
|
||||
// 是否将 base_url 视为完整 API 端点(代理直接使用此 URL,不拼接路径)
|
||||
isFullUrl?: boolean;
|
||||
// Prompt cache key for OpenAI-compatible endpoints (improves cache hit rate)
|
||||
promptCacheKey?: string;
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ export const hasCommonConfigSnippet = (
|
||||
export const getApiKeyFromConfig = (
|
||||
jsonString: string,
|
||||
appType?: string,
|
||||
apiKeyField?: string,
|
||||
): string => {
|
||||
try {
|
||||
const config = JSON.parse(jsonString);
|
||||
@@ -203,11 +204,17 @@ export const getApiKeyFromConfig = (
|
||||
const token = env.ANTHROPIC_AUTH_TOKEN;
|
||||
const apiKey = env.ANTHROPIC_API_KEY;
|
||||
const value =
|
||||
typeof token === "string"
|
||||
? token
|
||||
: typeof apiKey === "string"
|
||||
apiKeyField === "ANTHROPIC_API_KEY"
|
||||
? typeof apiKey === "string"
|
||||
? apiKey
|
||||
: "";
|
||||
: typeof token === "string"
|
||||
? token
|
||||
: ""
|
||||
: typeof token === "string"
|
||||
? token
|
||||
: typeof apiKey === "string"
|
||||
? apiKey
|
||||
: "";
|
||||
return value;
|
||||
} catch (err) {
|
||||
return "";
|
||||
@@ -341,13 +348,20 @@ export const setApiKeyInConfig = (
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
|
||||
// Claude API Key (优先写入已存在的字段;若两者均不存在且允许创建,则使用 apiKeyField 或默认 AUTH_TOKEN 字段)
|
||||
if ("ANTHROPIC_AUTH_TOKEN" in env) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
||||
} else if ("ANTHROPIC_API_KEY" in env) {
|
||||
env.ANTHROPIC_API_KEY = apiKey;
|
||||
// Claude API Key: follow the selected field when provided.
|
||||
const preferredClaudeField = apiKeyField ?? "ANTHROPIC_AUTH_TOKEN";
|
||||
const alternateClaudeField =
|
||||
preferredClaudeField === "ANTHROPIC_API_KEY"
|
||||
? "ANTHROPIC_AUTH_TOKEN"
|
||||
: "ANTHROPIC_API_KEY";
|
||||
|
||||
if (preferredClaudeField in env) {
|
||||
env[preferredClaudeField] = apiKey;
|
||||
} else if (alternateClaudeField in env) {
|
||||
env[preferredClaudeField] = apiKey;
|
||||
delete env[alternateClaudeField];
|
||||
} else if (createIfMissing) {
|
||||
env[apiKeyField ?? "ANTHROPIC_AUTH_TOKEN"] = apiKey;
|
||||
env[preferredClaudeField] = apiKey;
|
||||
} else {
|
||||
return jsonString;
|
||||
}
|
||||
|
||||
@@ -52,4 +52,3 @@ describe("Codex TOML utils", () => {
|
||||
expect(extractCodexModelName(output2)).toBe("new-model");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user