use crate::transport::{BodyStream, HttpRequest};
use crate::LingerError;
use crate::RequestId;
use bytes::Bytes;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct Skill {
pub id: String,
pub object: String,
pub name: String,
pub description: String,
pub created_at: u64,
pub default_version: String,
pub latest_version: String,
#[serde(skip)]
request_id: Option<RequestId>,
}
impl Skill {
pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
self.request_id = request_id;
self
}
pub fn request_id(&self) -> Option<&RequestId> {
self.request_id.as_ref()
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct SkillPage {
pub object: String,
#[serde(default)]
pub data: Vec<Skill>,
#[serde(default)]
pub first_id: Option<String>,
#[serde(default)]
pub last_id: Option<String>,
pub has_more: bool,
#[serde(skip)]
request_id: Option<RequestId>,
}
impl SkillPage {
pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
self.request_id = request_id;
self
}
pub fn request_id(&self) -> Option<&RequestId> {
self.request_id.as_ref()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum SkillListOrder {
Asc,
Desc,
}
impl SkillListOrder {
pub(crate) fn as_query_value(self) -> &'static str {
match self {
Self::Asc => "asc",
Self::Desc => "desc",
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct SkillListRequest {
pub limit: Option<u8>,
pub order: Option<SkillListOrder>,
pub after: Option<String>,
}
impl SkillListRequest {
pub fn builder() -> SkillListRequestBuilder {
SkillListRequestBuilder::default()
}
pub(crate) fn path(&self) -> String {
path_with_query(
"/v1/skills",
ListQuery {
limit: self.limit,
order: self.order.map(SkillListOrder::as_query_value),
after: self.after.as_deref(),
},
)
}
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct SkillListRequestBuilder {
limit: Option<u8>,
order: Option<SkillListOrder>,
after: Option<String>,
}
impl SkillListRequestBuilder {
pub fn limit(mut self, limit: u8) -> Self {
self.limit = Some(limit);
self
}
pub fn order(mut self, order: SkillListOrder) -> Self {
self.order = Some(order);
self
}
pub fn after(mut self, after: impl Into<String>) -> Self {
self.after = Some(after.into());
self
}
pub fn build(self) -> Result<SkillListRequest, LingerError> {
if let Some(limit) = self.limit {
if limit == 0 || limit > 100 {
return Err(LingerError::invalid_config(
"limit must be between 1 and 100",
));
}
}
if let Some(after) = &self.after {
if after.trim().is_empty() {
return Err(LingerError::invalid_config("after must not be empty"));
}
}
Ok(SkillListRequest {
limit: self.limit,
order: self.order,
after: self.after,
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum SkillVersionListOrder {
Asc,
Desc,
}
impl SkillVersionListOrder {
pub(crate) fn as_query_value(self) -> &'static str {
match self {
Self::Asc => "asc",
Self::Desc => "desc",
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct SkillVersionListRequest {
pub limit: Option<u8>,
pub order: Option<SkillVersionListOrder>,
pub after: Option<String>,
}
impl SkillVersionListRequest {
pub fn builder() -> SkillVersionListRequestBuilder {
SkillVersionListRequestBuilder::default()
}
pub(crate) fn path(&self, skill_id: &str) -> String {
let base = format!("/v1/skills/{skill_id}/versions");
path_with_query(
&base,
ListQuery {
limit: self.limit,
order: self.order.map(SkillVersionListOrder::as_query_value),
after: self.after.as_deref(),
},
)
}
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct SkillVersionListRequestBuilder {
limit: Option<u8>,
order: Option<SkillVersionListOrder>,
after: Option<String>,
}
impl SkillVersionListRequestBuilder {
pub fn limit(mut self, limit: u8) -> Self {
self.limit = Some(limit);
self
}
pub fn order(mut self, order: SkillVersionListOrder) -> Self {
self.order = Some(order);
self
}
pub fn after(mut self, after: impl Into<String>) -> Self {
self.after = Some(after.into());
self
}
pub fn build(self) -> Result<SkillVersionListRequest, LingerError> {
if let Some(limit) = self.limit {
if limit == 0 || limit > 100 {
return Err(LingerError::invalid_config(
"limit must be between 1 and 100",
));
}
}
if let Some(after) = &self.after {
if after.trim().is_empty() {
return Err(LingerError::invalid_config("after must not be empty"));
}
}
Ok(SkillVersionListRequest {
limit: self.limit,
order: self.order,
after: self.after,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct CreateSkillRequest {
pub file: SkillUpload,
}
impl CreateSkillRequest {
pub fn builder() -> CreateSkillRequestBuilder {
CreateSkillRequestBuilder::default()
}
pub(crate) fn apply_multipart_body(&self, request: &mut HttpRequest) {
let boundary = multipart_boundary(&self.file.content);
request.insert_header(
"content-type",
format!("multipart/form-data; boundary={boundary}"),
);
request.set_body_stream(self.multipart_stream(boundary));
}
fn multipart_stream(
&self,
boundary: String,
) -> impl futures_core::Stream<Item = Result<Bytes, LingerError>> {
let mut chunks = Vec::new();
push_file_field(&mut chunks, &boundary, "files", &self.file);
chunks.push(Ok(Bytes::from(format!("--{boundary}--\r\n"))));
futures_util::stream::iter(chunks)
}
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct CreateSkillRequestBuilder {
file: Option<SkillUpload>,
}
impl CreateSkillRequestBuilder {
pub fn file(mut self, file: SkillUpload) -> Self {
self.file = Some(file);
self
}
pub fn build(self) -> Result<CreateSkillRequest, LingerError> {
let file = self
.file
.ok_or_else(|| LingerError::invalid_config("file is required"))?;
Ok(CreateSkillRequest { file })
}
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct UpdateSkillRequest {
pub default_version: String,
}
impl UpdateSkillRequest {
pub fn builder() -> UpdateSkillRequestBuilder {
UpdateSkillRequestBuilder::default()
}
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct UpdateSkillRequestBuilder {
default_version: Option<String>,
}
impl UpdateSkillRequestBuilder {
pub fn default_version(mut self, default_version: impl Into<String>) -> Self {
self.default_version = Some(default_version.into());
self
}
pub fn build(self) -> Result<UpdateSkillRequest, LingerError> {
let default_version = self
.default_version
.ok_or_else(|| LingerError::invalid_config("default_version is required"))?;
if default_version.trim().is_empty() {
return Err(LingerError::invalid_config(
"default_version must not be empty",
));
}
Ok(UpdateSkillRequest { default_version })
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct CreateSkillVersionRequest {
pub file: SkillUpload,
pub default: Option<bool>,
}
impl CreateSkillVersionRequest {
pub fn builder() -> CreateSkillVersionRequestBuilder {
CreateSkillVersionRequestBuilder::default()
}
pub(crate) fn apply_multipart_body(&self, request: &mut HttpRequest) {
let boundary = multipart_boundary(&self.file.content);
request.insert_header(
"content-type",
format!("multipart/form-data; boundary={boundary}"),
);
request.set_body_stream(self.multipart_stream(boundary));
}
fn multipart_stream(
&self,
boundary: String,
) -> impl futures_core::Stream<Item = Result<Bytes, LingerError>> {
let mut chunks = Vec::new();
if let Some(default) = self.default {
push_text_field(&mut chunks, &boundary, "default", bool_field(default));
}
push_file_field(&mut chunks, &boundary, "files", &self.file);
chunks.push(Ok(Bytes::from(format!("--{boundary}--\r\n"))));
futures_util::stream::iter(chunks)
}
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct CreateSkillVersionRequestBuilder {
file: Option<SkillUpload>,
default: Option<bool>,
}
impl CreateSkillVersionRequestBuilder {
pub fn file(mut self, file: SkillUpload) -> Self {
self.file = Some(file);
self
}
pub fn default_version(mut self, default: bool) -> Self {
self.default = Some(default);
self
}
pub fn build(self) -> Result<CreateSkillVersionRequest, LingerError> {
let file = self
.file
.ok_or_else(|| LingerError::invalid_config("file is required"))?;
Ok(CreateSkillVersionRequest {
file,
default: self.default,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct SkillUpload {
pub filename: String,
pub content_type: String,
content: Bytes,
}
impl SkillUpload {
pub fn from_bytes(
filename: impl Into<String>,
content: impl Into<Bytes>,
) -> Result<Self, LingerError> {
let filename = filename.into();
validate_header_param("filename", &filename)?;
Ok(Self {
filename,
content_type: "application/zip".to_string(),
content: content.into(),
})
}
pub fn content_type(mut self, content_type: impl Into<String>) -> Result<Self, LingerError> {
let content_type = content_type.into();
validate_header_value("content_type", &content_type)?;
self.content_type = content_type;
Ok(self)
}
pub fn bytes(&self) -> Bytes {
self.content.clone()
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct SkillDeletion {
pub object: String,
pub deleted: bool,
pub id: String,
#[serde(skip)]
request_id: Option<RequestId>,
}
impl SkillDeletion {
pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
self.request_id = request_id;
self
}
pub fn request_id(&self) -> Option<&RequestId> {
self.request_id.as_ref()
}
}
pub struct SkillContent {
request_id: Option<RequestId>,
body: BodyStream,
}
impl SkillContent {
pub(crate) fn new(request_id: Option<RequestId>, body: BodyStream) -> Self {
Self { request_id, body }
}
pub fn request_id(&self) -> Option<&RequestId> {
self.request_id.as_ref()
}
pub fn into_stream(self) -> BodyStream {
self.body
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct SkillVersion {
pub object: String,
pub id: String,
pub skill_id: String,
pub version: String,
pub created_at: u64,
pub name: String,
pub description: String,
#[serde(skip)]
request_id: Option<RequestId>,
}
impl SkillVersion {
pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
self.request_id = request_id;
self
}
pub fn request_id(&self) -> Option<&RequestId> {
self.request_id.as_ref()
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct SkillVersionPage {
pub object: String,
#[serde(default)]
pub data: Vec<SkillVersion>,
#[serde(default)]
pub first_id: Option<String>,
#[serde(default)]
pub last_id: Option<String>,
pub has_more: bool,
#[serde(skip)]
request_id: Option<RequestId>,
}
impl SkillVersionPage {
pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
self.request_id = request_id;
self
}
pub fn request_id(&self) -> Option<&RequestId> {
self.request_id.as_ref()
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct SkillVersionDeletion {
pub object: String,
pub deleted: bool,
pub id: String,
pub version: String,
#[serde(skip)]
request_id: Option<RequestId>,
}
impl SkillVersionDeletion {
pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
self.request_id = request_id;
self
}
pub fn request_id(&self) -> Option<&RequestId> {
self.request_id.as_ref()
}
}
fn push_text_field(
chunks: &mut Vec<Result<Bytes, LingerError>>,
boundary: &str,
name: &str,
value: &str,
) {
chunks.push(Ok(Bytes::from(format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n"
))));
}
fn push_file_field(
chunks: &mut Vec<Result<Bytes, LingerError>>,
boundary: &str,
field: &str,
file: &SkillUpload,
) {
chunks.push(Ok(Bytes::from(format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"{field}\"; filename=\"{}\"\r\nContent-Type: {}\r\n\r\n",
escape_multipart_param(&file.filename),
file.content_type
))));
chunks.push(Ok(file.content.clone()));
chunks.push(Ok(Bytes::from("\r\n")));
}
fn bool_field(value: bool) -> &'static str {
if value {
"true"
} else {
"false"
}
}
fn multipart_boundary(content: &Bytes) -> String {
for counter in 0.. {
let boundary = format!("linger-openai-sdk-skill-boundary-{counter}");
if !contains_bytes(content, boundary.as_bytes()) {
return boundary;
}
}
unreachable!("unbounded boundary counter")
}
fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() {
return true;
}
haystack
.windows(needle.len())
.any(|window| window == needle)
}
fn validate_header_param(name: &str, value: &str) -> Result<(), LingerError> {
if value.trim().is_empty() {
return Err(LingerError::invalid_config(format!("{name} is required")));
}
validate_header_value(name, value)
}
fn validate_header_value(name: &str, value: &str) -> Result<(), LingerError> {
if value.contains('\r') || value.contains('\n') {
return Err(LingerError::invalid_config(format!(
"{name} must not contain CR or LF"
)));
}
Ok(())
}
fn escape_multipart_param(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
fn encode_query_value(value: &str) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
encoded.push(byte as char);
}
_ => {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
encoded.push('%');
encoded.push(HEX[(byte >> 4) as usize] as char);
encoded.push(HEX[(byte & 0x0F) as usize] as char);
}
}
}
encoded
}
struct ListQuery<'a> {
limit: Option<u8>,
order: Option<&'static str>,
after: Option<&'a str>,
}
fn path_with_query(base: &str, params: ListQuery<'_>) -> String {
let mut query = Vec::new();
if let Some(limit) = params.limit {
query.push(format!("limit={limit}"));
}
if let Some(order) = params.order {
query.push(format!("order={order}"));
}
if let Some(after) = params.after {
query.push(format!("after={}", encode_query_value(after)));
}
if query.is_empty() {
base.to_string()
} else {
format!("{base}?{}", query.join("&"))
}
}