Files
codex-app-server/cloud/models.py
T

126 lines
4.1 KiB
Python

"""Model fallback routing.
Zero third-party dependencies; standard library only.
This module decides *whether* a failed model attempt may fall back to
the next model in a priority list, and *which* model to try next. It
never runs a model turn itself and never talks to any provider.
"""
FALLBACKABLE = {404, 429, 500, 502, 503, 504, "timeout"}
def _is_fallbackable_code(value):
"""Return True for a single status code / error token."""
if isinstance(value, bool):
return False
if isinstance(value, int):
if value in FALLBACKABLE:
return True
# Any other 5xx is a server-side failure of the same kind.
return 500 <= value <= 599
if isinstance(value, str):
text = value.strip().lower()
if text in FALLBACKABLE:
return True
if text.isdigit():
return _is_fallbackable_code(int(text))
return False
return False
def is_fallbackable(error) -> bool:
"""Return True if ``error`` may trigger a model fallback.
``error`` may be:
* an int HTTP status code (404, 429, 5xx -> True; 401, 400 -> False);
* the string ``"timeout"``;
* an object / exception carrying a ``status`` or ``code`` attribute
holding one of the above.
Authentication failures (401) and bad-request errors (400) are
deliberately *not* fallbackable: retrying them on another model
cannot fix bad credentials or a bad request.
"""
if error is None:
return False
if isinstance(error, (int, str)) and not isinstance(error, bool):
return _is_fallbackable_code(error)
for attr in ("status", "code"):
value = getattr(error, attr, None)
if value is not None:
return _is_fallbackable_code(value)
return False
class ModelRouter:
"""Walk a priority list of models, one fallback at a time."""
def __init__(self, priority_list):
if not priority_list:
raise ValueError("priority_list must not be empty")
self.priority_list = list(priority_list)
def first(self):
"""Return the first (highest-priority) model."""
return self.priority_list[0]
def next_after(self, current_model, error):
"""Return the model after ``current_model``, or None.
Returns None when ``error`` is not fallbackable, when
``current_model`` is not in the priority list, or when there
is no model left after ``current_model``.
"""
if not is_fallbackable(error):
return None
try:
index = self.priority_list.index(current_model)
except ValueError:
return None
if index + 1 < len(self.priority_list):
return self.priority_list[index + 1]
return None
def route(self, attempts):
"""Return the next model to try given ``attempts``, or None.
``attempts`` is a list of ``(model, error)`` pairs in the order
they were tried. With no attempts yet, the next model to try
is the first in the priority list. A non-fallbackable error
anywhere in the chain stops routing (returns None), as does
exhausting the priority list.
"""
if not attempts:
return self.first()
last_model = None
for model, error in attempts:
if not is_fallbackable(error):
return None
last_model = model
return self.next_after(last_model, attempts[-1][1])
def record_fallback(task_record, from_model, to_model, reason) -> dict:
"""Record a model fallback on ``task_record`` and return the event.
Fallback must be recorded as an event, never silently downgraded:
the returned event is appended to ``task_record["events"]``
(created if absent) so the downgrade is always visible in the
task's history.
"""
event = {
"type": "model_fallback",
"task_id": task_record.get("id"),
"from_model": from_model,
"to_model": to_model,
"reason": reason,
}
events = task_record.get("events")
if not isinstance(events, list):
events = []
task_record["events"] = events
events.append(event)
return event