Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ rand = "0.8"
tempfile = "3.27.0"
zip = "2.2.0"
rig = { version = "0.40.0", default-features = false, features = ["reqwest", "native-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
futures = "0.3.30"

[dev-dependencies]
193 changes: 174 additions & 19 deletions src-tauri/src/command/llm_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::error::AppError;
use crate::llm::service::{normalize_provider_base_url, provider_requires_key, LlmService};
use crate::llm::types::ConnectionReport;
use rig::client::ModelListingClient;
use rig::model::ModelListingError;
use rig::model::{Model, ModelList, ModelListingError};
use serde::Serialize;
use std::time::Duration;
use tokio::time::timeout;
Expand Down Expand Up @@ -278,22 +278,7 @@ async fn fetch_model_list_with_credential(
.map_err(|_| AppError::network("获取模型列表超时"))?
.map_err(map_model_listing_error)?
}
LlmProviderPreset::DeepSeek => {
let client = rig::providers::deepseek::Client::builder()
.api_key(api_key)
.base_url(&base_url)
.build()
.map_err(|error| {
AppError::configuration("无法创建大模型客户端").with_detail(error.to_string())
})?;
timeout(
Duration::from_secs(MODEL_LIST_TIMEOUT_SECONDS),
client.list_models(),
)
.await
.map_err(|_| AppError::network("获取模型列表超时"))?
.map_err(map_model_listing_error)?
}
LlmProviderPreset::DeepSeek => fetch_openai_compatible_models(&base_url, api_key).await?,
LlmProviderPreset::OpenAi | LlmProviderPreset::OpenAiResponses => {
let client = rig::providers::openai::Client::builder()
.api_key(api_key)
Expand All @@ -302,13 +287,29 @@ async fn fetch_model_list_with_credential(
.map_err(|error| {
AppError::configuration("无法创建大模型客户端").with_detail(error.to_string())
})?;
timeout(
match timeout(
Duration::from_secs(MODEL_LIST_TIMEOUT_SECONDS),
client.list_models(),
)
.await
.map_err(|_| AppError::network("获取模型列表超时"))?
.map_err(map_model_listing_error)?
{
Ok(models) => models,
Err(rig_error) => fetch_openai_compatible_models(&base_url, api_key)
.await
.map_err(|fallback_error| {
let rig_error = map_model_listing_error(rig_error);
let fallback_detail = fallback_error
.detail
.as_deref()
.unwrap_or(fallback_error.message.as_str())
.to_string();
fallback_error.with_detail(format!(
"rig 获取失败:{};/models 兜底失败:{}",
rig_error.message, fallback_detail,
))
})?,
}
}
LlmProviderPreset::MiniMax | LlmProviderPreset::Moonshot | LlmProviderPreset::ZAi => {
return Err(AppError::provider(
Expand Down Expand Up @@ -375,6 +376,88 @@ async fn fetch_model_list_with_credential(
Ok(names)
}

fn openai_compatible_models_url(base_url: &str) -> String {
format!("{}/models", base_url.trim().trim_end_matches('/'))
}

async fn fetch_openai_compatible_models(
base_url: &str,
api_key: &str,
) -> Result<ModelList, AppError> {
let url = openai_compatible_models_url(base_url);
let response = timeout(
Duration::from_secs(MODEL_LIST_TIMEOUT_SECONDS),
reqwest::Client::new()
.get(&url)
.bearer_auth(api_key)
.header(reqwest::header::ACCEPT, "application/json")
.send(),
)
.await
.map_err(|_| AppError::network("获取模型列表超时"))?
.map_err(|error| {
AppError::network("无法连接大模型服务获取模型列表").with_detail(error.to_string())
})?;

let status = response.status();
let body = response.text().await.map_err(|error| {
AppError::network("读取模型列表响应失败").with_detail(error.to_string())
})?;
if !status.is_success() {
return Err(map_openai_compatible_models_status(status.as_u16(), body));
}

Ok(ModelList {
data: parse_openai_compatible_models(&body)?,
})
}

fn map_openai_compatible_models_status(status_code: u16, body: String) -> AppError {
let mut mapped = match status_code {
401 | 403 => AppError::credential("大模型密钥无效或无权获取模型列表"),
404 => AppError::provider("大模型服务未提供模型列表接口,请手动填写模型名称"),
429 => AppError::provider("获取模型列表受限或账户额度不足"),
_ => AppError::provider(format!("获取模型列表失败(HTTP {status_code})")),
};
mapped = mapped.with_detail(format!("HTTP {status_code}; {body}"));
mapped
}

fn parse_openai_compatible_models(body: &str) -> Result<Vec<Model>, AppError> {
let value: serde_json::Value = serde_json::from_str(body).map_err(|error| {
AppError::provider("模型列表响应解析失败,请手动填写模型名称")
.with_detail(error.to_string())
})?;
let items = if let Some(data) = value.get("data") {
data.as_array()
} else {
value.as_array()
}
.ok_or_else(|| {
AppError::provider("模型列表响应解析失败,请手动填写模型名称").with_detail("缺少 data 数组")
})?;

let mut names = items
.iter()
.filter_map(|item| {
item.as_str()
.or_else(|| item.get("id").and_then(|id| id.as_str()))
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_string)
})
.collect::<Vec<_>>();
names.sort();
names.dedup();
if names.is_empty() {
return Err(
AppError::provider("模型列表响应解析失败,请手动填写模型名称")
.with_detail("响应中没有可用模型标识"),
);
}
Ok(names.into_iter().map(Model::from_id).collect())
}

fn map_model_listing_error(error: ModelListingError) -> AppError {
match error {
ModelListingError::ApiError {
Expand Down Expand Up @@ -604,6 +687,78 @@ mod tests {
assert_eq!(error.code, AppErrorCode::Credential);
}

#[test]
fn openai_compatible_models_url_normalizes_trailing_slashes() {
assert_eq!(
openai_compatible_models_url(" https://api.deepseek.com/// "),
"https://api.deepseek.com/models"
);
assert_eq!(
openai_compatible_models_url(" https://proxy.example.test/v1/ "),
"https://proxy.example.test/v1/models"
);
assert_eq!(
openai_compatible_models_url("https://proxy.example.test/v1///"),
"https://proxy.example.test/v1/models"
);
}

#[test]
fn parse_openai_compatible_models_accepts_standard_openai_payload() {
let models = parse_openai_compatible_models(
r#"{"object":"list","data":[{"id":"deepseek-chat"},{"id":"deepseek-reasoner"}]}"#,
)
.unwrap();

assert_eq!(
models.into_iter().map(|model| model.id).collect::<Vec<_>>(),
vec!["deepseek-chat", "deepseek-reasoner"]
);
}

#[test]
fn parse_openai_compatible_models_accepts_string_data_items() {
let models = parse_openai_compatible_models(r#"{"data":["model-a","model-b"]}"#).unwrap();

assert_eq!(
models.into_iter().map(|model| model.id).collect::<Vec<_>>(),
vec!["model-a", "model-b"]
);
}

#[test]
fn parse_openai_compatible_models_accepts_top_level_array() {
let models =
parse_openai_compatible_models(r#"[{"id":"model-a"},{"id":"model-b"}]"#).unwrap();

assert_eq!(
models.into_iter().map(|model| model.id).collect::<Vec<_>>(),
vec!["model-a", "model-b"]
);
}

#[test]
fn parse_openai_compatible_models_sorts_deduplicates_and_filters_blank_ids() {
let models = parse_openai_compatible_models(
r#"{"data":[{"id":" z-model "},{"id":""},{"id":"a-model"},"z-model",{"id":" "}]}"#,
)
.unwrap();

assert_eq!(
models.into_iter().map(|model| model.id).collect::<Vec<_>>(),
vec!["a-model", "z-model"]
);
}

#[test]
fn parse_openai_compatible_models_reports_missing_model_ids() {
let error = parse_openai_compatible_models(r#"{"data":[{"object":"model"}]}"#).unwrap_err();

assert_eq!(error.code, AppErrorCode::Provider);
assert!(error.message.contains("模型列表响应解析失败"));
assert_eq!(error.detail.as_deref(), Some("响应中没有可用模型标识"));
}

#[test]
fn batch_status_never_serializes_a_secret_field() {
let statuses = collect_entry_credential_status(&["backup-a".to_string()], |_| {
Expand Down
29 changes: 20 additions & 9 deletions src-tauri/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,13 +354,15 @@ fn parse_llm_config(
serde_yaml::from_value(value.clone()).map_err(|error| error.to_string())?;
let base_url = raw.base_url.unwrap_or_default();
let model = raw.model.unwrap_or_default();
if allow_incomplete_legacy && (base_url.trim().is_empty() || model.trim().is_empty()) {
let has_service_fields = !base_url.trim().is_empty() || !model.trim().is_empty();
if allow_incomplete_legacy && !service_is_usable(&base_url, &model) {
return Ok(None);
}

let provider = match raw.provider {
Some(provider) => provider,
None if allow_incomplete_legacy => infer_legacy_provider(&base_url),
None if !has_service_fields => return Ok(None),
None => return Err("大模型服务预设不能为空".to_string()),
};
let mut config = LlmConfig {
Expand Down Expand Up @@ -1005,6 +1007,9 @@ impl AppRuntimeConfig {
let Some(primary) = self.llm_config.as_ref() else {
return Vec::new();
};
if !service_is_usable(&primary.base_url, &primary.model) {
return Vec::new();
}

let mut chain = Vec::with_capacity(self.llm_fallbacks.len() + 1);
chain.push(LlmChainLink {
Expand Down Expand Up @@ -2078,11 +2083,11 @@ mod tests {
/// 旧版已写入的不完整主用服务必须能读出来修复,但不允许再次落盘。
#[test]
fn incomplete_current_llm_config_loads_as_a_draft_but_cannot_be_persisted() {
for (base_url, model) in [
("", "qwen3"),
(" ", "qwen3"),
("http://localhost/v1", ""),
("http://localhost/v1", " "),
for (base_url, model, expected_base_url, expected_model) in [
("", "qwen3", "", "qwen3"),
(" ", "qwen3", "", "qwen3"),
(" http://localhost/v1/// ", "", "http://localhost/v1", ""),
("http://localhost/v1", " ", "http://localhost/v1", ""),
] {
let mut config = default_app_config();
config.llm_config = Some(LlmConfig {
Expand All @@ -2093,8 +2098,9 @@ mod tests {

let yaml = serde_yaml::to_string(&config).unwrap();
let mut loaded = parse_config_content(&yaml).expect("历史草稿不应阻断启动");

assert!(loaded.llm_config.is_some());
let llm = loaded.llm_config.as_ref().unwrap();
assert_eq!(llm.base_url, expected_base_url);
assert_eq!(llm.model, expected_model);
assert!(!loaded.llm_active());
assert!(loaded.llm_chain().is_empty());
let error = validate_and_normalize(&mut loaded).unwrap_err();
Expand Down Expand Up @@ -2125,7 +2131,7 @@ llm_config:
schema_version: 3
llm_config:
provider: openai
base_url: https://llm.example.test/v1
base_url: https://llm.example.test/v1/
model: ""
browser_config:
user_data_dir: ""
Expand All @@ -2134,6 +2140,11 @@ browser_config:
)
.expect("历史草稿必须能加载");

let llm = config.llm_config.as_ref().unwrap();
assert_eq!(llm.provider, LlmProviderPreset::OpenAi);
assert_eq!(llm.base_url, "https://llm.example.test/v1");
assert_eq!(llm.model, "");
assert!(config.llm_chain().is_empty());
assert!(!load_repairs_can_be_persisted(&config));
}

Expand Down
Loading
Loading