Skip to content

Commit c80f5bc

Browse files
authored
feat: add per-request context to the /v1/messages loop (#137) (#249)
## Summary Closes #137. Follow-up to #131, addressing the code review concern that the Messages loop took a bare `serde_json::Value` at a public API boundary while the handler separately parsed a `MessagesRequest` for typed field access. ## Technical Details Introduces `MessagesRequestContext { typed: MessagesRequest, raw: Value }`, built once per request, and threads it through `run_messages_loop` and `run_messages_stream` in place of the untyped body. The raw body stays the thing forwarded upstream, and is deliberately not re-serialized from the typed view. `ContentBlock` catches unmodeled block types in `#[serde(other)] Unknown` and several variants model only the fields the gateway reads, so a typed round-trip would drop `cache_control` and `is_error` and collapse `image`/`redacted_thinking` into a literal `{"type":"unknown"}` block. - The typed view exposes only `tools()`, `stream()` and `model()` — the fields the loops read and never mutate. `messages` and `system` are unreachable through it, so a stale typed view cannot be read back after a round is appended. - The context owns every mutation to the upstream body (`force_stream`, `append_round`) plus the native web-search budget, replacing the two duplicated `append_round_to_history` copies with one method. - The two views diverge only where the gateway rewrites for upstream: `normalize_native_web_search` rewrites the native `web_search_20250305` declaration in the raw body, while the typed view keeps the client's original so the tool seam can still classify it. - `MessagesRequestContext::new` reuses the handler's routing parse, so neither view is built twice and a proxied request never pays for a raw parse it does not use. Native web-search validation folds into the constructor, still ahead of a streaming response committing its status, and `validate_native_web_search_request` drops off the public API. Also removes the single-field `ResolvedCall`/`ResolvedStreamCall` wrappers, which every caller immediately unwrapped, and a redundant deep clone of the assistant turn per round in the non-streaming loop. Note this keeps routing on the full `MessagesRequest` parse, so a body that declares a gateway tool but fails that parse on an unrelated field still falls through to the proxy silently. That behaviour is unchanged here and is worth a separate issue. ## Test Plan - `cargo fmt -- --check`, `cargo clippy --all-targets -- -D warnings` and `cargo test --workspace` all clean: 889 passed, 0 failed, cassette replays included. - New `messages_loop_preserves_unmodeled_blocks_and_tool_result_fields_across_rounds` covers `image`, `redacted_thinking`, `citations` and a `tool_result` carrying `is_error` and `cache_control`, asserting they reach upstream unchanged across a gateway round. - Negative control: pointing `upstream_body` at the typed view fails 8 tests, including the existing Claude Code cache_control replays, which confirms the guard is not vacuous. --------- Signed-off-by: Zheng Lu <Lz429671594@gmail.com>
1 parent 2d0c66c commit c80f5bc

12 files changed

Lines changed: 555 additions & 144 deletions

File tree

ARCHITECTURE.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,7 @@ scheduler switch. It is forwarded to vLLM for all supported declaration mixtures
565565
defaults to `false` when omitted. Whatever calls the model emits are executed under
566566
the per-round execution permit limit and each handler's same-tool safety policy.
567567

568-
#### `messages_loop.rs` / `messages_request.rs` / `messages_stream.rs`
568+
#### `messages_context.rs` / `messages_loop.rs` / `messages_request.rs` / `messages_stream.rs`
569569

570570
A **parallel, independent implementation** of the same shape of loop for the Anthropic
571571
Messages API. `messages_stream.rs`'s own header comment describes it as "structurally
@@ -578,6 +578,18 @@ constants (`MAX_GATEWAY_TOOL_ROUNDS`, `GATEWAY_TOOL_TIMEOUT`) are duplicated and
578578
manually kept in sync with the Responses-side ones rather than shared — a known seam,
579579
not an oversight, per the future-consolidation note.
580580

581+
Both loops take a `MessagesRequestContext` (`messages_context.rs`), the per-request
582+
type that replaced a bare `serde_json::Value` at that boundary. It holds two views of
583+
one request: a typed `MessagesRequest` for reading `tools`/`stream`/`model`, and the
584+
raw JSON body that is actually forwarded upstream. The raw body is deliberately *not*
585+
re-serialized from the typed view — `ContentBlock` catches unmodeled block types in
586+
`#[serde(other)] Unknown` and models only the fields the gateway reads, so a typed
587+
round-trip would drop `cache_control` and `is_error` and collapse `image`/
588+
`redacted_thinking` into `{"type":"unknown"}`. The context owns every mutation the
589+
loops make to that body (`force_stream`, `append_round`) and the native web-search
590+
budget, so the two views cannot drift apart uncontrolled; `messages` and `system` are
591+
reachable only through the raw body, never the typed view.
592+
581593
### `storage/` — persistence
582594

583595
- **`pool.rs`**`DbPool = sqlx::Pool<sqlx::Any>`, driver-agnostic across SQLite and
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
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

Comments
 (0)