"""Runtime configuration, entirely from the environment. Nothing here has a value that would be wrong to commit, so the defaults are the lab's real endpoints. The two secrets — the agent's Keycloak client secret and nothing else — have no default and fail loudly if unset. """ from __future__ import annotations from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_prefix="CATALOGUE_", env_file=".env") # --- identity ----------------------------------------------------------- keycloak_realm_url: str = "https://keycloak.sttlab.local/realms/classic-models" client_id: str = "classic-models-agent-catalogue" client_secret: str = Field(..., description="Keycloak client secret; no default on purpose") # --- data --------------------------------------------------------------- # One entry per MCP server this agent may reach. The audience is requested # as a SCOPE, not through the `audience` parameter — Keycloak answers # "Requested audience not available" to the latter. See identity.py. # # Two audiences per server, not one. The MCP endpoint exchanges the token # again on its own account before reaching the domain API, so the token has # to name the broker that performs that second exchange as well as the # server itself. Without it the call fails with a bare 401 and no usable # detail. This is part of the endpoint's contract, not of this agent's # design — if the middleware serving these URLs is replaced by something # that does not re-exchange, the second scope simply becomes unnecessary. mcp_catalog_url: str = "https://catalog-dev.sttlab.local/catalog-mcp" mcp_catalog_scope: str = "mcp-catalog-audience token-exchange-broker-audience" mcp_sales_url: str = "https://sales-dev.sttlab.local/sales-mcp" mcp_sales_scope: str = "mcp-sales-audience token-exchange-broker-audience" # --- inference ---------------------------------------------------------- # Always the estate's LLM endpoint, never a provider directly: it carries # the rate limits, the prompt guards and the audit trail. It speaks the # OpenAI protocol, which is why the OpenAI provider is used against it. llm_base_url: str = "https://llm-dev.sttlab.local/llm/v1" llm_model: str = "google/gemini-3.7-flash" # The LLM endpoint requires this audience. The bearer it validates is an # exchanged token, not a static API key, so inference carries the same # `azp`/`sub` pair as every tool call and lands in the same audit trail. # Static-key authentication is also accepted there, for callers that cannot # do OIDC outbound, but it proves nothing about the end user — this agent # can do better and does. llm_scope: str = "llm-proxy-audience" # A turn that loops has to stop somewhere. Twenty tool calls is far # beyond any legitimate catalogue question and well under the point # where the token budget would notice. max_tool_calls: int = 20 # --- transport ---------------------------------------------------------- # The lab uses a private CA (sttlab-local-ca). Point this at the CA bundle # rather than disabling verification. ca_bundle: str | None = None # Generous on purpose, and it is not paranoia. macOS resolves any `.local` # name through mDNS before falling back to /etc/hosts, and that costs a flat # ~5s per hostname — right on httpx's 5s default connect timeout. The result # is an intermittent ConnectTimeout that fastmcp reports only as # "Failed to initialize server session", with the real cause swallowed. In # the cluster the names resolve instantly and this timeout never binds. http_timeout: float = 60.0 host: str = "0.0.0.0" port: int = 8080 # Advertised in the Agent Card, so it must be the URL other agents reach, # not the bind address. public_url: str = "http://localhost:8080/" @property def token_endpoint(self) -> str: return f"{self.keycloak_realm_url}/protocol/openid-connect/token" @property def verify(self) -> str | bool: return self.ca_bundle if self.ca_bundle else True