agent_search_gateway.config
TOML and environment configuration resolution.
1"""TOML and environment configuration resolution.""" 2 3import copy 4import math 5import tomllib 6from collections.abc import Mapping 7from dataclasses import dataclass, field 8from pathlib import Path 9from types import MappingProxyType 10from urllib.parse import urlsplit 11 12from .errors import ConfigFailure, ErrorCode 13from .models import LLMInvocation, RetryPolicy 14from .observability import SecretValue 15from .providers.academic.registry import ( 16 AcademicProviderRegistration, 17 AcademicProviderRegistry, 18 OAResolverRegistration, 19 OAResolverRegistry, 20 Requirement, 21) 22from .providers.registry import ProviderRegistry, WebProviderRegistration 23 24_WEB_SHARED_KEYS = frozenset({"enable_search", "enable_fetch", "api_key_env", "max_concurrency"}) 25_ACADEMIC_SHARED_KEYS = frozenset( 26 {"enabled", "max_concurrency", "api_key_env", "contact_email_env"} 27) 28_ACADEMIC_RESERVED_OPTIONS = frozenset({"executor", "api_key", "contact_email", "name"}) 29 30 31@dataclass(frozen=True, slots=True) 32class ResolvedWebProviderConfig: 33 name: str 34 enable_search: bool 35 enable_fetch: bool 36 max_concurrency: int 37 api_key_env: str | None 38 secret: SecretValue | None 39 options: Mapping[str, object] 40 41 42@dataclass(frozen=True, slots=True) 43class ResolvedWebProviderGroup: 44 default_max_concurrency: int 45 providers: tuple[ResolvedWebProviderConfig, ...] 46 47 48def _config_error(message: str) -> ConfigFailure: 49 return ConfigFailure(ErrorCode.CONFIG_ERROR, message) 50 51 52def _require_mapping(value: object, label: str) -> Mapping[str, object]: 53 if not isinstance(value, dict): 54 raise _config_error(f"{label} must be a table") 55 return value 56 57 58def _positive_int(value: object, label: str) -> int: 59 if isinstance(value, bool) or not isinstance(value, int) or value <= 0: 60 raise _config_error(f"{label} must be a positive integer") 61 return value 62 63 64def _optional_bool(table: Mapping[str, object], key: str) -> bool: 65 value = table.get(key, False) 66 if not isinstance(value, bool): 67 raise _config_error(f"{key} must be a boolean") 68 return value 69 70 71def _validate_options( 72 registration: WebProviderRegistration, 73 options: Mapping[str, object], 74) -> None: 75 unknown = set(options) - registration.allowed_config_keys 76 if unknown: 77 names = ", ".join(sorted(unknown)) 78 raise _config_error(f"unknown config key(s) for {registration.name}: {names}") 79 80 81def _resolve_one_web_provider( 82 name: str, 83 table: Mapping[str, object], 84 default_max_concurrency: int, 85 registry: ProviderRegistry, 86 environ: Mapping[str, str], 87) -> ResolvedWebProviderConfig: 88 enable_search = _optional_bool(table, "enable_search") 89 enable_fetch = _optional_bool(table, "enable_fetch") 90 options = {key: value for key, value in table.items() if key not in _WEB_SHARED_KEYS} 91 registration = registry.get(name) 92 93 if registration is not None: 94 _validate_options(registration, options) 95 96 max_concurrency = _positive_int( 97 table.get("max_concurrency", default_max_concurrency), 98 f"web provider {name} max_concurrency", 99 ) 100 101 if not enable_search and not enable_fetch: 102 return ResolvedWebProviderConfig( 103 name=name, 104 enable_search=False, 105 enable_fetch=False, 106 max_concurrency=max_concurrency, 107 api_key_env=None, 108 secret=None, 109 options=MappingProxyType(dict(options)), 110 ) 111 112 if registration is None: 113 raise _config_error(f"unknown enabled web provider: {name}") 114 if enable_search and not registration.capabilities.search: 115 raise _config_error(f"web provider {name} does not support search") 116 if enable_fetch and not registration.capabilities.fetch: 117 raise _config_error(f"web provider {name} does not support fetch") 118 119 if not registration.requires_api_key: 120 if "api_key_env" in table: 121 raise _config_error(f"web provider {name} does not accept api_key_env") 122 return ResolvedWebProviderConfig( 123 name=name, 124 enable_search=enable_search, 125 enable_fetch=enable_fetch, 126 max_concurrency=max_concurrency, 127 api_key_env=None, 128 secret=None, 129 options=MappingProxyType(dict(options)), 130 ) 131 132 api_key_env = table.get("api_key_env") 133 if not isinstance(api_key_env, str) or not api_key_env.strip(): 134 raise _config_error(f"web provider {name} requires api_key_env") 135 secret_text = environ.get(api_key_env) 136 if not secret_text: 137 raise _config_error(f"environment variable {api_key_env} is required") 138 139 return ResolvedWebProviderConfig( 140 name=name, 141 enable_search=enable_search, 142 enable_fetch=enable_fetch, 143 max_concurrency=max_concurrency, 144 api_key_env=api_key_env, 145 secret=SecretValue(secret_text), 146 options=MappingProxyType(dict(options)), 147 ) 148 149 150def resolve_web_provider_config( 151 data: Mapping[str, object], 152 registry: ProviderRegistry, 153 environ: Mapping[str, str], 154) -> ResolvedWebProviderGroup: 155 web_table = _require_mapping(data.get("web_providers", {}), "web_providers") 156 default_max_concurrency = _positive_int( 157 web_table.get("default_max_concurrency", 3), 158 "web_providers.default_max_concurrency", 159 ) 160 161 providers: list[ResolvedWebProviderConfig] = [] 162 for name, value in web_table.items(): 163 if name == "default_max_concurrency": 164 continue 165 provider_table = _require_mapping(value, f"web_providers.{name}") 166 providers.append( 167 _resolve_one_web_provider( 168 name, 169 provider_table, 170 default_max_concurrency, 171 registry, 172 environ, 173 ) 174 ) 175 return ResolvedWebProviderGroup(default_max_concurrency, tuple(providers)) 176 177 178@dataclass(frozen=True, slots=True) 179class ResolvedAcademicProviderConfig: 180 name: str 181 enabled: bool 182 max_concurrency: int 183 api_key_env: str | None 184 api_key: SecretValue | None 185 contact_email_env: str | None 186 contact_email: SecretValue | None 187 options: Mapping[str, object] 188 189 190@dataclass(frozen=True, slots=True) 191class ResolvedAcademicProviderGroup: 192 default_max_concurrency: int = 3 193 providers: tuple[ResolvedAcademicProviderConfig, ...] = () 194 195 196@dataclass(frozen=True, slots=True) 197class ResolvedOAResolverConfig: 198 name: str 199 api_key_env: str | None 200 api_key: SecretValue | None 201 contact_email_env: str | None 202 contact_email: SecretValue | None 203 options: Mapping[str, object] 204 205 206def _enabled(table: Mapping[str, object], label: str) -> bool: 207 value = table.get("enabled", False) 208 if not isinstance(value, bool): 209 raise _config_error(f"{label}.enabled must be a boolean") 210 return value 211 212 213def _validate_http_url(value: object, label: str) -> None: 214 if not isinstance(value, str) or not value.strip(): 215 raise _config_error(f"{label} must be a non-empty HTTP(S) URL") 216 try: 217 parsed = urlsplit(value) 218 except ValueError as exc: 219 raise _config_error(f"{label} must be a non-empty HTTP(S) URL") from exc 220 if parsed.scheme not in {"http", "https"} or parsed.hostname is None: 221 raise _config_error(f"{label} must be a non-empty HTTP(S) URL") 222 223 224def _validate_academic_options( 225 name: str, 226 registration: AcademicProviderRegistration | OAResolverRegistration, 227 options: Mapping[str, object], 228) -> None: 229 reserved = set(options) & _ACADEMIC_RESERVED_OPTIONS 230 if reserved: 231 names = ", ".join(sorted(reserved)) 232 raise _config_error(f"reserved config key(s) for {name}: {names}") 233 unknown = set(options) - registration.allowed_config_keys 234 if unknown: 235 names = ", ".join(sorted(unknown)) 236 raise _config_error(f"unknown config key(s) for {name}: {names}") 237 if "api_url" in options: 238 _validate_http_url(options["api_url"], f"{name}.api_url") 239 240 241def _resolve_environment_value( 242 table: Mapping[str, object], 243 *, 244 key: str, 245 requirement: Requirement, 246 label: str, 247 environ: Mapping[str, str], 248) -> tuple[str | None, SecretValue | None]: 249 if requirement == "none": 250 if key in table: 251 raise _config_error(f"{label} does not accept {key}") 252 return None, None 253 if key not in table: 254 if requirement == "required": 255 raise _config_error(f"{label} requires {key}") 256 return None, None 257 env_name = table[key] 258 if not isinstance(env_name, str) or not env_name.strip(): 259 raise _config_error(f"{label}.{key} must be a non-empty string") 260 resolved_value = environ.get(env_name) 261 if not resolved_value: 262 raise _config_error(f"environment variable {env_name} is required") 263 return env_name, SecretValue(resolved_value) 264 265 266def resolve_academic_provider_config( 267 data: Mapping[str, object], 268 registry: AcademicProviderRegistry, 269 environ: Mapping[str, str], 270) -> ResolvedAcademicProviderGroup: 271 group = _require_mapping(data.get("academic_providers", {}), "academic_providers") 272 default_limit = _positive_int( 273 group.get("default_max_concurrency", 3), 274 "academic_providers.default_max_concurrency", 275 ) 276 providers: list[ResolvedAcademicProviderConfig] = [] 277 for name, raw_value in group.items(): 278 if name == "default_max_concurrency": 279 continue 280 table = _require_mapping(raw_value, f"academic_providers.{name}") 281 registration = registry.get(name) 282 if registration is None: 283 raise _config_error(f"unknown academic provider: {name}") 284 enabled = _enabled(table, f"academic_providers.{name}") 285 options = {key: value for key, value in table.items() if key not in _ACADEMIC_SHARED_KEYS} 286 _validate_academic_options(name, registration, options) 287 limit = _positive_int( 288 table.get("max_concurrency", default_limit), 289 f"academic provider {name} max_concurrency", 290 ) 291 if not enabled: 292 for key, requirement in ( 293 ("api_key_env", registration.authentication), 294 ("contact_email_env", registration.contact), 295 ): 296 if requirement == "none" and key in table: 297 raise _config_error(f"academic provider {name} does not accept {key}") 298 if key in table and ( 299 not isinstance(table[key], str) or not str(table[key]).strip() 300 ): 301 raise _config_error( 302 f"academic provider {name}.{key} must be a non-empty string" 303 ) 304 providers.append( 305 ResolvedAcademicProviderConfig( 306 name, 307 False, 308 limit, 309 None, 310 None, 311 None, 312 None, 313 MappingProxyType(dict(options)), 314 ) 315 ) 316 continue 317 auth_env, auth_value = _resolve_environment_value( 318 table, 319 key="api_key_env", 320 requirement=registration.authentication, 321 label=f"academic provider {name}", 322 environ=environ, 323 ) 324 contact_env, contact_value = _resolve_environment_value( 325 table, 326 key="contact_email_env", 327 requirement=registration.contact, 328 label=f"academic provider {name}", 329 environ=environ, 330 ) 331 providers.append( 332 ResolvedAcademicProviderConfig( 333 name, 334 enabled, 335 limit, 336 auth_env, 337 auth_value, 338 contact_env, 339 contact_value, 340 MappingProxyType(dict(options)), 341 ) 342 ) 343 return ResolvedAcademicProviderGroup(default_limit, tuple(providers)) 344 345 346def resolve_oa_resolver_config( 347 data: Mapping[str, object], 348 registry: OAResolverRegistry, 349 environ: Mapping[str, str], 350) -> ResolvedOAResolverConfig | None: 351 group = _require_mapping(data.get("oa_resolvers", {}), "oa_resolvers") 352 resolved: ResolvedOAResolverConfig | None = None 353 for name, raw_value in group.items(): 354 table = _require_mapping(raw_value, f"oa_resolvers.{name}") 355 registration = registry.get(name) 356 if registration is None: 357 raise _config_error(f"unknown OA resolver: {name}") 358 enabled = _enabled(table, f"oa_resolvers.{name}") 359 options = {key: value for key, value in table.items() if key not in _ACADEMIC_SHARED_KEYS} 360 _validate_academic_options(name, registration, options) 361 if not enabled: 362 continue 363 if resolved is not None: 364 raise _config_error("only one OA resolver may be enabled") 365 auth_env, auth_value = _resolve_environment_value( 366 table, 367 key="api_key_env", 368 requirement=registration.authentication, 369 label=f"OA resolver {name}", 370 environ=environ, 371 ) 372 contact_env, contact_value = _resolve_environment_value( 373 table, 374 key="contact_email_env", 375 requirement=registration.contact, 376 label=f"OA resolver {name}", 377 environ=environ, 378 ) 379 resolved = ResolvedOAResolverConfig( 380 name, 381 auth_env, 382 auth_value, 383 contact_env, 384 contact_value, 385 MappingProxyType(dict(options)), 386 ) 387 return resolved 388 389 390@dataclass(frozen=True, slots=True) 391class LLMProviderConfig: 392 name: str 393 protocol: str 394 api_endpoint: str 395 api_url: str 396 api_key_env: str 397 secret: SecretValue 398 max_concurrency: int 399 400 401@dataclass(frozen=True, slots=True) 402class ResolvedLLMConfig: 403 default_max_concurrency: int 404 providers: tuple[LLMProviderConfig, ...] 405 search_invocations: tuple[LLMInvocation, ...] 406 judge: LLMInvocation 407 safety: LLMInvocation 408 content_clean: LLMInvocation 409 focus_summary: LLMInvocation 410 411 412@dataclass(frozen=True, slots=True) 413class ResolvedConfig: 414 web: ResolvedWebProviderGroup 415 llm: ResolvedLLMConfig 416 retry: RetryPolicy 417 academic: ResolvedAcademicProviderGroup = field(default_factory=ResolvedAcademicProviderGroup) 418 oa_resolver: ResolvedOAResolverConfig | None = None 419 420 421def _required_string(table: Mapping[str, object], key: str, label: str) -> str: 422 value = table.get(key) 423 if not isinstance(value, str) or not value.strip(): 424 raise _config_error(f"{label}.{key} must be a non-empty string") 425 return value 426 427 428def _first_defined(key: str, *tables: Mapping[str, object]) -> object | None: 429 for table in tables: 430 if key in table: 431 return table[key] 432 return None 433 434 435def _resolve_extra_body(*tables: Mapping[str, object]) -> dict[str, object]: 436 value = _first_defined("extra_body", *tables) 437 if value is None: 438 return {} 439 if not isinstance(value, dict): 440 raise _config_error("LLM extra_body must be a table") 441 return copy.deepcopy(value) 442 443 444def _resolve_invocation( 445 label: str, 446 scopes: tuple[Mapping[str, object], ...], 447 *, 448 require_explicit_provider: bool = False, 449) -> LLMInvocation: 450 provider_value = ( 451 scopes[0].get("provider") 452 if require_explicit_provider 453 else _first_defined("provider", *scopes) 454 ) 455 model_value = _first_defined("model", *scopes) 456 if not isinstance(provider_value, str) or not provider_value.strip(): 457 raise _config_error(f"{label} requires provider") 458 if not isinstance(model_value, str) or not model_value.strip(): 459 raise _config_error(f"{label} requires model") 460 return LLMInvocation( 461 provider=provider_value, 462 model=model_value, 463 extra_body=_resolve_extra_body(*scopes), 464 ) 465 466 467def _search_invocations( 468 data: Mapping[str, object], 469 global_table: Mapping[str, object], 470) -> tuple[LLMInvocation, ...]: 471 search_table = _require_mapping(data.get("search_llm", {}), "search_llm") 472 raw_entries = search_table.get("providers", []) 473 if not isinstance(raw_entries, list): 474 raise _config_error("search_llm.providers must be an array of tables") 475 476 resolved: list[LLMInvocation] = [] 477 for index, raw_entry in enumerate(raw_entries): 478 entry = _require_mapping(raw_entry, f"search_llm.providers[{index}]") 479 resolved.append( 480 _resolve_invocation( 481 f"search_llm.providers[{index}]", 482 (entry, global_table), 483 require_explicit_provider=True, 484 ) 485 ) 486 return tuple(resolved) 487 488 489def _fetch_invocations( 490 data: Mapping[str, object], 491 global_table: Mapping[str, object], 492) -> tuple[LLMInvocation, LLMInvocation, LLMInvocation, LLMInvocation]: 493 fetch_table = _require_mapping(data.get("fetch_llm", {}), "fetch_llm") 494 names = ("judge", "safety", "content_clean", "focus_summary") 495 invocations: list[LLMInvocation] = [] 496 for name in names: 497 stage_table = _require_mapping(fetch_table.get(name, {}), f"fetch_llm.{name}") 498 invocations.append( 499 _resolve_invocation( 500 f"fetch_llm.{name}", 501 (stage_table, fetch_table, global_table), 502 ) 503 ) 504 return invocations[0], invocations[1], invocations[2], invocations[3] 505 506 507def _resolve_referenced_llm_providers( 508 llm_table: Mapping[str, object], 509 referenced: set[str], 510 default_max_concurrency: int, 511 environ: Mapping[str, str], 512) -> tuple[LLMProviderConfig, ...]: 513 providers: list[LLMProviderConfig] = [] 514 for name, raw_value in llm_table.items(): 515 if name == "default_max_concurrency" or name not in referenced: 516 continue 517 table = _require_mapping(raw_value, f"llm_providers.{name}") 518 protocol = _required_string(table, "protocol", f"llm_providers.{name}") 519 endpoint = _required_string(table, "api_endpoint", f"llm_providers.{name}") 520 if protocol != "openai" or endpoint != "chat_completions": 521 raise _config_error(f"unsupported LLM provider protocol/endpoint: {name}") 522 api_url = _required_string(table, "api_url", f"llm_providers.{name}") 523 api_key_env = _required_string(table, "api_key_env", f"llm_providers.{name}") 524 secret_text = environ.get(api_key_env) 525 if not secret_text: 526 raise _config_error(f"environment variable {api_key_env} is required") 527 max_concurrency = _positive_int( 528 table.get("max_concurrency", default_max_concurrency), 529 f"llm provider {name} max_concurrency", 530 ) 531 providers.append( 532 LLMProviderConfig( 533 name=name, 534 protocol=protocol, 535 api_endpoint=endpoint, 536 api_url=api_url, 537 api_key_env=api_key_env, 538 secret=SecretValue(secret_text), 539 max_concurrency=max_concurrency, 540 ) 541 ) 542 543 configured = {provider.name for provider in providers} 544 missing = referenced - configured 545 if missing: 546 raise _config_error(f"unknown referenced LLM provider(s): {', '.join(sorted(missing))}") 547 return tuple(providers) 548 549 550def resolve_llm_config( 551 data: Mapping[str, object], 552 environ: Mapping[str, str], 553) -> ResolvedLLMConfig: 554 llm_table = _require_mapping(data.get("llm_providers", {}), "llm_providers") 555 default_max_concurrency = _positive_int( 556 llm_table.get("default_max_concurrency", 2), 557 "llm_providers.default_max_concurrency", 558 ) 559 global_table = _require_mapping(data.get("global_default_llm", {}), "global_default_llm") 560 search_invocations = _search_invocations(data, global_table) 561 judge, safety, content_clean, focus_summary = _fetch_invocations(data, global_table) 562 all_invocations = (*search_invocations, judge, safety, content_clean, focus_summary) 563 providers = _resolve_referenced_llm_providers( 564 llm_table, 565 {invocation.provider for invocation in all_invocations}, 566 default_max_concurrency, 567 environ, 568 ) 569 return ResolvedLLMConfig( 570 default_max_concurrency=default_max_concurrency, 571 providers=providers, 572 search_invocations=search_invocations, 573 judge=judge, 574 safety=safety, 575 content_clean=content_clean, 576 focus_summary=focus_summary, 577 ) 578 579 580def _positive_float(value: object, label: str) -> float: 581 if ( 582 isinstance(value, bool) 583 or not isinstance(value, int | float) 584 or not math.isfinite(value) 585 or value <= 0 586 ): 587 raise _config_error(f"{label} must be a positive number") 588 return float(value) 589 590 591def resolve_retry_policy(data: Mapping[str, object]) -> RetryPolicy: 592 retry_table = _require_mapping(data.get("retry", {}), "retry") 593 return RetryPolicy( 594 max_attempts=_positive_int(retry_table.get("max_attempts", 3), "retry.max_attempts"), 595 base_delay_seconds=_positive_float( 596 retry_table.get("base_delay_seconds", 0.25), 597 "retry.base_delay_seconds", 598 ), 599 max_delay_seconds=_positive_float( 600 retry_table.get("max_delay_seconds", 2.0), 601 "retry.max_delay_seconds", 602 ), 603 request_timeout_seconds=_positive_float( 604 retry_table.get("request_timeout_seconds", 30.0), 605 "retry.request_timeout_seconds", 606 ), 607 ) 608 609 610def resolve_config( 611 data: Mapping[str, object], 612 registry: ProviderRegistry, 613 environ: Mapping[str, str], 614 *, 615 academic_registry: AcademicProviderRegistry | None = None, 616 oa_resolver_registry: OAResolverRegistry | None = None, 617) -> ResolvedConfig: 618 academic = ( 619 resolve_academic_provider_config(data, academic_registry, environ) 620 if academic_registry is not None 621 else ResolvedAcademicProviderGroup() 622 ) 623 oa_resolver = ( 624 resolve_oa_resolver_config(data, oa_resolver_registry, environ) 625 if oa_resolver_registry is not None 626 else None 627 ) 628 return ResolvedConfig( 629 web=resolve_web_provider_config(data, registry, environ), 630 llm=resolve_llm_config(data, environ), 631 retry=resolve_retry_policy(data), 632 academic=academic, 633 oa_resolver=oa_resolver, 634 ) 635 636 637def load_toml(path: Path) -> dict[str, object]: 638 try: 639 with path.open("rb") as handle: 640 return tomllib.load(handle) 641 except (OSError, tomllib.TOMLDecodeError) as exc: 642 raise _config_error(f"failed to load config: {exc}") from exc
@dataclass(frozen=True, slots=True)
class
ResolvedWebProviderConfig:
32@dataclass(frozen=True, slots=True) 33class ResolvedWebProviderConfig: 34 name: str 35 enable_search: bool 36 enable_fetch: bool 37 max_concurrency: int 38 api_key_env: str | None 39 secret: SecretValue | None 40 options: Mapping[str, object]
ResolvedWebProviderConfig( name: str, enable_search: bool, enable_fetch: bool, max_concurrency: int, api_key_env: str | None, secret: agent_search_gateway.observability.SecretValue | None, options: Mapping[str, object])
secret: agent_search_gateway.observability.SecretValue | None
@dataclass(frozen=True, slots=True)
class
ResolvedWebProviderGroup:
43@dataclass(frozen=True, slots=True) 44class ResolvedWebProviderGroup: 45 default_max_concurrency: int 46 providers: tuple[ResolvedWebProviderConfig, ...]
ResolvedWebProviderGroup( default_max_concurrency: int, providers: tuple[ResolvedWebProviderConfig, ...])
providers: tuple[ResolvedWebProviderConfig, ...]
def
resolve_web_provider_config( data: Mapping[str, object], registry: agent_search_gateway.providers.registry.ProviderRegistry, environ: Mapping[str, str]) -> ResolvedWebProviderGroup:
151def resolve_web_provider_config( 152 data: Mapping[str, object], 153 registry: ProviderRegistry, 154 environ: Mapping[str, str], 155) -> ResolvedWebProviderGroup: 156 web_table = _require_mapping(data.get("web_providers", {}), "web_providers") 157 default_max_concurrency = _positive_int( 158 web_table.get("default_max_concurrency", 3), 159 "web_providers.default_max_concurrency", 160 ) 161 162 providers: list[ResolvedWebProviderConfig] = [] 163 for name, value in web_table.items(): 164 if name == "default_max_concurrency": 165 continue 166 provider_table = _require_mapping(value, f"web_providers.{name}") 167 providers.append( 168 _resolve_one_web_provider( 169 name, 170 provider_table, 171 default_max_concurrency, 172 registry, 173 environ, 174 ) 175 ) 176 return ResolvedWebProviderGroup(default_max_concurrency, tuple(providers))
@dataclass(frozen=True, slots=True)
class
ResolvedAcademicProviderConfig:
179@dataclass(frozen=True, slots=True) 180class ResolvedAcademicProviderConfig: 181 name: str 182 enabled: bool 183 max_concurrency: int 184 api_key_env: str | None 185 api_key: SecretValue | None 186 contact_email_env: str | None 187 contact_email: SecretValue | None 188 options: Mapping[str, object]
ResolvedAcademicProviderConfig( name: str, enabled: bool, max_concurrency: int, api_key_env: str | None, api_key: agent_search_gateway.observability.SecretValue | None, contact_email_env: str | None, contact_email: agent_search_gateway.observability.SecretValue | None, options: Mapping[str, object])
api_key: agent_search_gateway.observability.SecretValue | None
contact_email: agent_search_gateway.observability.SecretValue | None
@dataclass(frozen=True, slots=True)
class
ResolvedAcademicProviderGroup:
191@dataclass(frozen=True, slots=True) 192class ResolvedAcademicProviderGroup: 193 default_max_concurrency: int = 3 194 providers: tuple[ResolvedAcademicProviderConfig, ...] = ()
ResolvedAcademicProviderGroup( default_max_concurrency: int = 3, providers: tuple[ResolvedAcademicProviderConfig, ...] = ())
providers: tuple[ResolvedAcademicProviderConfig, ...]
@dataclass(frozen=True, slots=True)
class
ResolvedOAResolverConfig:
197@dataclass(frozen=True, slots=True) 198class ResolvedOAResolverConfig: 199 name: str 200 api_key_env: str | None 201 api_key: SecretValue | None 202 contact_email_env: str | None 203 contact_email: SecretValue | None 204 options: Mapping[str, object]
ResolvedOAResolverConfig( name: str, api_key_env: str | None, api_key: agent_search_gateway.observability.SecretValue | None, contact_email_env: str | None, contact_email: agent_search_gateway.observability.SecretValue | None, options: Mapping[str, object])
api_key: agent_search_gateway.observability.SecretValue | None
contact_email: agent_search_gateway.observability.SecretValue | None
def
resolve_academic_provider_config( data: Mapping[str, object], registry: agent_search_gateway.providers.academic.registry.AcademicProviderRegistry, environ: Mapping[str, str]) -> ResolvedAcademicProviderGroup:
267def resolve_academic_provider_config( 268 data: Mapping[str, object], 269 registry: AcademicProviderRegistry, 270 environ: Mapping[str, str], 271) -> ResolvedAcademicProviderGroup: 272 group = _require_mapping(data.get("academic_providers", {}), "academic_providers") 273 default_limit = _positive_int( 274 group.get("default_max_concurrency", 3), 275 "academic_providers.default_max_concurrency", 276 ) 277 providers: list[ResolvedAcademicProviderConfig] = [] 278 for name, raw_value in group.items(): 279 if name == "default_max_concurrency": 280 continue 281 table = _require_mapping(raw_value, f"academic_providers.{name}") 282 registration = registry.get(name) 283 if registration is None: 284 raise _config_error(f"unknown academic provider: {name}") 285 enabled = _enabled(table, f"academic_providers.{name}") 286 options = {key: value for key, value in table.items() if key not in _ACADEMIC_SHARED_KEYS} 287 _validate_academic_options(name, registration, options) 288 limit = _positive_int( 289 table.get("max_concurrency", default_limit), 290 f"academic provider {name} max_concurrency", 291 ) 292 if not enabled: 293 for key, requirement in ( 294 ("api_key_env", registration.authentication), 295 ("contact_email_env", registration.contact), 296 ): 297 if requirement == "none" and key in table: 298 raise _config_error(f"academic provider {name} does not accept {key}") 299 if key in table and ( 300 not isinstance(table[key], str) or not str(table[key]).strip() 301 ): 302 raise _config_error( 303 f"academic provider {name}.{key} must be a non-empty string" 304 ) 305 providers.append( 306 ResolvedAcademicProviderConfig( 307 name, 308 False, 309 limit, 310 None, 311 None, 312 None, 313 None, 314 MappingProxyType(dict(options)), 315 ) 316 ) 317 continue 318 auth_env, auth_value = _resolve_environment_value( 319 table, 320 key="api_key_env", 321 requirement=registration.authentication, 322 label=f"academic provider {name}", 323 environ=environ, 324 ) 325 contact_env, contact_value = _resolve_environment_value( 326 table, 327 key="contact_email_env", 328 requirement=registration.contact, 329 label=f"academic provider {name}", 330 environ=environ, 331 ) 332 providers.append( 333 ResolvedAcademicProviderConfig( 334 name, 335 enabled, 336 limit, 337 auth_env, 338 auth_value, 339 contact_env, 340 contact_value, 341 MappingProxyType(dict(options)), 342 ) 343 ) 344 return ResolvedAcademicProviderGroup(default_limit, tuple(providers))
def
resolve_oa_resolver_config( data: Mapping[str, object], registry: agent_search_gateway.providers.academic.registry.OAResolverRegistry, environ: Mapping[str, str]) -> ResolvedOAResolverConfig | None:
347def resolve_oa_resolver_config( 348 data: Mapping[str, object], 349 registry: OAResolverRegistry, 350 environ: Mapping[str, str], 351) -> ResolvedOAResolverConfig | None: 352 group = _require_mapping(data.get("oa_resolvers", {}), "oa_resolvers") 353 resolved: ResolvedOAResolverConfig | None = None 354 for name, raw_value in group.items(): 355 table = _require_mapping(raw_value, f"oa_resolvers.{name}") 356 registration = registry.get(name) 357 if registration is None: 358 raise _config_error(f"unknown OA resolver: {name}") 359 enabled = _enabled(table, f"oa_resolvers.{name}") 360 options = {key: value for key, value in table.items() if key not in _ACADEMIC_SHARED_KEYS} 361 _validate_academic_options(name, registration, options) 362 if not enabled: 363 continue 364 if resolved is not None: 365 raise _config_error("only one OA resolver may be enabled") 366 auth_env, auth_value = _resolve_environment_value( 367 table, 368 key="api_key_env", 369 requirement=registration.authentication, 370 label=f"OA resolver {name}", 371 environ=environ, 372 ) 373 contact_env, contact_value = _resolve_environment_value( 374 table, 375 key="contact_email_env", 376 requirement=registration.contact, 377 label=f"OA resolver {name}", 378 environ=environ, 379 ) 380 resolved = ResolvedOAResolverConfig( 381 name, 382 auth_env, 383 auth_value, 384 contact_env, 385 contact_value, 386 MappingProxyType(dict(options)), 387 ) 388 return resolved
@dataclass(frozen=True, slots=True)
class
LLMProviderConfig:
391@dataclass(frozen=True, slots=True) 392class LLMProviderConfig: 393 name: str 394 protocol: str 395 api_endpoint: str 396 api_url: str 397 api_key_env: str 398 secret: SecretValue 399 max_concurrency: int
LLMProviderConfig( name: str, protocol: str, api_endpoint: str, api_url: str, api_key_env: str, secret: agent_search_gateway.observability.SecretValue, max_concurrency: int)
@dataclass(frozen=True, slots=True)
class
ResolvedLLMConfig:
402@dataclass(frozen=True, slots=True) 403class ResolvedLLMConfig: 404 default_max_concurrency: int 405 providers: tuple[LLMProviderConfig, ...] 406 search_invocations: tuple[LLMInvocation, ...] 407 judge: LLMInvocation 408 safety: LLMInvocation 409 content_clean: LLMInvocation 410 focus_summary: LLMInvocation
ResolvedLLMConfig( default_max_concurrency: int, providers: tuple[LLMProviderConfig, ...], search_invocations: tuple[agent_search_gateway.models.LLMInvocation, ...], judge: agent_search_gateway.models.LLMInvocation, safety: agent_search_gateway.models.LLMInvocation, content_clean: agent_search_gateway.models.LLMInvocation, focus_summary: agent_search_gateway.models.LLMInvocation)
providers: tuple[LLMProviderConfig, ...]
search_invocations: tuple[agent_search_gateway.models.LLMInvocation, ...]
content_clean: agent_search_gateway.models.LLMInvocation
focus_summary: agent_search_gateway.models.LLMInvocation
@dataclass(frozen=True, slots=True)
class
ResolvedConfig:
413@dataclass(frozen=True, slots=True) 414class ResolvedConfig: 415 web: ResolvedWebProviderGroup 416 llm: ResolvedLLMConfig 417 retry: RetryPolicy 418 academic: ResolvedAcademicProviderGroup = field(default_factory=ResolvedAcademicProviderGroup) 419 oa_resolver: ResolvedOAResolverConfig | None = None
ResolvedConfig( web: ResolvedWebProviderGroup, llm: ResolvedLLMConfig, retry: agent_search_gateway.models.RetryPolicy, academic: ResolvedAcademicProviderGroup = <factory>, oa_resolver: ResolvedOAResolverConfig | None = None)
llm: ResolvedLLMConfig
academic: ResolvedAcademicProviderGroup
oa_resolver: ResolvedOAResolverConfig | None
def
resolve_llm_config( data: Mapping[str, object], environ: Mapping[str, str]) -> ResolvedLLMConfig:
551def resolve_llm_config( 552 data: Mapping[str, object], 553 environ: Mapping[str, str], 554) -> ResolvedLLMConfig: 555 llm_table = _require_mapping(data.get("llm_providers", {}), "llm_providers") 556 default_max_concurrency = _positive_int( 557 llm_table.get("default_max_concurrency", 2), 558 "llm_providers.default_max_concurrency", 559 ) 560 global_table = _require_mapping(data.get("global_default_llm", {}), "global_default_llm") 561 search_invocations = _search_invocations(data, global_table) 562 judge, safety, content_clean, focus_summary = _fetch_invocations(data, global_table) 563 all_invocations = (*search_invocations, judge, safety, content_clean, focus_summary) 564 providers = _resolve_referenced_llm_providers( 565 llm_table, 566 {invocation.provider for invocation in all_invocations}, 567 default_max_concurrency, 568 environ, 569 ) 570 return ResolvedLLMConfig( 571 default_max_concurrency=default_max_concurrency, 572 providers=providers, 573 search_invocations=search_invocations, 574 judge=judge, 575 safety=safety, 576 content_clean=content_clean, 577 focus_summary=focus_summary, 578 )
592def resolve_retry_policy(data: Mapping[str, object]) -> RetryPolicy: 593 retry_table = _require_mapping(data.get("retry", {}), "retry") 594 return RetryPolicy( 595 max_attempts=_positive_int(retry_table.get("max_attempts", 3), "retry.max_attempts"), 596 base_delay_seconds=_positive_float( 597 retry_table.get("base_delay_seconds", 0.25), 598 "retry.base_delay_seconds", 599 ), 600 max_delay_seconds=_positive_float( 601 retry_table.get("max_delay_seconds", 2.0), 602 "retry.max_delay_seconds", 603 ), 604 request_timeout_seconds=_positive_float( 605 retry_table.get("request_timeout_seconds", 30.0), 606 "retry.request_timeout_seconds", 607 ), 608 )
def
resolve_config( data: Mapping[str, object], registry: agent_search_gateway.providers.registry.ProviderRegistry, environ: Mapping[str, str], *, academic_registry: agent_search_gateway.providers.academic.registry.AcademicProviderRegistry | None = None, oa_resolver_registry: agent_search_gateway.providers.academic.registry.OAResolverRegistry | None = None) -> ResolvedConfig:
611def resolve_config( 612 data: Mapping[str, object], 613 registry: ProviderRegistry, 614 environ: Mapping[str, str], 615 *, 616 academic_registry: AcademicProviderRegistry | None = None, 617 oa_resolver_registry: OAResolverRegistry | None = None, 618) -> ResolvedConfig: 619 academic = ( 620 resolve_academic_provider_config(data, academic_registry, environ) 621 if academic_registry is not None 622 else ResolvedAcademicProviderGroup() 623 ) 624 oa_resolver = ( 625 resolve_oa_resolver_config(data, oa_resolver_registry, environ) 626 if oa_resolver_registry is not None 627 else None 628 ) 629 return ResolvedConfig( 630 web=resolve_web_provider_config(data, registry, environ), 631 llm=resolve_llm_config(data, environ), 632 retry=resolve_retry_policy(data), 633 academic=academic, 634 oa_resolver=oa_resolver, 635 )
def
load_toml(path: pathlib.Path) -> dict[str, object]: