Skip to content

Commit 0061aa7

Browse files
fix: preserve Claude Code Messages transport fidelity (vllm-project#161)
## Summary - preserve the original Messages query string and open-ended Anthropic/Claude Code request headers across every server-side gateway tool round - preserve upstream Messages status, body, request IDs, retry guidance, and rate-limit metadata while keeping local connection failures in valid Anthropic JSON envelopes - forward documented upstream SSE error events and terminate without a synthetic successful message stop - verify multi-block system attribution and a valid four-breakpoint, correctly ordered cache-control layout survive transparent proxying and native gateway tool loops - run the real Claude Code CLI through agentic-server against recorded vLLM streaming responses and a local web-search replay service - pin Claude Code 2.1.218, PyYAML 6.0.3, Node 24, and immutable action SHAs in the dedicated GitHub Actions check The E2E job uses localhost services and a placeholder token. It requires no Anthropic credential, GPU, model download, or paid inference. This change does not add conversation persistence or other server-side client state; the request-scoped transport context is reused only for the gateway-owned tool loop. For non-streaming requests, response metadata comes from the terminal upstream round. For streaming requests, HTTP response metadata comes from the initial upstream response because later rounds occur after the client response has started. The visible reasoning double-render remains a vLLM-side issue tracked separately; this PR closes the agentic-api transport and error-fidelity gaps found by running Claude Code. Related to vllm-project#116. ## Test Plan - cargo test - cargo clippy --all-targets -- -D warnings - cargo fmt --all -- --check - pre-commit 4.4.0: pre-commit run --all-files - pinned replay-server unit tests: 7 passed - pinned Claude Code smoke test: capture valid: messages=2 transports=2 searches=1 - bash -n scripts/claude-code-smoke.sh --------- Signed-off-by: Francisco Javier Arceo <farceo@redhat.com>
1 parent e1c61ce commit 0061aa7

20 files changed

Lines changed: 1520 additions & 422 deletions
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: Claude Code E2E
2+
3+
run-name: Claude Code through Messages gateway
4+
5+
on:
6+
pull_request:
7+
merge_group:
8+
push:
9+
branches:
10+
- main
11+
12+
permissions:
13+
contents: read
14+
15+
concurrency:
16+
group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.run_id || github.ref }}
17+
cancel-in-progress: true
18+
19+
jobs:
20+
claude-code-e2e:
21+
runs-on: ubuntu-latest
22+
timeout-minutes: 15
23+
steps:
24+
- name: Checkout code
25+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
26+
27+
- name: Set up Python
28+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
29+
with:
30+
python-version: '3.12'
31+
32+
- name: Set up Node.js
33+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
34+
with:
35+
node-version: '24'
36+
37+
- name: Install Rust toolchain
38+
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
39+
with:
40+
toolchain: stable
41+
42+
- name: Cache cargo registry and build
43+
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v4
44+
with:
45+
path: |
46+
~/.cargo/registry
47+
~/.cargo/git
48+
target
49+
key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
50+
restore-keys: |
51+
cargo-${{ runner.os }}-
52+
53+
- name: Install pinned test dependencies
54+
run: |
55+
python -m pip install 'PyYAML==6.0.3'
56+
npm install --global '@anthropic-ai/claude-code@2.1.218'
57+
test "$(claude --version)" = '2.1.218 (Claude Code)'
58+
59+
- name: Test replay server
60+
run: python -m unittest scripts/test_claude_code_replay_server.py -v
61+
62+
- name: Build agentic-server
63+
run: cargo build -p agentic-server
64+
65+
- name: Run Claude Code through agentic-server
66+
run: bash scripts/claude-code-smoke.sh

‎crates/agentic-server-core/src/executor/error.rs‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,17 @@ pub enum ExecutorError {
2929
source: StorageError,
3030
},
3131

32-
/// The LLM backend returned a non-2xx status or was unreachable.
32+
/// The LLM backend returned a non-2xx HTTP response.
3333
#[error("LLM request failed ({status}): {body}")]
34-
LLMRequest { status: StatusCode, body: String },
34+
LLMRequest {
35+
status: StatusCode,
36+
body: String,
37+
headers: http::HeaderMap,
38+
},
39+
40+
/// The LLM backend could not be reached or timed out before responding.
41+
#[error("{message}")]
42+
LLMTransport { status: StatusCode, message: &'static str },
3543

3644
/// A network error occurred reading from the LLM response stream.
3745
///
@@ -101,7 +109,7 @@ impl ExecutorError {
101109
pub fn http_status(&self) -> StatusCode {
102110
match self.client_visible_error() {
103111
Self::Storage(e) if e.is_not_found() => StatusCode::NOT_FOUND,
104-
Self::LLMRequest { status, .. } => *status,
112+
Self::LLMRequest { status, .. } | Self::LLMTransport { status, .. } => *status,
105113
Self::ConversationLocked { .. }
106114
| Self::Tool(ToolError::Config(_))
107115
| Self::InvalidRequest(_)
@@ -122,7 +130,7 @@ impl ExecutorError {
122130
| Self::ParseError(_)
123131
| Self::JsonError(_) => "invalid_request_error",
124132
Self::Storage(e) if e.is_not_found() => "not_found",
125-
Self::LLMRequest { .. } | Self::CompactionFailed { .. } => "upstream_error",
133+
Self::LLMRequest { .. } | Self::LLMTransport { .. } | Self::CompactionFailed { .. } => "upstream_error",
126134
Self::Tool(ToolError::Execution(_)) => "tool_error",
127135
_ => "server_error",
128136
}

‎crates/agentic-server-core/src/executor/inference.rs‎

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use async_stream::stream;
1010
use futures::{Stream, StreamExt};
1111

1212
use crate::executor::error::{ExecutorError, ExecutorResult};
13+
use crate::proxy::processed_response_headers;
1314

1415
/// SSE stream of raw lines sent to the client (`data: …\n\n` per event).
1516
pub type BoxStream = std::pin::Pin<Box<dyn Stream<Item = String> + Send>>;
@@ -55,33 +56,40 @@ fn drain_complete_utf8_lines(buffer: &mut Vec<u8>) -> Vec<String> {
5556
///
5657
/// Shared by both the blocking path (caller reads `.text()`) and the streaming
5758
/// path (caller reads `.bytes_stream()`). Maps connect/timeout failures and
58-
/// non-2xx status codes to [`ExecutorError::LLMRequest`].
59+
/// non-2xx status codes to [`ExecutorError::LLMRequest`] and connection
60+
/// failures to [`ExecutorError::LLMTransport`].
5961
pub(super) async fn send_request(
6062
client: &reqwest::Client,
6163
url: &str,
6264
body: String,
6365
auth: Option<&str>,
66+
forwarded_headers: Option<&reqwest::header::HeaderMap>,
6467
) -> ExecutorResult<reqwest::Response> {
65-
let mut req = client.post(url).header("Content-Type", "application/json").body(body);
68+
let mut headers = forwarded_headers.cloned().unwrap_or_default();
69+
headers
70+
.entry(reqwest::header::CONTENT_TYPE)
71+
.or_insert(reqwest::header::HeaderValue::from_static("application/json"));
72+
let mut req = client.post(url).headers(headers).body(body);
6673
if let Some(key) = auth {
6774
req = req.bearer_auth(key);
6875
}
6976

70-
let resp = req.send().await.map_err(|e| ExecutorError::LLMRequest {
77+
let resp = req.send().await.map_err(|e| ExecutorError::LLMTransport {
7178
status: if e.is_timeout() {
7279
http::StatusCode::GATEWAY_TIMEOUT
7380
} else {
7481
http::StatusCode::BAD_GATEWAY
7582
},
76-
body: if e.is_timeout() {
77-
"upstream timeout".into()
83+
message: if e.is_timeout() {
84+
"LLM timeout"
7885
} else {
79-
"upstream unavailable".into()
86+
"LLM unavailable"
8087
},
8188
})?;
8289

8390
if !resp.status().is_success() {
8491
let status = resp.status().as_u16();
92+
let headers = processed_response_headers(resp.headers());
8593
// Log and discard any error reading the error body — the status code
8694
// is the primary signal; an empty body is acceptable here.
8795
let body = resp
@@ -92,6 +100,7 @@ pub(super) async fn send_request(
92100
return Err(ExecutorError::LLMRequest {
93101
status: http::StatusCode::from_u16(status).unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR),
94102
body,
103+
headers,
95104
});
96105
}
97106

@@ -107,19 +116,32 @@ pub(super) async fn fetch_response_json(
107116
client: &reqwest::Client,
108117
auth: Option<&str>,
109118
) -> ExecutorResult<String> {
110-
let resp = send_request(client, url, upstream_json, auth).await?;
119+
let resp = send_request(client, url, upstream_json, auth, None).await?;
111120
// Preserve the reqwest::Error as the typed source (NetworkError).
112121
resp.text().await.map_err(ExecutorError::NetworkError)
113122
}
114123

124+
/// Makes a non-streaming HTTP POST with caller-supplied upstream headers.
125+
pub(super) async fn fetch_response_json_with_headers(
126+
upstream_json: String,
127+
url: &str,
128+
client: &reqwest::Client,
129+
headers: &reqwest::header::HeaderMap,
130+
) -> ExecutorResult<(String, http::HeaderMap)> {
131+
let resp = send_request(client, url, upstream_json, None, Some(headers)).await?;
132+
let response_headers = processed_response_headers(resp.headers());
133+
let body = resp.text().await.map_err(ExecutorError::NetworkError)?;
134+
Ok((body, response_headers))
135+
}
136+
115137
/// Step 2 — Call the LLM inference backend; yields raw SSE lines (`data: …`).
116138
///
117139
/// Always requests `stream=true` upstream. Stops on `[DONE]`.
118140
///
119141
/// # Errors
120142
/// Each stream item is `Result<String, ExecutorError>`. The stream yields `Err` on:
121-
/// - [`ExecutorError::LLMRequest`] — connect timeout (504), connection failure (502),
122-
/// or non-2xx HTTP status from the backend
143+
/// - [`ExecutorError::LLMTransport`] — connect timeout (504) or connection failure (502)
144+
/// - [`ExecutorError::LLMRequest`] — non-2xx HTTP status from the backend
123145
/// - [`ExecutorError::NetworkError`] — network failure while reading the response body
124146
pub fn call_inference(
125147
upstream_json: String,
@@ -129,11 +151,24 @@ pub fn call_inference(
129151
chunk_timeout: Duration,
130152
) -> impl Stream<Item = Result<String, ExecutorError>> + Send + 'static {
131153
stream! {
132-
let resp = match send_request(&client, &url, upstream_json, auth.as_deref()).await {
154+
let resp = match send_request(&client, &url, upstream_json, auth.as_deref(), None).await {
133155
Ok(r) => r,
134156
Err(e) => { yield Err(e); return; }
135157
};
136158

159+
let mut lines = Box::pin(response_lines(resp, chunk_timeout));
160+
while let Some(line) = lines.next().await {
161+
yield line;
162+
}
163+
}
164+
}
165+
166+
/// Convert a successful upstream response body into normalized SSE data lines.
167+
pub(super) fn response_lines(
168+
resp: reqwest::Response,
169+
chunk_timeout: Duration,
170+
) -> impl Stream<Item = Result<String, ExecutorError>> + Send + 'static {
171+
stream! {
137172
let mut bytes = resp.bytes_stream();
138173
let mut buf = Vec::with_capacity(8192);
139174

‎crates/agentic-server-core/src/executor/messages_loop.rs‎

Lines changed: 68 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
//! Messages-native gateway tool loop.
22
//!
33
//! Runs the server-side gateway-tool loop for `/v1/messages` **natively**: the
4-
//! client's Anthropic request is forwarded to vLLM `/v1/messages` while
5-
//! preserving Anthropic fields except for native server-tool declarations that
6-
//! must be normalized to the function-tool shape vLLM accepts. The assistant turn is
4+
//! client's Anthropic request is forwarded to vLLM `/v1/messages` essentially
5+
//! untouched (preserving every Anthropic field), the assistant turn is
76
//! inspected, any gateway-owned `tool_use` is executed server-side and hidden,
87
//! the loop appends the `tool_result` and re-POSTs, until the model stops asking
98
//! for a gateway tool. Only the final assistant message reaches the client.
@@ -19,7 +18,7 @@ use futures::future::join_all;
1918
use serde_json::{Value, json};
2019

2120
use crate::executor::error::{ExecutorError, ExecutorResult};
22-
use crate::executor::inference::fetch_response_json;
21+
use crate::executor::inference::fetch_response_json_with_headers;
2322
use crate::executor::messages_request::{normalize_native_web_search, web_search_budget_exhausted_result};
2423
use crate::executor::request::ExecutionContext;
2524
use crate::tool::ToolRegistry;
@@ -37,6 +36,41 @@ pub(super) const MAX_GATEWAY_TOOL_ROUNDS: usize = 10;
3736
/// the streaming loop; matches the Responses loop's `gateway::GATEWAY_TOOL_TIMEOUT`.
3837
pub(super) const GATEWAY_TOOL_TIMEOUT: Duration = Duration::from_secs(60);
3938

39+
/// Per-request transport data reused for every upstream Messages round.
40+
#[derive(Clone, Debug)]
41+
pub struct MessagesUpstream {
42+
url: String,
43+
headers: reqwest::header::HeaderMap,
44+
}
45+
46+
impl MessagesUpstream {
47+
#[must_use]
48+
pub fn new(base_url: &str, query: Option<&str>, headers: reqwest::header::HeaderMap) -> Self {
49+
let mut url = format!("{}/v1/messages", base_url.trim_end_matches('/'));
50+
if let Some(query) = query.filter(|query| !query.is_empty()) {
51+
url.push('?');
52+
url.push_str(query);
53+
}
54+
Self { url, headers }
55+
}
56+
57+
pub(super) fn url(&self) -> &str {
58+
&self.url
59+
}
60+
61+
pub(super) fn headers(&self) -> &reqwest::header::HeaderMap {
62+
&self.headers
63+
}
64+
}
65+
66+
/// A Messages loop result paired with safe metadata from the relevant upstream response.
67+
pub struct MessagesResponse<T> {
68+
/// The completed message or client-facing stream.
69+
pub body: T,
70+
/// Safe metadata retained from the terminal response, or the initial response for streaming.
71+
pub headers: http::HeaderMap,
72+
}
73+
4074
/// The `tool_result` block for one executed gateway call, fed back next round.
4175
/// (The model's own `tool_use` block is carried forward via the preserved
4276
/// assistant content, not reconstructed here — see `append_round_to_history`.)
@@ -58,23 +92,26 @@ pub async fn run_messages_loop(
5892
mut request: Value,
5993
registry: &ToolRegistry,
6094
exec_ctx: &ExecutionContext,
61-
auth: Option<&str>,
62-
) -> ExecutorResult<Value> {
63-
let url = format!("{}/v1/messages", exec_ctx.llm_base_url);
95+
upstream: &MessagesUpstream,
96+
) -> ExecutorResult<MessagesResponse<Value>> {
6497
let mut web_search_budget = normalize_native_web_search(&mut request)?;
6598
// The loop drives turns itself; force non-streaming upstream regardless of
6699
// what the client asked (the handler routes streaming elsewhere).
67100
request["stream"] = Value::Bool(false);
68101

69102
for _round in 0..MAX_GATEWAY_TOOL_ROUNDS {
70103
let body = serialize_to_string(&request).map_err(ExecutorError::JsonError)?;
71-
let resp_text = fetch_response_json(body, &url, &exec_ctx.client, auth).await?;
104+
let (resp_text, response_headers) =
105+
fetch_response_json_with_headers(body, &upstream.url, &exec_ctx.client, &upstream.headers).await?;
72106
let message: Value = deserialize_from_str(&resp_text).map_err(ExecutorError::JsonError)?;
73107

74108
// Any error body from upstream is surfaced verbatim (handler maps it to
75109
// the Anthropic error envelope).
76110
if message.get("type").and_then(Value::as_str) == Some("error") {
77-
return Ok(message);
111+
return Ok(MessagesResponse {
112+
body: message,
113+
headers: response_headers,
114+
});
78115
}
79116

80117
let content = message.get("content").and_then(Value::as_array);
@@ -84,7 +121,10 @@ pub async fn run_messages_loop(
84121
// client should see. A client-owned tool_use means we cannot continue
85122
// the loop server-side — return the turn to the client (edge E7).
86123
let Some(content) = content else {
87-
return Ok(message);
124+
return Ok(MessagesResponse {
125+
body: message,
126+
headers: response_headers,
127+
});
88128
};
89129
let gateway_map = &exec_ctx.messages_gateway_tools;
90130
let mut gateway_calls: Vec<Value> = Vec::new();
@@ -105,15 +145,21 @@ pub async fn run_messages_loop(
105145
// must run it) — but the gateway tool_use, if any, must still be hidden
106146
// (F5): strip gateway blocks from the client-facing content.
107147
if gateway_calls.is_empty() || stop_reason != Some("tool_use") {
108-
return Ok(message);
148+
return Ok(MessagesResponse {
149+
body: message,
150+
headers: response_headers,
151+
});
109152
}
110153
if has_client_tool_use {
111154
// Strip the gateway tool_use from the client-facing content (compute
112155
// before mutating to end the immutable borrow of `message`).
113156
let stripped = tool_seam::strip_gateway_tool_use(content, gateway_map);
114157
let mut message = message;
115158
message["content"] = Value::Array(stripped);
116-
return Ok(message);
159+
return Ok(MessagesResponse {
160+
body: message,
161+
headers: response_headers,
162+
});
117163
}
118164

119165
// Pure gateway-tool round: execute the calls, then feed the model's FULL
@@ -129,13 +175,16 @@ pub async fn run_messages_loop(
129175
// last message. (Open Q1: a dedicated pause_turn signal could go here.)
130176
// Reaching here means every round emitted a gateway tool_use; surface a
131177
// minimal terminal so the client isn't left hanging.
132-
Ok(json!({
133-
"type": "error",
134-
"error": {
135-
"type": "api_error",
136-
"message": format!("gateway tool loop exceeded {MAX_GATEWAY_TOOL_ROUNDS} rounds")
137-
}
138-
}))
178+
Ok(MessagesResponse {
179+
body: json!({
180+
"type": "error",
181+
"error": {
182+
"type": "api_error",
183+
"message": format!("gateway tool loop exceeded {MAX_GATEWAY_TOOL_ROUNDS} rounds")
184+
}
185+
}),
186+
headers: http::HeaderMap::new(),
187+
})
139188
}
140189

141190
/// Execute the gateway-owned `tool_use` blocks concurrently, each bounded by the

0 commit comments

Comments
 (0)