From f5a96e356b5d9d008bbe0e0e5221591c069c2098 Mon Sep 17 00:00:00 2001 From: NingNing0111 Date: Tue, 25 Aug 2026 11:18:48 +0800 Subject: [PATCH] fix(llm): fetch DeepSeek models from provider endpoint Merge upstream LLM config changes while keeping model listing credential handling server-side. DeepSeek now uses the provider's OpenAI-compatible /models endpoint instead of relying on a hard-coded list. Constraint: Keep saved LLM API keys in backend credential storage Rejected: Hard-code DeepSeek model list | provider exposes /models endpoint Confidence: high Scope-risk: moderate Not-tested: Full cargo fmt check fails on pre-existing unrelated rustfmt diffs --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/command/llm_provider.rs | 193 +++++++++++++++++++++--- src-tauri/src/config.rs | 29 ++-- src/view/config/LlmConfigPanel.test.tsx | 59 +++++++- src/view/config/LlmConfigPanel.tsx | 4 +- 6 files changed, 249 insertions(+), 38 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index efdde92..ac2d13a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5816,6 +5816,7 @@ dependencies = [ "once_cell", "rand 0.8.6", "regex", + "reqwest 0.12.28", "rig", "rust_drission", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3dbbc16..d1ae3c6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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] diff --git a/src-tauri/src/command/llm_provider.rs b/src-tauri/src/command/llm_provider.rs index c35fe20..36fb5f0 100644 --- a/src-tauri/src/command/llm_provider.rs +++ b/src-tauri/src/command/llm_provider.rs @@ -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; @@ -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) @@ -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( @@ -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 { + 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, 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::>(); + 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 { @@ -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!["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!["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!["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!["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()], |_| { diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 96fcbc4..c1fc3a5 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -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 { @@ -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 { @@ -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 { @@ -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(); @@ -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: "" @@ -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)); } diff --git a/src/view/config/LlmConfigPanel.test.tsx b/src/view/config/LlmConfigPanel.test.tsx index a841350..a488cba 100644 --- a/src/view/config/LlmConfigPanel.test.tsx +++ b/src/view/config/LlmConfigPanel.test.tsx @@ -14,14 +14,16 @@ import { PRIMARY_LLM_ENTRY_ID, } from "@/types/app-config"; -vi.mock("@tauri-apps/api/core", () => ({ - invoke: vi.fn((command: string) => - Promise.resolve( - command === "list_llm_credential_status" - ? { success: true, data: [], error: null } - : { success: true, data: { configured: false, source: "none" }, error: null }, - ), +const invokeMock = vi.hoisted(() => vi.fn((command: string): Promise => + Promise.resolve( + command === "list_llm_credential_status" + ? { success: true, data: [], error: null } + : { success: true, data: { configured: false, source: "none" }, error: null }, ), +)); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: invokeMock, })); import { @@ -193,7 +195,7 @@ function Harness({ onPersistAll, dirty, }: { - initialConfig?: LlmConfig; + initialConfig?: LlmConfig | null; initialFallbacks?: LlmProviderEntry[]; onFallbacks?: (next: LlmProviderEntry[]) => void; onRetry?: (next: LlmRetryConfig) => void; @@ -462,6 +464,47 @@ describe("LlmConfigPanel 降级链界面", () => { expect(screen.getByRole("button", { name: "下移备用 2" })).toBeDisabled(); }); + it("主用模型名为空时仍保留草稿编辑区并提示补全后才会生效", async () => { + render(); + + expect(await screen.findByLabelText(`${PRIMARY_LLM_ENTRY_ID} 服务地址`)).toBeInTheDocument(); + expect(screen.getByLabelText(`${PRIMARY_LLM_ENTRY_ID} 模型`)).toBeInTheDocument(); + expect(screen.getByLabelText(`${PRIMARY_LLM_ENTRY_ID} API Key`)).toBeInTheDocument(); + expect(screen.getByText("补齐服务地址和模型后,大模型才会启用")).toBeInTheDocument(); + expect(screen.getByText(/补齐前不会触发自动保存/)).toBeInTheDocument(); + }); + + it("模型列表自动填入第一个模型后再次展开仍展示完整候选", async () => { + invokeMock.mockImplementation((command: string) => { + if (command === "list_llm_credential_status") { + return Promise.resolve({ + success: true, + data: [{ entry_id: PRIMARY_LLM_ENTRY_ID, configured: true, source: "keychain" }], + error: null, + }); + } + if (command === "list_llm_models") { + return Promise.resolve({ + success: true, + data: ["deepseek-chat", "deepseek-coder", "deepseek-reasoner"], + error: null, + }); + } + return Promise.resolve({ success: true, data: { configured: false, source: "none" }, error: null }); + }); + render(); + + const modelInput = await screen.findByLabelText(`${PRIMARY_LLM_ENTRY_ID} 模型`); + fireEvent.click(screen.getByRole("button", { name: "刷新primary模型列表" })); + + await waitFor(() => expect(modelInput).toHaveValue("deepseek-chat")); + fireEvent.mouseDown(modelInput); + + for (const model of ["deepseek-chat", "deepseek-coder", "deepseek-reasoner"]) { + expect((await screen.findAllByText(model)).length).toBeGreaterThan(0); + } + }); + // 越界取值的夹紧行为由 clampRetryAttempts / clampRetryBaseDelay 的纯函数测试覆盖。 // 这里只验证控件本身声明了正确的取值边界,并且合法输入能正常回传, // 不再往 max=5 的 InputNumber 里塞 99——rc-input-number 会直接拒绝该输入, diff --git a/src/view/config/LlmConfigPanel.tsx b/src/view/config/LlmConfigPanel.tsx index 9171eee..432ce6e 100644 --- a/src/view/config/LlmConfigPanel.tsx +++ b/src/view/config/LlmConfigPanel.tsx @@ -537,7 +537,7 @@ export function LlmConfigPanel({ options={entryModels.map((model) => ({ value: model, label: model }))} placeholder="直接输入模型名称,或点右侧按钮获取" onChange={(model) => patchEntry(entryId, { model })} - filterOption={(input, option) => String(option?.value ?? "").toLowerCase().includes(input.toLowerCase())} + filterOption={false} />