Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion chatlas/_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down
1 change: 1 addition & 0 deletions chatlas/_provider_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions chatlas/_provider_bedrock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions chatlas/_provider_bedrock_converse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions chatlas/_provider_databricks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions chatlas/_provider_google.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
2 changes: 2 additions & 0 deletions chatlas/_provider_lmstudio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "")


Expand Down
3 changes: 3 additions & 0 deletions chatlas/_provider_ollama.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions chatlas/_provider_openai_azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
1 change: 1 addition & 0 deletions chatlas/_provider_openai_generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions chatlas/_provider_posit.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ def __init__(
},
),
)
self._base_url = flavor_base_url

def set_model(self, value: str) -> "PositAnthropicProvider | PositOpenAIProvider":
if value.startswith("claude"):
Expand Down Expand Up @@ -373,6 +374,7 @@ def __init__(
},
),
)
self._base_url = flavor_base_url

def set_model(self, value: str) -> "PositAnthropicProvider | PositOpenAIProvider":
if value.startswith("claude"):
Expand Down
11 changes: 11 additions & 0 deletions chatlas/_provider_snowflake.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
36 changes: 36 additions & 0 deletions tests/test_provider_base_url.py
Original file line number Diff line number Diff line change
@@ -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/"
)
Loading