logiguard fork v3: full patch set on verified 8c74db0 tree
Includes prior-session patches (carry forward so the app compiles): - crates/gpui/build.rs: cross-compile manifest fix - crates/gpui/src/platform.rs: PlatformWindow::activate_with_token trait method - crates/gpui/src/window.rs: Window::activate_with_token public API - crates/gpui_linux/src/linux/wayland/window.rs: WaylandWindow::activate_with_token + activate() keyboard-serial fix Plus the focus-serial fix: - serial.rs: SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; latest_serial_of() Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
33
crates/open_ai/Cargo.toml
Normal file
33
crates/open_ai/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "open_ai"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/open_ai.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
schemars = ["dep:schemars"]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
collections.workspace = true
|
||||
futures.workspace = true
|
||||
http_client.workspace = true
|
||||
language_model_core.workspace = true
|
||||
rand.workspace = true
|
||||
schemars = { workspace = true, optional = true }
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
strum.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions.workspace = true
|
||||
1
crates/open_ai/LICENSE-GPL
Symbolic link
1
crates/open_ai/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
332
crates/open_ai/src/batches.rs
Normal file
332
crates/open_ai/src/batches.rs
Normal file
@@ -0,0 +1,332 @@
|
||||
use anyhow::Result;
|
||||
use futures::AsyncReadExt;
|
||||
use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Request, RequestError, Response};
|
||||
|
||||
/// A single request within a batch
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BatchRequestItem {
|
||||
pub custom_id: String,
|
||||
pub method: String,
|
||||
pub url: String,
|
||||
pub body: Request,
|
||||
}
|
||||
|
||||
impl BatchRequestItem {
|
||||
pub fn new(custom_id: String, request: Request) -> Self {
|
||||
Self {
|
||||
custom_id,
|
||||
method: "POST".to_string(),
|
||||
url: "/v1/chat/completions".to_string(),
|
||||
body: request,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_jsonl_line(&self) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Request to create a batch
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateBatchRequest {
|
||||
pub input_file_id: String,
|
||||
pub endpoint: String,
|
||||
pub completion_window: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl CreateBatchRequest {
|
||||
pub fn new(input_file_id: String) -> Self {
|
||||
Self {
|
||||
input_file_id,
|
||||
endpoint: "/v1/chat/completions".to_string(),
|
||||
completion_window: "24h".to_string(),
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Response from batch creation or retrieval
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Batch {
|
||||
pub id: String,
|
||||
pub object: String,
|
||||
pub endpoint: String,
|
||||
pub input_file_id: String,
|
||||
pub completion_window: String,
|
||||
pub status: String,
|
||||
pub output_file_id: Option<String>,
|
||||
pub error_file_id: Option<String>,
|
||||
pub created_at: u64,
|
||||
#[serde(default)]
|
||||
pub in_progress_at: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub finalizing_at: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub completed_at: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub failed_at: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub expired_at: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub cancelling_at: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub cancelled_at: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub request_counts: Option<BatchRequestCounts>,
|
||||
#[serde(default)]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct BatchRequestCounts {
|
||||
pub total: u64,
|
||||
pub completed: u64,
|
||||
pub failed: u64,
|
||||
}
|
||||
|
||||
/// Response from file upload
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct FileObject {
|
||||
pub id: String,
|
||||
pub object: String,
|
||||
pub bytes: u64,
|
||||
pub created_at: u64,
|
||||
pub filename: String,
|
||||
pub purpose: String,
|
||||
}
|
||||
|
||||
/// Individual result from batch output
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BatchOutputItem {
|
||||
pub id: String,
|
||||
pub custom_id: String,
|
||||
pub response: Option<BatchResponseBody>,
|
||||
pub error: Option<BatchError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BatchResponseBody {
|
||||
pub status_code: u16,
|
||||
pub body: Response,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BatchError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Upload a JSONL file for batch processing
|
||||
pub async fn upload_batch_file(
|
||||
client: &dyn HttpClient,
|
||||
api_url: &str,
|
||||
api_key: &str,
|
||||
filename: &str,
|
||||
content: Vec<u8>,
|
||||
) -> Result<FileObject, RequestError> {
|
||||
let uri = format!("{api_url}/files");
|
||||
|
||||
let boundary = format!("----WebKitFormBoundary{:x}", rand::random::<u64>());
|
||||
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend_from_slice(b"Content-Disposition: form-data; name=\"purpose\"\r\n\r\n");
|
||||
body.extend_from_slice(b"batch\r\n");
|
||||
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend_from_slice(
|
||||
format!("Content-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n")
|
||||
.as_bytes(),
|
||||
);
|
||||
body.extend_from_slice(b"Content-Type: application/jsonl\r\n\r\n");
|
||||
body.extend_from_slice(&content);
|
||||
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
|
||||
|
||||
let request = HttpRequest::builder()
|
||||
.method(Method::POST)
|
||||
.uri(uri)
|
||||
.header("Authorization", format!("Bearer {}", api_key.trim()))
|
||||
.header(
|
||||
"Content-Type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(AsyncBody::from(body))
|
||||
.map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
let mut response = client.send(request).await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let mut body = String::new();
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_string(&mut body)
|
||||
.await
|
||||
.map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
serde_json::from_str(&body).map_err(|e| RequestError::Other(e.into()))
|
||||
} else {
|
||||
let mut body = String::new();
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_string(&mut body)
|
||||
.await
|
||||
.map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
Err(RequestError::HttpResponseError {
|
||||
provider: "openai".to_owned(),
|
||||
status_code: response.status(),
|
||||
body,
|
||||
headers: response.headers().clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a batch from an uploaded file
|
||||
pub async fn create_batch(
|
||||
client: &dyn HttpClient,
|
||||
api_url: &str,
|
||||
api_key: &str,
|
||||
request: CreateBatchRequest,
|
||||
) -> Result<Batch, RequestError> {
|
||||
let uri = format!("{api_url}/batches");
|
||||
|
||||
let serialized = serde_json::to_string(&request).map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
let request = HttpRequest::builder()
|
||||
.method(Method::POST)
|
||||
.uri(uri)
|
||||
.header("Authorization", format!("Bearer {}", api_key.trim()))
|
||||
.header("Content-Type", "application/json")
|
||||
.body(AsyncBody::from(serialized))
|
||||
.map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
let mut response = client.send(request).await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let mut body = String::new();
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_string(&mut body)
|
||||
.await
|
||||
.map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
serde_json::from_str(&body).map_err(|e| RequestError::Other(e.into()))
|
||||
} else {
|
||||
let mut body = String::new();
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_string(&mut body)
|
||||
.await
|
||||
.map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
Err(RequestError::HttpResponseError {
|
||||
provider: "openai".to_owned(),
|
||||
status_code: response.status(),
|
||||
body,
|
||||
headers: response.headers().clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve batch status
|
||||
pub async fn retrieve_batch(
|
||||
client: &dyn HttpClient,
|
||||
api_url: &str,
|
||||
api_key: &str,
|
||||
batch_id: &str,
|
||||
) -> Result<Batch, RequestError> {
|
||||
let uri = format!("{api_url}/batches/{batch_id}");
|
||||
|
||||
let request = HttpRequest::builder()
|
||||
.method(Method::GET)
|
||||
.uri(uri)
|
||||
.header("Authorization", format!("Bearer {}", api_key.trim()))
|
||||
.body(AsyncBody::default())
|
||||
.map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
let mut response = client.send(request).await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let mut body = String::new();
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_string(&mut body)
|
||||
.await
|
||||
.map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
serde_json::from_str(&body).map_err(|e| RequestError::Other(e.into()))
|
||||
} else {
|
||||
let mut body = String::new();
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_string(&mut body)
|
||||
.await
|
||||
.map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
Err(RequestError::HttpResponseError {
|
||||
provider: "openai".to_owned(),
|
||||
status_code: response.status(),
|
||||
body,
|
||||
headers: response.headers().clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Download file content (for batch results)
|
||||
pub async fn download_file(
|
||||
client: &dyn HttpClient,
|
||||
api_url: &str,
|
||||
api_key: &str,
|
||||
file_id: &str,
|
||||
) -> Result<String, RequestError> {
|
||||
let uri = format!("{api_url}/files/{file_id}/content");
|
||||
|
||||
let request = HttpRequest::builder()
|
||||
.method(Method::GET)
|
||||
.uri(uri)
|
||||
.header("Authorization", format!("Bearer {}", api_key.trim()))
|
||||
.body(AsyncBody::default())
|
||||
.map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
let mut response = client.send(request).await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let mut body = String::new();
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_string(&mut body)
|
||||
.await
|
||||
.map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
Ok(body)
|
||||
} else {
|
||||
let mut body = String::new();
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_string(&mut body)
|
||||
.await
|
||||
.map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
Err(RequestError::HttpResponseError {
|
||||
provider: "openai".to_owned(),
|
||||
status_code: response.status(),
|
||||
body,
|
||||
headers: response.headers().clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse batch output JSONL into individual results
|
||||
pub fn parse_batch_output(content: &str) -> Result<Vec<BatchOutputItem>, serde_json::Error> {
|
||||
content
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| serde_json::from_str(line))
|
||||
.collect()
|
||||
}
|
||||
3139
crates/open_ai/src/completion.rs
Normal file
3139
crates/open_ai/src/completion.rs
Normal file
File diff suppressed because it is too large
Load Diff
868
crates/open_ai/src/open_ai.rs
Normal file
868
crates/open_ai/src/open_ai.rs
Normal file
@@ -0,0 +1,868 @@
|
||||
pub mod batches;
|
||||
pub mod completion;
|
||||
pub mod responses;
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
|
||||
use http_client::{
|
||||
AsyncBody, HttpClient, Method, Request as HttpRequest, StatusCode,
|
||||
http::{HeaderMap, HeaderValue},
|
||||
};
|
||||
pub use language_model_core::ReasoningEffort;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::{convert::TryFrom, future::Future};
|
||||
use strum::EnumIter;
|
||||
use thiserror::Error;
|
||||
|
||||
pub const OPEN_AI_API_URL: &str = "https://api.openai.com/v1";
|
||||
|
||||
fn is_none_or_empty<T: AsRef<[U]>, U>(opt: &Option<T>) -> bool {
|
||||
opt.as_ref().is_none_or(|v| v.as_ref().is_empty())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Role {
|
||||
User,
|
||||
Assistant,
|
||||
System,
|
||||
Tool,
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Role {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: String) -> Result<Self> {
|
||||
match value.as_str() {
|
||||
"user" => Ok(Self::User),
|
||||
"assistant" => Ok(Self::Assistant),
|
||||
"system" => Ok(Self::System),
|
||||
"tool" => Ok(Self::Tool),
|
||||
_ => anyhow::bail!("invalid role '{value}'"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Role> for String {
|
||||
fn from(val: Role) -> Self {
|
||||
match val {
|
||||
Role::User => "user".to_owned(),
|
||||
Role::Assistant => "assistant".to_owned(),
|
||||
Role::System => "system".to_owned(),
|
||||
Role::Tool => "tool".to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)]
|
||||
pub enum Model {
|
||||
#[serde(rename = "gpt-4")]
|
||||
Four,
|
||||
#[serde(rename = "gpt-4o-mini")]
|
||||
FourOmniMini,
|
||||
#[serde(rename = "o3")]
|
||||
O3,
|
||||
#[serde(rename = "gpt-5")]
|
||||
Five,
|
||||
#[serde(rename = "gpt-5-mini")]
|
||||
#[default]
|
||||
FiveMini,
|
||||
#[serde(rename = "gpt-5-nano")]
|
||||
FiveNano,
|
||||
#[serde(rename = "gpt-5.1")]
|
||||
FivePointOne,
|
||||
#[serde(rename = "gpt-5.2")]
|
||||
FivePointTwo,
|
||||
#[serde(rename = "gpt-5.3-codex")]
|
||||
FivePointThreeCodex,
|
||||
#[serde(rename = "gpt-5.4-nano")]
|
||||
FivePointFourNano,
|
||||
#[serde(rename = "gpt-5.4-mini")]
|
||||
FivePointFourMini,
|
||||
#[serde(rename = "gpt-5.4")]
|
||||
FivePointFour,
|
||||
#[serde(rename = "gpt-5.4-pro")]
|
||||
FivePointFourPro,
|
||||
#[serde(rename = "gpt-5.5")]
|
||||
FivePointFive,
|
||||
#[serde(rename = "gpt-5.5-pro")]
|
||||
FivePointFivePro,
|
||||
#[serde(rename = "custom")]
|
||||
Custom {
|
||||
name: String,
|
||||
/// The name displayed in the UI, such as in the agent panel model dropdown menu.
|
||||
display_name: Option<String>,
|
||||
max_tokens: u64,
|
||||
max_output_tokens: Option<u64>,
|
||||
max_completion_tokens: Option<u64>,
|
||||
reasoning_effort: Option<ReasoningEffort>,
|
||||
#[serde(default = "default_supports_chat_completions")]
|
||||
supports_chat_completions: bool,
|
||||
#[serde(default = "default_supports_images")]
|
||||
supports_images: bool,
|
||||
},
|
||||
}
|
||||
|
||||
const fn default_supports_chat_completions() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
const fn default_supports_images() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn default_fast() -> Self {
|
||||
Self::FiveMini
|
||||
}
|
||||
|
||||
pub fn from_id(id: &str) -> Result<Self> {
|
||||
match id {
|
||||
"gpt-4" => Ok(Self::Four),
|
||||
"gpt-4o-mini" => Ok(Self::FourOmniMini),
|
||||
"o3" => Ok(Self::O3),
|
||||
"gpt-5" => Ok(Self::Five),
|
||||
"gpt-5-mini" => Ok(Self::FiveMini),
|
||||
"gpt-5-nano" => Ok(Self::FiveNano),
|
||||
"gpt-5.1" => Ok(Self::FivePointOne),
|
||||
"gpt-5.2" => Ok(Self::FivePointTwo),
|
||||
"gpt-5.3-codex" => Ok(Self::FivePointThreeCodex),
|
||||
"gpt-5.4-nano" => Ok(Self::FivePointFourNano),
|
||||
"gpt-5.4-mini" => Ok(Self::FivePointFourMini),
|
||||
"gpt-5.4" => Ok(Self::FivePointFour),
|
||||
"gpt-5.4-pro" => Ok(Self::FivePointFourPro),
|
||||
"gpt-5.5" => Ok(Self::FivePointFive),
|
||||
"gpt-5.5-pro" => Ok(Self::FivePointFivePro),
|
||||
invalid_id => anyhow::bail!("invalid model id '{invalid_id}'"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
match self {
|
||||
Self::Four => "gpt-4",
|
||||
Self::FourOmniMini => "gpt-4o-mini",
|
||||
Self::O3 => "o3",
|
||||
Self::Five => "gpt-5",
|
||||
Self::FiveMini => "gpt-5-mini",
|
||||
Self::FiveNano => "gpt-5-nano",
|
||||
Self::FivePointOne => "gpt-5.1",
|
||||
Self::FivePointTwo => "gpt-5.2",
|
||||
Self::FivePointThreeCodex => "gpt-5.3-codex",
|
||||
Self::FivePointFourNano => "gpt-5.4-nano",
|
||||
Self::FivePointFourMini => "gpt-5.4-mini",
|
||||
Self::FivePointFour => "gpt-5.4",
|
||||
Self::FivePointFourPro => "gpt-5.4-pro",
|
||||
Self::FivePointFive => "gpt-5.5",
|
||||
Self::FivePointFivePro => "gpt-5.5-pro",
|
||||
Self::Custom { name, .. } => name,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &str {
|
||||
match self {
|
||||
Self::Four => "gpt-4",
|
||||
Self::FourOmniMini => "gpt-4o-mini",
|
||||
Self::O3 => "o3",
|
||||
Self::Five => "gpt-5",
|
||||
Self::FiveMini => "gpt-5-mini",
|
||||
Self::FiveNano => "gpt-5-nano",
|
||||
Self::FivePointOne => "gpt-5.1",
|
||||
Self::FivePointTwo => "gpt-5.2",
|
||||
Self::FivePointThreeCodex => "gpt-5.3-codex",
|
||||
Self::FivePointFourNano => "gpt-5.4-nano",
|
||||
Self::FivePointFourMini => "gpt-5.4-mini",
|
||||
Self::FivePointFour => "gpt-5.4",
|
||||
Self::FivePointFourPro => "gpt-5.4-pro",
|
||||
Self::FivePointFive => "gpt-5.5",
|
||||
Self::FivePointFivePro => "gpt-5.5-pro",
|
||||
Self::Custom { display_name, .. } => display_name.as_deref().unwrap_or(&self.id()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max_token_count(&self) -> u64 {
|
||||
match self {
|
||||
Self::Four => 8_192,
|
||||
Self::FourOmniMini => 128_000,
|
||||
Self::O3 => 200_000,
|
||||
Self::Five => 272_000,
|
||||
Self::FiveMini => 400_000,
|
||||
Self::FiveNano => 400_000,
|
||||
Self::FivePointOne => 400_000,
|
||||
Self::FivePointTwo => 400_000,
|
||||
Self::FivePointThreeCodex => 400_000,
|
||||
Self::FivePointFourNano => 400_000,
|
||||
Self::FivePointFourMini => 400_000,
|
||||
Self::FivePointFour => 1_050_000,
|
||||
Self::FivePointFourPro => 1_050_000,
|
||||
Self::FivePointFive => 1_050_000,
|
||||
Self::FivePointFivePro => 1_050_000,
|
||||
Self::Custom { max_tokens, .. } => *max_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max_output_tokens(&self) -> Option<u64> {
|
||||
match self {
|
||||
Self::Custom {
|
||||
max_output_tokens, ..
|
||||
} => *max_output_tokens,
|
||||
Self::Four => Some(8_192),
|
||||
Self::FourOmniMini => Some(16_384),
|
||||
Self::O3 => Some(100_000),
|
||||
Self::Five => Some(128_000),
|
||||
Self::FiveMini => Some(128_000),
|
||||
Self::FiveNano => Some(128_000),
|
||||
Self::FivePointOne => Some(128_000),
|
||||
Self::FivePointTwo => Some(128_000),
|
||||
Self::FivePointThreeCodex => Some(128_000),
|
||||
Self::FivePointFourNano => Some(128_000),
|
||||
Self::FivePointFourMini => Some(128_000),
|
||||
Self::FivePointFour => Some(128_000),
|
||||
Self::FivePointFourPro => Some(128_000),
|
||||
Self::FivePointFive => Some(128_000),
|
||||
Self::FivePointFivePro => Some(128_000),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reasoning_effort(&self) -> Option<ReasoningEffort> {
|
||||
match self {
|
||||
Self::Custom {
|
||||
reasoning_effort, ..
|
||||
} => reasoning_effort.to_owned(),
|
||||
Self::FivePointOne
|
||||
| Self::FivePointTwo
|
||||
| Self::FivePointFour
|
||||
| Self::FivePointFourMini
|
||||
| Self::FivePointFourNano => Some(ReasoningEffort::None),
|
||||
Self::O3
|
||||
| Self::Five
|
||||
| Self::FiveMini
|
||||
| Self::FiveNano
|
||||
| Self::FivePointThreeCodex
|
||||
| Self::FivePointFourPro
|
||||
| Self::FivePointFive
|
||||
| Self::FivePointFivePro => Some(ReasoningEffort::Medium),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn supported_reasoning_efforts(&self) -> &'static [ReasoningEffort] {
|
||||
match self {
|
||||
Self::Custom {
|
||||
reasoning_effort: Some(effort),
|
||||
..
|
||||
} => match effort {
|
||||
ReasoningEffort::None => &[ReasoningEffort::None],
|
||||
ReasoningEffort::Minimal => &[ReasoningEffort::Minimal],
|
||||
ReasoningEffort::Low => &[ReasoningEffort::Low],
|
||||
ReasoningEffort::Medium => &[ReasoningEffort::Medium],
|
||||
ReasoningEffort::High => &[ReasoningEffort::High],
|
||||
ReasoningEffort::XHigh => &[ReasoningEffort::XHigh],
|
||||
},
|
||||
Self::O3 => &[
|
||||
ReasoningEffort::Low,
|
||||
ReasoningEffort::Medium,
|
||||
ReasoningEffort::High,
|
||||
],
|
||||
Self::FivePointOne => &[
|
||||
ReasoningEffort::None,
|
||||
ReasoningEffort::Low,
|
||||
ReasoningEffort::Medium,
|
||||
ReasoningEffort::High,
|
||||
],
|
||||
Self::Five | Self::FiveMini | Self::FiveNano => &[
|
||||
ReasoningEffort::Minimal,
|
||||
ReasoningEffort::Low,
|
||||
ReasoningEffort::Medium,
|
||||
ReasoningEffort::High,
|
||||
],
|
||||
Self::FivePointFourPro | Self::FivePointFivePro => &[
|
||||
ReasoningEffort::Medium,
|
||||
ReasoningEffort::High,
|
||||
ReasoningEffort::XHigh,
|
||||
],
|
||||
Self::FivePointThreeCodex => &[
|
||||
ReasoningEffort::Low,
|
||||
ReasoningEffort::Medium,
|
||||
ReasoningEffort::High,
|
||||
ReasoningEffort::XHigh,
|
||||
],
|
||||
Self::FivePointTwo
|
||||
| Self::FivePointFour
|
||||
| Self::FivePointFive
|
||||
| Self::FivePointFourMini
|
||||
| Self::FivePointFourNano => &[
|
||||
ReasoningEffort::None,
|
||||
ReasoningEffort::Low,
|
||||
ReasoningEffort::Medium,
|
||||
ReasoningEffort::High,
|
||||
ReasoningEffort::XHigh,
|
||||
],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uses_responses_api(&self) -> bool {
|
||||
match self {
|
||||
Self::Custom {
|
||||
supports_chat_completions,
|
||||
..
|
||||
} => !*supports_chat_completions,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the given model supports the `parallel_tool_calls` parameter.
|
||||
///
|
||||
/// If the model does not support the parameter, do not pass it up, or the API will return an error.
|
||||
pub fn supports_parallel_tool_calls(&self) -> bool {
|
||||
match self {
|
||||
Self::Four
|
||||
| Self::FourOmniMini
|
||||
| Self::Five
|
||||
| Self::FiveMini
|
||||
| Self::FivePointOne
|
||||
| Self::FivePointTwo
|
||||
| Self::FivePointThreeCodex
|
||||
| Self::FivePointFour
|
||||
| Self::FivePointFourMini
|
||||
| Self::FivePointFourNano
|
||||
| Self::FivePointFourPro
|
||||
| Self::FivePointFive
|
||||
| Self::FivePointFivePro
|
||||
| Self::FiveNano => true,
|
||||
Self::O3 | Model::Custom { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the given model supports the `prompt_cache_key` parameter.
|
||||
///
|
||||
/// If the model does not support the parameter, do not pass it up.
|
||||
pub fn supports_prompt_cache_key(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Model, ReasoningEffort};
|
||||
|
||||
#[test]
|
||||
fn gpt_5_1_uses_none_reasoning_by_default() {
|
||||
let expected_efforts = [
|
||||
ReasoningEffort::None,
|
||||
ReasoningEffort::Low,
|
||||
ReasoningEffort::Medium,
|
||||
ReasoningEffort::High,
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
Model::FivePointOne.reasoning_effort(),
|
||||
Some(ReasoningEffort::None)
|
||||
);
|
||||
assert_eq!(
|
||||
Model::FivePointOne.supported_reasoning_efforts(),
|
||||
expected_efforts.as_slice()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_frontier_models_support_none_reasoning() {
|
||||
let expected_efforts = [
|
||||
ReasoningEffort::None,
|
||||
ReasoningEffort::Low,
|
||||
ReasoningEffort::Medium,
|
||||
ReasoningEffort::High,
|
||||
ReasoningEffort::XHigh,
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
Model::FivePointTwo.reasoning_effort(),
|
||||
Some(ReasoningEffort::None)
|
||||
);
|
||||
assert_eq!(
|
||||
Model::FivePointTwo.supported_reasoning_efforts(),
|
||||
expected_efforts.as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
Model::FivePointFour.reasoning_effort(),
|
||||
Some(ReasoningEffort::None)
|
||||
);
|
||||
assert_eq!(
|
||||
Model::FivePointFour.supported_reasoning_efforts(),
|
||||
expected_efforts.as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
Model::FivePointFive.reasoning_effort(),
|
||||
Some(ReasoningEffort::Medium)
|
||||
);
|
||||
assert_eq!(
|
||||
Model::FivePointFive.supported_reasoning_efforts(),
|
||||
expected_efforts.as_slice()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_codex_models_support_low_reasoning_effort() {
|
||||
let expected_efforts = [
|
||||
ReasoningEffort::Low,
|
||||
ReasoningEffort::Medium,
|
||||
ReasoningEffort::High,
|
||||
ReasoningEffort::XHigh,
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
Model::FivePointThreeCodex.supported_reasoning_efforts(),
|
||||
expected_efforts.as_slice()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct StreamOptions {
|
||||
pub include_usage: bool,
|
||||
}
|
||||
|
||||
impl Default for StreamOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
include_usage: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Request {
|
||||
pub model: String,
|
||||
pub messages: Vec<RequestMessage>,
|
||||
pub stream: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stream_options: Option<StreamOptions>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_completion_tokens: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub stop: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<ToolChoice>,
|
||||
/// Whether to enable parallel function calling during tool use.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parallel_tool_calls: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tools: Vec<ToolDefinition>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_key: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_effort: Option<ReasoningEffort>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ToolChoice {
|
||||
Auto,
|
||||
Required,
|
||||
None,
|
||||
#[serde(untagged)]
|
||||
Other(ToolDefinition),
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, Debug)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ToolDefinition {
|
||||
#[allow(dead_code)]
|
||||
Function { function: FunctionDefinition },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct FunctionDefinition {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub parameters: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
#[serde(tag = "role", rename_all = "lowercase")]
|
||||
pub enum RequestMessage {
|
||||
Assistant {
|
||||
content: Option<MessageContent>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
tool_calls: Vec<ToolCall>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
reasoning_content: Option<String>,
|
||||
},
|
||||
User {
|
||||
content: MessageContent,
|
||||
},
|
||||
System {
|
||||
content: MessageContent,
|
||||
},
|
||||
Tool {
|
||||
content: MessageContent,
|
||||
tool_call_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum MessageContent {
|
||||
Plain(String),
|
||||
Multipart(Vec<MessagePart>),
|
||||
}
|
||||
|
||||
impl MessageContent {
|
||||
pub fn empty() -> Self {
|
||||
MessageContent::Multipart(vec![])
|
||||
}
|
||||
|
||||
pub fn push_part(&mut self, part: MessagePart) {
|
||||
match self {
|
||||
MessageContent::Plain(text) => {
|
||||
*self =
|
||||
MessageContent::Multipart(vec![MessagePart::Text { text: text.clone() }, part]);
|
||||
}
|
||||
MessageContent::Multipart(parts) if parts.is_empty() => match part {
|
||||
MessagePart::Text { text } => *self = MessageContent::Plain(text),
|
||||
MessagePart::Image { .. } => *self = MessageContent::Multipart(vec![part]),
|
||||
},
|
||||
MessageContent::Multipart(parts) => parts.push(part),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<MessagePart>> for MessageContent {
|
||||
fn from(mut parts: Vec<MessagePart>) -> Self {
|
||||
if let [MessagePart::Text { text }] = parts.as_mut_slice() {
|
||||
MessageContent::Plain(std::mem::take(text))
|
||||
} else {
|
||||
MessageContent::Multipart(parts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum MessagePart {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "image_url")]
|
||||
Image { image_url: ImageUrl },
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ImageUrl {
|
||||
pub url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
#[serde(flatten)]
|
||||
pub content: ToolCallContent,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum ToolCallContent {
|
||||
Function { function: FunctionContent },
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
pub struct FunctionContent {
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
pub struct Response {
|
||||
pub id: String,
|
||||
pub object: String,
|
||||
pub created: u64,
|
||||
pub model: String,
|
||||
pub choices: Vec<Choice>,
|
||||
pub usage: Usage,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
pub struct Choice {
|
||||
pub index: u32,
|
||||
pub message: RequestMessage,
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
pub struct ResponseMessageDelta {
|
||||
pub role: Option<Role>,
|
||||
pub content: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "is_none_or_empty")]
|
||||
pub tool_calls: Option<Vec<ToolCallChunk>>,
|
||||
#[serde(default, skip_serializing_if = "is_none_or_empty")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
pub struct ToolCallChunk {
|
||||
pub index: usize,
|
||||
pub id: Option<String>,
|
||||
|
||||
// There is also an optional `type` field that would determine if a
|
||||
// function is there. Sometimes this streams in with the `function` before
|
||||
// it streams in the `type`
|
||||
pub function: Option<FunctionChunk>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
pub struct FunctionChunk {
|
||||
pub name: Option<String>,
|
||||
pub arguments: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
pub struct Usage {
|
||||
pub prompt_tokens: u64,
|
||||
pub completion_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ChoiceDelta {
|
||||
pub index: u32,
|
||||
pub delta: Option<ResponseMessageDelta>,
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum RequestError {
|
||||
#[error("HTTP response error from {provider}'s API: status {status_code} - {body:?}")]
|
||||
HttpResponseError {
|
||||
provider: String,
|
||||
status_code: StatusCode,
|
||||
body: String,
|
||||
headers: HeaderMap<HeaderValue>,
|
||||
},
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ResponseStreamError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(untagged)]
|
||||
pub enum ResponseStreamResult {
|
||||
Ok(ResponseStreamEvent),
|
||||
Err { error: ResponseStreamError },
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ResponseStreamEvent {
|
||||
pub choices: Vec<ChoiceDelta>,
|
||||
pub usage: Option<Usage>,
|
||||
}
|
||||
|
||||
pub async fn non_streaming_completion(
|
||||
client: &dyn HttpClient,
|
||||
api_url: &str,
|
||||
api_key: &str,
|
||||
request: Request,
|
||||
) -> Result<Response, RequestError> {
|
||||
let uri = format!("{api_url}/chat/completions");
|
||||
let request_builder = HttpRequest::builder()
|
||||
.method(Method::POST)
|
||||
.uri(uri)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", format!("Bearer {}", api_key.trim()));
|
||||
|
||||
let request = request_builder
|
||||
.body(AsyncBody::from(
|
||||
serde_json::to_string(&request).map_err(|e| RequestError::Other(e.into()))?,
|
||||
))
|
||||
.map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
let mut response = client.send(request).await?;
|
||||
if response.status().is_success() {
|
||||
let mut body = String::new();
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_string(&mut body)
|
||||
.await
|
||||
.map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
serde_json::from_str(&body).map_err(|e| RequestError::Other(e.into()))
|
||||
} else {
|
||||
let mut body = String::new();
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_string(&mut body)
|
||||
.await
|
||||
.map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
Err(RequestError::HttpResponseError {
|
||||
provider: "openai".to_owned(),
|
||||
status_code: response.status(),
|
||||
body,
|
||||
headers: response.headers().clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stream_completion(
|
||||
client: &dyn HttpClient,
|
||||
provider_name: &str,
|
||||
api_url: &str,
|
||||
api_key: &str,
|
||||
request: Request,
|
||||
) -> Result<BoxStream<'static, Result<ResponseStreamEvent>>, RequestError> {
|
||||
let uri = format!("{api_url}/chat/completions");
|
||||
let request_builder = HttpRequest::builder()
|
||||
.method(Method::POST)
|
||||
.uri(uri)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", format!("Bearer {}", api_key.trim()));
|
||||
|
||||
let request = request_builder
|
||||
.body(AsyncBody::from(
|
||||
serde_json::to_string(&request).map_err(|e| RequestError::Other(e.into()))?,
|
||||
))
|
||||
.map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
let mut response = client.send(request).await?;
|
||||
if response.status().is_success() {
|
||||
let reader = BufReader::new(response.into_body());
|
||||
Ok(reader
|
||||
.lines()
|
||||
.filter_map(|line| async move {
|
||||
match line {
|
||||
Ok(line) => {
|
||||
let line = line.strip_prefix("data: ").or_else(|| line.strip_prefix("data:"))?;
|
||||
if line == "[DONE]" {
|
||||
None
|
||||
} else {
|
||||
match serde_json::from_str(line) {
|
||||
Ok(ResponseStreamResult::Ok(response)) => Some(Ok(response)),
|
||||
Ok(ResponseStreamResult::Err { error }) => {
|
||||
Some(Err(anyhow!(error.message)))
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!(
|
||||
"Failed to parse OpenAI response into ResponseStreamResult: `{}`\n\
|
||||
Response: `{}`",
|
||||
error,
|
||||
line,
|
||||
);
|
||||
Some(Err(anyhow!(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => Some(Err(anyhow!(error))),
|
||||
}
|
||||
})
|
||||
.boxed())
|
||||
} else {
|
||||
let mut body = String::new();
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_string(&mut body)
|
||||
.await
|
||||
.map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
Err(RequestError::HttpResponseError {
|
||||
provider: provider_name.to_owned(),
|
||||
status_code: response.status(),
|
||||
body,
|
||||
headers: response.headers().clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Serialize, Deserialize)]
|
||||
pub enum OpenAiEmbeddingModel {
|
||||
#[serde(rename = "text-embedding-3-small")]
|
||||
TextEmbedding3Small,
|
||||
#[serde(rename = "text-embedding-3-large")]
|
||||
TextEmbedding3Large,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OpenAiEmbeddingRequest<'a> {
|
||||
model: OpenAiEmbeddingModel,
|
||||
input: Vec<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct OpenAiEmbeddingResponse {
|
||||
pub data: Vec<OpenAiEmbedding>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct OpenAiEmbedding {
|
||||
pub embedding: Vec<f32>,
|
||||
}
|
||||
|
||||
pub fn embed<'a>(
|
||||
client: &dyn HttpClient,
|
||||
api_url: &str,
|
||||
api_key: &str,
|
||||
model: OpenAiEmbeddingModel,
|
||||
texts: impl IntoIterator<Item = &'a str>,
|
||||
) -> impl 'static + Future<Output = Result<OpenAiEmbeddingResponse>> {
|
||||
let uri = format!("{api_url}/embeddings");
|
||||
|
||||
let request = OpenAiEmbeddingRequest {
|
||||
model,
|
||||
input: texts.into_iter().collect(),
|
||||
};
|
||||
let body = AsyncBody::from(serde_json::to_string(&request).unwrap());
|
||||
let request = HttpRequest::builder()
|
||||
.method(Method::POST)
|
||||
.uri(uri)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", format!("Bearer {}", api_key.trim()))
|
||||
.body(body)
|
||||
.map(|request| client.send(request));
|
||||
|
||||
async move {
|
||||
let mut response = request?.await?;
|
||||
let mut body = String::new();
|
||||
response.body_mut().read_to_string(&mut body).await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
response.status().is_success(),
|
||||
"error during embedding, status: {:?}, body: {:?}",
|
||||
response.status(),
|
||||
body
|
||||
);
|
||||
let response: OpenAiEmbeddingResponse =
|
||||
serde_json::from_str(&body).context("failed to parse OpenAI embedding response")?;
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Conversions to `language_model_core` types --
|
||||
|
||||
impl From<RequestError> for language_model_core::LanguageModelCompletionError {
|
||||
fn from(error: RequestError) -> Self {
|
||||
match error {
|
||||
RequestError::HttpResponseError {
|
||||
provider,
|
||||
status_code,
|
||||
body,
|
||||
headers,
|
||||
} => {
|
||||
let retry_after = headers
|
||||
.get(http_client::http::header::RETRY_AFTER)
|
||||
.and_then(|val| val.to_str().ok()?.parse::<u64>().ok())
|
||||
.map(std::time::Duration::from_secs);
|
||||
|
||||
Self::from_http_status(provider.into(), status_code, body, retry_after)
|
||||
}
|
||||
RequestError::Other(e) => Self::Other(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
564
crates/open_ai/src/responses.rs
Normal file
564
crates/open_ai/src/responses.rs
Normal file
@@ -0,0 +1,564 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
|
||||
use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{ReasoningEffort, RequestError, Role, ToolChoice};
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct Request {
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub input: Vec<ResponseInputItem>,
|
||||
pub store: bool,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub include: Vec<ResponseIncludable>,
|
||||
#[serde(default)]
|
||||
pub stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_output_tokens: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parallel_tool_calls: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<ToolChoice>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub tools: Vec<ToolDefinition>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning: Option<ReasoningConfig>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ResponseIncludable {
|
||||
#[serde(rename = "reasoning.encrypted_content")]
|
||||
ReasoningEncryptedContent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ResponseInputItem {
|
||||
Message(ResponseMessageItem),
|
||||
FunctionCall(ResponseFunctionCallItem),
|
||||
FunctionCallOutput(ResponseFunctionCallOutputItem),
|
||||
Reasoning(ResponseReasoningInputItem),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ResponseMessageItem {
|
||||
pub role: Role,
|
||||
pub content: Vec<ResponseInputContent>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub phase: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ResponseFunctionCallItem {
|
||||
pub call_id: String,
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ResponseFunctionCallOutputItem {
|
||||
pub call_id: String,
|
||||
pub output: ResponseFunctionCallOutputContent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ResponseReasoningInputItem {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub summary: Vec<ResponseReasoningSummaryPart>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub content: Vec<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub encrypted_content: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ResponseReasoningSummaryPart {
|
||||
SummaryText { text: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ResponseFunctionCallOutputContent {
|
||||
List(Vec<ResponseInputContent>),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ResponseInputContent {
|
||||
#[serde(rename = "input_text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "input_image")]
|
||||
Image { image_url: String },
|
||||
#[serde(rename = "output_text")]
|
||||
OutputText {
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
annotations: Vec<serde_json::Value>,
|
||||
},
|
||||
#[serde(rename = "refusal")]
|
||||
Refusal { refusal: String },
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct ReasoningConfig {
|
||||
pub effort: ReasoningEffort,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<ReasoningSummaryMode>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ReasoningSummaryMode {
|
||||
Auto,
|
||||
Concise,
|
||||
Detailed,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ToolDefinition {
|
||||
Function {
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
parameters: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
strict: Option<bool>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct ResponseError {
|
||||
#[serde(default)]
|
||||
pub code: Option<String>,
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub param: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum StreamEvent {
|
||||
#[serde(rename = "response.created")]
|
||||
Created { response: ResponseSummary },
|
||||
#[serde(rename = "response.in_progress")]
|
||||
InProgress { response: ResponseSummary },
|
||||
#[serde(rename = "response.output_item.added")]
|
||||
OutputItemAdded {
|
||||
output_index: usize,
|
||||
#[serde(default)]
|
||||
sequence_number: Option<u64>,
|
||||
item: ResponseOutputItem,
|
||||
},
|
||||
#[serde(rename = "response.output_item.done")]
|
||||
OutputItemDone {
|
||||
output_index: usize,
|
||||
#[serde(default)]
|
||||
sequence_number: Option<u64>,
|
||||
item: ResponseOutputItem,
|
||||
},
|
||||
#[serde(rename = "response.content_part.added")]
|
||||
ContentPartAdded {
|
||||
item_id: String,
|
||||
output_index: usize,
|
||||
content_index: usize,
|
||||
part: Value,
|
||||
},
|
||||
#[serde(rename = "response.content_part.done")]
|
||||
ContentPartDone {
|
||||
item_id: String,
|
||||
output_index: usize,
|
||||
content_index: usize,
|
||||
part: Value,
|
||||
},
|
||||
#[serde(rename = "response.output_text.delta")]
|
||||
OutputTextDelta {
|
||||
item_id: String,
|
||||
output_index: usize,
|
||||
#[serde(default)]
|
||||
content_index: Option<usize>,
|
||||
delta: String,
|
||||
},
|
||||
#[serde(rename = "response.output_text.done")]
|
||||
OutputTextDone {
|
||||
item_id: String,
|
||||
output_index: usize,
|
||||
#[serde(default)]
|
||||
content_index: Option<usize>,
|
||||
text: String,
|
||||
},
|
||||
#[serde(rename = "response.refusal.delta")]
|
||||
RefusalDelta {
|
||||
item_id: String,
|
||||
output_index: usize,
|
||||
content_index: usize,
|
||||
delta: String,
|
||||
#[serde(default)]
|
||||
sequence_number: Option<u64>,
|
||||
},
|
||||
#[serde(rename = "response.refusal.done")]
|
||||
RefusalDone {
|
||||
item_id: String,
|
||||
output_index: usize,
|
||||
content_index: usize,
|
||||
refusal: String,
|
||||
#[serde(default)]
|
||||
sequence_number: Option<u64>,
|
||||
},
|
||||
#[serde(rename = "response.reasoning_summary_part.added")]
|
||||
ReasoningSummaryPartAdded {
|
||||
item_id: String,
|
||||
output_index: usize,
|
||||
summary_index: usize,
|
||||
},
|
||||
#[serde(rename = "response.reasoning_summary_text.delta")]
|
||||
ReasoningSummaryTextDelta {
|
||||
item_id: String,
|
||||
output_index: usize,
|
||||
delta: String,
|
||||
},
|
||||
#[serde(rename = "response.reasoning_summary_text.done")]
|
||||
ReasoningSummaryTextDone {
|
||||
item_id: String,
|
||||
output_index: usize,
|
||||
text: String,
|
||||
},
|
||||
#[serde(rename = "response.reasoning_summary_part.done")]
|
||||
ReasoningSummaryPartDone {
|
||||
item_id: String,
|
||||
output_index: usize,
|
||||
summary_index: usize,
|
||||
},
|
||||
#[serde(rename = "response.function_call_arguments.delta")]
|
||||
FunctionCallArgumentsDelta {
|
||||
item_id: String,
|
||||
output_index: usize,
|
||||
delta: String,
|
||||
#[serde(default)]
|
||||
sequence_number: Option<u64>,
|
||||
},
|
||||
#[serde(rename = "response.function_call_arguments.done")]
|
||||
FunctionCallArgumentsDone {
|
||||
item_id: String,
|
||||
output_index: usize,
|
||||
arguments: String,
|
||||
#[serde(default)]
|
||||
sequence_number: Option<u64>,
|
||||
},
|
||||
#[serde(rename = "response.completed")]
|
||||
Completed { response: ResponseSummary },
|
||||
#[serde(rename = "response.incomplete")]
|
||||
Incomplete { response: ResponseSummary },
|
||||
#[serde(rename = "response.failed")]
|
||||
Failed { response: ResponseSummary },
|
||||
#[serde(rename = "response.error")]
|
||||
Error { error: ResponseError },
|
||||
#[serde(rename = "error")]
|
||||
GenericError {
|
||||
#[serde(flatten)]
|
||||
error: ResponseError,
|
||||
},
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Default, Clone)]
|
||||
pub struct ResponseSummary {
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub incomplete_details: Option<ResponseIncompleteDetails>,
|
||||
#[serde(default)]
|
||||
pub error: Option<ResponseError>,
|
||||
#[serde(default)]
|
||||
pub usage: Option<ResponseUsage>,
|
||||
#[serde(default)]
|
||||
pub output: Vec<ResponseOutputItem>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Default, Clone)]
|
||||
pub struct ResponseIncompleteDetails {
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Default, Clone)]
|
||||
pub struct ResponseUsage {
|
||||
#[serde(default)]
|
||||
pub input_tokens: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub input_tokens_details: ResponseInputTokensDetails,
|
||||
#[serde(default)]
|
||||
pub output_tokens: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub output_tokens_details: ResponseOutputTokensDetails,
|
||||
#[serde(default)]
|
||||
pub total_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Default, Clone)]
|
||||
pub struct ResponseInputTokensDetails {
|
||||
#[serde(default)]
|
||||
pub cached_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Default, Clone)]
|
||||
pub struct ResponseOutputTokensDetails {
|
||||
#[serde(default)]
|
||||
pub reasoning_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ResponseOutputItem {
|
||||
Message(ResponseOutputMessage),
|
||||
FunctionCall(ResponseFunctionToolCall),
|
||||
Reasoning(ResponseReasoningItem),
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct ResponseReasoningItem {
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub summary: Vec<ReasoningSummaryPart>,
|
||||
#[serde(default)]
|
||||
pub content: Vec<Value>,
|
||||
#[serde(default)]
|
||||
pub encrypted_content: Option<String>,
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ReasoningSummaryPart {
|
||||
SummaryText {
|
||||
text: String,
|
||||
},
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct ResponseOutputMessage {
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub content: Vec<Value>,
|
||||
#[serde(default)]
|
||||
pub role: Option<String>,
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub phase: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct ResponseFunctionToolCall {
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub arguments: String,
|
||||
#[serde(default)]
|
||||
pub call_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn stream_response(
|
||||
client: &dyn HttpClient,
|
||||
provider_name: &str,
|
||||
api_url: &str,
|
||||
api_key: &str,
|
||||
request: Request,
|
||||
) -> Result<BoxStream<'static, Result<StreamEvent>>, RequestError> {
|
||||
let uri = format!("{api_url}/responses");
|
||||
let request_builder = HttpRequest::builder()
|
||||
.method(Method::POST)
|
||||
.uri(uri)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", format!("Bearer {}", api_key.trim()));
|
||||
|
||||
let is_streaming = request.stream;
|
||||
let request = request_builder
|
||||
.body(AsyncBody::from(
|
||||
serde_json::to_string(&request).map_err(|e| RequestError::Other(e.into()))?,
|
||||
))
|
||||
.map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
let mut response = client.send(request).await?;
|
||||
if response.status().is_success() {
|
||||
if is_streaming {
|
||||
let reader = BufReader::new(response.into_body());
|
||||
Ok(reader
|
||||
.lines()
|
||||
.filter_map(|line| async move {
|
||||
match line {
|
||||
Ok(line) => {
|
||||
let line = line
|
||||
.strip_prefix("data: ")
|
||||
.or_else(|| line.strip_prefix("data:"))?;
|
||||
if line == "[DONE]" || line.is_empty() {
|
||||
None
|
||||
} else {
|
||||
match serde_json::from_str::<StreamEvent>(line) {
|
||||
Ok(event) => Some(Ok(event)),
|
||||
Err(error) => {
|
||||
log::error!(
|
||||
"Failed to parse OpenAI responses stream event: `{}`\nResponse: `{}`",
|
||||
error,
|
||||
line,
|
||||
);
|
||||
Some(Err(anyhow!(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => Some(Err(anyhow!(error))),
|
||||
}
|
||||
})
|
||||
.boxed())
|
||||
} else {
|
||||
let mut body = String::new();
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_string(&mut body)
|
||||
.await
|
||||
.map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
match serde_json::from_str::<ResponseSummary>(&body) {
|
||||
Ok(response_summary) => {
|
||||
let events = vec![
|
||||
StreamEvent::Created {
|
||||
response: response_summary.clone(),
|
||||
},
|
||||
StreamEvent::InProgress {
|
||||
response: response_summary.clone(),
|
||||
},
|
||||
];
|
||||
|
||||
let mut all_events = events;
|
||||
for (output_index, item) in response_summary.output.iter().enumerate() {
|
||||
all_events.push(StreamEvent::OutputItemAdded {
|
||||
output_index,
|
||||
sequence_number: None,
|
||||
item: item.clone(),
|
||||
});
|
||||
|
||||
match item {
|
||||
ResponseOutputItem::Message(message) => {
|
||||
for content_item in &message.content {
|
||||
if let Some(text) = content_item.get("text") {
|
||||
if let Some(text_str) = text.as_str() {
|
||||
if let Some(ref item_id) = message.id {
|
||||
all_events.push(StreamEvent::OutputTextDelta {
|
||||
item_id: item_id.clone(),
|
||||
output_index,
|
||||
content_index: None,
|
||||
delta: text_str.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ResponseOutputItem::FunctionCall(function_call) => {
|
||||
if let Some(ref item_id) = function_call.id {
|
||||
all_events.push(StreamEvent::FunctionCallArgumentsDone {
|
||||
item_id: item_id.clone(),
|
||||
output_index,
|
||||
arguments: function_call.arguments.clone(),
|
||||
sequence_number: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
ResponseOutputItem::Reasoning(reasoning) => {
|
||||
if let Some(ref item_id) = reasoning.id {
|
||||
for part in &reasoning.summary {
|
||||
if let ReasoningSummaryPart::SummaryText { text } = part {
|
||||
all_events.push(
|
||||
StreamEvent::ReasoningSummaryTextDelta {
|
||||
item_id: item_id.clone(),
|
||||
output_index,
|
||||
delta: text.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ResponseOutputItem::Unknown => {}
|
||||
}
|
||||
|
||||
all_events.push(StreamEvent::OutputItemDone {
|
||||
output_index,
|
||||
sequence_number: None,
|
||||
item: item.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let status = response_summary.status.clone();
|
||||
all_events.push(match status.as_deref() {
|
||||
Some("incomplete") => StreamEvent::Incomplete {
|
||||
response: response_summary,
|
||||
},
|
||||
Some("failed") => StreamEvent::Failed {
|
||||
response: response_summary,
|
||||
},
|
||||
_ => StreamEvent::Completed {
|
||||
response: response_summary,
|
||||
},
|
||||
});
|
||||
|
||||
Ok(futures::stream::iter(all_events.into_iter().map(Ok)).boxed())
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!(
|
||||
"Failed to parse OpenAI non-streaming response: `{}`\nResponse: `{}`",
|
||||
error,
|
||||
body,
|
||||
);
|
||||
Err(RequestError::Other(anyhow!(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut body = String::new();
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_string(&mut body)
|
||||
.await
|
||||
.map_err(|e| RequestError::Other(e.into()))?;
|
||||
|
||||
Err(RequestError::HttpResponseError {
|
||||
provider: provider_name.to_owned(),
|
||||
status_code: response.status(),
|
||||
body,
|
||||
headers: response.headers().clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user