I think this is the final attempt of mine to do anything about the binary size, as those fixes become relatively intricate and I like them less and less. Each commit has its own improvement stats and mainly reduce generic-code duplication through shared non-generic helpers and `#[inline(never)]` boundaries. Total reduction stats: With sccache disabled, `cargo clean` and `cargo build --release -p zed`: Before: zed binary 396716472 bytes After: zed binary 359416504 bytes (-9.40%, -37.29MB) Release Notes: - N/A --------- Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
1423 lines
47 KiB
Rust
1423 lines
47 KiB
Rust
use anyhow::{Context as _, Result, anyhow};
|
|
use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
|
|
use http_client::{
|
|
AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, RequestBuilderExt,
|
|
};
|
|
use serde::{Deserialize, Serialize, ser::SerializeSeq as _};
|
|
use serde_json::Value;
|
|
use std::sync::Arc;
|
|
|
|
use crate::{ReasoningEffort, RequestError, Role, ServiceTier, ToolChoice};
|
|
use language_model_core::{
|
|
CompactedContext, LanguageModelProviderId, ProviderCompactionState, SharedString,
|
|
};
|
|
|
|
pub const COMPACTION_STATE_FORMAT: &str = "openai.responses.input-items.v1";
|
|
|
|
#[derive(Serialize, Debug)]
|
|
pub struct Request {
|
|
pub model: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub instructions: Option<String>,
|
|
#[serde(skip_serializing_if = "ResponseInput::is_empty")]
|
|
pub input: ResponseInput,
|
|
#[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>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub store: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub service_tier: Option<ServiceTier>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub context_management: Option<Vec<ContextManagement>>,
|
|
}
|
|
|
|
impl Request {
|
|
pub fn into_compact_request(self) -> CompactRequest {
|
|
CompactRequest {
|
|
model: self.model,
|
|
instructions: self.instructions,
|
|
input: self.input,
|
|
prompt_cache_key: self.prompt_cache_key,
|
|
service_tier: self.service_tier,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Debug)]
|
|
pub struct CompactRequest {
|
|
pub model: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub instructions: Option<String>,
|
|
pub input: ResponseInput,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub prompt_cache_key: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub service_tier: Option<ServiceTier>,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
pub struct CompactedResponse {
|
|
#[serde(default)]
|
|
pub id: String,
|
|
#[serde(default)]
|
|
pub created_at: u64,
|
|
#[serde(default)]
|
|
pub object: String,
|
|
pub output: Vec<Value>,
|
|
#[serde(default)]
|
|
pub usage: ResponseUsage,
|
|
}
|
|
|
|
impl CompactedResponse {
|
|
pub fn into_compacted_context(
|
|
self,
|
|
owner: LanguageModelProviderId,
|
|
) -> Result<CompactedContext> {
|
|
Ok(CompactedContext::ProviderState(
|
|
provider_compaction_state_from_items(owner, self.output)?,
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Packages a canonical replacement window into opaque provider state owned by
|
|
/// `owner`.
|
|
///
|
|
/// Several backends speak the OpenAI Responses protocol (OpenAI itself, Zed
|
|
/// Cloud's OpenAI models, OpenAI-compatible endpoints, and others), but their
|
|
/// encrypted compaction items are not interchangeable: only the backend that
|
|
/// produced an item can decrypt it. The owner recorded here is what
|
|
/// [`provider_compaction_items`] later compares against, so it must identify
|
|
/// the backend whose infrastructure produced the items, not merely the wire
|
|
/// protocol.
|
|
pub fn provider_compaction_state_from_items(
|
|
owner: LanguageModelProviderId,
|
|
items: Vec<Value>,
|
|
) -> Result<ProviderCompactionState> {
|
|
validate_compaction_items(&items)?;
|
|
Ok(ProviderCompactionState::new(
|
|
owner,
|
|
SharedString::new_static(COMPACTION_STATE_FORMAT),
|
|
serde_json::to_string(&items)?,
|
|
))
|
|
}
|
|
|
|
/// Recovers the canonical replacement window from `state` if it is owned by
|
|
/// `owner`, or `None` when the state belongs to a different backend and the
|
|
/// caller should fall back to replaying the full transcript.
|
|
pub fn provider_compaction_items(
|
|
state: &ProviderCompactionState,
|
|
owner: &LanguageModelProviderId,
|
|
) -> Result<Option<Vec<Value>>> {
|
|
if state.provider_id() != owner {
|
|
return Ok(None);
|
|
}
|
|
if state.format() != COMPACTION_STATE_FORMAT {
|
|
return Err(anyhow!(
|
|
"unsupported OpenAI compaction state format: {}",
|
|
state.format()
|
|
));
|
|
}
|
|
|
|
let items = serde_json::from_str::<Vec<Value>>(state.payload())
|
|
.context("malformed OpenAI compaction state payload")?;
|
|
validate_compaction_items(&items)?;
|
|
Ok(Some(items))
|
|
}
|
|
|
|
fn validate_compaction_items(items: &[Value]) -> Result<()> {
|
|
if items.is_empty() {
|
|
return Err(anyhow!("OpenAI returned an empty compaction output"));
|
|
}
|
|
if !items.iter().any(|item| {
|
|
item.get("type")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|item_type| {
|
|
matches!(
|
|
item_type,
|
|
"compaction" | "compaction_summary" | "context_compaction"
|
|
)
|
|
})
|
|
}) {
|
|
let item_types = items
|
|
.iter()
|
|
.filter_map(|item| item.get("type").and_then(Value::as_str))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
return Err(anyhow!(
|
|
"OpenAI compaction output did not contain a recognized compaction item \
|
|
(output types: {item_types})"
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct ResponseInput {
|
|
provider_items: Vec<Value>,
|
|
generated_items: Vec<ResponseInputItem>,
|
|
}
|
|
|
|
impl ResponseInput {
|
|
pub fn new(provider_items: Vec<Value>, generated_items: Vec<ResponseInputItem>) -> Self {
|
|
Self {
|
|
provider_items,
|
|
generated_items,
|
|
}
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.provider_items.is_empty() && self.generated_items.is_empty()
|
|
}
|
|
|
|
/// Filters only the items this crate generated from the request.
|
|
///
|
|
/// Provider items are a canonical replacement window that must be replayed
|
|
/// verbatim, so they are exempt from filtering. Callers that rewrite the
|
|
/// input to satisfy backend-specific requirements (and therefore can't
|
|
/// tolerate arbitrary items inside a replayed window) should not accept
|
|
/// provider-native compaction state in the first place.
|
|
pub fn retain(&mut self, predicate: impl FnMut(&ResponseInputItem) -> bool) {
|
|
self.generated_items.retain(predicate);
|
|
}
|
|
|
|
pub fn push(&mut self, item: ResponseInputItem) {
|
|
self.generated_items.push(item);
|
|
}
|
|
}
|
|
|
|
impl Serialize for ResponseInput {
|
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
let mut sequence = serializer
|
|
.serialize_seq(Some(self.provider_items.len() + self.generated_items.len()))?;
|
|
for item in &self.provider_items {
|
|
sequence.serialize_element(item)?;
|
|
}
|
|
for item in &self.generated_items {
|
|
sequence.serialize_element(item)?;
|
|
}
|
|
sequence.end()
|
|
}
|
|
}
|
|
|
|
/// Server-side context management configuration.
|
|
///
|
|
/// <https://developers.openai.com/api/docs/guides/compaction>
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum ContextManagement {
|
|
Compaction { compact_threshold: u64 },
|
|
}
|
|
|
|
#[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),
|
|
CustomToolCall(ResponseCustomToolCallItem),
|
|
CustomToolCallOutput(ResponseCustomToolCallOutputItem),
|
|
Reasoning(ResponseReasoningInputItem),
|
|
Compaction(ResponseCompactionItem),
|
|
CompactionTrigger,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct ResponseCompactionItem {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub id: Option<Arc<str>>,
|
|
pub encrypted_content: Arc<str>,
|
|
}
|
|
|
|
#[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, Serialize, Deserialize)]
|
|
pub struct ResponseCustomToolCallItem {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub id: Option<String>,
|
|
pub call_id: String,
|
|
pub name: String,
|
|
pub input: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct ResponseCustomToolCallOutputItem {
|
|
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, Deserialize, 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>,
|
|
},
|
|
Custom {
|
|
name: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
description: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
format: Option<CustomToolFormat>,
|
|
},
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum CustomToolFormat {
|
|
Text,
|
|
Grammar {
|
|
syntax: CustomToolGrammarSyntax,
|
|
definition: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum CustomToolGrammarSyntax {
|
|
Lark,
|
|
Regex,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct ResponseError {
|
|
#[serde(default)]
|
|
pub code: Option<String>,
|
|
#[serde(default, rename = "type")]
|
|
pub error_type: Option<String>,
|
|
pub message: String,
|
|
#[serde(default)]
|
|
pub param: Option<Value>,
|
|
}
|
|
|
|
/// Payload of the top-level `error` SSE event from the Responses API.
|
|
///
|
|
/// OpenAI's spec documents the error fields as being at the top level of the
|
|
/// event, but in practice the API often nests them under an `error` object.
|
|
#[derive(Deserialize, Debug, Clone, Default)]
|
|
pub struct GenericStreamErrorPayload {
|
|
#[serde(flatten)]
|
|
top_level: PartialResponseError,
|
|
#[serde(default)]
|
|
error: Option<PartialResponseError>,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug, Clone, Default)]
|
|
struct PartialResponseError {
|
|
#[serde(default)]
|
|
code: Option<String>,
|
|
#[serde(default, rename = "type")]
|
|
error_type: Option<String>,
|
|
#[serde(default)]
|
|
message: Option<String>,
|
|
#[serde(default)]
|
|
param: Option<Value>,
|
|
}
|
|
|
|
impl GenericStreamErrorPayload {
|
|
pub fn into_response_error(self) -> ResponseError {
|
|
let nested = self.error.unwrap_or_default();
|
|
ResponseError {
|
|
code: self.top_level.code.or(nested.code),
|
|
error_type: self.top_level.error_type.or(nested.error_type),
|
|
message: self
|
|
.top_level
|
|
.message
|
|
.or(nested.message)
|
|
.unwrap_or_default(),
|
|
param: self.top_level.param.or(nested.param),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
summary_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.reasoning.delta")]
|
|
ReasoningDelta {
|
|
#[serde(default)]
|
|
item_id: Option<String>,
|
|
#[serde(default)]
|
|
output_index: Option<usize>,
|
|
delta: String,
|
|
},
|
|
#[serde(rename = "response.reasoning.done")]
|
|
ReasoningDone {
|
|
#[serde(default)]
|
|
item_id: Option<String>,
|
|
#[serde(default)]
|
|
output_index: Option<usize>,
|
|
#[serde(default)]
|
|
text: Option<String>,
|
|
},
|
|
#[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.custom_tool_call_input.delta")]
|
|
CustomToolCallInputDelta {
|
|
item_id: String,
|
|
output_index: usize,
|
|
delta: String,
|
|
#[serde(default)]
|
|
sequence_number: Option<u64>,
|
|
},
|
|
#[serde(rename = "response.custom_tool_call_input.done")]
|
|
CustomToolCallInputDone {
|
|
item_id: String,
|
|
output_index: usize,
|
|
input: 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: GenericStreamErrorPayload,
|
|
},
|
|
#[serde(other)]
|
|
Unknown,
|
|
}
|
|
|
|
#[derive(Serialize, 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>,
|
|
#[serde(default)]
|
|
pub service_tier: Option<crate::ServiceTier>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
|
|
pub struct ResponseIncompleteDetails {
|
|
#[serde(default)]
|
|
pub reason: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize, 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(Serialize, Deserialize, Debug, Default, Clone)]
|
|
pub struct ResponseInputTokensDetails {
|
|
#[serde(default)]
|
|
pub cached_tokens: u64,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
|
|
pub struct ResponseOutputTokensDetails {
|
|
#[serde(default)]
|
|
pub reasoning_tokens: u64,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum ResponseOutputItem {
|
|
Message(ResponseOutputMessage),
|
|
FunctionCall(ResponseFunctionToolCall),
|
|
CustomToolCall(ResponseCustomToolCall),
|
|
Reasoning(ResponseReasoningItem),
|
|
Compaction(ResponseCompactionItem),
|
|
/// Deserialization-only catch-all; must never be serialized back to the API.
|
|
#[serde(other)]
|
|
Unknown,
|
|
}
|
|
|
|
#[derive(Serialize, 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(Serialize, Deserialize, Debug, Clone)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum ReasoningSummaryPart {
|
|
SummaryText {
|
|
text: String,
|
|
},
|
|
#[serde(other)]
|
|
Unknown,
|
|
}
|
|
|
|
#[derive(Serialize, 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(Serialize, 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>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct ResponseCustomToolCall {
|
|
#[serde(default)]
|
|
pub id: Option<String>,
|
|
#[serde(default)]
|
|
pub status: Option<String>,
|
|
#[serde(default)]
|
|
pub call_id: Option<String>,
|
|
#[serde(default)]
|
|
pub name: Option<String>,
|
|
#[serde(default)]
|
|
pub input: String,
|
|
}
|
|
|
|
pub async fn compact_response(
|
|
client: &dyn HttpClient,
|
|
provider_name: &str,
|
|
api_url: &str,
|
|
api_key: &str,
|
|
request: CompactRequest,
|
|
extra_headers: &CustomHeaders,
|
|
) -> Result<CompactedResponse, RequestError> {
|
|
let request = HttpRequest::builder()
|
|
.method(Method::POST)
|
|
.uri(format!("{api_url}/responses/compact"))
|
|
.header("Content-Type", "application/json")
|
|
.header("Authorization", format!("Bearer {}", api_key.trim()))
|
|
.extra_headers(extra_headers)
|
|
.body(AsyncBody::from(
|
|
serde_json::to_string(&request).map_err(|error| RequestError::Other(error.into()))?,
|
|
))
|
|
.map_err(|error| RequestError::Other(error.into()))?;
|
|
|
|
let host = request.uri().host().unwrap_or(api_url).to_owned();
|
|
let mut response = client
|
|
.send(request)
|
|
.await
|
|
.map_err(|error| RequestError::HttpSend {
|
|
provider: provider_name.to_owned(),
|
|
host,
|
|
error,
|
|
})?;
|
|
let mut body = String::new();
|
|
response
|
|
.body_mut()
|
|
.read_to_string(&mut body)
|
|
.await
|
|
.map_err(|error| RequestError::Other(error.into()))?;
|
|
|
|
if response.status().is_success() {
|
|
serde_json::from_str(&body).map_err(|error| RequestError::Other(error.into()))
|
|
} else {
|
|
Err(RequestError::HttpResponseError {
|
|
provider: provider_name.to_owned(),
|
|
status_code: response.status(),
|
|
body,
|
|
headers: Box::new(response.headers().clone()),
|
|
})
|
|
}
|
|
}
|
|
|
|
pub async fn stream_response(
|
|
client: &dyn HttpClient,
|
|
provider_name: &str,
|
|
api_url: &str,
|
|
api_key: &str,
|
|
request: Request,
|
|
extra_headers: &CustomHeaders,
|
|
) -> Result<BoxStream<'static, Result<StreamEvent>>, RequestError> {
|
|
let uri = format!("{api_url}/responses");
|
|
let is_streaming = request.stream;
|
|
let request = HttpRequest::builder()
|
|
.method(Method::POST)
|
|
.uri(uri)
|
|
.header("Content-Type", "application/json")
|
|
.header("Authorization", format!("Bearer {}", api_key.trim()))
|
|
.extra_headers(extra_headers)
|
|
.body(AsyncBody::from(
|
|
serde_json::to_string(&request).map_err(|e| RequestError::Other(e.into()))?,
|
|
))
|
|
.map_err(|e| RequestError::Other(e.into()))?;
|
|
|
|
let host = request.uri().host().unwrap_or(api_url).to_owned();
|
|
let mut response = client
|
|
.send(request)
|
|
.await
|
|
.map_err(|error| RequestError::HttpSend {
|
|
provider: provider_name.to_owned(),
|
|
host,
|
|
error,
|
|
})?;
|
|
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 decode_stream_event(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::CustomToolCall(custom_tool_call) => {
|
|
if let Some(ref item_id) = custom_tool_call.id {
|
|
all_events.push(StreamEvent::CustomToolCallInputDone {
|
|
item_id: item_id.clone(),
|
|
output_index,
|
|
input: custom_tool_call.input.clone(),
|
|
sequence_number: None,
|
|
});
|
|
}
|
|
}
|
|
ResponseOutputItem::Reasoning(reasoning) => {
|
|
if let Some(ref item_id) = reasoning.id {
|
|
for (summary_index, part) in
|
|
reasoning.summary.iter().enumerate()
|
|
{
|
|
if let ReasoningSummaryPart::SummaryText { text } = part {
|
|
all_events.push(
|
|
StreamEvent::ReasoningSummaryTextDelta {
|
|
item_id: item_id.clone(),
|
|
output_index,
|
|
summary_index,
|
|
delta: text.clone(),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// No synthesized deltas; the `OutputItemDone`
|
|
// event pushed below carries the full item.
|
|
ResponseOutputItem::Compaction(_) => {}
|
|
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: Box::new(response.headers().clone()),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[inline(never)]
|
|
fn decode_stream_event(line: &str) -> serde_json::Result<StreamEvent> {
|
|
serde_json::from_str(line)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use futures::executor::block_on;
|
|
use http_client::FakeHttpClient;
|
|
use language_model_core::OPEN_AI_PROVIDER_ID;
|
|
use serde_json::json;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
#[test]
|
|
fn compact_response_posts_supported_request_fields() {
|
|
let captured_request = Arc::new(Mutex::new(None));
|
|
let captured_request_for_handler = captured_request.clone();
|
|
let http_client = FakeHttpClient::create(move |request| {
|
|
let captured_request = captured_request_for_handler.clone();
|
|
async move {
|
|
let method = request.method().clone();
|
|
let uri = request.uri().to_string();
|
|
let authorization = request
|
|
.headers()
|
|
.get("Authorization")
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::to_string);
|
|
let mut body = request.into_body();
|
|
let mut body_text = String::new();
|
|
body.read_to_string(&mut body_text).await?;
|
|
*captured_request.lock().unwrap() = Some((method, uri, authorization, body_text));
|
|
|
|
Ok(http_client::Response::builder()
|
|
.status(200)
|
|
.body(AsyncBody::from(
|
|
json!({
|
|
"id": "resp_compact",
|
|
"created_at": 1_700_000_000,
|
|
"object": "response.compaction",
|
|
"output": [{
|
|
"type": "compaction",
|
|
"id": "cmp_manual",
|
|
"encrypted_content": "opaque-state"
|
|
}],
|
|
"usage": {
|
|
"input_tokens": 100,
|
|
"input_tokens_details": {"cached_tokens": 20},
|
|
"output_tokens": 10,
|
|
"output_tokens_details": {"reasoning_tokens": 5},
|
|
"total_tokens": 110
|
|
}
|
|
})
|
|
.to_string(),
|
|
))?)
|
|
}
|
|
});
|
|
let response = block_on(compact_response(
|
|
http_client.as_ref(),
|
|
"OpenAI",
|
|
"https://api.openai.com/v1",
|
|
"secret",
|
|
compact_test_request(),
|
|
&CustomHeaders::default(),
|
|
))
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
provider_compaction_items(
|
|
&match response
|
|
.into_compacted_context(OPEN_AI_PROVIDER_ID)
|
|
.unwrap()
|
|
{
|
|
CompactedContext::ProviderState(state) => state,
|
|
CompactedContext::Summary { .. } => panic!("expected provider state"),
|
|
},
|
|
&OPEN_AI_PROVIDER_ID
|
|
)
|
|
.unwrap(),
|
|
Some(vec![json!({
|
|
"type": "compaction",
|
|
"id": "cmp_manual",
|
|
"encrypted_content": "opaque-state"
|
|
})])
|
|
);
|
|
let (method, uri, authorization, body) = captured_request.lock().unwrap().take().unwrap();
|
|
assert_eq!(method, Method::POST);
|
|
assert_eq!(uri, "https://api.openai.com/v1/responses/compact");
|
|
assert_eq!(authorization.as_deref(), Some("Bearer secret"));
|
|
assert_eq!(
|
|
serde_json::from_str::<Value>(&body).unwrap(),
|
|
json!({
|
|
"model": "gpt-5.4",
|
|
"input": [{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{
|
|
"type": "input_text",
|
|
"text": "Retain this context."
|
|
}]
|
|
}],
|
|
"prompt_cache_key": "thread-123",
|
|
"service_tier": "priority"
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compact_response_reports_http_and_deserialization_errors() {
|
|
let http_client = FakeHttpClient::create(|_| async move {
|
|
Ok(http_client::Response::builder()
|
|
.status(429)
|
|
.header("retry-after", "5")
|
|
.body(AsyncBody::from("rate limited"))?)
|
|
});
|
|
|
|
let error = block_on(compact_response(
|
|
http_client.as_ref(),
|
|
"OpenAI",
|
|
"https://api.openai.com/v1",
|
|
"secret",
|
|
compact_test_request(),
|
|
&CustomHeaders::default(),
|
|
))
|
|
.unwrap_err();
|
|
|
|
match error {
|
|
RequestError::HttpResponseError {
|
|
provider,
|
|
status_code,
|
|
body,
|
|
headers,
|
|
} => {
|
|
assert_eq!(provider, "OpenAI");
|
|
assert_eq!(status_code, 429);
|
|
assert_eq!(body, "rate limited");
|
|
assert_eq!(headers["retry-after"], "5");
|
|
}
|
|
error => panic!("expected an HTTP response error, got {error:?}"),
|
|
}
|
|
|
|
let http_client = FakeHttpClient::create(|_| async move {
|
|
Ok(http_client::Response::builder()
|
|
.status(200)
|
|
.body(AsyncBody::from("not valid JSON"))?)
|
|
});
|
|
|
|
let error = block_on(compact_response(
|
|
http_client.as_ref(),
|
|
"OpenAI",
|
|
"https://api.openai.com/v1",
|
|
"secret",
|
|
compact_test_request(),
|
|
&CustomHeaders::default(),
|
|
))
|
|
.unwrap_err();
|
|
|
|
assert!(
|
|
matches!(error, RequestError::Other(_)),
|
|
"expected malformed JSON to produce a request error, got {error:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stream_response_reports_http_send_errors() {
|
|
let http_client =
|
|
FakeHttpClient::create(|_| async move { Err(anyhow::anyhow!("DNS lookup failed")) });
|
|
|
|
let error = block_on(stream_response(
|
|
http_client.as_ref(),
|
|
"ChatGPT Subscription",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
"secret",
|
|
response_test_request(),
|
|
&CustomHeaders::default(),
|
|
));
|
|
let error = match error {
|
|
Ok(_) => panic!("expected request to fail"),
|
|
Err(error) => language_model_core::LanguageModelCompletionError::from(error),
|
|
};
|
|
|
|
match error {
|
|
language_model_core::LanguageModelCompletionError::HttpSend {
|
|
provider,
|
|
host,
|
|
error,
|
|
} => {
|
|
assert_eq!(provider.0.as_ref(), "ChatGPT Subscription");
|
|
assert_eq!(host, "chatgpt.com");
|
|
assert_eq!(error.to_string(), "DNS lookup failed");
|
|
}
|
|
error => panic!("expected an HTTP send error, got {error:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn compact_response_reports_http_send_errors() {
|
|
let http_client =
|
|
FakeHttpClient::create(|_| async move { Err(anyhow::anyhow!("DNS lookup failed")) });
|
|
|
|
let error = block_on(compact_response(
|
|
http_client.as_ref(),
|
|
"ChatGPT Subscription",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
"secret",
|
|
compact_test_request(),
|
|
&CustomHeaders::default(),
|
|
));
|
|
let error = match error {
|
|
Ok(_) => panic!("expected request to fail"),
|
|
Err(error) => language_model_core::LanguageModelCompletionError::from(error),
|
|
};
|
|
|
|
match error {
|
|
language_model_core::LanguageModelCompletionError::HttpSend {
|
|
provider,
|
|
host,
|
|
error,
|
|
} => {
|
|
assert_eq!(provider.0.as_ref(), "ChatGPT Subscription");
|
|
assert_eq!(host, "chatgpt.com");
|
|
assert_eq!(error.to_string(), "DNS lookup failed");
|
|
}
|
|
error => panic!("expected an HTTP send error, got {error:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn compacted_response_preserves_canonical_output_items() {
|
|
let output = vec![
|
|
json!({
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": "Retained user context.",
|
|
"provider_extension": {"preserve": true}
|
|
}),
|
|
json!({
|
|
"type": "compaction",
|
|
"id": "cmp_manual",
|
|
"encrypted_content": "opaque-state"
|
|
}),
|
|
];
|
|
let response: CompactedResponse = serde_json::from_value(json!({
|
|
"id": "resp_compact",
|
|
"created_at": 1_700_000_000,
|
|
"object": "response.compaction",
|
|
"output": &output,
|
|
"usage": {
|
|
"input_tokens": 100,
|
|
"input_tokens_details": {"cached_tokens": 20},
|
|
"output_tokens": 10,
|
|
"output_tokens_details": {"reasoning_tokens": 5},
|
|
"total_tokens": 110
|
|
}
|
|
}))
|
|
.unwrap();
|
|
|
|
let CompactedContext::ProviderState(state) = response
|
|
.into_compacted_context(OPEN_AI_PROVIDER_ID)
|
|
.unwrap()
|
|
else {
|
|
panic!("expected provider state");
|
|
};
|
|
assert_eq!(
|
|
provider_compaction_items(&state, &OPEN_AI_PROVIDER_ID).unwrap(),
|
|
Some(output)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compacted_response_preserves_legacy_compaction_item_types() {
|
|
for item_type in ["compaction_summary", "context_compaction"] {
|
|
let output = vec![json!({
|
|
"type": item_type,
|
|
"encrypted_content": "opaque-state"
|
|
})];
|
|
let response: CompactedResponse =
|
|
serde_json::from_value(json!({ "output": &output })).unwrap();
|
|
|
|
let CompactedContext::ProviderState(state) = response
|
|
.into_compacted_context(OPEN_AI_PROVIDER_ID)
|
|
.unwrap()
|
|
else {
|
|
panic!("expected provider state");
|
|
};
|
|
assert_eq!(
|
|
provider_compaction_items(&state, &OPEN_AI_PROVIDER_ID).unwrap(),
|
|
Some(output)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn compacted_response_rejects_output_without_compaction_item() {
|
|
let response: CompactedResponse = serde_json::from_value(json!({
|
|
"id": "resp_compact",
|
|
"created_at": 1_700_000_000,
|
|
"object": "response.compaction",
|
|
"output": [{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": "Retained user context."
|
|
}],
|
|
"usage": {
|
|
"input_tokens": 100,
|
|
"input_tokens_details": {"cached_tokens": 20},
|
|
"output_tokens": 10,
|
|
"output_tokens_details": {"reasoning_tokens": 5},
|
|
"total_tokens": 110
|
|
}
|
|
}))
|
|
.unwrap();
|
|
|
|
assert!(
|
|
response
|
|
.into_compacted_context(OPEN_AI_PROVIDER_ID)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("compaction item")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compacted_response_rejects_empty_output() {
|
|
let response: CompactedResponse = serde_json::from_value(json!({
|
|
"id": "resp_compact",
|
|
"created_at": 1_700_000_000,
|
|
"object": "response.compaction",
|
|
"output": [],
|
|
"usage": {
|
|
"input_tokens": 100,
|
|
"input_tokens_details": {"cached_tokens": 20},
|
|
"output_tokens": 10,
|
|
"output_tokens_details": {"reasoning_tokens": 5},
|
|
"total_tokens": 110
|
|
}
|
|
}))
|
|
.unwrap();
|
|
|
|
assert!(
|
|
response
|
|
.into_compacted_context(OPEN_AI_PROVIDER_ID)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("empty")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_compaction_items_ignores_state_owned_by_another_provider() {
|
|
let items = vec![json!({
|
|
"type": "compaction",
|
|
"id": "cmp_manual",
|
|
"encrypted_content": "opaque-state"
|
|
})];
|
|
let state =
|
|
provider_compaction_state_from_items(OPEN_AI_PROVIDER_ID, items.clone()).unwrap();
|
|
|
|
assert_eq!(
|
|
provider_compaction_items(&state, &LanguageModelProviderId::new("anthropic")).unwrap(),
|
|
None
|
|
);
|
|
|
|
// The same window stamped for a different OpenAI-protocol backend is
|
|
// opaque to OpenAI proper: encrypted compaction items are only
|
|
// decryptable by the infrastructure that produced them.
|
|
let compatible_backend = LanguageModelProviderId::new("my-compatible-endpoint");
|
|
let state =
|
|
provider_compaction_state_from_items(compatible_backend.clone(), items).unwrap();
|
|
assert_eq!(
|
|
provider_compaction_items(&state, &OPEN_AI_PROVIDER_ID).unwrap(),
|
|
None
|
|
);
|
|
assert!(
|
|
provider_compaction_items(&state, &compatible_backend)
|
|
.unwrap()
|
|
.is_some()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_compaction_items_rejects_unknown_open_ai_format() {
|
|
let state = ProviderCompactionState::new(
|
|
OPEN_AI_PROVIDER_ID,
|
|
"openai.responses.input-items.v2",
|
|
"[]",
|
|
);
|
|
assert!(
|
|
provider_compaction_items(&state, &OPEN_AI_PROVIDER_ID)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("unsupported OpenAI compaction state format")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_compaction_items_rejects_malformed_open_ai_state() {
|
|
let state = ProviderCompactionState::new(
|
|
OPEN_AI_PROVIDER_ID,
|
|
COMPACTION_STATE_FORMAT,
|
|
"not valid JSON",
|
|
);
|
|
|
|
assert!(provider_compaction_items(&state, &OPEN_AI_PROVIDER_ID).is_err());
|
|
}
|
|
|
|
fn compact_test_request() -> CompactRequest {
|
|
CompactRequest {
|
|
model: "gpt-5.4".to_string(),
|
|
instructions: None,
|
|
input: ResponseInput::new(
|
|
Vec::new(),
|
|
vec![ResponseInputItem::Message(ResponseMessageItem {
|
|
role: Role::User,
|
|
content: vec![ResponseInputContent::Text {
|
|
text: "Retain this context.".to_string(),
|
|
}],
|
|
phase: None,
|
|
})],
|
|
),
|
|
prompt_cache_key: Some("thread-123".to_string()),
|
|
service_tier: Some(ServiceTier::Priority),
|
|
}
|
|
}
|
|
|
|
fn response_test_request() -> Request {
|
|
Request {
|
|
model: "gpt-5.4".to_string(),
|
|
instructions: None,
|
|
input: ResponseInput::new(Vec::new(), Vec::new()),
|
|
include: Vec::new(),
|
|
stream: true,
|
|
temperature: None,
|
|
top_p: None,
|
|
max_output_tokens: None,
|
|
parallel_tool_calls: None,
|
|
tool_choice: None,
|
|
tools: Vec::new(),
|
|
prompt_cache_key: None,
|
|
reasoning: None,
|
|
store: None,
|
|
service_tier: None,
|
|
context_management: None,
|
|
}
|
|
}
|
|
}
|