agentic_core/
cloud.rs

1use crate::models::AtomicNote;
2use reqwest::Client;
3use serde::{Deserialize, Serialize};
4use std::time::Duration;
5use thiserror::Error;
6
7#[derive(Debug, Error)]
8pub enum CloudError {
9    #[error("The cloud provider rejected the API key. It might have expired or been disabled.")]
10    ApiKey,
11
12    #[error("The cloud model returned a response that could not be understood.")]
13    ParseError,
14
15    #[error("The cloud provider returned an unexpected error: {status}: {text}")]
16    ApiError { status: u16, text: String },
17
18    #[error(transparent)]
19    RequestError(#[from] reqwest::Error),
20}
21
22const SYNTHESIZER_PROMPT: &str = r#"You are an expert-level AI Synthesizer. Your task is to answer the user's prompt by generating a concise, "atomic note" of knowledge.
23
24CRITICAL OUTPUT CONSTRAINTS:
25
26Header (Metadata): You MUST generate a set of 3-5 semantic keywords or tags that capture the absolute essence of the topic. These tags are for a knowledge graph.
27
28Body (Content): The main response MUST be a maximum of four (4) sentences. It must be a dense, self-contained summary of the most critical information.
29
30OUTPUT FORMAT (JSON):
31Your final output MUST be a single, valid JSON object with two keys: header_tags and body_text.
32
33{
34  "header_tags": ["keyword1", "keyword2", "keyword3"],
35  "body_text": "Your concise, 3-4 sentence summary goes here."
36}
37
38USER PROMPT:
39{prompt}
40"#;
41
42#[derive(Serialize)]
43struct ResponseFormat {
44    r#type: String,
45}
46
47#[derive(Serialize)]
48struct OpenRouterRequest<'a> {
49    model: String,
50    messages: Vec<ChatMessage<'a>>,
51    max_tokens: u32,
52    response_format: ResponseFormat,
53}
54
55#[derive(Serialize)]
56struct ChatMessage<'a> {
57    role: String,
58    content: &'a str,
59}
60
61#[derive(Deserialize)]
62struct OpenRouterResponse {
63    choices: Vec<Choice>,
64}
65
66#[derive(Deserialize)]
67struct Choice {
68    message: Message,
69}
70
71#[derive(Deserialize)]
72struct Message {
73    content: String,
74}
75
76pub async fn call_cloud_model(
77    api_key: &str,
78    model: &str,
79    prompt: &str,
80) -> Result<AtomicNote, CloudError> {
81    let client = Client::builder().timeout(Duration::from_secs(30)).build()?;
82
83    let synthesizer_prompt = SYNTHESIZER_PROMPT.replace("{prompt}", prompt);
84
85    let request_body = OpenRouterRequest {
86        model: model.to_string(),
87        messages: vec![ChatMessage {
88            role: "user".to_string(),
89            content: &synthesizer_prompt,
90        }],
91        max_tokens: 1024,
92        response_format: ResponseFormat {
93            r#type: "json_object".to_string(),
94        },
95    };
96
97    let response = client
98        .post("https://openrouter.ai/api/v1/chat/completions")
99        .header("Authorization", format!("Bearer {}", api_key))
100        .header("Content-Type", "application/json")
101        .json(&request_body)
102        .send()
103        .await?;
104
105    if !response.status().is_success() {
106        let status = response.status();
107        if status == 401 {
108            return Err(CloudError::ApiKey);
109        }
110        let error_text = response.text().await.unwrap_or_default();
111        return Err(CloudError::ApiError {
112            status: status.as_u16(),
113            text: error_text,
114        });
115    }
116
117    let openrouter_response: OpenRouterResponse = match response.json().await {
118        Ok(res) => res,
119        Err(_) => return Err(CloudError::ParseError),
120    };
121
122    let message_content = openrouter_response
123        .choices
124        .first()
125        .map(|choice| &choice.message.content)
126        .ok_or(CloudError::ParseError)?;
127
128    let atomic_note: AtomicNote =
129        serde_json::from_str(message_content).map_err(|_| CloudError::ParseError)?;
130
131    Ok(atomic_note)
132}