use std::borrow::Cow;
use std::collections::HashSet;
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::Error;
use crate::macros::enum_names;
mod attempt;
pub(crate) mod degradations;
#[cfg(feature = "warning-events")]
mod events;
pub(crate) use attempt::AttemptScope;
pub(crate) use degradations::Degradations;
pub fn emit(provider: &str, model: &str, warning: &mut Warning) {
if warning.emitted {
return;
}
#[cfg(feature = "warning-events")]
{
warning.emitted = true;
events::emit(provider, model, warning);
}
#[cfg(not(feature = "warning-events"))]
let _ = (provider, model);
}
#[cfg(any(feature = "tracing", test))]
pub(crate) fn was_emitted(warning: &Warning) -> bool {
warning.emitted
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Warning {
pub kind: WarningKind,
pub subject: Subject,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<Cow<'static, str>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<Cow<'static, str>>,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cause: Option<WarningCause>,
#[serde(skip)]
emitted: bool,
}
impl PartialEq for Warning {
fn eq(&self, other: &Self) -> bool {
self.kind == other.kind
&& self.subject == other.subject
&& self.provider == other.provider
&& self.code == other.code
&& self.message == other.message
&& self.cause == other.cause
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct WarningCause {
pub kind: Cow<'static, str>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stage: Option<crate::UnsupportedStage>,
}
impl WarningCause {
#[must_use]
pub fn new(kind: impl Into<Cow<'static, str>>) -> Self {
Self {
kind: kind.into(),
stage: None,
}
}
#[must_use]
pub fn with_stage(mut self, stage: crate::UnsupportedStage) -> Self {
self.stage = Some(stage);
self
}
#[must_use]
pub fn of(error: &Error) -> Self {
Self {
kind: error.kind_name().into(),
stage: error.unsupported_stage(),
}
}
}
impl Eq for Warning {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum WarningKind {
Unsupported,
Approximated,
Dropped,
Deprecated,
Fallback,
#[serde(other)]
Other,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Subject {
Field {
field: RequestField,
},
Content {
message: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
block: Option<usize>,
},
EmbeddingInput {
input: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
part: Option<usize>,
},
Messages {
messages: Vec<usize>,
},
System {
index: usize,
},
Option {
namespace: String,
},
Response,
Other {
name: String,
},
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RequestField {
Temperature,
MaxOutputTokens,
TopP,
TopK,
Stop,
FrequencyPenalty,
PresencePenalty,
Seed,
Tools,
ToolChoice,
ParallelToolCalls,
ResponseFormat,
Reasoning,
System,
Cache,
Dimensions,
InputType,
#[serde(other)]
Unknown,
}
impl WarningKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Unsupported => "unsupported",
Self::Approximated => "approximated",
Self::Dropped => "dropped",
Self::Deprecated => "deprecated",
Self::Fallback => "fallback",
Self::Other => "other",
}
}
}
impl fmt::Display for WarningKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl fmt::Display for Subject {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Subject::Field { field } => write!(f, "`{field}`"),
Subject::Content {
message,
block: Some(block),
} => write!(f, "message {message}, block {block}"),
Subject::Content {
message,
block: None,
} => write!(f, "message {message}"),
Subject::EmbeddingInput {
input,
part: Some(part),
} => write!(f, "embedding input {input}, part {part}"),
Subject::EmbeddingInput { input, part: None } => write!(f, "embedding input {input}"),
Subject::Messages { messages } => {
f.write_str("messages ")?;
write_indices(f, messages, ", ")
}
Subject::System { index } => write!(f, "system prompt {index}"),
Subject::Option { namespace } => write!(f, "the `{namespace}` options"),
Subject::Response => f.write_str("the response"),
Subject::Other { name } => f.write_str(name),
Subject::Unknown => f.write_str("an unknown subject"),
}
}
}
pub(crate) fn write_indices(
f: &mut fmt::Formatter<'_>,
indices: &[usize],
separator: &str,
) -> fmt::Result {
for (i, index) in indices.iter().enumerate() {
if i > 0 {
f.write_str(separator)?;
}
write!(f, "{index}")?;
}
Ok(())
}
impl RequestField {
pub const ALL: &'static [Self] = &[
Self::Temperature,
Self::MaxOutputTokens,
Self::TopP,
Self::TopK,
Self::Stop,
Self::FrequencyPenalty,
Self::PresencePenalty,
Self::Seed,
Self::Tools,
Self::ToolChoice,
Self::ParallelToolCalls,
Self::ResponseFormat,
Self::Reasoning,
Self::System,
Self::Cache,
Self::Dimensions,
Self::InputType,
];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Temperature => "temperature",
Self::MaxOutputTokens => "max_output_tokens",
Self::TopP => "top_p",
Self::TopK => "top_k",
Self::Stop => "stop",
Self::FrequencyPenalty => "frequency_penalty",
Self::PresencePenalty => "presence_penalty",
Self::Seed => "seed",
Self::Tools => "tools",
Self::ToolChoice => "tool_choice",
Self::ParallelToolCalls => "parallel_tool_calls",
Self::ResponseFormat => "response_format",
Self::Reasoning => "reasoning",
Self::System => "system",
Self::Cache => "cache",
Self::Dimensions => "dimensions",
Self::InputType => "input_type",
Self::Unknown => "unknown",
}
}
}
impl fmt::Display for RequestField {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl Warning {
#[must_use]
pub fn new(kind: WarningKind, subject: Subject, message: impl Into<String>) -> Self {
Self {
kind,
subject,
provider: None,
code: None,
message: message.into(),
cause: None,
emitted: false,
}
}
#[must_use]
pub fn unsupported(field: RequestField, message: impl Into<String>) -> Self {
Self::new(WarningKind::Unsupported, Subject::Field { field }, message)
}
#[must_use]
pub fn with_provider(mut self, provider: impl Into<Cow<'static, str>>) -> Self {
self.provider = Some(provider.into());
self
}
#[must_use]
pub fn with_code(mut self, code: &'static str) -> Self {
self.code = Some(Cow::Borrowed(code));
self
}
#[must_use]
pub fn recurs(&self) -> bool {
self.code.is_some()
}
#[must_use]
pub fn replay_state(&self) -> Option<ReplayState> {
if self.kind != WarningKind::Dropped {
return None;
}
let code = self.code.as_deref()?;
ReplayState::ALL
.into_iter()
.find(|state| state.code() == code)
}
#[must_use]
pub fn with_cause(mut self, cause: WarningCause) -> Self {
self.cause = Some(cause);
self
}
pub(crate) fn mark_emitted(&mut self) {
self.emitted = true;
}
fn with_default_code(mut self) -> Self {
if self.code.is_none() {
let field = match &self.subject {
Subject::Field { field } => Some(field.as_str()),
Subject::System { .. } => Some(RequestField::System.as_str()),
_ => None,
};
if let Some(field) = field {
self.code = Some(Cow::Owned(format!("{}.{field}", self.kind.as_str())));
}
}
self
}
}
impl fmt::Display for Warning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(provider) = &self.provider {
write!(f, "[{provider}] ")?;
}
if let Subject::Content { .. } | Subject::EmbeddingInput { .. } = self.subject {
write!(f, "{}: ", self.subject)?;
}
f.write_str(&self.message)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum UnsupportedPolicy {
#[default]
Warn,
Error,
}
enum_names!(UnsupportedPolicy, "unsupported policy", {
Warn => "warn",
Error => "error",
});
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ReplayState {
Reasoning,
ForeignReasoning,
ForeignProviderData,
ForeignContent,
Content,
}
impl ReplayState {
const ALL: [Self; 5] = [
Self::Reasoning,
Self::ForeignReasoning,
Self::ForeignProviderData,
Self::ForeignContent,
Self::Content,
];
#[must_use]
pub fn code(self) -> &'static str {
match self {
Self::Reasoning => "dropped.reasoning_replay",
Self::ForeignReasoning => "dropped.foreign_reasoning",
Self::ForeignProviderData => "dropped.foreign_provider_data",
Self::ForeignContent => "dropped.foreign_content",
Self::Content => "dropped.content_replay",
}
}
#[must_use]
pub fn is_foreign(self) -> bool {
matches!(
self,
Self::ForeignReasoning | Self::ForeignProviderData | Self::ForeignContent
)
}
}
#[derive(Debug, Clone, Default)]
pub struct SeenWarnings {
seen: HashSet<SeenKey>,
}
impl SeenWarnings {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, model: &str, warning: &Warning) -> bool {
let Some(code) = &warning.code else {
return true;
};
self.seen.insert(SeenKey {
provider: warning.provider.clone(),
model: model.to_owned(),
kind: warning.kind,
code: code.clone(),
})
}
pub fn clear(&mut self) {
self.seen.clear();
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct SeenKey {
provider: Option<Cow<'static, str>>,
model: String,
kind: WarningKind,
code: Cow<'static, str>,
}
#[cfg(all(test, feature = "warning-events"))]
pub(crate) mod capture {
use std::cell::RefCell;
use std::future::Future;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, Once};
use tracing::field::{Field, Visit};
use tracing::level_filters::LevelFilter;
use tracing::span::{Attributes, Id, Record};
use tracing::subscriber::Interest;
use tracing::{Event, Metadata, Subscriber};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Captured {
pub(crate) target: String,
pub(crate) level: tracing::Level,
pub(crate) message: String,
pub(crate) model: Option<String>,
pub(crate) code: Option<String>,
pub(crate) repeats: bool,
pub(crate) finish_reason: Option<String>,
pub(crate) cause_kind: Option<String>,
pub(crate) cause_stage: Option<String>,
}
type Sink = Arc<Mutex<Vec<Captured>>>;
thread_local! {
static SINK: RefCell<Option<Sink>> = const { RefCell::new(None) };
}
static EVERY_THREAD: Mutex<Vec<Sink>> = Mutex::new(Vec::new());
pub(crate) fn warning_events<T>(future: impl Future<Output = T>) -> (T, Vec<String>) {
let (output, events) = captured(future);
let messages = events
.into_iter()
.filter(|e| e.target.starts_with("allama::warning"))
.map(|e| e.message)
.collect();
(output, messages)
}
pub(crate) fn captured<T>(future: impl Future<Output = T>) -> (T, Vec<Captured>) {
install();
let sink = Sink::default();
SINK.with(|current| *current.borrow_mut() = Some(sink.clone()));
let output = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.expect("a runtime")
.block_on(future);
SINK.with(|current| current.borrow_mut().take());
let events = sink.lock().unwrap().clone();
(output, events)
}
pub(crate) fn captured_on_threads<T>(
model_prefix: &str,
future: impl Future<Output = T>,
) -> (T, Vec<Captured>) {
install();
let sink = Sink::default();
EVERY_THREAD.lock().unwrap().push(sink.clone());
let output = tokio::runtime::Builder::new_multi_thread()
.worker_threads(4)
.enable_time()
.build()
.expect("a runtime")
.block_on(future);
EVERY_THREAD
.lock()
.unwrap()
.retain(|every| !Arc::ptr_eq(every, &sink));
let events = sink
.lock()
.unwrap()
.iter()
.filter(|e| {
e.model
.as_deref()
.is_some_and(|m| m.starts_with(model_prefix))
})
.cloned()
.collect();
(output, events)
}
fn install() {
static INSTALL: Once = Once::new();
INSTALL.call_once(|| {
tracing::subscriber::set_global_default(Global)
.expect("no other global subscriber in the unit tests");
ARMED.store(true, Ordering::SeqCst);
tracing::callsite::rebuild_interest_cache();
});
}
static ARMED: AtomicBool = AtomicBool::new(false);
struct Global;
impl Subscriber for Global {
fn register_callsite(&self, _: &'static Metadata<'static>) -> Interest {
Interest::sometimes()
}
fn max_level_hint(&self) -> Option<LevelFilter> {
if ARMED.load(Ordering::SeqCst) {
None
} else {
Some(LevelFilter::OFF)
}
}
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
!metadata.is_span()
&& (metadata.target().starts_with("allama::warning")
|| metadata.target() == "allama::finish")
&& (SINK.with(|sink| sink.borrow().is_some())
|| !EVERY_THREAD.lock().unwrap().is_empty())
}
fn new_span(&self, _: &Attributes<'_>) -> Id {
Id::from_u64(1)
}
fn record(&self, _: &Id, _: &Record<'_>) {}
fn record_follows_from(&self, _: &Id, _: &Id) {}
fn event(&self, event: &Event<'_>) {
let metadata = event.metadata();
let mut fields = Fields::default();
event.record(&mut fields);
let captured = Captured {
target: metadata.target().to_owned(),
level: *metadata.level(),
message: fields.message,
model: fields.model,
code: fields.code,
repeats: fields.repeats,
finish_reason: fields.finish_reason,
cause_kind: fields.cause_kind,
cause_stage: fields.cause_stage,
};
for every in EVERY_THREAD.lock().unwrap().iter() {
every.lock().unwrap().push(captured.clone());
}
SINK.with(|sink| {
if let Some(sink) = sink.borrow().as_ref() {
sink.lock().unwrap().push(captured);
}
});
}
fn enter(&self, _: &Id) {}
fn exit(&self, _: &Id) {}
}
#[derive(Default)]
struct Fields {
message: String,
model: Option<String>,
code: Option<String>,
repeats: bool,
finish_reason: Option<String>,
cause_kind: Option<String>,
cause_stage: Option<String>,
}
impl Visit for Fields {
fn record_str(&mut self, field: &Field, value: &str) {
match field.name() {
"allama.warning.model" => self.model = Some(value.to_owned()),
"allama.warning.code" => self.code = Some(value.to_owned()),
"allama.warning.repeats" => self.repeats = true,
"allama.finish.reason" => self.finish_reason = Some(value.to_owned()),
"allama.warning.cause_kind" => self.cause_kind = Some(value.to_owned()),
"allama.warning.cause_stage" => self.cause_stage = Some(value.to_owned()),
_ => {}
}
}
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.message = format!("{value:?}");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coded_lossy_hints_merge_per_code() {
let mut d = Degradations::new("demo", UnsupportedPolicy::Warn);
let code = "approximated.tool_result_error";
for message in [5, 2, 5] {
d.coded_lossy_hint(
Subject::Content {
message,
block: None,
},
format!("demo has no tool error flag (message {message}); it was sent as text"),
code,
)
.unwrap();
}
d.coded_lossy_hint(
Subject::Content {
message: 1,
block: None,
},
"other; it was approximated",
"approximated.tool_call_arguments",
)
.unwrap();
d.coded_lossy_hint(
Subject::Content {
message: 1,
block: Some(3),
},
"other; it was approximated",
"approximated.tool_call_arguments",
)
.unwrap();
let warnings = d.warnings();
assert_eq!(warnings.len(), 2, "{warnings:?}");
assert_eq!(warnings[0].code.as_deref(), Some(code));
assert_eq!(
warnings[0].subject,
Subject::Messages {
messages: vec![2, 5]
}
);
assert!(warnings[0].message.contains("(message 5)"));
assert_eq!(
warnings[1].subject,
Subject::Content {
message: 1,
block: None
}
);
}
#[test]
fn coded_approximations_are_recorded_under_every_policy() {
let code = "approximated.tool_result_error";
for policy in [UnsupportedPolicy::Warn, UnsupportedPolicy::Error] {
let mut d = Degradations::new("demo", policy);
d.record_kept_blocks(1, 2, vec![1]);
d.coded_approximated(
Subject::Content {
message: 1,
block: Some(0),
},
"demo has no flag; it was sent as text",
"approximated.demo",
);
for message in [6, 3, 6] {
d.coded_approximated(
Subject::Content {
message,
block: None,
},
format!("demo has no tool error flag (message {message}); it was sent as text"),
code,
);
}
let warnings = d.warnings();
assert_eq!(warnings.len(), 2, "{policy:?}: {warnings:?}");
assert_eq!(
warnings[0].subject,
Subject::Content {
message: 1,
block: Some(1)
}
);
let joined = &warnings[1];
assert_eq!(joined.kind, WarningKind::Approximated);
assert_eq!(joined.code.as_deref(), Some(code));
assert_eq!(joined.provider.as_deref(), Some("demo"));
assert_eq!(
joined.subject,
Subject::Messages {
messages: vec![3, 6]
}
);
assert!(joined.message.contains("(message 6)"), "{joined:?}");
}
}
#[test]
fn replay_state_reads_every_state_back() {
fn _exhaustive(state: ReplayState) {
match state {
ReplayState::Reasoning
| ReplayState::ForeignReasoning
| ReplayState::ForeignProviderData
| ReplayState::ForeignContent
| ReplayState::Content => {}
}
}
let subject = Subject::Content {
message: 0,
block: None,
};
for state in ReplayState::ALL {
let mut d = Degradations::new("demo", UnsupportedPolicy::Warn);
d.dropped_replay_state(state, subject.clone(), "x; it was dropped");
let warning = &d.warnings()[0];
assert_eq!(warning.replay_state(), Some(state));
assert_eq!(
warning.message.contains("retain_provider_state_for"),
state.is_foreign(),
"{state:?}"
);
}
let coded = Warning::new(WarningKind::Dropped, subject.clone(), "x; it was dropped")
.with_code("dropped.content_replay");
assert_eq!(coded.replay_state(), Some(ReplayState::Content));
assert!(!ReplayState::Content.is_foreign());
let approximated = Warning::new(WarningKind::Approximated, subject.clone(), "x")
.with_code("dropped.foreign_content");
assert_eq!(approximated.replay_state(), None);
let system =
Warning::new(WarningKind::Dropped, subject.clone(), "x").with_code("dropped.system");
assert_eq!(system.replay_state(), None);
assert_eq!(
Warning::new(WarningKind::Dropped, subject, "x").replay_state(),
None
);
}
#[test]
fn the_warning_codes_golden_names_every_kind() {
fn index(kind: WarningKind) -> usize {
match kind {
WarningKind::Unsupported => 0,
WarningKind::Approximated => 1,
WarningKind::Dropped => 2,
WarningKind::Deprecated => 3,
WarningKind::Fallback => 4,
WarningKind::Other => 5,
}
}
let all = [
WarningKind::Unsupported,
WarningKind::Approximated,
WarningKind::Dropped,
WarningKind::Deprecated,
WarningKind::Fallback,
WarningKind::Other,
];
for (i, kind) in all.into_iter().enumerate() {
assert_eq!(index(kind), i);
}
let golden: serde_json::Value =
serde_json::from_str(include_str!("../tests/fixtures/warning_codes.json")).unwrap();
let names: Vec<&str> = all.iter().map(|kind| kind.as_str()).collect();
assert_eq!(golden["kinds"], serde_json::json!(names));
}
#[test]
fn all_request_fields_listed() {
fn _exhaustive(field: RequestField) {
match field {
RequestField::Temperature
| RequestField::MaxOutputTokens
| RequestField::TopP
| RequestField::TopK
| RequestField::Stop
| RequestField::FrequencyPenalty
| RequestField::PresencePenalty
| RequestField::Seed
| RequestField::Tools
| RequestField::ToolChoice
| RequestField::ParallelToolCalls
| RequestField::ResponseFormat
| RequestField::Reasoning
| RequestField::System
| RequestField::Cache
| RequestField::Dimensions
| RequestField::InputType
| RequestField::Unknown => {}
}
}
let unique: std::collections::HashSet<_> = RequestField::ALL.iter().collect();
assert_eq!(unique.len(), RequestField::ALL.len());
assert_eq!(RequestField::ALL.len(), 17);
assert!(!RequestField::ALL.contains(&RequestField::Unknown));
}
#[test]
fn errors_are_authored_and_logged() {
let mut strict = Degradations::new("demo", UnsupportedPolicy::Error);
let errors = [
strict.unsupported(RequestField::Seed, "no seed"),
strict.reject(None, "no audio parts"),
strict
.hint(
Subject::Field {
field: RequestField::Seed,
},
"no seed; it was ignored",
)
.unwrap_err(),
strict
.coded_lossy_hint(
Subject::Field {
field: RequestField::Seed,
},
"args replayed as {}",
"approximated.seed",
)
.unwrap_err(),
];
let lines: Vec<String> = errors.iter().map(|e| e.as_log().to_string()).collect();
assert_eq!(
lines,
[
r#"kind=unsupported provider=demo message="cannot send `seed`: no seed""#,
r#"kind=unsupported provider=demo message="cannot send the request: no audio parts""#,
r#"kind=unsupported provider=demo message="cannot send `seed`: no seed""#,
r#"kind=unsupported provider=demo message="cannot send `seed`: args replayed as {}""#,
]
);
}
#[test]
fn warn_policy_collects() {
let mut d = Degradations::new("demo", UnsupportedPolicy::Warn);
d.unsupported_hint(RequestField::Seed, "no seed").unwrap();
d.dropped(
Subject::Content {
message: 1,
block: Some(0),
},
"foreign signature",
);
let warnings = d.into_warnings();
assert_eq!(warnings.len(), 2);
assert_eq!(warnings[0].kind, WarningKind::Unsupported);
assert_eq!(warnings[0].provider.as_deref(), Some("demo"));
assert_eq!(warnings[1].kind, WarningKind::Dropped);
}
#[test]
fn error_policy_fails_hints_and_semantic_fields_always_fail() {
let mut strict = Degradations::new("demo", UnsupportedPolicy::Error);
let err = strict
.unsupported_hint(RequestField::Seed, "no seed")
.unwrap_err();
assert!(matches!(err, Error::Unsupported { .. }));
assert_eq!(err.context().unwrap().provider.as_deref(), Some("demo"));
let lenient = Degradations::new("demo", UnsupportedPolicy::Warn);
let err = lenient.unsupported(RequestField::ResponseFormat, "no json schema");
assert!(err.to_string().contains("response_format"));
}
#[test]
fn lossy_hints_warn_or_fail_and_rejections_are_attributed() {
let subject = Subject::Content {
message: 1,
block: Some(0),
};
let mut lenient = Degradations::new("demo", UnsupportedPolicy::Warn);
lenient
.coded_lossy_hint(
subject.clone(),
"arguments replayed as {}",
"approximated.tool_call_arguments",
)
.unwrap();
assert_eq!(lenient.warnings()[0].kind, WarningKind::Approximated);
assert_eq!(lenient.warnings()[0].subject, subject);
let mut strict = Degradations::new("demo", UnsupportedPolicy::Error);
assert!(
strict
.coded_lossy_hint(subject, "x", "approximated.tool_call_arguments")
.is_err()
);
let err = strict.reject(None, "audio input is not supported");
assert!(matches!(err, Error::Unsupported { .. }));
assert_eq!(err.context().unwrap().provider.as_deref(), Some("demo"));
}
#[test]
fn coded_lossy_hints_strip_the_outcome_and_translate_subjects() {
let message = "demo has no tool error flag; the error text was sent as the tool result; \
to send it under a strict policy, push it with `push_tool_result`";
let code = "approximated.tool_result_error";
let removed = |policy| {
let mut d = Degradations::new("demo", policy);
d.record_kept_blocks(1, 2, vec![1]);
d
};
let subject = Subject::Content {
message: 1,
block: Some(0),
};
let caller = Subject::Content {
message: 1,
block: Some(1),
};
let mut lenient = removed(UnsupportedPolicy::Warn);
lenient
.coded_lossy_hint(subject.clone(), message, code)
.unwrap();
let warning = &lenient.warnings()[0];
assert_eq!(warning.kind, WarningKind::Approximated);
assert_eq!(warning.code.as_deref(), Some(code));
assert_eq!(warning.subject, caller);
assert_eq!(warning.message, message);
assert_eq!(warning.provider.as_deref(), Some("demo"));
let mut strict = removed(UnsupportedPolicy::Error);
let err = strict.coded_lossy_hint(subject, message, code).unwrap_err();
assert_eq!(err.unsupported_subject(), Some(&caller));
assert_eq!(
err.message(),
"cannot send message 1, block 1: demo has no tool error flag; \
to send it under a strict policy, push it with `push_tool_result`"
);
assert!(strict.warnings().is_empty());
let err = strict
.coded_lossy_hint(
Subject::Content {
message: 0,
block: None,
},
"limited; it was approximated",
code,
)
.unwrap_err();
assert_eq!(err.message(), "cannot send message 0: limited");
}
#[test]
fn every_hint_refusal_drops_the_outcome_and_keeps_the_remedy() {
let message = "demo has no seed; `seed` was not sent; remove it to send the request";
let expected =
"cannot send `seed`: demo has no seed; remove it to send the request".to_owned();
let field = || Subject::Field {
field: RequestField::Seed,
};
let mut strict = Degradations::new("demo", UnsupportedPolicy::Error);
let refusals = [
strict.unsupported_hint(RequestField::Seed, message),
strict.hint(field(), message),
strict.coded_hint(field(), message, "unsupported.seed"),
strict.coded_lossy_hint(field(), message, "unsupported.seed"),
];
for refusal in refusals {
assert_eq!(refusal.unwrap_err().message(), expected);
}
let err = strict
.unsupported_hint(RequestField::Seed, "demo has no seed; `seed` was not sent")
.unwrap_err();
assert_eq!(err.message(), "cannot send `seed`: demo has no seed");
}
#[test]
fn refusal_messages_share_one_form() {
let mut strict = Degradations::new("demo", UnsupportedPolicy::Error);
let semantic = strict.unsupported(RequestField::ResponseFormat, "no JSON Schema output");
let hint = strict
.unsupported_hint(RequestField::Seed, "Demo has no seed; it was ignored")
.unwrap_err();
let content = strict.reject(
Subject::Content {
message: 1,
block: Some(0),
},
"audio input is not supported",
);
assert_eq!(
semantic.to_string(),
"demo: unsupported: cannot send `response_format`: no JSON Schema output"
);
assert_eq!(
hint.to_string(),
"demo: unsupported: cannot send `seed`: Demo has no seed"
);
assert_eq!(
content.to_string(),
"demo: unsupported: cannot send message 1, block 0: audio input is not supported"
);
}
#[test]
fn every_refusal_is_before_sending() {
let mut strict = Degradations::new("demo", UnsupportedPolicy::Error);
let refusals = [
strict.unsupported(RequestField::Seed, "x"),
strict.reject(None, "x"),
strict
.unsupported_hint(RequestField::Seed, "x")
.unwrap_err(),
strict
.coded_lossy_hint(Subject::Response, "x", "approximated.response")
.unwrap_err(),
];
for err in refusals {
assert_eq!(
err.unsupported_stage(),
Some(crate::UnsupportedStage::BeforeSending),
"{err}"
);
}
}
#[test]
fn refused_content_is_translated_to_the_callers_indices() {
let mut d = Degradations::new("demo", UnsupportedPolicy::Error);
d.record_kept_blocks(1, 2, vec![1]);
let err = d.reject(
Subject::Content {
message: 1,
block: Some(0),
},
"no images",
);
assert_eq!(
err.unsupported_subject(),
Some(&Subject::Content {
message: 1,
block: Some(1)
})
);
}
#[test]
fn refusals_name_the_refused_field() {
let mut strict = Degradations::new("demo", UnsupportedPolicy::Error);
let field = |field| Subject::Field { field };
let content = Subject::Content {
message: 0,
block: None,
};
let refusals = [
(
strict.unsupported(RequestField::ResponseFormat, "no schema"),
Some(RequestField::ResponseFormat),
),
(
strict
.unsupported_hint(RequestField::Seed, "no seed")
.unwrap_err(),
Some(RequestField::Seed),
),
(
strict
.hint(field(RequestField::Tools), "strict dropped")
.unwrap_err(),
Some(RequestField::Tools),
),
(
strict
.coded_lossy_hint(
field(RequestField::Reasoning),
"approximated",
"approximated.reasoning",
)
.unwrap_err(),
Some(RequestField::Reasoning),
),
(strict.hint(content.clone(), "detail").unwrap_err(), None),
(
strict
.coded_lossy_hint(content, "args", "approximated.tool_call_arguments")
.unwrap_err(),
None,
),
(strict.reject(None, "no audio parts"), None),
];
for (err, expected) in refusals {
assert_eq!(err.unsupported_field(), expected, "{err}");
}
assert_eq!(Error::unsupported("HTTP 400").unsupported_field(), None);
}
#[test]
fn the_refused_field_survives_serde_and_fallback() {
let refused = Degradations::new("demo", UnsupportedPolicy::Warn)
.unsupported(RequestField::ResponseFormat, "no schema");
let json = serde_json::to_value(&refused).unwrap();
assert_eq!(
json["data"]["subject"],
serde_json::json!({"type": "field", "field": "response_format"})
);
assert_eq!(json["data"]["stage"], "before_sending");
let back: Error = serde_json::from_value(json).unwrap();
assert_eq!(back.unsupported_field(), Some(RequestField::ResponseFormat));
assert_eq!(
back.unsupported_stage(),
Some(crate::UnsupportedStage::BeforeSending)
);
let unnamed = serde_json::to_value(Error::unsupported("x")).unwrap();
assert!(unnamed["data"].get("subject").is_none(), "{unnamed}");
assert!(unnamed["data"].get("stage").is_none(), "{unnamed}");
let err = serde_json::from_value::<Error>(serde_json::json!({
"type": "unsupported",
"data": {"message": "x", "subject": {"type": "content", "message": "three"}}
}))
.unwrap_err()
.to_string();
assert!(err.contains("invalid type"), "{err}");
assert!(!err.contains("did not match any variant"), "{err}");
let newer: Error = serde_json::from_value(serde_json::json!({
"type": "unsupported",
"data": {"message": "x", "stage": "after_dinner",
"subject": {"type": "content", "message": 2, "block": 1}}
}))
.unwrap();
assert_eq!(
newer.unsupported_stage(),
Some(crate::UnsupportedStage::Unspecified)
);
assert_eq!(
newer.unsupported_subject(),
Some(&Subject::Content {
message: 2,
block: Some(1)
})
);
let chained = refused
.clone()
.with_fallback_from(Error::rate_limited("slow down"));
assert_eq!(
chained.unsupported_field(),
Some(RequestField::ResponseFormat)
);
let reversed = Error::rate_limited("slow down").with_fallback_from(refused);
assert_eq!(reversed.unsupported_field(), None);
assert_eq!(
reversed.fallback_from().unwrap().unsupported_field(),
Some(RequestField::ResponseFormat)
);
}
#[test]
fn a_second_removal_composes_with_the_first() {
let mut d = Degradations::new("demo", UnsupportedPolicy::Warn);
d.record_kept_blocks(0, 5, vec![1, 3, 4]);
d.record_kept_blocks(0, 3, vec![0, 2]);
let content = |block| Subject::Content {
message: 0,
block: Some(block),
};
let translated: Vec<_> = (0..3)
.map(|block| {
d.translate(Warning::new(WarningKind::Other, content(block), "x"))
.subject
})
.collect();
assert_eq!(translated, [content(1), content(4), content(5)]);
}
#[test]
fn translation_leaves_the_provider_and_message_alone() {
let mut d = Degradations::new("demo", UnsupportedPolicy::Warn);
d.record_kept_blocks(1, 2, vec![1]);
let warning = Warning::new(
WarningKind::Dropped,
Subject::Content {
message: 1,
block: Some(0),
},
"inner",
)
.with_provider("inner");
let translated = d.translate(warning.clone());
assert_eq!(translated.provider.as_deref(), Some("inner"));
assert_eq!(translated.message, "inner");
assert_eq!(
translated.subject,
Subject::Content {
message: 1,
block: Some(1)
}
);
assert!(d.warnings().is_empty());
}
#[test]
fn codes_round_trip_and_equality_ignores_emission() {
let mut warning = Warning::unsupported(RequestField::Seed, "no seed").with_code("x.seed");
let unmarked = warning.clone();
emit("demo", "m", &mut warning);
assert_eq!(warning, unmarked);
let json = serde_json::to_value(&warning).unwrap();
assert_eq!(json["code"], "x.seed");
assert!(json.get("emitted").is_none(), "{json}");
let back: Warning = serde_json::from_value(json).unwrap();
assert_eq!(back, warning);
assert!(!back.emitted);
let old: Warning = serde_json::from_value(serde_json::json!({
"kind": "unsupported", "subject": {"type": "response"}, "message": "m"
}))
.unwrap();
assert_eq!(old.code, None);
}
#[test]
fn degradations_give_field_and_system_warnings_a_default_code() {
let mut d = Degradations::new("demo", UnsupportedPolicy::Warn);
d.unsupported_hint(RequestField::Seed, "no seed").unwrap();
d.approximated(
Subject::Field {
field: RequestField::Reasoning,
},
"budget rounded",
);
d.dropped(Subject::System { index: 1 }, "options ignored");
d.dropped(
Subject::Content {
message: 0,
block: None,
},
"attachment dropped",
);
d.warn(Warning::unsupported(RequestField::TopP, "x").with_code("custom.top_p"));
d.warn(Warning::new(WarningKind::Other, Subject::Response, "x"));
d.coded_hint(
Subject::Content {
message: 0,
block: Some(0),
},
"detail",
"unsupported.image_detail",
)
.unwrap();
let codes: Vec<_> = d.warnings().iter().map(|w| w.code.as_deref()).collect();
assert_eq!(
codes,
[
Some("unsupported.seed"),
Some("approximated.reasoning"),
Some("dropped.system"),
None,
Some("custom.top_p"),
None,
Some("unsupported.image_detail"),
]
);
let mut strict = Degradations::new("demo", UnsupportedPolicy::Error);
let content = Subject::Content {
message: 0,
block: Some(0),
};
assert!(strict.coded_hint(content, "detail", "x.y").is_err());
}
#[cfg(feature = "warning-events")]
#[test]
fn emit_sends_a_warning_once() {
let mut warning = Warning::new(WarningKind::Other, Subject::Response, "once");
let ((), events) = capture::warning_events(async {
emit("demo", "m", &mut warning);
let mut copy = warning.clone();
emit("wrapper", "m", &mut copy);
});
assert_eq!(events, ["once"]);
}
#[test]
fn seen_warnings_key_on_provider_model_kind_and_code() {
let coded = |provider: &'static str, kind: WarningKind, message: &str| {
Warning::new(
kind,
Subject::Content {
message: 0,
block: Some(1),
},
message,
)
.with_provider(provider)
.with_code("dropped.reasoning_replay")
};
let mut seen = SeenWarnings::new();
assert!(seen.insert("m", &coded("a", WarningKind::Dropped, "first")));
assert!(
!seen.insert("m", &coded("a", WarningKind::Dropped, "other text")),
"message text is not in the key"
);
let mut moved = coded("a", WarningKind::Dropped, "first");
moved.subject = Subject::Content {
message: 5,
block: None,
};
assert!(!seen.insert("m", &moved), "the subject is not in the key");
assert!(seen.insert("m", &coded("b", WarningKind::Dropped, "first")));
assert!(seen.insert("m", &coded("a", WarningKind::Other, "first")));
assert!(seen.insert("n", &coded("a", WarningKind::Dropped, "first")));
let unnamed = Warning::new(WarningKind::Dropped, Subject::Response, "x")
.with_code("dropped.reasoning_replay");
assert!(seen.insert("m", &unnamed), "no provider is its own key");
assert!(!seen.insert("m", &unnamed));
let uncoded = Warning::new(WarningKind::Dropped, Subject::Response, "x");
assert!(!uncoded.recurs());
assert!(seen.insert("m", &uncoded));
assert!(seen.insert("m", &uncoded));
seen.clear();
assert!(seen.insert("m", &coded("a", WarningKind::Dropped, "first")));
}
#[test]
fn display_shows_the_location_of_content_and_embedding_inputs() {
let shown = |subject: Subject, provider: Option<&'static str>| {
let warning = Warning::new(WarningKind::Dropped, subject, "it was dropped");
match provider {
Some(provider) => warning.with_provider(provider),
None => warning,
}
.to_string()
};
let content = |message, block| Subject::Content { message, block };
assert_eq!(
shown(content(3, Some(0)), Some("ollama")),
"[ollama] message 3, block 0: it was dropped"
);
assert_eq!(
shown(content(1, None), Some("ollama")),
"[ollama] message 1: it was dropped"
);
assert_eq!(
shown(content(0, Some(2)), None),
"message 0, block 2: it was dropped"
);
assert_eq!(
shown(
Subject::EmbeddingInput {
input: 2,
part: Some(1)
},
Some("gemini")
),
"[gemini] embedding input 2, part 1: it was dropped"
);
assert_eq!(
shown(
Subject::EmbeddingInput {
input: 4,
part: None
},
None
),
"embedding input 4: it was dropped"
);
for subject in [
Subject::Field {
field: RequestField::Seed,
},
Subject::System { index: 0 },
Subject::Response,
Subject::Option {
namespace: "x".into(),
},
Subject::Other { name: "y".into() },
Subject::Unknown,
] {
assert_eq!(shown(subject, Some("p")), "[p] it was dropped");
}
}
#[test]
fn warning_serde_round_trip() {
let w = Warning::unsupported(RequestField::TopP, "ignored").with_provider("gemini");
let json = serde_json::to_value(&w).unwrap();
assert_eq!(json["subject"]["field"], "top_p");
let back: Warning = serde_json::from_value(json).unwrap();
assert_eq!(back, w);
}
#[test]
fn newer_kinds_fields_and_subjects_read_as_their_fallbacks() {
use serde_json::json;
let read = |value: serde_json::Value| serde_json::from_value::<Warning>(value).unwrap();
let kind = read(json!({"kind": "throttled", "subject": {"type": "response"},
"message": "m"}));
assert_eq!(kind.kind, WarningKind::Other);
assert_eq!(kind.subject, Subject::Response);
let field = read(json!({"kind": "unsupported",
"subject": {"type": "field", "field": "logit_bias"},
"message": "m"}));
assert_eq!(
field.subject,
Subject::Field {
field: RequestField::Unknown
}
);
assert_eq!(RequestField::Unknown.as_str(), "unknown");
let subject = read(json!({"kind": "dropped",
"subject": {"type": "audio_track", "track": 2, "label": "x"},
"provider": "p", "message": "m"}));
assert_eq!(subject.kind, WarningKind::Dropped);
assert_eq!(subject.subject, Subject::Unknown);
assert_eq!(subject.provider.as_deref(), Some("p"));
assert_eq!(serde_json::to_value(&kind).unwrap()["kind"], json!("other"));
assert_eq!(
serde_json::to_value(&field).unwrap()["subject"],
json!({"type": "field", "field": "unknown"})
);
assert_eq!(
serde_json::to_value(&subject).unwrap()["subject"],
json!({"type": "unknown"})
);
}
}