use crate::chat::stream::Event;
use crate::chat::{
ChatRequest, ChatResponse, ContentBlock, ImageSource, Message, Role, StopReason,
};
use crate::cost::Usage;
use crate::error::{Error, Result};
use crate::model::{ModelId, Reach};
use crate::providers::api::{ApiProvider, Protocol, SseFrame};
use crate::registry::Registry;
use crate::secret::Secret;
use crate::transport::HttpTransport;
use serde_json::{json, Value};
use std::sync::Arc;
#[derive(Debug, Clone, Copy, Default)]
pub struct ChatCompletions {
id: &'static str,
}
pub type OpenAiCompatible = ApiProvider<ChatCompletions>;
pub fn at(
id: &'static str,
base_url: impl Into<String>,
transport: Arc<dyn HttpTransport>,
key: Secret,
reach: Reach,
registry: Arc<Registry>,
) -> OpenAiCompatible {
ApiProvider::new(
ChatCompletions { id },
base_url,
transport,
key,
reach,
registry,
)
}
#[cfg(feature = "reqwest")]
#[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))]
pub fn from_env(timeout: std::time::Duration) -> Result<OpenAiCompatible> {
Ok(at(
"openai",
"https://api.openai.com/v1",
Arc::new(crate::transport::Reqwest::new(timeout)?),
Secret::from_env("openai-api-key", "OPENAI_API_KEY")?,
Reach::FirstPartyApi,
Arc::new(Registry::empty("openai", Reach::FirstPartyApi)),
))
}
impl Protocol for ChatCompletions {
fn id(&self) -> &str {
self.id
}
fn chat_url(&self, base_url: &str, _model: &ModelId) -> String {
format!("{base_url}/chat/completions")
}
fn catalogue_url(&self, base_url: &str) -> Option<String> {
Some(format!("{base_url}/models"))
}
fn headers(&self, key: &Secret) -> Result<Vec<(String, String)>> {
let key = key
.expose_str()
.map_err(|_| Error::Auth("the API key is not valid UTF-8".into()))?;
Ok(vec![
("authorization".into(), format!("Bearer {key}")),
("content-type".into(), "application/json".into()),
])
}
fn body(&self, request: &ChatRequest) -> Result<Value> {
let mut messages = Vec::new();
if let Some(system) = &request.system {
messages.push(json!({ "role": "system", "content": system }));
}
for message in &request.messages {
messages.extend(wire_message(message));
}
let mut body = json!({
"model": request.model.as_str(),
"messages": messages,
});
if let Some(max) = request.generation.max_tokens {
body["max_completion_tokens"] = json!(max);
}
if let Some(temperature) = request.generation.temperature {
body["temperature"] = json!(temperature);
}
if let Some(top_p) = request.generation.top_p {
body["top_p"] = json!(top_p);
}
if !request.tools.is_empty() {
body["tools"] = json!(request
.tools
.iter()
.map(|tool| json!({
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
}
}))
.collect::<Vec<_>>());
}
if let Some(schema) = &request.response_schema {
body["response_format"] = json!({
"type": "json_schema",
"json_schema": { "name": "response", "schema": schema, "strict": true },
});
}
Ok(body)
}
fn read(&self, body: &Value, asked_for: &ModelId) -> Result<ChatResponse> {
let choice = body
.get("choices")
.and_then(Value::as_array)
.and_then(|c| c.first())
.ok_or_else(|| Error::Unreadable("the reply carried no choices".into()))?;
let message = choice
.get("message")
.ok_or_else(|| Error::Unreadable("the choice carried no message".into()))?;
let mut blocks = Vec::new();
if let Some(text) = message.get("content").and_then(Value::as_str) {
if !text.is_empty() {
blocks.push(ContentBlock::Text(text.to_string()));
}
}
for call in message
.get("tool_calls")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let function = call.get("function");
let arguments = function
.and_then(|f| f.get("arguments"))
.and_then(Value::as_str)
.unwrap_or("{}");
blocks.push(ContentBlock::ToolUse {
id: call
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
name: function
.and_then(|f| f.get("name"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
input: serde_json::from_str(arguments)
.unwrap_or_else(|_| json!({ "raw": arguments })),
});
}
if blocks.is_empty() {
return Err(Error::Unreadable(
"the reply carried no content this crate could read".into(),
));
}
let mut response = ChatResponse::new(
Message {
role: Role::Assistant,
content: blocks,
},
read_stop(choice.get("finish_reason").and_then(Value::as_str)),
read_usage(body.get("usage")),
body.get("model")
.and_then(Value::as_str)
.map(ModelId::from)
.unwrap_or_else(|| asked_for.clone()),
);
if let Some(reason) = choice.get("finish_reason").and_then(Value::as_str) {
response = response.with_stop_details(reason);
}
Ok(response)
}
fn stream_body(&self, request: &ChatRequest) -> Result<Option<Value>> {
let mut body = self.body(request)?;
body["stream"] = json!(true);
body["stream_options"] = json!({ "include_usage": true });
Ok(Some(body))
}
fn read_event(&self, frame: &SseFrame, asked_for: &ModelId) -> Result<Vec<Event>> {
if frame.data.trim() == "[DONE]" {
return Ok(Vec::new());
}
let Some(body) = frame.json() else {
return Err(Error::Unreadable(format!(
"a frame that was neither JSON nor [DONE]: {}",
frame.data.chars().take(120).collect::<String>()
)));
};
let mut events = Vec::new();
if let Some(model) = body.get("model").and_then(Value::as_str) {
if body
.get("choices")
.and_then(Value::as_array)
.is_some_and(|c| {
c.first()
.and_then(|choice| choice.get("delta"))
.and_then(|delta| delta.get("role"))
.is_some()
})
{
events.push(Event::Started {
model: ModelId::from(model),
});
}
}
if let Some(choice) = body
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
{
if let Some(delta) = choice.get("delta") {
if let Some(text) = delta.get("content").and_then(Value::as_str) {
if !text.is_empty() {
events.push(Event::TextDelta(text.to_string()));
}
}
for field in ["reasoning_content", "reasoning"] {
if let Some(text) = delta.get(field).and_then(Value::as_str) {
if !text.is_empty() {
events.push(Event::ThinkingDelta(text.to_string()));
}
}
}
for call in delta
.get("tool_calls")
.and_then(Value::as_array)
.unwrap_or(&Vec::new())
{
let function = call.get("function");
if let Some(name) = function.and_then(|f| f.get("name")).and_then(Value::as_str)
{
events.push(Event::ToolUseStarted {
id: call
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
name: name.to_string(),
});
}
if let Some(arguments) = function
.and_then(|f| f.get("arguments"))
.and_then(Value::as_str)
{
if !arguments.is_empty() {
events.push(Event::ToolArgumentsDelta(arguments.to_string()));
}
}
}
}
if let Some(reason) = choice.get("finish_reason") {
if !reason.is_null() {
events.push(Event::Stopped {
reason: read_stop(reason.as_str()),
details: None,
});
}
}
}
let usage = read_usage(body.get("usage"));
if usage.coverage() != crate::cost::UsageCoverage::Absent {
events.push(Event::Metered(usage));
}
let _ = asked_for;
Ok(events)
}
fn read_catalogue(&self, body: &Value) -> Result<Vec<ModelId>> {
let mut ids: Vec<ModelId> = body
.get("data")
.and_then(Value::as_array)
.ok_or_else(|| Error::Unreadable("the model list had no data array".into()))?
.iter()
.filter_map(|m| m.get("id").and_then(Value::as_str))
.map(ModelId::from)
.collect();
ids.sort();
Ok(ids)
}
}
fn wire_message(message: &Message) -> Vec<Value> {
let mut out = Vec::new();
let mut text = Vec::new();
let mut calls = Vec::new();
let mut images = Vec::new();
for block in &message.content {
match block {
ContentBlock::Text(t) => text.push(t.clone()),
ContentBlock::Thinking { .. } => {}
ContentBlock::Opaque { .. } => {}
ContentBlock::Image { media_type, source } => images.push(json!({
"type": "image_url",
"image_url": {
"url": match source {
ImageSource::Url(url) => url.clone(),
ImageSource::Bytes(bytes) => {
use base64::Engine as _;
format!(
"data:{media_type};base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
)
}
},
},
})),
ContentBlock::ToolUse { id, name, input } => calls.push(json!({
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": serde_json::to_string(input).unwrap_or_else(|_| "{}".into()),
}
})),
ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
} => out.push(json!({
"role": "tool",
"tool_call_id": tool_use_id,
"content": if *is_error { format!("error: {content}") } else { content.clone() },
})),
}
}
if !text.is_empty() || !calls.is_empty() || !images.is_empty() {
let role = match message.role {
Role::User => "user",
Role::Assistant => "assistant",
};
let content = if images.is_empty() {
json!(text.join("\n"))
} else {
let mut parts: Vec<Value> = text
.iter()
.map(|t| json!({ "type": "text", "text": t }))
.collect();
parts.extend(images);
Value::Array(parts)
};
let mut turn = json!({ "role": role, "content": content });
if !calls.is_empty() {
turn["tool_calls"] = Value::Array(calls);
}
out.push(turn);
}
out
}
fn read_stop(reason: Option<&str>) -> StopReason {
match reason {
Some("stop") => StopReason::EndTurn,
Some("tool_calls") | Some("function_call") => StopReason::ToolUse,
Some("length") => StopReason::MaxTokens,
Some("content_filter") => StopReason::Refusal,
Some("context_length_exceeded") => StopReason::ContextWindowExceeded,
_ => StopReason::Other,
}
}
fn read_usage(value: Option<&Value>) -> Usage {
let Some(usage) = value else {
return Usage::absent();
};
let field = |name: &str| usage.get(name).and_then(Value::as_u64);
let cached = usage
.get("prompt_tokens_details")
.and_then(|d| d.get("cached_tokens"))
.and_then(Value::as_u64);
Usage {
input_tokens: field("prompt_tokens").map(|total| total.saturating_sub(cached.unwrap_or(0))),
cache_read_tokens: cached,
cache_write_tokens: None,
output_tokens: field("completion_tokens"),
}
}