Skip to main content

vectorless/summarizer/
llm.rs

1// Copyright (c) 2026 vectorless developers
2// SPDX-License-Identifier: Apache-2.0
3
4//! LLM provider using async-openai.
5//!
6//! This module provides utilities for LLM-based text summarization.
7//! API keys are automatically detected from environment variables.
8
9use async_openai::{
10    types::chat::{ChatCompletionRequestSystemMessage, ChatCompletionRequestUserMessage, CreateChatCompletionRequestArgs},
11    Client,
12    config::OpenAIConfig,
13    error::OpenAIError,
14};
15use thiserror::Error;
16use crate::config::SummaryConfig;
17
18/// LLM error types.
19#[derive(Debug, Error)]
20pub enum LlmError {
21    /// API error
22    #[error("API error: {0}")]
23    Api(String),
24
25    /// Request construction error
26    #[error("Request error: {0}")]
27    Request(String),
28
29    /// Configuration error
30    #[error("Configuration error: {0}")]
31    Config(String),
32}
33
34impl From<OpenAIError> for LlmError {
35    fn from(e: OpenAIError) -> Self {
36        LlmError::Api(e.to_string())
37    }
38}
39
40/// Get API key from environment variables.
41///
42/// Checks in order: OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.
43fn get_api_key_from_env() -> Option<String> {
44    std::env::var("OPENAI_API_KEY").ok()
45        .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
46        .or_else(|| std::env::var("AZURE_OPENAI_API_KEY").ok())
47}
48
49/// Get API base URL from environment variables.
50fn get_api_base_from_env() -> Option<String> {
51    std::env::var("OPENAI_API_BASE").ok()
52        .or_else(|| std::env::var("OPENAI_BASE_URL").ok())
53        .or_else(|| std::env::var("AZURE_OPENAI_ENDPOINT").ok())
54}
55
56/// Generate a summary of the given text.
57///
58/// Uses the configured summary model for cost-effective indexing.
59/// API key is automatically detected from environment variables if not configured.
60pub async fn summarize(
61    config: &SummaryConfig,
62    text: &str,
63) -> Result<String, LlmError> {
64    // Use config API key or fall back to environment
65    let api_key = config.api_key.as_ref()
66        .cloned()
67        .or_else(get_api_key_from_env)
68        .ok_or_else(|| LlmError::Config(
69            "No API key found. Set OPENAI_API_KEY environment variable or configure in SummaryConfig.".to_string()
70        ))?;
71
72    // Use config endpoint or fall back to environment
73    let api_base = if config.endpoint.is_empty() || config.endpoint == "https://api.openai.com/v1" {
74        get_api_base_from_env().unwrap_or_else(|| config.endpoint.clone())
75    } else {
76        config.endpoint.clone()
77    };
78
79    let openai_config = OpenAIConfig::new()
80        .with_api_key(api_key)
81        .with_api_base(&api_base);
82    let client = Client::with_config(openai_config);
83
84    let truncated = if text.len() > 8000 { &text[..8000] } else { text };
85
86    let request = CreateChatCompletionRequestArgs::default()
87        .model(&config.model)
88        .messages(
89            [
90                // System message: define the task and behavior
91                ChatCompletionRequestSystemMessage::from(
92                    "You are a helpful assistant that summarizes text concisely and accurately. \
93                     Summarize the user's text in 2-3 sentences. Be specific and factual. \
94                     Do not add anything not in the original text."
95                ).into(),
96                // User message: the actual content to summarize
97                ChatCompletionRequestUserMessage::from(truncated).into(),
98            ]
99        )
100        .temperature(1.0)
101        .build()
102        .map_err(|e| LlmError::Request(e.to_string()))?;
103
104    let response = client.chat().create(request).await?;
105    let content = response
106        .choices
107        .first()
108        .map(|choice| choice.message.content.clone())
109        .unwrap_or_default();
110
111    content.ok_or_else(|| LlmError::Api("LLM returned no content".to_string()))
112}