Files
cc-switch/src-tauri/src/services/model_fetch.rs
T
85f0be9e1d feat(provider-form): soften validation with "save anyway" prompt (#2307)
* 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>
2026-04-25 09:28:28 +08:00

415 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 模型列表获取服务
//!
//! 通过 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() {
// 虚构 casebaseURL 就是 "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());
}
}