"""LLM Config - 配置加载""" import json import logging from dataclasses import dataclass, field from typing import Any, TYPE_CHECKING from agentkit.llm.retry import CircuitBreakerConfig, RetryConfig if TYPE_CHECKING: from agentkit.channels.secrets import SecretsStore logger = logging.getLogger(__name__) @dataclass class CacheConfig: """LLM Cache 配置""" enabled: bool = False backend: str = "auto" # "auto" | "redis" | "memory" redis_url: str = "redis://localhost:6379" exact_ttl: int = 3600 semantic_ttl: int = 86400 similarity_threshold: float = 0.92 max_entries: int = 10000 # Embedding config for semantic cache (Chinese-first: bge-m3 via Xinference) embedding_provider: str = "openai" # "openai" | "xinference" | "local" embedding_model: str = "bge-m3" embedding_base_url: str | None = None embedding_api_key: str | None = None @classmethod def from_dict(cls, data: dict) -> "CacheConfig": if not data: return cls() emb = data.get("embedding", {}) return cls( enabled=data.get("enabled", False), backend=data.get("backend", "auto"), redis_url=data.get("redis_url", "redis://localhost:6379"), exact_ttl=data.get("exact_ttl", 3600), semantic_ttl=data.get("semantic_ttl", 86400), similarity_threshold=data.get("similarity_threshold", 0.92), max_entries=data.get("max_entries", 10000), embedding_provider=emb.get("provider", "openai"), embedding_model=emb.get("model", "bge-m3"), embedding_base_url=emb.get("base_url"), embedding_api_key=emb.get("api_key"), ) @dataclass class ProviderConfig: """Provider 配置""" api_key: str base_url: str models: dict[str, dict[str, Any]] = field(default_factory=dict) type: str = "openai" # "openai" | "anthropic" | "gemini" max_tokens: int = 4096 # Anthropic: default max_tokens timeout: float = 120.0 # Anthropic: request timeout max_connections: int = 100 # httpx 连接池最大连接数 max_keepalive_connections: int = 20 # httpx 连接池最大保活连接数 keepalive_expiry: float = 30.0 # httpx 保活连接过期时间(秒) retry: RetryConfig | None = None circuit_breaker: CircuitBreakerConfig | None = None # U15 — API Key 加密迁移(plaintext → SecretsStore)。 # api_key_encrypted: JSON 编码的 SecretEntry(base64 nonce+salt+ciphertext)。 # 为 None 表示未迁移,仍走 plaintext api_key。 # api_key_source: 当前 key 来源标记,用于双写/双读迁移窗口。 # "plaintext" — 仅 plaintext 列;"secrets_store" — 仅加密列; # "dual" — 双写窗口中(两列都有值,读时优先加密)。 api_key_encrypted: str | None = None api_key_source: str = "plaintext" def get_api_key(self) -> str: """同步读取 API Key — 返回 plaintext。 双读窗口的同步入口:无法 await ``SecretsStore.get_secret``, 因此加密列需通过异步方法 :meth:`aget_api_key` 读取。 本方法始终返回 plaintext ``api_key``(迁移期保证可用性)。 """ if self.api_key_encrypted: logger.debug("get_api_key: encrypted key set — use aget_api_key for decryption") return self.api_key async def aget_api_key(self, secrets_store: "SecretsStore | None" = None) -> str: """异步读取 API Key — 双读窗口优先加密列,失败回退 plaintext。 Args: secrets_store: 可选的加密存储。为 None 时直接返回 plaintext。 Returns: 解密后的 API Key;加密列解密失败时回退到 plaintext ``api_key``。 """ if self.api_key_encrypted and secrets_store is not None: try: entry = self._decode_secret_entry(self.api_key_encrypted) decrypted = await secrets_store.get_secret(entry.key) if decrypted is not None: return decrypted # store 里没有这个 key(可能已被删除)— 回退 plaintext logger.warning( f"aget_api_key: encrypted key for provider type={self.type} " f"not found in secrets_store — fallback to plaintext" ) except Exception as e: # 解密失败(master key 不匹配 / 密文损坏)— 回退 plaintext logger.warning( f"aget_api_key: decrypt failed for type={self.type}: {e} — fallback to plaintext" ) return self.api_key async def migrate_to_secrets(self, secrets_store: "SecretsStore") -> None: """把 plaintext api_key 迁移到 SecretsStore(幂等)。 迁移步骤(双写窗口): 1. 若 ``api_key_source == "secrets_store"`` 且 plaintext 已清空 → 已迁移,no-op。 2. 否则:调用 ``secrets_store.set_secret`` 加密存储 key。 3. 把返回的 SecretEntry JSON 编码写入 ``api_key_encrypted``。 4. 标记 ``api_key_source = "secrets_store"``。 5. 验证:调用 ``get_secret`` 读回对比,成功后清空 plaintext ``api_key=""``。 幂等性:重复调用不会重复加密(已迁移时直接返回)。 部分失败恢复:若 set 成功但验证失败,保留 plaintext 不清空, ``api_key_encrypted`` 已写入 — 下次重试时由幂等性保证最终一致。 Args: secrets_store: 用于加密存储的 SecretsStore 实例。 """ # 幂等:已迁移完成(source 标记 + plaintext 已清空) if self.api_key_source == "secrets_store" and not self.api_key and self.api_key_encrypted: return # 没有 plaintext 可迁移(空 key)— 跳过 if not self.api_key: return secret_key = self._secret_key_for_type() # 双写:先写加密列 entry = await secrets_store.set_secret(secret_key, self.api_key) self.api_key_encrypted = self._encode_secret_entry(entry, secret_key) # 验证:读回对比 try: decrypted = await secrets_store.get_secret(secret_key) except Exception as e: logger.warning(f"migrate_to_secrets: verify read failed for type={self.type}: {e}") # 加密列已写但验证失败 — 保留 plaintext,标记 dual 待重试 self.api_key_source = "dual" return if decrypted != self.api_key: logger.error( f"migrate_to_secrets: verify mismatch for type={self.type} " f"— plaintext retained, source=dual" ) self.api_key_source = "dual" return # 验证通过:清空 plaintext,标记完成 self.api_key_source = "secrets_store" self.api_key = "" def _secret_key_for_type(self) -> str: """生成 SecretsStore 中的 key(按 provider type 命名空间隔离)。""" return f"llm:provider:{self.type}:api_key" @staticmethod def _encode_secret_entry(entry: Any, key: str) -> str: """把 SecretEntry 编码为 JSON 字符串(含 key 字段)。""" # entry 是 SecretEntry pydantic 模型,有 model_dump() if hasattr(entry, "model_dump"): data = entry.model_dump() else: data = dict(entry) data["key"] = key return json.dumps(data) @staticmethod def _decode_secret_entry(encoded: str) -> Any: """从 JSON 字符串解码 SecretEntry。返回带 .key 属性的对象。""" from agentkit.channels.secrets import SecretEntry data = json.loads(encoded) return SecretEntry( key=data.get("key", ""), value=data["value"], nonce=data["nonce"], salt=data["salt"], key_id=data.get("key_id", "default"), created_at=data.get("created_at", ""), updated_at=data.get("updated_at", ""), ) @dataclass class LLMConfig: """LLM 配置""" providers: dict[str, ProviderConfig] = field(default_factory=dict) model_aliases: dict[str, str] = field(default_factory=dict) fallbacks: dict[str, list[str]] = field(default_factory=dict) cache: CacheConfig | None = None @classmethod def from_dict(cls, data: dict) -> "LLMConfig": """从字典加载配置""" providers = {} for name, pconf in data.get("providers", {}).items(): retry = None retry_data = pconf.get("retry") if retry_data: retry = RetryConfig( max_retries=retry_data.get("max_retries", 3), base_delay=retry_data.get("base_delay", 1.0), max_delay=retry_data.get("max_delay", 30.0), exponential_base=retry_data.get("exponential_base", 2.0), ) circuit_breaker = None cb_data = pconf.get("circuit_breaker") if cb_data: circuit_breaker = CircuitBreakerConfig( failure_threshold=cb_data.get("failure_threshold", 5), recovery_timeout=cb_data.get("recovery_timeout", 60.0), half_open_max=cb_data.get("half_open_max", 1), ) providers[name] = ProviderConfig( api_key=pconf.get("api_key", ""), base_url=pconf.get("base_url", ""), models=pconf.get("models", {}), type=pconf.get("type", "openai"), max_tokens=pconf.get("max_tokens", 4096), timeout=pconf.get("timeout", 120.0), max_connections=pconf.get("max_connections", 100), max_keepalive_connections=pconf.get("max_keepalive_connections", 20), keepalive_expiry=pconf.get("keepalive_expiry", 30.0), retry=retry, circuit_breaker=circuit_breaker, # U15 — 新增加密迁移字段,缺省时保持 plaintext 行为 api_key_encrypted=pconf.get("api_key_encrypted"), api_key_source=pconf.get("api_key_source", "plaintext"), ) cache = None cache_data = data.get("cache") if cache_data: cache = CacheConfig.from_dict(cache_data) return cls( providers=providers, model_aliases=data.get("model_aliases", {}), fallbacks=data.get("fallbacks", {}), cache=cache, )