@@ -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
426430impl 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🎯 ñ é 漢字\n line2\n \t indented\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