From 11246510432b64248790d3b16720d1b76d59528f Mon Sep 17 00:00:00 2001 From: maheshrbapatu Date: Tue, 27 Oct 2026 03:41:16 +0000 Subject: [PATCH 1/3] feat(anthropic): preserve thinking display and URL image sources Signed-off-by: maheshrbapatu --- protocols/README.md | 17 +++++++ protocols/src/types/anthropic.rs | 87 +++++++++++++++++++++++++++++--- 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/protocols/README.md b/protocols/README.md index 07ff73454..8035a42e3 100644 --- a/protocols/README.md +++ b/protocols/README.md @@ -12,6 +12,23 @@ 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` and +`ThinkingDisplay::Omitted`. Omission remains unset. The serving layer validates +combinations with the thinking mode and applies the response-display policy. +Beta display modes and file sources are outside this subset. + +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` diff --git a/protocols/src/types/anthropic.rs b/protocols/src/types/anthropic.rs index 914bb748b..3ecb71bb6 100644 --- a/protocols/src/types/anthropic.rs +++ b/protocols/src/types/anthropic.rs @@ -203,6 +203,17 @@ 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, + /// 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, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ThinkingDisplay { + Summarized, + Omitted, } /// A single message in the conversation. @@ -518,12 +529,76 @@ 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::(source.clone()).is_err(), + "{source}" + ); + } + } + + #[test] + fn thinking_display_preserves_explicit_values_and_omission() { + for (display, expected) in [ + ("summarized", ThinkingDisplay::Summarized), + ("omitted", ThinkingDisplay::Omitted), + ] { + 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::( + json!({"type": "adaptive", "display": display}) + ) + .is_err() + ); + } + } } /// A tool definition. From 39a83bd6d40c703c2801f4935a500fd37b8c36ac Mon Sep 17 00:00:00 2001 From: maheshrbapatu Date: Tue, 27 Oct 2026 03:57:09 +0000 Subject: [PATCH 2/3] fix(anthropic): reject malformed nested tool content Signed-off-by: maheshrbapatu --- protocols/src/types/anthropic.rs | 41 ++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/protocols/src/types/anthropic.rs b/protocols/src/types/anthropic.rs index 3ecb71bb6..28e892779 100644 --- a/protocols/src/types/anthropic.rs +++ b/protocols/src/types/anthropic.rs @@ -474,8 +474,11 @@ impl<'de> Deserialize<'de> for AnthropicContentBlock { } "tool_result" => { let tool_use_id = take_required_string::(&mut value, "tool_use_id")?; - let content: Option = - take_field(&mut value, "content").and_then(|v| serde_json::from_value(v).ok()); + let content = take_field(&mut value, "content") + .map(serde_json::from_value::>) + .transpose() + .map_err(serde::de::Error::custom)? + .flatten(); let is_error = value.get("is_error").and_then(|v| v.as_bool()); let cache_control: Option = take_field(&mut value, "cache_control") .and_then(|v| serde_json::from_value(v).ok()); @@ -572,6 +575,40 @@ mod source_and_display_tests { serde_json::from_value::(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::(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), + } } } From 893b8efd3b6af33216befb2907d89a8b816a9bda Mon Sep 17 00:00:00 2001 From: maheshrbapatu Date: Tue, 27 Oct 2026 17:07:41 +0000 Subject: [PATCH 3/3] fix(protocols): preserve beta display and reject malformed tool status Signed-off-by: maheshrbapatu --- protocols/README.md | 10 ++++++---- protocols/src/types/anthropic.rs | 25 ++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/protocols/README.md b/protocols/README.md index 8035a42e3..79b869211 100644 --- a/protocols/README.md +++ b/protocols/README.md @@ -19,10 +19,12 @@ Request/response types for OpenAI- and Anthropic-compatible inference servers. B 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` and -`ThinkingDisplay::Omitted`. Omission remains unset. The serving layer validates -combinations with the thinking mode and applies the response-display policy. -Beta display modes and file sources are outside this subset. +`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 diff --git a/protocols/src/types/anthropic.rs b/protocols/src/types/anthropic.rs index 28e892779..59bfe7439 100644 --- a/protocols/src/types/anthropic.rs +++ b/protocols/src/types/anthropic.rs @@ -214,6 +214,8 @@ pub struct ThinkingConfig { pub enum ThinkingDisplay { Summarized, Omitted, + /// Beta progress-update display; serving capability is validated downstream. + Updates, } /// A single message in the conversation. @@ -479,7 +481,11 @@ impl<'de> Deserialize<'de> for AnthropicContentBlock { .transpose() .map_err(serde::de::Error::custom)? .flatten(); - let is_error = value.get("is_error").and_then(|v| v.as_bool()); + let is_error = take_field(&mut value, "is_error") + .map(serde_json::from_value::>) + .transpose() + .map_err(serde::de::Error::custom)? + .flatten(); let cache_control: Option = take_field(&mut value, "cache_control") .and_then(|v| serde_json::from_value(v).ok()); Ok(AnthropicContentBlock::ToolResult { @@ -612,11 +618,28 @@ mod source_and_display_tests { } } + #[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::(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();