Skip to content
Open
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
59 changes: 41 additions & 18 deletions openhands-sdk/openhands/sdk/llm/utils/litellm_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,25 +33,48 @@ class LLMProvider:

@classmethod
def from_model(cls, *, model: str, api_base: str | None) -> LLMProvider:
"""Parse a model string using LiteLLM's provider inference logic."""
try:
get_llm_provider = cast(Any, litellm).get_llm_provider
parsed_model, provider_name, _dynamic_key, _resolved_api_base = (
get_llm_provider(
model=model,
custom_llm_provider=None,
api_base=api_base,
api_key=None,
"""Parse a model string using LiteLLM's provider inference logic.

LiteLLM infers the provider from the model string's first ``/``
segment. When ``api_base`` points at a custom endpoint (a
self-hosted server such as LM Studio, or a third-party router) and
that segment isn't a LiteLLM-recognized provider name, inference
raises instead of falling back to the custom base — so a model id
like ``auto/coding`` configured against a personal OpenAI-compatible
router fails with "LLM Provider NOT provided" deep inside the actual
completion call, not here. Retry once with
``custom_llm_provider="openai"`` in that case: a caller-supplied
``api_base`` already implies an OpenAI-compatible endpoint, and
LiteLLM only strips a *recognized* provider prefix, so an
unrecognized one like ``auto/`` reaches the endpoint unchanged.
"""
get_llm_provider = cast(Any, litellm).get_llm_provider
custom_llm_providers: tuple[str | None, ...] = (
(None,) if api_base is None else (None, "openai")
)

parsed_model, provider_name = model, None
for custom_llm_provider in custom_llm_providers:
try:
parsed_model, provider_name, _dynamic_key, _resolved_api_base = (
get_llm_provider(
model=model,
custom_llm_provider=custom_llm_provider,
api_base=api_base,
api_key=None,
)
)
except Exception as exc:
logger.debug(
"Failed to parse LiteLLM provider for model=%s "
"(custom_llm_provider=%s): %s",
model,
custom_llm_provider,
exc,
)
)
except Exception as exc:
logger.debug(
"Failed to parse LiteLLM provider for model=%s: %s",
model,
exc,
)
parsed_model = model
provider_name = None
parsed_model, provider_name = model, None
if provider_name is not None:
break

return cls(
model=parsed_model,
Expand Down
32 changes: 32 additions & 0 deletions tests/sdk/llm/test_litellm_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,38 @@ def test_llm_provider_handles_unknown_model_without_provider():
assert provider.as_litellm_call_kwargs() == {"model": "unknown-model"}


def test_llm_provider_resolves_unrecognized_model_against_custom_api_base():
# A custom router/self-hosted endpoint (e.g. LM Studio, a personal
# OpenAI-compatible gateway) whose model id's first "/" segment isn't a
# LiteLLM-recognized provider (e.g. "auto/coding") must still resolve to
# the "openai" custom_llm_provider, and the model id must survive intact
# since "auto/" is not a prefix LiteLLM strips. See regression: without
# this fallback, this raises "LLM Provider NOT provided" deep inside the
# actual completion call instead of resolving here.
provider = LLMProvider.from_model(
model="auto/coding",
api_base="https://omniroute.example.com/v1",
)

assert provider.name == "openai"
assert provider.model == "auto/coding"
assert provider.as_litellm_call_kwargs() == {
"model": "auto/coding",
"custom_llm_provider": "openai",
}


def test_llm_provider_does_not_force_openai_without_api_base():
# Without a custom api_base there is no OpenAI-compatible endpoint to
# infer, so an unrecognized model id must stay unresolved exactly as
# before this fix (matches test_llm_provider_handles_unknown_model_
# without_provider).
provider = LLMProvider.from_model(model="auto/coding", api_base=None)

assert provider.name is None
assert provider.model == "auto/coding"


def test_llm_provider_keeps_requested_api_base_verbatim():
# LiteLLM's own resolution appends "/v1" to a custom mistral base; the
# helper must not leak that mutated value back into the forwarded kwargs.
Expand Down