Files
llm-connect/llm_connect/routing.py
Bernd Worsch d51d6303e2
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
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>
2026-04-01 22:34:00 +00:00

90 lines
2.9 KiB
Python

"""
RoutingPolicy — task-type-aware adapter selection (FR-2).
Maps task types to preferred adapters with optional cost-cap fallback.
"""
from dataclasses import dataclass, field
from typing import Optional, List
from llm_connect.adapter import LLMAdapter
@dataclass
class RoutingRule:
"""Single routing rule binding a task type to an adapter.
Attributes:
task_type: Logical task identifier (e.g. ``"triage"``, ``"summarise"``).
prefer: Adapter to use when this rule matches.
max_cost_per_1k: Optional cost ceiling (USD per 1 000 tokens). When the
caller supplies ``estimated_cost_per_1k`` to :meth:`RoutingPolicy.resolve`
and it exceeds this cap, *fallback* is returned instead of *prefer*.
fallback: Adapter to use when the cost cap is breached.
"""
task_type: str
prefer: LLMAdapter
max_cost_per_1k: Optional[float] = None
fallback: Optional[LLMAdapter] = None
@dataclass
class RoutingPolicy:
"""Route task types to LLM adapters.
Rules are evaluated in order; the first match wins. When no rule matches,
*default* is returned. If *default* is also absent, ``LookupError`` is raised.
Example::
policy = RoutingPolicy(
rules=[
RoutingRule("triage", prefer=fast_adapter, max_cost_per_1k=0.5, fallback=cheap_adapter),
RoutingRule("analysis", prefer=smart_adapter),
],
default=cheap_adapter,
)
adapter = policy.resolve("triage")
"""
rules: List[RoutingRule] = field(default_factory=list)
default: Optional[LLMAdapter] = None
def resolve(
self,
task_type: str,
estimated_cost_per_1k: Optional[float] = None,
) -> LLMAdapter:
"""Return the adapter for *task_type*.
Args:
task_type: Logical task identifier.
estimated_cost_per_1k: Caller-supplied cost estimate (USD / 1k tokens).
When provided and a matching rule has ``max_cost_per_1k`` set, the
rule's ``fallback`` is returned if the estimate exceeds the cap.
Returns:
The selected :class:`~llm_connect.adapter.LLMAdapter`.
Raises:
LookupError: No matching rule and no *default* configured.
"""
for rule in self.rules:
if rule.task_type == task_type:
if (
estimated_cost_per_1k is not None
and rule.max_cost_per_1k is not None
and estimated_cost_per_1k > rule.max_cost_per_1k
and rule.fallback is not None
):
return rule.fallback
return rule.prefer
if self.default is not None:
return self.default
raise LookupError(
f"No routing rule for task_type={task_type!r} and no default configured"
)