mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-11 14:21:22 +08:00
Merge branch 'main' into feat/thinking-signature-rectifier
This commit is contained in:
+239
-776
File diff suppressed because it is too large
Load Diff
@@ -55,7 +55,7 @@ pub struct DeepLinkImportRequest {
|
||||
/// Provider homepage URL
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub homepage: Option<String>,
|
||||
/// API endpoint/base URL
|
||||
/// API endpoint/base URL (supports comma-separated multiple URLs)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint: Option<String>,
|
||||
/// API key
|
||||
|
||||
@@ -101,9 +101,13 @@ fn parse_provider_deeplink(
|
||||
validate_url(hp, "homepage")?;
|
||||
}
|
||||
}
|
||||
// Validate each endpoint (supports comma-separated multiple URLs)
|
||||
if let Some(ref ep) = endpoint {
|
||||
if !ep.is_empty() {
|
||||
validate_url(ep, "endpoint")?;
|
||||
for (i, url) in ep.split(',').enumerate() {
|
||||
let trimmed = url.trim();
|
||||
if !trimmed.is_empty() {
|
||||
validate_url(trimmed, &format!("endpoint[{i}]"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,12 +33,12 @@ pub fn import_provider_from_deeplink(
|
||||
}
|
||||
|
||||
// Step 1: Merge config file if provided (v3.8+)
|
||||
let merged_request = parse_and_merge_config(&request)?;
|
||||
let mut merged_request = parse_and_merge_config(&request)?;
|
||||
|
||||
// Extract required fields (now as Option)
|
||||
let app_str = merged_request
|
||||
.app
|
||||
.as_ref()
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?;
|
||||
|
||||
let api_key = merged_request.api_key.as_ref().ok_or_else(|| {
|
||||
@@ -51,14 +51,29 @@ pub fn import_provider_from_deeplink(
|
||||
));
|
||||
}
|
||||
|
||||
let endpoint = merged_request.endpoint.as_ref().ok_or_else(|| {
|
||||
// Get endpoint: supports comma-separated multiple URLs (first is primary)
|
||||
let endpoint_str = merged_request.endpoint.as_ref().ok_or_else(|| {
|
||||
AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string())
|
||||
})?;
|
||||
|
||||
if endpoint.is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"Endpoint cannot be empty".to_string(),
|
||||
));
|
||||
// Parse endpoints: split by comma, first is primary
|
||||
let all_endpoints: Vec<String> = endpoint_str
|
||||
.split(',')
|
||||
.map(|e| e.trim().to_string())
|
||||
.filter(|e| !e.is_empty())
|
||||
.collect();
|
||||
|
||||
let primary_endpoint = all_endpoints
|
||||
.first()
|
||||
.ok_or_else(|| AppError::InvalidInput("Endpoint cannot be empty".to_string()))?;
|
||||
|
||||
// Auto-infer homepage from endpoint if not provided
|
||||
if merged_request
|
||||
.homepage
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.is_empty())
|
||||
{
|
||||
merged_request.homepage = infer_homepage_from_endpoint(primary_endpoint);
|
||||
}
|
||||
|
||||
let homepage = merged_request.homepage.as_ref().ok_or_else(|| {
|
||||
@@ -73,11 +88,11 @@ pub fn import_provider_from_deeplink(
|
||||
|
||||
let name = merged_request
|
||||
.name
|
||||
.as_ref()
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?;
|
||||
|
||||
// Parse app type
|
||||
let app_type = AppType::from_str(app_str)
|
||||
let app_type = AppType::from_str(&app_str)
|
||||
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?;
|
||||
|
||||
// Build provider configuration based on app type
|
||||
@@ -97,6 +112,21 @@ pub fn import_provider_from_deeplink(
|
||||
// Use ProviderService to add the provider
|
||||
ProviderService::add(state, app_type.clone(), provider)?;
|
||||
|
||||
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
|
||||
for ep in all_endpoints.iter().skip(1) {
|
||||
let normalized = ep.trim().trim_end_matches('/').to_string();
|
||||
if !normalized.is_empty() {
|
||||
if let Err(e) = ProviderService::add_custom_endpoint(
|
||||
state,
|
||||
app_type.clone(),
|
||||
&provider_id,
|
||||
normalized.clone(),
|
||||
) {
|
||||
log::warn!("Failed to add custom endpoint '{normalized}': {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If enabled=true, set as current provider
|
||||
if merged_request.enabled.unwrap_or(false) {
|
||||
ProviderService::switch(state, app_type.clone(), &provider_id)?;
|
||||
@@ -138,6 +168,16 @@ pub(crate) fn build_provider_from_request(
|
||||
Ok(provider)
|
||||
}
|
||||
|
||||
/// Get primary endpoint from request (first one if comma-separated)
|
||||
fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {
|
||||
request
|
||||
.endpoint
|
||||
.as_ref()
|
||||
.and_then(|ep| ep.split(',').next())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Build provider meta with usage script configuration
|
||||
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
|
||||
// Check if any usage script fields are provided
|
||||
@@ -165,6 +205,7 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
||||
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
||||
|
||||
// Build UsageScript - use provider's API key and endpoint as defaults
|
||||
// Note: use primary endpoint only (first one if comma-separated)
|
||||
let usage_script = UsageScript {
|
||||
enabled,
|
||||
language: "javascript".to_string(),
|
||||
@@ -174,10 +215,14 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
||||
.usage_api_key
|
||||
.clone()
|
||||
.or_else(|| request.api_key.clone()),
|
||||
base_url: request
|
||||
.usage_base_url
|
||||
.clone()
|
||||
.or_else(|| request.endpoint.clone()),
|
||||
base_url: request.usage_base_url.clone().or_else(|| {
|
||||
let primary = get_primary_endpoint(request);
|
||||
if primary.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(primary)
|
||||
}
|
||||
}),
|
||||
access_token: request.usage_access_token.clone(),
|
||||
user_id: request.usage_user_id.clone(),
|
||||
auto_query_interval: request.usage_auto_interval,
|
||||
@@ -198,7 +243,7 @@ fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
);
|
||||
env.insert(
|
||||
"ANTHROPIC_BASE_URL".to_string(),
|
||||
json!(request.endpoint.clone().unwrap_or_default()),
|
||||
json!(get_primary_endpoint(request)),
|
||||
);
|
||||
|
||||
// Add default model if provided
|
||||
@@ -271,11 +316,8 @@ fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
.unwrap_or("gpt-5-codex")
|
||||
.to_string();
|
||||
|
||||
// Endpoint: normalize trailing slashes
|
||||
let endpoint = request
|
||||
.endpoint
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
// Endpoint: normalize trailing slashes (use primary endpoint only)
|
||||
let endpoint = get_primary_endpoint(request)
|
||||
.trim()
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
@@ -309,7 +351,7 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
|
||||
env.insert(
|
||||
"GOOGLE_GEMINI_BASE_URL".to_string(),
|
||||
json!(request.endpoint),
|
||||
json!(get_primary_endpoint(request)),
|
||||
);
|
||||
|
||||
// Add model if provided
|
||||
|
||||
@@ -404,3 +404,57 @@ fn test_parse_skill_deeplink() {
|
||||
assert_eq!(request.directory.unwrap(), "skills");
|
||||
assert_eq!(request.branch.unwrap(), "dev");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Multiple Endpoints Tests
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_parse_multiple_endpoints_comma_separated() {
|
||||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com,https%3A%2F%2Fapi2.example.com,https%3A%2F%2Fapi3.example.com&apiKey=sk-test";
|
||||
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
assert!(request.endpoint.is_some());
|
||||
let endpoint = request.endpoint.unwrap();
|
||||
// Should contain all endpoints comma-separated
|
||||
assert!(endpoint.contains("https://api1.example.com"));
|
||||
assert!(endpoint.contains("https://api2.example.com"));
|
||||
assert!(endpoint.contains("https://api3.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_single_endpoint_backward_compatible() {
|
||||
// Old format with single endpoint should still work
|
||||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test";
|
||||
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
request.endpoint,
|
||||
Some("https://api.example.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_endpoints_with_spaces_trimmed() {
|
||||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com%20,%20https%3A%2F%2Fapi2.example.com&apiKey=sk-test";
|
||||
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
// Validation should pass (spaces are trimmed during validation)
|
||||
assert!(request.endpoint.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infer_homepage_from_endpoint_without_homepage() {
|
||||
// Test that homepage is auto-inferred from endpoint when not provided
|
||||
assert_eq!(
|
||||
infer_homepage_from_endpoint("https://api.cubence.com/v1"),
|
||||
Some("https://cubence.com".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
infer_homepage_from_endpoint("https://cubence.com"),
|
||||
Some("https://cubence.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,6 +147,9 @@ pub struct ProviderMeta {
|
||||
/// 用量查询脚本配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage_script: Option<UsageScript>,
|
||||
/// 请求地址管理:测速后自动选择最佳端点
|
||||
#[serde(rename = "endpointAutoSelect", skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint_auto_select: Option<bool>,
|
||||
/// 合作伙伴标记(前端使用 isPartner,保持字段名一致)
|
||||
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
|
||||
pub is_partner: Option<bool>,
|
||||
|
||||
@@ -389,12 +389,27 @@ export function DeepLinkImportDialog() {
|
||||
</div>
|
||||
|
||||
{/* API Endpoint */}
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
<div className="grid grid-cols-3 items-start gap-4">
|
||||
<div className="font-medium text-sm text-muted-foreground pt-0.5">
|
||||
{t("deeplink.endpoint")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm break-all">
|
||||
{request.endpoint}
|
||||
<div className="col-span-2 text-sm break-all space-y-1">
|
||||
{request.endpoint?.split(",").map((ep, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={
|
||||
idx === 0 ? "font-medium" : "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{idx === 0 ? "🔹 " : "└ "}
|
||||
{ep.trim()}
|
||||
{idx === 0 && request.endpoint?.includes(",") && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
({t("deeplink.primaryEndpoint")})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ interface ClaudeFormFieldsProps {
|
||||
isEndpointModalOpen: boolean;
|
||||
onEndpointModalToggle: (open: boolean) => void;
|
||||
onCustomEndpointsChange?: (endpoints: string[]) => void;
|
||||
autoSelect: boolean;
|
||||
onAutoSelectChange: (checked: boolean) => void;
|
||||
|
||||
// Model Selector
|
||||
shouldShowModelSelector: boolean;
|
||||
@@ -83,6 +85,8 @@ export function ClaudeFormFields({
|
||||
isEndpointModalOpen,
|
||||
onEndpointModalToggle,
|
||||
onCustomEndpointsChange,
|
||||
autoSelect,
|
||||
onAutoSelectChange,
|
||||
shouldShowModelSelector,
|
||||
claudeModel,
|
||||
reasoningModel,
|
||||
@@ -170,6 +174,8 @@ export function ClaudeFormFields({
|
||||
initialEndpoints={speedTestEndpoints}
|
||||
visible={isEndpointModalOpen}
|
||||
onClose={() => onEndpointModalToggle(false)}
|
||||
autoSelect={autoSelect}
|
||||
onAutoSelectChange={onAutoSelectChange}
|
||||
onCustomEndpointsChange={onCustomEndpointsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,8 @@ interface CodexFormFieldsProps {
|
||||
isEndpointModalOpen: boolean;
|
||||
onEndpointModalToggle: (open: boolean) => void;
|
||||
onCustomEndpointsChange?: (endpoints: string[]) => void;
|
||||
autoSelect: boolean;
|
||||
onAutoSelectChange: (checked: boolean) => void;
|
||||
|
||||
// Model Name
|
||||
shouldShowModelField?: boolean;
|
||||
@@ -50,6 +52,8 @@ export function CodexFormFields({
|
||||
isEndpointModalOpen,
|
||||
onEndpointModalToggle,
|
||||
onCustomEndpointsChange,
|
||||
autoSelect,
|
||||
onAutoSelectChange,
|
||||
shouldShowModelField = true,
|
||||
modelName = "",
|
||||
onModelNameChange,
|
||||
@@ -130,6 +134,8 @@ export function CodexFormFields({
|
||||
initialEndpoints={speedTestEndpoints}
|
||||
visible={isEndpointModalOpen}
|
||||
onClose={() => onEndpointModalToggle(false)}
|
||||
autoSelect={autoSelect}
|
||||
onAutoSelectChange={onAutoSelectChange}
|
||||
onCustomEndpointsChange={onCustomEndpointsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -30,6 +30,8 @@ interface EndpointSpeedTestProps {
|
||||
initialEndpoints: EndpointCandidate[];
|
||||
visible?: boolean;
|
||||
onClose: () => void;
|
||||
autoSelect: boolean;
|
||||
onAutoSelectChange: (checked: boolean) => void;
|
||||
// 新建模式:当自定义端点列表变化时回传(仅包含 isCustom 的条目)
|
||||
// 编辑模式:不使用此回调,端点直接保存到后端
|
||||
onCustomEndpointsChange?: (urls: string[]) => void;
|
||||
@@ -85,6 +87,8 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
||||
initialEndpoints,
|
||||
visible = true,
|
||||
onClose,
|
||||
autoSelect,
|
||||
onAutoSelectChange,
|
||||
onCustomEndpointsChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -93,7 +97,6 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
||||
);
|
||||
const [customUrl, setCustomUrl] = useState("");
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const [autoSelect, setAutoSelect] = useState(true);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -488,7 +491,9 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoSelect}
|
||||
onChange={(event) => setAutoSelect(event.target.checked)}
|
||||
onChange={(event) => {
|
||||
onAutoSelectChange(event.target.checked);
|
||||
}}
|
||||
className="h-3.5 w-3.5 rounded border-border-default bg-background text-primary focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
{t("endpointTest.autoSelect")}
|
||||
|
||||
@@ -29,6 +29,8 @@ interface GeminiFormFieldsProps {
|
||||
isEndpointModalOpen: boolean;
|
||||
onEndpointModalToggle: (open: boolean) => void;
|
||||
onCustomEndpointsChange: (endpoints: string[]) => void;
|
||||
autoSelect: boolean;
|
||||
onAutoSelectChange: (checked: boolean) => void;
|
||||
|
||||
// Model
|
||||
shouldShowModelField: boolean;
|
||||
@@ -55,6 +57,8 @@ export function GeminiFormFields({
|
||||
isEndpointModalOpen,
|
||||
onEndpointModalToggle,
|
||||
onCustomEndpointsChange,
|
||||
autoSelect,
|
||||
onAutoSelectChange,
|
||||
shouldShowModelField,
|
||||
model,
|
||||
onModelChange,
|
||||
@@ -142,6 +146,8 @@ export function GeminiFormFields({
|
||||
initialEndpoints={speedTestEndpoints}
|
||||
visible={isEndpointModalOpen}
|
||||
onClose={() => onEndpointModalToggle(false)}
|
||||
autoSelect={autoSelect}
|
||||
onAutoSelectChange={onAutoSelectChange}
|
||||
onCustomEndpointsChange={onCustomEndpointsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -124,6 +124,9 @@ export function ProviderForm({
|
||||
return [];
|
||||
},
|
||||
);
|
||||
const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(
|
||||
() => initialData?.meta?.endpointAutoSelect ?? true,
|
||||
);
|
||||
|
||||
// 使用 category hook
|
||||
const { category } = useProviderCategory({
|
||||
@@ -141,6 +144,7 @@ export function ProviderForm({
|
||||
if (!initialData) {
|
||||
setDraftCustomEndpoints([]);
|
||||
}
|
||||
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
|
||||
}, [appId, initialData]);
|
||||
|
||||
const defaultValues: ProviderFormData = useMemo(
|
||||
@@ -647,6 +651,13 @@ export function ProviderForm({
|
||||
}
|
||||
}
|
||||
|
||||
const baseMeta: ProviderMeta | undefined =
|
||||
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
|
||||
payload.meta = {
|
||||
...(baseMeta ?? {}),
|
||||
endpointAutoSelect,
|
||||
};
|
||||
|
||||
onSubmit(payload);
|
||||
};
|
||||
|
||||
@@ -856,6 +867,8 @@ export function ProviderForm({
|
||||
onCustomEndpointsChange={
|
||||
isEditMode ? undefined : setDraftCustomEndpoints
|
||||
}
|
||||
autoSelect={endpointAutoSelect}
|
||||
onAutoSelectChange={setEndpointAutoSelect}
|
||||
shouldShowModelSelector={category !== "official"}
|
||||
claudeModel={claudeModel}
|
||||
reasoningModel={reasoningModel}
|
||||
@@ -889,6 +902,8 @@ export function ProviderForm({
|
||||
onCustomEndpointsChange={
|
||||
isEditMode ? undefined : setDraftCustomEndpoints
|
||||
}
|
||||
autoSelect={endpointAutoSelect}
|
||||
onAutoSelectChange={setEndpointAutoSelect}
|
||||
shouldShowModelField={category !== "official"}
|
||||
modelName={codexModelName}
|
||||
onModelNameChange={handleCodexModelNameChange}
|
||||
@@ -917,6 +932,8 @@ export function ProviderForm({
|
||||
isEndpointModalOpen={isEndpointModalOpen}
|
||||
onEndpointModalToggle={setIsEndpointModalOpen}
|
||||
onCustomEndpointsChange={setDraftCustomEndpoints}
|
||||
autoSelect={endpointAutoSelect}
|
||||
onAutoSelectChange={setEndpointAutoSelect}
|
||||
shouldShowModelField={true}
|
||||
model={geminiModel}
|
||||
onModelChange={handleGeminiModelChange}
|
||||
|
||||
@@ -968,6 +968,7 @@
|
||||
"configDetails": "Config Details",
|
||||
"configUrl": "Config File URL",
|
||||
"configMergeError": "Failed to merge configuration file",
|
||||
"primaryEndpoint": "Primary",
|
||||
"mcp": {
|
||||
"title": "Batch Import MCP Servers",
|
||||
"targetApps": "Target Apps",
|
||||
|
||||
@@ -968,6 +968,7 @@
|
||||
"configDetails": "設定の詳細",
|
||||
"configUrl": "設定ファイル URL",
|
||||
"configMergeError": "設定ファイルのマージに失敗しました",
|
||||
"primaryEndpoint": "メイン",
|
||||
"mcp": {
|
||||
"title": "MCP サーバーを一括インポート",
|
||||
"targetApps": "ターゲットアプリ",
|
||||
|
||||
@@ -968,6 +968,7 @@
|
||||
"configDetails": "配置详情",
|
||||
"configUrl": "配置文件 URL",
|
||||
"configMergeError": "合并配置文件失败",
|
||||
"primaryEndpoint": "主",
|
||||
"mcp": {
|
||||
"title": "批量导入 MCP Servers",
|
||||
"targetApps": "目标应用",
|
||||
|
||||
@@ -92,6 +92,8 @@ export interface ProviderMeta {
|
||||
custom_endpoints?: Record<string, CustomEndpoint>;
|
||||
// 用量查询脚本配置
|
||||
usage_script?: UsageScript;
|
||||
// 请求地址管理:测速后自动选择最佳端点
|
||||
endpointAutoSelect?: boolean;
|
||||
// 是否为官方合作伙伴
|
||||
isPartner?: boolean;
|
||||
// 合作伙伴促销 key(用于后端识别 PackyCode 等)
|
||||
|
||||
Reference in New Issue
Block a user