@@ -338,6 +338,179 @@ class MantisStreamingTruncationError(RuntimeError):
338338 pass
339339
340340
341+ class ContextBudgetExceededError (RuntimeError ):
342+ """Raised before dispatch when a request cannot possibly fit the model's context window.
343+
344+ Sending it anyway costs a full upload of the payload, a provider-side rejection, and --
345+ because the node retry layer cannot tell a deterministic overflow from a transient fault
346+ -- two more identical uploads of the same doomed request. The failure is deterministic in
347+ the request itself, so the only useful thing to do with it is refuse early and say which
348+ message was too big.
349+ """
350+
351+ def __init__ (self , message : str , estimated_tokens : int = 0 , limit : int = 0 ):
352+ super ().__init__ (message )
353+ self .estimated_tokens = estimated_tokens
354+ self .limit = limit
355+
356+
357+ # Exceptions whose outcome is fully determined by the request, so re-running the node with
358+ # the identical request can only reproduce them. ADK's node retry cannot distinguish these
359+ # from transient faults and will burn the full attempt budget on them.
360+ #
361+ # Matched by class name because several of these types live in third-party packages that may
362+ # not be importable in every deployment, and a missing import must not silently disable the
363+ # rule. Note this is intentionally NOT the same list as the log-suppression filter below:
364+ # retrying an error and reporting it are separate decisions, and a context overflow must stay
365+ # loudly visible precisely because the operator is the one who has to act on it.
366+ _NON_RETRYABLE_EXC_NAMES = (
367+ "BudgetExceededError" ,
368+ "LlmCallsLimitExceededError" ,
369+ "MantisAuthError" ,
370+ "ContextBudgetExceededError" ,
371+ "ContextWindowExceededError" ,
372+ )
373+
374+
375+ # Characters per token. Deliberately a constant rather than a real tokenizer: tokenizing a
376+ # multi-megabyte payload to discover that it is multi-megabyte is self-defeating, and the
377+ # guard only needs to separate "plausibly fits" from "an order of magnitude over". Real code
378+ # runs denser than 4 chars/token, so this UNDER-estimates and the guard errs toward letting
379+ # a borderline request through to the provider -- the fail-open direction, which matters
380+ # because this is a reliability control, not a security boundary.
381+ _CHARS_PER_TOKEN = 4
382+
383+ # Fraction of the window a request may occupy before it is refused. Below 1.0 because the
384+ # estimate is approximate and the response needs room too; a request at 95% of the window
385+ # has no space left to answer in.
386+ _CONTEXT_BUDGET_RATIO = 0.9
387+
388+
389+ def _message_char_len (message : Any ) -> int :
390+ """Total character length of one chat message, including structured content parts."""
391+ if isinstance (message , str ):
392+ return len (message )
393+ if not isinstance (message , Mapping ):
394+ return len (str (message ))
395+
396+ total = 0
397+ for key in ("role" , "name" , "tool_call_id" ):
398+ value = message .get (key )
399+ if isinstance (value , str ):
400+ total += len (value )
401+
402+ content = message .get ("content" )
403+ if isinstance (content , str ):
404+ total += len (content )
405+ elif isinstance (content , (list , tuple )):
406+ for part in content :
407+ if isinstance (part , Mapping ):
408+ text = part .get ("text" )
409+ total += len (text ) if isinstance (text , str ) else len (str (part ))
410+ else :
411+ total += len (str (part ))
412+ elif content is not None :
413+ total += len (str (content ))
414+
415+ tool_calls = message .get ("tool_calls" )
416+ if isinstance (tool_calls , (list , tuple )):
417+ for call in tool_calls :
418+ total += len (str (call ))
419+
420+ return total
421+
422+
423+ def estimate_prompt_tokens (messages : Any , tools : Any = None ) -> int :
424+ """Cheap upper-bound-ish token estimate for a request, without tokenizing it."""
425+ total_chars = 0
426+ if isinstance (messages , (list , tuple )):
427+ for message in messages :
428+ total_chars += _message_char_len (message )
429+ elif messages is not None :
430+ total_chars += _message_char_len (messages )
431+
432+ if tools :
433+ try :
434+ total_chars += len (json .dumps (tools , default = str ))
435+ except (TypeError , ValueError ):
436+ total_chars += len (str (tools ))
437+
438+ return total_chars // _CHARS_PER_TOKEN
439+
440+
441+ def resolve_context_limit (model : Any ) -> Optional [int ]:
442+ """Returns the model's max input tokens, or None when it cannot be determined.
443+
444+ Fails open on purpose. An unknown window means we cannot prove the request is doomed,
445+ and refusing a request that would have succeeded is a worse failure than the one this
446+ guard exists to prevent.
447+ """
448+ try :
449+ import litellm
450+ except Exception :
451+ return None
452+
453+ model_name = str (model )
454+ # Only ever input limits. litellm's get_max_tokens()/"max_tokens" report the max OUTPUT
455+ # tokens -- often a couple of orders of magnitude smaller than the context window -- so
456+ # using either as an input budget would refuse ordinary requests.
457+ fn = getattr (litellm , "get_max_input_tokens" , None )
458+ if fn is not None :
459+ try :
460+ value = fn (model_name )
461+ except Exception :
462+ value = None
463+ if isinstance (value , int ) and value > 0 :
464+ return value
465+
466+ try :
467+ info = litellm .get_model_info (model_name )
468+ except Exception :
469+ return None
470+ if isinstance (info , Mapping ):
471+ value = info .get ("max_input_tokens" )
472+ if isinstance (value , int ) and value > 0 :
473+ return value
474+ return None
475+
476+
477+ def _largest_message_summary (messages : Any ) -> str :
478+ """Names the biggest contributor to an oversized request so the failure is actionable."""
479+ if not isinstance (messages , (list , tuple )) or not messages :
480+ return ""
481+ sizes = [(_message_char_len (m ), i , m ) for i , m in enumerate (messages )]
482+ chars , index , message = max (sizes , key = lambda item : item [0 ])
483+ role = message .get ("role" , "?" ) if isinstance (message , Mapping ) else "?"
484+ return (
485+ f" Largest contributor: message { index } (role={ role } ) at { chars :,} characters "
486+ f"(~{ chars // _CHARS_PER_TOKEN :,} tokens)."
487+ )
488+
489+
490+ def enforce_context_budget (model : Any , messages : Any , tools : Any = None ) -> None :
491+ """Refuses a request that cannot fit the model's context window, before dispatching it."""
492+ limit = resolve_context_limit (model )
493+ if not limit :
494+ return
495+
496+ estimated = estimate_prompt_tokens (messages , tools )
497+ budget = int (limit * _CONTEXT_BUDGET_RATIO )
498+ if estimated <= budget :
499+ return
500+
501+ raise ContextBudgetExceededError (
502+ f"Request to '{ model } ' is too large for its context window: estimated "
503+ f"~{ estimated :,} input tokens against a { limit :,} -token window "
504+ f"({ estimated / limit :.1f} x the window, budget { budget :,} )."
505+ f"{ _largest_message_summary (messages )} "
506+ " Refused before dispatch; retrying the identical request cannot succeed. "
507+ "Narrow the tool output feeding this node (for example, pass a 'directory' to "
508+ "list_files or a smaller range to read_file)." ,
509+ estimated_tokens = estimated ,
510+ limit = limit ,
511+ )
512+
513+
341514def is_auth_error (e : Optional [Exception ]) -> bool :
342515 """Detects whether an exception represents an authentication or token refresh failure."""
343516 if e is None :
@@ -677,21 +850,13 @@ def _non_retryable_should_retry_node(
677850 if is_auth_error (exception ) or isinstance (exception , MantisAuthError ):
678851 return False
679852 exc_cls_name = getattr (getattr (exception , "__class__" , None ), "__name__" , "" )
680- if exc_cls_name in (
681- "BudgetExceededError" ,
682- "LlmCallsLimitExceededError" ,
683- "MantisAuthError" ,
684- ):
853+ if exc_cls_name in _NON_RETRYABLE_EXC_NAMES :
685854 return False
686855 cause = getattr (exception , "__cause__" , None ) or getattr (exception , "__context__" , None )
687856 if cause is not None and (
688857 is_auth_error (cause )
689858 or isinstance (cause , MantisAuthError )
690- or getattr (getattr (cause , "__class__" , None ), "__name__" , "" ) in (
691- "BudgetExceededError" ,
692- "LlmCallsLimitExceededError" ,
693- "MantisAuthError" ,
694- )
859+ or getattr (getattr (cause , "__class__" , None ), "__name__" , "" ) in _NON_RETRYABLE_EXC_NAMES
695860 ):
696861 return False
697862 return _orig_adk_should_retry_node (exception , retry_config , node_state )
@@ -713,6 +878,10 @@ async def acompletion(
713878 ) -> Any :
714879 import litellm
715880
881+ # Before the retry loop, not inside it: an oversized request is deterministic, so
882+ # every pass through the loop would upload the same doomed payload again.
883+ enforce_context_budget (model , messages , tools )
884+
716885 max_patience = float (os .environ .get ("MANTIS_LLM_MAX_PATIENCE_SECONDS" , "3600.0" ))
717886 initial_delay = float (os .environ .get ("MANTIS_LLM_RETRY_INITIAL_DELAY" , "5.0" ))
718887 max_delay = float (os .environ .get ("MANTIS_LLM_RETRY_MAX_DELAY" , "60.0" ))
@@ -783,6 +952,10 @@ def completion(
783952 ) -> Any :
784953 import litellm
785954
955+ # Both dispatch paths are guarded: a control that only covers the async path is a
956+ # control that a single synchronous caller silently disables.
957+ enforce_context_budget (model , messages , tools )
958+
786959 max_patience = float (os .environ .get ("MANTIS_LLM_MAX_PATIENCE_SECONDS" , "3600.0" ))
787960 initial_delay = float (os .environ .get ("MANTIS_LLM_RETRY_INITIAL_DELAY" , "5.0" ))
788961 max_delay = float (os .environ .get ("MANTIS_LLM_RETRY_MAX_DELAY" , "60.0" ))
0 commit comments