{"repo": "agions/vynaro", "files": {"crates/vynaro-script/src/lib.rs": {"7bd402ca7866de2bd615bb46a3bf15e0bba3e603": {"t": 1786068396.669566, "n": 21281, "h": "2a3eb3842c5beb17", "c": "//! vynaro-llm v1.0.0 · LlmProvider trait + 11 provider 真实实现 + LlmManager 故障切换\n//!\n//! ## 11 个 Provider\n//! - Qwen (Qwen3-Max / Qwen-Plus / Qwen-Turbo) — OpenAI 兼容\n//! - Kimi (Moonshot) — OpenAI 兼容\n//! - GLM5 (智谱) — OpenAI 兼容\n//! - Claude (Anthropic) — Anthropic 原生\n//! - Gemini (Google) — Google Gen AI 原生\n//! - DeepSeek — OpenAI 兼容\n//! - Doubao (字节豆包) — OpenAI 兼容\n//! - Hunyuan (腾讯混元) — OpenAI 兼容\n//! - Local (Ollama 等本地) — OpenAI 兼容\n//! - OpenAI (GPT-4o / GPT-4-Turbo) — OpenAI\n//! - Qwen3.7 (实验版) — OpenAI 兼容\n//!\n//! ## M3 范围\n//! - 9 个 OpenAI 兼容 provider 共享 `OpenAiCompatible` 底座\n//! - Claude 与 Gemini 各自实现原生协议\n//! - 11 个 provider 全部可实际调用\n//! - `LlmProviderFactory` 按 `LlmProviderKind` 工厂创建\n//!\n//! ## 历史\n//! - M2: OpenAI 作为 PoC,其余 10 个 stub\n//! - M3: 全部 11 个真实实现\n\nuse async_trait::async_trait;\nuse serde::{Deserialize, Serialize};\n\npub use vynaro_core::error::{LlmProviderKind, VynaroError, VynaroResult};\n\n// ════════════════════════════════════════════════════════════════════════\n// 输入 / 输出契约\n// ════════════════════════════════════════════════════════════════════════\n\n/// 单次 LLM 调用的输入。\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct LlmRequest {\n pub system: String,\n pub user: String,\n pub model: Option, // 默认使用 provider 的 default model\n pub max_tokens: Option, // 默认 4096\n pub temperature: Option, // 默认 0.7\n pub stream: bool, // 是否流式\n}\n\n/// LLM 响应 (一次性)。\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct LlmResponse {\n pub content: String,\n pub model: String,\n pub tokens_used: u32,\n pub duration_ms: u64,\n}\n\n/// LlmProvider trait — 每个 11 个 provider 实现它。\n#[async_trait]\npub trait LlmProvider: Send + Sync {\n fn kind(&self) -> LlmProviderKind;\n fn default_model(&self) -> &'static str;\n async fn chat(&self, req: &LlmRequest) -> VynaroResult;\n}\n\n// ════════════════════════════════════════════════════════════════════════\n// 公共 builder: 9 个 OpenAI 兼容 provider 共享\n// ════════════════════════════════════════════════════════════════════════\n\n/// OpenAI 兼容协议 (8 个厂商 + OpenAI 自己也用)\n#[derive(Debug, Clone)]\npub struct OpenAiCompatible {\n pub kind: LlmProviderKind,\n pub api_key: String,\n pub base_url: String,\n pub default_model: &'static str,\n pub client: reqwest::Client,\n}\n\nimpl OpenAiCompatible {\n pub fn new(\n kind: LlmProviderKind,\n api_key: impl Into,\n base_url: impl Into,\n default_model: &'static str,\n ) -> Self {\n Self {\n kind,\n api_key: api_key.into(),\n base_url: base_url.into(),\n default_model,\n client: reqwest::Client::builder()\n .timeout(std::time::Duration::from_secs(60))\n .build()\n .expect(\"reqwest client build\"),\n }\n }\n}\n\n#[async_trait]\nimpl LlmProvider for OpenAiCompatible {\n fn kind(&self) -> LlmProviderKind {\n self.kind\n }\n\n fn default_model(&self) -> &'static str {\n self.default_model\n }\n\n async fn chat(&self, req: &LlmRequest) -> VynaroResult {\n let start = std::time::Instant::now();\n\n let body = serde_json::json!({\n \"model\": req.model.clone().unwrap_or_else(|| self.default_model.to_string()),\n \"messages\": [\n { \"role\": \"system\", \"content\": req.system },\n { \"role\": \"user\", \"content\": req.user },\n ],\n \"max_tokens\": req.max_tokens.unwrap_or(4096),\n \"temperature\": req.temperature.unwrap_or(0.7),\n \"stream\": false,\n });\n\n let resp = self\n .client\n .post(&self.base_url)\n .bearer_auth(&self.api_key)\n .json(&body)\n .send()\n .await\n .map_err(|e| VynaroError::Llm {\n provider: self.kind,\n message: format!(\"request send failed: {e}\"),\n })?;\n\n let status = resp.status();\n if !status.is_success() {\n let body = resp.text().await.unwrap_or_default();\n return Err(VynaroError::Llm {\n provider: self.kind,\n message: format!(\"HTTP {status}: {body}\"),\n });\n }\n\n #[derive(Deserialize)]\n struct Choice {\n message: ChoiceMessage,\n }\n #[derive(Deserialize)]\n struct ChoiceMessage {\n content: String,\n }\n #[derive(Deserialize)]\n struct Usage {\n total_tokens: u32,\n }\n #[derive(Deserialize)]\n struct ApiResp {\n model: String,\n choices: Vec,\n usage: Usage,\n }\n\n let api: ApiResp = resp.json().await.map_err(|e| VynaroError::Llm {\n provider: self.kind,\n message: format!(\"response json parse failed: {e}\"),\n })?;\n\n let content = api\n .choices\n .into_iter()\n .next()\n .map(|c| c.message.content)\n .unwrap_or_default();\n\n Ok(LlmResponse {\n content,\n model: api.model,\n tokens_used: api.usage.total_tokens,\n duration_ms: start.elapsed().as_millis() as u64,\n })\n }\n}\n\n// ════════════════════════════════════════════════════════════════════════\n// Claude (Anthropic 原生协议)\n// ════════════════════════════════════════════════════════════════════════\n\nconst CLAUDE_MESSAGES_URL: &str = \"https://api.anthropic.com/v1/messages\";\n\n#[derive(Debug, Clone)]\npub struct ClaudeProvider {\n api_key: String,\n client: reqwest::Client,\n}\n\nimpl ClaudeProvider {\n pub fn new(api_key: impl Into) -> Self {\n Self {\n api_key: api_key.into(),\n client: reqwest::Client::builder()\n .timeout(std::time::Duration::from_secs(60))\n .build()\n .expect(\"reqwest client build\"),\n }\n }\n}\n\n#[async_trait]\nimpl LlmProvider for ClaudeProvider {\n fn kind(&self) -> LlmProviderKind {\n LlmProviderKind::Claude\n }\n\n fn default_model(&self) -> &'static str {\n \"claude-sonnet-5\"\n }\n\n async fn chat(&self, req: &LlmRequest) -> VynaroResult {\n let start = std::time::Instant::now();\n\n let body = serde_json::json!({\n \"model\": req.model.clone().unwrap_or_else(|| self.default_model().to_string()),\n \"system\": req.system,\n \"messages\": [\n { \"role\": \"user\", \"content\": req.user },\n ],\n \"max_tokens\": req.max_tokens.unwrap_or(4096),\n \"temperature\": req.temperature.unwrap_or(0.7),\n });\n\n let resp = self\n .client\n .post(CLAUDE_MESSAGES_URL)\n .header(\"x-api-key\", &self.api_key)\n .header(\"anthropic-version\", \"2023-06-01\")\n .json(&body)\n .send()\n .await\n .map_err(|e| VynaroError::Llm {\n provider: LlmProviderKind::Claude,\n message: format!(\"request send failed: {e}\"),\n })?;\n\n let status = resp.status();\n if !status.is_success() {\n let body = resp.text().await.unwrap_or_default();\n return Err(VynaroError::Llm {\n provider: LlmProviderKind::Claude,\n message: format!(\"HTTP {status}: {body}\"),\n });\n }\n\n #[derive(Deserialize)]\n struct Content {\n #[serde(rename = \"type\")]\n kind: String,\n text: Option,\n }\n #[derive(Deserialize)]\n struct ApiResp {\n model: String,\n content: Vec,\n usage: ClaudeUsage,\n }\n #[derive(Deserialize)]\n struct ClaudeUsage {\n input_tokens: u32,\n output_tokens: u32,\n }\n\n let api: ApiResp = resp.json().await.map_err(|e| VynaroError::Llm {\n provider: LlmProviderKind::Claude,\n message: format!(\"response json parse failed: {e}\"),\n })?;\n\n let content = api\n .content\n .into_iter()\n .filter(|c| c.kind == \"text\")\n .filter_map(|c| c.text)\n .collect::>()\n .join(\"\");\n\n Ok(LlmResponse {\n content,\n model: api.model,\n tokens_used: api.usage.input_tokens + api.usage.output_tokens,\n duration_ms: start.elapsed().as_millis() as u64,\n })\n }\n}\n\n// ════════════════════════════════════════════════════════════════════════\n// Gemini (Google Gen AI 原生协议)\n// ════════════════════════════════════════════════════════════════════════\n\n#[derive(Debug, Clone)]\npub struct GeminiProvider {\n api_key: String,\n client: reqwest::Client,\n}\n\nimpl GeminiProvider {\n pub fn new(api_key: impl Into) -> Self {\n Self {\n api_key: api_key.into(),\n client: reqwest::Client::builder()\n .timeout(std::time::Duration::from_secs(60))\n .build()\n .expect(\"reqwest client build\"),\n }\n }\n}\n\n#[async_trait]\nimpl LlmProvider for GeminiProvider {\n fn kind(&self) -> LlmProviderKind {\n LlmProviderKind::Gemini\n }\n\n fn default_model(&self) -> &'static str {\n \"gemini-3.6-flash\"\n }\n\n async fn chat(&self, req: &LlmRequest) -> VynaroResult {\n let start = std::time::Instant::now();\n\n let model = req\n .model\n .clone()\n .unwrap_or_else(|| self.default_model().to_string());\n // 使用 streamGenerateContent=false 切到非流式\n let url = format!(\n \"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}\",\n model = model,\n key = self.api_key,\n );\n\n let body = serde_json::json!({\n \"systemInstruction\": {\n \"parts\": [{ \"text\": req.system }],\n },\n \"contents\": [\n {\n \"role\": \"user\",\n \"parts\": [{ \"text\": req.user }],\n },\n ],\n \"generationConfig\": {\n \"maxOutputTokens\": req.max_tokens.unwrap_or(4096),\n \"temperature\": req.temperature.unwrap_or(0.7),\n },\n });\n\n let resp = self\n .client\n .post(&url)\n .json(&body)\n .send()\n .await\n .map_err(|e| VynaroError::Llm {\n provider: LlmProviderKind::Gemini,\n message: format!(\"request send failed: {e}\"),\n })?;\n\n let status = resp.status();\n if !status.is_success() {\n let body = resp.text().await.unwrap_or_default();\n return Err(VynaroError::Llm {\n provider: LlmProviderKind::Gemini,\n message: format!(\"HTTP {status}: {body}\"),\n });\n }\n\n #[derive(Deserialize)]\n struct Part {\n text: Option,\n }\n #[derive(Deserialize)]\n struct Content {\n parts: Vec,\n }\n #[derive(Deserialize)]\n struct Candidate {\n content: Content,\n }\n #[derive(Deserialize)]\n struct UsageMetadata {\n total_token_count: u32,\n }\n #[derive(Deserialize)]\n struct ApiResp {\n candidates: Vec,\n #[serde(rename = \"modelVersion\")]\n model_version: String,\n #[serde(rename = \"usageMetadata\")]\n usage_metadata: UsageMetadata,\n }\n\n let api: ApiResp = resp.json().await.map_err(|e| VynaroError::Llm {\n provider: LlmProviderKind::Gemini,\n message: format!(\"response json parse failed: {e}\"),\n })?;\n\n let content = api\n .candidates\n .into_iter()\n .next()\n .map(|c| {\n c.content\n .parts\n .into_iter()\n .filter_map(|p| p.text)\n .collect::>()\n .join(\"\")\n })\n .unwrap_or_default();\n\n Ok(LlmResponse {\n content,\n model: api.model_version,\n tokens_used: api.usage_metadata.total_token_count,\n duration_ms: start.elapsed().as_millis() as u64,\n })\n }\n}\n\n// ════════════════════════════════════════════════════════════════════════\n// Provider 别名 (11 个工厂 constructor)\n// ════════════════════════════════════════════════════════════════════════\n\n/// OpenAI 公开别名 (M2 兼容)\npub type OpenAiProvider = OpenAiCompatible;\n\npub fn openai(api_key: impl Into) -> OpenAiProvider {\n OpenAiCompatible::new(\n LlmProviderKind::OpenAi,\n api_key,\n \"https://api.openai.com/v1/chat/completions\",\n \"gpt-5.6-sol\",\n )\n}\n\npub fn qwen(api_key: impl Into) -> OpenAiCompatible {\n OpenAiCompatible::new(\n LlmProviderKind::Qwen,\n api_key,\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions\",\n \"qwen3.8-max\",\n )\n}\n\npub fn kimi(api_key: impl Into) -> OpenAiCompatible {\n OpenAiCompatible::new(\n LlmProviderKind::Kimi,\n api_key,\n \"https://api.moonshot.cn/v1/chat/completions\",\n \"kimi-k3\",\n )\n}\n\npub fn glm5(api_key: impl Into) -> OpenAiCompatible {\n OpenAiCompatible::new(\n LlmProviderKind::Glm5,\n api_key,\n \"https://open.bigmodel.cn/api/paas/v4/chat/completions\",\n \"glm-5.2\",\n )\n}\n\npub fn deepseek(api_key: impl Into) -> OpenAiCompatible {\n OpenAiCompatible::new(\n LlmProviderKind::DeepSeek,\n api_key,\n \"https://api.deepseek.com/v1/chat/completions\",\n \"deepseek-v4-pro\",\n )\n}\n\npub fn doubao(api_key: impl Into) -> OpenAiCompatible {\n OpenAiCompatible::new(\n LlmProviderKind::Doubao,\n api_key,\n \"https://ark.cn-beijing.volces.com/api/v3/chat/completions\",\n \"doubao-seed-2-1-pro-260628\",\n )\n}\n\npub fn hunyuan(api_key: impl Into) -> OpenAiCompatible {\n OpenAiCompatible::new(\n LlmProviderKind::Hunyuan,\n api_key,\n \"https://api.hunyuan.tencent.com/v1/chat/completions\",\n \"hunyuan-pro\",\n )\n}\n\n// Hunyuan 与 Qwen3.7 端点为开发中默认值,M3.1 任务说明:\n// - Hunyuan: 视腾讯侧正式 GA 端点调整\n// - Qwen3.7: 实验版,默认端点为标准 DashScope\npub fn local(base_url: Option) -> OpenAiCompatible {\n OpenAiCompatible::new(\n LlmProviderKind::Local,\n \"no-key-required\",\n base_url.unwrap_or_else(|| \"http://localhost:11434/v1/chat/completions\".into()),\n \"llama3.2\",\n )\n}\n\npub fn qwen37(api_key: impl Into) -> OpenAiCompatible {\n OpenAiCompatible::new(\n LlmProviderKind::Qwen37,\n api_key,\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions\",\n \"qwen3.7-max\",\n )\n}\n\npub fn claude(api_key: impl Into) -> ClaudeProvider {\n ClaudeProvider::new(api_key)\n}\n\npub fn gemini(api_key: impl Into) -> GeminiProvider {\n GeminiProvider::new(api_key)\n}\n\n/// 工厂: 按 `LlmProviderKind` 构造(API key 必传)\npub fn factory(kind: LlmProviderKind, api_key: impl Into) -> Box {\n let key: String = api_key.into();\n match kind {\n LlmProviderKind::OpenAi => Box::new(openai(key)),\n LlmProviderKind::Qwen => Box::new(qwen(key)),\n LlmProviderKind::Kimi => Box::new(kimi(key)),\n LlmProviderKind::Glm5 => Box::new(glm5(key)),\n LlmProviderKind::DeepSeek => Box::new(deepseek(key)),\n LlmProviderKind::Doubao => Box::new(doubao(key)),\n LlmProviderKind::Hunyuan => Box::new(hunyuan(key)),\n LlmProviderKind::Local => Box::new(local(None)),\n LlmProviderKind::Qwen37 => Box::new(qwen37(key)),\n LlmProviderKind::Claude => Box::new(claude(key)),\n LlmProviderKind::Gemini => Box::new(gemini(key)),\n }\n}\n\n// ════════════════════════════════════════════════════════════════════════\n// LlmManager — 故障切换链\n// ════════════════════════════════════════════════════════════════════════\n\n/// 多个 provider 链式调用,失败 fallback 至下一个。\n///\n/// 设计: 顺序尝试 `chain` 中的 provider,第一个成功的返回;全部失败返回最后一次错误。\npub struct LlmManager {\n chain: Vec>,\n}\n\nimpl std::fmt::Debug for LlmManager {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n let names: Vec<_> = self.chain.iter().map(|p| p.kind()).collect();\n f.debug_struct(\"LlmManager\").field(\"chain\", &names).finish()\n }\n}\n\nimpl LlmManager {\n pub fn new(chain: Vec>) -> Self {\n Self { chain }\n }\n\n /// 顺序尝试所有 provider,直到有成功响应。\n pub async fn chat(&self, req: &LlmRequest) -> VynaroResult {\n if self.chain.is_empty() {\n return Err(VynaroError::Llm {\n provider: LlmProviderKind::OpenAi, // 占位\n message: \"no LLM provider configured\".into(),\n });\n }\n\n let mut last_err: Option = None;\n for provider in &self.chain {\n match provider.chat(req).await {\n Ok(resp) => {\n tracing::info!(\n provider = ?provider.kind(),\n tokens = resp.tokens_used,\n duration_ms = resp.duration_ms,\n \"llm request succeeded\"\n );\n return Ok(resp);\n }\n Err(e) => {\n tracing::warn!(\n provider = ?provider.kind(),\n error = %e,\n \"llm request failed, fallback to next provider\"\n );\n last_err = Some(e);\n }\n }\n }\n\n Err(last_err.unwrap_or_else(|| {\n VynaroError::Other(\"LlmManager: chain empty after iteration\".into())\n }))\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[tokio::test]\n async fn manager_chain_empty_returns_err() {\n let m = LlmManager::new(vec![]);\n let r = m\n .chat(&LlmRequest {\n system: \"test\".into(),\n user: \"hi\".into(),\n model: None,\n max_tokens: None,\n temperature: None,\n stream: false,\n })\n .await;\n assert!(r.is_err());\n }\n\n #[test]\n fn all_eleven_providers_kind_correct() {\n let all: Vec<(&str, LlmProviderKind)> = vec![\n (\"openai\", LlmProviderKind::OpenAi),\n (\"qwen\", LlmProviderKind::Qwen),\n (\"kimi\", LlmProviderKind::Kimi),\n (\"glm5\", LlmProviderKind::Glm5),\n (\"deepseek\", LlmProviderKind::DeepSeek),\n (\"doubao\", LlmProviderKind::Doubao),\n (\"hunyuan\", LlmProviderKind::Hunyuan),\n (\"local\", LlmProviderKind::Local),\n (\"qwen37\", LlmProviderKind::Qwen37),\n (\"claude\", LlmProviderKind::Claude),\n (\"gemini\", LlmProviderKind::Gemini),\n ];\n\n for (name, kind) in all {\n let p = factory(kind, \"test-key\");\n assert_eq!(p.kind(), kind, \"factory[{name}] kind mismatch\");\n }\n }\n\n #[test]\n fn default_models_distinct() {\n let m = [\n (LlmProviderKind::OpenAi, openai(\"k\").default_model()),\n (LlmProviderKind::Qwen, qwen(\"k\").default_model()),\n (LlmProviderKind::Kimi, kimi(\"k\").default_model()),\n (LlmProviderKind::Glm5, glm5(\"k\").default_model()),\n (LlmProviderKind::DeepSeek, deepseek(\"k\").default_model()),\n (LlmProviderKind::Doubao, doubao(\"k\").default_model()),\n (LlmProviderKind::Hunyuan, hunyuan(\"k\").default_model()),\n (LlmProviderKind::Local, local(None).default_model()),\n (LlmProviderKind::Qwen37, qwen37(\"k\").default_model()),\n (LlmProviderKind::Claude, claude(\"k\").default_model()),\n (LlmProviderKind::Gemini, gemini(\"k\").default_model()),\n ];\n for (kind, model) in m {\n assert!(!model.is_empty(), \"{kind:?} default model empty\");\n }\n }\n\n #[tokio::test]\n async fn manager_chain_propagates_last_error() {\n // 1 个无法联通的 provider (无 URL) 应该得到 Err\n let bad = OpenAiCompatible::new(\n LlmProviderKind::OpenAi,\n \"k\",\n \"http://127.0.0.1:1/v1/chat/completions\", // 1 端口不可达\n \"gpt-4o-mini\",\n );\n let mgr = LlmManager::new(vec![Box::new(bad)]);\n let r = mgr\n .chat(&LlmRequest {\n system: \"test\".into(),\n user: \"hi\".into(),\n model: None,\n max_tokens: None,\n temperature: None,\n stream: false,\n })\n .await;\n assert!(r.is_err());\n }\n}\n"}}}}