Skip to content

Commit 88805b7

Browse files
Fix proactive reasoning starvation, untyped failures, and generic workstream labels (#11661)
1 parent d27b1fa commit 88805b7

10 files changed

Lines changed: 462 additions & 74 deletions

backend/routers/desktop_proactivity.py

Lines changed: 94 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,23 @@
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+
435474
def _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+
621677
def _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

Comments
 (0)