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
This commit is contained in:
coso
2025-12-14 03:18:52 +08:00
parent 4c2840e08f
commit aca733ceb3
6 changed files with 305 additions and 0 deletions
+78
View File
@@ -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<OpenAICustomStatus, String> {
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<String>,
base_url: Option<String>,
enabled: bool,
) -> Result<String, String> {
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<ClaudeCustomStatus, String> {
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<String>,
base_url: Option<String>,
enabled: bool,
) -> Result<String, String> {
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<Vec<logger::LogEntry>, 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,
+86
View File
@@ -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<String>,
pub base_url: Option<String>,
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<reqwest::Response, Box<dyn Error + Send + Sync>> {
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<serde_json::Value, Box<dyn Error + Send + Sync>> {
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)
}
}
+4
View File
@@ -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;
+79
View File
@@ -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<String>,
pub base_url: Option<String>,
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<reqwest::Response, Box<dyn Error + Send + Sync>> {
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<serde_json::Value, Box<dyn Error + Send + Sync>> {
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)
}
}
+9
View File
@@ -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<oneshot::Sender<()>>,
}
@@ -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,
}
}
+49
View File
@@ -217,3 +217,52 @@ export async function getQwenTokenFileHash(): Promise<string> {
export async function checkAndReloadQwenCredentials(lastHash: string): Promise<CheckResult> {
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<OpenAICustomStatus> {
return invoke("get_openai_custom_status");
}
export async function setOpenAICustomConfig(
apiKey: string | null,
baseUrl: string | null,
enabled: boolean
): Promise<string> {
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<ClaudeCustomStatus> {
return invoke("get_claude_custom_status");
}
export async function setClaudeCustomConfig(
apiKey: string | null,
baseUrl: string | null,
enabled: boolean
): Promise<string> {
return invoke("set_claude_custom_config", {
api_key: apiKey,
base_url: baseUrl,
enabled
});
}