@@ -95,6 +95,98 @@ def _cap(s: Optional[str], limit: int) -> str:
9595 return s [:limit ]
9696 return s
9797
98+ # --- Tool-call arg-dialect normalization (#85) -------------------------------
99+ # Some open-weight backends intermittently fail to decode their own tool-call
100+ # argument encoding, leaving raw markup inside the structured `arguments` string
101+ # that an otherwise well-formed native `tool_calls` entry hands back:
102+ # GLM (z-ai/glm-5.2): <arg_key>NAME</arg_key> <arg_value>VALUE</arg_value>
103+ # qwen / hermes: <parameter=NAME>VALUE</parameter>
104+ # The intended payload is intact *inside* the wrapper, so this is a
105+ # serialization/parse gap, not lost data (#85; qwen precedent geo-agent#276).
106+ # We repair it here — before the response is returned or logged — so no
107+ # downstream consumer (client or log) ever sees the dialect. Fully defensive:
108+ # any parse failure leaves the value untouched.
109+ _ARG_DIALECT_MARKERS = ("<arg_key>" , "<arg_value>" , "<parameter=" )
110+ _GLM_ARG_PAIR_RE = re .compile (r"<arg_key>(.*?)</arg_key>\s*<arg_value>(.*?)(?:</arg_value>|$)" , re .DOTALL )
111+ _QWEN_ARG_PAIR_RE = re .compile (r"<parameter=(.*?)>(.*?)(?:</parameter>|$)" , re .DOTALL )
112+ _GLM_ARG_VALUE_RE = re .compile (r"<arg_value>(.*?)(?:</arg_value>|$)" , re .DOTALL )
113+ _QWEN_ARG_VALUE_RE = re .compile (r"<parameter=[^>]*>(.*?)(?:</parameter>|$)" , re .DOTALL )
114+
115+
116+ def _coerce_json (s : str ):
117+ """Parse `s` as JSON when it is structured data, else return it stripped.
118+ Lets an unwrapped `{...}`/`[...]`/number value come back structured while a
119+ bare scalar string stays a string."""
120+ s = s .strip ()
121+ try :
122+ return json .loads (s )
123+ except Exception :
124+ return s
125+
126+
127+ def _unwrap_dialect_value (val ):
128+ """If a single tool-call argument *value* carries leaked arg-dialect markup,
129+ extract the real payload from inside the <arg_value>/<parameter=…> wrapper.
130+ Returns (value, changed)."""
131+ if not isinstance (val , str ) or not any (m in val for m in _ARG_DIALECT_MARKERS ):
132+ return val , False
133+ m = _GLM_ARG_VALUE_RE .search (val ) or _QWEN_ARG_VALUE_RE .search (val )
134+ if not m :
135+ return val , False
136+ return _coerce_json (m .group (1 )), True
137+
138+
139+ def _normalize_tool_call_arguments (arguments ):
140+ """Strip leaked arg-dialect markup from a tool call's `arguments` string.
141+ Handles both the value-level leak (dialect inside one value of an otherwise
142+ valid JSON object, the #85 symptom) and the whole-string leak (the entire
143+ `arguments` is raw dialect). Returns (arguments_string, changed)."""
144+ if not isinstance (arguments , str ) or not any (m in arguments for m in _ARG_DIALECT_MARKERS ):
145+ return arguments , False
146+ # Case 1: arguments is valid JSON; dialect leaked into individual values.
147+ try :
148+ obj = json .loads (arguments )
149+ except Exception :
150+ obj = None
151+ if isinstance (obj , dict ):
152+ changed = False
153+ for k , v in list (obj .items ()):
154+ new_v , ch = _unwrap_dialect_value (v )
155+ if ch :
156+ obj [k ] = new_v
157+ changed = True
158+ return (json .dumps (obj ), True ) if changed else (arguments , False )
159+ # Case 2: the whole arguments string is raw dialect — rebuild the object by
160+ # pairing each key tag with the value tag that follows it.
161+ pairs = _GLM_ARG_PAIR_RE .findall (arguments ) or _QWEN_ARG_PAIR_RE .findall (arguments )
162+ if pairs :
163+ return json .dumps ({k .strip (): _coerce_json (v ) for k , v in pairs }), True
164+ return arguments , False
165+
166+
167+ def _normalize_response_tool_calls (result ) -> int :
168+ """Repair leaked tool-call arg dialect (#85) in an upstream response, in
169+ place. Returns the number of tool-call `arguments` repaired. Fully
170+ defensive: any error leaves `result` untouched and returns 0 —
171+ normalization must never corrupt a response or break serving."""
172+ repaired = 0
173+ try :
174+ for choice in result .get ("choices" ) or []:
175+ message = (choice or {}).get ("message" ) or {}
176+ for tc in message .get ("tool_calls" ) or []:
177+ fn = (tc or {}).get ("function" )
178+ if not isinstance (fn , dict ):
179+ continue
180+ new_args , changed = _normalize_tool_call_arguments (fn .get ("arguments" ))
181+ if changed :
182+ fn ["arguments" ] = new_args
183+ repaired += 1
184+ except Exception as e : # pragma: no cover - defensive
185+ print (f"⚠️ tool-call dialect normalization skipped: "
186+ f"{ type (e ).__name__ } : { e } " , flush = True )
187+ return 0
188+ return repaired
189+
98190# --- Credential scrubbing ----------------------------------------------------
99191# Credentials reach the logs because the geo-agent `query` MCP tool accepts
100192# s3_key/s3_secret in its arguments, which flow through `tool_calls`, tool
@@ -405,7 +497,7 @@ def log_request(provider: str, model: str, messages: List[Dict], tools_count: in
405497 _emit (log_entry )
406498
407499@_never_raises
408- def log_response (provider : str , model : str , response_data : dict , latency_ms : int , error : str = None , origin : str = None , request_id : str = None , session_id : str = None , client : str = None , upstream_headers : dict = None ):
500+ def log_response (provider : str , model : str , response_data : dict , latency_ms : int , error : str = None , origin : str = None , request_id : str = None , session_id : str = None , client : str = None , upstream_headers : dict = None , dialect_repaired : int = 0 ):
409501 """Log response in structured JSON format"""
410502 log_entry = {
411503 "timestamp" : datetime .utcnow ().isoformat () + "Z" ,
@@ -453,6 +545,12 @@ def log_response(provider: str, model: str, response_data: dict, latency_ms: int
453545 for tc in message ["tool_calls" ]
454546 ]
455547
548+ # How many tool-call arguments were repaired of leaked arg dialect (#85).
549+ # Kept queryable so the leak rate stays measurable even though the markup
550+ # itself no longer reaches the logs.
551+ if dialect_repaired :
552+ log_entry ["tool_call_dialect_repaired" ] = dialect_repaired
553+
456554 # Extract token usage if available
457555 if "usage" in response_data :
458556 log_entry ["tokens" ] = response_data ["usage" ]
@@ -595,10 +693,18 @@ async def proxy_chat(request: ChatRequest, http_request: Request, authorization:
595693 response = await http_client .post (endpoint , json = payload , headers = headers )
596694 response .raise_for_status ()
597695 result = response .json ()
598-
696+
697+ # Repair leaked tool-call arg dialect (#85) before returning OR
698+ # logging, so neither the client nor the log ever sees the markup.
699+ dialect_repaired = _normalize_response_tool_calls (result )
700+ if dialect_repaired :
701+ print (f"🧹 Normalized { dialect_repaired } tool-call argument(s) with "
702+ f"leaked arg dialect (model={ request .model } , request_id={ request_id } )" ,
703+ flush = True )
704+
599705 # Log successful response
600706 latency_ms = int ((time .time () - start_time ) * 1000 )
601- log_response (provider_name , request .model , result , latency_ms , origin = origin , request_id = request_id , session_id = session_id , client = client )
707+ log_response (provider_name , request .model , result , latency_ms , origin = origin , request_id = request_id , session_id = session_id , client = client , dialect_repaired = dialect_repaired )
602708
603709 return result
604710
0 commit comments