- core/config.py: fix ANTHROPIC_MODEL default (claude-sonnet-4-6-20260217 → claude-sonnet-4-6) - ci.yml unit-tests job: inject ANTHROPIC_AUTH_TOKEN/DATABASE_URL/REDIS_URL as dummy values so pydantic Settings() validates at collection time without a .env file Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
200 lines
7.2 KiB
Python
200 lines
7.2 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import functools
|
|
import json
|
|
from decimal import Decimal
|
|
from typing import Annotated, Self
|
|
|
|
from pydantic import Field, field_validator, model_validator
|
|
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
"""Application settings loaded from environment variables."""
|
|
|
|
# LLM
|
|
ANTHROPIC_AUTH_TOKEN: str = Field(..., description="Anthropic auth token")
|
|
ANTHROPIC_BASE_URL: str = Field(
|
|
default="https://api.anthropic.com",
|
|
description="Anthropic API base URL",
|
|
)
|
|
ANTHROPIC_MODEL: str = Field(
|
|
default="claude-sonnet-4-6",
|
|
description="Anthropic model identifier",
|
|
)
|
|
ANTHROPIC_TIMEOUT_CONNECT: float = Field(default=10.0, description="Anthropic connect timeout")
|
|
ANTHROPIC_TIMEOUT_READ: float = Field(default=60.0, description="Anthropic read timeout")
|
|
LOCAL_MODEL_ID: str = Field(
|
|
default="local-model-v1",
|
|
description="Local model identifier",
|
|
)
|
|
LOCAL_MODEL_ENDPOINT: str = Field(
|
|
default="http://localhost:11434/v1",
|
|
description="Local model API endpoint",
|
|
)
|
|
|
|
# Database
|
|
DATABASE_URL: str = Field(..., description="Async PostgreSQL connection URL")
|
|
DB_POOL_SIZE: int = Field(default=10, description="Database pool size")
|
|
DB_MAX_OVERFLOW: int = Field(default=20, description="Database max overflow")
|
|
DB_POOL_TIMEOUT: float = Field(default=30.0, description="Database pool timeout seconds")
|
|
DB_POOL_RECYCLE: int = Field(default=3600, description="Database pool recycle seconds")
|
|
|
|
# Redis
|
|
REDIS_URL: str = Field(default="redis://localhost:6379/0", description="Redis URL")
|
|
REDIS_RATE_LIMIT_PER_MINUTE: int = Field(default=60, description="Rate limit per minute")
|
|
|
|
# Milvus
|
|
MILVUS_HOST: str = Field(default="localhost", description="Milvus host")
|
|
MILVUS_PORT: int = Field(default=19530, description="Milvus port")
|
|
MILVUS_COLLECTION_PREFIX: str = Field(
|
|
default="agent_platform",
|
|
description="Milvus collection prefix",
|
|
)
|
|
|
|
# Auth
|
|
JWT_SECRET_KEY: str = Field(..., min_length=32, description="JWT secret key")
|
|
JWT_ALGORITHM: str = Field(default="HS256", description="JWT algorithm")
|
|
JWT_EXPIRE_MINUTES: int = Field(default=480, description="JWT expiry in minutes")
|
|
|
|
# Observability
|
|
OTEL_SERVICE_NAME: str = Field(default="agent-platform", description="OTel service name")
|
|
OTEL_EXPORTER_OTLP_ENDPOINT: str = Field(
|
|
default="http://localhost:4317",
|
|
description="OTLP endpoint",
|
|
)
|
|
LOG_LEVEL: str = Field(default="INFO", description="Application log level")
|
|
|
|
# Audit
|
|
AUDIT_KEYRING_JSON: str = Field(
|
|
default='{"key1": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}',
|
|
description="JSON keyring for audit encryption (base64 keys)",
|
|
)
|
|
AUDIT_ACTIVE_KEY_ID: str = Field(
|
|
default="key1",
|
|
description="Active key ID for audit encryption",
|
|
)
|
|
|
|
# App
|
|
ENVIRONMENT: str = Field(default="development", description="Runtime environment")
|
|
ALLOWED_ORIGINS: Annotated[list[str], NoDecode] = Field(
|
|
default_factory=list,
|
|
description="Allowed CORS origins",
|
|
)
|
|
|
|
# Cost Budgets (Decimal)
|
|
MAX_DAILY_COST_CNY: Decimal = Field(
|
|
default=Decimal("500.00"),
|
|
description="Daily budget in CNY",
|
|
)
|
|
MAX_MONTHLY_COST_CNY: Decimal = Field(
|
|
default=Decimal("2000.00"),
|
|
description="Monthly budget in CNY",
|
|
)
|
|
COST_ALERT_THRESHOLD_PCT: Decimal = Field(
|
|
default=Decimal("0.80"),
|
|
description="Cost alert threshold ratio",
|
|
)
|
|
|
|
model_config = SettingsConfigDict(
|
|
env_file=".env",
|
|
env_file_encoding="utf-8",
|
|
case_sensitive=False,
|
|
extra="ignore",
|
|
)
|
|
|
|
@field_validator("DATABASE_URL")
|
|
@classmethod
|
|
def validate_database_url(cls, value: str) -> str:
|
|
if not value.startswith("postgresql+asyncpg://"):
|
|
raise ValueError("DATABASE_URL must start with postgresql+asyncpg://")
|
|
return value
|
|
|
|
@field_validator("ENVIRONMENT")
|
|
@classmethod
|
|
def validate_environment(cls, value: str) -> str:
|
|
allowed = {"development", "production", "test"}
|
|
if value not in allowed:
|
|
raise ValueError("ENVIRONMENT must be one of development/production/test")
|
|
return value
|
|
|
|
@field_validator("ALLOWED_ORIGINS", mode="before")
|
|
@classmethod
|
|
def parse_allowed_origins(cls, value: object) -> list[str]:
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, list):
|
|
if not all(isinstance(item, str) for item in value):
|
|
raise ValueError("ALLOWED_ORIGINS list items must be strings")
|
|
return value
|
|
if isinstance(value, str):
|
|
stripped = value.strip()
|
|
if not stripped:
|
|
return []
|
|
if stripped.startswith("["):
|
|
try:
|
|
parsed = json.loads(stripped)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(
|
|
"ALLOWED_ORIGINS JSON must be a valid list of strings"
|
|
) from exc
|
|
if not isinstance(parsed, list) or not all(
|
|
isinstance(item, str) for item in parsed
|
|
):
|
|
raise ValueError("ALLOWED_ORIGINS JSON must be a list of strings")
|
|
return parsed
|
|
return [item.strip() for item in stripped.split(",") if item.strip()]
|
|
raise ValueError("ALLOWED_ORIGINS must be a list or string")
|
|
|
|
@model_validator(mode="after")
|
|
def validate_audit_keyring(self) -> Self:
|
|
parsed: object
|
|
try:
|
|
parsed = json.loads(self.AUDIT_KEYRING_JSON)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError("AUDIT_KEYRING_JSON must be valid JSON") from exc
|
|
|
|
if not isinstance(parsed, dict):
|
|
raise ValueError("AUDIT_KEYRING_JSON must be a JSON object")
|
|
|
|
keyring: dict[str, str] = {}
|
|
for key_id_obj, key_b64_obj in parsed.items():
|
|
if not isinstance(key_id_obj, str) or not isinstance(key_b64_obj, str):
|
|
raise ValueError(
|
|
"AUDIT_KEYRING_JSON must be a map of string key_id to string base64"
|
|
)
|
|
keyring[key_id_obj] = key_b64_obj
|
|
|
|
if self.AUDIT_ACTIVE_KEY_ID not in keyring:
|
|
raise ValueError("AUDIT_ACTIVE_KEY_ID must exist in AUDIT_KEYRING_JSON")
|
|
|
|
for key_id, key_b64 in keyring.items():
|
|
try:
|
|
key_bytes = base64.b64decode(key_b64, validate=True)
|
|
except Exception as exc: # noqa: BLE001
|
|
raise ValueError(f"Audit key '{key_id}' is not valid base64") from exc
|
|
if len(key_bytes) != 32:
|
|
raise ValueError(f"Audit key '{key_id}' must decode to exactly 32 bytes")
|
|
|
|
return self
|
|
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def get_settings() -> Settings:
|
|
"""Lazily load and cache application settings."""
|
|
|
|
return Settings() # type: ignore[call-arg]
|
|
|
|
|
|
def is_production() -> bool:
|
|
return get_settings().ENVIRONMENT == "production"
|
|
|
|
|
|
def is_development() -> bool:
|
|
return get_settings().ENVIRONMENT == "development"
|
|
|
|
|
|
def is_test() -> bool:
|
|
return get_settings().ENVIRONMENT == "test"
|