diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/models.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/models.py index dd9c30ea..91b557d9 100644 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/models.py +++ b/packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/models.py @@ -61,28 +61,6 @@ TextGenerationParameter.OUTPUT_SCHEMA: Schema(), }, ), - Model( - id="open-mixtral-8x7b", - provider=Provider.MISTRAL, - display_name="Open Mixtral 8x7B", - streaming=True, - parameter_constraints={ - Parameter.TEMPERATURE: Range(min=0.0, max=2.0, step=0.01), - Parameter.MAX_TOKENS: Range(min=1, max=32768, step=1), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), - }, - ), - Model( - id="open-mixtral-8x22b", - provider=Provider.MISTRAL, - display_name="Open Mixtral 8x22B", - streaming=True, - parameter_constraints={ - Parameter.TEMPERATURE: Range(min=0.0, max=2.0, step=0.01), - Parameter.MAX_TOKENS: Range(min=1, max=32768, step=1), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), - }, - ), Model( id="codestral-latest", provider=Provider.MISTRAL, diff --git a/packages/providers/anthropic/src/celeste_anthropic/messages/config.py b/packages/providers/anthropic/src/celeste_anthropic/messages/config.py index c2acbe6e..de7ed7e9 100644 --- a/packages/providers/anthropic/src/celeste_anthropic/messages/config.py +++ b/packages/providers/anthropic/src/celeste_anthropic/messages/config.py @@ -8,6 +8,8 @@ class AnthropicMessagesEndpoint(StrEnum): CREATE_MESSAGE = "/v1/messages" COUNT_MESSAGE_TOKENS = "/v1/messages/count_tokens" + LIST_MODELS = "/v1/models" + GET_MODEL = "/v1/models/{model_id}" BASE_URL = "https://api.anthropic.com" diff --git a/packages/providers/cohere/src/celeste_cohere/chat/config.py b/packages/providers/cohere/src/celeste_cohere/chat/config.py index 5c93fd32..fc6e2c92 100644 --- a/packages/providers/cohere/src/celeste_cohere/chat/config.py +++ b/packages/providers/cohere/src/celeste_cohere/chat/config.py @@ -7,6 +7,8 @@ class CohereChatEndpoint(StrEnum): """Endpoints for Chat API.""" CREATE_CHAT = "/v2/chat" + LIST_MODELS = "/v2/models" + GET_MODEL = "/v2/models/{model_id}" BASE_URL = "https://api.cohere.com" diff --git a/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/config.py b/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/config.py index aea0be89..5b0a989d 100644 --- a/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/config.py +++ b/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/config.py @@ -9,6 +9,7 @@ class ElevenLabsTextToSpeechEndpoint(StrEnum): CREATE_SPEECH = "/v1/text-to-speech/{voice_id}" STREAM_SPEECH = "/v1/text-to-speech/{voice_id}/stream" LIST_VOICES = "/v1/voices" + LIST_MODELS = "/v1/models" BASE_URL = "https://api.elevenlabs.io" diff --git a/packages/providers/openai/src/celeste_openai/responses/config.py b/packages/providers/openai/src/celeste_openai/responses/config.py index 24282c09..0aec0e53 100644 --- a/packages/providers/openai/src/celeste_openai/responses/config.py +++ b/packages/providers/openai/src/celeste_openai/responses/config.py @@ -7,6 +7,8 @@ class OpenAIResponsesEndpoint(StrEnum): """Endpoints for Responses API.""" CREATE_RESPONSE = "/v1/responses" + LIST_MODELS = "/v1/models" + GET_MODEL = "/v1/models/{model_id}" BASE_URL = "https://api.openai.com" diff --git a/packages/providers/xai/src/celeste_xai/responses/config.py b/packages/providers/xai/src/celeste_xai/responses/config.py index c28648e9..d640927d 100644 --- a/packages/providers/xai/src/celeste_xai/responses/config.py +++ b/packages/providers/xai/src/celeste_xai/responses/config.py @@ -7,6 +7,8 @@ class XAIResponsesEndpoint(StrEnum): """Endpoints for Responses API.""" CREATE_RESPONSE = "/v1/responses" + LIST_MODELS = "/v1/models" + GET_MODEL = "/v1/models/{model_id}" BASE_URL = "https://api.x.ai" diff --git a/src/celeste/__init__.py b/src/celeste/__init__.py index d3dc96c4..16790938 100644 --- a/src/celeste/__init__.py +++ b/src/celeste/__init__.py @@ -37,13 +37,28 @@ logger = logging.getLogger(__name__) +def _infer_capability(model: Model) -> Capability: + """Infer capability from model. Raises if ambiguous.""" + if len(model.capabilities) == 1: + return next(iter(model.capabilities)) + if len(model.capabilities) > 1: + caps = ", ".join(c.value for c in model.capabilities) + msg = f"Model '{model.id}' supports multiple capabilities: {caps}. Specify 'capability' explicitly." + raise ValueError(msg) + msg = f"Model '{model.id}' has no registered capabilities" + raise ValueError(msg) + + def _resolve_model( - capability: Capability, + capability: Capability | None, provider: Provider | None, model: Model | str | None, ) -> Model: """Resolve model parameter to Model object (auto-select if None, lookup if string).""" if model is None: + if capability is None: + msg = "Either 'capability' or 'model' must be provided" + raise ValueError(msg) # Auto-select first available model models = list_models(provider=provider, capability=capability) if not models: @@ -54,10 +69,6 @@ def _resolve_model( return models[0] if isinstance(model, str): - # String ID requires provider - if not provider: - msg = "provider required when model is a string ID" - raise ValueError(msg) found = get_model(model, provider) if not found: raise ModelNotFoundError(model_id=model, provider=provider) @@ -67,7 +78,7 @@ def _resolve_model( def create_client( - capability: Capability, + capability: Capability | None = None, provider: Provider | None = None, model: Model | str | None = None, api_key: str | SecretStr | None = None, @@ -76,8 +87,10 @@ def create_client( """Create an async client for the specified AI capability. Args: - capability: The AI capability to use (e.g., TEXT_GENERATION). - provider: Optional provider. Required if model is a string ID. + capability: The AI capability to use. If not provided and model is specified, + capability is inferred from the model (if unambiguous). + provider: Optional provider. If not specified and model ID matches multiple + providers, the first match is used with a warning. model: Model object, string model ID, or None for auto-selection. api_key: Optional API key override (string or SecretStr). auth: Optional Authentication object for custom auth (e.g., GoogleADC). @@ -90,14 +103,20 @@ def create_client( ClientNotFoundError: If no client registered for capability/provider. MissingCredentialsError: If required credentials are not configured. UnsupportedCapabilityError: If the resolved model doesn't support the requested capability. + ValueError: If capability cannot be inferred from model. """ # Load packages lazily when create_client is called _load_from_entry_points() # Resolve model resolved_model = _resolve_model(capability, provider, model) + # Infer capability if not provided + resolved_capability = ( + capability if capability else _infer_capability(resolved_model) + ) + # Get client class and authentication - client_class = get_client_class(capability, resolved_model.provider) + client_class = get_client_class(resolved_capability, resolved_model.provider) resolved_auth = credentials.get_auth( resolved_model.provider, override_auth=auth, @@ -108,7 +127,7 @@ def create_client( return client_class( model=resolved_model, provider=resolved_model.provider, - capability=capability, + capability=resolved_capability, auth=resolved_auth, ) diff --git a/src/celeste/models.py b/src/celeste/models.py index 5b6382bc..ddf015d5 100644 --- a/src/celeste/models.py +++ b/src/celeste/models.py @@ -68,17 +68,37 @@ def register_models(models: Model | list[Model], capability: Capability) -> None registered.parameter_constraints.update(model.parameter_constraints) -def get_model(model_id: str, provider: Provider) -> Model | None: - """Get a registered model by ID and provider. +def get_model(model_id: str, provider: Provider | None = None) -> Model | None: + """Get a registered model by ID, optionally filtered by provider. Args: model_id: The model identifier. - provider: The provider that owns the model. + provider: Optional provider. If None and multiple providers have the + model, returns the first match and emits a warning. Returns: Model instance if found, None otherwise. """ - return _models.get((model_id, provider)) + if provider is not None: + return _models.get((model_id, provider)) + + # Search across all providers + matches = [m for m in _models.values() if m.id == model_id] + if not matches: + return None + + if len(matches) > 1: + import warnings + + providers = ", ".join(m.provider.value for m in matches) + warnings.warn( + f"Model '{model_id}' found in multiple providers: {providers}. " + f"Using '{matches[0].provider.value}'.", + UserWarning, + stacklevel=2, + ) + + return matches[0] def list_models( diff --git a/tests/unit_tests/test_init.py b/tests/unit_tests/test_init.py index e4affcd5..08e31e94 100644 --- a/tests/unit_tests/test_init.py +++ b/tests/unit_tests/test_init.py @@ -91,15 +91,13 @@ def test_create_client_uses_explicit_model_when_both_provided( mock_get_model.assert_called_once_with("claude-3", Provider.ANTHROPIC) - def test_create_client_string_model_without_provider_raises_error(self) -> None: - """Test that string model ID without provider raises ValueError.""" - # Act & Assert - with pytest.raises( - ValueError, match="provider required when model is a string ID" - ): + def test_create_client_string_model_not_found_raises_error(self) -> None: + """Test that unknown model ID raises ModelNotFoundError.""" + # Act & Assert - model lookup searches all providers, raises if not found + with pytest.raises(ModelNotFoundError, match=r"Model.*not found"): create_client( capability=Capability.TEXT_GENERATION, - model="some-model", # provider=None - should error + model="nonexistent-model", ) def test_create_client_filters_by_provider_when_specified(