From aca733ceb380104114f5a01be56f499ba425327a Mon Sep 17 00:00:00 2001 From: coso Date: Sun, 14 Dec 2025 03:18:52 +0800 Subject: [PATCH] feat: add OpenAI and Claude custom providers - Add OpenAICustomProvider for custom OpenAI-compatible APIs - Add ClaudeCustomProvider for custom Claude APIs - Add configuration UI with API key and base URL inputs - Update Providers page with all 5 provider tabs - All providers now manageable from single page --- src-tauri/src/lib.rs | 78 +++++++++++++++++++++ src-tauri/src/providers/claude_custom.rs | 86 ++++++++++++++++++++++++ src-tauri/src/providers/mod.rs | 4 ++ src-tauri/src/providers/openai_custom.rs | 79 ++++++++++++++++++++++ src-tauri/src/server.rs | 9 +++ src/hooks/useTauri.ts | 49 ++++++++++++++ 6 files changed, 305 insertions(+) create mode 100644 src-tauri/src/providers/claude_custom.rs create mode 100644 src-tauri/src/providers/openai_custom.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 28397b452..d3fe87fd8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -533,6 +533,78 @@ async fn check_and_reload_qwen_credentials( } } +// ============ OpenAI Custom Provider Commands ============ + +#[derive(serde::Serialize, serde::Deserialize)] +struct OpenAICustomStatus { + enabled: bool, + has_api_key: bool, + base_url: String, +} + +#[tauri::command] +async fn get_openai_custom_status(state: tauri::State<'_, AppState>) -> Result { + let s = state.read().await; + let config = &s.openai_custom_provider.config; + Ok(OpenAICustomStatus { + enabled: config.enabled, + has_api_key: config.api_key.is_some(), + base_url: s.openai_custom_provider.get_base_url(), + }) +} + +#[tauri::command] +async fn set_openai_custom_config( + state: tauri::State<'_, AppState>, + logs: tauri::State<'_, LogState>, + api_key: Option, + base_url: Option, + enabled: bool, +) -> Result { + let mut s = state.write().await; + s.openai_custom_provider.config.api_key = api_key; + s.openai_custom_provider.config.base_url = base_url; + s.openai_custom_provider.config.enabled = enabled; + logs.write().await.add("info", &format!("[OpenAI Custom] 配置已更新, enabled={}", enabled)); + Ok("OpenAI Custom config updated".to_string()) +} + +// ============ Claude Custom Provider Commands ============ + +#[derive(serde::Serialize, serde::Deserialize)] +struct ClaudeCustomStatus { + enabled: bool, + has_api_key: bool, + base_url: String, +} + +#[tauri::command] +async fn get_claude_custom_status(state: tauri::State<'_, AppState>) -> Result { + let s = state.read().await; + let config = &s.claude_custom_provider.config; + Ok(ClaudeCustomStatus { + enabled: config.enabled, + has_api_key: config.api_key.is_some(), + base_url: s.claude_custom_provider.get_base_url(), + }) +} + +#[tauri::command] +async fn set_claude_custom_config( + state: tauri::State<'_, AppState>, + logs: tauri::State<'_, LogState>, + api_key: Option, + base_url: Option, + enabled: bool, +) -> Result { + let mut s = state.write().await; + s.claude_custom_provider.config.api_key = api_key; + s.claude_custom_provider.config.base_url = base_url; + s.claude_custom_provider.config.enabled = enabled; + logs.write().await.add("info", &format!("[Claude Custom] 配置已更新, enabled={}", enabled)); + Ok("Claude Custom config updated".to_string()) +} + #[tauri::command] async fn get_logs(logs: tauri::State<'_, LogState>) -> Result, String> { Ok(logs.read().await.get_logs()) @@ -638,6 +710,12 @@ pub fn run() { get_qwen_env_variables, get_qwen_token_file_hash, check_and_reload_qwen_credentials, + // OpenAI Custom commands + get_openai_custom_status, + set_openai_custom_config, + // Claude Custom commands + get_claude_custom_status, + set_claude_custom_config, // Common get_logs, clear_logs, diff --git a/src-tauri/src/providers/claude_custom.rs b/src-tauri/src/providers/claude_custom.rs new file mode 100644 index 000000000..7f0a4a50e --- /dev/null +++ b/src-tauri/src/providers/claude_custom.rs @@ -0,0 +1,86 @@ +//! Claude Custom Provider (自定义 Claude API) +use serde::{Deserialize, Serialize}; +use std::error::Error; +use reqwest::Client; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ClaudeCustomConfig { + pub api_key: Option, + pub base_url: Option, + pub enabled: bool, +} + +pub struct ClaudeCustomProvider { + pub config: ClaudeCustomConfig, + pub client: Client, +} + +impl ClaudeCustomProvider { + pub fn new() -> Self { + Self { + config: ClaudeCustomConfig::default(), + client: Client::new(), + } + } + + pub fn get_base_url(&self) -> String { + self.config.base_url + .clone() + .unwrap_or_else(|| "https://api.anthropic.com".to_string()) + } + + pub fn is_configured(&self) -> bool { + self.config.api_key.is_some() && self.config.enabled + } + + pub async fn messages( + &self, + request: &serde_json::Value, + ) -> Result> { + let api_key = self.config.api_key.as_ref() + .ok_or("Claude API key not configured")?; + + let base_url = self.get_base_url(); + let url = format!("{}/v1/messages", base_url); + + let resp = self.client + .post(&url) + .header("x-api-key", api_key) + .header("anthropic-version", "2023-06-01") + .header("Content-Type", "application/json") + .json(request) + .send() + .await?; + + Ok(resp) + } + + pub async fn count_tokens( + &self, + request: &serde_json::Value, + ) -> Result> { + let api_key = self.config.api_key.as_ref() + .ok_or("Claude API key not configured")?; + + let base_url = self.get_base_url(); + let url = format!("{}/v1/messages/count_tokens", base_url); + + let resp = self.client + .post(&url) + .header("x-api-key", api_key) + .header("anthropic-version", "2023-06-01") + .header("Content-Type", "application/json") + .json(request) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Failed to count tokens: {} - {}", status, body).into()); + } + + let data: serde_json::Value = resp.json().await?; + Ok(data) + } +} diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs index 8fcd92feb..58a68db69 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -1,7 +1,11 @@ pub mod kiro; pub mod gemini; pub mod qwen; +pub mod openai_custom; +pub mod claude_custom; pub use kiro::KiroProvider; pub use gemini::GeminiProvider; pub use qwen::QwenProvider; +pub use openai_custom::OpenAICustomProvider; +pub use claude_custom::ClaudeCustomProvider; diff --git a/src-tauri/src/providers/openai_custom.rs b/src-tauri/src/providers/openai_custom.rs new file mode 100644 index 000000000..df603fd30 --- /dev/null +++ b/src-tauri/src/providers/openai_custom.rs @@ -0,0 +1,79 @@ +//! OpenAI Custom Provider (自定义 OpenAI 兼容 API) +use serde::{Deserialize, Serialize}; +use std::error::Error; +use reqwest::Client; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct OpenAICustomConfig { + pub api_key: Option, + pub base_url: Option, + pub enabled: bool, +} + +pub struct OpenAICustomProvider { + pub config: OpenAICustomConfig, + pub client: Client, +} + +impl OpenAICustomProvider { + pub fn new() -> Self { + Self { + config: OpenAICustomConfig::default(), + client: Client::new(), + } + } + + pub fn get_base_url(&self) -> String { + self.config.base_url + .clone() + .unwrap_or_else(|| "https://api.openai.com/v1".to_string()) + } + + pub fn is_configured(&self) -> bool { + self.config.api_key.is_some() && self.config.enabled + } + + pub async fn chat_completions( + &self, + request: &serde_json::Value, + ) -> Result> { + let api_key = self.config.api_key.as_ref() + .ok_or("OpenAI API key not configured")?; + + let base_url = self.get_base_url(); + let url = format!("{}/chat/completions", base_url); + + let resp = self.client + .post(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .json(request) + .send() + .await?; + + Ok(resp) + } + + pub async fn list_models(&self) -> Result> { + let api_key = self.config.api_key.as_ref() + .ok_or("OpenAI API key not configured")?; + + let base_url = self.get_base_url(); + let url = format!("{}/models", base_url); + + let resp = self.client + .get(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Failed to list models: {} - {}", status, body).into()); + } + + let data: serde_json::Value = resp.json().await?; + Ok(data) + } +} diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs index 0203cc94b..c9b21c8d7 100644 --- a/src-tauri/src/server.rs +++ b/src-tauri/src/server.rs @@ -6,6 +6,8 @@ use crate::converter::anthropic_to_openai::convert_anthropic_to_openai; use crate::providers::kiro::KiroProvider; use crate::providers::gemini::GeminiProvider; use crate::providers::qwen::QwenProvider; +use crate::providers::openai_custom::OpenAICustomProvider; +use crate::providers::claude_custom::ClaudeCustomProvider; use crate::logger::LogStore; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -35,6 +37,8 @@ pub struct ServerState { pub kiro_provider: KiroProvider, pub gemini_provider: GeminiProvider, pub qwen_provider: QwenProvider, + pub openai_custom_provider: OpenAICustomProvider, + pub claude_custom_provider: ClaudeCustomProvider, shutdown_tx: Option>, } @@ -49,6 +53,9 @@ impl ServerState { let mut qwen = QwenProvider::new(); let _ = qwen.load_credentials(); + let openai_custom = OpenAICustomProvider::new(); + let claude_custom = ClaudeCustomProvider::new(); + Self { config, running: false, @@ -57,6 +64,8 @@ impl ServerState { kiro_provider: kiro, gemini_provider: gemini, qwen_provider: qwen, + openai_custom_provider: openai_custom, + claude_custom_provider: claude_custom, shutdown_tx: None, } } diff --git a/src/hooks/useTauri.ts b/src/hooks/useTauri.ts index 8e1e9b8e8..f5b10798b 100644 --- a/src/hooks/useTauri.ts +++ b/src/hooks/useTauri.ts @@ -217,3 +217,52 @@ export async function getQwenTokenFileHash(): Promise { export async function checkAndReloadQwenCredentials(lastHash: string): Promise { return invoke("check_and_reload_qwen_credentials", { last_hash: lastHash }); } + + +// ============ OpenAI Custom Provider ============ + +export interface OpenAICustomStatus { + enabled: boolean; + has_api_key: boolean; + base_url: string; +} + +export async function getOpenAICustomStatus(): Promise { + return invoke("get_openai_custom_status"); +} + +export async function setOpenAICustomConfig( + apiKey: string | null, + baseUrl: string | null, + enabled: boolean +): Promise { + return invoke("set_openai_custom_config", { + api_key: apiKey, + base_url: baseUrl, + enabled + }); +} + +// ============ Claude Custom Provider ============ + +export interface ClaudeCustomStatus { + enabled: boolean; + has_api_key: boolean; + base_url: string; +} + +export async function getClaudeCustomStatus(): Promise { + return invoke("get_claude_custom_status"); +} + +export async function setClaudeCustomConfig( + apiKey: string | null, + baseUrl: string | null, + enabled: boolean +): Promise { + return invoke("set_claude_custom_config", { + api_key: apiKey, + base_url: baseUrl, + enabled + }); +}