procyon 0.1.2

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
//! Adapter for providers that speak the OpenAI `/chat/completions` dialect.
//!
//! One adapter covers OpenAI, DeepSeek, Groq, OpenRouter, Together, Ollama and LM Studio: they
//! differ in `base_url` and model ids, not in wire format. The neutral vocabulary in `agent` is
//! Anthropic-shaped, so the translation lives in `wire` and nothing upstream of it changes.

mod stream;
mod wire;

use color_eyre::{eyre::bail, Result};
use reqwest::Client;
use tokio::sync::mpsc;

use self::stream::StreamState;
use self::wire::{blocks_from_message, ChatRequest, ChatResponse};
use crate::agent::{ContentPart, Message, StreamOutcome, ToolDefinition};
use crate::sse::EventReader;

pub struct OpenAiClient {
    client: Client,
    api_key: String,
    base_url: String,
    model: String,
    max_tokens: u32,
}

impl OpenAiClient {
    pub fn new(api_key: String, base_url: String, model: String, max_tokens: u32) -> Self {
        Self {
            client: crate::llm::http_client(),
            api_key,
            // Joining with a single `/` later would double it for the common
            // `https://host/v1/` spelling.
            base_url: base_url.trim_end_matches('/').to_string(),
            model,
            max_tokens,
        }
    }

    fn endpoint(&self) -> String {
        format!("{}/chat/completions", self.base_url)
    }

    fn build_request(
        &self,
        history: &[Message],
        tools: Option<&[ToolDefinition]>,
        system: Option<&str>,
        stream: bool,
    ) -> ChatRequest {
        ChatRequest::build(
            self.model.clone(),
            self.max_tokens,
            history,
            tools,
            system,
            stream,
        )
    }

    async fn dispatch(&self, request: &ChatRequest) -> Result<reqwest::Response> {
        crate::llm::send_with_retry(|| {
            self.client
                .post(self.endpoint())
                .bearer_auth(&self.api_key)
                .header("content-type", "application/json")
                .json(request)
        })
        .await
    }

    pub async fn send_message(
        &self,
        history: &[Message],
        tools: Option<&[ToolDefinition]>,
        system: Option<&str>,
    ) -> Result<Vec<ContentPart>> {
        let request = self.build_request(history, tools, system, false);
        let response = self.dispatch(&request).await?;
        let body: ChatResponse = response.json().await?;

        let choice = match body.choices.into_iter().next() {
            Some(choice) => choice,
            None => bail!("API returned no choices"),
        };

        if choice.message.content.is_none()
            && choice
                .message
                .tool_calls
                .as_ref()
                .is_none_or(|t| t.is_empty())
        {
            bail!("API returned a choice with no content and no tool calls");
        }

        Ok(blocks_from_message(choice.message))
    }

    pub async fn send_message_streaming(
        &self,
        history: &[Message],
        tools: Option<&[ToolDefinition]>,
        system: Option<&str>,
        update_tx: &mpsc::UnboundedSender<String>,
    ) -> Result<StreamOutcome> {
        let request = self.build_request(history, tools, system, true);
        let response = self.dispatch(&request).await?;

        let mut reader = EventReader::new(StreamState::new(update_tx));
        reader.read(response).await?;
        Ok(reader.into_sink().into_outcome())
    }
}

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

    #[test]
    fn trailing_slash_in_base_url_does_not_double() {
        let client = OpenAiClient::new(
            "k".to_string(),
            "https://api.deepseek.com/v1/".to_string(),
            "deepseek-chat".to_string(),
            4096,
        );

        assert_eq!(
            client.endpoint(),
            "https://api.deepseek.com/v1/chat/completions"
        );
    }
}