use std::borrow::Cow;
use crate::chat::preflight::{strip_cache, strip_image_detail};
use crate::{
CapabilitySupport, ChatCapability, ChatRequest, ContentBlock, ContentPart, Degradations,
FileKind, Message, OtherContent, ReasoningEffort, ReplayState, RequestField, ResponseFormat,
Result, Subject, ToolChoice, ToolResultContent, UserContent,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WireSupport {
fields: u32,
content: u8,
}
impl WireSupport {
#[must_use]
pub const fn text() -> Self {
Self {
fields: 0,
content: 0,
}
}
#[must_use]
pub const fn field(mut self, field: RequestField) -> Self {
self.fields |= field_bit(field);
self
}
#[must_use]
pub const fn fields<const N: usize>(mut self, fields: [RequestField; N]) -> Self {
let mut i = 0;
while i < N {
self = self.field(fields[i]);
i += 1;
}
self
}
#[must_use]
pub const fn image_input(mut self) -> Self {
self.content |= CONTENT_IMAGE_INPUT;
self
}
#[must_use]
pub const fn image_detail(mut self) -> Self {
self.content |= CONTENT_IMAGE_DETAIL;
self
}
#[must_use]
pub const fn file_input(mut self) -> Self {
self.content |= CONTENT_FILE_INPUT;
self
}
#[must_use]
pub const fn audio_input(mut self) -> Self {
self.content |= CONTENT_AUDIO_INPUT;
self
}
#[must_use]
pub const fn video_input(mut self) -> Self {
self.content |= CONTENT_VIDEO_INPUT;
self
}
#[must_use]
pub const fn image_replay(mut self) -> Self {
self.content |= CONTENT_IMAGE_REPLAY;
self
}
#[must_use]
pub const fn reasoning_replay(mut self) -> Self {
self.content |= CONTENT_REASONING_REPLAY;
self
}
#[must_use]
pub const fn provider_content(mut self) -> Self {
self.content |= CONTENT_PROVIDER;
self
}
#[must_use]
pub const fn carries(&self, field: RequestField) -> bool {
let bit = field_bit(field);
bit != 0 && self.fields & bit == bit
}
const fn carries_content(&self, content: u8) -> bool {
self.content & content == content
}
#[must_use]
pub const fn chat_capability(&self, capability: ChatCapability) -> Option<CapabilitySupport> {
let tools = self.carries(RequestField::Tools);
let carried = match capability {
ChatCapability::ToolCalls | ChatCapability::ParallelToolCalls => tools,
ChatCapability::ForcedToolChoice => tools && self.carries(RequestField::ToolChoice),
ChatCapability::StructuredOutput => self.carries(RequestField::ResponseFormat),
ChatCapability::ReasoningConfig => self.carries(RequestField::Reasoning),
ChatCapability::PromptCache => self.carries(RequestField::Cache),
ChatCapability::ImageInput => self.carries_content(CONTENT_IMAGE_INPUT),
ChatCapability::ImageDetail => {
self.carries_content(CONTENT_IMAGE_INPUT | CONTENT_IMAGE_DETAIL)
}
ChatCapability::FileInput => self.carries_content(CONTENT_FILE_INPUT),
ChatCapability::AudioInput => self.carries_content(CONTENT_AUDIO_INPUT),
ChatCapability::VideoInput => self.carries_content(CONTENT_VIDEO_INPUT),
ChatCapability::ImageReplay => self.carries_content(CONTENT_IMAGE_REPLAY),
ChatCapability::ReasoningReplay => self.carries_content(CONTENT_REASONING_REPLAY),
ChatCapability::Streaming
| ChatCapability::NativeStreaming
| ChatCapability::StreamUsage
| ChatCapability::ImageOutput
| ChatCapability::ReasoningOutput => true,
};
if carried {
None
} else {
Some(CapabilitySupport::Unsupported)
}
}
}
const CONTENT_IMAGE_INPUT: u8 = 1;
const CONTENT_IMAGE_DETAIL: u8 = 1 << 1;
const CONTENT_FILE_INPUT: u8 = 1 << 2;
const CONTENT_IMAGE_REPLAY: u8 = 1 << 3;
const CONTENT_REASONING_REPLAY: u8 = 1 << 4;
const CONTENT_PROVIDER: u8 = 1 << 5;
const CONTENT_AUDIO_INPUT: u8 = 1 << 6;
const CONTENT_VIDEO_INPUT: u8 = 1 << 7;
const fn field_bit(field: RequestField) -> u32 {
let index = match field {
RequestField::Temperature => 0,
RequestField::MaxOutputTokens => 1,
RequestField::TopP => 2,
RequestField::Stop => 3,
RequestField::FrequencyPenalty => 4,
RequestField::PresencePenalty => 5,
RequestField::Seed => 6,
RequestField::Tools => 7,
RequestField::ToolChoice => 8,
RequestField::ParallelToolCalls => 9,
RequestField::ResponseFormat => 10,
RequestField::Reasoning => 11,
RequestField::System => 12,
RequestField::Cache => 13,
RequestField::TopK => 14,
RequestField::Dimensions | RequestField::InputType | RequestField::Unknown => return 0,
};
1 << index
}
pub(crate) fn degrade(
wire: &WireSupport,
reads_namespace: &dyn Fn(&str) -> bool,
request: &mut Cow<'_, ChatRequest>,
d: &mut Degradations,
) -> Result<()> {
let name = d.provider().to_owned();
let not_supported = |what: &str| format!("{what} is not supported by `{name}`");
let carries = |field| wire.carries(field);
if request.tool_choice == Some(ToolChoice::Disabled)
&& !(carries(RequestField::Tools) && carries(RequestField::ToolChoice))
{
let request = request.to_mut();
request.tools = Vec::new();
request.tool_choice = None;
}
if !carries(RequestField::Tools) && !request.tools.is_empty() {
return Err(d.unsupported(RequestField::Tools, not_supported("tool calling")));
}
match request.tool_choice {
Some(ToolChoice::Auto) if !carries(RequestField::ToolChoice) => {
request.to_mut().tool_choice = None;
}
Some(ToolChoice::Auto | ToolChoice::Disabled) | None => {}
Some(_) if carries(RequestField::Tools) && carries(RequestField::ToolChoice) => {}
Some(_) => {
return Err(d.unsupported(
RequestField::ToolChoice,
not_supported("forced tool choice"),
));
}
}
if !carries(RequestField::ResponseFormat) {
match request.response_format {
None => {}
Some(ResponseFormat::Text) => request.to_mut().response_format = None,
Some(_) => {
return Err(d.unsupported(
RequestField::ResponseFormat,
not_supported("structured output"),
));
}
}
}
if request.max_output_tokens.is_some() && !carries(RequestField::MaxOutputTokens) {
return Err(d.unsupported(
RequestField::MaxOutputTokens,
not_supported("an output cap"),
));
}
if !request.system.is_empty() && !carries(RequestField::System) {
return Err(d.unsupported(RequestField::System, not_supported("a system prompt")));
}
refuse_content(wire, reads_namespace, request, d, ¬_supported)?;
let hint = |d: &mut Degradations, field: RequestField| {
d.unsupported_hint(
field,
format!(
"{}; `{field}` was not sent",
not_supported(&format!("`{field}`"))
),
)
};
let fields = [
(RequestField::Temperature, request.temperature.is_some()),
(RequestField::TopP, request.top_p.is_some()),
(RequestField::TopK, request.top_k.is_some()),
(RequestField::Stop, !request.stop.is_empty()),
(
RequestField::FrequencyPenalty,
request.frequency_penalty.is_some(),
),
(
RequestField::PresencePenalty,
request.presence_penalty.is_some(),
),
(RequestField::Seed, request.seed.is_some()),
];
for (field, set) in fields {
if set && !carries(field) {
hint(d, field)?;
let request = request.to_mut();
match field {
RequestField::Temperature => request.temperature = None,
RequestField::TopP => request.top_p = None,
RequestField::TopK => request.top_k = None,
RequestField::Stop => request.stop = Vec::new(),
RequestField::FrequencyPenalty => request.frequency_penalty = None,
RequestField::PresencePenalty => request.presence_penalty = None,
_ => request.seed = None,
}
}
}
if let Some(parallel) = request.parallel_tool_calls
&& !carries(RequestField::ParallelToolCalls)
{
if !parallel {
d.unsupported_hint(
RequestField::ParallelToolCalls,
format!(
"{}; `false` was not sent, and the model may call several tools at once",
not_supported("`parallel_tool_calls`")
),
)?;
}
request.to_mut().parallel_tool_calls = None;
}
if let Some(reasoning) = request.reasoning
&& !carries(RequestField::Reasoning)
{
if reasoning.effort != Some(ReasoningEffort::None) {
d.unsupported_hint(
RequestField::Reasoning,
format!(
"{}; `reasoning` was not sent",
not_supported("reasoning configuration")
),
)?;
}
request.to_mut().reasoning = None;
}
if (request.cache.is_some() || request.cache_breakpoints().next().is_some())
&& !carries(RequestField::Cache)
{
d.unsupported_hint(
RequestField::Cache,
format!("{}; `cache` was not sent", not_supported("prompt caching")),
)?;
strip_cache(request.to_mut());
}
if wire.carries_content(CONTENT_IMAGE_INPUT) && !wire.carries_content(CONTENT_IMAGE_DETAIL) {
let mut stripped = false;
for (message, block, part) in input_parts(request) {
if let ContentPart::Image(image) = part
&& image.detail.is_some()
{
d.coded_hint(
Subject::Content {
message,
block: Some(block),
},
format!("{}; the detail was not sent", not_supported("image detail")),
"unsupported.image_detail",
)?;
stripped = true;
}
}
if stripped {
strip_image_detail(request.to_mut());
}
}
drop_content(wire, reads_namespace, request, d);
Ok(())
}
fn refuse_content(
wire: &WireSupport,
reads_namespace: &dyn Fn(&str) -> bool,
request: &ChatRequest,
d: &Degradations,
not_supported: &dyn Fn(&str) -> String,
) -> Result<()> {
let content = |message, block| Subject::Content { message, block };
let tools = wire.carries(RequestField::Tools);
for (index, message) in request.messages.iter().enumerate() {
match message {
Message::Tool(_) if !tools => {
return Err(d.reject(content(index, None), not_supported("tool results")));
}
Message::Assistant(m) => {
for (block, b) in m.content.iter().enumerate() {
let what = match b {
ContentBlock::ToolCall(_) if !tools => "tool calls",
ContentBlock::Image(_) if !wire.carries_content(CONTENT_IMAGE_REPLAY) => {
"replaying assistant images"
}
_ => continue,
};
return Err(d.reject(content(index, Some(block)), not_supported(what)));
}
}
_ => {}
}
}
for (index, block, part) in input_parts(request) {
let what = match part {
ContentPart::Image(_) if !wire.carries_content(CONTENT_IMAGE_INPUT) => {
Cow::Borrowed("image input")
}
ContentPart::File(file) => match file.kind() {
FileKind::Audio if !wire.carries_content(CONTENT_AUDIO_INPUT) => {
Cow::Borrowed("audio input")
}
FileKind::Video if !wire.carries_content(CONTENT_VIDEO_INPUT) => {
Cow::Borrowed("video input")
}
FileKind::Document if !wire.carries_content(CONTENT_FILE_INPUT) => {
Cow::Borrowed("file input")
}
_ => continue,
},
ContentPart::Other(other)
if !wire.carries_content(CONTENT_PROVIDER) && !foreign(other, reads_namespace) =>
{
Cow::Owned(format!("`{}` content", other.type_name))
}
_ => continue,
};
return Err(d.reject(content(index, Some(block)), not_supported(&what)));
}
Ok(())
}
fn drop_content(
wire: &WireSupport,
reads_namespace: &dyn Fn(&str) -> bool,
request: &mut Cow<'_, ChatRequest>,
d: &mut Degradations,
) {
let name = d.provider().to_owned();
let reasoning = wire.carries_content(CONTENT_REASONING_REPLAY);
let provider = wire.carries_content(CONTENT_PROVIDER);
for index in 0..request.messages.len() {
let subject = |block| Subject::Content {
message: index,
block,
};
let mut dropped = Vec::new();
let message = &request.messages[index];
match message {
Message::Assistant(m) => {
for (block, b) in m.content.iter().enumerate() {
let drop = match b {
ContentBlock::Reasoning(_) if !reasoning => Some((
ReplayState::Reasoning,
format!("`{name}` takes no reasoning back; it was dropped"),
)),
ContentBlock::Other(_)
| ContentBlock::ProviderToolCall(_)
| ContentBlock::ProviderToolResult(_) => provider_drop(
b.type_name(),
b.origin(),
provider,
reads_namespace,
&name,
),
_ => None,
};
if let Some(drop) = drop {
dropped.push((block, drop));
}
}
}
Message::User(_) | Message::Tool(_) => {
for (block, part) in message_parts(message).iter().enumerate() {
if let ContentPart::Other(other) = part
&& foreign(other, reads_namespace)
{
let drop = provider_drop(
&other.type_name,
other.origin.as_deref(),
provider,
reads_namespace,
&name,
);
dropped.extend(drop.map(|drop| (block, drop)));
}
}
}
}
if dropped.is_empty() {
continue;
}
for (block, (state, message)) in &dropped {
d.dropped_replay_state(*state, subject(Some(*block)), message.clone());
}
let message = &mut request.to_mut().messages[index];
let len = message_len(message);
let kept: Vec<usize> = (0..len)
.filter(|block| !dropped.iter().any(|(gone, _)| gone == block))
.collect();
d.record_kept_blocks(index, len, kept.clone());
retain_blocks(message, &kept);
}
}
fn provider_drop(
type_name: &str,
origin: Option<&str>,
carried: bool,
reads_namespace: &dyn Fn(&str) -> bool,
name: &str,
) -> Option<(ReplayState, String)> {
match origin {
Some(origin) if !reads_namespace(origin) => Some((
ReplayState::ForeignContent,
format!(
"`{type_name}` content from `{origin}` can be replayed only to its origin; it was dropped"
),
)),
_ if carried => None,
_ => Some((
ReplayState::Content,
format!("`{type_name}` content cannot be replayed to `{name}`; it was dropped"),
)),
}
}
fn foreign(other: &OtherContent, reads_namespace: &dyn Fn(&str) -> bool) -> bool {
other
.origin
.as_deref()
.is_some_and(|origin| !reads_namespace(origin))
}
fn input_parts(request: &ChatRequest) -> impl Iterator<Item = (usize, usize, &ContentPart)> {
request
.messages
.iter()
.enumerate()
.flat_map(|(index, message)| {
message_parts(message)
.iter()
.enumerate()
.map(move |(part, content)| (index, part, content))
})
}
pub(crate) fn message_parts(message: &Message) -> &[ContentPart] {
match message {
Message::User(m) => match &m.content {
UserContent::Parts(parts) => parts,
_ => &[],
},
Message::Tool(m) => match &m.content {
ToolResultContent::Parts(parts) => parts,
_ => &[],
},
Message::Assistant(_) => &[],
}
}
fn message_len(message: &Message) -> usize {
match message {
Message::Assistant(m) => m.content.len(),
other => message_parts(other).len(),
}
}
fn retain_blocks(message: &mut Message, kept: &[usize]) {
fn retain<T>(items: &mut Vec<T>, kept: &[usize]) {
let mut index = 0;
items.retain(|_| {
let keep = kept.contains(&index);
index += 1;
keep
});
}
match message {
Message::Assistant(m) => retain(&mut m.content, kept),
Message::User(m) => {
if let UserContent::Parts(parts) = &mut m.content {
retain(parts, kept);
}
}
Message::Tool(m) => {
if let ToolResultContent::Parts(parts) = &mut m.content {
retain(parts, kept);
}
}
}
}
#[cfg(test)]
mod tests;