use std::time::Duration;
use crate::secrets;
const ANTHROPIC_KEY_NAME: &str = "llm.anthropic";
const ANTHROPIC_URL: &str = "https://api.anthropic.com/v1/messages";
const ANTHROPIC_VERSION: &str = "2023-06-01";
const DEFAULT_MAX_TOKENS: u32 = 1024;
const SYSTEM_PROMPT: &str = "You are a spelling, typo, and minor-grammar corrector. Return ONLY the \
corrected version of the user's text — no preamble, no commentary, no \
quotation marks. Preserve the user's voice, register, and punctuation \
style. If the text is already fine, return it unchanged.";
const WORD_SYSTEM_PROMPT: &str = "You correct ONE word at a time using sentence context. The \
user gives you a SENTENCE and one WORD from it to correct. Return ONLY the corrected \
version of that word — nothing else: no quotes, no punctuation, no commentary, no rest \
of the sentence. Use the rest of the sentence to disambiguate homophones \
(their/there/they're, its/it's, your/you're, etc.) and to pick the right fix for typos. \
Preserve the original casing of the word's first letter. If the word is already correct \
in context, return it unchanged.";
#[derive(Debug, thiserror::Error)]
pub enum LlmError {
#[error("no API key for the LLM provider — set one in Preferences → Providers")]
NoApiKey,
#[error("keychain: {0}")]
Keychain(String),
#[error("unsupported LLM backend: {0}")]
UnsupportedBackend(String),
#[error("LLM request failed: {0}")]
Request(String),
#[error("LLM response was unparseable: {0}")]
Response(String),
}
pub struct LlmProvider {
api_key: String,
model: String,
}
impl std::fmt::Debug for LlmProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LlmProvider")
.field("model", &self.model)
.field("api_key", &"[redacted]")
.finish()
}
}
impl LlmProvider {
pub fn from_config(llm: &crate::LlmConfig) -> Result<Self, LlmError> {
if llm.backend != "anthropic" {
return Err(LlmError::UnsupportedBackend(llm.backend.clone()));
}
let api_key = secrets::get(ANTHROPIC_KEY_NAME)
.map_err(|e| LlmError::Keychain(e.to_string()))?
.ok_or(LlmError::NoApiKey)?;
Ok(Self {
api_key,
model: llm.model.clone(),
})
}
pub fn rewrite(&self, text: &str) -> Result<String, LlmError> {
if text.trim().is_empty() {
return Ok(text.to_string());
}
self.request(SYSTEM_PROMPT, text.to_string())
}
pub fn fix_word_in_context(&self, sentence: &str, word: &str) -> Result<String, LlmError> {
if word.trim().is_empty() {
return Ok(word.to_string());
}
let content = format!("SENTENCE: {sentence}\nWORD: {word}");
let corrected = self.request(WORD_SYSTEM_PROMPT, content)?;
Ok(corrected
.trim()
.trim_matches(|c: char| c == '"' || c == '\'')
.to_string())
}
fn request(&self, system: &str, content: String) -> Result<String, LlmError> {
let agent: ureq::Agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(20))
.build();
let body = serde_json::json!({
"model": self.model,
"max_tokens": DEFAULT_MAX_TOKENS,
"system": system,
"messages": [{
"role": "user",
"content": content,
}],
});
let response = agent
.post(ANTHROPIC_URL)
.set("x-api-key", &self.api_key)
.set("anthropic-version", ANTHROPIC_VERSION)
.set("content-type", "application/json")
.send_json(body)
.map_err(|e| LlmError::Request(e.to_string()))?;
let json: serde_json::Value = response
.into_json()
.map_err(|e| LlmError::Response(e.to_string()))?;
let corrected = json["content"]
.as_array()
.and_then(|parts| {
parts
.iter()
.filter_map(|p| p.get("text").and_then(|t| t.as_str()))
.next()
})
.ok_or_else(|| LlmError::Response("no `content[*].text` in response".into()))?;
Ok(corrected.trim_end_matches('\n').to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::LlmConfig;
#[test]
fn unsupported_backend_is_rejected_cleanly() {
let cfg = LlmConfig {
backend: "openai".into(),
model: "gpt-5".into(),
};
match LlmProvider::from_config(&cfg) {
Err(LlmError::UnsupportedBackend(name)) => assert_eq!(name, "openai"),
other => panic!("expected UnsupportedBackend, got {other:?}"),
}
}
}