generated from coulomb/repo-seed
feat: WP-0003 — RoutingPolicy (FR-2) and HTTP serve mode (FR-1)
FR-2 RoutingPolicy: - RoutingPolicy + RoutingRule dataclasses in llm_connect/routing.py - resolve(task_type, estimated_cost_per_1k=None) with cost-cap fallback - Exported from llm_connect.__init__; contract doc at contracts/functional/routing-policy.md - 11 tests covering rule match, cost-cap, fallback, unknown type, no-match FR-1 HTTP serve mode: - LLMServer in llm_connect/server.py (stdlib http.server, zero extra deps) - POST /execute + GET /health; CLI via python -m llm_connect.server - [server] optional-dep group added to pyproject.toml - Contract doc at contracts/functional/server.md - 9 tests: health, round-trip, 400/404/500 errors, config forwarding - Added "mock" provider to factory for CLI default All 101 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -66,7 +66,7 @@ class TestCreateAdapter:
|
||||
assert isinstance(adapter, ClaudeCodeAdapter)
|
||||
|
||||
def test_all_known_providers_are_reachable(self):
|
||||
known = {"openrouter", "openai", "gemini", "claude-code"}
|
||||
known = {"openrouter", "openai", "gemini", "claude-code", "mock"}
|
||||
# Just verify each key is in the factory registry (no construction needed)
|
||||
from llm_connect.factory import _PROVIDERS
|
||||
assert known == set(_PROVIDERS.keys())
|
||||
|
||||
91
tests/test_routing.py
Normal file
91
tests/test_routing.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Tests for RoutingPolicy (FR-2).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from llm_connect.routing import RoutingPolicy, RoutingRule
|
||||
from llm_connect.adapter import MockLLMAdapter
|
||||
|
||||
|
||||
class TestRoutingPolicy:
|
||||
def _adapters(self, n: int = 3):
|
||||
return [MockLLMAdapter(mock_response=f"resp-{i}") for i in range(n)]
|
||||
|
||||
def test_rule_match_returns_prefer(self):
|
||||
prefer, *_ = self._adapters()
|
||||
policy = RoutingPolicy(rules=[RoutingRule("triage", prefer=prefer)])
|
||||
assert policy.resolve("triage") is prefer
|
||||
|
||||
def test_first_matching_rule_wins(self):
|
||||
a, b = self._adapters(2)
|
||||
policy = RoutingPolicy(rules=[
|
||||
RoutingRule("triage", prefer=a),
|
||||
RoutingRule("triage", prefer=b),
|
||||
])
|
||||
assert policy.resolve("triage") is a
|
||||
|
||||
def test_cost_cap_within_limit_returns_prefer(self):
|
||||
prefer, fallback = self._adapters(2)
|
||||
policy = RoutingPolicy(rules=[
|
||||
RoutingRule("triage", prefer=prefer, max_cost_per_1k=1.0, fallback=fallback)
|
||||
])
|
||||
assert policy.resolve("triage", estimated_cost_per_1k=0.5) is prefer
|
||||
|
||||
def test_cost_cap_exceeded_returns_fallback(self):
|
||||
prefer, fallback = self._adapters(2)
|
||||
policy = RoutingPolicy(rules=[
|
||||
RoutingRule("triage", prefer=prefer, max_cost_per_1k=1.0, fallback=fallback)
|
||||
])
|
||||
assert policy.resolve("triage", estimated_cost_per_1k=2.0) is fallback
|
||||
|
||||
def test_cost_cap_exceeded_no_fallback_returns_prefer(self):
|
||||
"""When cost exceeds cap but no fallback is set, still return prefer."""
|
||||
prefer, *_ = self._adapters()
|
||||
policy = RoutingPolicy(rules=[
|
||||
RoutingRule("triage", prefer=prefer, max_cost_per_1k=0.1)
|
||||
])
|
||||
assert policy.resolve("triage", estimated_cost_per_1k=5.0) is prefer
|
||||
|
||||
def test_no_estimated_cost_ignores_cap(self):
|
||||
prefer, fallback = self._adapters(2)
|
||||
policy = RoutingPolicy(rules=[
|
||||
RoutingRule("triage", prefer=prefer, max_cost_per_1k=0.01, fallback=fallback)
|
||||
])
|
||||
# No cost estimate → cap not applied
|
||||
assert policy.resolve("triage") is prefer
|
||||
|
||||
def test_unknown_task_type_returns_default(self):
|
||||
prefer, default = self._adapters(2)
|
||||
policy = RoutingPolicy(
|
||||
rules=[RoutingRule("triage", prefer=prefer)],
|
||||
default=default,
|
||||
)
|
||||
assert policy.resolve("unknown") is default
|
||||
|
||||
def test_no_match_no_default_raises_lookup_error(self):
|
||||
prefer, *_ = self._adapters()
|
||||
policy = RoutingPolicy(rules=[RoutingRule("triage", prefer=prefer)])
|
||||
with pytest.raises(LookupError, match="unknown"):
|
||||
policy.resolve("unknown")
|
||||
|
||||
def test_empty_rules_with_default_returns_default(self):
|
||||
default, *_ = self._adapters()
|
||||
policy = RoutingPolicy(default=default)
|
||||
assert policy.resolve("anything") is default
|
||||
|
||||
def test_empty_policy_raises(self):
|
||||
policy = RoutingPolicy()
|
||||
with pytest.raises(LookupError):
|
||||
policy.resolve("triage")
|
||||
|
||||
def test_multiple_task_types(self):
|
||||
a, b, c = self._adapters(3)
|
||||
policy = RoutingPolicy(rules=[
|
||||
RoutingRule("fast", prefer=a),
|
||||
RoutingRule("smart", prefer=b),
|
||||
RoutingRule("cheap", prefer=c),
|
||||
])
|
||||
assert policy.resolve("fast") is a
|
||||
assert policy.resolve("smart") is b
|
||||
assert policy.resolve("cheap") is c
|
||||
134
tests/test_server.py
Normal file
134
tests/test_server.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Tests for LLMServer HTTP serve mode (FR-1).
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
from llm_connect.adapter import MockLLMAdapter, ErrorLLMAdapter
|
||||
from llm_connect.models import RunConfig
|
||||
from llm_connect.server import LLMServer
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server():
|
||||
"""Start a server on a free port; stop after each test."""
|
||||
s = LLMServer(adapter=MockLLMAdapter(mock_response="hello world"), port=0)
|
||||
s.start()
|
||||
yield s
|
||||
s.stop()
|
||||
|
||||
|
||||
def _get(url: str) -> tuple[int, dict]:
|
||||
try:
|
||||
with urllib.request.urlopen(url) as resp:
|
||||
return resp.status, json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, json.loads(exc.read())
|
||||
|
||||
|
||||
def _post(url: str, body: dict) -> tuple[int, dict]:
|
||||
payload = json.dumps(body).encode()
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return resp.status, json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, json.loads(exc.read())
|
||||
|
||||
|
||||
class TestHealth:
|
||||
def test_health_returns_200(self, server):
|
||||
status, body = _get(f"http://127.0.0.1:{server.port}/health")
|
||||
assert status == 200
|
||||
assert body["status"] == "ok"
|
||||
|
||||
def test_unknown_get_returns_404(self, server):
|
||||
status, body = _get(f"http://127.0.0.1:{server.port}/nope")
|
||||
assert status == 404
|
||||
|
||||
|
||||
class TestExecute:
|
||||
def test_post_execute_round_trip(self, server):
|
||||
status, body = _post(
|
||||
f"http://127.0.0.1:{server.port}/execute",
|
||||
{"prompt": "say hello"},
|
||||
)
|
||||
assert status == 200
|
||||
assert body["content"] == "hello world"
|
||||
assert body["finish_reason"] == "stop"
|
||||
|
||||
def test_response_includes_usage(self, server):
|
||||
status, body = _post(
|
||||
f"http://127.0.0.1:{server.port}/execute",
|
||||
{"prompt": "count tokens"},
|
||||
)
|
||||
assert status == 200
|
||||
assert "usage" in body
|
||||
assert body["usage"]["total_tokens"] > 0
|
||||
|
||||
def test_missing_prompt_returns_400(self, server):
|
||||
status, body = _post(
|
||||
f"http://127.0.0.1:{server.port}/execute",
|
||||
{"config": {}},
|
||||
)
|
||||
assert status == 400
|
||||
assert "prompt" in body["error"]
|
||||
|
||||
def test_invalid_json_returns_400(self, server):
|
||||
req = urllib.request.Request(
|
||||
f"http://127.0.0.1:{server.port}/execute",
|
||||
data=b"not json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
status, body = resp.status, json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
status, body = exc.code, json.loads(exc.read())
|
||||
assert status == 400
|
||||
|
||||
def test_unknown_post_path_returns_404(self, server):
|
||||
status, body = _post(
|
||||
f"http://127.0.0.1:{server.port}/wrong",
|
||||
{"prompt": "hi"},
|
||||
)
|
||||
assert status == 404
|
||||
|
||||
def test_adapter_error_returns_500(self):
|
||||
s = LLMServer(adapter=ErrorLLMAdapter("boom"), port=0)
|
||||
s.start()
|
||||
try:
|
||||
status, body = _post(
|
||||
f"http://127.0.0.1:{s.port}/execute",
|
||||
{"prompt": "hello"},
|
||||
)
|
||||
assert status == 500
|
||||
assert "boom" in body["error"]
|
||||
finally:
|
||||
s.stop()
|
||||
|
||||
def test_config_fields_forwarded(self):
|
||||
"""Config fields in request body reach the adapter via RunConfig."""
|
||||
adapter = MockLLMAdapter(mock_response="x")
|
||||
s = LLMServer(adapter=adapter, port=0)
|
||||
s.start()
|
||||
try:
|
||||
status, body = _post(
|
||||
f"http://127.0.0.1:{s.port}/execute",
|
||||
{"prompt": "hi", "config": {"model_name": "gpt-3.5-turbo", "max_tokens": 100}},
|
||||
)
|
||||
assert status == 200
|
||||
assert adapter.last_config.model_name == "gpt-3.5-turbo"
|
||||
assert adapter.last_config.max_tokens == 100
|
||||
finally:
|
||||
s.stop()
|
||||
Reference in New Issue
Block a user