Skip to content

Commit e83b185

Browse files
v0.14.1 apiproxy: auto-resolve omc_proxy_expand_ref tool calls transparently
When the upstream response contains a sole tool_use for omc_proxy_expand_ref, the proxy now: 1. Pulls the hash_str from the tool_use input 2. Looks it up in the MemoryStore cache 3. Builds a follow-up request: original messages + assistant response + synthetic user turn with tool_result containing the expanded text 4. Re-calls upstream (bounded to 8 rounds to prevent runaway) 5. Returns the final non-expand-call response to the client The client never sees the omc_proxy_expand_ref round-trip. Solves the v0.14.0-alpha sharp edge where Claude Code would otherwise see the unknown expand tool and error. End-to-end smoke test with a mock upstream that simulates the LLM asking to expand: client sends 7KB request, proxy rewrites to 908 bytes upstream, mock emits expand tool_use, proxy resolves from cache, mock sees 6.8KB tool_result, mock emits final text, client receives a single clean assistant message. Round-trip lossless. Mixed tool_use (expand alongside another tool) still passes through — v0.14.3 follow-up will handle that case. Streaming still v0.14.2. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 87aa0e8 commit e83b185

2 files changed

Lines changed: 182 additions & 47 deletions

File tree

omnimcode-apiproxy/README.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Substrate-rewriting reverse proxy for `api.anthropic.com`. Compresses large content blocks in the LLM's context window by replacing them with content-addressed `<omc:ref/>` markers, exposing a lossless expansion path via an injected tool.
44

5-
Status: **v0.14.0-alpha**proof of concept. Measured 6.64× compression on a single 6.8 KB content block in smoke test. Known sharp edges below.
5+
Status: **v0.14.1**request rewriting + transparent expand-tool resolution. Measured 6.64× wire-bandwidth compression on a single 6.8 KB content block; expand-tool round-trips are invisible to the client. Streaming + tool_use-block content + image content are still v0.14.2+ work.
66

77
## What it does
88

@@ -52,13 +52,14 @@ Smoke test against a mock upstream:
5252

5353
Real-world LLM-token savings depend on how often the LLM resists calling `omc_proxy_expand_ref`. The tool's description tells the LLM to only expand when the preview isn't enough; in practice this should hold ~70-90% of the time on long contexts where most prior turns aren't load-bearing for the current response.
5454

55-
## Known limitations (v0.14.0-alpha)
55+
## Known limitations (v0.14.1)
5656

57-
1. **No streaming.** Requests with `"stream": true` pass through unchanged (no SSE rewriting yet).
58-
2. **The injected `omc_proxy_expand_ref` tool is not yet served by the proxy.** If the LLM actually emits a `tool_use` block calling it, the response flows back through Claude Code, which doesn't know the tool and will error. This means: in this alpha, the proxy works best in a "fire and forget" mode where the LLM responds without expanding markers. A v0.14.1 follow-up will catch tool_use for `omc_proxy_expand_ref` in the response stream, execute it locally from the cache, and inject the tool_result before returning to the client.
59-
3. **No image / tool_use / citation block rewriting.** Only `text` blocks and the `content` field of `tool_result` blocks (string or text-array form) are rewritten.
60-
4. **Response is forwarded unmodified.** Cache only fills on the request side, so the savings kick in on subsequent turns where prior big content reappears in conversation history.
57+
1. **No streaming.** Requests with `"stream": true` pass through unchanged (no SSE rewriting yet — v0.14.2 work).
58+
2. **Mixed tool_use passes through unchanged.** When the LLM emits the expand call alongside another tool call in the same response, the proxy doesn't intercept — it forwards the full response to the client (which will see the unknown expand tool). The auto-resolution only triggers when expand is the sole tool_use.
59+
3. **No image / `tool_use`-block / citation block rewriting.** Only `text` blocks and the `content` field of `tool_result` blocks (string or text-array form) are rewritten.
60+
4. **Response body is not cached for next-turn rewriting.** Cache only fills on the request side, so the savings kick in on subsequent turns where prior big content reappears in conversation history. A v0.15 follow-up will also index large assistant `text` blocks.
6161
5. **No batching API support.** `/v1/messages/batches` falls through to the generic passthrough.
62+
6. **Expand-loop bound at 8 rounds.** If the LLM keeps requesting expansion, the proxy gives up and returns 502 — protects against runaway tool-loop costs.
6263

6364
## Threat model
6465

@@ -84,7 +85,8 @@ The proxy is a thin axum HTTP server. State lives in the existing OMC MemoryStor
8485

8586
## Roadmap
8687

87-
- **v0.14.1**: catch `omc_proxy_expand_ref` tool_use in responses, execute locally
88+
- ~~**v0.14.1**: catch `omc_proxy_expand_ref` tool_use in responses, execute locally~~ ✅ shipped
8889
- **v0.14.2**: streaming SSE response rewriting
89-
- **v0.15.0**: tool_use / citation / image content support; batching API
90+
- **v0.14.3**: handle mixed tool_use (expand + other in same assistant turn)
91+
- **v0.15.0**: tool_use / citation / image content support; batching API; response-side caching for next-turn rewrites
9092
- **v0.16.0**: cache namespace per-conversation (use `x-conversation-id` or similar) so concurrent sessions don't collide

omnimcode-apiproxy/src/main.rs

Lines changed: 172 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,11 @@ async fn main() -> Result<()> {
125125
Ok(())
126126
}
127127

128-
/// Rewrite-and-forward the /v1/messages POST.
128+
/// Rewrite-and-forward the /v1/messages POST. After receiving the upstream
129+
/// response, if the assistant emitted a sole tool_use for
130+
/// `omc_proxy_expand_ref`, the proxy resolves it locally from the cache and
131+
/// issues a follow-up upstream request — the client never sees the
132+
/// expand-tool round-trip. Mixed tool_use (expand + other) passes through.
129133
async fn handle_messages(State(state): State<AppState>, req: Request) -> Response {
130134
let (parts, body) = req.into_parts();
131135
let body_bytes = match axum::body::to_bytes(body, usize::MAX).await {
@@ -134,20 +138,17 @@ async fn handle_messages(State(state): State<AppState>, req: Request) -> Respons
134138
&format!("read request body: {}", e)),
135139
};
136140

137-
// Streaming requests bypass the rewriter for now — handling SSE responses
138-
// requires per-event chunked rewriting which is v0.14.1+ work.
139141
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+
}
140146

141-
let rewritten = if is_streaming {
142-
debug!("streaming request — passing through without rewrite");
143-
body_bytes.clone()
144-
} else {
145-
match rewrite_request_body(&body_bytes, &state) {
146-
Ok(b) => b,
147-
Err(e) => {
148-
warn!("rewrite failed, passing original through: {}", e);
149-
body_bytes.clone()
150-
}
147+
let rewritten = match rewrite_request_body(&body_bytes, &state) {
148+
Ok(b) => b,
149+
Err(e) => {
150+
warn!("rewrite failed, passing original through: {}", e);
151+
body_bytes.clone()
151152
}
152153
};
153154

@@ -157,7 +158,140 @@ async fn handle_messages(State(state): State<AppState>, req: Request) -> Respons
157158
body_bytes.len(), rewritten.len(), -saved);
158159
}
159160

160-
forward_to_upstream(&state, &parts.headers, rewritten).await
161+
handle_with_expand_loop(&state, &parts.headers, rewritten).await
162+
}
163+
164+
/// Upstream call + expand-tool auto-resolution loop. If the upstream's
165+
/// response contains a sole `tool_use` for `omc_proxy_expand_ref`, look
166+
/// up the hash in the cache, build a follow-up request with the
167+
/// tool_result synthetically appended, and re-call upstream. Bounded to
168+
/// MAX_EXPAND_ROUNDS to prevent runaway loops if the LLM keeps asking
169+
/// to expand.
170+
async fn handle_with_expand_loop(
171+
state: &AppState, headers: &HeaderMap, initial_body: Bytes,
172+
) -> Response {
173+
const MAX_EXPAND_ROUNDS: usize = 8;
174+
let mut current_body = initial_body;
175+
for round in 0..MAX_EXPAND_ROUNDS {
176+
// Forward to upstream
177+
let url = format!("{}/v1/messages",
178+
state.upstream.trim_end_matches('/'));
179+
let mut req = state.http.post(&url).body(current_body.to_vec());
180+
for (k, v) in headers.iter() {
181+
if k != "host" && k != "content-length" { req = req.header(k, v); }
182+
}
183+
let upstream_resp = match req.send().await {
184+
Ok(r) => r,
185+
Err(e) => return error_response(StatusCode::BAD_GATEWAY,
186+
&format!("upstream: {}", e)),
187+
};
188+
let status = upstream_resp.status();
189+
let resp_headers = upstream_resp.headers().clone();
190+
let resp_body = match upstream_resp.bytes().await {
191+
Ok(b) => b,
192+
Err(e) => return error_response(StatusCode::BAD_GATEWAY,
193+
&format!("read upstream: {}", e)),
194+
};
195+
// Only intercept successful, parseable responses
196+
if !status.is_success() {
197+
return rebuild_response(status, &resp_headers, resp_body);
198+
}
199+
let resp_json: Value = match serde_json::from_slice(&resp_body) {
200+
Ok(v) => v,
201+
Err(_) => return rebuild_response(status, &resp_headers, resp_body),
202+
};
203+
204+
// Look for an exclusive expand tool_use
205+
let expand_calls = collect_sole_expand_tool_uses(&resp_json);
206+
if expand_calls.is_empty() {
207+
return rebuild_response(status, &resp_headers, resp_body);
208+
}
209+
info!("round {}: auto-resolving {} expand tool_use(s)",
210+
round + 1, expand_calls.len());
211+
212+
// Build follow-up request: previous messages + assistant response
213+
// (rewritten through marker logic) + new user turn with tool_result
214+
let mut next_req: Value = match serde_json::from_slice(&current_body) {
215+
Ok(v) => v,
216+
Err(_) => return rebuild_response(status, &resp_headers, resp_body),
217+
};
218+
let messages = next_req.get_mut("messages")
219+
.and_then(Value::as_array_mut);
220+
let Some(messages) = messages else {
221+
return rebuild_response(status, &resp_headers, resp_body);
222+
};
223+
// Append the assistant turn (the upstream's response) verbatim
224+
if let Some(asst_content) = resp_json.get("content").cloned() {
225+
messages.push(json!({"role": "assistant", "content": asst_content}));
226+
}
227+
// Append a user turn with one tool_result per expand call
228+
let mut tool_results: Vec<Value> = Vec::new();
229+
for (tool_use_id, hash_str) in &expand_calls {
230+
let body_text = lookup_expand(&hash_str, &state).unwrap_or_else(|e|
231+
format!("[apiproxy: expand cache miss for {}: {}]", hash_str, e));
232+
tool_results.push(json!({
233+
"type": "tool_result",
234+
"tool_use_id": tool_use_id,
235+
"content": body_text,
236+
}));
237+
}
238+
messages.push(json!({"role": "user", "content": tool_results}));
239+
240+
current_body = Bytes::from(serde_json::to_vec(&next_req).unwrap());
241+
}
242+
warn!("expand loop exceeded {} rounds, returning error", MAX_EXPAND_ROUNDS);
243+
error_response(StatusCode::BAD_GATEWAY,
244+
"apiproxy: expand loop limit exceeded")
245+
}
246+
247+
/// If the response's `content` array contains exactly one tool_use AND it
248+
/// is for `omc_proxy_expand_ref`, return its (id, hash_str). Returning
249+
/// multiple results means there were multiple expand calls in a row, which
250+
/// also auto-resolves. Returns empty Vec for mixed tool_use (skip
251+
/// interception, let client handle) or no tool_use at all.
252+
fn collect_sole_expand_tool_uses(resp: &Value) -> Vec<(String, String)> {
253+
let Some(content) = resp.get("content").and_then(Value::as_array) else {
254+
return vec![];
255+
};
256+
let mut expand = Vec::new();
257+
let mut has_other_tool_use = false;
258+
for block in content {
259+
if block.get("type").and_then(Value::as_str) == Some("tool_use") {
260+
let name = block.get("name").and_then(Value::as_str).unwrap_or("");
261+
if name == EXPAND_TOOL_NAME {
262+
let id = block.get("id").and_then(Value::as_str)
263+
.unwrap_or("").to_string();
264+
let hash = block.get("input")
265+
.and_then(|i| i.get("hash_str"))
266+
.and_then(Value::as_str).unwrap_or("").to_string();
267+
if !id.is_empty() && !hash.is_empty() {
268+
expand.push((id, hash));
269+
}
270+
} else {
271+
has_other_tool_use = true;
272+
}
273+
}
274+
}
275+
if has_other_tool_use { vec![] } else { expand }
276+
}
277+
278+
fn lookup_expand(hash_str: &str, state: &AppState) -> Result<String> {
279+
let hash: i64 = hash_str.parse()
280+
.map_err(|e| anyhow::anyhow!("hash_str parse: {}", e))?;
281+
let body = state.store.recall(Some(PROXY_CACHE_NAMESPACE), hash)
282+
.map_err(anyhow::Error::msg)?
283+
.ok_or_else(|| anyhow::anyhow!("not in cache"))?;
284+
Ok(body)
285+
}
286+
287+
fn rebuild_response(status: StatusCode, headers: &HeaderMap, body: Bytes) -> Response {
288+
let mut resp = Response::builder().status(status);
289+
for (k, v) in headers.iter() {
290+
if k != "transfer-encoding" && k != "connection" && k != "content-length" {
291+
resp = resp.header(k, v);
292+
}
293+
}
294+
resp.body(axum::body::Body::from(body)).unwrap()
161295
}
162296

163297
/// Forward anything else (model list, batches, etc.) unmodified.
@@ -178,44 +312,43 @@ async fn passthrough(State(state): State<AppState>, req: Request) -> Response {
178312
}
179313
}
180314
match req.send().await {
181-
Ok(r) => relay(r).await,
182-
Err(e) => error_response(StatusCode::BAD_GATEWAY, &format!("upstream: {}", e)),
315+
Ok(r) => {
316+
let status = r.status();
317+
let h = r.headers().clone();
318+
match r.bytes().await {
319+
Ok(b) => rebuild_response(status, &h, b),
320+
Err(e) => error_response(StatusCode::BAD_GATEWAY,
321+
&format!("read upstream: {}", e)),
322+
}
323+
}
324+
Err(e) => error_response(StatusCode::BAD_GATEWAY,
325+
&format!("upstream: {}", e)),
183326
}
184327
}
185328

329+
/// Used by the streaming-passthrough path in handle_messages and by the
330+
/// catch-all passthrough route. Bytes-in, bytes-out, no rewriting.
186331
async fn forward_to_upstream(
187332
state: &AppState, headers: &HeaderMap, body: Bytes,
188333
) -> Response {
189334
let url = format!("{}/v1/messages", state.upstream.trim_end_matches('/'));
190335
let mut req = state.http.post(&url).body(body.to_vec());
191336
for (k, v) in headers.iter() {
192-
if k != "host" && k != "content-length" {
193-
req = req.header(k, v);
194-
}
337+
if k != "host" && k != "content-length" { req = req.header(k, v); }
195338
}
196339
match req.send().await {
197-
Ok(r) => relay(r).await,
198-
Err(e) => error_response(StatusCode::BAD_GATEWAY, &format!("upstream: {}", e)),
199-
}
200-
}
201-
202-
async fn relay(upstream: reqwest::Response) -> Response {
203-
let status = upstream.status();
204-
let headers = upstream.headers().clone();
205-
let body = match upstream.bytes().await {
206-
Ok(b) => b,
207-
Err(e) => return error_response(StatusCode::BAD_GATEWAY,
208-
&format!("read upstream: {}", e)),
209-
};
210-
let mut resp = Response::builder().status(status);
211-
for (k, v) in headers.iter() {
212-
// Skip hop-by-hop headers; reqwest already drops Transfer-Encoding
213-
// but Content-Length needs to track our (possibly rewritten) body.
214-
if k != "transfer-encoding" && k != "connection" {
215-
resp = resp.header(k, v);
340+
Ok(r) => {
341+
let status = r.status();
342+
let h = r.headers().clone();
343+
match r.bytes().await {
344+
Ok(b) => rebuild_response(status, &h, b),
345+
Err(e) => error_response(StatusCode::BAD_GATEWAY,
346+
&format!("read upstream: {}", e)),
347+
}
216348
}
349+
Err(e) => error_response(StatusCode::BAD_GATEWAY,
350+
&format!("upstream: {}", e)),
217351
}
218-
resp.body(axum::body::Body::from(body)).unwrap()
219352
}
220353

221354
fn error_response(code: StatusCode, msg: &str) -> Response {

0 commit comments

Comments
 (0)