diff --git a/packages/decepticon/decepticon/llm/factory.py b/packages/decepticon/decepticon/llm/factory.py index 31bc98fe..9cbe5392 100644 --- a/packages/decepticon/decepticon/llm/factory.py +++ b/packages/decepticon/decepticon/llm/factory.py @@ -1359,6 +1359,25 @@ def _reraise_with_actionable_message(exc: Exception, model_name: str) -> None: # but interpolate the scrubbed copy so an echoed credential never reaches # the user-facing message — see _redact_secrets. safe_msg = _redact_secrets(msg) + status = getattr(exc, "status_code", None) + if status is None: + status = getattr(getattr(exc, "response", None), "status_code", None) + status_match = _STATUS_IN_MSG.search(msg) + is_bad_request = ( + "badrequest" in err_type.lower() + or status == 400 + or (status_match is not None and int(status_match.group(1)) == 400) + ) + + if is_bad_request and "invalid model name" in msg_lower: + raise RuntimeError( + f"{fatal_prefix}Model '{model_name}' is not available through the LiteLLM " + f"proxy. Verify its static route in config/litellm.yaml or dynamic environment " + f"configuration (DECEPTICON_MODEL*, DECEPTICON_LITELLM_MODELS, " + f"CUSTOM_OPENAI_*, or OLLAMA_MODEL). Restart the proxy, inspect its startup " + f"logs, and query /v1/models for the models available to your key.\n" + f"Underlying: {safe_msg}" + ) from exc # LiteLLM puts a recognizable prefix in the inner message when the # proxy ran out of fallback options for a model_group — issue #107. @@ -1372,7 +1391,7 @@ def _reraise_with_actionable_message(exc: Exception, model_name: str) -> None: f"Underlying: {safe_msg}" ) from exc - if "badrequest" in err_type.lower() or "code: 400" in msg_lower: + if is_bad_request: raise RuntimeError( f"{fatal_prefix}Model '{model_name}' rejected the request (400). " f"This usually means a parameter the model no longer supports " diff --git a/packages/decepticon/tests/unit/llm/test_factory_classify_errors.py b/packages/decepticon/tests/unit/llm/test_factory_classify_errors.py index 0828b5f5..ad140686 100644 --- a/packages/decepticon/tests/unit/llm/test_factory_classify_errors.py +++ b/packages/decepticon/tests/unit/llm/test_factory_classify_errors.py @@ -100,6 +100,54 @@ def test_400_message_labelled_non_retryable(self): # Existing remediation text preserved (regression). assert "rejected the request (400)" in msg + def test_400_invalid_model_names_static_and_dynamic_proxy_configuration(self): + exc = _exc_with_status( + "BadRequestError", + 400, + "Error code: 400 - Invalid model name passed in model=custom/kimi-k2.7-code. " + "Authorization: Bearer ***. Call `/v1/models` to view " + "available models for your key.", + ) + with pytest.raises(RuntimeError) as info: + _reraise_with_actionable_message(exc, "custom/kimi-k2.7-code") + + msg = str(info.value) + assert "non-retryable provider error" in msg + assert "config/litellm.yaml" in msg + assert "dynamic environment" in msg + assert "DECEPTICON_MODEL" in msg + assert "restart" in msg.lower() + assert "startup logs" in msg.lower() + assert "/v1/models" in msg + assert "ROUTELEAKTOKEN12345" not in msg + assert "[REDACTED]" in msg + + def test_503_invalid_model_text_is_not_reclassified_as_a_route_error(self): + exc = _exc_with_status( + "ServiceUnavailableError", + 503, + "Error code: 503 - upstream temporarily returned Invalid model name", + ) + assert _reraise_with_actionable_message(exc, "custom/kimi-k2.7-code") is None + + def test_value_error_invalid_model_text_passes_through(self): + exc = ValueError("parser captured provider text: Invalid model name") + assert _reraise_with_actionable_message(exc, "custom/kimi-k2.7-code") is None + + def test_composite_400_invalid_model_precedes_no_fallback_guidance(self): + exc = _exc_with_status( + "BadRequestError", + 400, + "Error code: 400 - Invalid model name passed in model=custom/kimi-k2.7-code. " + "No fallback model group found for original model_group=custom/kimi-k2.7-code", + ) + with pytest.raises(RuntimeError) as info: + _reraise_with_actionable_message(exc, "custom/kimi-k2.7-code") + + msg = str(info.value) + assert "not available through the LiteLLM proxy" in msg + assert "another auth method" not in msg + def test_401_message_labelled_non_retryable(self): exc = _exc_with_status("AuthenticationError", 401, "Error code: 401 - invalid_api_key") with pytest.raises(RuntimeError) as info: