Files
pocketpaw/tests/test_llm_client.py
Rohit Kushwaha 3815047b98 feat: full tool access for non-Claude backends + OpenRouter provider support (#543)
* fix: include shell/fs tools in tool bridge for non-Claude backends

The tool bridge excluded ShellTool, ReadFileTool, WriteFileTool, and
ListDirTool from all backends. This was only correct for claude_agent_sdk
(which provides them natively via the CLI subprocess). Other backends
(openai_agents, google_adk, codex_cli, copilot_sdk, opencode) need these
tools from the bridge to provide full autonomous shell/filesystem access
without requiring Claude Code CLI.

Add a backend parameter to _instantiate_all_tools() and all public bridge
functions. Only claude_agent_sdk excludes shell/fs tools. BrowserTool and
DesktopTool remain excluded for all backends (need special session state).

* chore: remove plan doc from commit

* feat: add first-class OpenRouter provider support

Add "OpenRouter" as a dedicated provider option across the stack:

Backend (llm/client.py):
- resolve_llm_client maps "openrouter" to openai_compatible with
  pre-filled https://openrouter.ai/api/v1 base URL
- to_sdk_env detects OpenRouter URLs and sets ANTHROPIC_AUTH_TOKEN +
  blank ANTHROPIC_API_KEY (required by OpenRouter's Anthropic skin)
- URL normalization strips trailing /v1 for the Anthropic-compat endpoint

Agent backends:
- claude_agent_sdk and openai_agents list "openrouter" in supported_providers
- openai_agents._build_model handles "openrouter" provider directly

Dashboard + Client UI:
- OpenRouter appears as a named option in provider dropdowns
- Dedicated config fields (API key + model) with links to openrouter.ai
- Client settings store maps openrouter API keys correctly

Tests:
- 4 new tests for OpenRouter resolution, env var generation, and URL
  normalization

* fix: address PR #543 review - wire backend params, isolate OpenRouter settings

- Pass explicit backend= at all call sites: openai_agents.py,
  google_adk.py, opencode.py, copilot_sdk.py (C1)
- Add openrouter_api_key and openrouter_model as dedicated Settings
  fields, isolating OpenRouter from openai_compatible fields (C2)
- Add openrouter_api_key to SECRET_FIELDS and _API_KEY_PATTERNS
  with sk-or-v1- prefix validation (W1)
- Use urlparse().hostname for is_openrouter check instead of
  substring matching (W2)
- Add caching comments on _build_custom_tools methods (W3)
- Add comment explaining empty ANTHROPIC_API_KEY for OpenRouter (W4)
- Update client Settings type and API key routing for OpenRouter

* test(credentials): add openrouter_api_key to expected SECRET_FIELDS
2026-03-10 18:08:47 +05:30

323 lines
11 KiB
Python

"""Tests for the centralized LLMClient abstraction."""
import dataclasses
from unittest.mock import patch
import pytest
from pocketpaw.config import Settings
from pocketpaw.llm.client import LLMClient, resolve_llm_client
# ---------------------------------------------------------------------------
# resolve_llm_client
# ---------------------------------------------------------------------------
class TestResolveLLMClient:
def test_resolve_auto_anthropic(self):
"""auto + anthropic key → anthropic provider."""
settings = Settings(llm_provider="auto", anthropic_api_key="sk-ant")
llm = resolve_llm_client(settings)
assert llm.provider == "anthropic"
assert llm.model == settings.anthropic_model
assert llm.api_key == "sk-ant"
assert llm.ollama_host # always populated from settings
def test_resolve_auto_openai(self):
"""auto + openai key only → openai provider."""
settings = Settings(
llm_provider="auto",
anthropic_api_key=None,
openai_api_key="sk-oai",
)
llm = resolve_llm_client(settings)
assert llm.provider == "openai"
assert llm.model == settings.openai_model
assert llm.api_key == "sk-oai"
def test_resolve_auto_ollama(self):
"""auto + no keys → ollama fallback."""
settings = Settings(
llm_provider="auto",
anthropic_api_key=None,
openai_api_key=None,
ollama_host="http://myhost:11434",
ollama_model="qwen2.5:7b",
)
llm = resolve_llm_client(settings)
assert llm.provider == "ollama"
assert llm.model == "qwen2.5:7b"
assert llm.api_key is None
assert llm.ollama_host == "http://myhost:11434"
def test_resolve_explicit_ollama(self):
"""Explicit provider='ollama'."""
settings = Settings(
llm_provider="ollama",
ollama_model="llama3.2",
ollama_host="http://localhost:11434",
)
llm = resolve_llm_client(settings)
assert llm.is_ollama
assert not llm.is_anthropic
assert llm.model == "llama3.2"
def test_resolve_force_provider(self):
"""force_provider overrides settings."""
settings = Settings(
llm_provider="ollama",
anthropic_api_key="sk-test",
anthropic_model="claude-sonnet-4-5-20250929",
)
llm = resolve_llm_client(settings, force_provider="anthropic")
assert llm.provider == "anthropic"
assert llm.api_key == "sk-test"
def test_resolve_auto_prefers_anthropic_over_openai(self):
"""When both keys exist, auto prefers anthropic."""
settings = Settings(
llm_provider="auto",
anthropic_api_key="sk-ant",
openai_api_key="sk-oai",
)
llm = resolve_llm_client(settings)
assert llm.provider == "anthropic"
def test_resolve_openrouter(self):
"""openrouter provider resolves as openai_compatible with OpenRouter URL."""
settings = Settings(
openai_compatible_api_key="sk-or-v1-test",
openai_compatible_model="meta-llama/llama-4-maverick",
)
llm = resolve_llm_client(settings, force_provider="openrouter")
assert llm.provider == "openai_compatible"
assert llm.openai_compatible_base_url == "https://openrouter.ai/api/v1"
assert llm.api_key == "sk-or-v1-test"
assert llm.model == "meta-llama/llama-4-maverick"
# ---------------------------------------------------------------------------
# create_anthropic_client
# ---------------------------------------------------------------------------
class TestCreateAnthropicClient:
@patch("anthropic.AsyncAnthropic")
def test_create_client_ollama(self, mock_cls):
"""Ollama client uses correct base_url, api_key, timeout, retries."""
llm = LLMClient(
provider="ollama",
model="llama3.2",
api_key=None,
ollama_host="http://localhost:11434",
)
llm.create_anthropic_client()
mock_cls.assert_called_once_with(
base_url="http://localhost:11434",
api_key="ollama",
timeout=120.0,
max_retries=1,
)
@patch("anthropic.AsyncAnthropic")
def test_create_client_anthropic(self, mock_cls):
"""Anthropic client uses correct api_key, timeout, retries."""
llm = LLMClient(
provider="anthropic",
model="claude-sonnet-4-5-20250929",
api_key="sk-ant",
ollama_host="http://localhost:11434",
)
llm.create_anthropic_client()
mock_cls.assert_called_once_with(
api_key="sk-ant",
timeout=60.0,
max_retries=2,
)
@patch("anthropic.AsyncAnthropic")
def test_create_client_custom_timeout(self, mock_cls):
"""Custom timeout and retries are forwarded."""
llm = LLMClient(
provider="anthropic",
model="claude-sonnet-4-5-20250929",
api_key="sk-ant",
ollama_host="http://localhost:11434",
)
llm.create_anthropic_client(timeout=30.0, max_retries=5)
mock_cls.assert_called_once_with(
api_key="sk-ant",
timeout=30.0,
max_retries=5,
)
def test_create_client_openai_raises(self):
"""OpenAI provider raises ValueError."""
llm = LLMClient(
provider="openai",
model="gpt-4o",
api_key="sk-oai",
ollama_host="http://localhost:11434",
)
with pytest.raises(ValueError, match="OpenAI provider"):
llm.create_anthropic_client()
# ---------------------------------------------------------------------------
# to_sdk_env
# ---------------------------------------------------------------------------
class TestToSdkEnv:
def test_to_sdk_env_ollama(self):
llm = LLMClient(
provider="ollama",
model="llama3.2",
api_key=None,
ollama_host="http://myhost:11434",
)
env = llm.to_sdk_env()
assert env == {
"ANTHROPIC_BASE_URL": "http://myhost:11434",
"ANTHROPIC_API_KEY": "ollama",
}
def test_to_sdk_env_anthropic(self):
llm = LLMClient(
provider="anthropic",
model="claude-sonnet-4-5-20250929",
api_key="sk-real",
ollama_host="http://localhost:11434",
)
env = llm.to_sdk_env()
assert env == {"ANTHROPIC_API_KEY": "sk-real"}
def test_to_sdk_env_no_key(self):
llm = LLMClient(
provider="anthropic",
model="claude-sonnet-4-5-20250929",
api_key=None,
ollama_host="http://localhost:11434",
)
env = llm.to_sdk_env()
assert env == {}
def test_to_sdk_env_openrouter(self):
"""OpenRouter uses ANTHROPIC_AUTH_TOKEN and blanks ANTHROPIC_API_KEY."""
llm = LLMClient(
provider="openai_compatible",
model="anthropic/claude-sonnet-4-6",
api_key="sk-or-v1-test",
ollama_host="http://localhost:11434",
openai_compatible_base_url="https://openrouter.ai/api/v1",
)
env = llm.to_sdk_env()
assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-or-v1-test"
assert env["ANTHROPIC_API_KEY"] == ""
assert env["ANTHROPIC_BASE_URL"] == "https://openrouter.ai/api"
def test_to_sdk_env_openrouter_strips_v1(self):
"""OpenRouter URL normalization strips trailing /v1."""
llm = LLMClient(
provider="openai_compatible",
model="meta-llama/llama-4-maverick",
api_key="sk-or-v1-key",
ollama_host="http://localhost:11434",
openai_compatible_base_url="https://openrouter.ai/api/v1",
)
env = llm.to_sdk_env()
# Should strip /v1 so the Anthropic skin at /api is used
assert env["ANTHROPIC_BASE_URL"] == "https://openrouter.ai/api"
def test_to_sdk_env_non_openrouter_compatible(self):
"""Non-OpenRouter compatible endpoints use ANTHROPIC_API_KEY normally."""
llm = LLMClient(
provider="openai_compatible",
model="some-model",
api_key="my-key",
ollama_host="http://localhost:11434",
openai_compatible_base_url="http://localhost:4000/v1",
)
env = llm.to_sdk_env()
assert env["ANTHROPIC_API_KEY"] == "my-key"
assert "ANTHROPIC_AUTH_TOKEN" not in env
# ---------------------------------------------------------------------------
# format_api_error
# ---------------------------------------------------------------------------
class TestFormatApiError:
def test_format_error_ollama_not_found(self):
llm = LLMClient(
provider="ollama",
model="missing-model",
api_key=None,
ollama_host="http://localhost:11434",
)
msg = llm.format_api_error(Exception("model not_found"))
assert "missing-model" in msg
assert "not found" in msg.lower()
def test_format_error_ollama_connection(self):
llm = LLMClient(
provider="ollama",
model="llama3.2",
api_key=None,
ollama_host="http://localhost:11434",
)
msg = llm.format_api_error(Exception("Connection refused"))
assert "Cannot connect" in msg
assert "localhost:11434" in msg
def test_format_error_ollama_generic(self):
llm = LLMClient(
provider="ollama",
model="llama3.2",
api_key=None,
ollama_host="http://localhost:11434",
)
msg = llm.format_api_error(Exception("some weird error"))
assert "Ollama error" in msg
def test_format_error_anthropic_auth(self):
llm = LLMClient(
provider="anthropic",
model="claude-sonnet-4-5-20250929",
api_key="bad-key",
ollama_host="http://localhost:11434",
)
msg = llm.format_api_error(Exception("Invalid API key"))
assert "API key" in msg
def test_format_error_anthropic_generic(self):
llm = LLMClient(
provider="anthropic",
model="claude-sonnet-4-5-20250929",
api_key="sk-test",
ollama_host="http://localhost:11434",
)
msg = llm.format_api_error(Exception("rate limit exceeded"))
assert "rate limit exceeded" in msg
# ---------------------------------------------------------------------------
# frozen immutability
# ---------------------------------------------------------------------------
class TestFrozen:
def test_frozen(self):
llm = LLMClient(
provider="anthropic",
model="claude-sonnet-4-5-20250929",
api_key="sk-test",
ollama_host="http://localhost:11434",
)
with pytest.raises(dataclasses.FrozenInstanceError):
llm.provider = "ollama" # type: ignore[misc]