"""Runtime configuration schema for Beaver sandbox instances.""" from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path from typing import Any @dataclass(slots=True) class ProviderConfig: """One configured LLM provider profile.""" api_key: str | None = None api_base: str | None = None extra_headers: dict[str, str] = field(default_factory=dict) request_timeout_seconds: float | None = None @dataclass(slots=True) class AgentDefaultsConfig: """Default agent settings for this sandbox instance.""" workspace: str | None = None model: str | None = None provider: str | None = None embedding_model: str | None = None @dataclass(slots=True) class EmbeddingConfig: """Optional dedicated embedding model settings.""" provider: str | None = None model: str | None = None api_key: str | None = None api_base: str | None = None extra_headers: dict[str, str] = field(default_factory=dict) request_timeout_seconds: float | None = None @dataclass(slots=True) class BeaverConfig: """Config loaded once per backend sandbox instance.""" agents_defaults: AgentDefaultsConfig = field(default_factory=AgentDefaultsConfig) providers: dict[str, ProviderConfig] = field(default_factory=dict) embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig) config_path: Path | None = None @property def default_model(self) -> str | None: return _clean(self.agents_defaults.model) @property def default_embedding_model(self) -> str: return _clean(self.embedding.model) or _clean(self.agents_defaults.embedding_model) or "text-embedding-v4" def resolve_provider_target( self, *, model: str | None = None, provider_name: str | None = None, ) -> dict[str, Any]: """Resolve model/provider credentials from instance config. Request-level model/provider overrides are allowed, but credentials are still read from backend config, not from Web/channel payloads. """ resolved_model = _clean(model) or self.default_model resolved_provider = _clean(provider_name) or self._infer_provider(resolved_model) provider_cfg = self.providers.get(resolved_provider or "") if resolved_provider else None payload: dict[str, Any] = { "model": resolved_model, "provider_name": resolved_provider, } if provider_cfg is not None: payload.update( { "api_key": provider_cfg.api_key, "api_base": provider_cfg.api_base, "extra_headers": dict(provider_cfg.extra_headers), "request_timeout_seconds": provider_cfg.request_timeout_seconds, } ) return {key: value for key, value in payload.items() if value not in (None, "", {})} def resolve_embedding_target(self) -> dict[str, Any] | None: """Return an explicit embedding target when configured.""" has_explicit_embedding = any( [ _clean(self.embedding.provider), _clean(self.embedding.api_key), _clean(self.embedding.api_base), self.embedding.extra_headers, self.embedding.request_timeout_seconds is not None, ] ) if not has_explicit_embedding: return None provider_cfg = self.providers.get(_clean(self.embedding.provider) or "") payload: dict[str, Any] = { "provider": _clean(self.embedding.provider), "model": self.default_embedding_model, "api_key": _clean(self.embedding.api_key) or (provider_cfg.api_key if provider_cfg else None), "api_base": _clean(self.embedding.api_base) or (provider_cfg.api_base if provider_cfg else None), "extra_headers": self.embedding.extra_headers or (dict(provider_cfg.extra_headers) if provider_cfg else {}), "request_timeout_seconds": self.embedding.request_timeout_seconds or (provider_cfg.request_timeout_seconds if provider_cfg else None), } return {key: value for key, value in payload.items() if value not in (None, "", {})} def _infer_provider(self, model: str | None) -> str | None: configured_provider = _clean(self.agents_defaults.provider) if configured_provider: return configured_provider if model and "/" in model: prefix = model.split("/", 1)[0] if prefix in self.providers: return prefix if len(self.providers) == 1: return next(iter(self.providers)) return None def _clean(value: str | None) -> str | None: if value is None: return None value = str(value).strip() return value or None