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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions packages/providers/cohere/src/celeste_cohere/chat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 2 additions & 0 deletions packages/providers/xai/src/celeste_xai/responses/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
39 changes: 29 additions & 10 deletions src/celeste/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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).
Expand All @@ -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,
Expand All @@ -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,
)

Expand Down
28 changes: 24 additions & 4 deletions src/celeste/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 5 additions & 7 deletions tests/unit_tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading