|
| 1 | +//! Per-request context for the Anthropic Messages gateway tool loops. |
| 2 | +//! |
| 3 | +//! The Messages loops are a pass-through, not a transform: the client's request |
| 4 | +//! is forwarded to vLLM `/v1/messages` essentially untouched, and only `tools` |
| 5 | +//! and `stream`/`messages` are read or rewritten. That rules out a typed |
| 6 | +//! round-trip through [`MessagesRequest`] as the upstream body — `ContentBlock` |
| 7 | +//! carries a `#[serde(other)] Unknown` catch-all, and several block types model |
| 8 | +//! only the fields the gateway reads, so re-serializing would silently drop |
| 9 | +//! `cache_control`, `is_error`, and every unmodeled block (`image`, |
| 10 | +//! `redacted_thinking`, future provider extensions). |
| 11 | +//! |
| 12 | +//! So this context carries **two views of one request**, built once per request: |
| 13 | +//! |
| 14 | +//! * `raw` — the JSON body actually sent upstream. It |
| 15 | +//! is the single source of truth for `messages` and `system`, and the only |
| 16 | +//! thing the loops mutate. |
| 17 | +//! * `typed` — only the client's `tools`, `stream`, and `model`, retained for |
| 18 | +//! safe field access in routing and the loops. The parsed message history, |
| 19 | +//! system prompt, and other fields are dropped before the loop begins. |
| 20 | +//! |
| 21 | +//! The two are **not** kept byte-identical, and must not be confused: `typed` is |
| 22 | +//! what the client sent, `raw` is what the gateway sends upstream. They diverge |
| 23 | +//! wherever the gateway rewrites the body for upstream — today |
| 24 | +//! `normalize_native_web_search` rewriting a native `web_search_20250305` |
| 25 | +//! declaration into the ordinary function-tool shape vLLM accepts. Accordingly, |
| 26 | +//! only the fields the loops never mutate are exposed off `typed` |
| 27 | +//! ([`tools`](MessagesRequestContext::tools), |
| 28 | +//! [`stream`](MessagesRequestContext::stream), |
| 29 | +//! [`model`](MessagesRequestContext::model)); `messages` and `system` are |
| 30 | +//! deliberately unreachable through it, so a stale typed view can never be read |
| 31 | +//! back after a round is appended. |
| 32 | +
|
| 33 | +use serde::Deserialize; |
| 34 | +use serde_json::{Map, Value, json}; |
| 35 | + |
| 36 | +use crate::executor::error::{ExecutorError, ExecutorResult}; |
| 37 | +use crate::executor::messages_request::{WebSearchBudget, normalize_native_web_search}; |
| 38 | +use crate::types::messages::{GatewayToolResult, MessagesRequest, ToolParam}; |
| 39 | +use crate::utils::common::serialize_to_string; |
| 40 | + |
| 41 | +/// A Messages request parsed together with the exact immutable body bytes it |
| 42 | +/// came from. |
| 43 | +/// |
| 44 | +/// Private fields make independently pairing typed data and raw bytes |
| 45 | +/// impossible through the public API. The handler uses the typed view for |
| 46 | +/// routing, then consumes this value to build a [`MessagesRequestContext`] only |
| 47 | +/// when the request needs the gateway tool loop. |
| 48 | +#[derive(Debug)] |
| 49 | +pub struct ParsedMessagesRequest<'a> { |
| 50 | + typed: MessagesRequest, |
| 51 | + body: &'a [u8], |
| 52 | +} |
| 53 | + |
| 54 | +impl<'a> ParsedMessagesRequest<'a> { |
| 55 | + /// Parse a Messages request while retaining the exact bytes it came from. |
| 56 | + /// |
| 57 | + /// # Errors |
| 58 | + /// Returns [`ExecutorError::JsonError`] if `body` is not a well-formed |
| 59 | + /// Messages request. |
| 60 | + pub fn parse(body: &'a [u8]) -> ExecutorResult<Self> { |
| 61 | + let typed = serde_json::from_slice(body).map_err(ExecutorError::JsonError)?; |
| 62 | + Ok(Self { typed, body }) |
| 63 | + } |
| 64 | + |
| 65 | + /// The tools declared by the client, before upstream normalization. |
| 66 | + #[must_use] |
| 67 | + pub fn tools(&self) -> Option<&Vec<ToolParam>> { |
| 68 | + self.typed.tools.as_ref() |
| 69 | + } |
| 70 | + |
| 71 | + /// Whether the client requested a streaming response. |
| 72 | + #[must_use] |
| 73 | + pub fn stream(&self) -> bool { |
| 74 | + self.typed.stream |
| 75 | + } |
| 76 | +} |
| 77 | + |
| 78 | +#[derive(Debug)] |
| 79 | +struct MessagesTypedState { |
| 80 | + model: String, |
| 81 | + tools: Option<Vec<ToolParam>>, |
| 82 | + stream: bool, |
| 83 | +} |
| 84 | + |
| 85 | +impl From<MessagesRequest> for MessagesTypedState { |
| 86 | + fn from(request: MessagesRequest) -> Self { |
| 87 | + let MessagesRequest { |
| 88 | + model, tools, stream, .. |
| 89 | + } = request; |
| 90 | + Self { model, tools, stream } |
| 91 | + } |
| 92 | +} |
| 93 | + |
| 94 | +/// One `/v1/messages` request, in both the typed and raw views the gateway tool |
| 95 | +/// loops need. See the module docs for why both exist. |
| 96 | +#[derive(Debug)] |
| 97 | +pub struct MessagesRequestContext { |
| 98 | + /// The only typed request fields needed after routing. |
| 99 | + typed: MessagesTypedState, |
| 100 | + /// The upstream body. Mutated by the loops; the source of truth for |
| 101 | + /// `messages` and `system`. |
| 102 | + raw: Value, |
| 103 | + /// Request-wide native web-search budget, derived while normalizing `raw`. |
| 104 | + web_search_budget: WebSearchBudget, |
| 105 | +} |
| 106 | + |
| 107 | +impl MessagesRequestContext { |
| 108 | + /// Build the context from a validated typed/raw request pair. |
| 109 | + /// |
| 110 | + /// Consuming [`ParsedMessagesRequest`] guarantees both views derive from the |
| 111 | + /// same immutable input. Only the fields required after routing are retained |
| 112 | + /// from the typed view; the owned message history and system prompt are |
| 113 | + /// dropped before this function returns. |
| 114 | + /// |
| 115 | + /// Native web-search declarations are validated and normalized here, before |
| 116 | + /// a streaming handler commits its HTTP status — an invalid declaration must |
| 117 | + /// surface as an error response, not as a mid-stream event. |
| 118 | + /// |
| 119 | + /// # Errors |
| 120 | + /// Returns [`ExecutorError::JsonError`] if `body` is not valid JSON, or |
| 121 | + /// [`ExecutorError::InvalidRequest`] if it carries an unsupported or invalid |
| 122 | + /// native web-search declaration. |
| 123 | + pub fn new(parsed: ParsedMessagesRequest<'_>) -> ExecutorResult<Self> { |
| 124 | + let raw = serde_json::from_slice(parsed.body).map_err(ExecutorError::JsonError)?; |
| 125 | + Self::from_parts(parsed.typed, raw) |
| 126 | + } |
| 127 | + |
| 128 | + /// Build the context from a raw JSON body alone, deriving the typed view |
| 129 | + /// from it. |
| 130 | + /// |
| 131 | + /// Prefer [`new`](Self::new) when the caller has already parsed the request |
| 132 | + /// for routing; this exists for callers that only hold a [`Value`]. |
| 133 | + /// |
| 134 | + /// # Errors |
| 135 | + /// Returns [`ExecutorError::JsonError`] if `raw` is not a well-formed |
| 136 | + /// Messages request, or [`ExecutorError::InvalidRequest`] if it carries an |
| 137 | + /// unsupported or invalid native web-search declaration. |
| 138 | + pub fn from_value(raw: Value) -> ExecutorResult<Self> { |
| 139 | + // Deserializing from the parsed tree avoids re-lexing the body text. |
| 140 | + let typed = MessagesRequest::deserialize(&raw).map_err(ExecutorError::JsonError)?; |
| 141 | + Self::from_parts(typed, raw) |
| 142 | + } |
| 143 | + |
| 144 | + fn from_parts(typed: MessagesRequest, mut raw: Value) -> ExecutorResult<Self> { |
| 145 | + let web_search_budget = normalize_native_web_search(&mut raw)?; |
| 146 | + Ok(Self { |
| 147 | + typed: typed.into(), |
| 148 | + raw, |
| 149 | + web_search_budget, |
| 150 | + }) |
| 151 | + } |
| 152 | + |
| 153 | + /// The tools the client declared, for routing and registry construction. |
| 154 | + /// |
| 155 | + /// These are the client's declarations as received — before the upstream |
| 156 | + /// normalization applied to `raw` — which is what the tool seam |
| 157 | + /// needs to recognise a native server-tool declaration. |
| 158 | + #[must_use] |
| 159 | + pub fn tools(&self) -> Option<&Vec<ToolParam>> { |
| 160 | + self.typed.tools.as_ref() |
| 161 | + } |
| 162 | + |
| 163 | + /// Whether the client asked for a streaming response. |
| 164 | + #[must_use] |
| 165 | + pub fn stream(&self) -> bool { |
| 166 | + self.typed.stream |
| 167 | + } |
| 168 | + |
| 169 | + /// The model the client requested. |
| 170 | + #[must_use] |
| 171 | + pub fn model(&self) -> &str { |
| 172 | + &self.typed.model |
| 173 | + } |
| 174 | + |
| 175 | + /// The body to POST upstream for the next round. |
| 176 | + /// |
| 177 | + /// # Errors |
| 178 | + /// Returns [`ExecutorError::JsonError`] if the body cannot be serialized. |
| 179 | + pub(super) fn upstream_body(&self) -> ExecutorResult<String> { |
| 180 | + serialize_to_string(&self.raw).map_err(ExecutorError::JsonError) |
| 181 | + } |
| 182 | + |
| 183 | + /// Force the upstream streaming mode, regardless of what the client asked. |
| 184 | + /// |
| 185 | + /// Each loop drives its own rounds and so pins `stream` to what it can |
| 186 | + /// consume; the client-facing mode is [`stream`](Self::stream), decided by |
| 187 | + /// the handler before the loop starts. |
| 188 | + pub(super) fn force_stream(&mut self, streaming: bool) { |
| 189 | + self.raw["stream"] = Value::Bool(streaming); |
| 190 | + } |
| 191 | + |
| 192 | + /// Reserve up to `requested` native web searches, returning how many may run. |
| 193 | + pub(super) fn reserve_searches(&mut self, requested: usize) -> usize { |
| 194 | + self.web_search_budget.reserve(requested) |
| 195 | + } |
| 196 | + |
| 197 | + /// Append the model's assistant turn (preserving its `thinking`/`text`/ |
| 198 | + /// `tool_use` blocks in order — F3) and a following user turn of |
| 199 | + /// `tool_result`s, so the next upstream round sees the full conversation |
| 200 | + /// state. These stay internal — the client never sees them (hide-the-call). |
| 201 | + /// |
| 202 | + /// # Errors |
| 203 | + /// Returns [`ExecutorError::InvalidRequest`] if the body has no `messages` |
| 204 | + /// array to append to. Unreachable for a context built through either |
| 205 | + /// constructor, since `MessagesRequest::messages` is a required array — |
| 206 | + /// erroring keeps it from silently no-opping into a loop that re-POSTs an |
| 207 | + /// unchanged body until the round cap. |
| 208 | + pub(super) fn append_round( |
| 209 | + &mut self, |
| 210 | + assistant_content: &[Value], |
| 211 | + tool_results: Vec<GatewayToolResult>, |
| 212 | + ) -> ExecutorResult<()> { |
| 213 | + let messages = self |
| 214 | + .raw |
| 215 | + .get_mut("messages") |
| 216 | + .and_then(Value::as_array_mut) |
| 217 | + .ok_or_else(|| ExecutorError::InvalidRequest("request has no messages array".to_owned()))?; |
| 218 | + messages.push(json!({ "role": "assistant", "content": assistant_content })); |
| 219 | + // Built by hand rather than with `json!` so the tool outputs move in |
| 220 | + // instead of being deep-copied — a web-search result runs to kilobytes. |
| 221 | + let mut user = Map::new(); |
| 222 | + user.insert("role".to_owned(), Value::String("user".to_owned())); |
| 223 | + user.insert( |
| 224 | + "content".to_owned(), |
| 225 | + serde_json::to_value(tool_results).map_err(ExecutorError::JsonError)?, |
| 226 | + ); |
| 227 | + messages.push(Value::Object(user)); |
| 228 | + Ok(()) |
| 229 | + } |
| 230 | +} |
| 231 | + |
| 232 | +#[cfg(test)] |
| 233 | +mod tests { |
| 234 | + use super::*; |
| 235 | + |
| 236 | + fn request() -> Value { |
| 237 | + json!({ |
| 238 | + "model": "qwen3", "max_tokens": 1024, "stream": true, |
| 239 | + "messages": [{"role": "user", "content": "hi"}], |
| 240 | + "tools": [{"name": "web_search", "type": "web_search_20250305", "max_uses": 2}] |
| 241 | + }) |
| 242 | + } |
| 243 | + |
| 244 | + #[test] |
| 245 | + fn typed_view_reads_client_fields_and_raw_carries_upstream_normalization() { |
| 246 | + let ctx = MessagesRequestContext::from_value(request()).unwrap(); |
| 247 | + |
| 248 | + assert_eq!(ctx.model(), "qwen3"); |
| 249 | + assert!(ctx.stream()); |
| 250 | + // The typed view keeps the client's native declaration, which is what |
| 251 | + // the tool seam classifies on... |
| 252 | + let tools = ctx.tools().expect("tools"); |
| 253 | + assert_eq!(tools[0].name, "web_search"); |
| 254 | + assert_eq!(tools[0].type_.as_deref(), Some("web_search_20250305")); |
| 255 | + // ...while the raw body carries the function-tool shape vLLM accepts. |
| 256 | + assert_eq!(ctx.raw["tools"][0]["name"], "web_search"); |
| 257 | + assert!(ctx.raw["tools"][0].get("type").is_none()); |
| 258 | + assert!(ctx.raw["tools"][0].get("input_schema").is_some()); |
| 259 | + } |
| 260 | + |
| 261 | + #[test] |
| 262 | + fn force_stream_overrides_the_client_mode_without_touching_the_typed_view() { |
| 263 | + let mut ctx = MessagesRequestContext::from_value(request()).unwrap(); |
| 264 | + ctx.force_stream(false); |
| 265 | + |
| 266 | + assert_eq!(ctx.raw["stream"], json!(false)); |
| 267 | + assert!(ctx.stream(), "the client's requested mode is still readable"); |
| 268 | + } |
| 269 | + |
| 270 | + #[test] |
| 271 | + fn append_round_extends_the_raw_history_only() { |
| 272 | + let mut ctx = MessagesRequestContext::from_value(request()).unwrap(); |
| 273 | + let assistant = vec![json!({"type": "tool_use", "id": "t1", "name": "web_search", "input": {}})]; |
| 274 | + ctx.append_round( |
| 275 | + &assistant, |
| 276 | + vec![GatewayToolResult::new("t1", "answer".to_owned(), false)], |
| 277 | + ) |
| 278 | + .unwrap(); |
| 279 | + |
| 280 | + let messages = ctx.raw["messages"].as_array().expect("messages"); |
| 281 | + assert_eq!(messages.len(), 3); |
| 282 | + assert_eq!(messages[1]["role"], "assistant"); |
| 283 | + assert_eq!(messages[1]["content"], json!(assistant)); |
| 284 | + assert_eq!(messages[2]["role"], "user"); |
| 285 | + assert_eq!(messages[2]["content"][0]["tool_use_id"], "t1"); |
| 286 | + assert_eq!(messages[2]["content"][0]["content"], "answer"); |
| 287 | + assert_eq!(messages[2]["content"][0]["is_error"], false); |
| 288 | + } |
| 289 | + |
| 290 | + #[test] |
| 291 | + fn budget_is_shared_across_rounds() { |
| 292 | + let mut ctx = MessagesRequestContext::from_value(request()).unwrap(); |
| 293 | + assert_eq!(ctx.reserve_searches(1), 1); |
| 294 | + assert_eq!(ctx.reserve_searches(3), 1, "max_uses caps the request-wide total"); |
| 295 | + assert_eq!(ctx.reserve_searches(1), 0); |
| 296 | + } |
| 297 | + |
| 298 | + #[test] |
| 299 | + fn invalid_native_web_search_declaration_is_rejected_at_construction() { |
| 300 | + let mut body = request(); |
| 301 | + body["tools"][0]["max_uses"] = json!(0); |
| 302 | + let error = MessagesRequestContext::from_value(body).unwrap_err(); |
| 303 | + assert!(matches!(error, ExecutorError::InvalidRequest(_)), "{error:?}"); |
| 304 | + } |
| 305 | + |
| 306 | + #[test] |
| 307 | + fn unmodeled_blocks_and_cache_control_survive_in_the_raw_body() { |
| 308 | + // The reason the raw view exists: a typed round-trip would drop these. |
| 309 | + let body = json!({ |
| 310 | + "model": "m", "max_tokens": 8, |
| 311 | + "system": [{"type": "text", "text": "s", "cache_control": {"type": "ephemeral", "ttl": "1h"}}], |
| 312 | + "messages": [{"role": "user", "content": [ |
| 313 | + {"type": "text", "text": "hi", "cache_control": {"type": "ephemeral"}}, |
| 314 | + {"type": "redacted_thinking", "data": "enc"} |
| 315 | + ]}] |
| 316 | + }); |
| 317 | + let ctx = MessagesRequestContext::from_value(body.clone()).unwrap(); |
| 318 | + assert_eq!(ctx.raw, body); |
| 319 | + } |
| 320 | + |
| 321 | + #[test] |
| 322 | + fn parsed_request_builds_both_context_views_from_the_same_input() { |
| 323 | + let body = serde_json::to_vec(&request()).unwrap(); |
| 324 | + let parsed = ParsedMessagesRequest::parse(&body).unwrap(); |
| 325 | + let ctx = MessagesRequestContext::new(parsed).unwrap(); |
| 326 | + |
| 327 | + assert_eq!(ctx.model(), "qwen3"); |
| 328 | + assert_eq!(ctx.raw["messages"][0]["content"], "hi"); |
| 329 | + } |
| 330 | + |
| 331 | + #[test] |
| 332 | + fn parsed_request_rejects_non_messages_json() { |
| 333 | + let error = ParsedMessagesRequest::parse(br"[]").unwrap_err(); |
| 334 | + assert!(matches!(error, ExecutorError::JsonError(_)), "{error:?}"); |
| 335 | + } |
| 336 | +} |
0 commit comments