Add activity-core LLM endpoint support
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

This commit is contained in:
2026-06-07 19:24:45 +02:00
parent 1d9fc107ed
commit 14ba47c129
25 changed files with 2082 additions and 18 deletions

View File

@@ -55,6 +55,12 @@ from llm_connect.problem_classes import (
TokenEstimate,
default_problem_class_registry,
)
from llm_connect.profiles import (
CUSTODIAN_TRIAGE_BALANCED,
ProfiledLLMAdapter,
RuntimeProfile,
default_runtime_profiles,
)
from llm_connect.quality import QualityLedger, QualityObservation, is_stale
from llm_connect.rates import ModelRate, ModelRateRegistry
from llm_connect.routing import AdaptiveRoutingPolicy, RoutingPolicy, RoutingRule
@@ -124,4 +130,8 @@ __all__ = [
"RelationExtractionProblemClass",
"JudgeEvalProblemClass",
"ReportSynthesisProblemClass",
"CUSTODIAN_TRIAGE_BALANCED",
"RuntimeProfile",
"ProfiledLLMAdapter",
"default_runtime_profiles",
]

View File

@@ -2,7 +2,8 @@
Factory for creating LLM adapters by provider name.
"""
from typing import Optional, Dict, Any
import os
from typing import Optional, Dict, Any
from llm_connect.adapter import LLMAdapter
from llm_connect.exceptions import LLMConfigurationError
@@ -57,5 +58,10 @@ def create_adapter(
return cls(model=model, api_key=api_key, system_prompt=system_prompt, **kwargs)
elif provider == "claude-code":
return cls(model=model, **kwargs)
else:
return cls(**kwargs)
elif provider == "mock":
mock_response = os.environ.get("LLM_CONNECT_MOCK_RESPONSE")
if mock_response is not None and "mock_response" not in kwargs:
kwargs["mock_response"] = mock_response
return cls(**kwargs)
else:
return cls(**kwargs)

293
llm_connect/profiles.py Normal file
View File

@@ -0,0 +1,293 @@
"""Named runtime profiles for server-mode adapter dispatch."""
from __future__ import annotations
import json
import os
import threading
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any, Callable, Mapping
from llm_connect.adapter import LLMAdapter
from llm_connect.exceptions import LLMConfigurationError
from llm_connect.factory import create_adapter
from llm_connect.models import LLMResponse, RunConfig
CUSTODIAN_TRIAGE_BALANCED = "custodian-triage-balanced"
DEFAULT_CUSTODIAN_TRIAGE_PROVIDER = "openrouter"
DEFAULT_CUSTODIAN_TRIAGE_MODEL = "anthropic/claude-sonnet-4"
_RUN_CONFIG_DEFAULTS = RunConfig()
@dataclass(frozen=True)
class RuntimeProfile:
"""Provider/model routing and default call config for a named profile."""
name: str
provider: str
model: str
config: RunConfig = field(default_factory=RunConfig)
def resolve_config(self, request_config: RunConfig) -> RunConfig:
"""Merge profile defaults with request overrides.
`RunConfig` has value defaults rather than optional fields, so the
merge is intentionally conservative: provider/model identity comes from
the profile, scalar generation fields come from the request, and
`model_params` are shallow-merged with request keys winning.
"""
merged_params = {
**(self.config.model_params or {}),
**(request_config.model_params or {}),
}
return replace(
request_config,
model_name=self.model,
temperature=_profile_default_if_unchanged(
request_config.temperature,
_RUN_CONFIG_DEFAULTS.temperature,
self.config.temperature,
),
max_tokens=_profile_default_if_unchanged(
request_config.max_tokens,
_RUN_CONFIG_DEFAULTS.max_tokens,
self.config.max_tokens,
),
max_depth=_profile_default_if_unchanged(
request_config.max_depth,
_RUN_CONFIG_DEFAULTS.max_depth,
self.config.max_depth,
),
timeout_seconds=_profile_default_if_unchanged(
request_config.timeout_seconds,
_RUN_CONFIG_DEFAULTS.timeout_seconds,
self.config.timeout_seconds,
),
model_params=merged_params,
)
class ProfiledLLMAdapter(LLMAdapter):
"""Adapter wrapper that dispatches named profile requests to adapters."""
def __init__(
self,
default_adapter: LLMAdapter,
profiles: Mapping[str, RuntimeProfile],
*,
adapter_factory: Callable[[str, str], LLMAdapter] | None = None,
strict_profiles: bool = False,
profile_prefixes: tuple[str, ...] = ("custodian-",),
) -> None:
self.default_adapter = default_adapter
self.profiles = dict(profiles)
self.adapter_factory = adapter_factory or _default_adapter_factory
self.strict_profiles = strict_profiles
self.profile_prefixes = profile_prefixes
self._adapters: dict[tuple[str, str], LLMAdapter] = {}
self._lock = threading.Lock()
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
profile = self._resolve_profile(config.model_name)
if profile is None:
return self.default_adapter.execute_prompt(prompt, config)
adapter = self._adapter_for(profile)
resolved_config = profile.resolve_config(config)
response = adapter.execute_prompt(prompt, resolved_config)
response.metadata.setdefault("profile", profile.name)
response.metadata.setdefault("profile_provider", profile.provider)
response.metadata.setdefault("profile_model", profile.model)
return response
async def async_execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
profile = self._resolve_profile(config.model_name)
if profile is None:
return await self.default_adapter.async_execute_prompt(prompt, config)
adapter = self._adapter_for(profile)
resolved_config = profile.resolve_config(config)
response = await adapter.async_execute_prompt(prompt, resolved_config)
response.metadata.setdefault("profile", profile.name)
response.metadata.setdefault("profile_provider", profile.provider)
response.metadata.setdefault("profile_model", profile.model)
return response
def validate_config(self, config: RunConfig) -> bool:
profile = self._resolve_profile(config.model_name)
if profile is None:
return self.default_adapter.validate_config(config)
return self._adapter_for(profile).validate_config(profile.resolve_config(config))
def _resolve_profile(self, model_name: str) -> RuntimeProfile | None:
profile = self.profiles.get(model_name)
if profile is not None:
return profile
if self.strict_profiles or model_name.startswith(self.profile_prefixes):
known = ", ".join(sorted(self.profiles)) or "(none configured)"
raise LLMConfigurationError(
f"Unknown LLM runtime profile {model_name!r}. Known profiles: {known}",
context={"profile": model_name},
)
return None
def _adapter_for(self, profile: RuntimeProfile) -> LLMAdapter:
key = (profile.provider, profile.model)
with self._lock:
adapter = self._adapters.get(key)
if adapter is None:
adapter = self.adapter_factory(profile.provider, profile.model)
self._adapters[key] = adapter
return adapter
def default_runtime_profiles(
*,
provider: str | None = None,
model: str | None = None,
) -> dict[str, RuntimeProfile]:
"""Return built-in runtime profiles, with env/config overrides applied."""
triage_provider = (
os.environ.get("LLM_CONNECT_CUSTODIAN_TRIAGE_PROVIDER")
or provider
or DEFAULT_CUSTODIAN_TRIAGE_PROVIDER
)
triage_model = (
os.environ.get("LLM_CONNECT_CUSTODIAN_TRIAGE_MODEL")
or model
or DEFAULT_CUSTODIAN_TRIAGE_MODEL
)
profiles = {
CUSTODIAN_TRIAGE_BALANCED: RuntimeProfile(
name=CUSTODIAN_TRIAGE_BALANCED,
provider=triage_provider,
model=triage_model,
config=RunConfig(
model_name=triage_model,
temperature=_float_env("LLM_CONNECT_CUSTODIAN_TRIAGE_TEMPERATURE", 0.2),
max_tokens=_int_env("LLM_CONNECT_CUSTODIAN_TRIAGE_MAX_TOKENS", 1800),
max_depth=_int_env("LLM_CONNECT_CUSTODIAN_TRIAGE_MAX_DEPTH", 2),
timeout_seconds=_int_env("LLM_CONNECT_CUSTODIAN_TRIAGE_TIMEOUT_SECONDS", 300),
model_params={
"reasoning_effort": os.environ.get(
"LLM_CONNECT_CUSTODIAN_TRIAGE_REASONING_EFFORT",
"medium",
),
},
),
)
}
profiles.update(load_runtime_profiles_from_env())
return profiles
def load_runtime_profiles_from_env() -> dict[str, RuntimeProfile]:
"""Load optional profile overrides from JSON env/file config."""
raw = os.environ.get("LLM_CONNECT_PROFILES_JSON")
path = os.environ.get("LLM_CONNECT_PROFILE_FILE")
if raw and path:
raise LLMConfigurationError(
"Set only one of LLM_CONNECT_PROFILES_JSON or LLM_CONNECT_PROFILE_FILE",
context={"config": "runtime_profiles"},
)
if path:
try:
raw = Path(path).read_text(encoding="utf-8")
except OSError as exc:
raise LLMConfigurationError(
f"Could not read LLM runtime profile file {path!r}",
cause=exc,
context={"config": "runtime_profiles"},
) from exc
if not raw:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise LLMConfigurationError(
"LLM runtime profile config must be valid JSON",
cause=exc,
context={"config": "runtime_profiles"},
) from exc
profiles_data = data.get("profiles", data) if isinstance(data, dict) else None
if not isinstance(profiles_data, dict):
raise LLMConfigurationError(
"LLM runtime profile config must be an object keyed by profile name",
context={"config": "runtime_profiles"},
)
return {
name: _profile_from_mapping(name, value)
for name, value in profiles_data.items()
}
def _profile_from_mapping(name: str, value: Any) -> RuntimeProfile:
if not isinstance(value, dict):
raise LLMConfigurationError(
f"Runtime profile {name!r} must be an object",
context={"profile": name},
)
provider = value.get("provider")
model = value.get("model")
if not isinstance(provider, str) or not provider:
raise LLMConfigurationError(
f"Runtime profile {name!r} requires a provider",
context={"profile": name},
)
if not isinstance(model, str) or not model:
raise LLMConfigurationError(
f"Runtime profile {name!r} requires a model",
context={"profile": name},
)
config_data = value.get("config", {})
if not isinstance(config_data, dict):
raise LLMConfigurationError(
f"Runtime profile {name!r} config must be an object",
context={"profile": name},
)
config = RunConfig.from_dict({"model_name": model, **config_data})
return RuntimeProfile(name=name, provider=provider, model=model, config=config)
def _default_adapter_factory(provider: str, model: str) -> LLMAdapter:
return create_adapter(provider, model=model)
def _profile_default_if_unchanged(value: Any, default: Any, profile_value: Any) -> Any:
return profile_value if value == default else value
def _int_env(name: str, default: int) -> int:
value = os.environ.get(name)
if value is None or value == "":
return default
try:
return int(value)
except ValueError as exc:
raise LLMConfigurationError(
f"{name} must be an integer",
cause=exc,
context={"env": name},
) from exc
def _float_env(name: str, default: float) -> float:
value = os.environ.get(name)
if value is None or value == "":
return default
try:
return float(value)
except ValueError as exc:
raise LLMConfigurationError(
f"{name} must be a number",
cause=exc,
context={"env": name},
) from exc

View File

@@ -35,7 +35,16 @@ from urllib.parse import parse_qs, urlsplit
from llm_connect._diagnostics import capture_diagnostics
from llm_connect.adapter import LLMAdapter
from llm_connect.exceptions import (
LLMBudgetExceededError,
LLMAPIError,
LLMConfigurationError,
LLMError,
LLMRateLimitError,
LLMTimeoutError,
)
from llm_connect.models import LLMResponse, RunConfig
from llm_connect.profiles import ProfiledLLMAdapter, default_runtime_profiles
class _Handler(BaseHTTPRequestHandler):
@@ -86,7 +95,13 @@ class _Handler(BaseHTTPRequestHandler):
diagnostics_enabled = debug_enabled or bool(audit_dir)
try:
with capture_diagnostics(diagnostics_enabled) as diagnostics:
response = self.server.adapter.execute_prompt(prompt, config) # type: ignore[attr-defined]
adapter = self.server.adapter # type: ignore[attr-defined]
if not adapter.validate_config(config):
raise LLMConfigurationError(
"Adapter rejected RunConfig",
context={"model_name": config.model_name},
)
response = adapter.execute_prompt(prompt, config)
latency = time.time() - start
body = response.to_dict()
debug = diagnostics.to_dict() if diagnostics is not None else None
@@ -96,7 +111,8 @@ class _Handler(BaseHTTPRequestHandler):
_write_audit_record(audit_dir, prompt, config, response, debug, latency)
self._respond(200, body)
except Exception as exc:
self._respond(500, {"error": str(exc)})
status, body = _error_response(exc)
self._respond(status, body)
# ── helpers ────────────────────────────────────────────────────
@@ -155,9 +171,23 @@ class LLMServer:
# ── CLI entry point ────────────────────────────────────────────────────────────
def _build_adapter(provider: str, model: Optional[str]) -> LLMAdapter:
def _build_adapter(
provider: str,
model: Optional[str],
*,
enable_profiles: bool = True,
strict_profiles: bool = False,
) -> LLMAdapter:
from llm_connect.factory import create_adapter
return create_adapter(provider, model=model)
adapter = create_adapter(provider, model=model)
if not enable_profiles:
return adapter
return ProfiledLLMAdapter(
adapter,
default_runtime_profiles(provider=provider, model=model),
strict_profiles=strict_profiles,
)
def _debug_requested(query: str) -> bool:
@@ -172,6 +202,76 @@ def _truthy(value: str) -> bool:
return value.strip().lower() in {"1", "true", "yes", "on"}
def _error_response(exc: Exception) -> tuple[int, dict]:
"""Map exceptions to operator-useful, secret-safe server responses."""
if isinstance(exc, LLMRateLimitError):
body = _error_body("provider_rate_limited", exc)
body["provider_status"] = exc.status_code
return 429, body
if isinstance(exc, LLMTimeoutError):
return 504, _error_body("provider_timeout", exc)
if isinstance(exc, LLMAPIError):
body = _error_body("provider_api_error", exc)
if exc.status_code:
body["provider_status"] = exc.status_code
return 502, body
if isinstance(exc, LLMBudgetExceededError):
return 400, _error_body("budget_exceeded", exc)
if isinstance(exc, LLMConfigurationError):
if _message(exc).startswith("Unknown LLM runtime profile"):
return 400, _error_body("unknown_profile", exc)
return 500, _error_body("configuration_error", exc)
if isinstance(exc, LLMError):
return 500, _error_body("llm_error", exc)
return 500, _error_body("internal_error", exc)
def _error_body(code: str, exc: Exception) -> dict:
body = {
"error": code,
"message": _sanitize_text(_message(exc)),
"type": exc.__class__.__name__,
}
context = getattr(exc, "context", None)
if isinstance(context, dict):
safe_context = _safe_context(context)
if safe_context:
body["context"] = safe_context
return body
def _message(exc: Exception) -> str:
if exc.args:
return str(exc.args[0])
return str(exc)
def _safe_context(context: dict) -> dict:
safe = {}
for key, value in context.items():
lowered = str(key).lower()
if any(secret_word in lowered for secret_word in ("key", "secret", "token", "password")):
safe[key] = "<redacted>"
elif isinstance(value, (str, int, float, bool)) or value is None:
safe[key] = _sanitize_text(str(value)) if isinstance(value, str) else value
else:
safe[key] = _sanitize_text(str(value))
return safe
def _sanitize_text(value: str) -> str:
value = re.sub(r"Bearer\s+[A-Za-z0-9._~+/=-]+", "Bearer <redacted>", value)
value = re.sub(r"([?&]key=)[^&\s]+", r"\1<redacted>", value)
value = re.sub(r"\bsk-[A-Za-z0-9_-]{8,}", "sk-<redacted>", value)
value = re.sub(
r"(?i)(api[_-]?key|token|secret|password)=([^,\s\]]+)",
r"\1=<redacted>",
value,
)
return value
def _write_audit_record(
audit_dir: str,
prompt: str,
@@ -214,13 +314,46 @@ def main(argv=None) -> None:
prog="python -m llm_connect.server",
description="Start llm_connect HTTP serve mode.",
)
parser.add_argument("--port", type=int, default=8080, help="TCP port (default: 8080)")
parser.add_argument("--host", default="127.0.0.1", help="Bind address (default: 127.0.0.1)")
parser.add_argument("--provider", default="mock", help="Provider name passed to create_adapter")
parser.add_argument("--model", default=None, help="Model name (optional)")
parser.add_argument(
"--port",
type=int,
default=int(os.environ.get("LLM_CONNECT_PORT", "8080")),
help="TCP port (default: env LLM_CONNECT_PORT or 8080)",
)
parser.add_argument(
"--host",
default=os.environ.get("LLM_CONNECT_HOST", "127.0.0.1"),
help="Bind address (default: env LLM_CONNECT_HOST or 127.0.0.1)",
)
parser.add_argument(
"--provider",
default=os.environ.get("LLM_CONNECT_PROVIDER", "mock"),
help="Provider name passed to create_adapter (default: env LLM_CONNECT_PROVIDER or mock)",
)
parser.add_argument(
"--model",
default=os.environ.get("LLM_CONNECT_MODEL") or None,
help="Model name (default: env LLM_CONNECT_MODEL, optional)",
)
parser.add_argument(
"--disable-profiles",
action="store_true",
help="Disable server runtime profile dispatch.",
)
parser.add_argument(
"--strict-profiles",
action="store_true",
default=_truthy(os.environ.get("LLM_CONNECT_STRICT_PROFILES", "")),
help="Reject non-profile model_name values instead of passing them through.",
)
args = parser.parse_args(argv)
adapter = _build_adapter(args.provider, args.model)
adapter = _build_adapter(
args.provider,
args.model,
enable_profiles=not args.disable_profiles,
strict_profiles=args.strict_profiles,
)
server = LLMServer(adapter=adapter, host=args.host, port=args.port)
print(f"llm_connect server listening on http://{args.host}:{args.port}")
try: