use std::io::{BufRead, BufReader};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::backend::{GenerationResult, InferenceParams, LlmBackend, TokenCallback};
use crate::error::{CoreError, CoreResult};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OpenAiEndpoint {
#[default]
Chat,
Completions,
}
#[derive(Debug, Clone)]
pub struct OpenAiConfig {
pub base_url: String,
pub model: String,
pub api_key: Option<String>,
pub endpoint: OpenAiEndpoint,
pub timeout: Duration,
}
impl OpenAiConfig {
pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
model: model.into(),
api_key: None,
endpoint: OpenAiEndpoint::default(),
timeout: DEFAULT_TIMEOUT,
}
}
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into());
self
}
pub fn with_endpoint(mut self, endpoint: OpenAiEndpoint) -> Self {
self.endpoint = endpoint;
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
}
pub struct OpenAiHttpBackend {
client: reqwest::blocking::Client,
config: OpenAiConfig,
}
impl OpenAiHttpBackend {
pub fn new(config: OpenAiConfig) -> CoreResult<Self> {
let client = reqwest::blocking::Client::builder()
.timeout(config.timeout)
.build()
.map_err(|e| CoreError::Backend(format!("failed to build HTTP client: {e}")))?;
Ok(Self { client, config })
}
}
impl LlmBackend for OpenAiHttpBackend {
fn generate(
&self,
prompt: &str,
params: &InferenceParams,
abort: Arc<AtomicBool>,
mut on_token: TokenCallback,
) -> CoreResult<GenerationResult> {
let mut body = serde_json::json!({
"model": self.config.model,
"max_tokens": params.max_tokens,
"temperature": params.temperature,
"stream": true,
"stream_options": { "include_usage": true },
});
let path = match self.config.endpoint {
OpenAiEndpoint::Chat => {
body["messages"] = serde_json::json!([{ "role": "user", "content": prompt }]);
"chat/completions"
}
OpenAiEndpoint::Completions => {
body["prompt"] = serde_json::json!(prompt);
"completions"
}
};
let url = format!("{}/{path}", self.config.base_url);
let mut req = self.client.post(&url).json(&body);
if let Some(key) = &self.config.api_key {
req = req.bearer_auth(key);
}
let resp = req
.send()
.map_err(|e| CoreError::BackendUnreachable(format!("request to {url} failed: {e}")))?;
let status = resp.status();
if !status.is_success() {
let detail = resp.text().unwrap_or_default();
return Err(CoreError::Backend(format!(
"endpoint returned HTTP {}: {}",
status.as_u16(),
detail.trim()
)));
}
let start = Instant::now();
let mut ttft_ms = 0.0;
let mut text = String::new();
let mut streamed: u32 = 0;
let mut usage_prompt: Option<u32> = None;
let mut usage_completion: Option<u32> = None;
let reader = BufReader::new(resp);
for line in reader.lines() {
if abort.load(Ordering::Relaxed) {
return Err(CoreError::Aborted);
}
let line = line
.map_err(|e| CoreError::BackendUnreachable(format!("stream read failed: {e}")))?;
let Some(data) = line.strip_prefix("data: ") else {
continue;
};
let data = data.trim();
if data == "[DONE]" {
break;
}
let Ok(chunk) = serde_json::from_str::<serde_json::Value>(data) else {
continue;
};
if let Some(usage) = chunk.get("usage").filter(|u| !u.is_null()) {
usage_prompt = usage
.get("prompt_tokens")
.and_then(|v| v.as_u64())
.map(|n| n as u32);
usage_completion = usage
.get("completion_tokens")
.and_then(|v| v.as_u64())
.map(|n| n as u32);
}
let piece = match self.config.endpoint {
OpenAiEndpoint::Chat => chunk["choices"][0]["delta"]["content"].as_str(),
OpenAiEndpoint::Completions => chunk["choices"][0]["text"].as_str(),
};
if let Some(piece) = piece {
if piece.is_empty() {
continue;
}
if streamed == 0 {
ttft_ms = start.elapsed().as_secs_f64() * 1000.0;
}
streamed += 1;
text.push_str(piece);
let elapsed = start.elapsed().as_secs_f64().max(1e-6);
on_token(piece, streamed, streamed as f64 / elapsed);
}
}
let gen_ms = start.elapsed().as_secs_f64() * 1000.0;
let tokens_generated = usage_completion.unwrap_or(streamed);
let prompt_tokens = usage_prompt.unwrap_or(0);
Ok(GenerationResult {
text,
tokens_generated,
prompt_tokens,
tokens_per_sec: tokens_generated as f64 / (gen_ms / 1000.0).max(1e-6),
time_to_first_token_ms: ttft_ms,
generation_time_ms: gen_ms,
})
}
fn tokenize_count(&self, text: &str) -> CoreResult<u32> {
Ok((text.chars().count() as u32 / 4).max(1))
}
fn is_ready(&self) -> bool {
true
}
}