IB-WP-0020-T02: routing config loader

build_routing_policy_from_config(config, *, workspace=None, env=None,
adapter_factory=None) materialises a parsed RoutingConfig into a live
llm-connect routing policy:

- Static RoutingPolicy when the config has no adaptive signals; one
  RoutingRule per task type, prefer = first candidate, fallback =
  second candidate (when present), max_cost_per_1k pulled from the
  preferred candidate.
- AdaptiveRoutingPolicy when default_quality_floor, any per-task
  quality_floor, or ledger_path is set. ledger_path resolves relative
  to the supplied workspace; parent directory is created so the
  ledger writes never fail on first call.
- API-key resolution from env (default os.environ) using the
  per-provider DEFAULT_API_KEY_ENV map; candidate.api_key_env overrides
  the default. Missing key raises InfospaceError("missing_routing_api_key")
  before any provider constructor runs.
- claude_code candidates need no API key (shells out to the local CLI).
- adapter_factory hook lets tests inject a sentinel-returning factory
  so policy construction stays network- and llm-adapter-free.

Eight new tests cover: static-policy default, adaptive selection via
ledger_path, adaptive selection via quality_floor, multi-candidate
fallback rule, real-factory smoke (OpenRouterAdapter constructed with
env API key), missing-key fast-fail, claude_code zero-key path, and
custom api_key_env override.

168 tests pass, 1 skipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 19:58:15 +02:00
parent c11a942bb7
commit 82468c2165
3 changed files with 324 additions and 2 deletions

View File

@@ -11,9 +11,10 @@ deterministically testable.
from __future__ import annotations from __future__ import annotations
import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Callable, Mapping
import yaml import yaml
@@ -27,6 +28,16 @@ SUPPORTED_PROVIDERS: frozenset[str] = frozenset(
{"openrouter", "claude_code", "openai", "gemini"} {"openrouter", "claude_code", "openai", "gemini"}
) )
# Default env var per provider when the config does not name one explicitly.
DEFAULT_API_KEY_ENV: dict[str, str] = {
"openrouter": "OPENROUTER_API_KEY",
"openai": "OPENAI_API_KEY",
"gemini": "GOOGLE_API_KEY",
# claude_code shells out to the local CLI and needs no API key
}
AdapterFactory = Callable[["RoutingCandidateConfig", Mapping[str, str]], Any]
@dataclass(frozen=True) @dataclass(frozen=True)
class RoutingCandidateConfig: class RoutingCandidateConfig:
@@ -263,3 +274,131 @@ def _optional_quality_floor(value: Any, name: str, source: str) -> float | None:
{"source": source, "name": name, "value": floor}, {"source": source, "name": name, "value": floor},
) )
return floor return floor
# ---------------------------------------------------------------------------
# T02 — build live policies and adapters from a parsed config
# ---------------------------------------------------------------------------
def build_routing_policy_from_config(
config: RoutingConfig,
*,
workspace: str | Path | None = None,
env: Mapping[str, str] | None = None,
adapter_factory: AdapterFactory | None = None,
) -> Any:
"""Materialise a parsed config into a live llm-connect routing policy.
Returns an ``AdaptiveRoutingPolicy`` when the config sets a
``default_quality_floor``, any per-task ``quality_floor``, or a
``ledger_path``; otherwise returns a static ``RoutingPolicy``.
``adapter_factory`` is an opt-in override that builds an
``LLMAdapter`` from a ``RoutingCandidateConfig`` + env mapping. Tests
inject a factory to avoid hitting real provider constructors; the
default factory resolves API keys from ``env`` and instantiates the
matching llm-connect adapter.
Fails fast (before any network call) when a candidate's required API
key env var is missing from ``env``.
"""
from llm_connect.routing import AdaptiveRoutingPolicy, RoutingPolicy, RoutingRule
environment: Mapping[str, str] = env if env is not None else os.environ
factory: AdapterFactory = adapter_factory or _default_adapter_factory
adapters_by_id: dict[str, Any] = {}
rules: list[RoutingRule] = []
for task in config.task_types:
candidates: list[Any] = []
for candidate in task.candidates:
if candidate.id not in adapters_by_id:
adapters_by_id[candidate.id] = factory(candidate, environment)
candidates.append(adapters_by_id[candidate.id])
prefer = candidates[0]
prefer_candidate = task.candidates[0]
fallback = candidates[1] if len(candidates) > 1 else None
rules.append(
RoutingRule(
task_type=task.task_type,
prefer=prefer,
max_cost_per_1k=prefer_candidate.max_cost_per_1k,
fallback=fallback,
)
)
use_adaptive = (
config.default_quality_floor is not None
or any(task.quality_floor is not None for task in config.task_types)
or config.ledger_path is not None
)
if not use_adaptive:
return RoutingPolicy(rules=rules)
from llm_connect.quality import QualityLedger
ledger: QualityLedger | None = None
if config.ledger_path:
ledger_path = Path(config.ledger_path)
if not ledger_path.is_absolute() and workspace is not None:
ledger_path = Path(workspace) / ledger_path
ledger_path.parent.mkdir(parents=True, exist_ok=True)
ledger = QualityLedger(path=ledger_path)
return AdaptiveRoutingPolicy(
rules=rules,
ledger=ledger,
adapters_by_id=dict(adapters_by_id),
)
def _default_adapter_factory(
candidate: RoutingCandidateConfig, env: Mapping[str, str]
) -> Any:
"""Build a real llm-connect adapter for one config candidate.
API keys are resolved from ``env`` before construction; a missing key
raises ``missing_routing_api_key`` rather than letting the adapter
blow up later mid-run.
"""
provider = candidate.provider
if provider == "claude_code":
from llm_connect.claude_code import ClaudeCodeAdapter
return ClaudeCodeAdapter(model=candidate.model)
env_var = candidate.api_key_env or DEFAULT_API_KEY_ENV.get(provider, "")
api_key = env.get(env_var, "") if env_var else ""
if not api_key:
raise InfospaceError(
"missing_routing_api_key",
f"Candidate {candidate.id!r} ({provider}) needs API key from "
f"env var {env_var!r}, but it is unset",
{
"candidate_id": candidate.id,
"provider": provider,
"api_key_env": env_var,
},
)
if provider == "openrouter":
from llm_connect.openrouter import OpenRouterAdapter
return OpenRouterAdapter(model=candidate.model, api_key=api_key)
if provider == "openai":
from llm_connect.openai import OpenAIAdapter
return OpenAIAdapter(model=candidate.model, api_key=api_key)
if provider == "gemini":
from llm_connect.gemini import GeminiAdapter
return GeminiAdapter(model=candidate.model, api_key=api_key)
# Should have been rejected by the parser; defensive guard for callers
# that build a RoutingConfig programmatically without going through
# parse_routing_config.
raise InfospaceError(
"unsupported_routing_provider",
f"Cannot build adapter for unsupported provider {provider!r}",
{"candidate_id": candidate.id, "provider": provider},
)

View File

@@ -261,6 +261,189 @@ def test_rejects_non_mapping_stage_map() -> None:
assert exc_info.value.code == "invalid_routing_stage_map" assert exc_info.value.code == "invalid_routing_stage_map"
# ---------------------------------------------------------------------------
# T02 — loader that materialises a config into a live llm-connect policy
# ---------------------------------------------------------------------------
def _fake_adapter_factory_record(record: list):
"""Return a factory that records calls and returns a sentinel string."""
def _factory(candidate, env):
record.append({"id": candidate.id, "provider": candidate.provider, "model": candidate.model})
return f"adapter:{candidate.id}"
return _factory
def test_build_routing_policy_returns_static_when_no_adaptive_signals() -> None:
from llm_connect.routing import RoutingPolicy
from infospace_bench.routing_config import build_routing_policy_from_config
config = parse_routing_config(MINIMAL)
calls: list[dict] = []
policy = build_routing_policy_from_config(
config, adapter_factory=_fake_adapter_factory_record(calls)
)
assert isinstance(policy, RoutingPolicy)
assert type(policy).__name__ == "RoutingPolicy", "no adaptive signals -> static policy"
assert len(policy.rules) == 1
assert policy.rules[0].task_type == "summarize-source"
assert policy.rules[0].prefer == "adapter:openrouter:gpt-4o-mini"
assert policy.rules[0].fallback is None
assert calls and calls[0]["provider"] == "openrouter"
def test_build_routing_policy_returns_adaptive_when_ledger_path_set(tmp_path: Path) -> None:
from llm_connect.routing import AdaptiveRoutingPolicy
from infospace_bench.routing_config import build_routing_policy_from_config
data = {
**MINIMAL,
"ledger_path": "output/routing/quality.jsonl",
}
config = parse_routing_config(data)
policy = build_routing_policy_from_config(
config,
workspace=tmp_path,
adapter_factory=_fake_adapter_factory_record([]),
)
assert isinstance(policy, AdaptiveRoutingPolicy)
assert policy.ledger is not None
expected_path = tmp_path / "output" / "routing" / "quality.jsonl"
assert Path(policy.ledger.path) == expected_path
def test_build_routing_policy_returns_adaptive_when_quality_floor_set() -> None:
from llm_connect.routing import AdaptiveRoutingPolicy
from infospace_bench.routing_config import build_routing_policy_from_config
data = {
**MINIMAL,
"default_quality_floor": 0.8,
}
config = parse_routing_config(data)
policy = build_routing_policy_from_config(
config, adapter_factory=_fake_adapter_factory_record([])
)
assert isinstance(policy, AdaptiveRoutingPolicy)
assert policy.ledger is None # no ledger_path set
def test_build_routing_policy_routes_fallback_for_multi_candidate_rule() -> None:
from infospace_bench.routing_config import build_routing_policy_from_config
data = {
"schema_version": 1,
"task_types": {
"extract-entities": {
"candidates": [
{
"id": "openrouter:cheap",
"provider": "openrouter",
"model": "openai/gpt-4o-mini",
"max_cost_per_1k": 0.001,
},
{
"id": "openrouter:smart",
"provider": "openrouter",
"model": "anthropic/claude-3.5-sonnet",
},
],
},
},
}
config = parse_routing_config(data)
policy = build_routing_policy_from_config(
config, adapter_factory=_fake_adapter_factory_record([])
)
rule = policy.rules[0]
assert rule.prefer == "adapter:openrouter:cheap"
assert rule.max_cost_per_1k == 0.001
assert rule.fallback == "adapter:openrouter:smart"
def test_build_routing_policy_resolves_api_key_from_env() -> None:
from infospace_bench.routing_config import (
build_routing_policy_from_config,
_default_adapter_factory,
)
config = parse_routing_config(MINIMAL)
# Smoke: real factory with a fake env should construct an OpenRouterAdapter.
env = {"OPENROUTER_API_KEY": "sk-fake-test-key"}
policy = build_routing_policy_from_config(config, env=env)
rule = policy.rules[0]
# The constructed adapter is an OpenRouterAdapter from llm-connect.
from llm_connect.openrouter import OpenRouterAdapter
assert isinstance(rule.prefer, OpenRouterAdapter)
def test_build_routing_policy_fails_fast_on_missing_api_key() -> None:
from infospace_bench.routing_config import build_routing_policy_from_config
config = parse_routing_config(MINIMAL)
# Empty env — the candidate's required env var is unset.
with pytest.raises(InfospaceError) as exc_info:
build_routing_policy_from_config(config, env={})
assert exc_info.value.code == "missing_routing_api_key"
assert exc_info.value.detail["provider"] == "openrouter"
def test_build_routing_policy_claude_code_needs_no_api_key() -> None:
from infospace_bench.routing_config import build_routing_policy_from_config
from llm_connect.claude_code import ClaudeCodeAdapter
data = {
"schema_version": 1,
"task_types": {
"baseline": {
"candidates": [
{"id": "claude-code", "provider": "claude_code", "model": "claude-opus-4-7"},
],
},
},
}
config = parse_routing_config(data)
policy = build_routing_policy_from_config(config, env={})
assert isinstance(policy.rules[0].prefer, ClaudeCodeAdapter)
def test_build_routing_policy_honours_custom_api_key_env() -> None:
from infospace_bench.routing_config import build_routing_policy_from_config
from llm_connect.openrouter import OpenRouterAdapter
data = {
"schema_version": 1,
"task_types": {
"summarize-source": {
"candidates": [
{
"id": "openrouter:gpt-4o-mini",
"provider": "openrouter",
"model": "openai/gpt-4o-mini",
"api_key_env": "ALT_OPENROUTER_KEY",
},
],
},
},
}
config = parse_routing_config(data)
with pytest.raises(InfospaceError) as exc_info:
build_routing_policy_from_config(config, env={"OPENROUTER_API_KEY": "wrong-default"})
assert exc_info.value.code == "missing_routing_api_key"
assert exc_info.value.detail["api_key_env"] == "ALT_OPENROUTER_KEY"
policy = build_routing_policy_from_config(
config, env={"ALT_OPENROUTER_KEY": "sk-fake"}
)
assert isinstance(policy.rules[0].prefer, OpenRouterAdapter)
def test_rejects_non_string_ledger_path() -> None: def test_rejects_non_string_ledger_path() -> None:
payload = { payload = {
"schema_version": 1, "schema_version": 1,

View File

@@ -93,7 +93,7 @@ state_hub_task_id: "39597441-22ab-4dcf-b68d-b045823a9374"
```task ```task
id: IB-WP-0020-T02 id: IB-WP-0020-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "5e38514b-ad6a-4d39-8716-f812f241d9fd" state_hub_task_id: "5e38514b-ad6a-4d39-8716-f812f241d9fd"
``` ```