"""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