magi-code 0.77.1

Repository-aware CLI coding agent for terminal work
Documentation
use crate::{
    cancellation::AgentCancellation,
    model_catalog::types::ModelCatalogEntry,
    providers::{
        HttpRequest, HttpTransport, ProviderEvent, ProviderRequest,
        openai_stream::stream_with_transport_parser, stream::StreamParser,
    },
};
use serde_json::Value;
use std::{collections::BTreeMap, fmt};

use super::{
    Provider,
    bodies::{
        openai_compatible_chat_completions_body_with_protocol,
        openai_compatible_responses_body_with_support,
    },
    catalog::{
        fetch_model_catalog_response_text_cancellable,
        parse_openai_compatible_model_catalog_response,
    },
    headers::{model_catalog_headers, sse_json_headers},
};
use crate::config::{CustomProviderHeaderValue, CustomReasoningProtocol};

#[derive(Clone)]
pub struct OpenAiCompatibleProvider<T> {
    model: String,
    api_key: Option<String>,
    chat_completions_url: String,
    responses_url: String,
    models_url: String,
    use_responses_endpoint: bool,
    provider_id: String,
    transport: T,
    reasoning_protocol: CustomReasoningProtocol,
    max_output_tokens: Option<u64>,
    supports_text_verbosity: bool,
    service_tier: Option<String>,
    request_headers: BTreeMap<String, CustomProviderHeaderValue>,
    fallback_conversation_id: String,
}

impl<T: fmt::Debug> fmt::Debug for OpenAiCompatibleProvider<T> {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("OpenAiCompatibleProvider")
            .field("model", &self.model)
            .field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
            .field("chat_completions_url", &self.chat_completions_url)
            .field("responses_url", &self.responses_url)
            .field("models_url", &self.models_url)
            .field("use_responses_endpoint", &self.use_responses_endpoint)
            .field("provider_id", &self.provider_id)
            .field("transport", &self.transport)
            .field(
                "request_headers",
                &self.request_headers.keys().collect::<Vec<_>>(),
            )
            .finish()
    }
}

impl<T> OpenAiCompatibleProvider<T> {
    pub fn custom(
        provider_id: impl Into<String>,
        model: impl Into<String>,
        api_key: Option<String>,
        base_url: impl Into<String>,
        use_responses_endpoint: bool,
        transport: T,
    ) -> Self {
        let base_url = base_url.into();
        Self {
            model: model.into(),
            api_key,
            chat_completions_url: format!("{base_url}/chat/completions"),
            responses_url: format!("{base_url}/responses"),
            models_url: format!("{base_url}/models"),
            use_responses_endpoint,
            provider_id: provider_id.into(),
            reasoning_protocol: CustomReasoningProtocol::GptLike,
            max_output_tokens: None,
            supports_text_verbosity: false,
            service_tier: None,
            request_headers: BTreeMap::new(),
            fallback_conversation_id: format!("magi-code-conversation-{}", uuid::Uuid::new_v4()),
            transport,
        }
    }

    pub fn with_text_verbosity_support(mut self, supported: bool) -> Self {
        self.supports_text_verbosity = supported;
        self
    }
    pub(crate) fn with_service_tier(mut self, service_tier: Option<String>) -> Self {
        self.service_tier = service_tier.filter(|tier| !tier.trim().is_empty());
        self
    }
    pub fn with_reasoning_protocol(
        mut self,
        protocol: CustomReasoningProtocol,
        max_output_tokens: Option<u64>,
    ) -> Self {
        self.reasoning_protocol = protocol;
        self.max_output_tokens = max_output_tokens;
        self
    }

    pub fn with_request_headers(
        mut self,
        request_headers: BTreeMap<String, CustomProviderHeaderValue>,
    ) -> Self {
        self.request_headers = request_headers;
        self
    }

    pub fn build_http_request(&self, request: &ProviderRequest) -> HttpRequest {
        let (url, mut body) = if self.use_responses_endpoint {
            (
                self.responses_url.clone(),
                openai_compatible_responses_body_with_support(
                    self.supports_text_verbosity,
                    &self.model,
                    request,
                    self.reasoning_protocol,
                    self.max_output_tokens,
                ),
            )
        } else {
            (
                self.chat_completions_url.clone(),
                openai_compatible_chat_completions_body_with_protocol(
                    &self.model,
                    request,
                    self.reasoning_protocol,
                    self.max_output_tokens,
                ),
            )
        };
        if let Some(service_tier) = self.service_tier.as_deref() {
            body["service_tier"] = serde_json::json!(service_tier);
        }
        let mut headers = sse_json_headers(self.api_key.as_deref());
        let conversation_id = request
            .conversation_id()
            .unwrap_or(&self.fallback_conversation_id);
        for (name, value) in &self.request_headers {
            let value = match value {
                CustomProviderHeaderValue::ConversationId => conversation_id,
            };
            headers.insert(name.clone(), value.to_string());
        }
        HttpRequest {
            method: "POST".to_string(),
            url,
            headers,
            body,
        }
    }

    pub fn build_model_catalog_request(&self) -> HttpRequest {
        HttpRequest {
            method: "GET".to_string(),
            url: self.models_url.clone(),
            headers: model_catalog_headers(self.api_key.as_deref()),
            body: Value::Null,
        }
    }

    pub fn discover_model_catalog(&self) -> anyhow::Result<Vec<ModelCatalogEntry>> {
        self.discover_model_catalog_cancellable(&AgentCancellation::default())
    }

    pub fn discover_model_catalog_cancellable(
        &self,
        cancellation: &AgentCancellation,
    ) -> anyhow::Result<Vec<ModelCatalogEntry>> {
        let text = fetch_model_catalog_response_text_cancellable(
            self.build_model_catalog_request(),
            &self.provider_id,
            cancellation,
        )?;
        parse_openai_compatible_model_catalog_response(&self.provider_id, &text)
    }
}

impl<T: HttpTransport + Send + Sync> Provider for OpenAiCompatibleProvider<T> {
    fn requested_service_tier(&self) -> Option<&str> {
        self.service_tier.as_deref()
    }

    fn stream_cancellable(
        &self,
        request: ProviderRequest,
        cancellation: &AgentCancellation,
        on_event: &mut dyn FnMut(ProviderEvent) -> anyhow::Result<()>,
    ) -> anyhow::Result<()> {
        let semantic_progress_timeout = request.semantic_progress_timeout_or_default();
        stream_with_transport_parser(
            &self.transport,
            self.build_http_request(&request),
            cancellation,
            semantic_progress_timeout,
            || StreamParser::for_provider_model(&self.provider_id, &self.model),
            on_event,
        )
    }
}