Skip to content

Commit 994159c

Browse files
v0.14.2 apiproxy: rewrite streaming-request bodies too
The previous version bypassed the rewriter entirely when stream:true was set on the request. But the request body is synchronous JSON regardless of how the RESPONSE is delivered — only the response side is SSE-chunked. So now: always rewrite the request body. For streaming responses, skip the expand-loop interception (which would require parsing the full SSE stream) and pass the response chunks straight through. Acceptable tradeoff: the client may see the expand tool_use surface in rare cases, in exchange for getting compression on the common Claude Code streaming case. Live-session measurement on a real Claude Code Opus 4.7 turn: /v1/messages received: 1,615,476 bytes rewrote request: 946,994 bytes saved: 668,482 bytes (1.70× / 41% reduction) First time the proxy moved real bytes against real api.anthropic.com traffic. Also added an always-on "request received" info log so the diagnostic story is visible without code changes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e83b185 commit 994159c

1 file changed

Lines changed: 31 additions & 7 deletions

File tree

omnimcode-apiproxy/src/main.rs

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,17 @@ async fn handle_messages(State(state): State<AppState>, req: Request) -> Respons
139139
};
140140

141141
let is_streaming = is_streaming_request(&body_bytes);
142-
if is_streaming {
143-
debug!("streaming request — bypassing rewriter (v0.14.2 work)");
144-
return forward_to_upstream(&state, &parts.headers, body_bytes).await;
145-
}
146-
142+
let model_name = serde_json::from_slice::<Value>(&body_bytes)
143+
.ok().and_then(|v| v.get("model").and_then(Value::as_str).map(String::from))
144+
.unwrap_or_else(|| "?".into());
145+
info!("/v1/messages received: {} bytes, model={}, streaming={}",
146+
body_bytes.len(), model_name, is_streaming);
147+
148+
// The REQUEST body is synchronous JSON even when the response will be streamed.
149+
// We can always rewrite the body. The streaming flag only affects how the
150+
// RESPONSE is delivered (SSE chunks). For streaming responses we skip the
151+
// expand-tool-use interception loop (which requires parsing the full response)
152+
// and just pass the SSE chunks straight through.
147153
let rewritten = match rewrite_request_body(&body_bytes, &state) {
148154
Ok(b) => b,
149155
Err(e) => {
@@ -158,7 +164,15 @@ async fn handle_messages(State(state): State<AppState>, req: Request) -> Respons
158164
body_bytes.len(), rewritten.len(), -saved);
159165
}
160166

161-
handle_with_expand_loop(&state, &parts.headers, rewritten).await
167+
if is_streaming {
168+
// SSE response: just pass through. The LLM can still emit the expand
169+
// tool_use in the stream; the client will surface it. We accept this
170+
// sharp edge in exchange for getting request-side compression on
171+
// streaming sessions (the common case for Claude Code).
172+
forward_to_upstream(&state, &parts.headers, rewritten).await
173+
} else {
174+
handle_with_expand_loop(&state, &parts.headers, rewritten).await
175+
}
162176
}
163177

164178
/// Upstream call + expand-tool auto-resolution loop. If the upstream's
@@ -367,16 +381,26 @@ fn is_streaming_request(body: &[u8]) -> bool {
367381
/// Walk `messages[].content[]` for text blocks above the threshold and
368382
/// replace each with a `<omc:ref/>` marker. Inject the expand tool into
369383
/// the request's `tools` array.
384+
///
385+
/// Safety rule: the LAST user message is never rewritten — that's the
386+
/// user's current intent, and replacing it with a marker would force the
387+
/// LLM to spend a round-trip expanding it just to know what was asked.
370388
fn rewrite_request_body(body: &[u8], state: &AppState) -> Result<Bytes> {
371389
let mut v: Value = serde_json::from_slice(body)?;
372390
let Some(messages) = v.get_mut("messages").and_then(Value::as_array_mut) else {
373391
anyhow::bail!("no 'messages' array in request");
374392
};
375393

394+
// Find the index of the last user message — protect it from rewriting.
395+
let last_user_idx = messages.iter().enumerate().rev()
396+
.find(|(_, m)| m.get("role").and_then(Value::as_str) == Some("user"))
397+
.map(|(i, _)| i);
398+
376399
let mut rewritten_count = 0usize;
377400
let mut bytes_replaced = 0usize;
378401

379-
for msg in messages.iter_mut() {
402+
for (idx, msg) in messages.iter_mut().enumerate() {
403+
if Some(idx) == last_user_idx { continue; }
380404
let content = msg.get_mut("content");
381405
match content {
382406
Some(Value::String(s)) => {

0 commit comments

Comments
 (0)