* fix: replace async-openai library with post request to support custom providers * move models call to llm_client too * tweak post processing ui and add groq and cerebras --------- Co-authored-by: CJ Pais <cj@cjpais.com>
191 lines
5.6 KiB
Rust
191 lines
5.6 KiB
Rust
use crate::settings::PostProcessProvider;
|
|
use log::debug;
|
|
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE, REFERER, USER_AGENT};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ChatMessage {
|
|
role: String,
|
|
content: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ChatCompletionRequest {
|
|
model: String,
|
|
messages: Vec<ChatMessage>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ChatCompletionResponse {
|
|
choices: Vec<ChatChoice>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ChatChoice {
|
|
message: ChatMessageResponse,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ChatMessageResponse {
|
|
content: Option<String>,
|
|
}
|
|
|
|
/// Build headers for API requests based on provider type
|
|
fn build_headers(provider: &PostProcessProvider, api_key: &str) -> Result<HeaderMap, String> {
|
|
let mut headers = HeaderMap::new();
|
|
|
|
// Common headers
|
|
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
|
headers.insert(
|
|
REFERER,
|
|
HeaderValue::from_static("https://github.com/cjpais/Handy"),
|
|
);
|
|
headers.insert(
|
|
USER_AGENT,
|
|
HeaderValue::from_static("Handy/1.0 (+https://github.com/cjpais/Handy)"),
|
|
);
|
|
headers.insert("X-Title", HeaderValue::from_static("Handy"));
|
|
|
|
// Provider-specific auth headers
|
|
if !api_key.is_empty() {
|
|
if provider.id == "anthropic" {
|
|
headers.insert(
|
|
"x-api-key",
|
|
HeaderValue::from_str(api_key)
|
|
.map_err(|e| format!("Invalid API key header value: {}", e))?,
|
|
);
|
|
headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01"));
|
|
} else {
|
|
headers.insert(
|
|
AUTHORIZATION,
|
|
HeaderValue::from_str(&format!("Bearer {}", api_key))
|
|
.map_err(|e| format!("Invalid authorization header value: {}", e))?,
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(headers)
|
|
}
|
|
|
|
/// Create an HTTP client with provider-specific headers
|
|
fn create_client(provider: &PostProcessProvider, api_key: &str) -> Result<reqwest::Client, String> {
|
|
let headers = build_headers(provider, api_key)?;
|
|
reqwest::Client::builder()
|
|
.default_headers(headers)
|
|
.build()
|
|
.map_err(|e| format!("Failed to build HTTP client: {}", e))
|
|
}
|
|
|
|
/// Send a chat completion request to an OpenAI-compatible API
|
|
/// Returns Ok(Some(content)) on success, Ok(None) if response has no content,
|
|
/// or Err on actual errors (HTTP, parsing, etc.)
|
|
pub async fn send_chat_completion(
|
|
provider: &PostProcessProvider,
|
|
api_key: String,
|
|
model: &str,
|
|
prompt: String,
|
|
) -> Result<Option<String>, String> {
|
|
let base_url = provider.base_url.trim_end_matches('/');
|
|
let url = format!("{}/chat/completions", base_url);
|
|
|
|
debug!("Sending chat completion request to: {}", url);
|
|
|
|
let client = create_client(provider, &api_key)?;
|
|
|
|
let request_body = ChatCompletionRequest {
|
|
model: model.to_string(),
|
|
messages: vec![ChatMessage {
|
|
role: "user".to_string(),
|
|
content: prompt,
|
|
}],
|
|
};
|
|
|
|
let response = client
|
|
.post(&url)
|
|
.json(&request_body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("HTTP request failed: {}", e))?;
|
|
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
let error_text = response
|
|
.text()
|
|
.await
|
|
.unwrap_or_else(|_| "Failed to read error response".to_string());
|
|
return Err(format!(
|
|
"API request failed with status {}: {}",
|
|
status, error_text
|
|
));
|
|
}
|
|
|
|
let completion: ChatCompletionResponse = response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse API response: {}", e))?;
|
|
|
|
Ok(completion
|
|
.choices
|
|
.first()
|
|
.and_then(|choice| choice.message.content.clone()))
|
|
}
|
|
|
|
/// Fetch available models from an OpenAI-compatible API
|
|
/// Returns a list of model IDs
|
|
pub async fn fetch_models(
|
|
provider: &PostProcessProvider,
|
|
api_key: String,
|
|
) -> Result<Vec<String>, String> {
|
|
let base_url = provider.base_url.trim_end_matches('/');
|
|
let url = format!("{}/models", base_url);
|
|
|
|
debug!("Fetching models from: {}", url);
|
|
|
|
let client = create_client(provider, &api_key)?;
|
|
|
|
let response = client
|
|
.get(&url)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to fetch models: {}", e))?;
|
|
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
let error_text = response
|
|
.text()
|
|
.await
|
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
|
return Err(format!(
|
|
"Model list request failed ({}): {}",
|
|
status, error_text
|
|
));
|
|
}
|
|
|
|
let parsed: serde_json::Value = response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
|
|
|
let mut models = Vec::new();
|
|
|
|
// Handle OpenAI format: { data: [ { id: "..." }, ... ] }
|
|
if let Some(data) = parsed.get("data").and_then(|d| d.as_array()) {
|
|
for entry in data {
|
|
if let Some(id) = entry.get("id").and_then(|i| i.as_str()) {
|
|
models.push(id.to_string());
|
|
} else if let Some(name) = entry.get("name").and_then(|n| n.as_str()) {
|
|
models.push(name.to_string());
|
|
}
|
|
}
|
|
}
|
|
// Handle array format: [ "model1", "model2", ... ]
|
|
else if let Some(array) = parsed.as_array() {
|
|
for entry in array {
|
|
if let Some(model) = entry.as_str() {
|
|
models.push(model.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(models)
|
|
}
|