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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "statek"
version = "1.3.2"
version = "1.3.3"
description = "Durable Python workspaces for stateful LLM agents"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
44 changes: 32 additions & 12 deletions statek/llm_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,21 @@ async def process_request( # pylint: disable=too-many-arguments,too-many-positi
Returns:
LLM_Response containing step data and provider usage stats.
"""
settings = getattr(self, "settings", None)
if settings is not None or getattr(self, "_provider_settings_missing", False):
missing = [
suffix for suffix, value in (
("API_URL", getattr(settings, "api_url", None)),
("API_KEY", getattr(settings, "api_key", None)),
) if not value
]
if missing:
provider = getattr(self, "_provider_name", type(self).__name__.upper())
names = ", ".join(f"{provider}_{suffix}" for suffix in missing)
raise ValueError(
f"Cannot send an LLM request for {provider}: set {names}."
)

request_kwargs = self._prepare_request_kwargs(
system_prompt=system_prompt,
model=model,
Expand Down Expand Up @@ -733,21 +748,26 @@ def get(provider_name: str = None, model: str = None, **kwargs):
settings = custom_provider["settings"]
else:
settings = get_provider_settings(provider_key)
if not settings:
raise ValueError(f"No settings found for {provider_key} provider.")
settings_missing = settings is None
if settings_missing:
settings = LLM_API_Settings(api_url="", api_key="")

if custom_provider is not None:
provider_kwargs = {**custom_provider["kwargs"], **kwargs}
return custom_provider["impl"](settings=settings, **provider_kwargs)

compatible_provider_cls = OPENAI_COMPATIBLE_API_PROVIDERS.get(provider_key)
if compatible_provider_cls is not None:
return compatible_provider_cls(settings=settings, **kwargs)
if provider_key in ('VERTEXAI', 'VERTEX_AI', 'GOOGLE_VERTEXAI', 'GOOGLE'):
return VertexAI_API(settings=settings, **kwargs)
if provider_key in ('CLAUDEAI', 'CLAUDE_AI', 'CLAUDE', 'ANTHROPIC'):
return ClaudeAI_API(settings=settings, **kwargs)
raise ValueError(f"Unsupported LLM API provider: {provider_name}")
api = custom_provider["impl"](settings=settings, **provider_kwargs)
else:
compatible_provider_cls = OPENAI_COMPATIBLE_API_PROVIDERS.get(provider_key)
if compatible_provider_cls is not None:
api = compatible_provider_cls(settings=settings, **kwargs)
elif provider_key in ('VERTEXAI', 'VERTEX_AI', 'GOOGLE_VERTEXAI', 'GOOGLE'):
api = VertexAI_API(settings=settings, **kwargs)
elif provider_key in ('CLAUDEAI', 'CLAUDE_AI', 'CLAUDE', 'ANTHROPIC'):
api = ClaudeAI_API(settings=settings, **kwargs)
else:
raise ValueError(f"Unsupported LLM API provider: {provider_name}")
api._provider_name = provider_key # pylint: disable=protected-access,attribute-defined-outside-init
api._provider_settings_missing = settings_missing # pylint: disable=protected-access,attribute-defined-outside-init
return api

class DefaultLLM_API_Impl(LLM_API):
"""Default OpenAI-compatible chat-completions implementation of LLM_API.
Expand Down
38 changes: 37 additions & 1 deletion tests/test_llm_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def my_sys_tool(n: int, **kwargs): # pylint: disable=unused-argument
# LLM_API.get factory
# ---------------------------------------------------------------------------

class TestLLMAPIGetFactory:
class TestLLMAPIGetFactory: # pylint: disable=too-many-public-methods
"""Tests for provider factory selection and caching."""

def setup_method(self):
Expand All @@ -83,6 +83,42 @@ def test_get_openai_provider(self):

assert isinstance(api, OpenAI_API)

@pytest.mark.parametrize("provider_name", ["OPENROUTER", "CLAUDEAI", "VERTEXAI"])
def test_preview_without_provider_settings(self, provider_name):
with patch("statek.llm_api.get_provider_settings", return_value=None):
api = LLM_API.get(provider_name=provider_name)

payload = api.preview_request(model="test-model", system_prompt="hello")
assert payload

@pytest.mark.asyncio
@pytest.mark.parametrize("provider_name", ["OPENROUTER", "CLAUDEAI", "VERTEXAI"])
async def test_send_without_provider_settings_names_required_variables(self, provider_name):
with patch("statek.llm_api.get_provider_settings", return_value=None):
api = LLM_API.get(provider_name=provider_name)

with patch.object(api, "_process_request", new_callable=AsyncMock) as send:
with pytest.raises(ValueError) as error:
await api.process_request(model="test-model")

assert f"{provider_name}_API_URL" in str(error.value)
assert f"{provider_name}_API_KEY" in str(error.value)
send.assert_not_awaited()

@pytest.mark.asyncio
async def test_send_with_empty_api_key_names_only_missing_variable(self):
settings = LLM_API_Settings(api_url="https://example.test", api_key="")
with patch("statek.llm_api.get_provider_settings", return_value=settings):
api = LLM_API.get(provider_name="OPENAI")

assert api.preview_request(model="test-model")
with patch.object(api, "_process_request", new_callable=AsyncMock) as send:
with pytest.raises(ValueError, match="OPENAI_API_KEY") as error:
await api.process_request(model="test-model")

assert "OPENAI_API_URL" not in str(error.value)
send.assert_not_awaited()

def test_openai_and_openrouter_wrap_default_implementation(self):
openai_settings = LLM_API_Settings(
api_url="https://api.openai.com/v1/chat/completions",
Expand Down
Loading