Skip to content

Commit a9eeea6

Browse files
v0.14.5 apiproxy: integration tests + tool-definitions rewriting
Two complementary changes: 1. Test suite (7 tests, all green): - tool_use_input_preserves_keys: regression guard for the v0.14.4 hotfix. Explicitly asserts no `_omc_compressed_input_marker` key can ever be reintroduced and that the real input keys (file_path, content, etc.) survive the rewrite. - last_user_message_never_rewritten: the user's current intent stays uncompressed. - marker_round_trip_lossless: multi-byte / control-char text passes round-trip via the cache. - small_blocks_untouched: < threshold passes through. - system_prompt_preserves_cache_control: critical for Anthropic's 90% prompt-cache discount to keep firing. - tool_definitions_compressed_but_expand_tool_preserved: new in v0.14.5b. Long tool descriptions get markers, but the injected omc_proxy_expand_ref's own description stays plain. - five_turn_conversation_no_drift: simulates 5 Claude Code-shaped turns, asserts no semantic drift across turns. 2. Tool-definitions rewriting: - Walks request.tools[].description (top-level string) and request.tools[].input_schema (recursive walk for long string values inside the JSON Schema). - All preserved structure: tool name, input_schema.type, property keys, enum array shapes — only the long string VALUES become markers. - Done BEFORE injecting our own expand tool, so we don't accidentally compress the freshly-added omc_proxy_expand_ref. - Adds bytes_saved_tool_definitions to RewriteStats and /_stats. Expected savings on Claude Code traffic: 30-60 KB/turn from tool defs alone. Combined with v0.14.3's 58% live measurement, this should push the per-turn ratio toward 65-70%. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e4467e0 commit a9eeea6

1 file changed

Lines changed: 317 additions & 7 deletions

File tree

omnimcode-apiproxy/src/main.rs

Lines changed: 317 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ struct RewriteStats {
8686
bytes_saved_tool_result: u64,
8787
bytes_saved_system: u64,
8888
bytes_saved_tool_use_input: u64,
89+
bytes_saved_tool_definitions: u64,
8990
}
9091

9192
#[derive(Clone)]
@@ -169,11 +170,12 @@ async fn handle_messages(State(state): State<AppState>, req: Request) -> Respons
169170
Ok((b, outcome)) => {
170171
if outcome.any() {
171172
info!("rewrote request: {} → {} bytes ({:+} bytes saved across {} blocks) | \
172-
sys={}B msg={}B tool_result={}B tool_use_input={}B",
173+
sys={}B msg={}B tool_result={}B tool_use_input={}B tool_defs={}B",
173174
body_bytes.len(), b.len(), -((body_bytes.len() - b.len()) as i64),
174175
outcome.rewritten_count,
175176
outcome.bytes_system, outcome.bytes_messages_text,
176-
outcome.bytes_tool_result, outcome.bytes_tool_use_input);
177+
outcome.bytes_tool_result, outcome.bytes_tool_use_input,
178+
outcome.bytes_tool_definitions);
177179
}
178180
// Update cumulative stats
179181
{
@@ -186,6 +188,7 @@ async fn handle_messages(State(state): State<AppState>, req: Request) -> Respons
186188
s.bytes_saved_tool_result += outcome.bytes_tool_result as u64;
187189
s.bytes_saved_system += outcome.bytes_system as u64;
188190
s.bytes_saved_tool_use_input += outcome.bytes_tool_use_input as u64;
191+
s.bytes_saved_tool_definitions += outcome.bytes_tool_definitions as u64;
189192
}
190193
b
191194
}
@@ -421,13 +424,10 @@ struct RewriteOutcome {
421424
bytes_tool_result: usize,
422425
bytes_system: usize,
423426
bytes_tool_use_input: usize,
427+
bytes_tool_definitions: usize,
424428
}
425429

426430
impl RewriteOutcome {
427-
fn total_saved(&self) -> usize {
428-
self.bytes_messages_text + self.bytes_tool_result
429-
+ self.bytes_system + self.bytes_tool_use_input
430-
}
431431
fn any(&self) -> bool { self.rewritten_count > 0 }
432432
}
433433

@@ -438,7 +438,8 @@ async fn stats_endpoint(State(state): State<AppState>) -> Response {
438438
s.bytes_in as f64 / s.bytes_out as f64
439439
} else { 0.0 };
440440
let total_saved = s.bytes_saved_messages + s.bytes_saved_tool_result
441-
+ s.bytes_saved_system + s.bytes_saved_tool_use_input;
441+
+ s.bytes_saved_system + s.bytes_saved_tool_use_input
442+
+ s.bytes_saved_tool_definitions;
442443
let json = serde_json::to_string_pretty(&serde_json::json!({
443444
"requests_processed": s.requests,
444445
"bytes_in_total": s.bytes_in,
@@ -451,6 +452,7 @@ async fn stats_endpoint(State(state): State<AppState>) -> Response {
451452
"tool_result": s.bytes_saved_tool_result,
452453
"system_prompt": s.bytes_saved_system,
453454
"tool_use_input": s.bytes_saved_tool_use_input,
455+
"tool_definitions": s.bytes_saved_tool_definitions,
454456
}
455457
})).unwrap();
456458
(StatusCode::OK,
@@ -564,13 +566,67 @@ fn rewrite_request_body(body: &[u8], state: &AppState) -> Result<(Bytes, Rewrite
564566
}
565567
}
566568

569+
// ---- tool definitions (rewrite BEFORE injecting our expand tool,
570+
// so we don't compress + re-emit the expand tool we just added) ----
571+
if let Some(tools) = v.get_mut("tools").and_then(Value::as_array_mut) {
572+
for tool in tools.iter_mut() {
573+
if let Some(desc) = tool.get_mut("description") {
574+
if let Value::String(s) = desc {
575+
if s.len() >= state.rewrite_threshold {
576+
if let Ok(marker) = make_marker(s, state) {
577+
out.bytes_tool_definitions += s.len();
578+
out.rewritten_count += 1;
579+
*desc = Value::String(marker);
580+
}
581+
}
582+
}
583+
}
584+
// input_schema is a JSON Schema dict; walk it for big strings in
585+
// property descriptions, enums, etc. — preserves schema structure.
586+
if let Some(schema) = tool.get_mut("input_schema") {
587+
let before_count = out.rewritten_count;
588+
rewrite_schema_strings(schema, state, &mut out);
589+
if out.rewritten_count > before_count {
590+
// already counted via rewrite_schema_strings into the
591+
// bytes_tool_definitions field
592+
}
593+
}
594+
}
595+
}
596+
567597
if out.any() {
568598
inject_expand_tool(&mut v);
569599
}
570600
let bytes = Bytes::from(serde_json::to_vec(&v)?);
571601
Ok((bytes, out))
572602
}
573603

604+
/// Walk a JSON-Schema-shaped tool input_schema and marker-rewrite any large
605+
/// string VALUES while preserving structure. Schema dicts contain `description`
606+
/// fields, `enum` arrays of strings, and nested `properties` — all candidates.
607+
fn rewrite_schema_strings(
608+
val: &mut Value, state: &AppState, out: &mut RewriteOutcome,
609+
) {
610+
match val {
611+
Value::String(s) => {
612+
if s.len() >= state.rewrite_threshold {
613+
if let Ok(marker) = make_marker(s, state) {
614+
out.bytes_tool_definitions += s.len();
615+
out.rewritten_count += 1;
616+
*val = Value::String(marker);
617+
}
618+
}
619+
}
620+
Value::Object(map) => {
621+
for (_k, v) in map.iter_mut() { rewrite_schema_strings(v, state, out); }
622+
}
623+
Value::Array(arr) => {
624+
for v in arr.iter_mut() { rewrite_schema_strings(v, state, out); }
625+
}
626+
_ => {}
627+
}
628+
}
629+
574630
/// v0.14.4 — walk a JSON value and replace any large STRING values in place,
575631
/// preserving all key names so the LLM doesn't see (and copy) a fake field
576632
/// name when generating new tool calls. Used for `tool_use.input`.
@@ -688,3 +744,257 @@ fn inject_expand_tool(req: &mut Value) {
688744
}
689745
}
690746
}
747+
748+
#[cfg(test)]
749+
mod tests {
750+
use super::*;
751+
use serde_json::json;
752+
753+
/// Build an AppState pointing at a tempdir-scoped MemoryStore so tests
754+
/// don't share cache state with each other or the real user store.
755+
fn test_state(threshold: usize) -> AppState {
756+
let tmpdir = std::env::temp_dir().join(format!(
757+
"omc-apiproxy-test-{}-{}",
758+
std::process::id(),
759+
std::time::SystemTime::now()
760+
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
761+
));
762+
std::fs::create_dir_all(&tmpdir).unwrap();
763+
std::env::set_var("OMC_MEMORY_ROOT", &tmpdir);
764+
AppState {
765+
upstream: "http://127.0.0.1:0".into(),
766+
rewrite_threshold: threshold,
767+
preview_bytes: 80,
768+
http: reqwest::Client::new(),
769+
store: Arc::new(MemoryStore::from_env()),
770+
stats: Arc::new(std::sync::Mutex::new(RewriteStats::default())),
771+
}
772+
}
773+
774+
/// v0.14.4 regression: tool_use.input keys MUST be preserved. The whole
775+
/// point of the v0.14.4 hotfix was to stop replacing the input dict with
776+
/// `{"_omc_compressed_input_marker": "..."}` which the LLM then learned
777+
/// to copy. Verify the rewritten input still has its original keys.
778+
#[test]
779+
fn tool_use_input_preserves_keys() {
780+
let state = test_state(256);
781+
let big = "X".repeat(1000);
782+
let req = json!({
783+
"model": "test", "max_tokens": 10,
784+
"messages": [
785+
{"role": "assistant", "content": [
786+
{"type": "tool_use", "id": "tu_1", "name": "Write",
787+
"input": {"file_path": "/tmp/x.txt", "content": big}}
788+
]},
789+
{"role": "user", "content": "summarize"}
790+
]
791+
});
792+
let body = serde_json::to_vec(&req).unwrap();
793+
let (out, outcome) = rewrite_request_body(&body, &state).unwrap();
794+
assert!(outcome.rewritten_count > 0, "expected at least 1 rewrite");
795+
let v: Value = serde_json::from_slice(&out).unwrap();
796+
let input = &v["messages"][0]["content"][0]["input"];
797+
// Real keys must still be there. NO `_omc_compressed_input_marker` allowed.
798+
assert!(input.get("file_path").is_some(), "lost file_path key");
799+
assert!(input.get("content").is_some(), "lost content key");
800+
assert!(input.get("_omc_compressed_input_marker").is_none(),
801+
"v0.14.3 schema-poisoning regression — fake key reintroduced");
802+
// The big string was marker-replaced, file_path is small enough to stay.
803+
let content_str = input["content"].as_str().unwrap();
804+
assert!(content_str.starts_with("<omc:ref"),
805+
"expected content to become an <omc:ref/> marker, got: {}",
806+
&content_str[..50.min(content_str.len())]);
807+
assert_eq!(input["file_path"].as_str().unwrap(), "/tmp/x.txt",
808+
"small file_path should remain untouched");
809+
}
810+
811+
/// The LAST user message is the user's current intent — it must NEVER
812+
/// be marker-replaced, or the LLM would have to round-trip just to know
813+
/// what was asked.
814+
#[test]
815+
fn last_user_message_never_rewritten() {
816+
let state = test_state(256);
817+
let big_question = "Please analyze: ".to_string() + &"Q".repeat(1000);
818+
let req = json!({
819+
"model": "test", "max_tokens": 10,
820+
"messages": [
821+
{"role": "user", "content": "old turn"},
822+
{"role": "assistant", "content": "old reply"},
823+
{"role": "user", "content": big_question.clone()}
824+
]
825+
});
826+
let body = serde_json::to_vec(&req).unwrap();
827+
let (out, _) = rewrite_request_body(&body, &state).unwrap();
828+
let v: Value = serde_json::from_slice(&out).unwrap();
829+
let last = v["messages"][2]["content"].as_str().unwrap();
830+
assert_eq!(last, big_question,
831+
"last user message must be byte-identical to input");
832+
}
833+
834+
/// Marker round-trip: any text we compress must come back IDENTICAL via
835+
/// the cache lookup path that the expand-tool uses.
836+
#[test]
837+
fn marker_round_trip_lossless() {
838+
let state = test_state(256);
839+
let original = "abc🎯 ñ é 漢字\nline2\n\tindented\n".repeat(50); // multi-byte, control chars
840+
let marker = make_marker(&original, &state).unwrap();
841+
// Extract hash_str from the marker
842+
let hash_attr = marker.split("hash_str=\"").nth(1).unwrap();
843+
let hash_str = hash_attr.split('"').next().unwrap();
844+
let hash: i64 = hash_str.parse().unwrap();
845+
let recovered = state.store.recall(Some(PROXY_CACHE_NAMESPACE), hash)
846+
.unwrap().expect("must be in cache");
847+
assert_eq!(recovered, original, "byte-identical round-trip required");
848+
}
849+
850+
/// Small blocks under threshold pass through unmodified.
851+
#[test]
852+
fn small_blocks_untouched() {
853+
let state = test_state(1024);
854+
let small = "short content";
855+
let req = json!({
856+
"model": "test", "max_tokens": 10,
857+
"messages": [
858+
{"role": "assistant", "content": small},
859+
{"role": "user", "content": "ask"}
860+
]
861+
});
862+
let body = serde_json::to_vec(&req).unwrap();
863+
let (out, outcome) = rewrite_request_body(&body, &state).unwrap();
864+
assert_eq!(outcome.rewritten_count, 0);
865+
let v: Value = serde_json::from_slice(&out).unwrap();
866+
assert_eq!(v["messages"][0]["content"].as_str().unwrap(), small);
867+
}
868+
869+
/// System prompt with cache_control hints — the hint MUST survive the rewrite
870+
/// so Anthropic's 90% prompt-cache discount keeps working.
871+
#[test]
872+
fn system_prompt_preserves_cache_control() {
873+
let state = test_state(256);
874+
let big_sys = "You are an expert. ".repeat(100);
875+
let req = json!({
876+
"model": "test", "max_tokens": 10,
877+
"system": [
878+
{"type": "text", "text": big_sys,
879+
"cache_control": {"type": "ephemeral"}}
880+
],
881+
"messages": [{"role": "user", "content": "hi"}]
882+
});
883+
let body = serde_json::to_vec(&req).unwrap();
884+
let (out, outcome) = rewrite_request_body(&body, &state).unwrap();
885+
assert!(outcome.bytes_system > 0, "system prompt should have been compressed");
886+
let v: Value = serde_json::from_slice(&out).unwrap();
887+
let cc = &v["system"][0]["cache_control"];
888+
assert_eq!(cc["type"].as_str().unwrap(), "ephemeral",
889+
"cache_control hint lost — would break Anthropic prompt-cache");
890+
}
891+
892+
/// v0.14.5b: tool definitions (`tools[].description` + nested input_schema
893+
/// strings) get compressed. The injected `omc_proxy_expand_ref` tool MUST
894+
/// not itself be compressed (it was just added by us in this same pass).
895+
#[test]
896+
fn tool_definitions_compressed_but_expand_tool_preserved() {
897+
let state = test_state(256);
898+
let long_desc = "This tool does X. It accepts Y. Returns Z. ".repeat(50);
899+
let req = json!({
900+
"model": "test", "max_tokens": 10,
901+
"messages": [{"role": "user", "content": "use the tool"}],
902+
"tools": [
903+
{
904+
"name": "BigTool",
905+
"description": long_desc.clone(),
906+
"input_schema": {
907+
"type": "object",
908+
"properties": {
909+
"arg": {
910+
"type": "string",
911+
"description": "A long arg description. ".repeat(50)
912+
}
913+
}
914+
}
915+
}
916+
]
917+
});
918+
let body = serde_json::to_vec(&req).unwrap();
919+
let (out, outcome) = rewrite_request_body(&body, &state).unwrap();
920+
assert!(outcome.bytes_tool_definitions > 0,
921+
"expected tool definition bytes to be compressed");
922+
let v: Value = serde_json::from_slice(&out).unwrap();
923+
let tools = v["tools"].as_array().unwrap();
924+
// Original tool still has its name + shape, but description is a marker
925+
let big = tools.iter().find(|t| t["name"] == "BigTool").unwrap();
926+
let desc = big["description"].as_str().unwrap();
927+
assert!(desc.starts_with("<omc:ref"),
928+
"expected description to be marker, got: {}", &desc[..50.min(desc.len())]);
929+
assert_eq!(big["input_schema"]["type"].as_str().unwrap(), "object",
930+
"schema structure must be preserved");
931+
// The injected expand tool MUST exist and MUST have its uncompressed
932+
// description (otherwise the LLM can't tell what it does).
933+
let expand = tools.iter().find(|t| t["name"] == EXPAND_TOOL_NAME)
934+
.expect("expand tool must be injected");
935+
let expand_desc = expand["description"].as_str().unwrap();
936+
assert!(!expand_desc.starts_with("<omc:ref"),
937+
"expand tool's own description must not be compressed");
938+
}
939+
940+
/// Multi-turn dogfood simulation: walk a conversation, verify each turn's
941+
/// rewrite preserves the LLM-emitted shape AND the markers expand cleanly
942+
/// to the original bytes via the cache.
943+
#[test]
944+
fn five_turn_conversation_no_drift() {
945+
let state = test_state(256);
946+
let mut messages: Vec<Value> = Vec::new();
947+
let mut originals: Vec<(i64, String)> = Vec::new();
948+
949+
for turn in 0..5 {
950+
// User turn
951+
messages.push(json!({
952+
"role": "user",
953+
"content": format!("turn {} ask", turn)
954+
}));
955+
// Build the request with this conversation so far
956+
let req = json!({
957+
"model": "test", "max_tokens": 10,
958+
"messages": messages.clone()
959+
});
960+
let body = serde_json::to_vec(&req).unwrap();
961+
let (out, _) = rewrite_request_body(&body, &state).unwrap();
962+
let v: Value = serde_json::from_slice(&out).unwrap();
963+
964+
// Assert last user message is uncompressed every turn
965+
let last_idx = v["messages"].as_array().unwrap().len() - 1;
966+
let last_text = v["messages"][last_idx]["content"].as_str().unwrap();
967+
assert_eq!(last_text, format!("turn {} ask", turn),
968+
"turn {}: last user msg got rewritten", turn);
969+
970+
// Now LLM emits an assistant reply with a big tool result
971+
let big_output = format!("LARGE OUTPUT FOR TURN {} ", turn).repeat(50);
972+
let h = state.store.store(PROXY_CACHE_NAMESPACE, &big_output).unwrap();
973+
originals.push((h, big_output.clone()));
974+
messages.push(json!({
975+
"role": "assistant",
976+
"content": [
977+
{"type": "tool_use", "id": format!("tu_{}", turn),
978+
"name": "Write", "input": {
979+
"file_path": format!("/tmp/{}.txt", turn),
980+
"content": big_output
981+
}}
982+
]
983+
}));
984+
messages.push(json!({
985+
"role": "user",
986+
"content": [
987+
{"type": "tool_result", "tool_use_id": format!("tu_{}", turn),
988+
"content": format!("wrote turn {}", turn)}
989+
]
990+
}));
991+
}
992+
993+
// After 5 turns, all stored originals must round-trip from cache.
994+
for (h, expected) in originals {
995+
let got = state.store.recall(Some(PROXY_CACHE_NAMESPACE), h)
996+
.unwrap().expect("must still be in cache");
997+
assert_eq!(got, expected);
998+
}
999+
}
1000+
}

0 commit comments

Comments
 (0)