Skip to content

Commit 4918310

Browse files
authored
Merge pull request #351 from Lexus2016/evolution/issue-348-model-404-suffix-selfcorrect
fix(provider): self-correct bare model 404 via suffix variants (Closes #348)
2 parents cf3e1c8 + 16808f6 commit 4918310

3 files changed

Lines changed: 116 additions & 1 deletion

File tree

agent/conversation_loop.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@
2929

3030
from agent.codex_responses_adapter import _summarize_user_message_for_log
3131
from agent.display import KawaiiSpinner
32-
from agent.error_classifier import FailoverReason, classify_api_error
32+
from agent.error_classifier import (
33+
FailoverReason,
34+
classify_api_error,
35+
next_untried_model_variant,
36+
)
3337
from agent.iteration_budget import IterationBudget
3438
from agent.turn_context import build_turn_context
3539
from agent.turn_retry_state import TurnRetryState
@@ -3253,6 +3257,41 @@ def _perform_api_call(next_api_kwargs):
32533257
) and not is_context_length_error
32543258

32553259
if is_client_error:
3260+
# ── Model 404 self-correct (#348) — try suffix variants of
3261+
# the SAME model before switching providers. A bare model
3262+
# id (no ``:suffix``) that 404s often resolves once
3263+
# suffixed (``glm-4`` → ``glm-4:cloud``). Try each untried
3264+
# variant once; only on exhaustion do we fall through to
3265+
# the provider fallback / abort below. Variants are derived
3266+
# from the ORIGINAL id so a mutated ``agent.model`` from a
3267+
# prior attempt doesn't shrink the candidate set.
3268+
if classified.reason == FailoverReason.model_not_found:
3269+
_mnf_base = getattr(agent, "_model_404_base", None)
3270+
_mnf_tried = getattr(agent, "_model_404_tried", None)
3271+
# Fresh model (first 404, or the user switched models
3272+
# mid-session) → restart the variant search from it so a
3273+
# stale base never narrows the candidates.
3274+
if _mnf_tried is None or (agent.model or "") not in _mnf_tried:
3275+
_mnf_base = agent.model or ""
3276+
_mnf_tried = {_mnf_base}
3277+
_variant = next_untried_model_variant(_mnf_base, _mnf_tried)
3278+
if _variant is not None:
3279+
_mnf_tried.add(_variant)
3280+
agent._model_404_base = _mnf_base
3281+
agent._model_404_tried = _mnf_tried
3282+
agent._buffer_status(
3283+
f"⚠️ Model '{agent.model}' not found — trying '{_variant}'..."
3284+
)
3285+
agent._vprint(
3286+
f"{agent.log_prefix} 💡 Model not found; self-correcting "
3287+
f"to suffixed variant '{_variant}'.",
3288+
force=True,
3289+
)
3290+
agent.model = _variant
3291+
retry_count = 0
3292+
compression_attempts = 0
3293+
_retry.primary_recovery_attempted = False
3294+
continue
32563295
# Try fallback before aborting — a different provider may
32573296
# not have the same issue (rate limit, auth, etc.). Only
32583297
# announce the attempt when a fallback chain actually

agent/error_classifier.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,35 @@ def is_auth(self) -> bool:
252252
"unsupported model",
253253
]
254254

255+
256+
def model_id_suffix_variants(model: str) -> list[str]:
257+
"""Candidate suffixed variants of a *bare* model id, in priority order (#348).
258+
259+
A bare model id (no provider ``:suffix``) that 404s with "model not found"
260+
frequently resolves once suffixed — e.g. ``glm-4`` → ``glm-4:cloud`` — because
261+
the catalog only exposes the suffixed form (Ollama Cloud, vLLM tiers, etc.).
262+
Returns ``[]`` when the id already carries a ``:suffix`` (we never second-guess
263+
an explicit choice) or is empty/blank.
264+
"""
265+
model = (model or "").strip()
266+
if not model or ":" in model:
267+
return []
268+
return [f"{model}:cloud", f"{model}:local"]
269+
270+
271+
def next_untried_model_variant(base_model: str, tried: "set[str] | frozenset[str]") -> str | None:
272+
"""Next ``model:suffix`` variant of ``base_model`` not present in ``tried``.
273+
274+
Drives the bounded 404 self-correction loop (#348): callers pass the original
275+
bare id and the set of ids already attempted; ``None`` means "no variant left,
276+
fall through to provider fallback / abort".
277+
"""
278+
for candidate in model_id_suffix_variants(base_model):
279+
if candidate not in tried:
280+
return candidate
281+
return None
282+
283+
255284
# Request-validation patterns — the request is malformed and will fail
256285
# identically on every retry. Some OpenAI-compatible gateways (notably
257286
# codex.nekos.me) return these as 5xx instead of the standard 4xx, which

tests/agent/test_error_classifier.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
_extract_error_body,
1010
_extract_error_code,
1111
_classify_402,
12+
model_id_suffix_variants,
13+
next_untried_model_variant,
1214
)
1315

1416

@@ -1654,3 +1656,48 @@ def test_unrelated_400_is_not_misclassified(self):
16541656
e = MockAPIError("bad request: missing field 'model'", status_code=400)
16551657
result = classify_api_error(e, provider="openrouter", model="anthropic/claude-sonnet-4")
16561658
assert result.reason != FailoverReason.multimodal_tool_content_unsupported
1659+
1660+
1661+
# ── Model 404 suffix-variant self-correction (#348) ────────────────────
1662+
1663+
class TestModelSuffixVariants:
1664+
def test_bare_id_yields_cloud_then_local(self):
1665+
assert model_id_suffix_variants("glm-4") == ["glm-4:cloud", "glm-4:local"]
1666+
1667+
def test_already_suffixed_yields_nothing(self):
1668+
# An explicit ``:suffix`` is the user's choice — never second-guess it.
1669+
assert model_id_suffix_variants("glm-4:cloud") == []
1670+
assert model_id_suffix_variants("openrouter:anthropic/claude") == []
1671+
1672+
def test_blank_or_empty_yields_nothing(self):
1673+
assert model_id_suffix_variants("") == []
1674+
assert model_id_suffix_variants(" ") == []
1675+
1676+
def test_whitespace_is_trimmed(self):
1677+
assert model_id_suffix_variants(" kimi-k2 ") == ["kimi-k2:cloud", "kimi-k2:local"]
1678+
1679+
1680+
class TestNextUntriedModelVariant:
1681+
def test_first_call_returns_cloud(self):
1682+
assert next_untried_model_variant("glm-4", {"glm-4"}) == "glm-4:cloud"
1683+
1684+
def test_skips_already_tried(self):
1685+
assert next_untried_model_variant("glm-4", {"glm-4", "glm-4:cloud"}) == "glm-4:local"
1686+
1687+
def test_exhausted_returns_none(self):
1688+
tried = {"glm-4", "glm-4:cloud", "glm-4:local"}
1689+
assert next_untried_model_variant("glm-4", tried) is None
1690+
1691+
def test_suffixed_base_has_no_variants(self):
1692+
assert next_untried_model_variant("glm-4:cloud", {"glm-4:cloud"}) is None
1693+
1694+
def test_bounded_two_step_progression(self):
1695+
# The exact loop the conversation handler runs: base 404 → :cloud 404 →
1696+
# :local 404 → give up (fall through to provider fallback / abort).
1697+
base, tried = "qwen3", {"qwen3"}
1698+
seq = []
1699+
while (v := next_untried_model_variant(base, tried)) is not None:
1700+
seq.append(v)
1701+
tried.add(v)
1702+
assert seq == ["qwen3:cloud", "qwen3:local"]
1703+

0 commit comments

Comments
 (0)