mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
* feat(provider-form): soften business-rule validation with "save anyway" prompt Refactor handleSubmit so empty-field / missing-item validations (provider name, endpoint, API key, opencode model, template variables, provider key required) no longer hard-reject with toast.error. Instead they are collected into an issues list and presented via a ConfirmDialog; the user can cancel or choose "Save anyway" to proceed. Integrity constraints stay as hard rejections: - providerKey regex / duplicate (would corrupt other providers) - Copilot / Codex OAuth not authenticated (no token, cannot establish) - omo Other Fields JSON not an object / parse failure This aligns the frontend with the backend's existing "relaxed save / strict switch" split (see gemini_config.rs: validate_gemini_settings vs validate_gemini_settings_strict) and unblocks legitimate configs such as AWS Bedrock, Vertex AI, and custom Gemini base URLs that the UI previously refused to save. Refs: #2196, #1204 * fix(provider-form): address review feedback on soft-validation P1: move empty providerKey back to hard rejection for OpenCode / OpenClaw / Hermes. Since providerKey is the primary identity for these apps and the mutations layer throws "Provider key is required" when absent, letting users click "save anyway" would surface a generic error toast instead of a precise, actionable one. Treat empty providerKey as an integrity constraint alongside regex / duplicate checks. P2: give the soft-confirm submit path its own submitting state. The confirm-dialog path bypassed react-hook-form's isSubmitting lifecycle, so slow or failing saves left the outer submit button responsive and could spawn unhandled rejections. Now the confirm handler awaits performSubmit inside try/catch/finally, uses an isConfirmSubmitting flag to gate both confirm and cancel clicks, and folds the flag into the outer disabled state and onSubmittingChange callback. Refs: #2307 review comments * chore(clippy): use push for single char '…' in truncate_body Clippy 1.95 added single_char_add_str which flagged the push_str("…") in truncate_body. Rebased onto latest upstream/main and applied the suggested fix so the Backend Checks clippy job passes. Unrelated to this PR's core changes; bundled in so the PR is mergeable without waiting for a separate upstream fix. --------- Co-authored-by: Allen <allen@AllenMacBook-M4-Pro.local>
415 lines
13 KiB
Rust
415 lines
13 KiB
Rust
//! 模型列表获取服务
|
||
//!
|
||
//! 通过 OpenAI 兼容的 GET /v1/models 端点获取供应商可用模型列表。
|
||
//! 主要面向第三方聚合站(硅基流动、OpenRouter 等),以及把 Anthropic
|
||
//! 协议挂在兼容子路径上的官方供应商(DeepSeek、Kimi、智谱 GLM 等)。
|
||
|
||
use reqwest::StatusCode;
|
||
use serde::{Deserialize, Serialize};
|
||
use std::time::Duration;
|
||
|
||
/// 获取到的模型信息
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct FetchedModel {
|
||
pub id: String,
|
||
pub owned_by: Option<String>,
|
||
}
|
||
|
||
/// OpenAI 兼容的 /v1/models 响应格式
|
||
#[derive(Debug, Deserialize)]
|
||
struct ModelsResponse {
|
||
data: Option<Vec<ModelEntry>>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct ModelEntry {
|
||
id: String,
|
||
owned_by: Option<String>,
|
||
}
|
||
|
||
const FETCH_TIMEOUT_SECS: u64 = 15;
|
||
|
||
/// 404/405 响应体截断长度:避免把几十 KB HTML 404 页整页保留到错误串里。
|
||
const ERROR_BODY_MAX_CHARS: usize = 512;
|
||
|
||
/// 已知的「Anthropic 协议兼容子路径」后缀;按长度降序,最长前缀优先匹配。
|
||
/// baseURL 命中这些后缀时,候选列表会追加「剥离后缀再拼 /v1/models / /models」的版本。
|
||
const KNOWN_COMPAT_SUFFIXES: &[&str] = &[
|
||
"/api/claudecode",
|
||
"/api/anthropic",
|
||
"/apps/anthropic",
|
||
"/api/coding",
|
||
"/claudecode",
|
||
"/anthropic",
|
||
"/step_plan",
|
||
"/coding",
|
||
"/claude",
|
||
];
|
||
|
||
/// 获取供应商的可用模型列表
|
||
///
|
||
/// 使用 OpenAI 兼容的 GET /v1/models 端点,按候选列表顺序尝试。
|
||
pub async fn fetch_models(
|
||
base_url: &str,
|
||
api_key: &str,
|
||
is_full_url: bool,
|
||
models_url_override: Option<&str>,
|
||
) -> Result<Vec<FetchedModel>, String> {
|
||
if api_key.is_empty() {
|
||
return Err("API Key is required to fetch models".to_string());
|
||
}
|
||
|
||
let candidates = build_models_url_candidates(base_url, is_full_url, models_url_override)?;
|
||
let client = crate::proxy::http_client::get();
|
||
let mut last_err: Option<String> = None;
|
||
|
||
for url in &candidates {
|
||
log::debug!("[ModelFetch] Trying endpoint: {url}");
|
||
let response = match client
|
||
.get(url)
|
||
.header("Authorization", format!("Bearer {api_key}"))
|
||
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
|
||
.send()
|
||
.await
|
||
{
|
||
Ok(r) => r,
|
||
Err(e) => {
|
||
return Err(format!("Request failed: {e}"));
|
||
}
|
||
};
|
||
|
||
let status = response.status();
|
||
|
||
if status.is_success() {
|
||
let resp: ModelsResponse = response
|
||
.json()
|
||
.await
|
||
.map_err(|e| format!("Failed to parse response: {e}"))?;
|
||
|
||
let mut models: Vec<FetchedModel> = resp
|
||
.data
|
||
.unwrap_or_default()
|
||
.into_iter()
|
||
.map(|m| FetchedModel {
|
||
id: m.id,
|
||
owned_by: m.owned_by,
|
||
})
|
||
.collect();
|
||
|
||
models.sort_by(|a, b| a.id.cmp(&b.id));
|
||
return Ok(models);
|
||
}
|
||
|
||
if status == StatusCode::NOT_FOUND || status == StatusCode::METHOD_NOT_ALLOWED {
|
||
let body = truncate_body(response.text().await.unwrap_or_default());
|
||
last_err = Some(format!("HTTP {status}: {body}"));
|
||
continue;
|
||
}
|
||
|
||
let body = truncate_body(response.text().await.unwrap_or_default());
|
||
return Err(format!("HTTP {status}: {body}"));
|
||
}
|
||
|
||
Err(format!(
|
||
"All candidates failed: {}",
|
||
last_err.unwrap_or_else(|| "no candidates".to_string())
|
||
))
|
||
}
|
||
|
||
/// 构造「模型列表端点」的候选 URL 列表
|
||
///
|
||
/// 候选顺序:
|
||
/// 1. `models_url_override` 非空 → 只返回它
|
||
/// 2. baseURL 直接拼 `/v1/models`(若已有 `/v1` 结尾则拼 `/models`)
|
||
/// 3. 若 baseURL 命中 [`KNOWN_COMPAT_SUFFIXES`],剥离后缀再拼 `/v1/models`
|
||
/// 4. 同上,但拼 `/models`(部分站点如 DeepSeek 官方只暴露 `/models`)
|
||
///
|
||
/// 结果已去重且保持首次出现顺序。
|
||
pub fn build_models_url_candidates(
|
||
base_url: &str,
|
||
is_full_url: bool,
|
||
models_url_override: Option<&str>,
|
||
) -> Result<Vec<String>, String> {
|
||
if let Some(raw) = models_url_override {
|
||
let trimmed = raw.trim();
|
||
if !trimmed.is_empty() {
|
||
return Ok(vec![trimmed.to_string()]);
|
||
}
|
||
}
|
||
|
||
let trimmed = base_url.trim().trim_end_matches('/');
|
||
if trimmed.is_empty() {
|
||
return Err("Base URL is empty".to_string());
|
||
}
|
||
|
||
let mut candidates: Vec<String> = Vec::new();
|
||
|
||
if is_full_url {
|
||
if let Some(idx) = trimmed.find("/v1/") {
|
||
candidates.push(format!("{}/v1/models", &trimmed[..idx]));
|
||
} else if let Some(idx) = trimmed.rfind('/') {
|
||
let root = &trimmed[..idx];
|
||
if root.contains("://") && root.len() > root.find("://").unwrap() + 3 {
|
||
candidates.push(format!("{root}/v1/models"));
|
||
}
|
||
}
|
||
if candidates.is_empty() {
|
||
return Err("Cannot derive models endpoint from full URL".to_string());
|
||
}
|
||
return Ok(candidates);
|
||
}
|
||
|
||
let primary = if trimmed.ends_with("/v1") {
|
||
format!("{trimmed}/models")
|
||
} else {
|
||
format!("{trimmed}/v1/models")
|
||
};
|
||
candidates.push(primary);
|
||
|
||
if let Some(stripped) = strip_compat_suffix(trimmed) {
|
||
let root = stripped.trim_end_matches('/');
|
||
if !root.is_empty() && root.contains("://") {
|
||
candidates.push(format!("{root}/v1/models"));
|
||
candidates.push(format!("{root}/models"));
|
||
}
|
||
}
|
||
|
||
// 候选最多 3 条,线性去重即可,不值得上 HashSet。
|
||
let mut unique: Vec<String> = Vec::with_capacity(candidates.len());
|
||
for url in candidates {
|
||
if !unique.iter().any(|u| u == &url) {
|
||
unique.push(url);
|
||
}
|
||
}
|
||
|
||
Ok(unique)
|
||
}
|
||
|
||
/// 截断响应体到 [`ERROR_BODY_MAX_CHARS`] 字符,避免 HTML 404 页占用错误串。
|
||
fn truncate_body(body: String) -> String {
|
||
if body.chars().count() <= ERROR_BODY_MAX_CHARS {
|
||
body
|
||
} else {
|
||
let mut s: String = body.chars().take(ERROR_BODY_MAX_CHARS).collect();
|
||
s.push('…');
|
||
s
|
||
}
|
||
}
|
||
|
||
/// 若 baseURL 以任一已知兼容子路径结尾,返回剥离后的剩余部分;否则 `None`。
|
||
///
|
||
/// 依赖 [`KNOWN_COMPAT_SUFFIXES`] 按长度降序排列,确保最长前缀优先命中
|
||
/// (否则 `/anthropic` 会提前匹配掉 `/api/anthropic` 的场景)。
|
||
fn strip_compat_suffix(base_url: &str) -> Option<&str> {
|
||
for suffix in KNOWN_COMPAT_SUFFIXES {
|
||
if base_url.ends_with(*suffix) {
|
||
return Some(&base_url[..base_url.len() - suffix.len()]);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_candidates_plain_root() {
|
||
let c = build_models_url_candidates("https://api.siliconflow.cn", false, None).unwrap();
|
||
assert_eq!(c, vec!["https://api.siliconflow.cn/v1/models"]);
|
||
}
|
||
|
||
#[test]
|
||
fn test_candidates_trailing_slash() {
|
||
let c = build_models_url_candidates("https://api.example.com/", false, None).unwrap();
|
||
assert_eq!(c, vec!["https://api.example.com/v1/models"]);
|
||
}
|
||
|
||
#[test]
|
||
fn test_candidates_with_v1() {
|
||
let c = build_models_url_candidates("https://api.example.com/v1", false, None).unwrap();
|
||
assert_eq!(c, vec!["https://api.example.com/v1/models"]);
|
||
}
|
||
|
||
#[test]
|
||
fn test_candidates_full_url() {
|
||
let c = build_models_url_candidates(
|
||
"https://proxy.example.com/v1/chat/completions",
|
||
true,
|
||
None,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(c, vec!["https://proxy.example.com/v1/models"]);
|
||
}
|
||
|
||
#[test]
|
||
fn test_candidates_empty() {
|
||
assert!(build_models_url_candidates("", false, None).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_candidates_override_returns_single() {
|
||
let c = build_models_url_candidates(
|
||
"https://api.deepseek.com/anthropic",
|
||
false,
|
||
Some("https://api.deepseek.com/models"),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(c, vec!["https://api.deepseek.com/models"]);
|
||
}
|
||
|
||
#[test]
|
||
fn test_candidates_override_empty_falls_through() {
|
||
let c =
|
||
build_models_url_candidates("https://api.siliconflow.cn", false, Some(" ")).unwrap();
|
||
assert_eq!(c, vec!["https://api.siliconflow.cn/v1/models"]);
|
||
}
|
||
|
||
#[test]
|
||
fn test_candidates_deepseek_strip_anthropic() {
|
||
let c =
|
||
build_models_url_candidates("https://api.deepseek.com/anthropic", false, None).unwrap();
|
||
assert_eq!(
|
||
c,
|
||
vec![
|
||
"https://api.deepseek.com/anthropic/v1/models",
|
||
"https://api.deepseek.com/v1/models",
|
||
"https://api.deepseek.com/models",
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_candidates_zhipu_strip_api_anthropic() {
|
||
let c = build_models_url_candidates("https://open.bigmodel.cn/api/anthropic", false, None)
|
||
.unwrap();
|
||
assert_eq!(
|
||
c,
|
||
vec![
|
||
"https://open.bigmodel.cn/api/anthropic/v1/models",
|
||
"https://open.bigmodel.cn/v1/models",
|
||
"https://open.bigmodel.cn/models",
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_candidates_bailian_strip_apps_anthropic() {
|
||
let c = build_models_url_candidates(
|
||
"https://dashscope.aliyuncs.com/apps/anthropic",
|
||
false,
|
||
None,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
c,
|
||
vec![
|
||
"https://dashscope.aliyuncs.com/apps/anthropic/v1/models",
|
||
"https://dashscope.aliyuncs.com/v1/models",
|
||
"https://dashscope.aliyuncs.com/models",
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_candidates_stepfun_strip_step_plan() {
|
||
let c =
|
||
build_models_url_candidates("https://api.stepfun.com/step_plan", false, None).unwrap();
|
||
assert_eq!(
|
||
c,
|
||
vec![
|
||
"https://api.stepfun.com/step_plan/v1/models",
|
||
"https://api.stepfun.com/v1/models",
|
||
"https://api.stepfun.com/models",
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_candidates_doubao_strip_api_coding() {
|
||
let c = build_models_url_candidates(
|
||
"https://ark.cn-beijing.volces.com/api/coding",
|
||
false,
|
||
None,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
c,
|
||
vec![
|
||
"https://ark.cn-beijing.volces.com/api/coding/v1/models",
|
||
"https://ark.cn-beijing.volces.com/v1/models",
|
||
"https://ark.cn-beijing.volces.com/models",
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_candidates_rightcode_strip_claude() {
|
||
let c = build_models_url_candidates("https://www.right.codes/claude", false, None).unwrap();
|
||
assert_eq!(
|
||
c,
|
||
vec![
|
||
"https://www.right.codes/claude/v1/models",
|
||
"https://www.right.codes/v1/models",
|
||
"https://www.right.codes/models",
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_candidates_longer_suffix_wins() {
|
||
// baseURL 以 /api/anthropic 结尾时,应剥离整个 /api/anthropic,
|
||
// 而不是只剥离 /anthropic(那样会得到残缺的 https://.../api 根)。
|
||
let c = build_models_url_candidates("https://api.z.ai/api/anthropic", false, None).unwrap();
|
||
assert_eq!(
|
||
c,
|
||
vec![
|
||
"https://api.z.ai/api/anthropic/v1/models",
|
||
"https://api.z.ai/v1/models",
|
||
"https://api.z.ai/models",
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_candidates_no_suffix_no_strip() {
|
||
let c = build_models_url_candidates("https://openrouter.ai/api", false, None).unwrap();
|
||
assert_eq!(c, vec!["https://openrouter.ai/api/v1/models"]);
|
||
}
|
||
|
||
#[test]
|
||
fn test_candidates_deduplicate() {
|
||
// 虚构 case:baseURL 就是 "scheme://host",剥不出子路径,应只有一个候选。
|
||
let c = build_models_url_candidates("https://host.example.com", false, None).unwrap();
|
||
assert_eq!(c.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_response() {
|
||
let json = r#"{"object":"list","data":[{"id":"gpt-4","object":"model","owned_by":"openai"},{"id":"claude-3-sonnet","object":"model","owned_by":"anthropic"}]}"#;
|
||
let resp: ModelsResponse = serde_json::from_str(json).unwrap();
|
||
let data = resp.data.unwrap();
|
||
assert_eq!(data.len(), 2);
|
||
assert_eq!(data[0].id, "gpt-4");
|
||
assert_eq!(data[0].owned_by.as_deref(), Some("openai"));
|
||
assert_eq!(data[1].id, "claude-3-sonnet");
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_response_no_owned_by() {
|
||
let json = r#"{"object":"list","data":[{"id":"my-model","object":"model"}]}"#;
|
||
let resp: ModelsResponse = serde_json::from_str(json).unwrap();
|
||
let data = resp.data.unwrap();
|
||
assert_eq!(data[0].id, "my-model");
|
||
assert!(data[0].owned_by.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_response_empty_data() {
|
||
let json = r#"{"object":"list","data":[]}"#;
|
||
let resp: ModelsResponse = serde_json::from_str(json).unwrap();
|
||
assert!(resp.data.unwrap().is_empty());
|
||
}
|
||
}
|