mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-26 06:03:19 +00:00
Merge c7f19d0d22 into 120fa92e55
This commit is contained in:
commit
c468108fed
21 changed files with 1904 additions and 86 deletions
|
|
@ -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;`
|
||||
|
|
|
|||
11
litellm-rust/Cargo.lock
generated
11
litellm-rust/Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
20
litellm-rust/crates/integrations/AGENTS.md
Normal file
20
litellm-rust/crates/integrations/AGENTS.md
Normal 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`
|
||||
14
litellm-rust/crates/integrations/Cargo.toml
Normal file
14
litellm-rust/crates/integrations/Cargo.toml
Normal 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
|
||||
1064
litellm-rust/crates/integrations/src/anthropic_cache_control_hook.rs
Normal file
1064
litellm-rust/crates/integrations/src/anthropic_cache_control_hook.rs
Normal file
File diff suppressed because it is too large
Load diff
3
litellm-rust/crates/integrations/src/lib.rs
Normal file
3
litellm-rust/crates/integrations/src/lib.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod anthropic_cache_control_hook;
|
||||
|
||||
pub use anthropic_cache_control_hook::{Injected, InjectionInputs, maybe_inject_cache_control};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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"))]
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
);
|
||||
}
|
||||
}
|
||||
3
litellm-rust/crates/llms-types/src/integrations/mod.rs
Normal file
3
litellm-rust/crates/llms-types/src/integrations/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
mod anthropic_cache_control_hook;
|
||||
|
||||
pub use anthropic_cache_control_hook::*;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
74
litellm-rust/crates/llms-types/src/message_role.rs
Normal file
74
litellm-rust/crates/llms-types/src/message_role.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 } => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue