Files
MyAiDesk/nomifun-tauri/crates/backend/nomifun-system/src/model_fetcher/mod.rs
T
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

300 lines
11 KiB
Rust

mod fetchers;
mod url_fixer;
use std::sync::Arc;
use nomifun_api_types::{BedrockConfig, FetchModelsAnonymousRequest, FetchModelsRequest, FetchModelsResponse};
use nomifun_common::{AppError, decrypt_string};
use nomifun_db::IProviderRepository;
use crate::provider::deserialize_opt;
/// Internal configuration extracted from a provider row for model fetching.
#[derive(Debug)]
pub(crate) struct FetchConfig {
pub platform: String,
pub base_url: String,
pub api_key: String,
pub bedrock_config: Option<BedrockConfig>,
}
/// Service for fetching model lists from remote provider APIs.
#[derive(Clone)]
pub struct ModelFetchService {
repo: Arc<dyn IProviderRepository>,
encryption_key: [u8; 32],
http_client: reqwest::Client,
}
impl ModelFetchService {
pub fn new(repo: Arc<dyn IProviderRepository>, encryption_key: [u8; 32], http_client: reqwest::Client) -> Self {
Self {
repo,
encryption_key,
http_client,
}
}
/// Fetch models for a provider by ID. If `try_fix` is true and the
/// initial request fails on an OpenAI-compatible platform, attempt
/// URL auto-correction with parallel probing.
pub async fn fetch_models(
&self,
provider_id: &str,
req: &FetchModelsRequest,
) -> Result<FetchModelsResponse, AppError> {
let config = self.load_provider_config(provider_id).await?;
self.fetch_with_config(&config, req.try_fix).await
}
/// Fetch models using credentials supplied in the request, without a
/// persisted provider row. Powers the pre-create "Fetch Models" preview
/// in the Add-Platform form.
pub async fn fetch_models_anonymous(
&self,
req: &FetchModelsAnonymousRequest,
) -> Result<FetchModelsResponse, AppError> {
validate_anonymous_request(req)?;
let config = FetchConfig {
platform: req.platform.clone(),
base_url: req.base_url.clone(),
api_key: req.api_key.clone(),
bedrock_config: req.bedrock_config.clone(),
};
self.fetch_with_config(&config, req.try_fix).await
}
/// Shared fetch+try_fix branch used by both the by-id and anonymous
/// entry points.
async fn fetch_with_config(&self, config: &FetchConfig, try_fix: bool) -> Result<FetchModelsResponse, AppError> {
match fetchers::fetch_for_platform(&self.http_client, config).await {
Ok(models) => Ok(FetchModelsResponse {
models,
fixed_base_url: None,
}),
Err(err) if try_fix && supports_url_fix(&config.platform) => {
url_fixer::try_fix_url(&self.http_client, config).await.map_err(|_| err)
}
Err(err) => Err(err),
}
}
/// Extract and decrypt provider configuration from DB.
async fn load_provider_config(&self, provider_id: &str) -> Result<FetchConfig, AppError> {
let row = self
.repo
.find_by_id(provider_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("Provider {provider_id} not found")))?;
let api_key = decrypt_string(&row.api_key_encrypted, &self.encryption_key)?;
if api_key.trim().is_empty() {
return Err(AppError::BadRequest("API key is empty".into()));
}
let bedrock_config: Option<BedrockConfig> = deserialize_opt(&row.bedrock_config, "bedrock_config")?;
Ok(FetchConfig {
platform: row.platform,
base_url: row.base_url,
api_key,
bedrock_config,
})
}
}
/// Validate a `FetchModelsAnonymousRequest` — platform / base_url / api_key
/// must all be non-empty after trim.
fn validate_anonymous_request(req: &FetchModelsAnonymousRequest) -> Result<(), AppError> {
if req.platform.trim().is_empty() {
return Err(AppError::BadRequest("platform is required".into()));
}
if req.base_url.trim().is_empty() {
return Err(AppError::BadRequest("baseUrl is required".into()));
}
// Bedrock uses bedrock_config for credentials; empty api_key is allowed there.
if req.platform != "bedrock" && req.api_key.trim().is_empty() {
return Err(AppError::BadRequest("apiKey is required".into()));
}
Ok(())
}
/// Platforms that support URL auto-fix (OpenAI-compatible).
fn supports_url_fix(platform: &str) -> bool {
!matches!(
platform,
"anthropic" | "claude" | "gemini" | "bedrock" | "vertex-ai" | "minimax" | "dashscope-coding"
)
}
#[cfg(test)]
mod tests {
use super::*;
use nomifun_common::encrypt_string;
use nomifun_db::{CreateProviderParams, SqliteProviderRepository, init_database_memory};
const TEST_KEY: [u8; 32] = [0x42; 32];
async fn setup() -> (ModelFetchService, nomifun_db::Database) {
let db = init_database_memory().await.unwrap();
let repo = Arc::new(SqliteProviderRepository::new(db.pool().clone()));
let svc = ModelFetchService::new(repo, TEST_KEY, reqwest::Client::new());
(svc, db)
}
async fn create_provider(db: &nomifun_db::Database, platform: &str, base_url: &str, api_key: &str) -> String {
let repo = SqliteProviderRepository::new(db.pool().clone());
let encrypted = encrypt_string(api_key, &TEST_KEY).unwrap();
let row = repo
.create(CreateProviderParams {
id: None,
platform,
name: "Test",
base_url,
api_key_encrypted: &encrypted,
models: "[]",
enabled: true,
capabilities: "[]",
context_limit: None,
model_protocols: None,
model_enabled: None,
model_health: None,
bedrock_config: None,
is_full_url: false,
})
.await
.unwrap();
row.id
}
#[test]
fn supports_url_fix_openai_compatible() {
assert!(supports_url_fix("openai"));
assert!(supports_url_fix("new-api"));
assert!(supports_url_fix("some-custom-provider"));
}
#[test]
fn supports_url_fix_non_openai() {
assert!(!supports_url_fix("anthropic"));
assert!(!supports_url_fix("claude"));
assert!(!supports_url_fix("gemini"));
assert!(!supports_url_fix("bedrock"));
assert!(!supports_url_fix("vertex-ai"));
assert!(!supports_url_fix("minimax"));
assert!(!supports_url_fix("dashscope-coding"));
}
#[tokio::test]
async fn load_config_nonexistent_provider_returns_not_found() {
let (svc, _db) = setup().await;
let err = svc.load_provider_config("no_such_id").await.unwrap_err();
assert_eq!(err.status_code(), axum::http::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn load_config_empty_api_key_returns_bad_request() {
let (svc, db) = setup().await;
let id = create_provider(&db, "openai", "https://api.openai.com", " ").await;
let err = svc.load_provider_config(&id).await.unwrap_err();
assert_eq!(err.status_code(), axum::http::StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn load_config_decrypts_api_key() {
let (svc, db) = setup().await;
let id = create_provider(&db, "openai", "https://api.openai.com", "sk-test-key").await;
let config = svc.load_provider_config(&id).await.unwrap();
assert_eq!(config.api_key, "sk-test-key");
assert_eq!(config.platform, "openai");
assert_eq!(config.base_url, "https://api.openai.com");
assert!(config.bedrock_config.is_none());
}
#[tokio::test]
async fn fetch_models_vertex_ai_returns_hardcoded() {
let (svc, db) = setup().await;
let id = create_provider(&db, "vertex-ai", "https://unused", "fake-key").await;
let req = FetchModelsRequest { try_fix: false };
let resp = svc.fetch_models(&id, &req).await.unwrap();
assert_eq!(resp.models.len(), 2);
assert!(resp.fixed_base_url.is_none());
}
#[tokio::test]
async fn fetch_models_minimax_returns_hardcoded() {
let (svc, db) = setup().await;
let id = create_provider(&db, "minimax", "https://unused", "fake-key").await;
let req = FetchModelsRequest { try_fix: false };
let resp = svc.fetch_models(&id, &req).await.unwrap();
assert_eq!(resp.models.len(), 3);
}
#[tokio::test]
async fn fetch_models_nonexistent_provider() {
let (svc, _db) = setup().await;
let req = FetchModelsRequest { try_fix: false };
let err = svc.fetch_models("no_such_id", &req).await.unwrap_err();
assert_eq!(err.status_code(), axum::http::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn fetch_models_anonymous_minimax_returns_hardcoded() {
let (svc, _db) = setup().await;
let req = FetchModelsAnonymousRequest {
platform: "minimax".into(),
base_url: "https://unused".into(),
api_key: "fake-key".into(),
bedrock_config: None,
try_fix: false,
};
let resp = svc.fetch_models_anonymous(&req).await.unwrap();
assert_eq!(resp.models.len(), 3);
assert!(resp.fixed_base_url.is_none());
}
#[tokio::test]
async fn fetch_models_anonymous_rejects_empty_api_key() {
let (svc, _db) = setup().await;
let req = FetchModelsAnonymousRequest {
platform: "openai".into(),
base_url: "https://api.openai.com".into(),
api_key: " ".into(),
bedrock_config: None,
try_fix: false,
};
let err = svc.fetch_models_anonymous(&req).await.unwrap_err();
assert_eq!(err.status_code(), axum::http::StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn fetch_models_anonymous_rejects_empty_platform() {
let (svc, _db) = setup().await;
let req = FetchModelsAnonymousRequest {
platform: "".into(),
base_url: "https://api.openai.com".into(),
api_key: "sk-test".into(),
bedrock_config: None,
try_fix: false,
};
let err = svc.fetch_models_anonymous(&req).await.unwrap_err();
assert_eq!(err.status_code(), axum::http::StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn fetch_models_anonymous_bedrock_allows_empty_api_key() {
// Bedrock uses bedrock_config for credentials, not api_key.
// With no bedrock_config attached the fetcher itself will fail,
// but validate_anonymous_request must not reject up-front.
let (_svc, _db) = setup().await;
let req = FetchModelsAnonymousRequest {
platform: "bedrock".into(),
base_url: "https://bedrock.example".into(),
api_key: "".into(),
bedrock_config: None,
try_fix: false,
};
assert!(validate_anonymous_request(&req).is_ok());
}
}