diff --git a/openhands-sdk/openhands/sdk/llm/utils/litellm_provider.py b/openhands-sdk/openhands/sdk/llm/utils/litellm_provider.py index 16f4199a51..338246a6c1 100644 --- a/openhands-sdk/openhands/sdk/llm/utils/litellm_provider.py +++ b/openhands-sdk/openhands/sdk/llm/utils/litellm_provider.py @@ -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, diff --git a/tests/sdk/llm/test_litellm_provider.py b/tests/sdk/llm/test_litellm_provider.py index 6ddffe0a62..1d9fdb3dd5 100644 --- a/tests/sdk/llm/test_litellm_provider.py +++ b/tests/sdk/llm/test_litellm_provider.py @@ -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.