diff --git a/CHANGELOG.md b/CHANGELOG.md index 040d34c8..631a8a12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### New features +* Providers now expose a `base_url` attribute (`chat.provider.base_url`) reporting the endpoint requests are sent to, when the provider can determine it. This is groundwork for richer telemetry (e.g. OpenTelemetry `server.address`/`server.port` attributes) and is handy for confirming which endpoint a provider resolved. * The result of `chat.files.get()` (and elements of `chat.files.list()`) can now be passed straight to `.chat()` (and anywhere else content is accepted), so a file uploaded in an earlier session can be reused from its id alone: `chat.chat("Summarize this.", chat.files.get("file_011CRabc..."))`. When the provider doesn't report a MIME type (OpenAI's Files API never does), it's guessed from the filename. For when you want the underlying `ContentUploaded` reference itself (e.g. to build a `UserTurn` by hand), `FileMetadata` gains a `.to_content()` method. This works via a new general convention: any object implementing `to_content() -> Content` (see `chatlas.types.SupportsContent`) is accepted anywhere content is passed, so third-party packages can make their own objects usable as chat inputs. (#417) ### Changes diff --git a/chatlas/_provider.py b/chatlas/_provider.py index eac98e85..e8e29840 100644 --- a/chatlas/_provider.py +++ b/chatlas/_provider.py @@ -152,9 +152,25 @@ class Provider( server-side tools, or naming a `Chat` constructor that can. """ - def __init__(self, *, name: str, model: str): + def __init__(self, *, name: str, model: str, base_url: str | None = None): self._name = name self._model = model + self._base_url = base_url + + @property + def base_url(self) -> str | None: + """ + The base URL the provider sends requests to, if known. + + Used for telemetry (e.g. the OpenTelemetry `server.address` and + `server.port` attributes). Providers that can't determine their + endpoint leave it as None. + """ + return self._base_url + + @base_url.setter + def base_url(self, value: str | None) -> None: + self._base_url = value def check_builtin_tool_support(self, tool: ToolBuiltIn) -> None: """ diff --git a/chatlas/_provider_anthropic.py b/chatlas/_provider_anthropic.py index 3a0b4e0e..4d817a6b 100644 --- a/chatlas/_provider_anthropic.py +++ b/chatlas/_provider_anthropic.py @@ -411,6 +411,7 @@ def __init__( # TODO: worth bringing in sync types? self._client = Anthropic(**sync_kwargs) # type: ignore self._async_client = AsyncAnthropic(**async_kwargs) + self._base_url = str(self._client.base_url) def close(self) -> None: self._client.close() diff --git a/chatlas/_provider_bedrock.py b/chatlas/_provider_bedrock.py index adcc3ae2..cadd2bfa 100644 --- a/chatlas/_provider_bedrock.py +++ b/chatlas/_provider_bedrock.py @@ -278,6 +278,7 @@ def __init__( ) self._client = BedrockOpenAI(**sync_kwargs) self._async_client = AsyncBedrockOpenAI(**async_kwargs) + self._base_url = resolved_base_url self._models_base_url = bedrock_models_base_url(resolved_base_url) def list_models(self) -> list[ModelInfo]: @@ -335,6 +336,7 @@ def __init__( ) ) self._client = AnthropicBedrockMantle(**sync_kwargs) + self._base_url = str(self._client.base_url) # Signing here runs on the event loop: the mantle client's async # _prepare_request() calls botocore synchronously, where the openai SDK # offloads it to a thread. Left alone deliberately -- signing a request diff --git a/chatlas/_provider_bedrock_converse.py b/chatlas/_provider_bedrock_converse.py index 6c16f859..e3843798 100644 --- a/chatlas/_provider_bedrock_converse.py +++ b/chatlas/_provider_bedrock_converse.py @@ -430,6 +430,7 @@ def __init__( self._cache: Literal["auto", "5m", "1h", "none"] = cache resolved_base_url = base_url or bedrock_base_url("converse", aws_region) + self._base_url = resolved_base_url # `LazyCredentials` defers the botocore credential chain to the first # signed request. Resolving it here instead (e.g. via # `bedrock_credentials(aws_profile)`, which eagerly freezes credentials diff --git a/chatlas/_provider_databricks.py b/chatlas/_provider_databricks.py index 929d364b..43858acc 100644 --- a/chatlas/_provider_databricks.py +++ b/chatlas/_provider_databricks.py @@ -151,6 +151,7 @@ def __init__( client = workspace_client.serving_endpoints.get_open_ai_client() self._client = client + self._base_url = str(client.base_url) # The databricks sdk does currently expose an async client, but we can # effectively mirror what .get_open_ai_client() does internally. diff --git a/chatlas/_provider_google.py b/chatlas/_provider_google.py index 4c750a76..0a5e8fa3 100644 --- a/chatlas/_provider_google.py +++ b/chatlas/_provider_google.py @@ -283,6 +283,15 @@ def __init__( self._client = genai.Client(**kwargs_full) + # The resolved endpoint (Gemini API or Vertex) lives in the client's + # private HTTP options; best-effort, for telemetry. + api_client = getattr(self._client, "_api_client", None) + http_options = getattr(api_client, "_http_options", None) + if isinstance(http_options, dict): + self._base_url = http_options.get("base_url") + else: + self._base_url = getattr(http_options, "base_url", None) + def close(self) -> None: self._client.close() diff --git a/chatlas/_provider_lmstudio.py b/chatlas/_provider_lmstudio.py index 8efc3e03..4acd2d54 100644 --- a/chatlas/_provider_lmstudio.py +++ b/chatlas/_provider_lmstudio.py @@ -142,6 +142,8 @@ def __init__(self, *, api_key, model, base_url, seed, name, kwargs): self.base_url = base_url def list_models(self): + # Always set from the constructor's (required) base_url argument + assert self.base_url is not None return lmstudio_model_info(self.base_url, api_key=self._client.api_key or "") diff --git a/chatlas/_provider_ollama.py b/chatlas/_provider_ollama.py index 4449fe7f..2bf26062 100644 --- a/chatlas/_provider_ollama.py +++ b/chatlas/_provider_ollama.py @@ -154,10 +154,13 @@ def __init__(self, *, api_key, model, base_url, seed, name, kwargs): self.base_url = base_url def list_models(self): + # Always set from the constructor's (required) base_url argument + assert self.base_url is not None return ollama_model_info(self.base_url) def _chat_perform_args(self, *args, **kwargs): if not self.model: + assert self.base_url is not None model_ids = ", ".join(m["id"] for m in ollama_model_info(self.base_url)) raise ValueError(f"Must specify model. Available models: {model_ids}") return super()._chat_perform_args(*args, **kwargs) diff --git a/chatlas/_provider_openai_azure.py b/chatlas/_provider_openai_azure.py index fccc13db..aa562517 100644 --- a/chatlas/_provider_openai_azure.py +++ b/chatlas/_provider_openai_azure.py @@ -160,6 +160,7 @@ def __init__( self._client = create_openai_client(AzureOpenAI, sync_kwargs) self._async_client = create_openai_client(AsyncAzureOpenAI, async_kwargs) + self._base_url = str(self._client.base_url) def ChatAzureOpenAICompletions( @@ -231,3 +232,4 @@ def __init__( self._client = create_openai_client(AzureOpenAI, sync_kwargs) self._async_client = create_openai_client(AsyncAzureOpenAI, async_kwargs) + self._base_url = str(self._client.base_url) diff --git a/chatlas/_provider_openai_generic.py b/chatlas/_provider_openai_generic.py index 159632f6..d6870d18 100644 --- a/chatlas/_provider_openai_generic.py +++ b/chatlas/_provider_openai_generic.py @@ -148,6 +148,7 @@ def __init__( self._client = create_openai_client(OpenAI, sync_kwargs) self._async_client = create_openai_client(AsyncOpenAI, async_kwargs) + self._base_url = str(self._client.base_url) def close(self) -> None: self._client.close() diff --git a/chatlas/_provider_posit.py b/chatlas/_provider_posit.py index d6b36b23..ecbbc89b 100644 --- a/chatlas/_provider_posit.py +++ b/chatlas/_provider_posit.py @@ -308,6 +308,7 @@ def __init__( }, ), ) + self._base_url = flavor_base_url def set_model(self, value: str) -> "PositAnthropicProvider | PositOpenAIProvider": if value.startswith("claude"): @@ -373,6 +374,7 @@ def __init__( }, ), ) + self._base_url = flavor_base_url def set_model(self, value: str) -> "PositAnthropicProvider | PositOpenAIProvider": if value.startswith("claude"): diff --git a/chatlas/_provider_snowflake.py b/chatlas/_provider_snowflake.py index bbc9710e..6b641f24 100644 --- a/chatlas/_provider_snowflake.py +++ b/chatlas/_provider_snowflake.py @@ -227,6 +227,17 @@ def __init__( self._session = session self._cortex_service = Root(self._session).cortex_inference_service + # Best-effort endpoint discovery, for telemetry. The Cortex REST API + # is served from the account host. + try: + conn = session.connection + host = getattr(conn, "host", None) + if host: + protocol = getattr(conn, "protocol", None) or "https" + self._base_url = f"{protocol}://{host}" + except Exception: + pass + def close(self) -> None: """ Close the underlying Snowpark session (and its Snowflake connection), diff --git a/tests/test_provider_base_url.py b/tests/test_provider_base_url.py new file mode 100644 index 00000000..76a6e6a9 --- /dev/null +++ b/tests/test_provider_base_url.py @@ -0,0 +1,36 @@ +"""Tests for the `Provider.base_url` attribute used by telemetry.""" + +from chatlas import ChatAnthropic, ChatGoogle, ChatGroq, ChatOpenAI +from chatlas._provider import Provider + + +def test_base_url_defaults_to_none(): + class MinimalProvider(Provider): + pass + + # Not a usable provider; just needs to be constructible + MinimalProvider.__abstractmethods__ = frozenset() + provider = MinimalProvider(name="Test", model="test-model") + assert provider.base_url is None + + provider.base_url = "https://example.com" + assert provider.base_url == "https://example.com" + + +def test_openai_base_url(): + assert ChatOpenAI().provider.base_url == "https://api.openai.com/v1/" + + +def test_openai_completions_base_url(): + assert ChatGroq().provider.base_url == "https://api.groq.com/openai/v1/" + + +def test_anthropic_base_url(): + assert ChatAnthropic().provider.base_url == "https://api.anthropic.com" + + +def test_google_base_url(): + assert ( + ChatGoogle().provider.base_url + == "https://generativelanguage.googleapis.com/" + )