fischer-agentkit/src/agentkit/llm/config.py

258 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 编码的 SecretEntrybase64 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,
)