Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion packages/decepticon/decepticon/llm/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 "
Expand Down
48 changes: 48 additions & 0 deletions packages/decepticon/tests/unit/llm/test_factory_classify_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading