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,
)
}
}