diff --git a/scripts/generate_api_docs.py b/scripts/generate_api_docs.py index 76e4edc0c..7c87dc327 100755 --- a/scripts/generate_api_docs.py +++ b/scripts/generate_api_docs.py @@ -434,9 +434,15 @@ def generate_any_llm_page() -> str: | `model` | `str` | Combined identifier in `"provider:model"` format (e.g., `"openai:gpt-4.1-mini"`). The legacy `"provider/model"` format is also accepted but deprecated. |""" ) parts.append("") - parts.append("**Returns:** A `(LLMProvider, model_name)` tuple.") + parts.append( + "**Returns:** A `(provider, model_name)` tuple. `provider` is an `LLMProvider` member " + "when the provider has one, and the bare name for a config-only registry gateway." + ) parts.append("") - parts.append("**Raises:** `ValueError` if the string does not contain a `:` or `/` delimiter.") + parts.append( + "**Raises:** `ValueError` if the string does not contain a `:` or `/` delimiter, or " + "`UnsupportedProviderError` if the provider is not resolvable." + ) parts.append("") parts.append( """```python diff --git a/src/any_llm/any_llm.py b/src/any_llm/any_llm.py index 44d40f40b..cd4de2c8c 100644 --- a/src/any_llm/any_llm.py +++ b/src/any_llm/any_llm.py @@ -250,7 +250,9 @@ def _create_provider( if registry_class is not None: return registry_class(api_key=api_key, api_base=api_base, **kwargs) - provider_key = LLMProvider.from_string(provider_key).value + # Resolve through the shared resolver so the unsupported-provider error lists + # registry gateways too, not just the enum. + provider_key = str(cls.resolve_provider_key(provider_key)) provider_class_name = f"{provider_key.capitalize()}Provider" provider_module_name = f"{provider_key}" @@ -296,7 +298,9 @@ def get_provider_class(cls, provider_key: str | LLMProvider) -> type[AnyLLM]: if registry_class is not None: return registry_class - provider_key = LLMProvider.from_string(provider_key).value + # Resolve through the shared resolver so the unsupported-provider error lists + # registry gateways too, not just the enum. + provider_key = str(cls.resolve_provider_key(provider_key)) provider_class_name = f"{provider_key.capitalize()}Provider" provider_module_name = f"{provider_key}" @@ -312,10 +316,54 @@ def get_provider_class(cls, provider_key: str | LLMProvider) -> type[AnyLLM]: provider_class: type[AnyLLM] = getattr(module, provider_class_name) return provider_class + @staticmethod + def get_registry_provider_names() -> list[str]: + """Names of config-only gateways that exist only as registry rows. + + Registry rows do not need an ``LLMProvider`` member, so these names are + absent from the enum and have to be added to any enumeration of + providers explicitly. + """ + from any_llm.providers.registry import PROVIDER_REGISTRY + + enum_values = {provider.value for provider in LLMProvider} + return sorted(name for name in PROVIDER_REGISTRY if name not in enum_values) + @classmethod def get_supported_providers(cls) -> list[str]: - """Get a list of supported provider keys.""" - return [provider.value for provider in LLMProvider] + """Get a list of supported provider keys. + + Includes registry-only gateways, which resolve by name without an + ``LLMProvider`` member. + """ + return [provider.value for provider in LLMProvider] + cls.get_registry_provider_names() + + @classmethod + def resolve_provider_key(cls, provider_key: str | LLMProvider) -> str | LLMProvider: + """Resolve a provider key to an ``LLMProvider`` member where one exists. + + Registry-only gateways have no enum member, so their name is returned + unchanged. Everything downstream (``create``, ``get_provider_class``) + accepts either form. + + Raises: + UnsupportedProviderError: The key is neither an enum member nor a + registry row. + + """ + if isinstance(provider_key, LLMProvider): + return provider_key + # Match LLMProvider.from_string's normalization so both resolution paths + # accept the same spellings. + normalized = provider_key.strip().lower() + try: + return LLMProvider(normalized) + except ValueError: + from any_llm.providers.registry import get_registry_config + + if get_registry_config(normalized) is not None: + return normalized + raise UnsupportedProviderError(provider_key, cls.get_supported_providers()) from None @classmethod def get_all_provider_metadata(cls) -> list[ProviderMetadata]: @@ -337,21 +385,29 @@ def get_all_provider_metadata(cls) -> list[ProviderMetadata]: @classmethod def get_provider_enum(cls, provider_key: str) -> LLMProvider: - """Convert a string provider key to a ProviderName enum.""" + """Convert a string provider key to a ProviderName enum. + + Registry-only gateways have no enum member, so this raises for them even + though they are resolvable. Use ``resolve_provider_key`` to accept both. + """ try: return LLMProvider(provider_key) except ValueError as e: - supported = [provider.value for provider in LLMProvider] - raise UnsupportedProviderError(provider_key, supported) from e + # Report everything resolvable, not just the enum, so the message does + # not omit registry-only gateways. + raise UnsupportedProviderError(provider_key, cls.get_supported_providers()) from e @classmethod - def split_model_provider(cls, model: str) -> tuple[LLMProvider, str]: + def split_model_provider(cls, model: str) -> tuple[str | LLMProvider, str]: """Extract the provider key from the model identifier. Supports both new format 'provider:model' (e.g., 'mistral:mistral-small') and legacy format 'provider/model' (e.g., 'mistral/mistral-small'). The legacy format will be deprecated in version 1.0. + + Returns an ``LLMProvider`` member when the provider has one, and the bare + name for registry-only gateways. Both forms are accepted by ``create``. """ colon_index = model.find(":") slash_index = model.find("/") @@ -376,7 +432,7 @@ def split_model_provider(cls, model: str) -> tuple[LLMProvider, str]: if not provider or not model_name: msg = f"Invalid model format. Expected 'provider:model' or 'provider/model', got '{model}'" raise ValueError(msg) - return cls.get_provider_enum(provider), model_name + return cls.resolve_provider_key(provider), model_name @staticmethod @abstractmethod diff --git a/src/any_llm/api.py b/src/any_llm/api.py index 586940627..d7c763b76 100644 --- a/src/any_llm/api.py +++ b/src/any_llm/api.py @@ -95,7 +95,7 @@ def completion( if provider is None: provider_key, model_id = AnyLLM.split_model_provider(model) else: - provider_key = LLMProvider.from_string(provider) + provider_key = AnyLLM.resolve_provider_key(provider) model_id = model llm = AnyLLM.create( @@ -204,7 +204,7 @@ async def acompletion( if provider is None: provider_key, model_id = AnyLLM.split_model_provider(model) else: - provider_key = LLMProvider.from_string(provider) + provider_key = AnyLLM.resolve_provider_key(provider) model_id = model llm = AnyLLM.create( @@ -345,7 +345,7 @@ def responses( if provider is None: provider_key, model_id = AnyLLM.split_model_provider(model) else: - provider_key = LLMProvider.from_string(provider) + provider_key = AnyLLM.resolve_provider_key(provider) model_id = model llm = AnyLLM.create( @@ -494,7 +494,7 @@ async def aresponses( if provider is None: provider_key, model_id = AnyLLM.split_model_provider(model) else: - provider_key = LLMProvider.from_string(provider) + provider_key = AnyLLM.resolve_provider_key(provider) model_id = model llm = AnyLLM.create( @@ -598,7 +598,7 @@ def messages( if provider is None: provider_key, model_id = AnyLLM.split_model_provider(model) else: - provider_key = LLMProvider.from_string(provider) + provider_key = AnyLLM.resolve_provider_key(provider) model_id = model llm = AnyLLM.create(provider_key, api_key=api_key, api_base=api_base, **client_args or {}) @@ -682,7 +682,7 @@ async def amessages( if provider is None: provider_key, model_id = AnyLLM.split_model_provider(model) else: - provider_key = LLMProvider.from_string(provider) + provider_key = AnyLLM.resolve_provider_key(provider) model_id = model llm = AnyLLM.create(provider_key, api_key=api_key, api_base=api_base, **client_args or {}) @@ -737,7 +737,7 @@ def embedding( if provider is None: provider_key, model_name = AnyLLM.split_model_provider(model) else: - provider_key = LLMProvider.from_string(provider) + provider_key = AnyLLM.resolve_provider_key(provider) model_name = model llm = AnyLLM.create(provider_key, api_key=api_key, api_base=api_base, **client_args or {}) @@ -775,7 +775,7 @@ async def aembedding( if provider is None: provider_key, model_name = AnyLLM.split_model_provider(model) else: - provider_key = LLMProvider.from_string(provider) + provider_key = AnyLLM.resolve_provider_key(provider) model_name = model llm = AnyLLM.create(provider_key, api_key=api_key, api_base=api_base, **client_args or {}) @@ -823,7 +823,7 @@ def image_generation( if provider is None: provider_key, model_name = AnyLLM.split_model_provider(model) else: - provider_key = LLMProvider.from_string(provider) + provider_key = AnyLLM.resolve_provider_key(provider) model_name = model llm = AnyLLM.create(provider_key, api_key=api_key, api_base=api_base, **client_args or {}) @@ -881,7 +881,7 @@ async def aimage_generation( if provider is None: provider_key, model_name = AnyLLM.split_model_provider(model) else: - provider_key = LLMProvider.from_string(provider) + provider_key = AnyLLM.resolve_provider_key(provider) model_name = model llm = AnyLLM.create(provider_key, api_key=api_key, api_base=api_base, **client_args or {}) @@ -942,7 +942,7 @@ def transcription( if provider is None: provider_key, model_name = AnyLLM.split_model_provider(model) else: - provider_key = LLMProvider.from_string(provider) + provider_key = AnyLLM.resolve_provider_key(provider) model_name = model llm = AnyLLM.create(provider_key, api_key=api_key, api_base=api_base, **client_args or {}) @@ -1000,7 +1000,7 @@ async def atranscription( if provider is None: provider_key, model_name = AnyLLM.split_model_provider(model) else: - provider_key = LLMProvider.from_string(provider) + provider_key = AnyLLM.resolve_provider_key(provider) model_name = model llm = AnyLLM.create(provider_key, api_key=api_key, api_base=api_base, **client_args or {}) @@ -1056,7 +1056,7 @@ def speech( if provider is None: provider_key, model_name = AnyLLM.split_model_provider(model) else: - provider_key = LLMProvider.from_string(provider) + provider_key = AnyLLM.resolve_provider_key(provider) model_name = model llm = AnyLLM.create(provider_key, api_key=api_key, api_base=api_base, **client_args or {}) @@ -1109,7 +1109,7 @@ async def aspeech( if provider is None: provider_key, model_name = AnyLLM.split_model_provider(model) else: - provider_key = LLMProvider.from_string(provider) + provider_key = AnyLLM.resolve_provider_key(provider) model_name = model llm = AnyLLM.create(provider_key, api_key=api_key, api_base=api_base, **client_args or {}) @@ -1163,7 +1163,7 @@ def moderation( if provider is None: provider_key, model_name = AnyLLM.split_model_provider(model) else: - provider_key = LLMProvider.from_string(provider) + provider_key = AnyLLM.resolve_provider_key(provider) model_name = model llm = AnyLLM.create(provider_key, api_key=api_key, api_base=api_base, **client_args or {}) @@ -1184,7 +1184,7 @@ async def amoderation( if provider is None: provider_key, model_name = AnyLLM.split_model_provider(model) else: - provider_key = LLMProvider.from_string(provider) + provider_key = AnyLLM.resolve_provider_key(provider) model_name = model llm = AnyLLM.create(provider_key, api_key=api_key, api_base=api_base, **client_args or {}) @@ -1204,7 +1204,7 @@ def _resolve_rerank_target( if provider is None: provider_key, model_name = AnyLLM.split_model_provider(model) else: - provider_key = LLMProvider.from_string(provider) + provider_key = AnyLLM.resolve_provider_key(provider) model_name = model if top_n is not None: @@ -1287,7 +1287,7 @@ def list_models( **kwargs: Any, ) -> Sequence[Model]: """List available models for a provider.""" - llm = AnyLLM.create(LLMProvider.from_string(provider), api_key=api_key, api_base=api_base, **client_args or {}) + llm = AnyLLM.create(AnyLLM.resolve_provider_key(provider), api_key=api_key, api_base=api_base, **client_args or {}) return llm.list_models(**kwargs) @@ -1299,7 +1299,7 @@ async def alist_models( **kwargs: Any, ) -> Sequence[Model]: """List available models for a provider asynchronously.""" - llm = AnyLLM.create(LLMProvider.from_string(provider), api_key=api_key, api_base=api_base, **client_args or {}) + llm = AnyLLM.create(AnyLLM.resolve_provider_key(provider), api_key=api_key, api_base=api_base, **client_args or {}) return await llm.alist_models(**kwargs) @@ -1332,7 +1332,7 @@ def create_batch( The created batch object """ - llm = AnyLLM.create(LLMProvider.from_string(provider), api_key=api_key, api_base=api_base, **client_args or {}) + llm = AnyLLM.create(AnyLLM.resolve_provider_key(provider), api_key=api_key, api_base=api_base, **client_args or {}) return llm.create_batch( input_file_path=input_file_path, endpoint=endpoint, @@ -1371,7 +1371,7 @@ async def acreate_batch( The created batch object """ - llm = AnyLLM.create(LLMProvider.from_string(provider), api_key=api_key, api_base=api_base, **client_args or {}) + llm = AnyLLM.create(AnyLLM.resolve_provider_key(provider), api_key=api_key, api_base=api_base, **client_args or {}) return await llm.acreate_batch( input_file_path=input_file_path, endpoint=endpoint, @@ -1404,7 +1404,7 @@ def retrieve_batch( The batch object """ - llm = AnyLLM.create(LLMProvider.from_string(provider), api_key=api_key, api_base=api_base, **client_args or {}) + llm = AnyLLM.create(AnyLLM.resolve_provider_key(provider), api_key=api_key, api_base=api_base, **client_args or {}) return llm.retrieve_batch(batch_id, **kwargs) @@ -1431,7 +1431,7 @@ async def aretrieve_batch( The batch object """ - llm = AnyLLM.create(LLMProvider.from_string(provider), api_key=api_key, api_base=api_base, **client_args or {}) + llm = AnyLLM.create(AnyLLM.resolve_provider_key(provider), api_key=api_key, api_base=api_base, **client_args or {}) return await llm.aretrieve_batch(batch_id, **kwargs) @@ -1458,7 +1458,7 @@ def cancel_batch( The cancelled batch object """ - llm = AnyLLM.create(LLMProvider.from_string(provider), api_key=api_key, api_base=api_base, **client_args or {}) + llm = AnyLLM.create(AnyLLM.resolve_provider_key(provider), api_key=api_key, api_base=api_base, **client_args or {}) return llm.cancel_batch(batch_id, **kwargs) @@ -1485,7 +1485,7 @@ async def acancel_batch( The cancelled batch object """ - llm = AnyLLM.create(LLMProvider.from_string(provider), api_key=api_key, api_base=api_base, **client_args or {}) + llm = AnyLLM.create(AnyLLM.resolve_provider_key(provider), api_key=api_key, api_base=api_base, **client_args or {}) return await llm.acancel_batch(batch_id, **kwargs) @@ -1514,7 +1514,7 @@ def list_batches( A list of batch objects """ - llm = AnyLLM.create(LLMProvider.from_string(provider), api_key=api_key, api_base=api_base, **client_args or {}) + llm = AnyLLM.create(AnyLLM.resolve_provider_key(provider), api_key=api_key, api_base=api_base, **client_args or {}) return llm.list_batches(after=after, limit=limit, **kwargs) @@ -1543,7 +1543,7 @@ async def alist_batches( A list of batch objects """ - llm = AnyLLM.create(LLMProvider.from_string(provider), api_key=api_key, api_base=api_base, **client_args or {}) + llm = AnyLLM.create(AnyLLM.resolve_provider_key(provider), api_key=api_key, api_base=api_base, **client_args or {}) return await llm.alist_batches(after=after, limit=limit, **kwargs) @@ -1570,7 +1570,7 @@ def retrieve_batch_results( The batch results containing per-request outcomes. """ - llm = AnyLLM.create(LLMProvider.from_string(provider), api_key=api_key, api_base=api_base, **client_args or {}) + llm = AnyLLM.create(AnyLLM.resolve_provider_key(provider), api_key=api_key, api_base=api_base, **client_args or {}) return llm.retrieve_batch_results(batch_id, **kwargs) @@ -1597,5 +1597,5 @@ async def aretrieve_batch_results( The batch results containing per-request outcomes. """ - llm = AnyLLM.create(LLMProvider.from_string(provider), api_key=api_key, api_base=api_base, **client_args or {}) + llm = AnyLLM.create(AnyLLM.resolve_provider_key(provider), api_key=api_key, api_base=api_base, **client_args or {}) return await llm.aretrieve_batch_results(batch_id, **kwargs) diff --git a/tests/unit/providers/test_databricks_provider.py b/tests/unit/providers/test_databricks_provider.py index 594ec1747..fe8d72d38 100644 --- a/tests/unit/providers/test_databricks_provider.py +++ b/tests/unit/providers/test_databricks_provider.py @@ -1,6 +1,7 @@ import pytest from any_llm import AnyLLM +from any_llm.constants import LLMProvider from any_llm.providers.databricks.databricks import DatabricksProvider @@ -37,7 +38,7 @@ def test_factory_integration() -> None: def test_model_provider_split() -> None: """Test that model string parsing works correctly.""" provider_enum, model_name = AnyLLM.split_model_provider("databricks:databricks-meta-llama-3-70b-instruct") - assert provider_enum.value == "databricks" + assert provider_enum is LLMProvider.DATABRICKS assert model_name == "databricks-meta-llama-3-70b-instruct" diff --git a/tests/unit/providers/test_lmstudio_provider.py b/tests/unit/providers/test_lmstudio_provider.py index 85f64145f..1ea0a1e52 100644 --- a/tests/unit/providers/test_lmstudio_provider.py +++ b/tests/unit/providers/test_lmstudio_provider.py @@ -5,6 +5,7 @@ import pytest from any_llm import AnyLLM +from any_llm.constants import LLMProvider from any_llm.providers.lmstudio import utils from any_llm.providers.lmstudio.lmstudio import LmstudioProvider from any_llm.types.completion import ( @@ -118,7 +119,7 @@ def test_factory_integration() -> None: def test_model_provider_split() -> None: """Test that model string parsing works correctly.""" provider_enum, model_name = AnyLLM.split_model_provider("lmstudio:google/gemma-3-4b") - assert provider_enum.value == "lmstudio" + assert provider_enum is LLMProvider.LMSTUDIO assert model_name == "google/gemma-3-4b" diff --git a/tests/unit/providers/test_moonshot_provider.py b/tests/unit/providers/test_moonshot_provider.py index e9c6c3c42..69e1df3df 100644 --- a/tests/unit/providers/test_moonshot_provider.py +++ b/tests/unit/providers/test_moonshot_provider.py @@ -1,6 +1,7 @@ import pytest from any_llm import AnyLLM +from any_llm.constants import LLMProvider from any_llm.providers.moonshot.moonshot import MoonshotProvider @@ -35,7 +36,7 @@ def test_factory_integration() -> None: def test_model_provider_split() -> None: """Test that model string parsing works correctly.""" provider_enum, model_name = AnyLLM.split_model_provider("moonshot:moonshot-v1-8k") - assert provider_enum.value == "moonshot" + assert provider_enum is LLMProvider.MOONSHOT assert model_name == "moonshot-v1-8k" diff --git a/tests/unit/providers/test_perplexity_provider.py b/tests/unit/providers/test_perplexity_provider.py index 3016cc088..98936ab0c 100644 --- a/tests/unit/providers/test_perplexity_provider.py +++ b/tests/unit/providers/test_perplexity_provider.py @@ -1,6 +1,7 @@ import pytest from any_llm import AnyLLM +from any_llm.constants import LLMProvider from any_llm.providers.perplexity import PerplexityProvider @@ -35,7 +36,7 @@ def test_factory_integration() -> None: def test_model_provider_split() -> None: """Test that model string parsing works correctly.""" provider_enum, model_name = AnyLLM.split_model_provider("perplexity/llama-3.1-sonar-small-128k-chat") - assert provider_enum.value == "perplexity" + assert provider_enum is LLMProvider.PERPLEXITY assert model_name == "llama-3.1-sonar-small-128k-chat" diff --git a/tests/unit/test_registry.py b/tests/unit/test_registry.py index e219ecd5f..17f57e42c 100644 --- a/tests/unit/test_registry.py +++ b/tests/unit/test_registry.py @@ -1,8 +1,28 @@ from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, Mock, patch import pytest from any_llm.any_llm import AnyLLM +from any_llm.api import ( + acancel_batch, + acreate_batch, + alist_batches, + alist_models, + aretrieve_batch, + aretrieve_batch_results, + cancel_batch, + completion, + create_batch, + embedding, + image_generation, + list_batches, + list_models, + moderation, + retrieve_batch, + retrieve_batch_results, +) from any_llm.constants import LLMProvider from any_llm.exceptions import UnsupportedProviderError from any_llm.providers import registry @@ -114,3 +134,264 @@ def test_import_shim_returns_registry_class() -> None: assert AtlascloudProvider is get_registry_provider_class("atlascloud") assert DeepAtlascloudProvider is AtlascloudProvider + + +def test_registry_only_name_is_listed_as_supported(community_row: OpenAICompatibleProviderConfig) -> None: + assert "testgateway" in AnyLLM.get_supported_providers() + assert "testgateway" in AnyLLM.get_registry_provider_names() + + +def test_registry_provider_names_excludes_rows_that_have_an_enum_member() -> None: + # atlascloud is a row and an enum member, so it is already covered by the enum listing. + assert "atlascloud" not in AnyLLM.get_registry_provider_names() + assert "atlascloud" in AnyLLM.get_supported_providers() + + +def test_registry_only_name_appears_in_metadata(community_row: OpenAICompatibleProviderConfig) -> None: + metadata = {entry.name: entry for entry in AnyLLM.get_all_provider_metadata()} + assert metadata["testgateway"].env_key == "TESTGATEWAY_API_KEY" + assert metadata["testgateway"].class_name == "TestgatewayProvider" + + +def test_resolve_provider_key_returns_enum_member_when_one_exists() -> None: + assert AnyLLM.resolve_provider_key("openai") is LLMProvider.OPENAI + assert AnyLLM.resolve_provider_key(LLMProvider.OPENAI) is LLMProvider.OPENAI + + +def test_resolve_provider_key_returns_bare_name_for_registry_only_row( + community_row: OpenAICompatibleProviderConfig, +) -> None: + assert AnyLLM.resolve_provider_key("testgateway") == "testgateway" + + +def test_resolve_provider_key_normalizes_like_from_string(community_row: OpenAICompatibleProviderConfig) -> None: + assert AnyLLM.resolve_provider_key(" TestGateway ") == "testgateway" + assert AnyLLM.resolve_provider_key(" OpenAI ") is LLMProvider.OPENAI + + +def test_resolve_provider_key_unknown_name_lists_registry_names( + community_row: OpenAICompatibleProviderConfig, +) -> None: + with pytest.raises(UnsupportedProviderError) as excinfo: + AnyLLM.resolve_provider_key("not-a-provider") + assert "testgateway" in str(excinfo.value) + + +def test_string_routing_resolves_registry_only_row(community_row: OpenAICompatibleProviderConfig) -> None: + provider_key, model_id = AnyLLM.split_model_provider("testgateway:some-model") + assert provider_key == "testgateway" + assert model_id == "some-model" + assert AnyLLM.get_provider_class(provider_key) is get_registry_provider_class("testgateway") + + +def test_legacy_slash_routing_resolves_registry_only_row(community_row: OpenAICompatibleProviderConfig) -> None: + with pytest.deprecated_call(): + provider_key, model_id = AnyLLM.split_model_provider("testgateway/some-model") + assert provider_key == "testgateway" + assert model_id == "some-model" + + +def test_split_model_provider_still_returns_enum_for_enum_providers() -> None: + provider_key, model_id = AnyLLM.split_model_provider("openai:gpt-4o") + assert provider_key is LLMProvider.OPENAI + assert model_id == "gpt-4o" + + +def test_api_provider_argument_accepts_registry_only_row(community_row: OpenAICompatibleProviderConfig) -> None: + """The explicit provider= argument resolves registry rows, not just the enum.""" + mock_provider = Mock() + mock_provider.completion.return_value = Mock() + + with patch("any_llm.any_llm.AnyLLM.create") as mock_create: + mock_create.return_value = mock_provider + completion(model="some-model", provider="testgateway", messages=[{"role": "user", "content": "Hello"}]) + + assert mock_create.call_args.args[0] == "testgateway" + mock_provider.completion.assert_called_once() + + +def test_api_string_routing_accepts_registry_only_row(community_row: OpenAICompatibleProviderConfig) -> None: + mock_provider = Mock() + mock_provider.completion.return_value = Mock() + + with patch("any_llm.any_llm.AnyLLM.create") as mock_create: + mock_create.return_value = mock_provider + completion(model="testgateway:some-model", messages=[{"role": "user", "content": "Hello"}]) + + assert mock_create.call_args.args[0] == "testgateway" + mock_provider.completion.assert_called_once() + + +def test_unsupported_provider_error_lists_registry_names_from_get_provider_enum( + community_row: OpenAICompatibleProviderConfig, +) -> None: + """get_provider_enum's error must not omit registry-only gateways. + + Regression guard: its supported list was the enum alone, which diverged from + get_supported_providers() the moment a row without an enum member existed. + """ + with pytest.raises(UnsupportedProviderError) as excinfo: + AnyLLM.get_provider_enum("nonexistent") + assert excinfo.value.supported_providers == AnyLLM.get_supported_providers() + assert "testgateway" in excinfo.value.supported_providers + + +def test_get_provider_enum_still_rejects_registry_only_names( + community_row: OpenAICompatibleProviderConfig, +) -> None: + """A registry row has no enum member, so the enum accessor raises by design.""" + with pytest.raises(UnsupportedProviderError): + AnyLLM.get_provider_enum("testgateway") + # ...while the resolver accepts it. + assert AnyLLM.resolve_provider_key("testgateway") == "testgateway" + + +def test_create_unsupported_error_lists_registry_names(community_row: OpenAICompatibleProviderConfig) -> None: + """create() must report registry gateways in its supported list. + + Regression guard: create() and get_provider_class() resolved through + LLMProvider.from_string, so their error listed only the enum even after + get_provider_enum was fixed. + """ + with pytest.raises(UnsupportedProviderError) as excinfo: + AnyLLM.create("not-a-provider", api_key="k") + assert excinfo.value.supported_providers == AnyLLM.get_supported_providers() + assert "testgateway" in excinfo.value.supported_providers + + +def test_get_provider_class_unsupported_error_lists_registry_names( + community_row: OpenAICompatibleProviderConfig, +) -> None: + with pytest.raises(UnsupportedProviderError) as excinfo: + AnyLLM.get_provider_class("not-a-provider") + assert excinfo.value.supported_providers == AnyLLM.get_supported_providers() + assert "testgateway" in excinfo.value.supported_providers + + +@pytest.mark.parametrize( + ("api_function", "call_kwargs"), + [ + (completion, {"messages": [{"role": "user", "content": "hi"}]}), + (embedding, {"inputs": "hi"}), + (moderation, {"input": "hi"}), + (image_generation, {"prompt": "a cat"}), + ], + ids=["completion", "embedding", "moderation", "image_generation"], +) +def test_api_entry_points_resolve_registry_only_rows( + community_row: OpenAICompatibleProviderConfig, + api_function: Any, + call_kwargs: dict[str, Any], +) -> None: + """Every api.py entry point resolves a registry-only name, not just completion.""" + mock_provider = Mock() + + with patch("any_llm.any_llm.AnyLLM.create") as mock_create: + mock_create.return_value = mock_provider + api_function(model="some-model", provider="testgateway", **call_kwargs) + + assert mock_create.call_args.args[0] == "testgateway" + + +@pytest.mark.parametrize( + ("api_function", "call_kwargs"), + [ + (completion, {"messages": [{"role": "user", "content": "hi"}]}), + (embedding, {"inputs": "hi"}), + (moderation, {"input": "hi"}), + (image_generation, {"prompt": "a cat"}), + ], + ids=["completion", "embedding", "moderation", "image_generation"], +) +def test_api_entry_points_still_resolve_enum_providers(api_function: Any, call_kwargs: dict[str, Any]) -> None: + mock_provider = Mock() + + with patch("any_llm.any_llm.AnyLLM.create") as mock_create: + mock_create.return_value = mock_provider + api_function(model="some-model", provider="openai", **call_kwargs) + + assert mock_create.call_args.args[0] is LLMProvider.OPENAI + + +# The provider-only entry points take no model string, so a registry row is only +# reachable through the explicit provider= argument. +PROVIDER_ONLY_ENTRY_POINTS = [ + (list_models, {}), + (create_batch, {"input_file_path": "batch.jsonl", "endpoint": "/v1/chat/completions"}), + (retrieve_batch, {"batch_id": "batch-1"}), + (cancel_batch, {"batch_id": "batch-1"}), + (list_batches, {}), + (retrieve_batch_results, {"batch_id": "batch-1"}), +] +PROVIDER_ONLY_IDS = ["list_models", "create_batch", "retrieve_batch", "cancel_batch", "list_batches", "batch_results"] + + +@pytest.mark.parametrize(("api_function", "call_kwargs"), PROVIDER_ONLY_ENTRY_POINTS, ids=PROVIDER_ONLY_IDS) +def test_provider_only_entry_points_resolve_registry_only_rows( + community_row: OpenAICompatibleProviderConfig, + api_function: Any, + call_kwargs: dict[str, Any], +) -> None: + """list_models and the batch helpers take provider= without a model string. + + They resolved through LLMProvider.from_string, so a registry-only gateway was + rejected even though get_supported_providers() advertised it. + """ + mock_provider = Mock() + + with patch("any_llm.any_llm.AnyLLM.create") as mock_create: + mock_create.return_value = mock_provider + api_function(provider="testgateway", **call_kwargs) + + assert mock_create.call_args.args[0] == "testgateway" + + +@pytest.mark.parametrize(("api_function", "call_kwargs"), PROVIDER_ONLY_ENTRY_POINTS, ids=PROVIDER_ONLY_IDS) +def test_provider_only_entry_points_still_resolve_enum_providers( + api_function: Any, call_kwargs: dict[str, Any] +) -> None: + mock_provider = Mock() + + with patch("any_llm.any_llm.AnyLLM.create") as mock_create: + mock_create.return_value = mock_provider + api_function(provider="openai", **call_kwargs) + + assert mock_create.call_args.args[0] is LLMProvider.OPENAI + + +@pytest.mark.parametrize(("api_function", "call_kwargs"), PROVIDER_ONLY_ENTRY_POINTS, ids=PROVIDER_ONLY_IDS) +def test_provider_only_entry_points_report_registry_names_when_unresolvable( + community_row: OpenAICompatibleProviderConfig, + api_function: Any, + call_kwargs: dict[str, Any], +) -> None: + with pytest.raises(UnsupportedProviderError) as excinfo: + api_function(provider="not-a-provider", **call_kwargs) + assert excinfo.value.supported_providers == AnyLLM.get_supported_providers() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("api_function", "call_kwargs"), + [ + (alist_models, {}), + (acreate_batch, {"input_file_path": "batch.jsonl", "endpoint": "/v1/chat/completions"}), + (aretrieve_batch, {"batch_id": "batch-1"}), + (acancel_batch, {"batch_id": "batch-1"}), + (alist_batches, {}), + (aretrieve_batch_results, {"batch_id": "batch-1"}), + ], + ids=PROVIDER_ONLY_IDS, +) +async def test_async_provider_only_entry_points_resolve_registry_only_rows( + community_row: OpenAICompatibleProviderConfig, + api_function: Any, + call_kwargs: dict[str, Any], +) -> None: + mock_provider = AsyncMock() + + with patch("any_llm.any_llm.AnyLLM.create") as mock_create: + mock_create.return_value = mock_provider + await api_function(provider="testgateway", **call_kwargs) + + assert mock_create.call_args.args[0] == "testgateway"