Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions protocols/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,25 @@ Request/response types for OpenAI- and Anthropic-compatible inference servers. B
- **Anthropic Messages** — fully owned (no upstream equivalent).
- **Embeddings**, **Images** — re-exported.

## Anthropic image sources and thinking display

`AnthropicImageSource` distinguishes the `Base64 { media_type, data }` and
`Url { url }` source variants. Both ordinary image blocks and image-bearing tool
results preserve their source when serialized. The serving layer owns URL policy,
fetching, and image validation; parsing a URL source does not fetch it.

`ThinkingConfig.display` preserves `ThinkingDisplay::Summarized`,
`ThinkingDisplay::Omitted`, and the beta `ThinkingDisplay::Updates`. Omission remains
unset. The serving layer must gate beta support, validate combinations with the
thinking mode, and apply the display policy. Parsing does not implement progress updates.
File sources are outside this subset. Non-null, non-boolean `tool_result.is_error` values are
rejected during deserialization.

Rust callers migrating from the previous image-source struct must construct an
enum variant and match on it instead of accessing `source_type`, `media_type`, or
`data` directly. Existing `ThinkingConfig` literals need `display: None` to retain
their previous behavior.

## Optional protocol schemas

The `protocol-schema` feature is disabled by default. When enabled, it supplies utoipa 5 `ToSchema`
Expand Down
153 changes: 144 additions & 9 deletions protocols/src/types/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,19 @@ pub struct ThinkingConfig {
/// Maximum tokens for internal reasoning. Only relevant when type is "enabled".
#[serde(skip_serializing_if = "Option::is_none")]
pub budget_tokens: Option<u32>,
/// Select readable thinking or empty thinking blocks on the response path.
/// The server owns display semantics and validates combinations with `type`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display: Option<ThinkingDisplay>,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ThinkingDisplay {
Summarized,
Omitted,
/// Beta progress-update display; serving capability is validated downstream.
Updates,
}

/// A single message in the conversation.
Expand Down Expand Up @@ -463,9 +476,16 @@ impl<'de> Deserialize<'de> for AnthropicContentBlock {
}
"tool_result" => {
let tool_use_id = take_required_string::<D::Error>(&mut value, "tool_use_id")?;
let content: Option<ToolResultContent> =
take_field(&mut value, "content").and_then(|v| serde_json::from_value(v).ok());
let is_error = value.get("is_error").and_then(|v| v.as_bool());
let content = take_field(&mut value, "content")
.map(serde_json::from_value::<Option<ToolResultContent>>)
.transpose()
.map_err(serde::de::Error::custom)?
.flatten();
let is_error = take_field(&mut value, "is_error")
.map(serde_json::from_value::<Option<bool>>)
.transpose()
.map_err(serde::de::Error::custom)?
.flatten();
let cache_control: Option<CacheControl> = take_field(&mut value, "cache_control")
.and_then(|v| serde_json::from_value(v).ok());
Ok(AnthropicContentBlock::ToolResult {
Expand Down Expand Up @@ -518,12 +538,127 @@ impl<'de> Deserialize<'de> for AnthropicContentBlock {
}

/// Image source for image content blocks.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicImageSource {
#[serde(rename = "type")]
pub source_type: String,
pub media_type: String,
pub data: String,
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum AnthropicImageSource {
Base64 { media_type: String, data: String },
Url { url: String },
}

#[cfg(test)]
mod source_and_display_tests {
use super::*;
use serde_json::json;

#[test]
fn image_sources_round_trip_in_messages_and_tool_results() {
for source in [
json!({"type": "base64", "media_type": "image/png", "data": "aGVsbG8="}),
json!({"type": "url", "url": "https://example.com/image.png"}),
] {
let image = json!({"type": "image", "source": source});
let block: AnthropicContentBlock = serde_json::from_value(image.clone()).unwrap();
assert_eq!(serde_json::to_value(block).unwrap(), image);
let result: ToolResultContentBlock = serde_json::from_value(image.clone()).unwrap();
assert_eq!(serde_json::to_value(result).unwrap(), image);
}
}

#[test]
fn image_source_discriminators_require_the_matching_fields() {
for source in [
json!({"type": "url"}),
json!({"type": "url", "url": null}),
json!({"type": "url", "url": 42}),
json!({"type": "url", "url": "https://example.com/image.png", "data": "x"}),
json!({"type": "base64", "media_type": "image/png"}),
json!({"type": "base64", "data": "aGVsbG8="}),
json!({"type": "base64", "media_type": "image/png", "data": "x", "url": "https://example.com/image.png"}),
json!({"type": "file", "file_id": "file_123"}),
json!({"url": "https://example.com/image.png"}),
] {
assert!(
serde_json::from_value::<AnthropicImageSource>(source.clone()).is_err(),
"{source}"
);
let image = json!({"type": "image", "source": source});
for block in [
image.clone(),
json!({"type": "tool_result", "tool_use_id": "toolu_image", "content": [image]}),
] {
let request = json!({"model": "test-model", "max_tokens": 64,
"messages": [{"role": "user", "content": [block]}]});
assert!(
serde_json::from_value::<AnthropicCreateMessageRequest>(request).is_err(),
"malformed nested image source must not be discarded: {source}"
);
}
}
}

#[test]
fn tool_result_content_keeps_absent_null_empty_and_text_semantics() {
for content in [
None,
Some(json!(null)),
Some(json!("")),
Some(json!([])),
Some(json!("result")),
] {
let mut block = json!({"type": "tool_result", "tool_use_id": "toolu_image"});
if let Some(content) = &content {
block["content"] = content.clone();
}
let parsed: AnthropicContentBlock = serde_json::from_value(block).unwrap();
let value = serde_json::to_value(parsed).unwrap();
match content {
None | Some(serde_json::Value::Null) => assert!(value.get("content").is_none()),
Some(content) => assert_eq!(value["content"], content),
}
}
}

#[test]
fn tool_error_status_requires_a_boolean() {
for is_error in [json!("true"), json!(1), json!({}), json!([])] {
let request = json!({"model": "model", "max_tokens": 2048,
"messages": [{"role": "user", "content": [{"type": "tool_result",
"tool_use_id": "tool_1", "content": "failed", "is_error": is_error}]}]});
assert!(serde_json::from_value::<AnthropicCreateMessageRequest>(request).is_err());
}
for is_error in [json!(true), json!(false), json!(null)] {
let block =
json!({"type": "tool_result", "tool_use_id": "tool_1", "is_error": is_error});
let parsed: AnthropicContentBlock = serde_json::from_value(block).unwrap();
assert_eq!(serde_json::to_value(parsed).unwrap()["is_error"], is_error);
}
}

#[test]
fn thinking_display_preserves_explicit_values_and_omission() {
for (display, expected) in [
("summarized", ThinkingDisplay::Summarized),
("omitted", ThinkingDisplay::Omitted),
("updates", ThinkingDisplay::Updates),
] {
let value = json!({"type": "adaptive", "display": display});
let config: ThinkingConfig = serde_json::from_value(value.clone()).unwrap();
assert_eq!(config.display, Some(expected));
assert_eq!(serde_json::to_value(config).unwrap(), value);
}
let value = json!({"type": "enabled", "budget_tokens": 1024});
let config: ThinkingConfig = serde_json::from_value(value.clone()).unwrap();
assert_eq!(config.display, None);
assert_eq!(serde_json::to_value(config).unwrap(), value);
for display in [json!("unknown"), json!(false), json!(1)] {
assert!(
serde_json::from_value::<ThinkingConfig>(
json!({"type": "adaptive", "display": display})
)
.is_err()
);
}
}
}

/// A tool definition.
Expand Down
Loading