"""U15 — API Key 加密迁移辅助函数。 把 ``agentkit.yaml`` 中 LLM provider 的 plaintext ``api_key`` 迁移到 :class:`SecretsStore` 加密存储,并更新配置文件中的 ``api_key_encrypted`` / ``api_key_source`` 字段。 迁移模型(双写/双读窗口): 1. 读 ``agentkit.yaml`` → ``LLMConfig.providers``。 2. 对每个 provider 调用 ``ProviderConfig.migrate_to_secrets(store)``: - 加密 plaintext 写入 SecretsStore; - 验证读回一致后清空 plaintext,标记 ``api_key_source="secrets_store"``; - 部分失败时保留 plaintext,标记 ``api_key_source="dual"`` 待重试。 3. 把更新后的 ``ProviderConfig`` 写回 YAML(plaintext 清空 + encrypted 列写入)。 4. 返回每个 provider 的迁移状态。 回滚步骤( documented ): - 把 YAML 中 ``api_key_source`` 改回 ``"plaintext"``; - 把 plaintext ``api_key`` 重新填回(从备份或 KMS 重新注入); - ``api_key_encrypted`` 列可保留(不影响 plaintext 读取路径)。 - 重启服务即可。 ponytail: CLI 命令接线(``agentkit llm migrate-keys``)延迟实现 — 本模块的 ``migrate_api_keys_to_secrets`` 函数已可被 CLI / 运维脚本直接调用。 """ from __future__ import annotations import logging from pathlib import Path logger = logging.getLogger(__name__) def migrate_api_keys_to_secrets(config_path: Path | str) -> dict[str, dict[str, object]]: """把 agentkit.yaml 中的 plaintext API Key 迁移到 SecretsStore。 流程: 1. 加载 YAML 配置(不依赖 ServerConfig,直接用 LLMConfig.from_dict)。 2. 初始化 SecretsStore(master key 从 env ``AGENTKIT_MASTER_KEY`` 读取)。 3. 对每个 provider 异步执行 ``migrate_to_secrets``。 4. 把更新后的 providers 段写回 YAML(保留其它段不变)。 5. 返回 ``{provider_name: {"status": ..., "source": ...}}`` 状态报告。 Args: config_path: ``agentkit.yaml`` 路径。 Returns: 每个 provider 的迁移状态字典: ``{"status": "migrated"|"skipped"|"failed", "source": str, "error"?: str}``。 """ import asyncio import yaml from agentkit.channels.secrets import SecretsStore from agentkit.llm.config import LLMConfig config_path = Path(config_path) raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} llm_section = raw.get("llm", {}) llm_config = LLMConfig.from_dict(llm_section) store = SecretsStore() # master key 从 env 加载 async def _run() -> dict[str, dict[str, object]]: report: dict[str, dict[str, object]] = {} for name, pconf in llm_config.providers.items(): if pconf.api_key_source == "secrets_store" and not pconf.api_key: report[name] = {"status": "skipped", "source": pconf.api_key_source} continue if not pconf.api_key: report[name] = { "status": "skipped", "source": pconf.api_key_source, "error": "no plaintext api_key to migrate", } continue try: await pconf.migrate_to_secrets(store) report[name] = { "status": "migrated" if pconf.api_key_source == "secrets_store" else "partial", "source": pconf.api_key_source, } except Exception as e: report[name] = { "status": "failed", "source": pconf.api_key_source, "error": str(e), } return report report = asyncio.run(_run()) # 写回 YAML:更新 llm.providers 段,保留其它段 providers_out: dict[str, dict[str, object]] = {} for name, pconf in llm_config.providers.items(): entry: dict[str, object] = { "type": pconf.type, "base_url": pconf.base_url, "models": pconf.models, "max_tokens": pconf.max_tokens, "timeout": pconf.timeout, "max_connections": pconf.max_connections, "max_keepalive_connections": pconf.max_keepalive_connections, "keepalive_expiry": pconf.keepalive_expiry, # plaintext 已清空(迁移成功)或保留(迁移失败 / dual) "api_key": pconf.api_key, "api_key_encrypted": pconf.api_key_encrypted, "api_key_source": pconf.api_key_source, } if pconf.retry is not None: entry["retry"] = { "max_retries": pconf.retry.max_retries, "base_delay": pconf.retry.base_delay, "max_delay": pconf.retry.max_delay, "exponential_base": pconf.retry.exponential_base, } if pconf.circuit_breaker is not None: entry["circuit_breaker"] = { "failure_threshold": pconf.circuit_breaker.failure_threshold, "recovery_timeout": pconf.circuit_breaker.recovery_timeout, "half_open_max": pconf.circuit_breaker.half_open_max, } providers_out[name] = entry raw.setdefault("llm", {})["providers"] = providers_out config_path.write_text( yaml.safe_dump(raw, allow_unicode=True, sort_keys=False), encoding="utf-8", ) return report # ponytail: CLI wiring deferred — 把本函数接到 ``agentkit llm migrate-keys`` # Typer 命令时,只需在 cli/ 下新增 thin wrapper 调用本函数并打印 report。