6060 "proactive_extraction" : "gpt-5-nano" ,
6161 "proactive_reasoning" : "gpt-5.6-luna" ,
6262}
63+ # Must match generated_route_overrides.yaml for these features. The direct
64+ # recovery path previously used medium for reasoning, which let luna spend a
65+ # client 800-token cap entirely on hidden reasoning and return truncated JSON.
66+ _DIRECT_REASONING_EFFORT = {
67+ "proactive_extraction" : "minimal" ,
68+ "proactive_reasoning" : "low" ,
69+ }
70+ # The Pydantic field maximum is also the last-resort reasoning retry ceiling.
71+ _MAX_COMPLETION_TOKENS = 4096
72+ # Proven recovery budget for length-exhausted structured output. Extraction
73+ # retries at this ceiling. Reasoning uses it as the first-attempt floor: a
74+ # 5-hour dogfood session exhausted the client's 800-token cap on 29/30 luna
75+ # failures (HTTP 200, truncated JSON, 1.7–2.9s). Unused cap is not billed.
6376_DIRECT_EXTRACTION_RETRY_MAX_COMPLETION_TOKENS = 2400
77+ _REASONING_MIN_COMPLETION_TOKENS = _DIRECT_EXTRACTION_RETRY_MAX_COMPLETION_TOKENS
78+ _INVALID_STRUCTURED_OUTPUT_STATUS = 422
79+ _INVALID_STRUCTURED_OUTPUT_DETAIL = "Proactive model returned invalid structured output"
6480
6581
6682@dataclass (frozen = True )
@@ -131,8 +147,10 @@ def _proactive_provider_request(request: "ProactiveCompletionRequest", uid: str,
131147 payload ["model" ] = _DIRECT_MODELS [request .operation .value ]
132148 # OpenAI reasoning models otherwise default to spending the entire completion
133149 # budget on hidden reasoning. Extraction then returns an empty message with
134- # finish_reason=length instead of the required strict JSON payload.
135- payload ["reasoning_effort" ] = "minimal" if request .operation == ProactiveOperation .EXTRACTION else "medium"
150+ # finish_reason=length instead of the required strict JSON payload. Reasoning
151+ # matches the gateway lane (low), not medium: medium plus the client's 800-token
152+ # cap is the combination that returned truncated JSON as a 502.
153+ payload ["reasoning_effort" ] = _DIRECT_REASONING_EFFORT [request .operation .value ]
136154 # Keep the breakpointed messages built above. The desktop client packs the stable
137155 # bucket prompt, the volatile frame metadata and the screenshot into ONE user
138156 # message, and the provider only serves a cache read from a prefix that ends on a
@@ -181,7 +199,7 @@ class ProactiveCompletionRequest(BaseModel):
181199 operation : ProactiveOperation
182200 messages : list [dict [str , Any ]] = Field (min_length = 1 , max_length = 16 )
183201 response_format : dict [str , Any ]
184- max_completion_tokens : int = Field (default = 1024 , ge = 1 , le = 4096 )
202+ max_completion_tokens : int = Field (default = 1024 , ge = 1 , le = _MAX_COMPLETION_TOKENS )
185203 cache_key : str | None = Field (default = None , min_length = 1 , max_length = 200 )
186204 metadata : dict [str , Any ] = Field (default_factory = dict )
187205
@@ -342,7 +360,7 @@ def _gateway_payload(request: ProactiveCompletionRequest) -> dict[str, Any]:
342360 "model" : _OPERATION_LANES [operation ],
343361 "messages" : request .messages ,
344362 "response_format" : request .response_format ,
345- "max_completion_tokens" : request . max_completion_tokens ,
363+ "max_completion_tokens" : _effective_max_completion_tokens ( request ) ,
346364 "metadata" : {
347365 ** request .metadata ,
348366 "omi_feature" : f"desktop_{ operation } " ,
@@ -432,6 +450,27 @@ def token_count(name: str) -> int:
432450 )
433451
434452
453+ def _effective_max_completion_tokens (request : ProactiveCompletionRequest ) -> int :
454+ """Return the completion cap actually forwarded to the provider.
455+
456+ The desktop client sizes ``max_completion_tokens`` for visible JSON. A
457+ reasoning model can spend that entire cap on hidden tokens, so reasoning
458+ requests are raised to the budget that already recovers extraction length
459+ exhaustion. Unused cap is not billed; a retry that doubles delivery-path
460+ latency is reserved for the remaining truncations.
461+ """
462+ requested = request .max_completion_tokens
463+ if request .operation == ProactiveOperation .REASONING :
464+ return max (requested , _REASONING_MIN_COMPLETION_TOKENS )
465+ return requested
466+
467+
468+ def _retry_max_completion_tokens (operation : ProactiveOperation ) -> int :
469+ if operation == ProactiveOperation .REASONING :
470+ return _MAX_COMPLETION_TOKENS
471+ return _DIRECT_EXTRACTION_RETRY_MAX_COMPLETION_TOKENS
472+
473+
435474def _looks_like_truncated_json (content : str ) -> bool :
436475 stripped = content .rstrip ()
437476 if not stripped :
@@ -454,17 +493,20 @@ def _looks_like_truncated_json(content: str) -> bool:
454493 return False
455494
456495
457- def _should_retry_direct_extraction (
496+ def _should_retry_truncated_structured_output (
458497 response : Any ,
459498 request : ProactiveCompletionRequest ,
460- provider_request : _ProviderRequest ,
499+ * ,
500+ attempted_max_completion_tokens : int ,
461501) -> bool :
462- """Retry only the known dev-direct extraction length exhaustion shape."""
463- if (
464- provider_request .fallback_class != "dev_direct_openai"
465- or request .operation != ProactiveOperation .EXTRACTION
466- or request .max_completion_tokens >= _DIRECT_EXTRACTION_RETRY_MAX_COMPLETION_TOKENS
467- ):
502+ """Retry once when a reasoning model spent the completion cap on hidden tokens.
503+
504+ The backend resizes the budget on retry, which a client retry of the same
505+ 800-token request cannot do. Transport errors are not retried here: 429 must
506+ reach the client cooldown, and a 4xx/5xx without a changed request would
507+ hide an outage as a transient blip.
508+ """
509+ if attempted_max_completion_tokens >= _retry_max_completion_tokens (request .operation ):
468510 return False
469511 if not isinstance (response , Mapping ):
470512 return False
@@ -481,12 +523,13 @@ def _should_retry_direct_extraction(
481523 return isinstance (content , str ) and _looks_like_truncated_json (content )
482524
483525
484- def _record_direct_extraction_retry_outcome (outcome : str ) -> None :
485- """Record one terminal event for the bounded direct Nano retry."""
526+ def _record_length_retry_outcome (provider_request : _ProviderRequest , outcome : str ) -> None :
527+ """Record one terminal event for the bounded structured-output length retry."""
528+ direct = provider_request .fallback_class == "dev_direct_openai"
486529 record_fallback (
487530 component = "llm_gateway" ,
488- from_mode = "direct_openai" ,
489- to_mode = "direct_openai_retry" ,
531+ from_mode = "direct_openai" if direct else "gateway" ,
532+ to_mode = "direct_openai_retry" if direct else "gateway_retry" ,
490533 reason = "capability_mismatch" ,
491534 outcome = outcome ,
492535 log = logger ,
@@ -535,39 +578,45 @@ async def proactive_completion(
535578 _apply_quota_headers (response , quota )
536579 request_id = str (uuid4 ())
537580 provider_request : _ProviderRequest | None = None
538- direct_extraction_retry_attempted = False
581+ length_retry_attempted = False
539582 try :
540583 provider_request = _proactive_provider_request (request , uid , request_id )
584+ attempted_max_completion_tokens = provider_request .payload ["max_completion_tokens" ]
541585 response_body = await _post_provider_completion (provider_request )
542- if _should_retry_direct_extraction (response_body , request , provider_request ):
543- direct_extraction_retry_attempted = True
586+ if _should_retry_truncated_structured_output (
587+ response_body ,
588+ request ,
589+ attempted_max_completion_tokens = attempted_max_completion_tokens ,
590+ ):
591+ length_retry_attempted = True
592+ retry_max = _retry_max_completion_tokens (request .operation )
544593 choice = response_body ["choices" ][0 ]
545594 message = choice .get ("message" ) if isinstance (choice , Mapping ) else None
546595 content = message .get ("content" ) if isinstance (message , Mapping ) else None
547596 logger .warning (
548- "desktop_proactivity_direct_extraction_length_retry operation=%s finish_reason=%s "
597+ "desktop_proactivity_length_retry operation=%s finish_reason=%s "
549598 "choices_count=%s content_type=%s content_length=%s initial_max_completion_tokens=%s "
550599 "retry_max_completion_tokens=%s" ,
551600 operation ,
552601 choice .get ("finish_reason" ) if isinstance (choice , Mapping ) else "unknown" ,
553602 len (response_body ["choices" ]),
554603 type (content ).__name__ ,
555604 len (content ) if isinstance (content , str ) else 0 ,
556- request . max_completion_tokens ,
557- _DIRECT_EXTRACTION_RETRY_MAX_COMPLETION_TOKENS ,
605+ attempted_max_completion_tokens ,
606+ retry_max ,
558607 )
559608 response_body = await _post_provider_completion (
560609 provider_request ,
561- max_completion_tokens = _DIRECT_EXTRACTION_RETRY_MAX_COMPLETION_TOKENS ,
610+ max_completion_tokens = retry_max ,
562611 )
563612 except HTTPException :
564- if direct_extraction_retry_attempted :
565- _record_direct_extraction_retry_outcome ( "exhausted" )
613+ if length_retry_attempted and provider_request is not None :
614+ _record_length_retry_outcome ( provider_request , "exhausted" )
566615 await _release_quota (uid , request .operation )
567616 raise
568617 except (httpx .HTTPError , ValueError , TypeError ) as exc :
569- if direct_extraction_retry_attempted :
570- _record_direct_extraction_retry_outcome ( "exhausted" )
618+ if length_retry_attempted and provider_request is not None :
619+ _record_length_retry_outcome ( provider_request , "exhausted" )
571620 await _release_quota (uid , request .operation )
572621 if isinstance (exc , httpx .HTTPStatusError ):
573622 logger .warning (
@@ -585,28 +634,31 @@ async def proactive_completion(
585634 )
586635 raise HTTPException (status_code = 502 , detail = "Proactive model unavailable" ) from exc
587636 if not isinstance (response_body , dict ):
588- if direct_extraction_retry_attempted :
589- _record_direct_extraction_retry_outcome ( "exhausted" )
637+ if length_retry_attempted :
638+ _record_length_retry_outcome ( provider_request , "exhausted" )
590639 await _release_quota (uid , request .operation )
591640 raise HTTPException (status_code = 502 , detail = "Proactive model returned an invalid response" )
592641 try :
593642 _validate_gateway_output (response_body , request )
594643 except HTTPException as exc :
595- if direct_extraction_retry_attempted :
596- _record_direct_extraction_retry_outcome ( "exhausted" )
644+ if length_retry_attempted :
645+ _record_length_retry_outcome ( provider_request , "exhausted" )
597646 await _release_quota (uid , request .operation )
598647 logger .warning (
599- "desktop_proactivity_invalid_structured_output operation=%s fallback_class=%s provider_model=%s detail=%s" ,
648+ "desktop_proactivity_invalid_structured_output operation=%s fallback_class=%s "
649+ "provider_model=%s status=%s detail=%s" ,
600650 operation ,
601651 provider_request .fallback_class ,
602652 response_body .get ("model" , "unknown" ),
653+ exc .status_code ,
603654 exc .detail ,
604655 )
605656 raise
606- if direct_extraction_retry_attempted :
607- _record_direct_extraction_retry_outcome ( "recovered" )
657+ if length_retry_attempted :
658+ _record_length_retry_outcome ( provider_request , "recovered" )
608659 usage = _usage_envelope (response_body )
609660 provider_model = response_body .get ("model" )
661+ assert provider_request is not None
610662 return ProactiveCompletionEnvelope (
611663 operation = request .operation ,
612664 lane = lane ,
@@ -618,23 +670,27 @@ async def proactive_completion(
618670 )
619671
620672
673+ def _invalid_structured_output () -> HTTPException :
674+ return HTTPException (status_code = _INVALID_STRUCTURED_OUTPUT_STATUS , detail = _INVALID_STRUCTURED_OUTPUT_DETAIL )
675+
676+
621677def _validate_gateway_output (response : Mapping [str , Any ], request : ProactiveCompletionRequest ) -> None :
622678 """Fail closed if the gateway/provider did not honor the strict JSON contract."""
623679 response_format = request .response_format .get ("json_schema" )
624680 schema = response_format .get ("schema" ) if isinstance (response_format , Mapping ) else None
625681 choices = response .get ("choices" )
626682 if not isinstance (schema , Mapping ) or not isinstance (choices , list ) or not choices :
627- raise HTTPException ( status_code = 502 , detail = "Proactive model returned invalid structured output" )
683+ raise _invalid_structured_output ( )
628684 validator = Draft202012Validator (schema )
629685 for choice in choices :
630686 if not isinstance (choice , Mapping ):
631- raise HTTPException ( status_code = 502 , detail = "Proactive model returned invalid structured output" )
687+ raise _invalid_structured_output ( )
632688 message = choice .get ("message" )
633689 content = message .get ("content" ) if isinstance (message , Mapping ) else None
634690 if not isinstance (content , str ):
635- raise HTTPException ( status_code = 502 , detail = "Proactive model returned invalid structured output" )
691+ raise _invalid_structured_output ( )
636692 try :
637693 decoded = json .loads (content )
638694 validator .validate (decoded )
639695 except (json .JSONDecodeError , ValidationError , TypeError ) as exc :
640- raise HTTPException ( status_code = 502 , detail = "Proactive model returned invalid structured output" ) from exc
696+ raise _invalid_structured_output ( ) from exc
0 commit comments