This commit is contained in:
yujonglee 2026-10-19 14:32:32 +00:00 • committed by GitHub
commit c468108fed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1904 additions and 86 deletions

View file

@ -8,6 +8,13 @@ For fieldless enums, derive `strum::VariantArray` and use `VARIANTS` instead of
Use the derived conversions directly (`<&'static str>::from(x)` / `.into()`, `str::parse`) with no `as_str`/`parse` wrapper that only delegates, and spell each variant with explicit `#[strum(serialize = "...")]` instead of `serialize_all`
## Python ports
- Prefer mirroring the outer Python structure. Map `litellm/<path>.py` to `crates/<crate>/src/<path>.rs` in the crate that mirrors that folder
- Keep class and public function names, with Rust acronym casing and no leading underscore for private helpers. A staticmethod-only class becomes free functions. A class with a Python base becomes a struct implementing the matching trait
- Inside functions, design freely. Prefer typed values over dict inspection, new values over mutation, and tagged unions over string modes. Pin behavior with Rust tests
- List skipped behavior under `Known partial ports` in the nearest AGENTS.md and test deliberate divergences
## Test placement
- Never create a `tests.rs` (or `test.rs`) file under `src/`, and never `#[path = "tests.rs"] mod tests;`

View file

@ -4141,6 +4141,16 @@ dependencies = [
"wiremock",
]
[[package]]
name = "litellm-integrations"
version = "0.1.0"
dependencies = [
"litellm-llms-types",
"rstest",
"serde",
"serde_json",
]
[[package]]
name = "litellm-llms"
version = "0.1.0"
@ -4188,6 +4198,7 @@ dependencies = [
"serde_json",
"serde_with",
"strum",
"unicode-properties",
]
[[package]]

View file

@ -49,6 +49,7 @@ litellm-secrets-cyberark = { path = "crates/secrets-cyberark" }
litellm-http = { path = "crates/http" }
litellm-llms = { path = "crates/llms" }
litellm-llms-types = { path = "crates/llms-types" }
litellm-integrations = { path = "crates/integrations" }
litellm-core-utils = { path = "crates/core-utils" }
litellm-db = { path = "crates/db" }
litellm-db-testing = { path = "crates/db-testing" }

View file

@ -0,0 +1,20 @@
# rules
- Mirrors `litellm/integrations/` and owns deterministic integration behavior, independent of provider I/O and host callbacks
- Shared request contracts belong in `litellm-llms-types`; runtime configuration and provider capability checks are inputs from callers
- Function names follow the Python hook's static methods so a drift report diff maps onto them
- Count breakpoints only where the provider reads them: system blocks, message-level marks, content blocks, tools (top level or under `function`) and the top-level `cache_control`. Explicit nulls are not marks
- Each distinct message target is resolved once against a role index, so cost stays linear in points plus messages
- Use inline named `rstest` cases for behavior
# Known partial ports
- `tool_config` points reserve one slot of the four-breakpoint cap and are returned for downstream handling while a slot remains. No native Messages provider builds Bedrock Converse `toolConfig`
- OpenAI prompt-cache dialects (`prompt_cache_breakpoint`, `_litellm_openai_dialect`) and Chat-only behavior (`tool_calls` marks, server tool replay) are not ported
- `extra_body` is not part of the typed Messages request projection and is not included in the census
- Python integer strings use the default digit limit. Host overrides of that limit are not yet projected
# references
- `litellm/integrations/anthropic_cache_control_hook.py`
- `litellm-rust/crates/llms-types/src/integrations/anthropic_cache_control_hook.rs`

View file

@ -0,0 +1,14 @@
[package]
name = "litellm-integrations"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
litellm-llms-types.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
rstest.workspace = true

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,3 @@
pub mod anthropic_cache_control_hook;
pub use anthropic_cache_control_hook::{Injected, InjectionInputs, maybe_inject_cache_control};

View file

@ -16,6 +16,7 @@ serde.workspace = true
serde_json.workspace = true
serde_with.workspace = true
strum.workspace = true
unicode-properties = { version = "0.1.4", default-features = false, features = ["general-category"] }
[dev-dependencies]
rstest.workspace = true

View file

@ -161,6 +161,7 @@ mod tests {
messages::ContentSource,
};
use crate::formats::messages::CacheControlType;
use rstest::rstest;
use serde_json::{Value, json};
@ -208,8 +209,8 @@ mod tests {
};
assert_eq!(text, "hello");
assert_eq!(
cache_control.as_ref().unwrap().cache_type.as_deref(),
Some("ephemeral")
cache_control.as_ref().unwrap().cache_type,
Some(Some(CacheControlType::Ephemeral))
);
assert_eq!(breakpoint.mode, PromptCacheMode::Explicit);
assert!(breakpoint.extra.is_empty());

View file

@ -152,48 +152,6 @@ pub struct Safeguard {
pub extra: Map<String, Value>,
}
#[derive(
Clone,
Debug,
PartialEq,
Eq,
strum::Display,
strum::EnumString,
strum::AsRefStr,
serde_with::DeserializeFromStr,
serde_with::SerializeDisplay,
)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schema", schemars(from = "String", into = "String"))]
pub enum MessageRole {
#[strum(serialize = "user")]
User,
#[strum(serialize = "assistant")]
Assistant,
#[strum(serialize = "system")]
System,
#[strum(default, transparent)]
Other(String),
}
impl MessageRole {
pub fn as_str(&self) -> &str {
self.as_ref()
}
}
impl From<String> for MessageRole {
fn from(value: String) -> Self {
value.parse().unwrap_or_else(|never| match never {})
}
}
impl From<MessageRole> for String {
fn from(value: MessageRole) -> Self {
value.to_string()
}
}
#[derive(
Clone,
Debug,
@ -350,9 +308,7 @@ mod tests {
use crate::formats::messages::McpServer;
use crate::formats::messages::{
MessageRole, MessageType, MessagesCompaction, MessagesContainer,
};
use crate::formats::messages::{MessageType, MessagesCompaction, MessagesContainer};
use crate::formats::messages::{
MessagesDiagnostics, MessagesDiagnosticsParam, MessagesMetadata,
@ -513,15 +469,6 @@ mod tests {
assert!(serde_json::from_value::<OutputFormat>(json!({"type":"json_schema"})).is_err());
}
#[rstest]
#[case::user(json!("user"))]
#[case::assistant(json!("assistant"))]
#[case::system(json!("system"))]
#[case::future(json!("future_role"))]
fn message_roles_round_trip(#[case] wire: Value) {
round_trip::<MessageRole>(wire);
}
#[rstest]
#[case::message(json!("message"))]
#[case::future(json!("future_type"))]

View file

@ -113,16 +113,86 @@ impl ContentBlock {
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Default)]
pub struct CacheControl {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub cache_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ttl: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(
rename = "type",
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
#[cfg_attr(feature = "schema", schemars(with = "Option<CacheControlType>"))]
pub cache_type: Option<Option<CacheControlType>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
#[cfg_attr(feature = "schema", schemars(with = "Option<CacheControlTtl>"))]
pub ttl: Option<Option<CacheControlTtl>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
#[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
pub scope: Option<Option<String>>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(
Clone,
Debug,
PartialEq,
Eq,
Hash,
strum::Display,
strum::EnumString,
serde_with::DeserializeFromStr,
serde_with::SerializeDisplay,
)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schema", schemars(from = "String", into = "String"))]
pub enum CacheControlType {
#[strum(serialize = "ephemeral")]
Ephemeral,
#[strum(default, transparent)]
Other(String),
}
impl From<String> for CacheControlType {
fn from(value: String) -> Self {
value.parse().unwrap_or_else(|never| match never {})
}
}
#[derive(
Clone,
Debug,
PartialEq,
Eq,
Hash,
strum::Display,
strum::EnumString,
serde_with::DeserializeFromStr,
serde_with::SerializeDisplay,
)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schema", schemars(from = "String", into = "String"))]
pub enum CacheControlTtl {
#[strum(serialize = "5m")]
FiveMinutes,
#[strum(serialize = "1h")]
OneHour,
#[strum(default, transparent)]
Other(String),
}
impl From<String> for CacheControlTtl {
fn from(value: String) -> Self {
value.parse().unwrap_or_else(|never| match never {})
}
}
#[macro_rules_attribute::apply(crate::wire_type)]
pub struct Message {
pub role: String,
@ -399,11 +469,94 @@ mod tests {
use serde_json::{Map, Value};
#[rstest]
#[case::known(
json!({"type": "ephemeral", "ttl": "1h"}),
Some(CacheControlType::Ephemeral),
Some(CacheControlTtl::OneHour)
)]
#[case::five_minutes(
json!({"type": "ephemeral", "ttl": "5m"}),
Some(CacheControlType::Ephemeral),
Some(CacheControlTtl::FiveMinutes)
)]
#[case::unknown_values_pass_through(
json!({"type": "persistent", "ttl": "future"}),
Some(CacheControlType::Other("persistent".into())),
Some(CacheControlTtl::Other("future".into()))
)]
#[case::variant_name_is_not_a_wire_value(
json!({"ttl": "FiveMinutes"}),
None,
Some(CacheControlTtl::Other("FiveMinutes".into()))
)]
#[case::empty_strings(json!({"type": "", "ttl": ""}), Some(CacheControlType::Other("".into())), Some(CacheControlTtl::Other("".into())))]
#[case::absent(json!({}), None, None)]
fn cache_control_types_known_values_and_keeps_unknown_ones(
#[case] input: Value,
#[case] cache_type: Option<CacheControlType>,
#[case] ttl: Option<CacheControlTtl>,
) {
let control: CacheControl = serde_json::from_value(input.clone()).unwrap();
assert_eq!(
(control.cache_type.clone(), control.ttl.clone()),
(cache_type.map(Some), ttl.map(Some))
);
assert_eq!(serde_json::to_value(control).unwrap(), input);
}
#[rstest]
#[case::null_type(json!({"type": null}))]
#[case::null_ttl(json!({"type": "ephemeral", "ttl": null}))]
#[case::null_scope(json!({"scope": null}))]
#[case::all_null(json!({"type": null, "ttl": null, "scope": null}))]
fn cache_control_preserves_explicit_null_fields(#[case] input: Value) {
let control: CacheControl = serde_json::from_value(input.clone()).unwrap();
assert_eq!(serde_json::to_value(control).unwrap(), input);
}
#[rstest]
#[case::number_type(json!({"type": 42}))]
#[case::object_type(json!({"type": {"nested": true}}))]
#[case::boolean_type(json!({"type": true}))]
#[case::number_ttl(json!({"ttl": 300}))]
#[case::object_ttl(json!({"ttl": {"nested": true}}))]
#[case::boolean_ttl(json!({"ttl": false}))]
fn cache_control_rejects_non_string_type_and_ttl(#[case] input: Value) {
assert!(serde_json::from_value::<CacheControl>(input).is_err());
}
fn round_trip<T: serde::de::DeserializeOwned + serde::Serialize>(value: &Value) -> Value {
let parsed: T = serde_json::from_value(value.clone()).unwrap();
serde_json::to_value(parsed).unwrap()
}
#[rstest]
fn malformed_cache_control_strings_are_rejected(
#[values("type", "ttl")] field: &str,
#[values(json!(42), json!(true), json!([]), json!({"seconds": 300}))] value: Value,
) {
let request = json!({
"model": "claude-test", "max_tokens": 16,
"messages": [{"role": "user", "content": [{
"type": "text", "text": "prompt", "cache_control": {field: value}
}]}]
});
assert!(serde_json::from_value::<MessagesRequest>(request).is_err());
}
#[cfg(feature = "schema")]
#[rstest]
#[case::cache_type(schemars::schema_for!(CacheControlType).to_value())]
#[case::ttl(schemars::schema_for!(CacheControlTtl).to_value())]
fn cache_control_enum_schema_accepts_unknown_strings(#[case] schema: Value) {
assert_eq!(schema.get("type"), Some(&json!("string")));
assert!(schema.get("enum").is_none());
}
#[rstest]
#[case::text(json!({"type": "text", "text": "hi"}))]
#[case::text_with_citations_and_cache_control(json!({

View file

@ -568,6 +568,7 @@ mod tests {
use crate::formats::messages::{
AdvisorTool, AdvisorToolName, CacheControl, UrlSourceToolReference,
};
use crate::formats::messages::{CacheControlTtl, CacheControlType};
use serde_json::Map;
use crate::formats::messages::AllowedCaller;
@ -819,7 +820,7 @@ mod tests {
#[case::text_editor_20250429(
json!({"type":"text_editor_20250429","name":"str_replace_based_edit_tool","cache_control":{"type":"ephemeral","ttl":"1h"}}),
BuiltinMessagesTool::TextEditor20250429(ClientTool {
cache_control: Some(cache_control("ephemeral", "1h")),
cache_control: Some(cache_control(CacheControlType::Ephemeral, CacheControlTtl::OneHour)),
..client_tool(StrReplaceBasedEditToolName::StrReplaceBasedEditTool)
})
)]
@ -1045,7 +1046,7 @@ mod tests {
model: String::from("claude-opus-5-5"),
allowed_callers: None,
cache_control: None,
caching: Some(cache_control("ephemeral", "5m")),
caching: Some(cache_control(CacheControlType::Ephemeral, CacheControlTtl::FiveMinutes)),
defer_loading: None,
max_tokens: Some(2048),
max_uses: None,
@ -1223,10 +1224,10 @@ mod tests {
}
}
fn cache_control(cache_type: &str, ttl: &str) -> CacheControl {
fn cache_control(cache_type: CacheControlType, ttl: CacheControlTtl) -> CacheControl {
CacheControl {
cache_type: Some(String::from(cache_type)),
ttl: Some(String::from(ttl)),
cache_type: Some(Some(cache_type)),
ttl: Some(Some(ttl)),
..CacheControl::default()
}
}

View file

@ -0,0 +1,220 @@
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value};
use crate::{
formats::messages::CacheControl,
message_role::MessageRole,
recognized::Recognized,
serde_compat::{PYTHON_DEFAULT_INT_MAX_STR_DIGITS, ParsedInteger, parse_python_integer},
};
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(tag = "location", rename = "message")]
pub struct CacheControlMessageInjectionPoint {
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
control: Option<Option<CacheControl>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
index: Option<Option<CacheControlIndex>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
role: Option<Option<Recognized<MessageRole>>>,
#[serde(flatten)]
extra: Map<String, Value>,
}
impl CacheControlMessageInjectionPoint {
pub fn new(
index: Option<i64>,
role: Option<MessageRole>,
control: Option<CacheControl>,
) -> Self {
Self {
control: control.map(Some),
index: index.map(|index| Some(CacheControlIndex::Number(index.into()))),
role: role.map(|role| Some(Recognized::Known(role))),
extra: Map::new(),
}
}
pub fn parsed_index(&self) -> ParsedInteger {
match self.index.as_ref().and_then(Option::as_ref) {
Some(CacheControlIndex::Number(number)) => number
.as_i64()
.map(ParsedInteger::Value)
.unwrap_or(ParsedInteger::OutOfRange),
Some(CacheControlIndex::String(index)) => {
parse_python_integer(index, PYTHON_DEFAULT_INT_MAX_STR_DIGITS)
}
Some(CacheControlIndex::Boolean(value)) => ParsedInteger::Value(i64::from(*value)),
Some(CacheControlIndex::Other(_)) | None => ParsedInteger::Invalid,
}
}
pub fn role(&self) -> Option<&MessageRole> {
self.role
.as_ref()
.and_then(Option::as_ref)
.and_then(Recognized::known)
}
pub fn control(&self) -> Option<&CacheControl> {
self.control.as_ref().and_then(Option::as_ref)
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(untagged)]
enum CacheControlIndex {
Number(serde_json::Number),
String(String),
Boolean(bool),
Other(Value),
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(tag = "location", rename = "tool_config")]
pub struct CacheControlToolConfigInjectionPoint {
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
control: Option<Option<CacheControl>>,
#[serde(flatten)]
extra: Map<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(untagged)]
pub enum CacheControlInjectionPoint {
Message(CacheControlMessageInjectionPoint),
ToolConfig(CacheControlToolConfigInjectionPoint),
Unknown(Value),
}
impl<'de> Deserialize<'de> for CacheControlInjectionPoint {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
match value.get("location").and_then(Value::as_str) {
Some("message") => serde_json::from_value(value).map(Self::Message),
Some("tool_config") => serde_json::from_value(value).map(Self::ToolConfig),
_ => return Ok(Self::Unknown(value)),
}
.map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use serde_json::{Value, json};
use super::{CacheControlInjectionPoint, CacheControlMessageInjectionPoint};
use crate::message_role::MessageRole;
use crate::serde_compat::ParsedInteger;
#[rstest]
#[case::negative_index_string(json!({"location": "message", "index": "-1"}), ParsedInteger::Value(-1))]
#[case::padded_index_string(json!({"location": "message", "index": " \t0\n"}), ParsedInteger::Value(0))]
#[case::out_of_range_index(json!({"location": "message", "index": 99}), ParsedInteger::Value(99))]
#[case::oversized_string(json!({"location": "message", "index": "9223372036854775808", "role": "user"}), ParsedInteger::OutOfRange)]
#[case::oversized_number(json!({"location": "message", "index": u64::MAX, "role": "user"}), ParsedInteger::OutOfRange)]
#[case::underscores(json!({"location": "message", "index": "0_0"}), ParsedInteger::Value(0))]
#[case::unicode_digits(json!({"location": "message", "index": "٠"}), ParsedInteger::Value(0))]
#[case::signed_and_padded(json!({"location": "message", "index": " +0_1 "}), ParsedInteger::Value(1))]
#[case::negative_limit(json!({"location": "message", "index": "-9223372036854775808"}), ParsedInteger::Value(i64::MIN))]
#[case::negative_overflow(json!({"location": "message", "index": "-9223372036854775809", "role": "user"}), ParsedInteger::OutOfRange)]
#[case::boolean_index(json!({"location": "message", "index": true}), ParsedInteger::Value(1))]
#[case::mixed_digit_scripts(json!({"location": "message", "index": "٠_1"}), ParsedInteger::Value(1))]
#[case::mathematical_digit(json!({"location": "message", "index": "𝟙"}), ParsedInteger::Value(1))]
#[case::double_underscore(json!({"location": "message", "index": "0__1", "role": "user"}), ParsedInteger::Invalid)]
#[case::leading_underscore(json!({"location": "message", "index": "_1", "role": "user"}), ParsedInteger::Invalid)]
#[case::trailing_underscore(json!({"location": "message", "index": "1_", "role": "user"}), ParsedInteger::Invalid)]
#[case::sign_without_digits(json!({"location": "message", "index": "+", "role": "user"}), ParsedInteger::Invalid)]
#[case::embedded_sign(json!({"location": "message", "index": "1+0", "role": "user"}), ParsedInteger::Invalid)]
#[case::non_decimal_digit(json!({"location": "message", "index": "²", "role": "user"}), ParsedInteger::Invalid)]
#[case::role_only(json!({"location": "message", "role": "user"}), ParsedInteger::Invalid)]
#[case::invalid_index_falls_back_to_role(
json!({"location": "message", "index": "latest", "role": "assistant"}),
ParsedInteger::Invalid
)]
#[case::index_precedes_system_role(
json!({"location": "message", "index": 0, "role": "system"}),
ParsedInteger::Value(0)
)]
#[case::missing_target(json!({"location": "message"}), ParsedInteger::Invalid)]
fn message_index_deserialization(#[case] input: Value, #[case] expected: ParsedInteger) {
let point: CacheControlInjectionPoint = serde_json::from_value(input).unwrap();
assert!(matches!(
point,
CacheControlInjectionPoint::Message(message) if message.parsed_index() == expected
));
}
#[rstest]
#[case::missing_control(json!({"location": "message", "index": 0}))]
#[case::message_null_control(json!({"location": "message", "index": 0, "control": null}))]
#[case::tool_config_without_control(json!({"location": "tool_config"}))]
#[case::tool_config_null_control(json!({"location": "tool_config", "control": null}))]
fn known_points_preserve_optional_control(#[case] input: Value) {
let point: CacheControlInjectionPoint = serde_json::from_value(input.clone()).unwrap();
assert_eq!(serde_json::to_value(point).unwrap(), input);
}
#[rstest]
#[case::message(json!({"location": "message", "index": 0, "control": "ephemeral"}))]
#[case::tool_config(json!({"location": "tool_config", "control": 5}))]
fn malformed_controls_are_rejected(#[case] input: Value) {
assert!(serde_json::from_value::<CacheControlInjectionPoint>(input).is_err());
}
#[rstest]
fn unknown_location_round_trips_unchanged() {
let input = json!({"location": "future", "index": 7, "custom": {"value": true}});
let point: CacheControlInjectionPoint = serde_json::from_value(input.clone()).unwrap();
assert!(matches!(&point, CacheControlInjectionPoint::Unknown(_)));
assert_eq!(serde_json::to_value(point).unwrap(), input);
}
#[rstest]
#[case::role_preserved_when_index_wins(json!({"location": "message", "index": 0, "role": "assistant"}))]
#[case::unparsed_index_preserved_when_role_wins(json!({"location": "message", "index": "latest", "role": "assistant"}))]
#[case::message(json!({"location": "message", "index": " 0 ", "role": "user", "control": null, "custom": "keep"}))]
#[case::tool_config(json!({"location": "tool_config", "control": null, "custom": "keep"}))]
#[case::null_control_fields(json!({"location": "message", "index": null, "role": null, "control": {"type": null, "ttl": null, "scope": null}}))]
#[case::unknown_target_types(json!({"location": "message", "index": [1], "role": {"custom": true}}))]
#[case::unicode_index(json!({"location": "message", "index": " +٠_1 ", "role": "assistant"}))]
fn known_points_preserve_wire_fields(#[case] input: Value) {
let point: CacheControlInjectionPoint = serde_json::from_value(input.clone()).unwrap();
assert_eq!(serde_json::to_value(point).unwrap(), input);
}
#[rstest]
fn constructed_points_preserve_both_index_and_role() {
let point =
CacheControlMessageInjectionPoint::new(Some(0), Some(MessageRole::System), None);
assert_eq!(
serde_json::to_value(point).unwrap(),
json!({"location": "message", "index": 0, "role": "system"})
);
}
}

View file

@ -0,0 +1,3 @@
mod anthropic_cache_control_hook;
pub use anthropic_cache_control_hook::*;

View file

@ -6,7 +6,9 @@ macro_rules_attribute::attribute_alias! {
pub mod formats;
pub mod headers;
pub mod integrations;
pub mod json_schema;
pub mod message_role;
pub mod providers;
pub mod recognized;
pub mod serde_compat;

View file

@ -0,0 +1,74 @@
#[derive(
Clone,
Debug,
PartialEq,
Eq,
strum::Display,
strum::EnumString,
strum::AsRefStr,
serde_with::DeserializeFromStr,
serde_with::SerializeDisplay,
)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schema", schemars(from = "String", into = "String"))]
pub enum MessageRole {
#[strum(serialize = "user")]
User,
#[strum(serialize = "assistant")]
Assistant,
#[strum(serialize = "system")]
System,
#[strum(default, transparent)]
Other(String),
}
impl From<String> for MessageRole {
fn from(value: String) -> Self {
value.parse().unwrap_or_else(|never| match never {})
}
}
impl From<MessageRole> for String {
fn from(value: MessageRole) -> Self {
value.to_string()
}
}
#[cfg(test)]
mod tests {
use super::MessageRole;
use rstest::rstest;
use serde_json::{Value, json};
#[rstest]
#[case::user("user", MessageRole::User)]
#[case::assistant("assistant", MessageRole::Assistant)]
#[case::system("system", MessageRole::System)]
#[case::unknown("future_role", MessageRole::Other("future_role".into()))]
#[case::case_sensitive("User", MessageRole::Other("User".into()))]
#[case::empty("", MessageRole::Other(String::new()))]
fn roles_preserve_wire_values(#[case] wire: &str, #[case] expected: MessageRole) {
let role: MessageRole = serde_json::from_value(json!(wire)).unwrap();
assert_eq!(role, expected);
assert_eq!(wire.parse::<MessageRole>().unwrap(), role);
assert_eq!(role.as_ref(), wire);
assert_eq!(serde_json::to_value(role).unwrap(), json!(wire));
}
#[rstest]
fn malformed_roles_are_rejected(
#[values(json!(7), json!(true), json!([]), json!({}), Value::Null)] wire: Value,
) {
assert!(serde_json::from_value::<MessageRole>(wire).is_err());
}
#[cfg(feature = "schema")]
#[rstest]
fn role_schema_accepts_unknown_strings() {
let schema = schemars::schema_for!(MessageRole).to_value();
assert_eq!(schema.get("type"), Some(&json!("string")));
assert!(schema.get("enum").is_none());
}
}

View file

@ -8,6 +8,7 @@ pub const BETA_HEADER: &str = "anthropic-beta";
pub const VERSION_HEADER: &str = "anthropic-version";
pub const DIRECT_BROWSER_ACCESS_HEADER: &str = "anthropic-dangerous-direct-browser-access";
pub const API_VERSION: &str = "2023-06-01";
pub const BILLING_HEADER_PREFIX: &str = "x-anthropic-billing-header:";
pub const DEFAULT_HEADERS: &[(&str, &str)] = &[
(VERSION_HEADER, API_VERSION),

View file

@ -64,6 +64,7 @@ mod tests {
MinimaxMediaDetail, MinimaxMediaSource, MinimaxMessagesContentBlock,
};
use crate::formats::messages::CacheControlType;
use rstest::rstest;
use serde_json::{Value, json};
@ -97,8 +98,8 @@ mod tests {
assert_eq!(options.max_long_side_pixel, Some(1024));
assert_eq!(options.extra["future"], Value::Null);
assert_eq!(
video.cache_control.as_ref().unwrap().cache_type.as_deref(),
Some("ephemeral")
video.cache_control.as_ref().unwrap().cache_type,
Some(Some(CacheControlType::Ephemeral))
);
}
MinimaxMessagesContentBlock::MidConvSystem { text, extra } => {

View file

@ -3,10 +3,66 @@ use serde::{
de::{Error, Visitor},
};
use serde_with::DeserializeAs;
use unicode_properties::{GeneralCategory, UnicodeGeneralCategory};
pub struct LaxI64;
pub struct FiniteF64;
pub const PYTHON_DEFAULT_INT_MAX_STR_DIGITS: usize = 4_300;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ParsedInteger {
Value(i64),
OutOfRange,
Invalid,
}
pub fn parse_python_integer(value: &str, max_digits: usize) -> ParsedInteger {
let Some(normalized) = value
.trim()
.chars()
.map(|character| match character {
'+' | '-' | '_' => Some(character),
_ => decimal_digit(character),
})
.collect::<Option<String>>()
else {
return ParsedInteger::Invalid;
};
let digits = normalized.strip_prefix(['+', '-']).unwrap_or(&normalized);
if !digits
.split('_')
.all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
|| (max_digits > 0 && digits.bytes().filter(u8::is_ascii_digit).count() > max_digits)
{
return ParsedInteger::Invalid;
}
normalized
.replace('_', "")
.parse::<i64>()
.map(ParsedInteger::Value)
.unwrap_or(ParsedInteger::OutOfRange)
}
fn decimal_digit(character: char) -> Option<char> {
if character.is_ascii_digit() {
return Some(character);
}
if character.general_category() != GeneralCategory::DecimalNumber {
return None;
}
// Unicode decimal digits form consecutive groups of ten
let preceding_digits = (0..u32::from(character))
.rev()
.take_while(|code| {
char::from_u32(*code).is_some_and(|character| {
character.general_category() == GeneralCategory::DecimalNumber
})
})
.count();
char::from_digit(u32::try_from(preceding_digits % 10).ok()?, 10)
}
impl<'de> DeserializeAs<'de, i64> for LaxI64 {
fn deserialize_as<D: Deserializer<'de>>(deserializer: D) -> Result<i64, D::Error> {
deserializer.deserialize_any(Self)
@ -89,19 +145,13 @@ fn integer_string(value: &str) -> Option<i64> {
}
None => value,
};
if integer.starts_with('_') || integer.ends_with('_') || integer.contains("__") {
if !integer.is_ascii() || integer.trim() != integer {
return None;
}
let digits = integer.strip_prefix(['+', '-']).unwrap_or(integer);
if digits.is_empty()
|| digits.starts_with('_')
|| !digits
.bytes()
.all(|byte| byte.is_ascii_digit() || byte == b'_')
{
return None;
match parse_python_integer(integer, 0) {
ParsedInteger::Value(value) => Some(value),
ParsedInteger::OutOfRange | ParsedInteger::Invalid => None,
}
integer.replace('_', "").parse().ok()
}
fn integral_float(value: f64) -> Option<i64> {
@ -114,7 +164,9 @@ fn integral_float(value: f64) -> Option<i64> {
#[cfg(test)]
mod tests {
use crate::serde_compat::{FiniteF64, LaxI64};
use super::{
FiniteF64, LaxI64, PYTHON_DEFAULT_INT_MAX_STR_DIGITS, ParsedInteger, parse_python_integer,
};
use rstest::rstest;
use serde::{Deserialize, Serialize};
@ -174,6 +226,8 @@ mod tests {
#[case::exponent(json!("1e3"))]
#[case::missing_fraction(json!("2."))]
#[case::missing_integer(json!(".0"))]
#[case::space_before_fraction(json!("2 .0"))]
#[case::unicode_digit(json!("١"))]
#[case::leading_separator(json!("_2"))]
#[case::repeated_separator(json!("2__0"))]
#[case::fraction(json!(2.5))]
@ -201,4 +255,41 @@ mod tests {
let numbers: Numbers = serde_json::from_value(json!({"float": input})).unwrap();
assert_eq!(numbers.float, Some(expected));
}
#[rstest]
#[case::padded(" \t+0_1\n", ParsedInteger::Value(1))]
#[case::negative("-1", ParsedInteger::Value(-1))]
#[case::mixed_scripts("٠_1", ParsedInteger::Value(1))]
#[case::mathematical_digit("𝟙", ParsedInteger::Value(1))]
#[case::minimum("-9223372036854775808", ParsedInteger::Value(i64::MIN))]
#[case::positive_overflow("9223372036854775808", ParsedInteger::OutOfRange)]
#[case::negative_overflow("-9223372036854775809", ParsedInteger::OutOfRange)]
#[case::empty("", ParsedInteger::Invalid)]
#[case::sign_only("+", ParsedInteger::Invalid)]
#[case::leading_separator("_1", ParsedInteger::Invalid)]
#[case::trailing_separator("1_", ParsedInteger::Invalid)]
#[case::repeated_separator("1__0", ParsedInteger::Invalid)]
#[case::embedded_sign("1+0", ParsedInteger::Invalid)]
#[case::superscript("²", ParsedInteger::Invalid)]
#[case::fraction("1.0", ParsedInteger::Invalid)]
fn python_integer_strings(#[case] input: &str, #[case] expected: ParsedInteger) {
assert_eq!(
parse_python_integer(input, PYTHON_DEFAULT_INT_MAX_STR_DIGITS),
expected
);
}
#[rstest]
#[case::at_limit("0".repeat(PYTHON_DEFAULT_INT_MAX_STR_DIGITS), PYTHON_DEFAULT_INT_MAX_STR_DIGITS, ParsedInteger::Value(0))]
#[case::over_limit("0".repeat(PYTHON_DEFAULT_INT_MAX_STR_DIGITS + 1), PYTHON_DEFAULT_INT_MAX_STR_DIGITS, ParsedInteger::Invalid)]
#[case::custom_limit("0".repeat(4), 3, ParsedInteger::Invalid)]
#[case::unlimited("0".repeat(PYTHON_DEFAULT_INT_MAX_STR_DIGITS + 1), 0, ParsedInteger::Value(0))]
#[case::sign_and_separators(" +٠_0 ".into(), 2, ParsedInteger::Value(0))]
fn python_integer_digit_limits(
#[case] input: String,
#[case] max_digits: usize,
#[case] expected: ParsedInteger,
) {
assert_eq!(parse_python_integer(&input, max_digits), expected);
}
}

View file

@ -41,7 +41,7 @@
## Python pairs
- Python paths identify counterparts but do not dictate Rust module names or class hierarchy. Preserve behavior and concepts, not structure
- Prefer mirroring Python's outer module and class structure, following the workspace's Python porting guideline. Preserve behavior while designing function internals freely
- Keep operation and parameter names when responsibilities match. Rust types keep the Python name with Rust acronym casing (`BaseOCRConfig` -> `BaseOcrConfig`). Private Python helpers drop the leading underscore
- Give Rust-only helpers distinct responsibility names instead of reusing trait method names
- Use trait defaults for unchanged inherited behavior and explicit delegation for shared provider behavior. Config traits represent real provider contracts, so do not recreate inheritance with extra traits

View file

@ -6,11 +6,11 @@ use litellm_http::request::{
};
use litellm_llms_types::{
formats::messages::{
ContentBlock, ContentBlockType, EffortLevel, Message, MessageContent, MessagesTool,
SystemPrompt,
ContentBlock, ContentBlockType, EffortLevel, Message, MessageContent, MessagesRequest,
MessagesTool, SystemPrompt,
},
providers::anthropic::{
API_BASE, API_KEY_HEADER, AnthropicBeta, BETA_HEADER, BetaSet,
API_BASE, API_KEY_HEADER, AnthropicBeta, BETA_HEADER, BILLING_HEADER_PREFIX, BetaSet,
DIRECT_BROWSER_ACCESS_HEADER, MESSAGES_PATH,
},
recognized::Recognized,
@ -18,6 +18,94 @@ use litellm_llms_types::{
use serde::Deserialize;
use serde_json::Value;
pub fn supports_anthropic_cache_control(
model: &str,
provider: &str,
supports_prompt_caching: bool,
) -> bool {
supports_prompt_caching
&& model.to_ascii_lowercase().contains("claude")
&& matches!(provider, "anthropic" | "bedrock" | "vertex_ai" | "azure_ai")
}
pub fn is_claude_code_one_shot_subagent_request(
request: &MessagesRequest,
user_agent: Option<&str>,
) -> bool {
let Some(user_agent) = user_agent else {
return false;
};
if (!user_agent.starts_with("claude-cli/") && !user_agent.starts_with("claude-code/"))
|| request.messages.len() != 1
|| request
.params
.tools
.as_ref()
.is_some_and(|tools| !tools.is_empty())
{
return false;
}
request.messages[0].role == "user"
&& request
.params
.system
.as_ref()
.is_some_and(is_claude_code_subagent_billing_system)
}
fn is_claude_code_subagent_billing_system(system: &SystemPrompt) -> bool {
let texts: Option<Vec<&str>> = match system {
SystemPrompt::Text(text) => Some(vec![text.as_str()]),
SystemPrompt::Blocks(blocks) => blocks
.iter()
.map(|block| {
block
.is_type(ContentBlockType::Text)
.then_some(block.text.as_deref())
.flatten()
})
.collect(),
};
let meaningful: Vec<&str> = texts
.into_iter()
.flatten()
.filter(|text| !text.trim().is_empty())
.collect();
if meaningful.is_empty() {
return false;
}
let fields: Option<Vec<Vec<(&str, &str)>>> = meaningful
.into_iter()
.map(claude_code_billing_fields)
.collect();
let subagent_values: Vec<&str> = fields
.into_iter()
.flatten()
.flatten()
.filter_map(|(key, value)| (key == "cc_is_subagent").then_some(value))
.collect();
subagent_values == ["true"]
}
fn claude_code_billing_fields(text: &str) -> Option<Vec<(&str, &str)>> {
let stripped = text.trim();
if stripped.contains(['\n', '\r']) {
return None;
}
let fields = stripped.strip_prefix(BILLING_HEADER_PREFIX)?;
let parsed: Option<Vec<(&str, &str)>> = fields
.split(';')
.filter(|field| !field.trim().is_empty())
.map(|field| {
let (key, value) = field.split_once('=')?;
let key = key.trim();
let value = value.trim();
(!key.is_empty() && !value.is_empty()).then_some((key, value))
})
.collect();
parsed.filter(|parsed| !parsed.is_empty())
}
use crate::{
anthropic::ANTHROPIC_OAUTH_TOKEN_PREFIX,
base_llm::auth::{AuthScheme, Headers},
@ -384,8 +472,6 @@ pub fn is_encrypted_reasoning_block(block: &ContentBlock) -> bool {
field.is_some_and(|value| value.starts_with(ENCRYPTED_REASONING_SIGNATURE_PREFIX))
}
const BILLING_HEADER_PREFIX: &str = "x-anthropic-billing-header:";
fn is_billing_header_block(block: &ContentBlock) -> bool {
block.block_type == Some(ContentBlockType::Text)
&& block
@ -1920,4 +2006,121 @@ mod tests {
expected
);
}
#[rstest]
#[case::anthropic("claude-sonnet", "anthropic", true, true)]
#[case::bedrock("claude-sonnet", "bedrock", true, true)]
#[case::vertex("claude-sonnet", "vertex_ai", true, true)]
#[case::azure("claude-sonnet", "azure_ai", true, true)]
#[case::unsupported_provider("claude-sonnet", "deepseek", true, false)]
#[case::unsupported_model("gpt-test", "anthropic", true, false)]
#[case::unsupported_capability("claude-sonnet", "anthropic", false, false)]
fn cache_control_support_requires_model_provider_and_capability(
#[case] model: &str,
#[case] provider: &str,
#[case] capability: bool,
#[case] expected: bool,
) {
assert_eq!(
supports_anthropic_cache_control(model, provider, capability),
expected
);
}
#[rstest]
#[case::claude_cli(
"claude-cli/1.0",
"x-anthropic-billing-header: cc_is_subagent=true",
None,
true
)]
#[case::claude_code(
"claude-code/1.0",
"x-anthropic-billing-header: cc_is_subagent=true",
None,
true
)]
#[case::user_agent_must_start_with_product(
"client claude-code/1.0",
"x-anthropic-billing-header: cc_is_subagent=true",
None,
false
)]
#[case::other_billing_field(
"claude-code/1.0",
"x-anthropic-billing-header: billing=true",
None,
false
)]
#[case::false_subagent_field(
"claude-code/1.0",
"x-anthropic-billing-header: cc_is_subagent=false",
None,
false
)]
#[case::billing_header_with_instructions(
"claude-code/1.0",
"x-anthropic-billing-header: cc_is_subagent=true\nYou are a helpful agent",
None,
false
)]
#[case::nonempty_tools(
"claude-code/1.0",
"x-anthropic-billing-header: cc_is_subagent=true",
Some(json!([{"name": "lookup", "input_schema": {"type": "object"}}])),
false
)]
fn one_shot_subagent_requires_claude_code_request_shape(
#[case] user_agent: &str,
#[case] system: &str,
#[case] tools: Option<Value>,
#[case] expected: bool,
) {
let mut request_value = json!({
"model": "claude-test",
"max_tokens": 16,
"system": system,
"messages": [{"role": "user", "content": "prompt"}]
});
if let Some(tools) = tools {
request_value["tools"] = tools;
}
let request: MessagesRequest = serde_json::from_value(request_value).unwrap();
assert_eq!(
is_claude_code_one_shot_subagent_request(&request, Some(user_agent)),
expected
);
}
#[rstest]
#[case::empty_text_blocks_are_ignored(json!([
{"type": "text", "text": ""},
{"type": "text", "text": "x-anthropic-billing-header: cc_is_subagent=true"}
]), true)]
#[case::billing_block_beside_instructions(json!([
{"type": "text", "text": "x-anthropic-billing-header: cc_is_subagent=true"},
{"type": "text", "text": "Answer the delegated question"}
]), false)]
#[case::non_text_block(json!([
{"type": "text", "text": "x-anthropic-billing-header: cc_is_subagent=true"},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "AA=="}}
]), false)]
fn one_shot_subagent_requires_every_system_text_to_be_a_billing_header(
#[case] system: Value,
#[case] expected: bool,
) {
let request: MessagesRequest = serde_json::from_value(json!({
"model": "claude-test",
"max_tokens": 16,
"system": system,
"messages": [{"role": "user", "content": "prompt"}]
}))
.unwrap();
assert_eq!(
is_claude_code_one_shot_subagent_request(&request, Some("claude-cli/1.0")),
expected
);
}
}