diff --git a/docs/api.md b/docs/api.md index 0d3d339..5d94005 100644 --- a/docs/api.md +++ b/docs/api.md @@ -167,11 +167,12 @@ from molecode.prompts import MARKUSH_SYSTEM_PROMPT # Markush + image→MoleC ## `molecode.llm` -An optional, dependency-free, OpenAI-compatible chat client. You supply the API -key and base URL — nothing is hard-coded. +An optional, dependency-free client for OpenAI Chat Completions and Anthropic +Messages. Supply a base URL directly or select a provider and region preset. ```python -class LLMClient(api_key=None, base_url=None, model=None, *, timeout=120.0, default_temperature=0.0) +class LLMClient(api_key=None, base_url=None, model=None, *, provider=None, region=None, + transport=None, timeout=120.0, default_temperature=0.0) .chat(user, system=None, *, images=None, model=None, temperature=None, **extra) -> str .complete(messages, *, model=None, temperature=None, **extra) -> str @@ -179,11 +180,16 @@ call_llm(system, user, *, temperature=0.0, **client_kwargs) -> Optional[str] image_to_data_uri(path_or_url: str) -> str DEFAULT_MODEL # 'gemini-3.1-pro-preview' DEFAULT_BASE_URL # 'https://api.openai.com/v1' +PROVIDER_PRESETS # regional OpenAI and Anthropic base URLs +PROVIDER_MODELS # provider defaults, supported model IDs, and model metadata ``` Credentials fall back to env vars `MOLECODE_API_KEY` / `OPENAI_API_KEY`, -`MOLECODE_BASE_URL`, `MOLECODE_MODEL`. Pass `images=[...]` (file paths or URLs) to -a vision model for OCSR (molecule image → MoleCode). +`MOLECODE_BASE_URL`, `MOLECODE_MODEL`, `MOLECODE_PROVIDER`, `MOLECODE_REGION`, +and `MOLECODE_TRANSPORT`. Anthropic transport also recognizes +`ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, and `ANTHROPIC_BASE_URL`. Pass +`images=[...]` (file paths or URLs) to a vision model for OCSR (molecule image -> +MoleCode). ```python from molecode import LLMClient @@ -191,6 +197,9 @@ from molecode.prompts import MOLECULE_SYSTEM_PROMPT, MARKUSH_SYSTEM_PROMPT client = LLMClient(api_key="sk-...", base_url="https://api.openai.com/v1", model="gpt-4o") +minimax = LLMClient(api_key="...", provider="minimax", region="global_en", + transport="anthropic", model="MiniMax-M3") + # text task client.chat("How many carbons are in this molecule? ...", system=MOLECULE_SYSTEM_PROMPT) diff --git a/molecode/llm.py b/molecode/llm.py index 2217b43..00b8b1f 100644 --- a/molecode/llm.py +++ b/molecode/llm.py @@ -4,10 +4,9 @@ module is an *optional* convenience so you can run the understanding / generation / editing / reasoning workflows without wiring up an SDK yourself. -``LLMClient`` speaks the **OpenAI Chat Completions** protocol over plain stdlib -``urllib`` (no third-party packages), so it works with any OpenAI-compatible -endpoint — OpenAI, Azure OpenAI, DeepSeek, Together, vLLM, Ollama, etc. You -supply the API key and base URL; nothing is hard-coded. +``LLMClient`` speaks the **OpenAI Chat Completions** and **Anthropic Messages** +protocols over plain stdlib ``urllib`` (no third-party packages). You supply the +API key and can either choose a provider preset or pass a base URL directly. from molecode.llm import LLMClient from molecode.prompts import MOLECULE_SYSTEM_PROMPT @@ -20,12 +19,33 @@ Credentials may also come from the environment (so you never commit a key): MOLECODE_API_KEY (or OPENAI_API_KEY) — required - MOLECODE_BASE_URL — default https://api.openai.com/v1 - MOLECODE_MODEL — default gemini-3.1-pro-preview + MOLECODE_BASE_URL - explicit transport base URL + MOLECODE_MODEL - model override + MOLECODE_PROVIDER - provider preset name + MOLECODE_REGION - provider region + MOLECODE_TRANSPORT - openai (default) or anthropic Prefer the official ``openai`` SDK? You don't need this class at all — the MoleCode prompts are plain strings, so pass them straight to ``openai.OpenAI().chat.completions.create(...)``. + +Provider presets +---------------- +``PROVIDER_PRESETS`` maps a provider and region to OpenAI Chat Completions and +Anthropic Messages base URLs, so callers can opt into either transport without +hard-coding URLs:: + + from molecode.llm import LLMClient, PROVIDER_PRESETS + + client = LLMClient(api_key="...", provider="minimax", region="global_en", + model="MiniMax-M3") + client = LLMClient(api_key="...", provider="minimax", region="cn_zh", + transport="anthropic", model="MiniMax-M2.7") + +A preset resolves ``base_url`` from ``provider`` + ``region`` + ``transport``. +An explicit ``base_url`` always wins, followed by the matching environment +variable. The provider also supplies its current default model when ``model`` +and ``MOLECODE_MODEL`` are unset. """ from __future__ import annotations @@ -38,6 +58,52 @@ DEFAULT_BASE_URL = "https://api.openai.com/v1" DEFAULT_MODEL = "gemini-3.1-pro-preview" +SUPPORTED_TRANSPORTS = ("openai", "anthropic") + +# Provider-specific API base URLs, keyed by provider, region, then transport. +PROVIDER_PRESETS: Dict[str, Dict[str, Dict[str, str]]] = { + "minimax": { + "global_en": { + "openai": "https://api.minimax.io/v1", + "anthropic": "https://api.minimax.io/anthropic", + }, + "cn_zh": { + "openai": "https://api.minimaxi.com/v1", + "anthropic": "https://api.minimaxi.com/anthropic", + }, + }, +} + +PROVIDER_MODELS: Dict[str, Dict[str, Any]] = { + "minimax": { + "default": "MiniMax-M3", + "models": ("MiniMax-M3", "MiniMax-M2.7"), + "metadata": { + "MiniMax-M3": { + "context_window": 1_000_000, + "pricing_usd_per_million_tokens": { + "input": 0.6, + "output": 2.4, + "cache_read": 0.12, + "cache_write": None, + }, + "input_modalities": ("text", "image", "video"), + "thinking": ("adaptive", "disabled"), + }, + "MiniMax-M2.7": { + "context_window": 204_800, + "pricing_usd_per_million_tokens": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06, + "cache_write": 0.375, + }, + "input_modalities": ("text",), + "thinking": ("always_on",), + }, + }, + }, +} _IMAGE_MIME = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", @@ -45,6 +111,10 @@ } +class MissingAPIKeyError(ValueError): + """Raised when no API key is available for an LLM request.""" + + def image_to_data_uri(path_or_url: str) -> str: """Return a value suitable for an OpenAI ``image_url`` content block. @@ -64,18 +134,29 @@ def image_to_data_uri(path_or_url: str) -> str: class LLMClient: - """OpenAI-compatible chat client. You provide ``api_key`` and ``base_url``. + """Minimal OpenAI Chat Completions and Anthropic Messages client. Parameters ---------- api_key: Bearer token. Falls back to ``$MOLECODE_API_KEY`` then ``$OPENAI_API_KEY``. base_url: - Chat-completions base URL (without the ``/chat/completions`` suffix). - Falls back to ``$MOLECODE_BASE_URL`` then ``https://api.openai.com/v1``. + Transport base URL without ``/chat/completions`` or ``/v1/messages``. + An explicit value wins over environment variables and provider presets. model: - Default model name. Falls back to ``$MOLECODE_MODEL`` then - ``gemini-3.1-pro-preview``. + Default model name. Falls back to ``$MOLECODE_MODEL``, the provider's + default model, then ``gemini-3.1-pro-preview``. + provider: + Optional provider name (e.g. ``"minimax"``). When set, the base URL is + resolved from ``PROVIDER_PRESETS`` using ``region`` unless ``base_url`` + is given explicitly. Falls back to ``$MOLECODE_PROVIDER``. + region: + Region key for the provider preset (e.g. ``"global_en"`` or + ``"cn_zh"``). Falls back to ``$MOLECODE_REGION`` then the preset's first + region. + transport: + ``"openai"`` (default) or ``"anthropic"``. Falls back to + ``$MOLECODE_TRANSPORT``. timeout: Per-request timeout in seconds. default_temperature: @@ -88,26 +169,87 @@ def __init__( base_url: Optional[str] = None, model: Optional[str] = None, *, + provider: Optional[str] = None, + region: Optional[str] = None, + transport: Optional[str] = None, timeout: float = 120.0, default_temperature: float = 0.0, ) -> None: + self.transport = ( + transport or os.environ.get("MOLECODE_TRANSPORT") or "openai" + ).lower() + if self.transport not in SUPPORTED_TRANSPORTS: + supported = ", ".join(SUPPORTED_TRANSPORTS) + raise ValueError( + f"Unsupported transport {self.transport!r}; expected {supported}." + ) self.api_key = ( api_key or os.environ.get("MOLECODE_API_KEY") + or ( + os.environ.get("ANTHROPIC_API_KEY") + if self.transport == "anthropic" + else None + ) + or ( + os.environ.get("ANTHROPIC_AUTH_TOKEN") + if self.transport == "anthropic" + else None + ) or os.environ.get("OPENAI_API_KEY") ) if not self.api_key: - raise ValueError( + raise MissingAPIKeyError( "No API key provided. Pass api_key=... or set the " - "MOLECODE_API_KEY (or OPENAI_API_KEY) environment variable." + "MOLECODE_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, or " + "ANTHROPIC_AUTH_TOKEN environment variable." ) - self.base_url = ( - base_url or os.environ.get("MOLECODE_BASE_URL") or DEFAULT_BASE_URL - ).rstrip("/") - self.model = model or os.environ.get("MOLECODE_MODEL") or DEFAULT_MODEL + configured_provider = provider or os.environ.get("MOLECODE_PROVIDER") + self.provider = configured_provider.lower() if configured_provider else None + self.region = region or os.environ.get("MOLECODE_REGION") + self.base_url = self._resolve_base_url(base_url).rstrip("/") + self.model = ( + model + or os.environ.get("MOLECODE_MODEL") + or self._provider_default_model() + or DEFAULT_MODEL + ) self.timeout = timeout self.default_temperature = default_temperature + def _resolve_base_url(self, base_url: Optional[str]) -> str: + """Resolve the chat base URL from an explicit value, preset, or env.""" + if base_url: + return base_url + transport_env = ( + "ANTHROPIC_BASE_URL" + if self.transport == "anthropic" + else "OPENAI_BASE_URL" + ) + env_url = os.environ.get("MOLECODE_BASE_URL") or os.environ.get(transport_env) + if env_url: + return env_url + if self.provider and self.provider in PROVIDER_PRESETS: + regions = PROVIDER_PRESETS[self.provider] + key = self.region if self.region and self.region in regions else None + if key is None: + key = next(iter(regions)) + return regions[key][self.transport] + if self.transport == "anthropic": + raise ValueError( + "Anthropic transport requires base_url, ANTHROPIC_BASE_URL, " + "or a provider preset." + ) + return DEFAULT_BASE_URL + + def _provider_default_model(self) -> Optional[str]: + if not self.provider: + return None + provider_models = PROVIDER_MODELS.get(self.provider) + if not provider_models: + return None + return provider_models["default"] + def chat( self, user: str, @@ -142,13 +284,21 @@ def chat( def complete( self, - messages: List[Dict[str, str]], + messages: List[Dict[str, Any]], *, model: Optional[str] = None, temperature: Optional[float] = None, **extra: Any, ) -> str: """Send a full ``messages`` list, return the assistant's text content.""" + if self.transport == "anthropic": + return self._complete_anthropic( + messages, + model=model, + temperature=temperature, + **extra, + ) + payload: Dict[str, Any] = { "model": model or self.model, "messages": messages, @@ -157,26 +307,118 @@ def complete( ), **extra, } - request = urllib.request.Request( + data = self._post_json( f"{self.base_url}/chat/completions", - data=json.dumps(payload).encode("utf-8"), - headers={ + payload, + { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", }, + ) + return data["choices"][0]["message"]["content"] + + def _complete_anthropic( + self, + messages: List[Dict[str, Any]], + *, + model: Optional[str], + temperature: Optional[float], + **extra: Any, + ) -> str: + system_parts: List[str] = [] + converted_messages: List[Dict[str, Any]] = [] + for message in messages: + role = message["role"] + content = message["content"] + if role == "system": + if isinstance(content, str): + system_parts.append(content) + else: + system_parts.extend( + block["text"] + for block in content + if block.get("type") == "text" + ) + continue + converted_messages.append( + {"role": role, "content": self._anthropic_content(content)} + ) + + resolved_temperature = ( + self.default_temperature if temperature is None else temperature + ) + payload: Dict[str, Any] = { + "model": model or self.model, + "messages": converted_messages, + **extra, + } + # MiniMax's Anthropic-compatible endpoint defaults temperature to 1.0 + # and rejects 0.0, which is this client's cross-transport default. + if self.provider != "minimax" or resolved_temperature > 0: + payload["temperature"] = resolved_temperature + if system_parts: + payload["system"] = "\n\n".join(system_parts) + + data = self._post_json( + f"{self.base_url}/v1/messages", + payload, + { + "X-Api-Key": self.api_key, + "Content-Type": "application/json", + }, + ) + return "".join( + block["text"] + for block in data["content"] + if block.get("type") == "text" + ) + + @staticmethod + def _anthropic_content(content: Any) -> Any: + if isinstance(content, str): + return content + + converted: List[Dict[str, Any]] = [] + for block in content: + if block.get("type") != "image_url": + converted.append(block) + continue + image_url = block["image_url"] + value = image_url["url"] if isinstance(image_url, dict) else image_url + if value.startswith("data:"): + metadata, data = value.split(",", 1) + media_type = metadata[5:].split(";", 1)[0] + source = { + "type": "base64", + "media_type": media_type, + "data": data, + } + else: + source = {"type": "url", "url": value} + converted.append({"type": "image", "source": source}) + return converted + + def _post_json( + self, + url: str, + payload: Dict[str, Any], + headers: Dict[str, str], + ) -> Dict[str, Any]: + request = urllib.request.Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers=headers, method="POST", ) try: with urllib.request.urlopen(request, timeout=self.timeout) as resp: - data = json.loads(resp.read().decode("utf-8")) + return json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as exc: # surface the server's message detail = exc.read().decode("utf-8", errors="replace") raise RuntimeError(f"LLM API HTTP {exc.code}: {detail}") from exc except urllib.error.URLError as exc: raise RuntimeError(f"LLM API connection error: {exc.reason}") from exc - return data["choices"][0]["message"]["content"] - def call_llm( system: str, @@ -193,6 +435,6 @@ def call_llm( """ try: client = LLMClient(**client_kwargs) - except ValueError: + except MissingAPIKeyError: return None return client.chat(user, system=system, temperature=temperature) diff --git a/tests/test_llm_provider_presets.py b/tests/test_llm_provider_presets.py new file mode 100644 index 0000000..8e62023 --- /dev/null +++ b/tests/test_llm_provider_presets.py @@ -0,0 +1,362 @@ +"""Tests for the provider/region preset support in ``molecode.llm``. + +These exercise URL/model resolution offline (no network) so they run anywhere. +""" + +import json +import os +from unittest import mock + +import pytest + +from molecode import llm as llm_mod +from molecode.llm import LLMClient, PROVIDER_MODELS, PROVIDER_PRESETS + + +class _FakeResponse: + def __init__(self, payload): + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return json.dumps(self.payload).encode("utf-8") + + +def _capture_urlopen(captured, response): + def fake_urlopen(request, timeout=None): + captured["url"] = request.full_url + captured["headers"] = request.headers + captured["payload"] = json.loads(request.data.decode("utf-8")) + return _FakeResponse(response) + + return fake_urlopen + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + """Ensure provider/base_url/model env vars do not leak between tests.""" + for name in ( + "MOLECODE_API_KEY", + "OPENAI_API_KEY", + "MOLECODE_BASE_URL", + "OPENAI_BASE_URL", + "MOLECODE_MODEL", + "MOLECODE_PROVIDER", + "MOLECODE_REGION", + "MOLECODE_TRANSPORT", + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + ): + monkeypatch.delenv(name, raising=False) + yield + + +def test_minimax_preset_global_endpoint(): + client = LLMClient( + api_key="dummy", + provider="minimax", + region="global_en", + model="MiniMax-M3", + ) + assert client.base_url == "https://api.minimax.io/v1" + assert client.model == "MiniMax-M3" + assert client.provider == "minimax" + assert client.region == "global_en" + + +def test_minimax_preset_china_endpoint(): + client = LLMClient( + api_key="dummy", + provider="minimax", + region="cn_zh", + model="MiniMax-M2.7", + ) + assert client.base_url == "https://api.minimaxi.com/v1" + assert client.model == "MiniMax-M2.7" + + +def test_provider_is_case_insensitive(): + client = LLMClient(api_key="dummy", provider="MiniMax", model="MiniMax-M3") + assert client.base_url == "https://api.minimax.io/v1" + assert client.provider == "minimax" + + +def test_explicit_base_url_wins_over_preset(): + client = LLMClient( + api_key="dummy", + provider="minimax", + base_url="https://custom.example.com/v1", + model="MiniMax-M3", + ) + assert client.base_url == "https://custom.example.com/v1" + + +def test_unknown_region_falls_back_to_first(): + client = LLMClient( + api_key="dummy", + provider="minimax", + region="mars", + model="MiniMax-M3", + ) + # First region in the preset dict is global_en. + assert client.base_url == "https://api.minimax.io/v1" + + +def test_no_provider_uses_openai_default(): + client = LLMClient(api_key="dummy") + assert client.base_url == "https://api.openai.com/v1" + + +def test_minimax_uses_current_default_model(): + client = LLMClient(api_key="dummy", provider="minimax") + assert client.model == "MiniMax-M3" + assert PROVIDER_MODELS["minimax"]["models"] == ( + "MiniMax-M3", + "MiniMax-M2.7", + ) + + +def test_minimax_model_metadata_is_current(): + metadata = PROVIDER_MODELS["minimax"]["metadata"] + assert metadata == { + "MiniMax-M3": { + "context_window": 1_000_000, + "pricing_usd_per_million_tokens": { + "input": 0.6, + "output": 2.4, + "cache_read": 0.12, + "cache_write": None, + }, + "input_modalities": ("text", "image", "video"), + "thinking": ("adaptive", "disabled"), + }, + "MiniMax-M2.7": { + "context_window": 204_800, + "pricing_usd_per_million_tokens": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06, + "cache_write": 0.375, + }, + "input_modalities": ("text",), + "thinking": ("always_on",), + }, + } + + +def test_provider_via_environment(): + with mock.patch.dict( + os.environ, + {"MOLECODE_PROVIDER": "minimax", "MOLECODE_REGION": "cn_zh"}, + ): + client = LLMClient(api_key="dummy", model="MiniMax-M3") + assert client.base_url == "https://api.minimaxi.com/v1" + + +def test_base_url_env_wins_over_provider_env(): + with mock.patch.dict( + os.environ, + { + "MOLECODE_PROVIDER": "minimax", + "MOLECODE_BASE_URL": "https://env.example.com/v1", + }, + ): + client = LLMClient(api_key="dummy", model="MiniMax-M3") + assert client.base_url == "https://env.example.com/v1" + + +def test_chat_completions_url_assembles_from_preset(): + client = LLMClient( + api_key="dummy", + provider="minimax", + region="global_en", + model="MiniMax-M3", + ) + # complete() posts to "/chat/completions"; verify without a + # network call by stubbing urlopen. + captured = {} + response = {"choices": [{"message": {"content": "ok"}}]} + with mock.patch.object( + llm_mod.urllib.request, + "urlopen", + _capture_urlopen(captured, response), + ): + out = client.chat("hi") + assert out == "ok" + assert captured["url"] == "https://api.minimax.io/v1/chat/completions" + assert captured["headers"]["Authorization"] == "Bearer dummy" + + +def test_openai_image_request_path_is_unchanged(): + client = LLMClient(api_key="dummy", provider="minimax", model="MiniMax-M3") + captured = {} + response = {"choices": [{"message": {"content": "ok"}}]} + with mock.patch.object( + llm_mod.urllib.request, + "urlopen", + _capture_urlopen(captured, response), + ): + out = client.chat("describe", images=["https://x.example.com/a.png"]) + + assert out == "ok" + assert captured["payload"]["messages"][0]["content"] == [ + {"type": "text", "text": "describe"}, + { + "type": "image_url", + "image_url": {"url": "https://x.example.com/a.png"}, + }, + ] + + +def test_presets_contain_both_minimax_regions(): + assert "minimax" in PROVIDER_PRESETS + regions = PROVIDER_PRESETS["minimax"] + assert regions["global_en"] == { + "openai": "https://api.minimax.io/v1", + "anthropic": "https://api.minimax.io/anthropic", + } + assert regions["cn_zh"] == { + "openai": "https://api.minimaxi.com/v1", + "anthropic": "https://api.minimaxi.com/anthropic", + } + + +@pytest.mark.parametrize( + ("region", "base_url"), + [ + ("global_en", "https://api.minimax.io/anthropic"), + ("cn_zh", "https://api.minimaxi.com/anthropic"), + ], +) +def test_anthropic_transport_uses_regional_endpoint(region, base_url): + client = LLMClient( + api_key="dummy", + provider="minimax", + region=region, + transport="anthropic", + ) + assert client.base_url == base_url + assert client.model == "MiniMax-M3" + + +def test_anthropic_messages_request_and_response(): + client = LLMClient( + api_key="dummy", + provider="minimax", + region="global_en", + transport="anthropic", + ) + captured = {} + response = { + "content": [ + {"type": "thinking", "thinking": "..."}, + {"type": "text", "text": "first"}, + {"type": "text", "text": " second"}, + ] + } + with mock.patch.object( + llm_mod.urllib.request, + "urlopen", + _capture_urlopen(captured, response), + ): + out = client.chat( + "describe", + system="Return MoleCode.", + images=["https://x.example.com/a.png"], + max_tokens=512, + thinking={"type": "adaptive"}, + ) + + assert out == "first second" + assert captured["url"] == "https://api.minimax.io/anthropic/v1/messages" + assert captured["headers"]["X-api-key"] == "dummy" + assert captured["payload"] == { + "model": "MiniMax-M3", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "describe"}, + { + "type": "image", + "source": { + "type": "url", + "url": "https://x.example.com/a.png", + }, + }, + ], + } + ], + "max_tokens": 512, + "thinking": {"type": "adaptive"}, + "system": "Return MoleCode.", + } + + +def test_anthropic_messages_sends_positive_temperature(): + client = LLMClient( + api_key="dummy", + provider="minimax", + transport="anthropic", + ) + captured = {} + response = {"content": [{"type": "text", "text": "ok"}]} + with mock.patch.object( + llm_mod.urllib.request, + "urlopen", + _capture_urlopen(captured, response), + ): + assert client.chat("hi", temperature=0.5) == "ok" + + assert captured["payload"]["temperature"] == 0.5 + + +def test_anthropic_converts_local_image_to_base64(tmp_path): + image = tmp_path / "image.png" + image.write_bytes(b"png") + client = LLMClient( + api_key="dummy", + provider="minimax", + transport="anthropic", + model="MiniMax-M3", + ) + captured = {} + response = {"content": [{"type": "text", "text": "ok"}]} + with mock.patch.object( + llm_mod.urllib.request, + "urlopen", + _capture_urlopen(captured, response), + ): + assert client.chat("describe", images=[str(image)]) == "ok" + + source = captured["payload"]["messages"][0]["content"][1]["source"] + assert source == {"type": "base64", "media_type": "image/png", "data": "cG5n"} + + +def test_anthropic_base_url_environment_wins_over_preset(): + with mock.patch.dict( + os.environ, + { + "MOLECODE_PROVIDER": "minimax", + "MOLECODE_TRANSPORT": "anthropic", + "ANTHROPIC_BASE_URL": "https://anthropic.example.com", + }, + ): + client = LLMClient(api_key="dummy") + assert client.base_url == "https://anthropic.example.com" + + +def test_invalid_transport_is_rejected(): + with pytest.raises(ValueError, match="Unsupported transport"): + LLMClient(api_key="dummy", transport="unknown") + + +def test_anthropic_transport_requires_base_url_or_provider(): + with pytest.raises(ValueError, match="Anthropic transport requires"): + LLMClient(api_key="dummy", transport="anthropic")