mirror of
https://github.com/pocketpaw/pocketpaw.git
synced 2026-05-22 17:55:03 +00:00
* 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
323 lines
11 KiB
Python
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]
|