apollo-agent 0.6.0

Local-first Rust AI agent runtime — Telegram-first, trait-driven, SurrealDB + RocksDB state layer.
Documentation
use async_trait::async_trait;
use rs_ai_oauth::codex::{codex_request_body, ChatGptCodexClient};
use serde_json::{json, Value};

use super::traits::{
    ChatMessage, ChatRequest, ChatResponse, Provider, ProviderCapabilities, ToolCall,
};

pub struct CodexProvider {
    client: ChatGptCodexClient,
}

impl CodexProvider {
    pub fn new(access_token: impl Into<String>) -> Self {
        Self {
            client: ChatGptCodexClient::new(access_token).with_originator("apollo"),
        }
    }
}

#[async_trait]
impl Provider for CodexProvider {
    fn name(&self) -> &str {
        "chatgpt"
    }

    fn capabilities(&self) -> ProviderCapabilities {
        ProviderCapabilities {
            native_tools: true,
            streaming: false,
            vision: true,
            max_context: 272_000,
            native_web_search: false,
        }
    }

    async fn chat(&self, request: &ChatRequest<'_>) -> anyhow::Result<ChatResponse> {
        let input = messages_to_responses_input(request.messages);
        let tools = request
            .tools
            .unwrap_or(&[])
            .iter()
            .map(|tool| {
                json!({
                    "type": "function",
                    "name": tool.name,
                    "description": tool.description,
                    "parameters": tool.parameters,
                    "strict": null,
                })
            })
            .collect();
        let instructions = request
            .messages
            .iter()
            .find(|message| message.role == "system")
            .map(|message| message.content.as_str())
            .unwrap_or("You are a helpful assistant.");
        let body = codex_request_body(request.model, instructions, input, tools, None);
        let response = self.client.complete(body, None).await?;
        Ok(ChatResponse {
            text: (!response.text.is_empty()).then_some(response.text),
            tool_calls: response
                .tool_calls
                .into_iter()
                .map(|call| ToolCall {
                    id: call.id,
                    name: call.name,
                    arguments: call.arguments,
                })
                .collect(),
            usage: Some(super::traits::Usage {
                input_tokens: response.input_tokens as u32,
                output_tokens: response.output_tokens as u32,
            }),
        })
    }
}

fn messages_to_responses_input(messages: &[ChatMessage]) -> Vec<Value> {
    messages
        .iter()
        .filter_map(|message| match message.role.as_str() {
            "system" => None,
            "tool_result" => Some(json!({
                "type": "function_call_output",
                "call_id": message.tool_use_id.clone().unwrap_or_default(),
                "output": message.content,
            })),
            "assistant" => Some(json!({
                "type": "message",
                "role": "assistant",
                "content": [{"type": "output_text", "text": message.content, "annotations": []}],
                "status": "completed",
            })),
            _ => Some(json!({
                "role": "user",
                "content": [{"type": "input_text", "text": message.content}],
            })),
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn converts_messages_to_responses_input() {
        let messages = [ChatMessage::user("hello")];
        let input = messages_to_responses_input(&messages);
        assert_eq!(input[0]["role"], "user");
        assert_eq!(input[0]["content"][0]["type"], "input_text");
    }
}