1 line
202 KiB
JSON
1 line
202 KiB
JSON
{"repo": "blake-john/tianji", "files": {"crates/config/src/lib.rs": {"fe1899ba91fe225f28dac66def6a6131258d038e": {"t": 1785745838.9833095, "n": 3811, "h": "9ff163d3bac56213", "c": "//! Tianji 框架的 YAML 配置系统。\n//!\n//! ⚠️ API 密钥不再在 `config.yml` 中配置,防止明文泄露。\n//! 使用环境变量或 [`KeyStore`](`~/.local/share/tianji/keys.json`)。\n//!\n//! 按层组织,每层可独立配置:\n//!\n//! - [`TianjiConfig`] — 顶级配置,汇总所有子配置(含 validate 校验)\n//! - [`ProviderConfig`] — LLM 提供商(Base URL、超时、重试、代理、速率限制)\n//! - [`AgentConfig`] — Agent 循环控制(迭代次数、Token 预算、并行工具调用)\n//! - [`SecurityConfig`] — 权限和沙箱(Shell 策略、沙箱模式、域名白名单、文件大小限制)\n//! - [`ModelConfig`] + [`ModelOverride`] — 模型选择和参数覆盖(temperature、top_p、reasoning_effort)\n//! - [`ToolConfig`] — 工具执行(超时、并发、禁用列表、输出截断)\n//! - [`StreamConfig`] — 流式行为(缓冲、超时、断线重传)\n//! - [`LoggingConfig`] + [`LogLevel`] — 日志级别(枚举)、输出和初始化\n//! - [`McpConfig`] + [`McpServerConfig`] — MCP 服务器连接\n//! - [`InstructionsConfig`] + [`SkillDef`] — 系统提示词和技能定义\n//!\n//! # API 密钥配置\n//!\n//! API 密钥通过以下两种方式配置(按优先级排序):\n//!\n//! ### 1. 环境变量(推荐 CI/生产)\n//!\n//! ```bash\n//! export DEEPSEEK_API_KEY=\"sk-xxx\"\n//! export AGNES_API_KEY=\"sk-yyy\"\n//! ```\n//!\n//! 环境变量命名规则:`{提供商名大写}_API_KEY`。\n//! `openrouter` 和 `openai` 额外支持 `OPENAI_API_KEY` 回退。\n//!\n//! ### 2. KeyStore(推荐桌面/开发)\n//!\n//! 密钥保存在 `~/.local/share/tianji/keys.json`:\n//!\n//! ```json\n//! {\"deepseek\": \"sk-xxx\", \"agnes\": \"sk-yyy\"}\n//! ```\n//!\n//! 可通过 TUI 的 `/provider` 命令交互式管理:\n//!\n//! ```text\n//! /provider # 打开提供商对话框\n//! → 选中提供商 # ↑↓ 选择\n//! → k # 设置 API 密钥\n//! → 输入密钥 → Enter # 自动保存并切换到该提供商\n//! ```\n//!\n//! ### 3. config.yml(不再支持)\n//!\n//! ```yaml\n//! # ❌ 以下 `api_key` 字段已弃用,将被忽略\n//! providers:\n//! deepseek:\n//! api_key: \"sk-xxx\" # 永不读取\n//! ```\n//!\n//! # 日志初始化\n//!\n//! 应用启动时调用 [`init_logging`] 初始化 tracing 日志系统:\n//!\n//! ```rust,no_run\n//! use tianji_config::logging::{LoggingConfig, init_logging};\n//! use tianji_config::LogLevel;\n//!\n//! let config = LoggingConfig::default().with_level(LogLevel::Info);\n//! let _guard = init_logging(&config).expect(\"failed to init logging\");\n//! ```\n//!\n//! 所有配置结构体使用构建者模式(`::new()` + `with_*` 方法),默认值集中于\n//! `defaults` 模块。启动时通过 `.validate()` 进行校验。\n//!\n//! # 从 YAML 加载\n//!\n//! ```no_run\n//! use tianji_config::TianjiConfig;\n//!\n//! let config = TianjiConfig::load(\"~/.config/tianji/config.yml\")\n//! .expect(\"failed to load config\");\n//! ```\n//!\n//! # 编程式构建\n//!\n//! ```no_run\n//! use tianji_config::{\n//! TianjiConfig, ProviderConfig, AgentConfig,\n//! McpConfig, McpServerConfig,\n//! };\n//!\n//! let config = TianjiConfig::builder()\n//! .provider(\n//! \"openrouter\",\n//! ProviderConfig::default()\n//! .with_base_url(\"https://openrouter.ai/api/v1\")\n//! .with_model(\"anthropic/claude-sonnet-4\"),\n//! )\n//! .default_provider(\"openrouter\")\n//! .agent(AgentConfig::default().with_max_iterations(20))\n//! .mcp(McpConfig {\n//! servers: vec![\n//! McpServerConfig::new(\"filesystem\")\n//! .with_command(\"npx\")\n//! .with_arg(\"-y\")\n//! .with_arg(\"@modelcontextprotocol/server-filesystem\"),\n//! ],\n//! })\n//! .build();\n//! ```\n//!\n//! 完整配置字段说明参见 [`ARCHITECTURE.md`](https://github.com/).\n\nmod defaults;\n\npub mod agent;\npub mod config;\npub mod instructions;\npub mod keystore;\npub mod logging;\npub mod mcp;\npub mod model;\npub mod provider;\npub mod security;\npub mod stream;\npub mod tool;\n\npub use agent::AgentConfig;\npub use config::TianjiConfig;\npub use config::TianjiConfigBuilder;\npub use instructions::InstructionsConfig;\npub use instructions::SkillDef;\npub use keystore::KeyStore;\npub use logging::LogLevel;\npub use logging::LoggingConfig;\npub use logging::init_logging;\npub use mcp::McpConfig;\npub use mcp::McpServerConfig;\npub use model::ModelConfig;\npub use model::ModelOverride;\npub use provider::ProviderConfig;\npub use security::SandboxMode;\npub use security::SecurityConfig;\npub use security::ShellPolicy;\npub use stream::StreamConfig;\npub use tool::ToolConfig;\n"}, "4d594e1115e4155b6199ea4f58fa3e5bad22a7a3": {"t": 1785757089.7978141, "n": 3811, "h": "9ff163d3bac56213", "c": "//! Tianji 框架的 YAML 配置系统。\n//!\n//! ⚠️ API 密钥不再在 `config.yml` 中配置,防止明文泄露。\n//! 使用环境变量或 [`KeyStore`](`~/.local/share/tianji/keys.json`)。\n//!\n//! 按层组织,每层可独立配置:\n//!\n//! - [`TianjiConfig`] — 顶级配置,汇总所有子配置(含 validate 校验)\n//! - [`ProviderConfig`] — LLM 提供商(Base URL、超时、重试、代理、速率限制)\n//! - [`AgentConfig`] — Agent 循环控制(迭代次数、Token 预算、并行工具调用)\n//! - [`SecurityConfig`] — 权限和沙箱(Shell 策略、沙箱模式、域名白名单、文件大小限制)\n//! - [`ModelConfig`] + [`ModelOverride`] — 模型选择和参数覆盖(temperature、top_p、reasoning_effort)\n//! - [`ToolConfig`] — 工具执行(超时、并发、禁用列表、输出截断)\n//! - [`StreamConfig`] — 流式行为(缓冲、超时、断线重传)\n//! - [`LoggingConfig`] + [`LogLevel`] — 日志级别(枚举)、输出和初始化\n//! - [`McpConfig`] + [`McpServerConfig`] — MCP 服务器连接\n//! - [`InstructionsConfig`] + [`SkillDef`] — 系统提示词和技能定义\n//!\n//! # API 密钥配置\n//!\n//! API 密钥通过以下两种方式配置(按优先级排序):\n//!\n//! ### 1. 环境变量(推荐 CI/生产)\n//!\n//! ```bash\n//! export DEEPSEEK_API_KEY=\"sk-xxx\"\n//! export AGNES_API_KEY=\"sk-yyy\"\n//! ```\n//!\n//! 环境变量命名规则:`{提供商名大写}_API_KEY`。\n//! `openrouter` 和 `openai` 额外支持 `OPENAI_API_KEY` 回退。\n//!\n//! ### 2. KeyStore(推荐桌面/开发)\n//!\n//! 密钥保存在 `~/.local/share/tianji/keys.json`:\n//!\n//! ```json\n//! {\"deepseek\": \"sk-xxx\", \"agnes\": \"sk-yyy\"}\n//! ```\n//!\n//! 可通过 TUI 的 `/provider` 命令交互式管理:\n//!\n//! ```text\n//! /provider # 打开提供商对话框\n//! → 选中提供商 # ↑↓ 选择\n//! → k # 设置 API 密钥\n//! → 输入密钥 → Enter # 自动保存并切换到该提供商\n//! ```\n//!\n//! ### 3. config.yml(不再支持)\n//!\n//! ```yaml\n//! # ❌ 以下 `api_key` 字段已弃用,将被忽略\n//! providers:\n//! deepseek:\n//! api_key: \"sk-xxx\" # 永不读取\n//! ```\n//!\n//! # 日志初始化\n//!\n//! 应用启动时调用 [`init_logging`] 初始化 tracing 日志系统:\n//!\n//! ```rust,no_run\n//! use tianji_config::logging::{LoggingConfig, init_logging};\n//! use tianji_config::LogLevel;\n//!\n//! let config = LoggingConfig::default().with_level(LogLevel::Info);\n//! let _guard = init_logging(&config).expect(\"failed to init logging\");\n//! ```\n//!\n//! 所有配置结构体使用构建者模式(`::new()` + `with_*` 方法),默认值集中于\n//! `defaults` 模块。启动时通过 `.validate()` 进行校验。\n//!\n//! # 从 YAML 加载\n//!\n//! ```no_run\n//! use tianji_config::TianjiConfig;\n//!\n//! let config = TianjiConfig::load(\"~/.config/tianji/config.yml\")\n//! .expect(\"failed to load config\");\n//! ```\n//!\n//! # 编程式构建\n//!\n//! ```no_run\n//! use tianji_config::{\n//! TianjiConfig, ProviderConfig, AgentConfig,\n//! McpConfig, McpServerConfig,\n//! };\n//!\n//! let config = TianjiConfig::builder()\n//! .provider(\n//! \"openrouter\",\n//! ProviderConfig::default()\n//! .with_base_url(\"https://openrouter.ai/api/v1\")\n//! .with_model(\"anthropic/claude-sonnet-4\"),\n//! )\n//! .default_provider(\"openrouter\")\n//! .agent(AgentConfig::default().with_max_iterations(20))\n//! .mcp(McpConfig {\n//! servers: vec![\n//! McpServerConfig::new(\"filesystem\")\n//! .with_command(\"npx\")\n//! .with_arg(\"-y\")\n//! .with_arg(\"@modelcontextprotocol/server-filesystem\"),\n//! ],\n//! })\n//! .build();\n//! ```\n//!\n//! 完整配置字段说明参见 [`ARCHITECTURE.md`](https://github.com/).\n\nmod defaults;\n\npub mod agent;\npub mod config;\npub mod instructions;\npub mod keystore;\npub mod logging;\npub mod mcp;\npub mod model;\npub mod provider;\npub mod security;\npub mod stream;\npub mod tool;\n\npub use agent::AgentConfig;\npub use config::TianjiConfig;\npub use config::TianjiConfigBuilder;\npub use instructions::InstructionsConfig;\npub use instructions::SkillDef;\npub use keystore::KeyStore;\npub use logging::LogLevel;\npub use logging::LoggingConfig;\npub use logging::init_logging;\npub use mcp::McpConfig;\npub use mcp::McpServerConfig;\npub use model::ModelConfig;\npub use model::ModelOverride;\npub use provider::ProviderConfig;\npub use security::SandboxMode;\npub use security::SecurityConfig;\npub use security::ShellPolicy;\npub use stream::StreamConfig;\npub use tool::ToolConfig;\n"}, "d5bf69d974b8b2ade78c0ddfb2a775b8b84901d5": {"t": 1785757091.106478, "n": 2943, "h": "1073d80372c2da10", "c": "//! Tianji 框架的 YAML 配置系统。\n//!\n//! 按层组织,每层可独立配置:\n//!\n//! - [`TianjiConfig`] — 顶级配置,汇总所有子配置(含 validate 校验)\n//! - [`ProviderConfig`] — LLM 提供商(API 密钥、Base URL、超时、重试、代理、速率限制)\n//! - [`AgentConfig`] — Agent 循环控制(迭代次数、Token 预算、并行工具调用)\n//! - [`SecurityConfig`] — 权限和沙箱(Shell 策略、沙箱模式、域名白名单、文件大小限制)\n//! - [`ModelConfig`] + [`ModelOverride`] — 模型选择和参数覆盖(temperature、top_p、reasoning_effort)\n//! - [`ToolConfig`] — 工具执行(超时、并发、禁用列表、输出截断)\n//! - [`StreamConfig`] — 流式行为(缓冲、超时、断线重传)\n//! - [`LoggingConfig`] + [`LogLevel`] — 日志级别(枚举)、输出和初始化\n//! - [`McpConfig`] + [`McpServerConfig`] — MCP 服务器连接\n//! - [`InstructionsConfig`] + [`SkillDef`] — 系统提示词和技能定义\n//!\n//! # 日志初始化\n//!\n//! 应用启动时调用 [`init_logging`] 初始化 tracing 日志系统:\n//!\n//! ```rust,no_run\n//! use tianji_config::logging::{LoggingConfig, init_logging};\n//! use tianji_config::LogLevel;\n//!\n//! let config = LoggingConfig::default().with_level(LogLevel::Info);\n//! let _guard = init_logging(&config).expect(\"failed to init logging\");\n//! ```\n//!\n//! 所有配置结构体使用构建者模式(`::new()` + `with_*` 方法),默认值集中于\n//! `defaults` 模块。启动时通过 `.validate()` 进行校验。\n//!\n//! # 从 YAML 加载\n//!\n//! ```no_run\n//! use tianji_config::TianjiConfig;\n//!\n//! let config = TianjiConfig::load(\"~/.config/tianji/config.yml\")\n//! .expect(\"failed to load config\");\n//! ```\n//!\n//! # 编程式构建\n//!\n//! ```no_run\n//! use tianji_config::{\n//! TianjiConfig, ProviderConfig, AgentConfig,\n//! McpConfig, McpServerConfig,\n//! };\n//!\n//! let config = TianjiConfig::builder()\n//! .provider(\n//! \"openrouter\",\n//! ProviderConfig::new(\"sk-or-xxx\")\n//! .with_base_url(\"https://openrouter.ai/api/v1\")\n//! .with_model(\"anthropic/claude-sonnet-4\"),\n//! )\n//! .default_provider(\"openrouter\")\n//! .agent(AgentConfig::default().with_max_iterations(20))\n//! .mcp(McpConfig {\n//! servers: vec![\n//! McpServerConfig::new(\"filesystem\")\n//! .with_command(\"npx\")\n//! .with_arg(\"-y\")\n//! .with_arg(\"@modelcontextprotocol/server-filesystem\"),\n//! ],\n//! })\n//! .build();\n//! ```\n//!\n//! 完整配置字段说明参见 [`ARCHITECTURE.md`](https://github.com/).\n\nmod defaults;\n\npub mod agent;\npub mod config;\npub mod instructions;\npub mod keystore;\npub mod logging;\npub mod mcp;\npub mod model;\npub mod provider;\npub mod security;\npub mod stream;\npub mod tool;\n\npub use agent::AgentConfig;\npub use config::TianjiConfig;\npub use config::TianjiConfigBuilder;\npub use instructions::InstructionsConfig;\npub use instructions::SkillDef;\npub use keystore::KeyStore;\npub use logging::LogLevel;\npub use logging::LoggingConfig;\npub use logging::init_logging;\npub use mcp::McpConfig;\npub use mcp::McpServerConfig;\npub use model::ModelConfig;\npub use model::ModelOverride;\npub use provider::ProviderConfig;\npub use security::SandboxMode;\npub use security::SecurityConfig;\npub use security::ShellPolicy;\npub use stream::StreamConfig;\npub use tool::ToolConfig;\n"}, "083345f887097e4dabc5433173180fcf8095d577": {"t": 1785757092.591478, "n": 2897, "h": "80bc70718f8efc4e", "c": "//! Tianji 框架的 YAML 配置系统。\n//!\n//! 按层组织,每层可独立配置:\n//!\n//! - [`TianjiConfig`] — 顶级配置,汇总所有子配置(含 validate 校验)\n//! - [`ProviderConfig`] — LLM 提供商(API 密钥、Base URL、超时、重试、代理、速率限制)\n//! - [`AgentConfig`] — Agent 循环控制(迭代次数、Token 预算、并行工具调用)\n//! - [`SecurityConfig`] — 权限和沙箱(Shell 策略、沙箱模式、域名白名单、文件大小限制)\n//! - [`ModelConfig`] + [`ModelOverride`] — 模型选择和参数覆盖(temperature、top_p、reasoning_effort)\n//! - [`ToolConfig`] — 工具执行(超时、并发、禁用列表、输出截断)\n//! - [`StreamConfig`] — 流式行为(缓冲、超时、断线重传)\n//! - [`LoggingConfig`] + [`LogLevel`] — 日志级别(枚举)、输出和初始化\n//! - [`McpConfig`] + [`McpServerConfig`] — MCP 服务器连接\n//! - [`InstructionsConfig`] + [`SkillDef`] — 系统提示词和技能定义\n//!\n//! # 日志初始化\n//!\n//! 应用启动时调用 [`init_logging`] 初始化 tracing 日志系统:\n//!\n//! ```rust,no_run\n//! use tianji_config::logging::{LoggingConfig, init_logging};\n//! use tianji_config::LogLevel;\n//!\n//! let config = LoggingConfig::default().with_level(LogLevel::Info);\n//! let _guard = init_logging(&config).expect(\"failed to init logging\");\n//! ```\n//!\n//! 所有配置结构体使用构建者模式(`::new()` + `with_*` 方法),默认值集中于\n//! `defaults` 模块。启动时通过 `.validate()` 进行校验。\n//!\n//! # 从 YAML 加载\n//!\n//! ```no_run\n//! use tianji_config::TianjiConfig;\n//!\n//! let config = TianjiConfig::load(\"~/.config/tianji/config.yml\")\n//! .expect(\"failed to load config\");\n//! ```\n//!\n//! # 编程式构建\n//!\n//! ```no_run\n//! use tianji_config::{\n//! TianjiConfig, ProviderConfig, AgentConfig,\n//! McpConfig, McpServerConfig,\n//! };\n//!\n//! let config = TianjiConfig::builder()\n//! .provider(\n//! \"openrouter\",\n//! ProviderConfig::new(\"sk-or-xxx\")\n//! .with_base_url(\"https://openrouter.ai/api/v1\")\n//! .with_model(\"anthropic/claude-sonnet-4\"),\n//! )\n//! .default_provider(\"openrouter\")\n//! .agent(AgentConfig::default().with_max_iterations(20))\n//! .mcp(McpConfig {\n//! servers: vec![\n//! McpServerConfig::new(\"filesystem\")\n//! .with_command(\"npx\")\n//! .with_arg(\"-y\")\n//! .with_arg(\"@modelcontextprotocol/server-filesystem\"),\n//! ],\n//! })\n//! .build();\n//! ```\n//!\n//! 完整配置字段说明参见 [`ARCHITECTURE.md`](https://github.com/).\n\nmod defaults;\n\npub mod agent;\npub mod config;\npub mod instructions;\npub mod logging;\npub mod mcp;\npub mod model;\npub mod provider;\npub mod security;\npub mod stream;\npub mod tool;\n\npub use agent::AgentConfig;\npub use config::TianjiConfig;\npub use config::TianjiConfigBuilder;\npub use instructions::InstructionsConfig;\npub use instructions::SkillDef;\npub use logging::LogLevel;\npub use logging::LoggingConfig;\npub use logging::init_logging;\npub use mcp::McpConfig;\npub use mcp::McpServerConfig;\npub use model::ModelConfig;\npub use model::ModelOverride;\npub use provider::ProviderConfig;\npub use security::SandboxMode;\npub use security::SecurityConfig;\npub use security::ShellPolicy;\npub use stream::StreamConfig;\npub use tool::ToolConfig;\n"}, "04f780f9b12f11c43936cc68f985ba552ec42fd2": {"t": 1785757094.0206218, "n": 2456, "h": "258a49da89aa82df", "c": "//! Tianji 框架的 YAML 配置系统。\n//!\n//! 按层组织,每层可独立配置:\n//!\n//! - [`TianjiConfig`] — 顶级配置,汇总所有子配置\n//! - [`ProviderConfig`] — 每个 LLM 提供商的设置(API 密钥、基础 URL、超时)\n//! - [`AgentConfig`] — Agent 循环控制(最大迭代次数、Token 预算)\n//! - [`SecurityConfig`] — 权限和沙箱策略\n//! - [`ModelConfig`] — 默认模型选择和按模型的参数覆盖\n//! - [`ToolConfig`] — 工具执行默认值(超时、并发数)\n//! - [`StreamConfig`] — 流式行为调优\n//! - [`LoggingConfig`] + [`init_logging`] — 日志级别、输出配置和初始化\n//! - [`McpConfig`] — MCP 服务器连接配置\n//!\n//! # 日志初始化\n//!\n//! 应用启动时调用 [`init_logging`] 初始化 tracing 日志系统:\n//!\n//! ```rust,no_run\n//! use tianji_config::logging::{LoggingConfig, init_logging};\n//!\n//! let config = LoggingConfig::default().with_level(\"info\");\n//! let _guard = init_logging(&config).expect(\"failed to init logging\");\n//! // 日志从此刻开始输出到 ~/.tianji/tianji.log\n//! ```\n//!\n//! 详情参见 [`logging` 模块文档](crate::logging) 和 [`docs/config-reference.md`](https://github.com/).\n//!\n//! 所有配置结构体使用构建者模式(`::new()` + `with_*` 方法),默认值集中于\n//! `defaults` 模块。启动时通过 `.validate()` 进行校验。\n//!\n//! # 示例\n//!\n//! ```no_run\n//! use tianji_config::{\n//! TianjiConfig, ProviderConfig, AgentConfig,\n//! McpConfig, McpServerConfig,\n//! };\n//!\n//! let config = TianjiConfig::builder()\n//! .provider(\n//! \"openrouter\",\n//! ProviderConfig::new(\"sk-or-xxx\")\n//! .with_base_url(\"https://openrouter.ai/api/v1\"),\n//! )\n//! .default_provider(\"openrouter\")\n//! .agent(AgentConfig::default().with_max_iterations(20))\n//! .mcp(McpConfig {\n//! servers: vec![\n//! McpServerConfig::new(\"filesystem\")\n//! .with_command(\"npx\")\n//! .with_arg(\"-y\")\n//! .with_arg(\"@modelcontextprotocol/server-filesystem\"),\n//! ],\n//! })\n//! .build();\n//! ```\n\nmod defaults;\n\npub mod agent;\npub mod config;\npub mod instructions;\npub mod logging;\npub mod mcp;\npub mod model;\npub mod provider;\npub mod security;\npub mod stream;\npub mod tool;\n\npub use agent::AgentConfig;\npub use config::TianjiConfig;\npub use config::TianjiConfigBuilder;\npub use instructions::InstructionsConfig;\npub use instructions::SkillDef;\npub use logging::LoggingConfig;\npub use logging::init_logging;\npub use mcp::McpConfig;\npub use mcp::McpServerConfig;\npub use model::ModelConfig;\npub use model::ModelOverride;\npub use provider::ProviderConfig;\npub use security::SandboxMode;\npub use security::SecurityConfig;\npub use security::ShellPolicy;\npub use stream::StreamConfig;\npub use tool::ToolConfig;\n"}, "52d8760fe4e04b3c01fcca9ed4c4450c3d99ebce": {"t": 1785757095.5142338, "n": 1995, "h": "795d70b0dcae61f7", "c": "//! Tianji 框架的 YAML 配置系统。\n//!\n//! 按层组织,每层可独立配置:\n//!\n//! - [`TianjiConfig`] — 顶级配置,汇总所有子配置\n//! - [`ProviderConfig`] — 每个 LLM 提供商的设置(API 密钥、基础 URL、超时)\n//! - [`AgentConfig`] — Agent 循环控制(最大迭代次数、Token 预算)\n//! - [`SecurityConfig`] — 权限和沙箱策略\n//! - [`ModelConfig`] — 默认模型选择和按模型的参数覆盖\n//! - [`ToolConfig`] — 工具执行默认值(超时、并发数)\n//! - [`StreamConfig`] — 流式行为调优\n//! - [`LoggingConfig`] — 日志级别和输出配置\n//! - [`McpConfig`] — MCP 服务器连接配置\n//!\n//! 所有配置结构体使用构建者模式(`::new()` + `with_*` 方法),默认值集中于\n//! `defaults` 模块。启动时通过 `.validate()` 进行校验。\n//!\n//! # 示例\n//!\n//! ```no_run\n//! use tianji_config::{\n//! TianjiConfig, ProviderConfig, AgentConfig,\n//! McpConfig, McpServerConfig,\n//! };\n//!\n//! let config = TianjiConfig::builder()\n//! .provider(\n//! \"openrouter\",\n//! ProviderConfig::new(\"sk-or-xxx\")\n//! .with_base_url(\"https://openrouter.ai/api/v1\"),\n//! )\n//! .default_provider(\"openrouter\")\n//! .agent(AgentConfig::default().with_max_iterations(20))\n//! .mcp(McpConfig {\n//! servers: vec![\n//! McpServerConfig::new(\"filesystem\")\n//! .with_command(\"npx\")\n//! .with_arg(\"-y\")\n//! .with_arg(\"@modelcontextprotocol/server-filesystem\"),\n//! ],\n//! })\n//! .build();\n//! ```\n\nmod defaults;\n\npub mod agent;\npub mod config;\npub mod instructions;\npub mod logging;\npub mod mcp;\npub mod model;\npub mod provider;\npub mod security;\npub mod stream;\npub mod tool;\n\npub use agent::AgentConfig;\npub use config::TianjiConfig;\npub use config::TianjiConfigBuilder;\npub use instructions::InstructionsConfig;\npub use instructions::SkillDef;\npub use logging::LoggingConfig;\npub use logging::init_logging;\npub use mcp::McpConfig;\npub use mcp::McpServerConfig;\npub use model::ModelConfig;\npub use model::ModelOverride;\npub use provider::ProviderConfig;\npub use security::SandboxMode;\npub use security::SecurityConfig;\npub use security::ShellPolicy;\npub use stream::StreamConfig;\npub use tool::ToolConfig;\n"}, "deb2a042a4fdd3b307256a4cd8e837b70f9d6005": {"t": 1785757097.1739962, "n": 1964, "h": "8b842557e90b33ca", "c": "//! Tianji 框架的 YAML 配置系统。\n//!\n//! 按层组织,每层可独立配置:\n//!\n//! - [`TianjiConfig`] — 顶级配置,汇总所有子配置\n//! - [`ProviderConfig`] — 每个 LLM 提供商的设置(API 密钥、基础 URL、超时)\n//! - [`AgentConfig`] — Agent 循环控制(最大迭代次数、Token 预算)\n//! - [`SecurityConfig`] — 权限和沙箱策略\n//! - [`ModelConfig`] — 默认模型选择和按模型的参数覆盖\n//! - [`ToolConfig`] — 工具执行默认值(超时、并发数)\n//! - [`StreamConfig`] — 流式行为调优\n//! - [`LoggingConfig`] — 日志级别和输出配置\n//! - [`McpConfig`] — MCP 服务器连接配置\n//!\n//! 所有配置结构体使用构建者模式(`::new()` + `with_*` 方法),默认值集中于\n//! `defaults` 模块。启动时通过 `.validate()` 进行校验。\n//!\n//! # 示例\n//!\n//! ```no_run\n//! use tianji_config::{\n//! TianjiConfig, ProviderConfig, AgentConfig,\n//! McpConfig, McpServerConfig,\n//! };\n//!\n//! let config = TianjiConfig::builder()\n//! .provider(\n//! \"openrouter\",\n//! ProviderConfig::new(\"sk-or-xxx\")\n//! .with_base_url(\"https://openrouter.ai/api/v1\"),\n//! )\n//! .default_provider(\"openrouter\")\n//! .agent(AgentConfig::default().with_max_iterations(20))\n//! .mcp(McpConfig {\n//! servers: vec![\n//! McpServerConfig::new(\"filesystem\")\n//! .with_command(\"npx\")\n//! .with_arg(\"-y\")\n//! .with_arg(\"@modelcontextprotocol/server-filesystem\"),\n//! ],\n//! })\n//! .build();\n//! ```\n\nmod defaults;\n\npub mod agent;\npub mod config;\npub mod instructions;\npub mod logging;\npub mod mcp;\npub mod model;\npub mod provider;\npub mod security;\npub mod stream;\npub mod tool;\n\npub use agent::AgentConfig;\npub use config::TianjiConfig;\npub use config::TianjiConfigBuilder;\npub use instructions::InstructionsConfig;\npub use instructions::SkillDef;\npub use logging::LoggingConfig;\npub use mcp::McpConfig;\npub use mcp::McpServerConfig;\npub use model::ModelConfig;\npub use model::ModelOverride;\npub use provider::ProviderConfig;\npub use security::SandboxMode;\npub use security::SecurityConfig;\npub use security::ShellPolicy;\npub use stream::StreamConfig;\npub use tool::ToolConfig;\n"}, "6c8355318d914145b507d0fab06d04937b4f14b6": {"t": 1785757098.7769794, "n": 1868, "h": "834b3513744e8976", "c": "//! Tianji 框架的 YAML 配置系统。\n//!\n//! 按层组织,每层可独立配置:\n//!\n//! - [`TianjiConfig`] — 顶级配置,汇总所有子配置\n//! - [`ProviderConfig`] — 每个 LLM 提供商的设置(API 密钥、基础 URL、超时)\n//! - [`AgentConfig`] — Agent 循环控制(最大迭代次数、Token 预算)\n//! - [`SecurityConfig`] — 权限和沙箱策略\n//! - [`ModelConfig`] — 默认模型选择和按模型的参数覆盖\n//! - [`ToolConfig`] — 工具执行默认值(超时、并发数)\n//! - [`StreamConfig`] — 流式行为调优\n//! - [`LoggingConfig`] — 日志级别和输出配置\n//! - [`McpConfig`] — MCP 服务器连接配置\n//!\n//! 所有配置结构体使用构建者模式(`::new()` + `with_*` 方法),默认值集中于\n//! `defaults` 模块。启动时通过 `.validate()` 进行校验。\n//!\n//! # 示例\n//!\n//! ```no_run\n//! use tianji_config::{\n//! TianjiConfig, ProviderConfig, AgentConfig,\n//! McpConfig, McpServerConfig,\n//! };\n//!\n//! let config = TianjiConfig::builder()\n//! .provider(\n//! \"openrouter\",\n//! ProviderConfig::new(\"sk-or-xxx\")\n//! .with_base_url(\"https://openrouter.ai/api/v1\"),\n//! )\n//! .default_provider(\"openrouter\")\n//! .agent(AgentConfig::default().with_max_iterations(20))\n//! .mcp(McpConfig {\n//! servers: vec![\n//! McpServerConfig::new(\"filesystem\")\n//! .with_command(\"npx\")\n//! .with_arg(\"-y\")\n//! .with_arg(\"@modelcontextprotocol/server-filesystem\"),\n//! ],\n//! })\n//! .build();\n//! ```\n\nmod defaults;\n\npub mod agent;\npub mod config;\npub mod logging;\npub mod mcp;\npub mod model;\npub mod provider;\npub mod security;\npub mod stream;\npub mod tool;\n\npub use agent::AgentConfig;\npub use config::TianjiConfig;\npub use config::TianjiConfigBuilder;\npub use logging::LoggingConfig;\npub use mcp::McpConfig;\npub use mcp::McpServerConfig;\npub use model::ModelConfig;\npub use model::ModelOverride;\npub use provider::ProviderConfig;\npub use security::SandboxMode;\npub use security::SecurityConfig;\npub use security::ShellPolicy;\npub use stream::StreamConfig;\npub use tool::ToolConfig;\n"}, "9622a8c6c64acbb34f1f700e1f27997746d3a5c8": {"t": 1785757100.3843606, "n": 1436, "h": "5a8a9d6d978c7ad9", "c": "//! Tianji 框架的 YAML 配置系统。\n//!\n//! 按层组织,每层可独立配置:\n//!\n//! - [`TianjiConfig`] — 顶级配置,汇总所有子配置\n//! - [`ProviderConfig`] — 每个 LLM 提供商的设置(API 密钥、基础 URL、超时)\n//! - [`AgentConfig`] — Agent 循环控制(最大迭代次数、Token 预算)\n//! - [`SecurityConfig`] — 权限和沙箱策略\n//! - [`ModelConfig`] — 默认模型选择和按模型的参数覆盖\n//! - [`ToolConfig`] — 工具执行默认值(超时、并发数)\n//! - [`StreamConfig`] — 流式行为调优\n//! - [`LoggingConfig`] — 日志级别和输出配置\n//!\n//! 所有配置结构体使用构建者模式(`::new()` + `with_*` 方法),默认值集中于\n//! `defaults` 模块。启动时通过 `.validate()` 进行校验。\n//!\n//! # 示例\n//!\n//! ```no_run\n//! use tianji_config::{TianjiConfig, ProviderConfig, AgentConfig};\n//!\n//! let config = TianjiConfig::builder()\n//! .provider(\n//! \"openrouter\",\n//! ProviderConfig::new(\"sk-or-xxx\")\n//! .with_base_url(\"https://openrouter.ai/api/v1\"),\n//! )\n//! .default_provider(\"openrouter\")\n//! .agent(AgentConfig::default().with_max_iterations(20))\n//! .build();\n//! ```\n\nmod defaults;\n\npub mod agent;\npub mod config;\npub mod logging;\npub mod model;\npub mod provider;\npub mod security;\npub mod stream;\npub mod tool;\n\npub use agent::AgentConfig;\npub use config::TianjiConfig;\npub use config::TianjiConfigBuilder;\npub use logging::LoggingConfig;\npub use model::ModelConfig;\npub use model::ModelOverride;\npub use provider::ProviderConfig;\npub use security::SandboxMode;\npub use security::SecurityConfig;\npub use security::ShellPolicy;\npub use stream::StreamConfig;\npub use tool::ToolConfig;\n"}, "5310083d8d4755ac99d6f100b117561ad8a38ddd": {"t": 1785757101.8920596, "n": 1537, "h": "088edeeb57256c54", "c": "//! Configuration system for the Tianji framework.\n//!\n//! Organized into layers:\n//!\n//! - [`TianjiConfig`] — top-level config, aggregated from all sub-configs\n//! - [`ProviderConfig`] — per-provider settings (API key, base URL, timeouts)\n//! - [`AgentConfig`] — agent loop control (max iterations, token budget, recording)\n//! - [`SecurityConfig`] — permission and sandbox policies\n//! - [`ModelConfig`] — default model selection and per-model overrides\n//! - [`ToolConfig`] — tool execution defaults (timeouts, concurrency)\n//! - [`StreamConfig`] — streaming behavior tuning\n//!\n//! # Example\n//!\n//! ```no_run\n//! use tianji_config::{TianjiConfig, ProviderConfig, AgentConfig};\n//!\n//! let config = TianjiConfig::builder()\n//! .provider(\n//! \"openrouter\",\n//! ProviderConfig::new(\"sk-or-xxx\")\n//! .with_base_url(\"https://openrouter.ai/api/v1\"),\n//! )\n//! .default_provider(\"openrouter\")\n//! .agent(AgentConfig::default().with_max_iterations(20))\n//! .build();\n//! ```\n\nmod defaults;\n\npub mod agent;\npub mod config;\npub mod logging;\npub mod model;\npub mod provider;\npub mod security;\npub mod stream;\npub mod tool;\n\npub use agent::AgentConfig;\npub use config::TianjiConfig;\npub use config::TianjiConfigBuilder;\npub use logging::LoggingConfig;\npub use model::ModelConfig;\npub use model::ModelOverride;\npub use provider::ProviderConfig;\npub use security::SandboxMode;\npub use security::SecurityConfig;\npub use security::ShellPolicy;\npub use stream::StreamConfig;\npub use tool::ToolConfig;\n"}, "b8041913d6cdac024ac2c89fe43bd9957027be4b": {"t": 1785757104.8422983, "n": 49186, "h": "623e0fbc1d6f3da3", "c": "//! Configuration system for the Tianji framework.\n//!\n//! Organized into layers:\n//!\n//! - [`TianjiConfig`] — top-level config, aggregated from all sub-configs\n//! - [`ProviderConfig`] — per-provider settings (API key, base URL, timeouts)\n//! - [`AgentConfig`] — agent loop control (max iterations, token budget, recording)\n//! - [`SecurityConfig`] — permission and sandbox policies\n//! - [`ModelConfig`] — default model selection and per-model overrides\n//! - [`ToolConfig`] — tool execution defaults (timeouts, concurrency)\n//! - [`StreamConfig`] — streaming behavior tuning\n//!\n//! # Example\n//!\n//! ```no_run\n//! use tianji_config::{TianjiConfig, ProviderConfig, AgentConfig};\n//!\n//! let config = TianjiConfig::builder()\n//! .provider(\n//! \"openrouter\",\n//! ProviderConfig::new(\"sk-or-xxx\")\n//! .with_base_url(\"https://openrouter.ai/api/v1\"),\n//! )\n//! .default_provider(\"openrouter\")\n//! .agent(AgentConfig::default().with_max_iterations(20))\n//! .build();\n//! ```\n\nuse std::collections::HashMap;\n\nuse serde::{Deserialize, Serialize};\n\nuse tianji_core::error::TianjiError;\n\n// ──────────────────────────────────────────────\n// ProviderConfig\n// ──────────────────────────────────────────────\n\n/// Configuration for a single LLM provider.\n///\n/// Each provider requires at minimum an API key. All other fields fall back\n/// to sensible defaults when not specified.\n///\n/// # Required\n///\n/// - `api_key` — Authentication credential for the provider API.\n///\n/// # Optional\n///\n/// - `base_url` — Override the default API endpoint (useful for proxies or\n/// compatible APIs like Ollama, vLLM, etc.)\n/// - `timeout` — Request timeout duration\n/// - `connect_timeout` — TCP connection establishment timeout\n/// - `max_retries` — Number of automatic retries on transient failures\n/// - `retry_backoff` — Base delay between retries (exponential backoff applied)\n/// - `extra_headers` — Additional HTTP headers sent with every request\n/// - `extra_body` — Additional JSON fields merged into every request body\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProviderConfig {\n /// API key or token for authentication.\n pub api_key: String,\n\n /// Base URL of the provider API. Defaults to the provider's official endpoint\n /// when set to the provider's canonical URL.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub base_url: Option<String>,\n\n /// Total request timeout in milliseconds.\n #[serde(default = \"default_timeout_ms\")]\n pub timeout_ms: u64,\n\n /// Connection establishment timeout in milliseconds.\n #[serde(default = \"default_connect_timeout_ms\")]\n pub connect_timeout_ms: u64,\n\n /// Maximum number of automatic retries on transient errors (5xx, connection reset).\n #[serde(default = \"default_max_retries\")]\n pub max_retries: u32,\n\n /// Base delay between retries in milliseconds. Actual delay is\n /// `retry_backoff_ms * 2^attempt` (exponential backoff with jitter).\n #[serde(default = \"default_retry_backoff_ms\")]\n pub retry_backoff_ms: u64,\n\n /// Extra HTTP headers appended to every request.\n #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n pub extra_headers: HashMap<String, String>,\n\n /// Extra JSON fields merged into every request body.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub extra_body: Option<serde_json::Value>,\n}\n\nimpl ProviderConfig {\n /// Creates a new [`ProviderConfig`] with the given API key.\n ///\n /// All optional fields are set to their defaults.\n pub fn new(api_key: impl Into<String>) -> Self {\n Self {\n api_key: api_key.into(),\n base_url: None,\n timeout_ms: default_timeout_ms(),\n connect_timeout_ms: default_connect_timeout_ms(),\n max_retries: default_max_retries(),\n retry_backoff_ms: default_retry_backoff_ms(),\n extra_headers: HashMap::new(),\n extra_body: None,\n }\n }\n\n /// Sets a custom base URL for the provider API.\n pub fn with_base_url(mut self, url: impl Into<String>) -> Self {\n self.base_url = Some(url.into());\n self\n }\n\n /// Sets the total request timeout in milliseconds.\n pub fn with_timeout_ms(mut self, ms: u64) -> Self {\n self.timeout_ms = ms;\n self\n }\n\n /// Sets the connection timeout in milliseconds.\n pub fn with_connect_timeout_ms(mut self, ms: u64) -> Self {\n self.connect_timeout_ms = ms;\n self\n }\n\n /// Sets the maximum number of automatic retries.\n pub fn with_max_retries(mut self, retries: u32) -> Self {\n self.max_retries = retries;\n self\n }\n\n /// Sets the base retry backoff delay in milliseconds.\n pub fn with_retry_backoff_ms(mut self, ms: u64) -> Self {\n self.retry_backoff_ms = ms;\n self\n }\n\n /// Adds a single extra HTTP header.\n pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {\n self.extra_headers.insert(key.into(), value.into());\n self\n }\n\n /// Sets extra JSON body fields merged into every request.\n pub fn with_extra_body(mut self, body: serde_json::Value) -> Self {\n self.extra_body = Some(body);\n self\n }\n\n /// Validates the provider configuration.\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.api_key.is_empty() {\n return Err(TianjiError::Config(\n \"provider API key must not be empty\".into(),\n ));\n }\n if self.timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider timeout_ms must be greater than 0\".into(),\n ));\n }\n if self.connect_timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider connect_timeout_ms must be greater than 0\".into(),\n ));\n }\n Ok(())\n }\n}\n\n// ──────────────────────────────────────────────\n// AgentConfig\n// ──────────────────────────────────────────────\n\n/// Controls the agent loop execution behavior.\n///\n/// The agent loop is the core reasoning cycle: send a request, receive a\n/// response, optionally execute tools, and repeat. These settings bound the\n/// loop to prevent runaway execution.\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AgentConfig {\n /// Maximum number of loop iterations before aborting.\n ///\n /// Each iteration consists of one provider call plus any tool executions.\n /// Prevents infinite loops when the model keeps requesting tool calls.\n #[serde(default = \"default_max_iterations\")]\n pub max_iterations: u32,\n\n /// Maximum number of tool calls allowed within a single iteration.\n ///\n /// When the model requests more tool calls than this limit in one turn,\n /// only the first N are executed.\n #[serde(default = \"default_max_tool_calls_per_turn\")]\n pub max_tool_calls_per_turn: u32,\n\n /// Token budget for the entire agent session.\n ///\n /// Tracks cumulative input + output tokens across all iterations.\n /// When exceeded, the loop aborts with [`crate::error::LoopError::TokenBudgetExceeded`].\n /// `0` means unlimited.\n #[serde(default)]\n pub token_budget: u64,\n\n /// Whether to record the full execution trace for replay / debugging.\n ///\n /// When enabled, every request, response, and tool execution is captured\n /// and can be serialized for later analysis.\n #[serde(default)]\n pub enable_recording: bool,\n\n /// Maximum nesting depth for subagent spawning.\n ///\n /// A subagent is a child agent spawned by the parent to handle a subtask.\n /// `0` disables subagent spawning entirely.\n #[serde(default = \"default_max_subagent_depth\")]\n pub max_subagent_depth: u32,\n\n /// Whether to suppress tool execution output in the final response.\n ///\n /// When `true`, intermediate tool results are not included in the final\n /// user-facing output, only the model's summary.\n #[serde(default)]\n pub suppress_tool_output: bool,\n}\n\nimpl AgentConfig {\n /// Sets the maximum loop iterations.\n pub fn with_max_iterations(mut self, max: u32) -> Self {\n self.max_iterations = max;\n self\n }\n\n /// Sets the maximum tool calls per turn.\n pub fn with_max_tool_calls_per_turn(mut self, max: u32) -> Self {\n self.max_tool_calls_per_turn = max;\n self\n }\n\n /// Sets the token budget (0 = unlimited).\n pub fn with_token_budget(mut self, budget: u64) -> Self {\n self.token_budget = budget;\n self\n }\n\n /// Enables execution recording.\n pub fn with_recording(mut self, enable: bool) -> Self {\n self.enable_recording = enable;\n self\n }\n\n /// Sets the maximum subagent nesting depth.\n pub fn with_max_subagent_depth(mut self, depth: u32) -> Self {\n self.max_subagent_depth = depth;\n self\n }\n\n /// Sets whether to suppress tool output.\n pub fn with_suppress_tool_output(mut self, suppress: bool) -> Self {\n self.suppress_tool_output = suppress;\n self\n }\n\n /// Validates the agent configuration.\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.max_iterations == 0 {\n return Err(TianjiError::Config(\n \"agent max_iterations must be greater than 0\".into(),\n ));\n }\n if self.max_tool_calls_per_turn == 0 {\n return Err(TianjiError::Config(\n \"agent max_tool_calls_per_turn must be greater than 0\".into(),\n ));\n }\n Ok(())\n }\n}\n\nimpl Default for AgentConfig {\n fn default() -> Self {\n Self {\n max_iterations: default_max_iterations(),\n max_tool_calls_per_turn: default_max_tool_calls_per_turn(),\n token_budget: 0,\n enable_recording: false,\n max_subagent_depth: default_max_subagent_depth(),\n suppress_tool_output: false,\n }\n }\n}\n\n// ──────────────────────────────────────────────\n// SecurityConfig\n// ──────────────────────────────────────────────\n\n/// Permission and sandbox policy configuration.\n///\n/// Governs what operations the agent is allowed to perform, including file\n/// system access, shell commands, and network access.\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SecurityConfig {\n /// Sandbox mode for file system operations.\n ///\n /// - `Off` — no restrictions (full filesystem access)\n /// - `ReadOnly` — only reads allowed, writes are rejected\n /// - `WorkspaceOnly` — only files within the workspace directory are accessible\n #[serde(default)]\n pub sandbox_mode: SandboxMode,\n\n /// Root directory for the workspace sandbox.\n ///\n /// Only used when `sandbox_mode` is `WorkspaceOnly`.\n /// Paths are canonicalized and validated on startup.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub workspace_root: Option<String>,\n\n /// Shell command execution policy.\n #[serde(default)]\n pub shell_policy: ShellPolicy,\n\n /// Explicit list of allowed shell commands.\n ///\n /// When `shell_policy` is `AllowList`, only commands whose binary name\n /// appears in this list are permitted.\n #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n pub allowed_commands: Vec<String>,\n\n /// Explicit list of denied shell commands.\n ///\n /// When `shell_policy` is `DenyList`, any command whose binary name\n /// appears in this list is rejected.\n #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n pub denied_commands: Vec<String>,\n\n /// Whether the agent is allowed to make outbound network requests\n /// (e.g., via a `fetch` or `http_request` tool).\n #[serde(default = \"default_true\")]\n pub allow_network: bool,\n\n /// Maximum rate of tool executions per minute.\n ///\n /// `0` means unlimited.\n #[serde(default)]\n pub tool_rate_limit_per_minute: u32,\n\n /// Whether to require user confirmation before executing any tool.\n ///\n /// When `true`, the agent pauses and waits for explicit approval\n /// before each tool invocation.\n #[serde(default)]\n pub require_tool_confirmation: bool,\n}\n\nimpl SecurityConfig {\n /// Sets the sandbox mode.\n pub fn with_sandbox_mode(mut self, mode: SandboxMode) -> Self {\n self.sandbox_mode = mode;\n self\n }\n\n /// Sets the workspace root directory.\n pub fn with_workspace_root(mut self, root: impl Into<String>) -> Self {\n self.workspace_root = Some(root.into());\n self\n }\n\n /// Sets the shell command policy.\n pub fn with_shell_policy(mut self, policy: ShellPolicy) -> Self {\n self.shell_policy = policy;\n self\n }\n\n /// Adds an allowed command to the list.\n pub fn with_allowed_command(mut self, cmd: impl Into<String>) -> Self {\n self.allowed_commands.push(cmd.into());\n self\n }\n\n /// Adds a denied command to the list.\n pub fn with_denied_command(mut self, cmd: impl Into<String>) -> Self {\n self.denied_commands.push(cmd.into());\n self\n }\n\n /// Sets whether outbound network access is allowed.\n pub fn with_allow_network(mut self, allow: bool) -> Self {\n self.allow_network = allow;\n self\n }\n\n /// Sets the tool execution rate limit per minute (0 = unlimited).\n pub fn with_tool_rate_limit(mut self, limit: u32) -> Self {\n self.tool_rate_limit_per_minute = limit;\n self\n }\n\n /// Sets whether user confirmation is required before tool execution.\n pub fn with_require_tool_confirmation(mut self, require: bool) -> Self {\n self.require_tool_confirmation = require;\n self\n }\n\n /// Validates the security configuration.\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.sandbox_mode == SandboxMode::WorkspaceOnly && self.workspace_root.is_none() {\n return Err(TianjiError::Config(\n \"workspace_root must be set when sandbox_mode is WorkspaceOnly\".into(),\n ));\n }\n if self.shell_policy == ShellPolicy::AllowList && self.allowed_commands.is_empty() {\n return Err(TianjiError::Config(\n \"allowed_commands must not be empty when shell_policy is AllowList\".into(),\n ));\n }\n Ok(())\n }\n}\n\nimpl Default for SecurityConfig {\n fn default() -> Self {\n Self {\n sandbox_mode: SandboxMode::Off,\n workspace_root: None,\n shell_policy: ShellPolicy::AllowList,\n allowed_commands: Vec::new(),\n denied_commands: Vec::new(),\n allow_network: true,\n tool_rate_limit_per_minute: 0,\n require_tool_confirmation: false,\n }\n }\n}\n\n/// Sandbox mode for file system access.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]\n#[serde(rename_all = \"snake_case\")]\npub enum SandboxMode {\n /// No sandboxing, full filesystem access.\n #[default]\n Off,\n /// Read-only access, all writes are denied.\n ReadOnly,\n /// Only files within the workspace directory are accessible.\n WorkspaceOnly,\n}\n\n/// Shell command execution policy.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]\n#[serde(rename_all = \"snake_case\")]\npub enum ShellPolicy {\n /// All commands are allowed (no restriction).\n AllowAll,\n /// Only commands in the allow list are permitted.\n AllowList,\n /// All commands are allowed except those in the deny list.\n #[default]\n DenyList,\n /// No shell commands are allowed.\n DenyAll,\n}\n\n// ──────────────────────────────────────────────\n// ModelConfig\n// ──────────────────────────────────────────────\n\n/// Default model selection and per-model parameter overrides.\n///\n/// The `model` field is the default model identifier used when no\n/// override is specified at the request level. The `overrides` map\n/// allows setting custom parameters (temperature, max_tokens, etc.)\n/// per model ID.\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ModelConfig {\n /// Default model identifier (e.g., `\"anthropic/claude-sonnet-4\"`).\n pub model: String,\n\n /// Default temperature for sampling. `0.0` means deterministic (greedy).\n /// `None` defers to the provider's default.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub temperature: Option<f32>,\n\n /// Maximum number of output tokens. `None` defers to the provider's limit.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub max_tokens: Option<u32>,\n\n /// Nucleus sampling parameter. `None` defers to the provider's default.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub top_p: Option<f32>,\n\n /// Presence penalty for repetition reduction. Range: [-2.0, 2.0].\n /// `None` defers to the provider's default.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub presence_penalty: Option<f32>,\n\n /// Frequency penalty for repetition reduction. Range: [-2.0, 2.0].\n /// `None` defers to the provider's default.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub frequency_penalty: Option<f32>,\n\n /// Sequences where the model will stop generating.\n #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n pub stop_sequences: Vec<String>,\n\n /// Per-model parameter overrides, keyed by model ID.\n ///\n /// When a model ID appears in this map, its override values take\n /// precedence over the top-level defaults.\n #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n pub overrides: HashMap<String, ModelOverride>,\n\n /// Whether to enable structured output (JSON schema mode) by default.\n #[serde(default)]\n pub structured_output: bool,\n}\n\nimpl ModelConfig {\n /// Creates a new [`ModelConfig`] with the given default model ID.\n pub fn new(model: impl Into<String>) -> Self {\n Self {\n model: model.into(),\n temperature: None,\n max_tokens: None,\n top_p: None,\n presence_penalty: None,\n frequency_penalty: None,\n stop_sequences: Vec::new(),\n overrides: HashMap::new(),\n structured_output: false,\n }\n }\n\n /// Sets the default temperature.\n pub fn with_temperature(mut self, temp: f32) -> Self {\n self.temperature = Some(temp);\n self\n }\n\n /// Sets the default maximum output tokens.\n pub fn with_max_tokens(mut self, max: u32) -> Self {\n self.max_tokens = Some(max);\n self\n }\n\n /// Sets the default top_p.\n pub fn with_top_p(mut self, top_p: f32) -> Self {\n self.top_p = Some(top_p);\n self\n }\n\n /// Sets the presence penalty.\n pub fn with_presence_penalty(mut self, penalty: f32) -> Self {\n self.presence_penalty = Some(penalty);\n self\n }\n\n /// Sets the frequency penalty.\n pub fn with_frequency_penalty(mut self, penalty: f32) -> Self {\n self.frequency_penalty = Some(penalty);\n self\n }\n\n /// Adds a stop sequence.\n pub fn with_stop_sequence(mut self, seq: impl Into<String>) -> Self {\n self.stop_sequences.push(seq.into());\n self\n }\n\n /// Adds a per-model parameter override.\n pub fn with_override(\n mut self,\n model_id: impl Into<String>,\n override_config: ModelOverride,\n ) -> Self {\n self.overrides.insert(model_id.into(), override_config);\n self\n }\n\n /// Enables structured output (JSON schema mode).\n pub fn with_structured_output(mut self, enable: bool) -> Self {\n self.structured_output = enable;\n self\n }\n\n /// Validates the model configuration.\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.model.is_empty() {\n return Err(TianjiError::Config(\n \"model identifier must not be empty\".into(),\n ));\n }\n if let Some(t) = self.temperature {\n if !(0.0..=2.0).contains(&t) {\n return Err(TianjiError::Config(\n \"temperature must be between 0.0 and 2.0\".into(),\n ));\n }\n }\n if let Some(p) = self.top_p {\n if !(0.0..=1.0).contains(&p) {\n return Err(TianjiError::Config(\n \"top_p must be between 0.0 and 1.0\".into(),\n ));\n }\n }\n if let Some(p) = self.presence_penalty {\n if !(-2.0..=2.0).contains(&p) {\n return Err(TianjiError::Config(\n \"presence_penalty must be between -2.0 and 2.0\".into(),\n ));\n }\n }\n if let Some(p) = self.frequency_penalty {\n if !(-2.0..=2.0).contains(&p) {\n return Err(TianjiError::Config(\n \"frequency_penalty must be between -2.0 and 2.0\".into(),\n ));\n }\n }\n Ok(())\n }\n}\n\n/// Per-model parameter overrides.\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ModelOverride {\n /// Override model ID to use instead of the requested one.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub model: Option<String>,\n\n /// Override temperature.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub temperature: Option<f32>,\n\n /// Override max output tokens.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub max_tokens: Option<u32>,\n\n /// Override top_p.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub top_p: Option<f32>,\n\n /// Override presence penalty.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub presence_penalty: Option<f32>,\n\n /// Override frequency penalty.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub frequency_penalty: Option<f32>,\n\n /// Override stop sequences.\n #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n pub stop_sequences: Vec<String>,\n}\n\nimpl ModelOverride {\n /// Creates an empty override with no fields set.\n pub fn new() -> Self {\n Self {\n model: None,\n temperature: None,\n max_tokens: None,\n top_p: None,\n presence_penalty: None,\n frequency_penalty: None,\n stop_sequences: Vec::new(),\n }\n }\n\n /// Sets the model ID override.\n pub fn with_model(mut self, model: impl Into<String>) -> Self {\n self.model = Some(model.into());\n self\n }\n\n /// Sets the temperature override.\n pub fn with_temperature(mut self, temp: f32) -> Self {\n self.temperature = Some(temp);\n self\n }\n\n /// Sets the max tokens override.\n pub fn with_max_tokens(mut self, max: u32) -> Self {\n self.max_tokens = Some(max);\n self\n }\n\n /// Sets the top_p override.\n pub fn with_top_p(mut self, top_p: f32) -> Self {\n self.top_p = Some(top_p);\n self\n }\n\n /// Sets the presence penalty override.\n pub fn with_presence_penalty(mut self, penalty: f32) -> Self {\n self.presence_penalty = Some(penalty);\n self\n }\n\n /// Sets the frequency penalty override.\n pub fn with_frequency_penalty(mut self, penalty: f32) -> Self {\n self.frequency_penalty = Some(penalty);\n self\n }\n\n /// Adds a stop sequence override.\n pub fn with_stop_sequence(mut self, seq: impl Into<String>) -> Self {\n self.stop_sequences.push(seq.into());\n self\n }\n}\n\nimpl Default for ModelOverride {\n fn default() -> Self {\n Self::new()\n }\n}\n\n// ──────────────────────────────────────────────\n// ToolConfig\n// ──────────────────────────────────────────────\n\n/// Global tool execution settings.\n///\n/// These defaults apply to all tools unless a tool specifies its own overrides.\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ToolConfig {\n /// Default timeout for tool execution in milliseconds.\n #[serde(default = \"default_tool_timeout_ms\")]\n pub timeout_ms: u64,\n\n /// Maximum number of concurrent tool executions within a single turn.\n ///\n /// Tools that are independent of each other can be executed in parallel.\n /// Set to `1` to force sequential execution.\n #[serde(default = \"default_max_concurrent_tools\")]\n pub max_concurrent: usize,\n\n /// Whether to automatically truncate tool output that exceeds the\n /// `max_output_bytes` limit, or reject it with an error.\n #[serde(default = \"default_true\")]\n pub truncate_output: bool,\n\n /// Maximum tool output size in bytes before truncation or rejection.\n #[serde(default = \"default_max_output_bytes\")]\n pub max_output_bytes: usize,\n\n /// Environment variables passed to tool execution.\n ///\n /// These are merged with the parent process environment.\n #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n pub env: HashMap<String, String>,\n\n /// Working directory for tool execution.\n /// `None` means the current process working directory.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub working_dir: Option<String>,\n}\n\nimpl ToolConfig {\n /// Sets the tool execution timeout in milliseconds.\n pub fn with_timeout_ms(mut self, ms: u64) -> Self {\n self.timeout_ms = ms;\n self\n }\n\n /// Sets the maximum number of concurrent tool executions.\n pub fn with_max_concurrent(mut self, max: usize) -> Self {\n self.max_concurrent = max;\n self\n }\n\n /// Sets whether to truncate oversized tool output.\n pub fn with_truncate_output(mut self, truncate: bool) -> Self {\n self.truncate_output = truncate;\n self\n }\n\n /// Sets the maximum tool output size in bytes.\n pub fn with_max_output_bytes(mut self, bytes: usize) -> Self {\n self.max_output_bytes = bytes;\n self\n }\n\n /// Adds an environment variable for tool execution.\n pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {\n self.env.insert(key.into(), value.into());\n self\n }\n\n /// Sets the working directory for tool execution.\n pub fn with_working_dir(mut self, dir: impl Into<String>) -> Self {\n self.working_dir = Some(dir.into());\n self\n }\n\n /// Validates the tool configuration.\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"tool timeout_ms must be greater than 0\".into(),\n ));\n }\n if self.max_concurrent == 0 {\n return Err(TianjiError::Config(\n \"tool max_concurrent must be greater than 0\".into(),\n ));\n }\n Ok(())\n }\n}\n\nimpl Default for ToolConfig {\n fn default() -> Self {\n Self {\n timeout_ms: default_tool_timeout_ms(),\n max_concurrent: default_max_concurrent_tools(),\n truncate_output: true,\n max_output_bytes: default_max_output_bytes(),\n env: HashMap::new(),\n working_dir: None,\n }\n }\n}\n\n// ──────────────────────────────────────────────\n// StreamConfig\n// ──────────────────────────────────────────────\n\n/// Streaming behavior settings.\n///\n/// Controls how streaming responses are buffered and delivered.\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct StreamConfig {\n /// Whether streaming is enabled by default.\n #[serde(default = \"default_true\")]\n pub enabled: bool,\n\n /// Minimum number of bytes to buffer before flushing a chunk downstream.\n ///\n /// Smaller values reduce latency but increase overhead.\n /// `0` means no buffering (flush every byte).\n #[serde(default = \"default_stream_buffer_size\")]\n pub buffer_size: usize,\n\n /// Timeout in milliseconds for receiving the next chunk from the provider.\n /// If no data arrives within this window, the stream is considered stale.\n #[serde(default = \"default_stream_idle_timeout_ms\")]\n pub idle_timeout_ms: u64,\n\n /// Whether to include usage (token count) information in the final stream chunk.\n #[serde(default = \"default_true\")]\n pub include_usage: bool,\n\n /// Whether to retry the stream from the beginning on a broken connection.\n /// Only applies to idempotent requests.\n #[serde(default)]\n pub retry_on_disconnect: bool,\n}\n\nimpl StreamConfig {\n /// Enables or disables streaming.\n pub fn with_enabled(mut self, enabled: bool) -> Self {\n self.enabled = enabled;\n self\n }\n\n /// Sets the stream buffer size in bytes.\n pub fn with_buffer_size(mut self, size: usize) -> Self {\n self.buffer_size = size;\n self\n }\n\n /// Sets the idle timeout in milliseconds.\n pub fn with_idle_timeout_ms(mut self, ms: u64) -> Self {\n self.idle_timeout_ms = ms;\n self\n }\n\n /// Sets whether to include usage in the final chunk.\n pub fn with_include_usage(mut self, include: bool) -> Self {\n self.include_usage = include;\n self\n }\n\n /// Sets whether to retry on disconnection.\n pub fn with_retry_on_disconnect(mut self, retry: bool) -> Self {\n self.retry_on_disconnect = retry;\n self\n }\n\n /// Validates the stream configuration.\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.idle_timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"stream idle_timeout_ms must be greater than 0\".into(),\n ));\n }\n Ok(())\n }\n}\n\nimpl Default for StreamConfig {\n fn default() -> Self {\n Self {\n enabled: true,\n buffer_size: default_stream_buffer_size(),\n idle_timeout_ms: default_stream_idle_timeout_ms(),\n include_usage: true,\n retry_on_disconnect: false,\n }\n }\n}\n\n// ──────────────────────────────────────────────\n// LoggingConfig\n// ──────────────────────────────────────────────\n\n/// Logging and observability settings.\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct LoggingConfig {\n /// Minimum log level.\n #[serde(default = \"default_log_level\")]\n pub level: String,\n\n /// Whether to log full request/response bodies.\n ///\n /// WARNING: This may log sensitive data (API keys in headers are always redacted).\n #[serde(default)]\n pub log_bodies: bool,\n\n /// Whether to log tool execution details (arguments, output).\n #[serde(default = \"default_true\")]\n pub log_tools: bool,\n\n /// Optional file path for log output. `None` means stderr only.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub file: Option<String>,\n\n /// Maximum log file size in bytes before rotation.\n #[serde(default = \"default_log_max_bytes\")]\n pub max_bytes: u64,\n\n /// Number of rotated log files to retain.\n #[serde(default = \"default_log_max_files\")]\n pub max_files: u32,\n}\n\nimpl LoggingConfig {\n /// Sets the minimum log level.\n pub fn with_level(mut self, level: impl Into<String>) -> Self {\n self.level = level.into();\n self\n }\n\n /// Sets whether to log request/response bodies.\n pub fn with_log_bodies(mut self, log: bool) -> Self {\n self.log_bodies = log;\n self\n }\n\n /// Sets whether to log tool execution details.\n pub fn with_log_tools(mut self, log: bool) -> Self {\n self.log_tools = log;\n self\n }\n\n /// Sets the log file path.\n pub fn with_file(mut self, path: impl Into<String>) -> Self {\n self.file = Some(path.into());\n self\n }\n\n /// Sets the maximum log file size before rotation.\n pub fn with_max_bytes(mut self, bytes: u64) -> Self {\n self.max_bytes = bytes;\n self\n }\n\n /// Sets the number of rotated log files to retain.\n pub fn with_max_files(mut self, files: u32) -> Self {\n self.max_files = files;\n self\n }\n\n /// Validates the logging configuration.\n pub fn validate(&self) -> Result<(), TianjiError> {\n let valid_levels = [\"trace\", \"debug\", \"info\", \"warn\", \"error\"];\n if !valid_levels.contains(&self.level.as_str()) {\n return Err(TianjiError::Config(format!(\n \"invalid log level '{}': must be one of {:?}\",\n self.level, valid_levels\n )));\n }\n Ok(())\n }\n}\n\nimpl Default for LoggingConfig {\n fn default() -> Self {\n Self {\n level: default_log_level(),\n log_bodies: false,\n log_tools: true,\n file: None,\n max_bytes: default_log_max_bytes(),\n max_files: default_log_max_files(),\n }\n }\n}\n\n// ──────────────────────────────────────────────\n// TianjiConfig (top-level)\n// ──────────────────────────────────────────────\n\n/// Top-level configuration for the Tianji framework.\n///\n/// Aggregates all sub-configurations into a single struct. Use\n/// [`TianjiConfig::builder()`] for ergonomic construction, or deserialize\n/// from TOML / JSON via serde.\n///\n/// # TOML Example\n///\n/// ```toml\n/// default_provider = \"openrouter\"\n///\n/// [provider.openrouter]\n/// api_key = \"sk-or-xxx\"\n/// base_url = \"https://openrouter.ai/api/v1\"\n/// timeout_ms = 60000\n///\n/// [agent]\n/// max_iterations = 20\n/// token_budget = 100000\n///\n/// [model]\n/// model = \"anthropic/claude-sonnet-4\"\n/// temperature = 0.7\n/// max_tokens = 4096\n///\n/// [security]\n/// sandbox_mode = \"workspace_only\"\n/// workspace_root = \"/home/user/project\"\n///\n/// [tool]\n/// timeout_ms = 30000\n/// max_concurrent = 4\n///\n/// [stream]\n/// enabled = true\n/// include_usage = true\n///\n/// [logging]\n/// level = \"info\"\n/// log_tools = true\n/// ```\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct TianjiConfig {\n /// Map of provider name → provider configuration.\n #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n pub providers: HashMap<String, ProviderConfig>,\n\n /// Name of the provider to use when no override is specified.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub default_provider: Option<String>,\n\n /// Agent loop control settings.\n #[serde(default)]\n pub agent: AgentConfig,\n\n /// Security and permission settings.\n #[serde(default)]\n pub security: SecurityConfig,\n\n /// Default model selection and per-model overrides.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub model: Option<ModelConfig>,\n\n /// Tool execution settings.\n #[serde(default)]\n pub tool: ToolConfig,\n\n /// Streaming behavior settings.\n #[serde(default)]\n pub stream: StreamConfig,\n\n /// Logging and observability settings.\n #[serde(default)]\n pub logging: LoggingConfig,\n}\n\nimpl TianjiConfig {\n /// Creates a builder for [`TianjiConfig`].\n pub fn builder() -> TianjiConfigBuilder {\n TianjiConfigBuilder::new()\n }\n\n /// Validates the entire configuration tree.\n pub fn validate(&self) -> Result<(), TianjiError> {\n if let Some(default) = &self.default_provider {\n if !self.providers.contains_key(default) {\n return Err(TianjiError::Config(format!(\n \"default provider '{}' is not defined in the providers map\",\n default\n )));\n }\n }\n for (name, provider) in &self.providers {\n provider\n .validate()\n .map_err(|e| TianjiError::Config(format!(\"provider '{}': {}\", name, e)))?;\n }\n self.agent.validate()?;\n self.security.validate()?;\n if let Some(model) = &self.model {\n model.validate()?;\n }\n self.tool.validate()?;\n self.stream.validate()?;\n self.logging.validate()?;\n Ok(())\n }\n}\n\n// ──────────────────────────────────────────────\n// TianjiConfigBuilder\n// ──────────────────────────────────────────────\n\n/// Builder for [`TianjiConfig`].\n///\n/// Provides a fluent API for constructing the top-level configuration.\n///\n/// # Example\n///\n/// ```\n/// use tianji_config::{TianjiConfig, ProviderConfig, AgentConfig};\n///\n/// let config = TianjiConfig::builder()\n/// .provider(\n/// \"openrouter\",\n/// ProviderConfig::new(\"sk-or-xxx\")\n/// .with_base_url(\"https://openrouter.ai/api/v1\"),\n/// )\n/// .default_provider(\"openrouter\")\n/// .agent(AgentConfig::default().with_max_iterations(20))\n/// .build();\n/// ```\npub struct TianjiConfigBuilder {\n providers: HashMap<String, ProviderConfig>,\n default_provider: Option<String>,\n agent: AgentConfig,\n security: SecurityConfig,\n model: Option<ModelConfig>,\n tool: ToolConfig,\n stream: StreamConfig,\n logging: LoggingConfig,\n}\n\nimpl TianjiConfigBuilder {\n fn new() -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n agent: AgentConfig::default(),\n security: SecurityConfig::default(),\n model: None,\n tool: ToolConfig::default(),\n stream: StreamConfig::default(),\n logging: LoggingConfig::default(),\n }\n }\n\n /// Registers a named provider.\n pub fn provider(mut self, name: impl Into<String>, config: ProviderConfig) -> Self {\n self.providers.insert(name.into(), config);\n self\n }\n\n /// Sets the default provider name.\n pub fn default_provider(mut self, name: impl Into<String>) -> Self {\n self.default_provider = Some(name.into());\n self\n }\n\n /// Sets the agent configuration.\n pub fn agent(mut self, config: AgentConfig) -> Self {\n self.agent = config;\n self\n }\n\n /// Sets the security configuration.\n pub fn security(mut self, config: SecurityConfig) -> Self {\n self.security = config;\n self\n }\n\n /// Sets the model configuration.\n pub fn model(mut self, config: ModelConfig) -> Self {\n self.model = Some(config);\n self\n }\n\n /// Sets the tool configuration.\n pub fn tool(mut self, config: ToolConfig) -> Self {\n self.tool = config;\n self\n }\n\n /// Sets the stream configuration.\n pub fn stream(mut self, config: StreamConfig) -> Self {\n self.stream = config;\n self\n }\n\n /// Sets the logging configuration.\n pub fn logging(mut self, config: LoggingConfig) -> Self {\n self.logging = config;\n self\n }\n\n /// Builds the final [`TianjiConfig`].\n pub fn build(self) -> TianjiConfig {\n TianjiConfig {\n providers: self.providers,\n default_provider: self.default_provider,\n agent: self.agent,\n security: self.security,\n model: self.model,\n tool: self.tool,\n stream: self.stream,\n logging: self.logging,\n }\n }\n}\n\n// ──────────────────────────────────────────────\n// Default constants\n// ──────────────────────────────────────────────\n\nfn default_timeout_ms() -> u64 {\n 60_000\n}\n\nfn default_connect_timeout_ms() -> u64 {\n 10_000\n}\n\nfn default_max_retries() -> u32 {\n 3\n}\n\nfn default_retry_backoff_ms() -> u64 {\n 1_000\n}\n\nfn default_max_iterations() -> u32 {\n 10\n}\n\nfn default_max_tool_calls_per_turn() -> u32 {\n 16\n}\n\nfn default_max_subagent_depth() -> u32 {\n 2\n}\n\nfn default_tool_timeout_ms() -> u64 {\n 30_000\n}\n\nfn default_max_concurrent_tools() -> usize {\n 4\n}\n\nfn default_max_output_bytes() -> usize {\n 1_048_576 // 1 MiB\n}\n\nfn default_stream_buffer_size() -> usize {\n 256\n}\n\nfn default_stream_idle_timeout_ms() -> u64 {\n 30_000\n}\n\nfn default_log_level() -> String {\n \"info\".into()\n}\n\nfn default_log_max_bytes() -> u64 {\n 10_485_760 // 10 MiB\n}\n\nfn default_log_max_files() -> u32 {\n 5\n}\n\nfn default_true() -> bool {\n true\n}\n\n// ──────────────────────────────────────────────\n// Tests\n// ──────────────────────────────────────────────\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_provider_config_builder() {\n let cfg = ProviderConfig::new(\"sk-test\")\n .with_base_url(\"https://example.com\")\n .with_timeout_ms(120_000)\n .with_max_retries(5)\n .with_retry_backoff_ms(2_000)\n .with_header(\"X-Custom\", \"value\")\n .with_extra_body(serde_json::json!({\"custom_field\": true}));\n\n assert_eq!(cfg.api_key, \"sk-test\");\n assert_eq!(cfg.base_url.as_deref(), Some(\"https://example.com\"));\n assert_eq!(cfg.timeout_ms, 120_000);\n assert_eq!(cfg.max_retries, 5);\n assert_eq!(cfg.retry_backoff_ms, 2_000);\n assert_eq!(\n cfg.extra_headers.get(\"X-Custom\"),\n Some(&\"value\".to_string())\n );\n assert!(cfg.extra_body.is_some());\n }\n\n #[test]\n fn test_provider_config_validate_empty_key() {\n let cfg = ProviderConfig::new(\"\");\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_provider_config_validate_zero_timeout() {\n let cfg = ProviderConfig::new(\"key\").with_timeout_ms(0);\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_agent_config_builder() {\n let cfg = AgentConfig::default()\n .with_max_iterations(50)\n .with_max_tool_calls_per_turn(32)\n .with_token_budget(200_000)\n .with_recording(true)\n .with_max_subagent_depth(3)\n .with_suppress_tool_output(true);\n\n assert_eq!(cfg.max_iterations, 50);\n assert_eq!(cfg.max_tool_calls_per_turn, 32);\n assert_eq!(cfg.token_budget, 200_000);\n assert!(cfg.enable_recording);\n assert_eq!(cfg.max_subagent_depth, 3);\n assert!(cfg.suppress_tool_output);\n }\n\n #[test]\n fn test_agent_config_validate_zero_iterations() {\n let cfg = AgentConfig::default().with_max_iterations(0);\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_security_config_builder() {\n let cfg = SecurityConfig::default()\n .with_sandbox_mode(SandboxMode::WorkspaceOnly)\n .with_workspace_root(\"/tmp/workspace\")\n .with_shell_policy(ShellPolicy::AllowList)\n .with_allowed_command(\"git\")\n .with_allowed_command(\"ls\")\n .with_allow_network(false)\n .with_tool_rate_limit(60)\n .with_require_tool_confirmation(true);\n\n assert_eq!(cfg.sandbox_mode, SandboxMode::WorkspaceOnly);\n assert_eq!(cfg.workspace_root.as_deref(), Some(\"/tmp/workspace\"));\n assert_eq!(cfg.shell_policy, ShellPolicy::AllowList);\n assert_eq!(cfg.allowed_commands, vec![\"git\", \"ls\"]);\n assert!(!cfg.allow_network);\n assert_eq!(cfg.tool_rate_limit_per_minute, 60);\n assert!(cfg.require_tool_confirmation);\n }\n\n #[test]\n fn test_security_config_validate_workspace_only_without_root() {\n let cfg = SecurityConfig::default().with_sandbox_mode(SandboxMode::WorkspaceOnly);\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_security_config_validate_allowlist_empty() {\n let cfg = SecurityConfig::default().with_shell_policy(ShellPolicy::AllowList);\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_model_config_builder() {\n let override_config = ModelOverride::new()\n .with_temperature(0.5)\n .with_max_tokens(2048);\n\n let cfg = ModelConfig::new(\"anthropic/claude-sonnet-4\")\n .with_temperature(0.7)\n .with_max_tokens(4096)\n .with_top_p(0.9)\n .with_presence_penalty(0.1)\n .with_frequency_penalty(0.2)\n .with_stop_sequence(\"<|end|>\")\n .with_override(\"openai/gpt-4o\", override_config.clone())\n .with_structured_output(true);\n\n assert_eq!(cfg.model, \"anthropic/claude-sonnet-4\");\n assert_eq!(cfg.temperature, Some(0.7));\n assert_eq!(cfg.max_tokens, Some(4096));\n assert_eq!(cfg.top_p, Some(0.9));\n assert_eq!(cfg.presence_penalty, Some(0.1));\n assert_eq!(cfg.frequency_penalty, Some(0.2));\n assert_eq!(cfg.stop_sequences, vec![\"<|end|>\"]);\n assert!(cfg.structured_output);\n assert!(cfg.overrides.contains_key(\"openai/gpt-4o\"));\n }\n\n #[test]\n fn test_model_config_validate_empty_model() {\n let cfg = ModelConfig::new(\"\");\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_model_config_validate_temperature_range() {\n let cfg = ModelConfig::new(\"test\").with_temperature(3.0);\n assert!(cfg.validate().is_err());\n let cfg = ModelConfig::new(\"test\").with_temperature(-0.1);\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_model_config_validate_top_p_range() {\n let cfg = ModelConfig::new(\"test\").with_top_p(1.5);\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_tool_config_builder() {\n let cfg = ToolConfig::default()\n .with_timeout_ms(60_000)\n .with_max_concurrent(8)\n .with_truncate_output(false)\n .with_max_output_bytes(2 * 1024 * 1024)\n .with_env(\"PATH\", \"/usr/local/bin:/usr/bin\")\n .with_working_dir(\"/tmp\");\n\n assert_eq!(cfg.timeout_ms, 60_000);\n assert_eq!(cfg.max_concurrent, 8);\n assert!(!cfg.truncate_output);\n assert_eq!(cfg.max_output_bytes, 2 * 1024 * 1024);\n assert_eq!(\n cfg.env.get(\"PATH\"),\n Some(&\"/usr/local/bin:/usr/bin\".to_string())\n );\n assert_eq!(cfg.working_dir.as_deref(), Some(\"/tmp\"));\n }\n\n #[test]\n fn test_stream_config_builder() {\n let cfg = StreamConfig::default()\n .with_enabled(false)\n .with_buffer_size(512)\n .with_idle_timeout_ms(60_000)\n .with_include_usage(false)\n .with_retry_on_disconnect(true);\n\n assert!(!cfg.enabled);\n assert_eq!(cfg.buffer_size, 512);\n assert_eq!(cfg.idle_timeout_ms, 60_000);\n assert!(!cfg.include_usage);\n assert!(cfg.retry_on_disconnect);\n }\n\n #[test]\n fn test_stream_config_validate_zero_idle_timeout() {\n let cfg = StreamConfig::default().with_idle_timeout_ms(0);\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_logging_config_builder() {\n let cfg = LoggingConfig::default()\n .with_level(\"debug\")\n .with_log_bodies(true)\n .with_log_tools(false)\n .with_file(\"/var/log/tianji.log\")\n .with_max_bytes(50 * 1024 * 1024)\n .with_max_files(10);\n\n assert_eq!(cfg.level, \"debug\");\n assert!(cfg.log_bodies);\n assert!(!cfg.log_tools);\n assert_eq!(cfg.file.as_deref(), Some(\"/var/log/tianji.log\"));\n assert_eq!(cfg.max_bytes, 50 * 1024 * 1024);\n assert_eq!(cfg.max_files, 10);\n }\n\n #[test]\n fn test_logging_config_validate_invalid_level() {\n let cfg = LoggingConfig::default().with_level(\"verbose\");\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_tianji_config_builder() {\n let config = TianjiConfig::builder()\n .provider(\n \"openrouter\",\n ProviderConfig::new(\"sk-or-xxx\").with_base_url(\"https://openrouter.ai/api/v1\"),\n )\n .default_provider(\"openrouter\")\n .agent(AgentConfig::default().with_max_iterations(20))\n .model(ModelConfig::new(\"anthropic/claude-sonnet-4\"))\n .build();\n\n assert!(config.providers.contains_key(\"openrouter\"));\n assert_eq!(config.default_provider.as_deref(), Some(\"openrouter\"));\n assert_eq!(config.agent.max_iterations, 20);\n assert!(config.model.is_some());\n }\n\n #[test]\n fn test_tianji_config_validate_unknown_default() {\n let config = TianjiConfig::builder()\n .default_provider(\"nonexistent\")\n .build();\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_json_roundtrip() {\n let config = TianjiConfig::builder()\n .provider(\n \"openrouter\",\n ProviderConfig::new(\"sk-or-xxx\").with_base_url(\"https://openrouter.ai/api/v1\"),\n )\n .default_provider(\"openrouter\")\n .agent(AgentConfig::default().with_max_iterations(20))\n .model(\n ModelConfig::new(\"anthropic/claude-sonnet-4\")\n .with_temperature(0.7)\n .with_max_tokens(4096),\n )\n .build();\n\n let json_str = serde_json::to_string_pretty(&config).expect(\"serialize to JSON failed\");\n let deserialized: TianjiConfig =\n serde_json::from_str(&json_str).expect(\"deserialize from JSON failed\");\n\n assert_eq!(deserialized.default_provider, config.default_provider);\n assert_eq!(deserialized.agent.max_iterations, 20);\n assert_eq!(\n deserialized.model.as_ref().unwrap().model,\n \"anthropic/claude-sonnet-4\"\n );\n assert_eq!(deserialized.model.as_ref().unwrap().temperature, Some(0.7));\n }\n}\n"}, "2345a7635d83349f828c8078578860a5b9583838": {"t": 1785757106.5317025, "n": 49250, "h": "19a693c39cf285cf", "c": "//! Configuration system for the Tianji framework.\n//!\n//! Organized into layers:\n//!\n//! - [`TianjiConfig`] — top-level config, aggregated from all sub-configs\n//! - [`ProviderConfig`] — per-provider settings (API key, base URL, timeouts)\n//! - [`AgentConfig`] — agent loop control (max iterations, token budget, recording)\n//! - [`SecurityConfig`] — permission and sandbox policies\n//! - [`ModelConfig`] — default model selection and per-model overrides\n//! - [`ToolConfig`] — tool execution defaults (timeouts, concurrency)\n//! - [`StreamConfig`] — streaming behavior tuning\n//!\n//! # Example\n//!\n//! ```no_run\n//! use tianji_config::{TianjiConfig, ProviderConfig, AgentConfig};\n//!\n//! let config = TianjiConfig::builder()\n//! .provider(\n//! \"openrouter\",\n//! ProviderConfig::new(\"sk-or-xxx\")\n//! .with_base_url(\"https://openrouter.ai/api/v1\"),\n//! )\n//! .default_provider(\"openrouter\")\n//! .agent(AgentConfig::default().with_max_iterations(20))\n//! .build();\n//! ```\n\nuse std::collections::HashMap;\n\nuse serde::{Deserialize, Serialize};\n\nuse tianji_core::error::TianjiError;\n\n// ──────────────────────────────────────────────\n// ProviderConfig\n// ──────────────────────────────────────────────\n\n/// Configuration for a single LLM provider.\n///\n/// Each provider requires at minimum an API key. All other fields fall back\n/// to sensible defaults when not specified.\n///\n/// # Required\n///\n/// - `api_key` — Authentication credential for the provider API.\n///\n/// # Optional\n///\n/// - `base_url` — Override the default API endpoint (useful for proxies or\n/// compatible APIs like Ollama, vLLM, etc.)\n/// - `timeout` — Request timeout duration\n/// - `connect_timeout` — TCP connection establishment timeout\n/// - `max_retries` — Number of automatic retries on transient failures\n/// - `retry_backoff` — Base delay between retries (exponential backoff applied)\n/// - `extra_headers` — Additional HTTP headers sent with every request\n/// - `extra_body` — Additional JSON fields merged into every request body\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProviderConfig {\n /// API key or token for authentication.\n pub api_key: String,\n\n /// Base URL of the provider API. Defaults to the provider's official endpoint\n /// when set to the provider's canonical URL.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub base_url: Option<String>,\n\n /// Total request timeout in milliseconds.\n #[serde(default = \"default_timeout_ms\")]\n pub timeout_ms: u64,\n\n /// Connection establishment timeout in milliseconds.\n #[serde(default = \"default_connect_timeout_ms\")]\n pub connect_timeout_ms: u64,\n\n /// Maximum number of automatic retries on transient errors (5xx, connection reset).\n #[serde(default = \"default_max_retries\")]\n pub max_retries: u32,\n\n /// Base delay between retries in milliseconds. Actual delay is\n /// `retry_backoff_ms * 2^attempt` (exponential backoff with jitter).\n #[serde(default = \"default_retry_backoff_ms\")]\n pub retry_backoff_ms: u64,\n\n /// Extra HTTP headers appended to every request.\n #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n pub extra_headers: HashMap<String, String>,\n\n /// Extra JSON fields merged into every request body.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub extra_body: Option<serde_json::Value>,\n}\n\nimpl ProviderConfig {\n /// Creates a new [`ProviderConfig`] with the given API key.\n ///\n /// All optional fields are set to their defaults.\n pub fn new(api_key: impl Into<String>) -> Self {\n Self {\n api_key: api_key.into(),\n base_url: None,\n timeout_ms: default_timeout_ms(),\n connect_timeout_ms: default_connect_timeout_ms(),\n max_retries: default_max_retries(),\n retry_backoff_ms: default_retry_backoff_ms(),\n extra_headers: HashMap::new(),\n extra_body: None,\n }\n }\n\n /// Sets a custom base URL for the provider API.\n pub fn with_base_url(mut self, url: impl Into<String>) -> Self {\n self.base_url = Some(url.into());\n self\n }\n\n /// Sets the total request timeout in milliseconds.\n pub fn with_timeout_ms(mut self, ms: u64) -> Self {\n self.timeout_ms = ms;\n self\n }\n\n /// Sets the connection timeout in milliseconds.\n pub fn with_connect_timeout_ms(mut self, ms: u64) -> Self {\n self.connect_timeout_ms = ms;\n self\n }\n\n /// Sets the maximum number of automatic retries.\n pub fn with_max_retries(mut self, retries: u32) -> Self {\n self.max_retries = retries;\n self\n }\n\n /// Sets the base retry backoff delay in milliseconds.\n pub fn with_retry_backoff_ms(mut self, ms: u64) -> Self {\n self.retry_backoff_ms = ms;\n self\n }\n\n /// Adds a single extra HTTP header.\n pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {\n self.extra_headers.insert(key.into(), value.into());\n self\n }\n\n /// Sets extra JSON body fields merged into every request.\n pub fn with_extra_body(mut self, body: serde_json::Value) -> Self {\n self.extra_body = Some(body);\n self\n }\n\n /// Validates the provider configuration.\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.api_key.is_empty() {\n return Err(TianjiError::Config(\n \"provider API key must not be empty\".into(),\n ));\n }\n if self.timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider timeout_ms must be greater than 0\".into(),\n ));\n }\n if self.connect_timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider connect_timeout_ms must be greater than 0\".into(),\n ));\n }\n Ok(())\n }\n}\n\n// ──────────────────────────────────────────────\n// AgentConfig\n// ──────────────────────────────────────────────\n\n/// Controls the agent loop execution behavior.\n///\n/// The agent loop is the core reasoning cycle: send a request, receive a\n/// response, optionally execute tools, and repeat. These settings bound the\n/// loop to prevent runaway execution.\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AgentConfig {\n /// Maximum number of loop iterations before aborting.\n ///\n /// Each iteration consists of one provider call plus any tool executions.\n /// Prevents infinite loops when the model keeps requesting tool calls.\n #[serde(default = \"default_max_iterations\")]\n pub max_iterations: u32,\n\n /// Maximum number of tool calls allowed within a single iteration.\n ///\n /// When the model requests more tool calls than this limit in one turn,\n /// only the first N are executed.\n #[serde(default = \"default_max_tool_calls_per_turn\")]\n pub max_tool_calls_per_turn: u32,\n\n /// Token budget for the entire agent session.\n ///\n /// Tracks cumulative input + output tokens across all iterations.\n /// When exceeded, the loop aborts with [`crate::error::LoopError::TokenBudgetExceeded`].\n /// `0` means unlimited.\n #[serde(default)]\n pub token_budget: u64,\n\n /// Whether to record the full execution trace for replay / debugging.\n ///\n /// When enabled, every request, response, and tool execution is captured\n /// and can be serialized for later analysis.\n #[serde(default)]\n pub enable_recording: bool,\n\n /// Maximum nesting depth for subagent spawning.\n ///\n /// A subagent is a child agent spawned by the parent to handle a subtask.\n /// `0` disables subagent spawning entirely.\n #[serde(default = \"default_max_subagent_depth\")]\n pub max_subagent_depth: u32,\n\n /// Whether to suppress tool execution output in the final response.\n ///\n /// When `true`, intermediate tool results are not included in the final\n /// user-facing output, only the model's summary.\n #[serde(default)]\n pub suppress_tool_output: bool,\n}\n\nimpl AgentConfig {\n /// Sets the maximum loop iterations.\n pub fn with_max_iterations(mut self, max: u32) -> Self {\n self.max_iterations = max;\n self\n }\n\n /// Sets the maximum tool calls per turn.\n pub fn with_max_tool_calls_per_turn(mut self, max: u32) -> Self {\n self.max_tool_calls_per_turn = max;\n self\n }\n\n /// Sets the token budget (0 = unlimited).\n pub fn with_token_budget(mut self, budget: u64) -> Self {\n self.token_budget = budget;\n self\n }\n\n /// Enables execution recording.\n pub fn with_recording(mut self, enable: bool) -> Self {\n self.enable_recording = enable;\n self\n }\n\n /// Sets the maximum subagent nesting depth.\n pub fn with_max_subagent_depth(mut self, depth: u32) -> Self {\n self.max_subagent_depth = depth;\n self\n }\n\n /// Sets whether to suppress tool output.\n pub fn with_suppress_tool_output(mut self, suppress: bool) -> Self {\n self.suppress_tool_output = suppress;\n self\n }\n\n /// Validates the agent configuration.\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.max_iterations == 0 {\n return Err(TianjiError::Config(\n \"agent max_iterations must be greater than 0\".into(),\n ));\n }\n if self.max_tool_calls_per_turn == 0 {\n return Err(TianjiError::Config(\n \"agent max_tool_calls_per_turn must be greater than 0\".into(),\n ));\n }\n Ok(())\n }\n}\n\nimpl Default for AgentConfig {\n fn default() -> Self {\n Self {\n max_iterations: default_max_iterations(),\n max_tool_calls_per_turn: default_max_tool_calls_per_turn(),\n token_budget: 0,\n enable_recording: false,\n max_subagent_depth: default_max_subagent_depth(),\n suppress_tool_output: false,\n }\n }\n}\n\n// ──────────────────────────────────────────────\n// SecurityConfig\n// ──────────────────────────────────────────────\n\n/// Permission and sandbox policy configuration.\n///\n/// Governs what operations the agent is allowed to perform, including file\n/// system access, shell commands, and network access.\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SecurityConfig {\n /// Sandbox mode for file system operations.\n ///\n /// - `Off` — no restrictions (full filesystem access)\n /// - `ReadOnly` — only reads allowed, writes are rejected\n /// - `WorkspaceOnly` — only files within the workspace directory are accessible\n #[serde(default)]\n pub sandbox_mode: SandboxMode,\n\n /// Root directory for the workspace sandbox.\n ///\n /// Only used when `sandbox_mode` is `WorkspaceOnly`.\n /// Paths are canonicalized and validated on startup.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub workspace_root: Option<String>,\n\n /// Shell command execution policy.\n #[serde(default)]\n pub shell_policy: ShellPolicy,\n\n /// Explicit list of allowed shell commands.\n ///\n /// When `shell_policy` is `AllowList`, only commands whose binary name\n /// appears in this list are permitted.\n #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n pub allowed_commands: Vec<String>,\n\n /// Explicit list of denied shell commands.\n ///\n /// When `shell_policy` is `DenyList`, any command whose binary name\n /// appears in this list is rejected.\n #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n pub denied_commands: Vec<String>,\n\n /// Whether the agent is allowed to make outbound network requests\n /// (e.g., via a `fetch` or `http_request` tool).\n #[serde(default = \"default_true\")]\n pub allow_network: bool,\n\n /// Maximum rate of tool executions per minute.\n ///\n /// `0` means unlimited.\n #[serde(default)]\n pub tool_rate_limit_per_minute: u32,\n\n /// Whether to require user confirmation before executing any tool.\n ///\n /// When `true`, the agent pauses and waits for explicit approval\n /// before each tool invocation.\n #[serde(default)]\n pub require_tool_confirmation: bool,\n}\n\nimpl SecurityConfig {\n /// Sets the sandbox mode.\n pub fn with_sandbox_mode(mut self, mode: SandboxMode) -> Self {\n self.sandbox_mode = mode;\n self\n }\n\n /// Sets the workspace root directory.\n pub fn with_workspace_root(mut self, root: impl Into<String>) -> Self {\n self.workspace_root = Some(root.into());\n self\n }\n\n /// Sets the shell command policy.\n pub fn with_shell_policy(mut self, policy: ShellPolicy) -> Self {\n self.shell_policy = policy;\n self\n }\n\n /// Adds an allowed command to the list.\n pub fn with_allowed_command(mut self, cmd: impl Into<String>) -> Self {\n self.allowed_commands.push(cmd.into());\n self\n }\n\n /// Adds a denied command to the list.\n pub fn with_denied_command(mut self, cmd: impl Into<String>) -> Self {\n self.denied_commands.push(cmd.into());\n self\n }\n\n /// Sets whether outbound network access is allowed.\n pub fn with_allow_network(mut self, allow: bool) -> Self {\n self.allow_network = allow;\n self\n }\n\n /// Sets the tool execution rate limit per minute (0 = unlimited).\n pub fn with_tool_rate_limit(mut self, limit: u32) -> Self {\n self.tool_rate_limit_per_minute = limit;\n self\n }\n\n /// Sets whether user confirmation is required before tool execution.\n pub fn with_require_tool_confirmation(mut self, require: bool) -> Self {\n self.require_tool_confirmation = require;\n self\n }\n\n /// Validates the security configuration.\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.sandbox_mode == SandboxMode::WorkspaceOnly && self.workspace_root.is_none() {\n return Err(TianjiError::Config(\n \"workspace_root must be set when sandbox_mode is WorkspaceOnly\".into(),\n ));\n }\n if self.shell_policy == ShellPolicy::AllowList && self.allowed_commands.is_empty() {\n return Err(TianjiError::Config(\n \"allowed_commands must not be empty when shell_policy is AllowList\".into(),\n ));\n }\n Ok(())\n }\n}\n\nimpl Default for SecurityConfig {\n fn default() -> Self {\n Self {\n sandbox_mode: SandboxMode::Off,\n workspace_root: None,\n shell_policy: ShellPolicy::AllowList,\n allowed_commands: Vec::new(),\n denied_commands: Vec::new(),\n allow_network: true,\n tool_rate_limit_per_minute: 0,\n require_tool_confirmation: false,\n }\n }\n}\n\n/// Sandbox mode for file system access.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]\n#[serde(rename_all = \"snake_case\")]\npub enum SandboxMode {\n /// No sandboxing, full filesystem access.\n #[default]\n Off,\n /// Read-only access, all writes are denied.\n ReadOnly,\n /// Only files within the workspace directory are accessible.\n WorkspaceOnly,\n}\n\n/// Shell command execution policy.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]\n#[serde(rename_all = \"snake_case\")]\npub enum ShellPolicy {\n /// All commands are allowed (no restriction).\n AllowAll,\n /// Only commands in the allow list are permitted.\n AllowList,\n /// All commands are allowed except those in the deny list.\n #[default]\n DenyList,\n /// No shell commands are allowed.\n DenyAll,\n}\n\n// ──────────────────────────────────────────────\n// ModelConfig\n// ──────────────────────────────────────────────\n\n/// Default model selection and per-model parameter overrides.\n///\n/// The `model` field is the default model identifier used when no\n/// override is specified at the request level. The `overrides` map\n/// allows setting custom parameters (temperature, max_tokens, etc.)\n/// per model ID.\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ModelConfig {\n /// Default model identifier (e.g., `\"anthropic/claude-sonnet-4\"`).\n pub model: String,\n\n /// Default temperature for sampling. `0.0` means deterministic (greedy).\n /// `None` defers to the provider's default.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub temperature: Option<f32>,\n\n /// Maximum number of output tokens. `None` defers to the provider's limit.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub max_tokens: Option<u32>,\n\n /// Nucleus sampling parameter. `None` defers to the provider's default.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub top_p: Option<f32>,\n\n /// Presence penalty for repetition reduction. Range: [-2.0, 2.0].\n /// `None` defers to the provider's default.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub presence_penalty: Option<f32>,\n\n /// Frequency penalty for repetition reduction. Range: [-2.0, 2.0].\n /// `None` defers to the provider's default.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub frequency_penalty: Option<f32>,\n\n /// Sequences where the model will stop generating.\n #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n pub stop_sequences: Vec<String>,\n\n /// Per-model parameter overrides, keyed by model ID.\n ///\n /// When a model ID appears in this map, its override values take\n /// precedence over the top-level defaults.\n #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n pub overrides: HashMap<String, ModelOverride>,\n\n /// Whether to enable structured output (JSON schema mode) by default.\n #[serde(default)]\n pub structured_output: bool,\n}\n\nimpl ModelConfig {\n /// Creates a new [`ModelConfig`] with the given default model ID.\n pub fn new(model: impl Into<String>) -> Self {\n Self {\n model: model.into(),\n temperature: None,\n max_tokens: None,\n top_p: None,\n presence_penalty: None,\n frequency_penalty: None,\n stop_sequences: Vec::new(),\n overrides: HashMap::new(),\n structured_output: false,\n }\n }\n\n /// Sets the default temperature.\n pub fn with_temperature(mut self, temp: f32) -> Self {\n self.temperature = Some(temp);\n self\n }\n\n /// Sets the default maximum output tokens.\n pub fn with_max_tokens(mut self, max: u32) -> Self {\n self.max_tokens = Some(max);\n self\n }\n\n /// Sets the default top_p.\n pub fn with_top_p(mut self, top_p: f32) -> Self {\n self.top_p = Some(top_p);\n self\n }\n\n /// Sets the presence penalty.\n pub fn with_presence_penalty(mut self, penalty: f32) -> Self {\n self.presence_penalty = Some(penalty);\n self\n }\n\n /// Sets the frequency penalty.\n pub fn with_frequency_penalty(mut self, penalty: f32) -> Self {\n self.frequency_penalty = Some(penalty);\n self\n }\n\n /// Adds a stop sequence.\n pub fn with_stop_sequence(mut self, seq: impl Into<String>) -> Self {\n self.stop_sequences.push(seq.into());\n self\n }\n\n /// Adds a per-model parameter override.\n pub fn with_override(\n mut self,\n model_id: impl Into<String>,\n override_config: ModelOverride,\n ) -> Self {\n self.overrides.insert(model_id.into(), override_config);\n self\n }\n\n /// Enables structured output (JSON schema mode).\n pub fn with_structured_output(mut self, enable: bool) -> Self {\n self.structured_output = enable;\n self\n }\n\n /// Validates the model configuration.\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.model.is_empty() {\n return Err(TianjiError::Config(\n \"model identifier must not be empty\".into(),\n ));\n }\n if let Some(t) = self.temperature {\n if !(0.0..=2.0).contains(&t) {\n return Err(TianjiError::Config(\n \"temperature must be between 0.0 and 2.0\".into(),\n ));\n }\n }\n if let Some(p) = self.top_p {\n if !(0.0..=1.0).contains(&p) {\n return Err(TianjiError::Config(\n \"top_p must be between 0.0 and 1.0\".into(),\n ));\n }\n }\n if let Some(p) = self.presence_penalty {\n if !(-2.0..=2.0).contains(&p) {\n return Err(TianjiError::Config(\n \"presence_penalty must be between -2.0 and 2.0\".into(),\n ));\n }\n }\n if let Some(p) = self.frequency_penalty {\n if !(-2.0..=2.0).contains(&p) {\n return Err(TianjiError::Config(\n \"frequency_penalty must be between -2.0 and 2.0\".into(),\n ));\n }\n }\n Ok(())\n }\n}\n\n/// Per-model parameter overrides.\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ModelOverride {\n /// Override model ID to use instead of the requested one.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub model: Option<String>,\n\n /// Override temperature.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub temperature: Option<f32>,\n\n /// Override max output tokens.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub max_tokens: Option<u32>,\n\n /// Override top_p.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub top_p: Option<f32>,\n\n /// Override presence penalty.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub presence_penalty: Option<f32>,\n\n /// Override frequency penalty.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub frequency_penalty: Option<f32>,\n\n /// Override stop sequences.\n #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n pub stop_sequences: Vec<String>,\n}\n\nimpl ModelOverride {\n /// Creates an empty override with no fields set.\n pub fn new() -> Self {\n Self {\n model: None,\n temperature: None,\n max_tokens: None,\n top_p: None,\n presence_penalty: None,\n frequency_penalty: None,\n stop_sequences: Vec::new(),\n }\n }\n\n /// Sets the model ID override.\n pub fn with_model(mut self, model: impl Into<String>) -> Self {\n self.model = Some(model.into());\n self\n }\n\n /// Sets the temperature override.\n pub fn with_temperature(mut self, temp: f32) -> Self {\n self.temperature = Some(temp);\n self\n }\n\n /// Sets the max tokens override.\n pub fn with_max_tokens(mut self, max: u32) -> Self {\n self.max_tokens = Some(max);\n self\n }\n\n /// Sets the top_p override.\n pub fn with_top_p(mut self, top_p: f32) -> Self {\n self.top_p = Some(top_p);\n self\n }\n\n /// Sets the presence penalty override.\n pub fn with_presence_penalty(mut self, penalty: f32) -> Self {\n self.presence_penalty = Some(penalty);\n self\n }\n\n /// Sets the frequency penalty override.\n pub fn with_frequency_penalty(mut self, penalty: f32) -> Self {\n self.frequency_penalty = Some(penalty);\n self\n }\n\n /// Adds a stop sequence override.\n pub fn with_stop_sequence(mut self, seq: impl Into<String>) -> Self {\n self.stop_sequences.push(seq.into());\n self\n }\n}\n\nimpl Default for ModelOverride {\n fn default() -> Self {\n Self::new()\n }\n}\n\n// ──────────────────────────────────────────────\n// ToolConfig\n// ──────────────────────────────────────────────\n\n/// Global tool execution settings.\n///\n/// These defaults apply to all tools unless a tool specifies its own overrides.\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ToolConfig {\n /// Default timeout for tool execution in milliseconds.\n #[serde(default = \"default_tool_timeout_ms\")]\n pub timeout_ms: u64,\n\n /// Maximum number of concurrent tool executions within a single turn.\n ///\n /// Tools that are independent of each other can be executed in parallel.\n /// Set to `1` to force sequential execution.\n #[serde(default = \"default_max_concurrent_tools\")]\n pub max_concurrent: usize,\n\n /// Whether to automatically truncate tool output that exceeds the\n /// `max_output_bytes` limit, or reject it with an error.\n #[serde(default = \"default_true\")]\n pub truncate_output: bool,\n\n /// Maximum tool output size in bytes before truncation or rejection.\n #[serde(default = \"default_max_output_bytes\")]\n pub max_output_bytes: usize,\n\n /// Environment variables passed to tool execution.\n ///\n /// These are merged with the parent process environment.\n #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n pub env: HashMap<String, String>,\n\n /// Working directory for tool execution.\n /// `None` means the current process working directory.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub working_dir: Option<String>,\n}\n\nimpl ToolConfig {\n /// Sets the tool execution timeout in milliseconds.\n pub fn with_timeout_ms(mut self, ms: u64) -> Self {\n self.timeout_ms = ms;\n self\n }\n\n /// Sets the maximum number of concurrent tool executions.\n pub fn with_max_concurrent(mut self, max: usize) -> Self {\n self.max_concurrent = max;\n self\n }\n\n /// Sets whether to truncate oversized tool output.\n pub fn with_truncate_output(mut self, truncate: bool) -> Self {\n self.truncate_output = truncate;\n self\n }\n\n /// Sets the maximum tool output size in bytes.\n pub fn with_max_output_bytes(mut self, bytes: usize) -> Self {\n self.max_output_bytes = bytes;\n self\n }\n\n /// Adds an environment variable for tool execution.\n pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {\n self.env.insert(key.into(), value.into());\n self\n }\n\n /// Sets the working directory for tool execution.\n pub fn with_working_dir(mut self, dir: impl Into<String>) -> Self {\n self.working_dir = Some(dir.into());\n self\n }\n\n /// Validates the tool configuration.\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"tool timeout_ms must be greater than 0\".into(),\n ));\n }\n if self.max_concurrent == 0 {\n return Err(TianjiError::Config(\n \"tool max_concurrent must be greater than 0\".into(),\n ));\n }\n Ok(())\n }\n}\n\nimpl Default for ToolConfig {\n fn default() -> Self {\n Self {\n timeout_ms: default_tool_timeout_ms(),\n max_concurrent: default_max_concurrent_tools(),\n truncate_output: true,\n max_output_bytes: default_max_output_bytes(),\n env: HashMap::new(),\n working_dir: None,\n }\n }\n}\n\n// ──────────────────────────────────────────────\n// StreamConfig\n// ──────────────────────────────────────────────\n\n/// Streaming behavior settings.\n///\n/// Controls how streaming responses are buffered and delivered.\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct StreamConfig {\n /// Whether streaming is enabled by default.\n #[serde(default = \"default_true\")]\n pub enabled: bool,\n\n /// Minimum number of bytes to buffer before flushing a chunk downstream.\n ///\n /// Smaller values reduce latency but increase overhead.\n /// `0` means no buffering (flush every byte).\n #[serde(default = \"default_stream_buffer_size\")]\n pub buffer_size: usize,\n\n /// Timeout in milliseconds for receiving the next chunk from the provider.\n /// If no data arrives within this window, the stream is considered stale.\n #[serde(default = \"default_stream_idle_timeout_ms\")]\n pub idle_timeout_ms: u64,\n\n /// Whether to include usage (token count) information in the final stream chunk.\n #[serde(default = \"default_true\")]\n pub include_usage: bool,\n\n /// Whether to retry the stream from the beginning on a broken connection.\n /// Only applies to idempotent requests.\n #[serde(default)]\n pub retry_on_disconnect: bool,\n}\n\nimpl StreamConfig {\n /// Enables or disables streaming.\n pub fn with_enabled(mut self, enabled: bool) -> Self {\n self.enabled = enabled;\n self\n }\n\n /// Sets the stream buffer size in bytes.\n pub fn with_buffer_size(mut self, size: usize) -> Self {\n self.buffer_size = size;\n self\n }\n\n /// Sets the idle timeout in milliseconds.\n pub fn with_idle_timeout_ms(mut self, ms: u64) -> Self {\n self.idle_timeout_ms = ms;\n self\n }\n\n /// Sets whether to include usage in the final chunk.\n pub fn with_include_usage(mut self, include: bool) -> Self {\n self.include_usage = include;\n self\n }\n\n /// Sets whether to retry on disconnection.\n pub fn with_retry_on_disconnect(mut self, retry: bool) -> Self {\n self.retry_on_disconnect = retry;\n self\n }\n\n /// Validates the stream configuration.\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.idle_timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"stream idle_timeout_ms must be greater than 0\".into(),\n ));\n }\n Ok(())\n }\n}\n\nimpl Default for StreamConfig {\n fn default() -> Self {\n Self {\n enabled: true,\n buffer_size: default_stream_buffer_size(),\n idle_timeout_ms: default_stream_idle_timeout_ms(),\n include_usage: true,\n retry_on_disconnect: false,\n }\n }\n}\n\n// ──────────────────────────────────────────────\n// LoggingConfig\n// ──────────────────────────────────────────────\n\n/// Logging and observability settings.\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct LoggingConfig {\n /// Minimum log level.\n #[serde(default = \"default_log_level\")]\n pub level: String,\n\n /// Whether to log full request/response bodies.\n ///\n /// WARNING: This may log sensitive data (API keys in headers are always redacted).\n #[serde(default)]\n pub log_bodies: bool,\n\n /// Whether to log tool execution details (arguments, output).\n #[serde(default = \"default_true\")]\n pub log_tools: bool,\n\n /// Optional file path for log output. `None` means stderr only.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub file: Option<String>,\n\n /// Maximum log file size in bytes before rotation.\n #[serde(default = \"default_log_max_bytes\")]\n pub max_bytes: u64,\n\n /// Number of rotated log files to retain.\n #[serde(default = \"default_log_max_files\")]\n pub max_files: u32,\n}\n\nimpl LoggingConfig {\n /// Sets the minimum log level.\n pub fn with_level(mut self, level: impl Into<String>) -> Self {\n self.level = level.into();\n self\n }\n\n /// Sets whether to log request/response bodies.\n pub fn with_log_bodies(mut self, log: bool) -> Self {\n self.log_bodies = log;\n self\n }\n\n /// Sets whether to log tool execution details.\n pub fn with_log_tools(mut self, log: bool) -> Self {\n self.log_tools = log;\n self\n }\n\n /// Sets the log file path.\n pub fn with_file(mut self, path: impl Into<String>) -> Self {\n self.file = Some(path.into());\n self\n }\n\n /// Sets the maximum log file size before rotation.\n pub fn with_max_bytes(mut self, bytes: u64) -> Self {\n self.max_bytes = bytes;\n self\n }\n\n /// Sets the number of rotated log files to retain.\n pub fn with_max_files(mut self, files: u32) -> Self {\n self.max_files = files;\n self\n }\n\n /// Validates the logging configuration.\n pub fn validate(&self) -> Result<(), TianjiError> {\n let valid_levels = [\"trace\", \"debug\", \"info\", \"warn\", \"error\"];\n if !valid_levels.contains(&self.level.as_str()) {\n return Err(TianjiError::Config(format!(\n \"invalid log level '{}': must be one of {:?}\",\n self.level, valid_levels\n )));\n }\n Ok(())\n }\n}\n\nimpl Default for LoggingConfig {\n fn default() -> Self {\n Self {\n level: default_log_level(),\n log_bodies: false,\n log_tools: true,\n file: None,\n max_bytes: default_log_max_bytes(),\n max_files: default_log_max_files(),\n }\n }\n}\n\n// ──────────────────────────────────────────────\n// TianjiConfig (top-level)\n// ──────────────────────────────────────────────\n\n/// Top-level configuration for the Tianji framework.\n///\n/// Aggregates all sub-configurations into a single struct. Use\n/// [`TianjiConfig::builder()`] for ergonomic construction, or deserialize\n/// from TOML / JSON via serde.\n///\n/// # TOML Example\n///\n/// ```toml\n/// default_provider = \"openrouter\"\n///\n/// [provider.openrouter]\n/// api_key = \"sk-or-xxx\"\n/// base_url = \"https://openrouter.ai/api/v1\"\n/// timeout_ms = 60000\n///\n/// [agent]\n/// max_iterations = 20\n/// token_budget = 100000\n///\n/// [model]\n/// model = \"anthropic/claude-sonnet-4\"\n/// temperature = 0.7\n/// max_tokens = 4096\n///\n/// [security]\n/// sandbox_mode = \"workspace_only\"\n/// workspace_root = \"/home/user/project\"\n///\n/// [tool]\n/// timeout_ms = 30000\n/// max_concurrent = 4\n///\n/// [stream]\n/// enabled = true\n/// include_usage = true\n///\n/// [logging]\n/// level = \"info\"\n/// log_tools = true\n/// ```\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct TianjiConfig {\n /// Map of provider name → provider configuration.\n #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n pub providers: HashMap<String, ProviderConfig>,\n\n /// Name of the provider to use when no override is specified.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub default_provider: Option<String>,\n\n /// Agent loop control settings.\n #[serde(default)]\n pub agent: AgentConfig,\n\n /// Security and permission settings.\n #[serde(default)]\n pub security: SecurityConfig,\n\n /// Default model selection and per-model overrides.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub model: Option<ModelConfig>,\n\n /// Tool execution settings.\n #[serde(default)]\n pub tool: ToolConfig,\n\n /// Streaming behavior settings.\n #[serde(default)]\n pub stream: StreamConfig,\n\n /// Logging and observability settings.\n #[serde(default)]\n pub logging: LoggingConfig,\n}\n\nimpl TianjiConfig {\n /// Creates a builder for [`TianjiConfig`].\n pub fn builder() -> TianjiConfigBuilder {\n TianjiConfigBuilder::new()\n }\n\n /// Validates the entire configuration tree.\n pub fn validate(&self) -> Result<(), TianjiError> {\n if let Some(default) = &self.default_provider {\n if !self.providers.contains_key(default) {\n return Err(TianjiError::Config(format!(\n \"default provider '{}' is not defined in the providers map\",\n default\n )));\n }\n }\n for (name, provider) in &self.providers {\n provider.validate().map_err(|e| {\n TianjiError::Config(format!(\"provider '{}': {}\", name, e))\n })?;\n }\n self.agent.validate()?;\n self.security.validate()?;\n if let Some(model) = &self.model {\n model.validate()?;\n }\n self.tool.validate()?;\n self.stream.validate()?;\n self.logging.validate()?;\n Ok(())\n }\n}\n\n// ──────────────────────────────────────────────\n// TianjiConfigBuilder\n// ──────────────────────────────────────────────\n\n/// Builder for [`TianjiConfig`].\n///\n/// Provides a fluent API for constructing the top-level configuration.\n///\n/// # Example\n///\n/// ```\n/// use tianji_config::{TianjiConfig, ProviderConfig, AgentConfig};\n///\n/// let config = TianjiConfig::builder()\n/// .provider(\n/// \"openrouter\",\n/// ProviderConfig::new(\"sk-or-xxx\")\n/// .with_base_url(\"https://openrouter.ai/api/v1\"),\n/// )\n/// .default_provider(\"openrouter\")\n/// .agent(AgentConfig::default().with_max_iterations(20))\n/// .build();\n/// ```\npub struct TianjiConfigBuilder {\n providers: HashMap<String, ProviderConfig>,\n default_provider: Option<String>,\n agent: AgentConfig,\n security: SecurityConfig,\n model: Option<ModelConfig>,\n tool: ToolConfig,\n stream: StreamConfig,\n logging: LoggingConfig,\n}\n\nimpl TianjiConfigBuilder {\n fn new() -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n agent: AgentConfig::default(),\n security: SecurityConfig::default(),\n model: None,\n tool: ToolConfig::default(),\n stream: StreamConfig::default(),\n logging: LoggingConfig::default(),\n }\n }\n\n /// Registers a named provider.\n pub fn provider(mut self, name: impl Into<String>, config: ProviderConfig) -> Self {\n self.providers.insert(name.into(), config);\n self\n }\n\n /// Sets the default provider name.\n pub fn default_provider(mut self, name: impl Into<String>) -> Self {\n self.default_provider = Some(name.into());\n self\n }\n\n /// Sets the agent configuration.\n pub fn agent(mut self, config: AgentConfig) -> Self {\n self.agent = config;\n self\n }\n\n /// Sets the security configuration.\n pub fn security(mut self, config: SecurityConfig) -> Self {\n self.security = config;\n self\n }\n\n /// Sets the model configuration.\n pub fn model(mut self, config: ModelConfig) -> Self {\n self.model = Some(config);\n self\n }\n\n /// Sets the tool configuration.\n pub fn tool(mut self, config: ToolConfig) -> Self {\n self.tool = config;\n self\n }\n\n /// Sets the stream configuration.\n pub fn stream(mut self, config: StreamConfig) -> Self {\n self.stream = config;\n self\n }\n\n /// Sets the logging configuration.\n pub fn logging(mut self, config: LoggingConfig) -> Self {\n self.logging = config;\n self\n }\n\n /// Builds the final [`TianjiConfig`].\n pub fn build(self) -> TianjiConfig {\n TianjiConfig {\n providers: self.providers,\n default_provider: self.default_provider,\n agent: self.agent,\n security: self.security,\n model: self.model,\n tool: self.tool,\n stream: self.stream,\n logging: self.logging,\n }\n }\n}\n\n// ──────────────────────────────────────────────\n// Default constants\n// ──────────────────────────────────────────────\n\nfn default_timeout_ms() -> u64 {\n 60_000\n}\n\nfn default_connect_timeout_ms() -> u64 {\n 10_000\n}\n\nfn default_max_retries() -> u32 {\n 3\n}\n\nfn default_retry_backoff_ms() -> u64 {\n 1_000\n}\n\nfn default_max_iterations() -> u32 {\n 10\n}\n\nfn default_max_tool_calls_per_turn() -> u32 {\n 16\n}\n\nfn default_max_subagent_depth() -> u32 {\n 2\n}\n\nfn default_tool_timeout_ms() -> u64 {\n 30_000\n}\n\nfn default_max_concurrent_tools() -> usize {\n 4\n}\n\nfn default_max_output_bytes() -> usize {\n 1_048_576 // 1 MiB\n}\n\nfn default_stream_buffer_size() -> usize {\n 256\n}\n\nfn default_stream_idle_timeout_ms() -> u64 {\n 30_000\n}\n\nfn default_log_level() -> String {\n \"info\".into()\n}\n\nfn default_log_max_bytes() -> u64 {\n 10_485_760 // 10 MiB\n}\n\nfn default_log_max_files() -> u32 {\n 5\n}\n\nfn default_true() -> bool {\n true\n}\n\n// ──────────────────────────────────────────────\n// Tests\n// ──────────────────────────────────────────────\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_provider_config_builder() {\n let cfg = ProviderConfig::new(\"sk-test\")\n .with_base_url(\"https://example.com\")\n .with_timeout_ms(120_000)\n .with_max_retries(5)\n .with_retry_backoff_ms(2_000)\n .with_header(\"X-Custom\", \"value\")\n .with_extra_body(serde_json::json!({\"custom_field\": true}));\n\n assert_eq!(cfg.api_key, \"sk-test\");\n assert_eq!(cfg.base_url.as_deref(), Some(\"https://example.com\"));\n assert_eq!(cfg.timeout_ms, 120_000);\n assert_eq!(cfg.max_retries, 5);\n assert_eq!(cfg.retry_backoff_ms, 2_000);\n assert_eq!(cfg.extra_headers.get(\"X-Custom\"), Some(&\"value\".to_string()));\n assert!(cfg.extra_body.is_some());\n }\n\n #[test]\n fn test_provider_config_validate_empty_key() {\n let cfg = ProviderConfig::new(\"\");\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_provider_config_validate_zero_timeout() {\n let cfg = ProviderConfig::new(\"key\").with_timeout_ms(0);\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_agent_config_builder() {\n let cfg = AgentConfig::default()\n .with_max_iterations(50)\n .with_max_tool_calls_per_turn(32)\n .with_token_budget(200_000)\n .with_recording(true)\n .with_max_subagent_depth(3)\n .with_suppress_tool_output(true);\n\n assert_eq!(cfg.max_iterations, 50);\n assert_eq!(cfg.max_tool_calls_per_turn, 32);\n assert_eq!(cfg.token_budget, 200_000);\n assert!(cfg.enable_recording);\n assert_eq!(cfg.max_subagent_depth, 3);\n assert!(cfg.suppress_tool_output);\n }\n\n #[test]\n fn test_agent_config_validate_zero_iterations() {\n let cfg = AgentConfig::default().with_max_iterations(0);\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_security_config_builder() {\n let cfg = SecurityConfig::default()\n .with_sandbox_mode(SandboxMode::WorkspaceOnly)\n .with_workspace_root(\"/tmp/workspace\")\n .with_shell_policy(ShellPolicy::AllowList)\n .with_allowed_command(\"git\")\n .with_allowed_command(\"ls\")\n .with_allow_network(false)\n .with_tool_rate_limit(60)\n .with_require_tool_confirmation(true);\n\n assert_eq!(cfg.sandbox_mode, SandboxMode::WorkspaceOnly);\n assert_eq!(cfg.workspace_root.as_deref(), Some(\"/tmp/workspace\"));\n assert_eq!(cfg.shell_policy, ShellPolicy::AllowList);\n assert_eq!(cfg.allowed_commands, vec![\"git\", \"ls\"]);\n assert!(!cfg.allow_network);\n assert_eq!(cfg.tool_rate_limit_per_minute, 60);\n assert!(cfg.require_tool_confirmation);\n }\n\n #[test]\n fn test_security_config_validate_workspace_only_without_root() {\n let cfg =\n SecurityConfig::default().with_sandbox_mode(SandboxMode::WorkspaceOnly);\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_security_config_validate_allowlist_empty() {\n let cfg = SecurityConfig::default()\n .with_shell_policy(ShellPolicy::AllowList);\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_model_config_builder() {\n let override_config = ModelOverride::new()\n .with_temperature(0.5)\n .with_max_tokens(2048);\n\n let cfg = ModelConfig::new(\"anthropic/claude-sonnet-4\")\n .with_temperature(0.7)\n .with_max_tokens(4096)\n .with_top_p(0.9)\n .with_presence_penalty(0.1)\n .with_frequency_penalty(0.2)\n .with_stop_sequence(\"<|end|>\")\n .with_override(\"openai/gpt-4o\", override_config.clone())\n .with_structured_output(true);\n\n assert_eq!(cfg.model, \"anthropic/claude-sonnet-4\");\n assert_eq!(cfg.temperature, Some(0.7));\n assert_eq!(cfg.max_tokens, Some(4096));\n assert_eq!(cfg.top_p, Some(0.9));\n assert_eq!(cfg.presence_penalty, Some(0.1));\n assert_eq!(cfg.frequency_penalty, Some(0.2));\n assert_eq!(cfg.stop_sequences, vec![\"<|end|>\"]);\n assert!(cfg.structured_output);\n assert!(cfg.overrides.contains_key(\"openai/gpt-4o\"));\n }\n\n #[test]\n fn test_model_config_validate_empty_model() {\n let cfg = ModelConfig::new(\"\");\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_model_config_validate_temperature_range() {\n let cfg = ModelConfig::new(\"test\").with_temperature(3.0);\n assert!(cfg.validate().is_err());\n let cfg = ModelConfig::new(\"test\").with_temperature(-0.1);\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_model_config_validate_top_p_range() {\n let cfg = ModelConfig::new(\"test\").with_top_p(1.5);\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_tool_config_builder() {\n let cfg = ToolConfig::default()\n .with_timeout_ms(60_000)\n .with_max_concurrent(8)\n .with_truncate_output(false)\n .with_max_output_bytes(2 * 1024 * 1024)\n .with_env(\"PATH\", \"/usr/local/bin:/usr/bin\")\n .with_working_dir(\"/tmp\");\n\n assert_eq!(cfg.timeout_ms, 60_000);\n assert_eq!(cfg.max_concurrent, 8);\n assert!(!cfg.truncate_output);\n assert_eq!(cfg.max_output_bytes, 2 * 1024 * 1024);\n assert_eq!(\n cfg.env.get(\"PATH\"),\n Some(&\"/usr/local/bin:/usr/bin\".to_string())\n );\n assert_eq!(cfg.working_dir.as_deref(), Some(\"/tmp\"));\n }\n\n #[test]\n fn test_stream_config_builder() {\n let cfg = StreamConfig::default()\n .with_enabled(false)\n .with_buffer_size(512)\n .with_idle_timeout_ms(60_000)\n .with_include_usage(false)\n .with_retry_on_disconnect(true);\n\n assert!(!cfg.enabled);\n assert_eq!(cfg.buffer_size, 512);\n assert_eq!(cfg.idle_timeout_ms, 60_000);\n assert!(!cfg.include_usage);\n assert!(cfg.retry_on_disconnect);\n }\n\n #[test]\n fn test_stream_config_validate_zero_idle_timeout() {\n let cfg = StreamConfig::default().with_idle_timeout_ms(0);\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_logging_config_builder() {\n let cfg = LoggingConfig::default()\n .with_level(\"debug\")\n .with_log_bodies(true)\n .with_log_tools(false)\n .with_file(\"/var/log/tianji.log\")\n .with_max_bytes(50 * 1024 * 1024)\n .with_max_files(10);\n\n assert_eq!(cfg.level, \"debug\");\n assert!(cfg.log_bodies);\n assert!(!cfg.log_tools);\n assert_eq!(cfg.file.as_deref(), Some(\"/var/log/tianji.log\"));\n assert_eq!(cfg.max_bytes, 50 * 1024 * 1024);\n assert_eq!(cfg.max_files, 10);\n }\n\n #[test]\n fn test_logging_config_validate_invalid_level() {\n let cfg = LoggingConfig::default().with_level(\"verbose\");\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_tianji_config_builder() {\n let config = TianjiConfig::builder()\n .provider(\n \"openrouter\",\n ProviderConfig::new(\"sk-or-xxx\")\n .with_base_url(\"https://openrouter.ai/api/v1\"),\n )\n .default_provider(\"openrouter\")\n .agent(AgentConfig::default().with_max_iterations(20))\n .model(ModelConfig::new(\"anthropic/claude-sonnet-4\"))\n .build();\n\n assert!(config.providers.contains_key(\"openrouter\"));\n assert_eq!(config.default_provider.as_deref(), Some(\"openrouter\"));\n assert_eq!(config.agent.max_iterations, 20);\n assert!(config.model.is_some());\n }\n\n #[test]\n fn test_tianji_config_validate_unknown_default() {\n let config = TianjiConfig::builder()\n .default_provider(\"nonexistent\")\n .build();\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_json_roundtrip() {\n let config = TianjiConfig::builder()\n .provider(\n \"openrouter\",\n ProviderConfig::new(\"sk-or-xxx\")\n .with_base_url(\"https://openrouter.ai/api/v1\"),\n )\n .default_provider(\"openrouter\")\n .agent(AgentConfig::default().with_max_iterations(20))\n .model(\n ModelConfig::new(\"anthropic/claude-sonnet-4\")\n .with_temperature(0.7)\n .with_max_tokens(4096),\n )\n .build();\n\n let json_str = serde_json::to_string_pretty(&config).expect(\"serialize to JSON failed\");\n let deserialized: TianjiConfig =\n serde_json::from_str(&json_str).expect(\"deserialize from JSON failed\");\n\n assert_eq!(deserialized.default_provider, config.default_provider);\n assert_eq!(deserialized.agent.max_iterations, 20);\n assert_eq!(\n deserialized.model.as_ref().unwrap().model,\n \"anthropic/claude-sonnet-4\"\n );\n assert_eq!(\n deserialized.model.as_ref().unwrap().temperature,\n Some(0.7)\n );\n }\n}"}}, "crates/config/src/provider.rs": {"fe1899ba91fe225f28dac66def6a6131258d038e": {"t": 1785745873.2987115, "n": 7525, "h": "9c9d36421ab49864", "c": "//! LLM 提供商连接配置。\n//!\n//! 每个 LLM 后端(OpenRouter、LongCat 等)的连接参数,\n//! 包括基础 URL、超时、重试策略等。\n//!\n//! ⚠️ API 密钥不在此处配置。请使用:\n//! - 环境变量(`DEEPSEEK_API_KEY` 等)\n//! - [`KeyStore`](crate::keystore::KeyStore)(`~/.local/share/tianji/keys.json`)\n\nuse std::collections::HashMap;\n\nuse serde::{Deserialize, Serialize};\n\nuse tianji_core::error::TianjiError;\n\nuse crate::keystore::KeyStore;\n\n/// LLM 提供商连接配置。\n///\n/// 支持自定义基础 URL(可用于 OpenAI 兼容 API)、超时时间、\n/// 重试策略和额外的 HTTP 头/请求体。\n///\n/// ⚠️ API 密钥不再从 config.yml 读取。\n/// 使用环境变量 `{NAME}_API_KEY` 或 `~/.local/share/tianji/keys.json`。\n///\n/// 通过 `ProviderConfig::new(\"sk-...\")` 创建(仅用于测试),\n/// 然后使用方法链配置其他参数。\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProviderConfig {\n /// API 密钥(仅程序内使用,不从 YAML 读取)。\n #[serde(skip)]\n pub api_key: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub base_url: Option<String>,\n\n #[serde(default = \"crate::defaults::default_timeout_ms\")]\n pub timeout_ms: u64,\n\n #[serde(default = \"crate::defaults::default_connect_timeout_ms\")]\n pub connect_timeout_ms: u64,\n\n #[serde(default = \"crate::defaults::default_max_retries\")]\n pub max_retries: u32,\n\n #[serde(default = \"crate::defaults::default_retry_backoff_ms\")]\n pub retry_backoff_ms: u64,\n\n #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n pub extra_headers: HashMap<String, String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub extra_body: Option<serde_json::Value>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub model: Option<String>,\n\n #[serde(default)]\n pub rate_limit_per_minute: u32,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub organization: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub project: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub proxy: Option<String>,\n}\n\nimpl Default for ProviderConfig {\n fn default() -> Self {\n Self {\n api_key: None,\n base_url: None,\n timeout_ms: crate::defaults::default_timeout_ms(),\n connect_timeout_ms: crate::defaults::default_connect_timeout_ms(),\n max_retries: crate::defaults::default_max_retries(),\n retry_backoff_ms: crate::defaults::default_retry_backoff_ms(),\n extra_headers: HashMap::new(),\n extra_body: None,\n model: None,\n rate_limit_per_minute: 0,\n organization: None,\n project: None,\n proxy: None,\n }\n }\n}\n\nimpl ProviderConfig {\n pub fn new(api_key: impl Into<String>) -> Self {\n Self {\n api_key: Some(api_key.into()),\n base_url: None,\n timeout_ms: crate::defaults::default_timeout_ms(),\n connect_timeout_ms: crate::defaults::default_connect_timeout_ms(),\n max_retries: crate::defaults::default_max_retries(),\n retry_backoff_ms: crate::defaults::default_retry_backoff_ms(),\n extra_headers: HashMap::new(),\n extra_body: None,\n model: None,\n rate_limit_per_minute: 0,\n organization: None,\n project: None,\n proxy: None,\n }\n }\n\n pub fn with_base_url(mut self, url: impl Into<String>) -> Self {\n self.base_url = Some(url.into());\n self\n }\n\n pub fn with_timeout_ms(mut self, ms: u64) -> Self {\n self.timeout_ms = ms;\n self\n }\n\n pub fn with_connect_timeout_ms(mut self, ms: u64) -> Self {\n self.connect_timeout_ms = ms;\n self\n }\n\n pub fn with_max_retries(mut self, retries: u32) -> Self {\n self.max_retries = retries;\n self\n }\n\n pub fn with_retry_backoff_ms(mut self, ms: u64) -> Self {\n self.retry_backoff_ms = ms;\n self\n }\n\n pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {\n self.extra_headers.insert(key.into(), value.into());\n self\n }\n\n pub fn with_extra_body(mut self, body: serde_json::Value) -> Self {\n self.extra_body = Some(body);\n self\n }\n\n pub fn with_model(mut self, model: impl Into<String>) -> Self {\n self.model = Some(model.into());\n self\n }\n\n pub fn with_rate_limit(mut self, rpm: u32) -> Self {\n self.rate_limit_per_minute = rpm;\n self\n }\n\n pub fn with_organization(mut self, org: impl Into<String>) -> Self {\n self.organization = Some(org.into());\n self\n }\n\n pub fn with_project(mut self, project: impl Into<String>) -> Self {\n self.project = Some(project.into());\n self\n }\n\n pub fn with_proxy(mut self, proxy: impl Into<String>) -> Self {\n self.proxy = Some(proxy.into());\n self\n }\n\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider timeout_ms must be greater than 0\".into(),\n ));\n }\n if self.connect_timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider connect_timeout_ms must be greater than 0\".into(),\n ));\n }\n Ok(())\n }\n\n pub fn resolve_api_key(&self, provider_name: &str) -> Option<String> {\n let env_key = format!(\"{}_API_KEY\", provider_name.to_uppercase());\n std::env::var(&env_key)\n .ok()\n .filter(|k| !k.is_empty())\n .or_else(|| {\n let name_lower = provider_name.to_lowercase();\n if name_lower == \"openai\" || name_lower == \"openrouter\" {\n std::env::var(\"OPENAI_API_KEY\").ok().filter(|k| !k.is_empty())\n } else {\n None\n }\n })\n .or_else(|| KeyStore::load().get(provider_name))\n .or_else(|| self.api_key.clone().filter(|k| !k.is_empty()))\n }\n\n pub fn resolve_base_url(&self, provider_name: &str) -> Option<String> {\n if let Some(url) = &self.base_url {\n if !url.is_empty() {\n return Some(url.clone());\n }\n }\n let env_key = format!(\"{}_BASE_URL\", provider_name.to_uppercase());\n std::env::var(env_key).ok()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_provider_config_builder() {\n let cfg = ProviderConfig::new(\"sk-test\")\n .with_base_url(\"https://example.com\")\n .with_timeout_ms(120_000)\n .with_max_retries(5)\n .with_retry_backoff_ms(2_000)\n .with_header(\"X-Custom\", \"value\")\n .with_extra_body(serde_json::json!({\"custom_field\": true}));\n\n assert_eq!(cfg.api_key, Some(\"sk-test\".to_string()));\n assert_eq!(cfg.base_url.as_deref(), Some(\"https://example.com\"));\n assert_eq!(cfg.timeout_ms, 120_000);\n assert_eq!(cfg.max_retries, 5);\n assert_eq!(cfg.retry_backoff_ms, 2_000);\n assert_eq!(\n cfg.extra_headers.get(\"X-Custom\"),\n Some(&\"value\".to_string())\n );\n assert!(cfg.extra_body.is_some());\n }\n\n #[test]\n fn test_provider_config_validate_empty_key() {\n let cfg = ProviderConfig::new(\"\");\n assert!(cfg.validate().is_ok());\n }\n\n #[test]\n fn test_provider_config_validate_zero_timeout() {\n let cfg = ProviderConfig::new(\"key\").with_timeout_ms(0);\n assert!(cfg.validate().is_err());\n }\n}\n"}, "ea266dbf8bb862f2318b5de517dabdb6ef678592": {"t": 1785757091.1985102, "n": 7525, "h": "9c9d36421ab49864", "c": "//! LLM 提供商连接配置。\n//!\n//! 每个 LLM 后端(OpenRouter、LongCat 等)的连接参数,\n//! 包括基础 URL、超时、重试策略等。\n//!\n//! ⚠️ API 密钥不在此处配置。请使用:\n//! - 环境变量(`DEEPSEEK_API_KEY` 等)\n//! - [`KeyStore`](crate::keystore::KeyStore)(`~/.local/share/tianji/keys.json`)\n\nuse std::collections::HashMap;\n\nuse serde::{Deserialize, Serialize};\n\nuse tianji_core::error::TianjiError;\n\nuse crate::keystore::KeyStore;\n\n/// LLM 提供商连接配置。\n///\n/// 支持自定义基础 URL(可用于 OpenAI 兼容 API)、超时时间、\n/// 重试策略和额外的 HTTP 头/请求体。\n///\n/// ⚠️ API 密钥不再从 config.yml 读取。\n/// 使用环境变量 `{NAME}_API_KEY` 或 `~/.local/share/tianji/keys.json`。\n///\n/// 通过 `ProviderConfig::new(\"sk-...\")` 创建(仅用于测试),\n/// 然后使用方法链配置其他参数。\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProviderConfig {\n /// API 密钥(仅程序内使用,不从 YAML 读取)。\n #[serde(skip)]\n pub api_key: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub base_url: Option<String>,\n\n #[serde(default = \"crate::defaults::default_timeout_ms\")]\n pub timeout_ms: u64,\n\n #[serde(default = \"crate::defaults::default_connect_timeout_ms\")]\n pub connect_timeout_ms: u64,\n\n #[serde(default = \"crate::defaults::default_max_retries\")]\n pub max_retries: u32,\n\n #[serde(default = \"crate::defaults::default_retry_backoff_ms\")]\n pub retry_backoff_ms: u64,\n\n #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n pub extra_headers: HashMap<String, String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub extra_body: Option<serde_json::Value>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub model: Option<String>,\n\n #[serde(default)]\n pub rate_limit_per_minute: u32,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub organization: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub project: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub proxy: Option<String>,\n}\n\nimpl Default for ProviderConfig {\n fn default() -> Self {\n Self {\n api_key: None,\n base_url: None,\n timeout_ms: crate::defaults::default_timeout_ms(),\n connect_timeout_ms: crate::defaults::default_connect_timeout_ms(),\n max_retries: crate::defaults::default_max_retries(),\n retry_backoff_ms: crate::defaults::default_retry_backoff_ms(),\n extra_headers: HashMap::new(),\n extra_body: None,\n model: None,\n rate_limit_per_minute: 0,\n organization: None,\n project: None,\n proxy: None,\n }\n }\n}\n\nimpl ProviderConfig {\n pub fn new(api_key: impl Into<String>) -> Self {\n Self {\n api_key: Some(api_key.into()),\n base_url: None,\n timeout_ms: crate::defaults::default_timeout_ms(),\n connect_timeout_ms: crate::defaults::default_connect_timeout_ms(),\n max_retries: crate::defaults::default_max_retries(),\n retry_backoff_ms: crate::defaults::default_retry_backoff_ms(),\n extra_headers: HashMap::new(),\n extra_body: None,\n model: None,\n rate_limit_per_minute: 0,\n organization: None,\n project: None,\n proxy: None,\n }\n }\n\n pub fn with_base_url(mut self, url: impl Into<String>) -> Self {\n self.base_url = Some(url.into());\n self\n }\n\n pub fn with_timeout_ms(mut self, ms: u64) -> Self {\n self.timeout_ms = ms;\n self\n }\n\n pub fn with_connect_timeout_ms(mut self, ms: u64) -> Self {\n self.connect_timeout_ms = ms;\n self\n }\n\n pub fn with_max_retries(mut self, retries: u32) -> Self {\n self.max_retries = retries;\n self\n }\n\n pub fn with_retry_backoff_ms(mut self, ms: u64) -> Self {\n self.retry_backoff_ms = ms;\n self\n }\n\n pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {\n self.extra_headers.insert(key.into(), value.into());\n self\n }\n\n pub fn with_extra_body(mut self, body: serde_json::Value) -> Self {\n self.extra_body = Some(body);\n self\n }\n\n pub fn with_model(mut self, model: impl Into<String>) -> Self {\n self.model = Some(model.into());\n self\n }\n\n pub fn with_rate_limit(mut self, rpm: u32) -> Self {\n self.rate_limit_per_minute = rpm;\n self\n }\n\n pub fn with_organization(mut self, org: impl Into<String>) -> Self {\n self.organization = Some(org.into());\n self\n }\n\n pub fn with_project(mut self, project: impl Into<String>) -> Self {\n self.project = Some(project.into());\n self\n }\n\n pub fn with_proxy(mut self, proxy: impl Into<String>) -> Self {\n self.proxy = Some(proxy.into());\n self\n }\n\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider timeout_ms must be greater than 0\".into(),\n ));\n }\n if self.connect_timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider connect_timeout_ms must be greater than 0\".into(),\n ));\n }\n Ok(())\n }\n\n pub fn resolve_api_key(&self, provider_name: &str) -> Option<String> {\n let env_key = format!(\"{}_API_KEY\", provider_name.to_uppercase());\n std::env::var(&env_key)\n .ok()\n .filter(|k| !k.is_empty())\n .or_else(|| {\n let name_lower = provider_name.to_lowercase();\n if name_lower == \"openai\" || name_lower == \"openrouter\" {\n std::env::var(\"OPENAI_API_KEY\").ok().filter(|k| !k.is_empty())\n } else {\n None\n }\n })\n .or_else(|| KeyStore::load().get(provider_name))\n .or_else(|| self.api_key.clone().filter(|k| !k.is_empty()))\n }\n\n pub fn resolve_base_url(&self, provider_name: &str) -> Option<String> {\n if let Some(url) = &self.base_url {\n if !url.is_empty() {\n return Some(url.clone());\n }\n }\n let env_key = format!(\"{}_BASE_URL\", provider_name.to_uppercase());\n std::env::var(env_key).ok()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_provider_config_builder() {\n let cfg = ProviderConfig::new(\"sk-test\")\n .with_base_url(\"https://example.com\")\n .with_timeout_ms(120_000)\n .with_max_retries(5)\n .with_retry_backoff_ms(2_000)\n .with_header(\"X-Custom\", \"value\")\n .with_extra_body(serde_json::json!({\"custom_field\": true}));\n\n assert_eq!(cfg.api_key, Some(\"sk-test\".to_string()));\n assert_eq!(cfg.base_url.as_deref(), Some(\"https://example.com\"));\n assert_eq!(cfg.timeout_ms, 120_000);\n assert_eq!(cfg.max_retries, 5);\n assert_eq!(cfg.retry_backoff_ms, 2_000);\n assert_eq!(\n cfg.extra_headers.get(\"X-Custom\"),\n Some(&\"value\".to_string())\n );\n assert!(cfg.extra_body.is_some());\n }\n\n #[test]\n fn test_provider_config_validate_empty_key() {\n let cfg = ProviderConfig::new(\"\");\n assert!(cfg.validate().is_ok());\n }\n\n #[test]\n fn test_provider_config_validate_zero_timeout() {\n let cfg = ProviderConfig::new(\"key\").with_timeout_ms(0);\n assert!(cfg.validate().is_err());\n }\n}\n"}, "4d594e1115e4155b6199ea4f58fa3e5bad22a7a3": {"t": 1785757092.6450438, "n": 7453, "h": "3d6c94b2049358e8", "c": "//! LLM 提供商连接配置。\n//!\n//! 每个 LLM 后端(OpenRouter、LongCat 等)的连接参数,\n//! 包括基础 URL、超时、重试策略等。\n//!\n//! ⚠️ API 密钥不在此处配置。请使用:\n//! - 环境变量(`DEEPSEEK_API_KEY` 等)\n//! - [`KeyStore`](crate::keystore::KeyStore)(`~/.local/share/tianji/keys.json`)\n\nuse std::collections::HashMap;\n\nuse serde::{Deserialize, Serialize};\n\nuse tianji_core::error::TianjiError;\n\nuse crate::keystore::KeyStore;\n\n/// LLM 提供商连接配置。\n///\n/// 支持自定义基础 URL(可用于 OpenAI 兼容 API)、超时时间、\n/// 重试策略和额外的 HTTP 头/请求体。\n///\n/// ⚠️ API 密钥不再从 config.yml 读取。\n/// 使用环境变量 `{NAME}_API_KEY` 或 `~/.local/share/tianji/keys.json`。\n///\n/// 通过 `ProviderConfig::new(\"sk-...\")` 创建(仅用于测试),\n/// 然后使用方法链配置其他参数。\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProviderConfig {\n /// API 密钥(仅程序内使用,不从 YAML 读取)。\n #[serde(skip)]\n pub api_key: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub base_url: Option<String>,\n\n #[serde(default = \"crate::defaults::default_timeout_ms\")]\n pub timeout_ms: u64,\n\n #[serde(default = \"crate::defaults::default_connect_timeout_ms\")]\n pub connect_timeout_ms: u64,\n\n #[serde(default = \"crate::defaults::default_max_retries\")]\n pub max_retries: u32,\n\n #[serde(default = \"crate::defaults::default_retry_backoff_ms\")]\n pub retry_backoff_ms: u64,\n\n #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n pub extra_headers: HashMap<String, String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub extra_body: Option<serde_json::Value>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub model: Option<String>,\n\n #[serde(default)]\n pub rate_limit_per_minute: u32,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub organization: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub project: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub proxy: Option<String>,\n}\n\nimpl Default for ProviderConfig {\n fn default() -> Self {\n Self {\n api_key: None,\n base_url: None,\n timeout_ms: crate::defaults::default_timeout_ms(),\n connect_timeout_ms: crate::defaults::default_connect_timeout_ms(),\n max_retries: crate::defaults::default_max_retries(),\n retry_backoff_ms: crate::defaults::default_retry_backoff_ms(),\n extra_headers: HashMap::new(),\n extra_body: None,\n model: None,\n rate_limit_per_minute: 0,\n organization: None,\n project: None,\n proxy: None,\n }\n }\n}\n\nimpl ProviderConfig {\n pub fn new(api_key: impl Into<String>) -> Self {\n Self {\n api_key: Some(api_key.into()),\n base_url: None,\n timeout_ms: crate::defaults::default_timeout_ms(),\n connect_timeout_ms: crate::defaults::default_connect_timeout_ms(),\n max_retries: crate::defaults::default_max_retries(),\n retry_backoff_ms: crate::defaults::default_retry_backoff_ms(),\n extra_headers: HashMap::new(),\n extra_body: None,\n model: None,\n rate_limit_per_minute: 0,\n organization: None,\n project: None,\n proxy: None,\n }\n }\n\n pub fn with_base_url(mut self, url: impl Into<String>) -> Self {\n self.base_url = Some(url.into());\n self\n }\n\n pub fn with_timeout_ms(mut self, ms: u64) -> Self {\n self.timeout_ms = ms;\n self\n }\n\n pub fn with_connect_timeout_ms(mut self, ms: u64) -> Self {\n self.connect_timeout_ms = ms;\n self\n }\n\n pub fn with_max_retries(mut self, retries: u32) -> Self {\n self.max_retries = retries;\n self\n }\n\n pub fn with_retry_backoff_ms(mut self, ms: u64) -> Self {\n self.retry_backoff_ms = ms;\n self\n }\n\n pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {\n self.extra_headers.insert(key.into(), value.into());\n self\n }\n\n pub fn with_extra_body(mut self, body: serde_json::Value) -> Self {\n self.extra_body = Some(body);\n self\n }\n\n pub fn with_model(mut self, model: impl Into<String>) -> Self {\n self.model = Some(model.into());\n self\n }\n\n pub fn with_rate_limit(mut self, rpm: u32) -> Self {\n self.rate_limit_per_minute = rpm;\n self\n }\n\n pub fn with_organization(mut self, org: impl Into<String>) -> Self {\n self.organization = Some(org.into());\n self\n }\n\n pub fn with_project(mut self, project: impl Into<String>) -> Self {\n self.project = Some(project.into());\n self\n }\n\n pub fn with_proxy(mut self, proxy: impl Into<String>) -> Self {\n self.proxy = Some(proxy.into());\n self\n }\n\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider timeout_ms must be greater than 0\".into(),\n ));\n }\n if self.connect_timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider connect_timeout_ms must be greater than 0\".into(),\n ));\n }\n Ok(())\n }\n\n pub fn resolve_api_key(&self, provider_name: &str) -> Option<String> {\n let env_key = format!(\"{}_API_KEY\", provider_name.to_uppercase());\n std::env::var(&env_key)\n .ok()\n .filter(|k| !k.is_empty())\n .or_else(|| {\n let name_lower = provider_name.to_lowercase();\n if name_lower == \"openai\" || name_lower == \"openrouter\" {\n std::env::var(\"OPENAI_API_KEY\").ok().filter(|k| !k.is_empty())\n } else {\n None\n }\n })\n .or_else(|| KeyStore::load().get(provider_name))\n }\n\n pub fn resolve_base_url(&self, provider_name: &str) -> Option<String> {\n if let Some(url) = &self.base_url {\n if !url.is_empty() {\n return Some(url.clone());\n }\n }\n let env_key = format!(\"{}_BASE_URL\", provider_name.to_uppercase());\n std::env::var(env_key).ok()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_provider_config_builder() {\n let cfg = ProviderConfig::new(\"sk-test\")\n .with_base_url(\"https://example.com\")\n .with_timeout_ms(120_000)\n .with_max_retries(5)\n .with_retry_backoff_ms(2_000)\n .with_header(\"X-Custom\", \"value\")\n .with_extra_body(serde_json::json!({\"custom_field\": true}));\n\n assert_eq!(cfg.api_key, Some(\"sk-test\".to_string()));\n assert_eq!(cfg.base_url.as_deref(), Some(\"https://example.com\"));\n assert_eq!(cfg.timeout_ms, 120_000);\n assert_eq!(cfg.max_retries, 5);\n assert_eq!(cfg.retry_backoff_ms, 2_000);\n assert_eq!(\n cfg.extra_headers.get(\"X-Custom\"),\n Some(&\"value\".to_string())\n );\n assert!(cfg.extra_body.is_some());\n }\n\n #[test]\n fn test_provider_config_validate_empty_key() {\n let cfg = ProviderConfig::new(\"\");\n assert!(cfg.validate().is_ok());\n }\n\n #[test]\n fn test_provider_config_validate_zero_timeout() {\n let cfg = ProviderConfig::new(\"key\").with_timeout_ms(0);\n assert!(cfg.validate().is_err());\n }\n}\n"}, "1291f819de90fd9c32192a3afa93ee45943582a2": {"t": 1785757094.0310502, "n": 7317, "h": "24f2d2a9ff0842e1", "c": "//! LLM 提供商连接配置。\n//!\n//! 每个 LLM 后端(OpenRouter、LongCat 等)的连接参数,\n//! 包括 API 密钥、基础 URL、超时和重试策略。\n\nuse std::collections::HashMap;\n\nuse serde::{Deserialize, Serialize};\n\nuse tianji_core::error::TianjiError;\n\nuse crate::keystore::KeyStore;\n\n/// LLM 提供商连接配置。\n///\n/// 支持自定义基础 URL(可用于 OpenAI 兼容 API)、超时时间、\n/// 重试策略和额外的 HTTP 头/请求体。\n///\n/// ⚠️ API 密钥不再从 config.yml 读取。\n/// 使用环境变量 `{NAME}_API_KEY` 或 `~/.local/share/tianji/keys.json`。\n///\n/// 通过 `ProviderConfig::new(\"sk-...\")` 创建(仅用于测试),\n/// 然后使用方法链配置其他参数。\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProviderConfig {\n /// API 密钥(仅程序内使用,不从 YAML 读取)。\n #[serde(skip)]\n pub api_key: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub base_url: Option<String>,\n\n #[serde(default = \"crate::defaults::default_timeout_ms\")]\n pub timeout_ms: u64,\n\n #[serde(default = \"crate::defaults::default_connect_timeout_ms\")]\n pub connect_timeout_ms: u64,\n\n #[serde(default = \"crate::defaults::default_max_retries\")]\n pub max_retries: u32,\n\n #[serde(default = \"crate::defaults::default_retry_backoff_ms\")]\n pub retry_backoff_ms: u64,\n\n #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n pub extra_headers: HashMap<String, String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub extra_body: Option<serde_json::Value>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub model: Option<String>,\n\n #[serde(default)]\n pub rate_limit_per_minute: u32,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub organization: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub project: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub proxy: Option<String>,\n}\n\nimpl Default for ProviderConfig {\n fn default() -> Self {\n Self {\n api_key: None,\n base_url: None,\n timeout_ms: crate::defaults::default_timeout_ms(),\n connect_timeout_ms: crate::defaults::default_connect_timeout_ms(),\n max_retries: crate::defaults::default_max_retries(),\n retry_backoff_ms: crate::defaults::default_retry_backoff_ms(),\n extra_headers: HashMap::new(),\n extra_body: None,\n model: None,\n rate_limit_per_minute: 0,\n organization: None,\n project: None,\n proxy: None,\n }\n }\n}\n\nimpl ProviderConfig {\n pub fn new(api_key: impl Into<String>) -> Self {\n Self {\n api_key: Some(api_key.into()),\n base_url: None,\n timeout_ms: crate::defaults::default_timeout_ms(),\n connect_timeout_ms: crate::defaults::default_connect_timeout_ms(),\n max_retries: crate::defaults::default_max_retries(),\n retry_backoff_ms: crate::defaults::default_retry_backoff_ms(),\n extra_headers: HashMap::new(),\n extra_body: None,\n model: None,\n rate_limit_per_minute: 0,\n organization: None,\n project: None,\n proxy: None,\n }\n }\n\n pub fn with_base_url(mut self, url: impl Into<String>) -> Self {\n self.base_url = Some(url.into());\n self\n }\n\n pub fn with_timeout_ms(mut self, ms: u64) -> Self {\n self.timeout_ms = ms;\n self\n }\n\n pub fn with_connect_timeout_ms(mut self, ms: u64) -> Self {\n self.connect_timeout_ms = ms;\n self\n }\n\n pub fn with_max_retries(mut self, retries: u32) -> Self {\n self.max_retries = retries;\n self\n }\n\n pub fn with_retry_backoff_ms(mut self, ms: u64) -> Self {\n self.retry_backoff_ms = ms;\n self\n }\n\n pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {\n self.extra_headers.insert(key.into(), value.into());\n self\n }\n\n pub fn with_extra_body(mut self, body: serde_json::Value) -> Self {\n self.extra_body = Some(body);\n self\n }\n\n pub fn with_model(mut self, model: impl Into<String>) -> Self {\n self.model = Some(model.into());\n self\n }\n\n pub fn with_rate_limit(mut self, rpm: u32) -> Self {\n self.rate_limit_per_minute = rpm;\n self\n }\n\n pub fn with_organization(mut self, org: impl Into<String>) -> Self {\n self.organization = Some(org.into());\n self\n }\n\n pub fn with_project(mut self, project: impl Into<String>) -> Self {\n self.project = Some(project.into());\n self\n }\n\n pub fn with_proxy(mut self, proxy: impl Into<String>) -> Self {\n self.proxy = Some(proxy.into());\n self\n }\n\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider timeout_ms must be greater than 0\".into(),\n ));\n }\n if self.connect_timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider connect_timeout_ms must be greater than 0\".into(),\n ));\n }\n Ok(())\n }\n\n pub fn resolve_api_key(&self, provider_name: &str) -> Option<String> {\n let env_key = format!(\"{}_API_KEY\", provider_name.to_uppercase());\n std::env::var(&env_key)\n .ok()\n .filter(|k| !k.is_empty())\n .or_else(|| {\n let name_lower = provider_name.to_lowercase();\n if name_lower == \"openai\" || name_lower == \"openrouter\" {\n std::env::var(\"OPENAI_API_KEY\").ok().filter(|k| !k.is_empty())\n } else {\n None\n }\n })\n .or_else(|| KeyStore::load().get(provider_name))\n }\n\n pub fn resolve_base_url(&self, provider_name: &str) -> Option<String> {\n if let Some(url) = &self.base_url {\n if !url.is_empty() {\n return Some(url.clone());\n }\n }\n let env_key = format!(\"{}_BASE_URL\", provider_name.to_uppercase());\n std::env::var(env_key).ok()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_provider_config_builder() {\n let cfg = ProviderConfig::new(\"sk-test\")\n .with_base_url(\"https://example.com\")\n .with_timeout_ms(120_000)\n .with_max_retries(5)\n .with_retry_backoff_ms(2_000)\n .with_header(\"X-Custom\", \"value\")\n .with_extra_body(serde_json::json!({\"custom_field\": true}));\n\n assert_eq!(cfg.api_key, Some(\"sk-test\".to_string()));\n assert_eq!(cfg.base_url.as_deref(), Some(\"https://example.com\"));\n assert_eq!(cfg.timeout_ms, 120_000);\n assert_eq!(cfg.max_retries, 5);\n assert_eq!(cfg.retry_backoff_ms, 2_000);\n assert_eq!(\n cfg.extra_headers.get(\"X-Custom\"),\n Some(&\"value\".to_string())\n );\n assert!(cfg.extra_body.is_some());\n }\n\n #[test]\n fn test_provider_config_validate_empty_key() {\n let cfg = ProviderConfig::new(\"\");\n assert!(cfg.validate().is_ok());\n }\n\n #[test]\n fn test_provider_config_validate_zero_timeout() {\n let cfg = ProviderConfig::new(\"key\").with_timeout_ms(0);\n assert!(cfg.validate().is_err());\n }\n}\n"}, "d5bf69d974b8b2ade78c0ddfb2a775b8b84901d5": {"t": 1785757095.5130692, "n": 6835, "h": "3688f03a39bb48a4", "c": "//! LLM 提供商连接配置。\n//!\n//! 每个 LLM 后端(OpenRouter、LongCat 等)的连接参数,\n//! 包括 API 密钥、基础 URL、超时和重试策略。\n\nuse std::collections::HashMap;\n\nuse serde::{Deserialize, Serialize};\n\nuse tianji_core::error::TianjiError;\n\nuse crate::keystore::KeyStore;\n\n/// LLM 提供商连接配置。\n///\n/// 支持自定义基础 URL(可用于 OpenAI 兼容 API)、超时时间、\n/// 重试策略和额外的 HTTP 头/请求体。\n///\n/// 通过 `ProviderConfig::new(\"sk-...\")` 创建,然后使用方法链配置其他参数。\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProviderConfig {\n #[serde(default, skip_serializing)]\n pub api_key: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub base_url: Option<String>,\n\n #[serde(default = \"crate::defaults::default_timeout_ms\")]\n pub timeout_ms: u64,\n\n #[serde(default = \"crate::defaults::default_connect_timeout_ms\")]\n pub connect_timeout_ms: u64,\n\n #[serde(default = \"crate::defaults::default_max_retries\")]\n pub max_retries: u32,\n\n #[serde(default = \"crate::defaults::default_retry_backoff_ms\")]\n pub retry_backoff_ms: u64,\n\n #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n pub extra_headers: HashMap<String, String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub extra_body: Option<serde_json::Value>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub model: Option<String>,\n\n #[serde(default)]\n pub rate_limit_per_minute: u32,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub organization: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub project: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub proxy: Option<String>,\n}\n\nimpl ProviderConfig {\n pub fn new(api_key: impl Into<String>) -> Self {\n Self {\n api_key: Some(api_key.into()),\n base_url: None,\n timeout_ms: crate::defaults::default_timeout_ms(),\n connect_timeout_ms: crate::defaults::default_connect_timeout_ms(),\n max_retries: crate::defaults::default_max_retries(),\n retry_backoff_ms: crate::defaults::default_retry_backoff_ms(),\n extra_headers: HashMap::new(),\n extra_body: None,\n model: None,\n rate_limit_per_minute: 0,\n organization: None,\n project: None,\n proxy: None,\n }\n }\n\n pub fn with_base_url(mut self, url: impl Into<String>) -> Self {\n self.base_url = Some(url.into());\n self\n }\n\n pub fn with_timeout_ms(mut self, ms: u64) -> Self {\n self.timeout_ms = ms;\n self\n }\n\n pub fn with_connect_timeout_ms(mut self, ms: u64) -> Self {\n self.connect_timeout_ms = ms;\n self\n }\n\n pub fn with_max_retries(mut self, retries: u32) -> Self {\n self.max_retries = retries;\n self\n }\n\n pub fn with_retry_backoff_ms(mut self, ms: u64) -> Self {\n self.retry_backoff_ms = ms;\n self\n }\n\n pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {\n self.extra_headers.insert(key.into(), value.into());\n self\n }\n\n pub fn with_extra_body(mut self, body: serde_json::Value) -> Self {\n self.extra_body = Some(body);\n self\n }\n\n pub fn with_model(mut self, model: impl Into<String>) -> Self {\n self.model = Some(model.into());\n self\n }\n\n pub fn with_rate_limit(mut self, rpm: u32) -> Self {\n self.rate_limit_per_minute = rpm;\n self\n }\n\n pub fn with_organization(mut self, org: impl Into<String>) -> Self {\n self.organization = Some(org.into());\n self\n }\n\n pub fn with_project(mut self, project: impl Into<String>) -> Self {\n self.project = Some(project.into());\n self\n }\n\n pub fn with_proxy(mut self, proxy: impl Into<String>) -> Self {\n self.proxy = Some(proxy.into());\n self\n }\n\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider timeout_ms must be greater than 0\".into(),\n ));\n }\n if self.connect_timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider connect_timeout_ms must be greater than 0\".into(),\n ));\n }\n Ok(())\n }\n\n pub fn resolve_api_key(&self, provider_name: &str) -> Option<String> {\n // 1. Config file key\n if let Some(key) = &self.api_key {\n if !key.is_empty() {\n return Some(key.clone());\n }\n }\n // 2. Environment variable\n let env_key = format!(\"{}_API_KEY\", provider_name.to_uppercase());\n std::env::var(&env_key)\n .ok()\n .filter(|k| !k.is_empty())\n .or_else(|| {\n let name_lower = provider_name.to_lowercase();\n if name_lower == \"openai\" || name_lower == \"openrouter\" {\n std::env::var(\"OPENAI_API_KEY\").ok().filter(|k| !k.is_empty())\n } else {\n None\n }\n })\n // 3. KeyStore (~/.local/share/tianji/keys.toml)\n .or_else(|| {\n KeyStore::load().get(provider_name)\n })\n }\n\n pub fn resolve_base_url(&self, provider_name: &str) -> Option<String> {\n if let Some(url) = &self.base_url {\n if !url.is_empty() {\n return Some(url.clone());\n }\n }\n let env_key = format!(\"{}_BASE_URL\", provider_name.to_uppercase());\n std::env::var(env_key).ok()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_provider_config_builder() {\n let cfg = ProviderConfig::new(\"sk-test\")\n .with_base_url(\"https://example.com\")\n .with_timeout_ms(120_000)\n .with_max_retries(5)\n .with_retry_backoff_ms(2_000)\n .with_header(\"X-Custom\", \"value\")\n .with_extra_body(serde_json::json!({\"custom_field\": true}));\n\n assert_eq!(cfg.api_key, Some(\"sk-test\".to_string()));\n assert_eq!(cfg.base_url.as_deref(), Some(\"https://example.com\"));\n assert_eq!(cfg.timeout_ms, 120_000);\n assert_eq!(cfg.max_retries, 5);\n assert_eq!(cfg.retry_backoff_ms, 2_000);\n assert_eq!(\n cfg.extra_headers.get(\"X-Custom\"),\n Some(&\"value\".to_string())\n );\n assert!(cfg.extra_body.is_some());\n }\n\n #[test]\n fn test_provider_config_validate_empty_key() {\n let cfg = ProviderConfig::new(\"\");\n assert!(cfg.validate().is_ok());\n }\n\n #[test]\n fn test_provider_config_validate_zero_timeout() {\n let cfg = ProviderConfig::new(\"key\").with_timeout_ms(0);\n assert!(cfg.validate().is_err());\n }\n}\n"}, "083345f887097e4dabc5433173180fcf8095d577": {"t": 1785757097.296484, "n": 6519, "h": "e62eaa77e70c1cca", "c": "//! LLM 提供商连接配置。\n//!\n//! 每个 LLM 后端(OpenRouter、LongCat 等)的连接参数,\n//! 包括 API 密钥、基础 URL、超时和重试策略。\n\nuse std::collections::HashMap;\n\nuse serde::{Deserialize, Serialize};\n\nuse tianji_core::error::TianjiError;\n\n/// LLM 提供商连接配置。\n///\n/// 支持自定义基础 URL(可用于 OpenAI 兼容 API)、超时时间、\n/// 重试策略和额外的 HTTP 头/请求体。\n///\n/// 通过 `ProviderConfig::new(\"sk-...\")` 创建,然后使用方法链配置其他参数。\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProviderConfig {\n #[serde(default, skip_serializing)]\n pub api_key: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub base_url: Option<String>,\n\n #[serde(default = \"crate::defaults::default_timeout_ms\")]\n pub timeout_ms: u64,\n\n #[serde(default = \"crate::defaults::default_connect_timeout_ms\")]\n pub connect_timeout_ms: u64,\n\n #[serde(default = \"crate::defaults::default_max_retries\")]\n pub max_retries: u32,\n\n #[serde(default = \"crate::defaults::default_retry_backoff_ms\")]\n pub retry_backoff_ms: u64,\n\n #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n pub extra_headers: HashMap<String, String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub extra_body: Option<serde_json::Value>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub model: Option<String>,\n\n #[serde(default)]\n pub rate_limit_per_minute: u32,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub organization: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub project: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub proxy: Option<String>,\n}\n\nimpl ProviderConfig {\n pub fn new(api_key: impl Into<String>) -> Self {\n Self {\n api_key: Some(api_key.into()),\n base_url: None,\n timeout_ms: crate::defaults::default_timeout_ms(),\n connect_timeout_ms: crate::defaults::default_connect_timeout_ms(),\n max_retries: crate::defaults::default_max_retries(),\n retry_backoff_ms: crate::defaults::default_retry_backoff_ms(),\n extra_headers: HashMap::new(),\n extra_body: None,\n model: None,\n rate_limit_per_minute: 0,\n organization: None,\n project: None,\n proxy: None,\n }\n }\n\n pub fn with_base_url(mut self, url: impl Into<String>) -> Self {\n self.base_url = Some(url.into());\n self\n }\n\n pub fn with_timeout_ms(mut self, ms: u64) -> Self {\n self.timeout_ms = ms;\n self\n }\n\n pub fn with_connect_timeout_ms(mut self, ms: u64) -> Self {\n self.connect_timeout_ms = ms;\n self\n }\n\n pub fn with_max_retries(mut self, retries: u32) -> Self {\n self.max_retries = retries;\n self\n }\n\n pub fn with_retry_backoff_ms(mut self, ms: u64) -> Self {\n self.retry_backoff_ms = ms;\n self\n }\n\n pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {\n self.extra_headers.insert(key.into(), value.into());\n self\n }\n\n pub fn with_extra_body(mut self, body: serde_json::Value) -> Self {\n self.extra_body = Some(body);\n self\n }\n\n pub fn with_model(mut self, model: impl Into<String>) -> Self {\n self.model = Some(model.into());\n self\n }\n\n pub fn with_rate_limit(mut self, rpm: u32) -> Self {\n self.rate_limit_per_minute = rpm;\n self\n }\n\n pub fn with_organization(mut self, org: impl Into<String>) -> Self {\n self.organization = Some(org.into());\n self\n }\n\n pub fn with_project(mut self, project: impl Into<String>) -> Self {\n self.project = Some(project.into());\n self\n }\n\n pub fn with_proxy(mut self, proxy: impl Into<String>) -> Self {\n self.proxy = Some(proxy.into());\n self\n }\n\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider timeout_ms must be greater than 0\".into(),\n ));\n }\n if self.connect_timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider connect_timeout_ms must be greater than 0\".into(),\n ));\n }\n Ok(())\n }\n\n pub fn resolve_api_key(&self, provider_name: &str) -> Option<String> {\n if let Some(key) = &self.api_key {\n if !key.is_empty() {\n return Some(key.clone());\n }\n }\n let env_key = format!(\"{}_API_KEY\", provider_name.to_uppercase());\n std::env::var(&env_key)\n .ok()\n .or_else(|| {\n let name_lower = provider_name.to_lowercase();\n if name_lower == \"openai\" || name_lower == \"openrouter\" {\n std::env::var(\"OPENAI_API_KEY\").ok()\n } else {\n None\n }\n })\n }\n\n pub fn resolve_base_url(&self, provider_name: &str) -> Option<String> {\n if let Some(url) = &self.base_url {\n if !url.is_empty() {\n return Some(url.clone());\n }\n }\n let env_key = format!(\"{}_BASE_URL\", provider_name.to_uppercase());\n std::env::var(env_key).ok()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_provider_config_builder() {\n let cfg = ProviderConfig::new(\"sk-test\")\n .with_base_url(\"https://example.com\")\n .with_timeout_ms(120_000)\n .with_max_retries(5)\n .with_retry_backoff_ms(2_000)\n .with_header(\"X-Custom\", \"value\")\n .with_extra_body(serde_json::json!({\"custom_field\": true}));\n\n assert_eq!(cfg.api_key, Some(\"sk-test\".to_string()));\n assert_eq!(cfg.base_url.as_deref(), Some(\"https://example.com\"));\n assert_eq!(cfg.timeout_ms, 120_000);\n assert_eq!(cfg.max_retries, 5);\n assert_eq!(cfg.retry_backoff_ms, 2_000);\n assert_eq!(\n cfg.extra_headers.get(\"X-Custom\"),\n Some(&\"value\".to_string())\n );\n assert!(cfg.extra_body.is_some());\n }\n\n #[test]\n fn test_provider_config_validate_empty_key() {\n let cfg = ProviderConfig::new(\"\");\n assert!(cfg.validate().is_ok());\n }\n\n #[test]\n fn test_provider_config_validate_zero_timeout() {\n let cfg = ProviderConfig::new(\"key\").with_timeout_ms(0);\n assert!(cfg.validate().is_err());\n }\n}\n"}, "6b29050ac23a97715b397985ccd910ea13f1131b": {"t": 1785757098.799556, "n": 5092, "h": "2ee375908edbe197", "c": "//! LLM 提供商连接配置。\n//!\n//! 每个 LLM 后端(OpenRouter、LongCat 等)的连接参数,\n//! 包括 API 密钥、基础 URL、超时和重试策略。\n\nuse std::collections::HashMap;\n\nuse serde::{Deserialize, Serialize};\n\nuse tianji_core::error::TianjiError;\n\n/// LLM 提供商连接配置。\n///\n/// 支持自定义基础 URL(可用于 OpenAI 兼容 API)、超时时间、\n/// 重试策略和额外的 HTTP 头/请求体。\n///\n/// 通过 `ProviderConfig::new(\"sk-...\")` 创建,然后使用方法链配置其他参数。\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProviderConfig {\n #[serde(default, skip_serializing)]\n pub api_key: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub base_url: Option<String>,\n\n #[serde(default = \"crate::defaults::default_timeout_ms\")]\n pub timeout_ms: u64,\n\n #[serde(default = \"crate::defaults::default_connect_timeout_ms\")]\n pub connect_timeout_ms: u64,\n\n #[serde(default = \"crate::defaults::default_max_retries\")]\n pub max_retries: u32,\n\n #[serde(default = \"crate::defaults::default_retry_backoff_ms\")]\n pub retry_backoff_ms: u64,\n\n #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n pub extra_headers: HashMap<String, String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub extra_body: Option<serde_json::Value>,\n}\n\nimpl ProviderConfig {\n pub fn new(api_key: impl Into<String>) -> Self {\n Self {\n api_key: Some(api_key.into()),\n base_url: None,\n timeout_ms: crate::defaults::default_timeout_ms(),\n connect_timeout_ms: crate::defaults::default_connect_timeout_ms(),\n max_retries: crate::defaults::default_max_retries(),\n retry_backoff_ms: crate::defaults::default_retry_backoff_ms(),\n extra_headers: HashMap::new(),\n extra_body: None,\n }\n }\n\n pub fn with_base_url(mut self, url: impl Into<String>) -> Self {\n self.base_url = Some(url.into());\n self\n }\n\n pub fn with_timeout_ms(mut self, ms: u64) -> Self {\n self.timeout_ms = ms;\n self\n }\n\n pub fn with_connect_timeout_ms(mut self, ms: u64) -> Self {\n self.connect_timeout_ms = ms;\n self\n }\n\n pub fn with_max_retries(mut self, retries: u32) -> Self {\n self.max_retries = retries;\n self\n }\n\n pub fn with_retry_backoff_ms(mut self, ms: u64) -> Self {\n self.retry_backoff_ms = ms;\n self\n }\n\n pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {\n self.extra_headers.insert(key.into(), value.into());\n self\n }\n\n pub fn with_extra_body(mut self, body: serde_json::Value) -> Self {\n self.extra_body = Some(body);\n self\n }\n\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider timeout_ms must be greater than 0\".into(),\n ));\n }\n if self.connect_timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider connect_timeout_ms must be greater than 0\".into(),\n ));\n }\n Ok(())\n }\n\n pub fn resolve_api_key(&self, provider_name: &str) -> Option<String> {\n if let Some(key) = &self.api_key {\n if !key.is_empty() {\n return Some(key.clone());\n }\n }\n let env_key = format!(\"{}_API_KEY\", provider_name.to_uppercase());\n std::env::var(&env_key)\n .ok()\n .or_else(|| std::env::var(\"OPENAI_API_KEY\").ok())\n }\n\n pub fn resolve_base_url(&self, provider_name: &str) -> Option<String> {\n if let Some(url) = &self.base_url {\n if !url.is_empty() {\n return Some(url.clone());\n }\n }\n self.base_url.clone().or_else(|| {\n let env_key = format!(\"{}_BASE_URL\", provider_name.to_uppercase());\n std::env::var(env_key).ok()\n })\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_provider_config_builder() {\n let cfg = ProviderConfig::new(\"sk-test\")\n .with_base_url(\"https://example.com\")\n .with_timeout_ms(120_000)\n .with_max_retries(5)\n .with_retry_backoff_ms(2_000)\n .with_header(\"X-Custom\", \"value\")\n .with_extra_body(serde_json::json!({\"custom_field\": true}));\n\n assert_eq!(cfg.api_key, Some(\"sk-test\".to_string()));\n assert_eq!(cfg.base_url.as_deref(), Some(\"https://example.com\"));\n assert_eq!(cfg.timeout_ms, 120_000);\n assert_eq!(cfg.max_retries, 5);\n assert_eq!(cfg.retry_backoff_ms, 2_000);\n assert_eq!(\n cfg.extra_headers.get(\"X-Custom\"),\n Some(&\"value\".to_string())\n );\n assert!(cfg.extra_body.is_some());\n }\n\n #[test]\n fn test_provider_config_validate_empty_key() {\n let cfg = ProviderConfig::new(\"\");\n assert!(cfg.validate().is_ok());\n }\n\n #[test]\n fn test_provider_config_validate_zero_timeout() {\n let cfg = ProviderConfig::new(\"key\").with_timeout_ms(0);\n assert!(cfg.validate().is_err());\n }\n}\n"}, "9622a8c6c64acbb34f1f700e1f27997746d3a5c8": {"t": 1785757100.4462223, "n": 5074, "h": "f2ff8807b26525db", "c": "//! LLM 提供商连接配置。\n//!\n//! 每个 LLM 后端(OpenRouter、LongCat 等)的连接参数,\n//! 包括 API 密钥、基础 URL、超时和重试策略。\n\nuse std::collections::HashMap;\n\nuse serde::{Deserialize, Serialize};\n\nuse tianji_core::error::TianjiError;\n\n/// LLM 提供商连接配置。\n///\n/// 支持自定义基础 URL(可用于 OpenAI 兼容 API)、超时时间、\n/// 重试策略和额外的 HTTP 头/请求体。\n///\n/// 通过 `ProviderConfig::new(\"sk-...\")` 创建,然后使用方法链配置其他参数。\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProviderConfig {\n #[serde(default)]\n pub api_key: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub base_url: Option<String>,\n\n #[serde(default = \"crate::defaults::default_timeout_ms\")]\n pub timeout_ms: u64,\n\n #[serde(default = \"crate::defaults::default_connect_timeout_ms\")]\n pub connect_timeout_ms: u64,\n\n #[serde(default = \"crate::defaults::default_max_retries\")]\n pub max_retries: u32,\n\n #[serde(default = \"crate::defaults::default_retry_backoff_ms\")]\n pub retry_backoff_ms: u64,\n\n #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n pub extra_headers: HashMap<String, String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub extra_body: Option<serde_json::Value>,\n}\n\nimpl ProviderConfig {\n pub fn new(api_key: impl Into<String>) -> Self {\n Self {\n api_key: Some(api_key.into()),\n base_url: None,\n timeout_ms: crate::defaults::default_timeout_ms(),\n connect_timeout_ms: crate::defaults::default_connect_timeout_ms(),\n max_retries: crate::defaults::default_max_retries(),\n retry_backoff_ms: crate::defaults::default_retry_backoff_ms(),\n extra_headers: HashMap::new(),\n extra_body: None,\n }\n }\n\n pub fn with_base_url(mut self, url: impl Into<String>) -> Self {\n self.base_url = Some(url.into());\n self\n }\n\n pub fn with_timeout_ms(mut self, ms: u64) -> Self {\n self.timeout_ms = ms;\n self\n }\n\n pub fn with_connect_timeout_ms(mut self, ms: u64) -> Self {\n self.connect_timeout_ms = ms;\n self\n }\n\n pub fn with_max_retries(mut self, retries: u32) -> Self {\n self.max_retries = retries;\n self\n }\n\n pub fn with_retry_backoff_ms(mut self, ms: u64) -> Self {\n self.retry_backoff_ms = ms;\n self\n }\n\n pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {\n self.extra_headers.insert(key.into(), value.into());\n self\n }\n\n pub fn with_extra_body(mut self, body: serde_json::Value) -> Self {\n self.extra_body = Some(body);\n self\n }\n\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider timeout_ms must be greater than 0\".into(),\n ));\n }\n if self.connect_timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider connect_timeout_ms must be greater than 0\".into(),\n ));\n }\n Ok(())\n }\n\n pub fn resolve_api_key(&self, provider_name: &str) -> Option<String> {\n if let Some(key) = &self.api_key {\n if !key.is_empty() {\n return Some(key.clone());\n }\n }\n let env_key = format!(\"{}_API_KEY\", provider_name.to_uppercase());\n std::env::var(&env_key)\n .ok()\n .or_else(|| std::env::var(\"OPENAI_API_KEY\").ok())\n }\n\n pub fn resolve_base_url(&self, provider_name: &str) -> Option<String> {\n if let Some(url) = &self.base_url {\n if !url.is_empty() {\n return Some(url.clone());\n }\n }\n self.base_url.clone().or_else(|| {\n let env_key = format!(\"{}_BASE_URL\", provider_name.to_uppercase());\n std::env::var(env_key).ok()\n })\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_provider_config_builder() {\n let cfg = ProviderConfig::new(\"sk-test\")\n .with_base_url(\"https://example.com\")\n .with_timeout_ms(120_000)\n .with_max_retries(5)\n .with_retry_backoff_ms(2_000)\n .with_header(\"X-Custom\", \"value\")\n .with_extra_body(serde_json::json!({\"custom_field\": true}));\n\n assert_eq!(cfg.api_key, Some(\"sk-test\".to_string()));\n assert_eq!(cfg.base_url.as_deref(), Some(\"https://example.com\"));\n assert_eq!(cfg.timeout_ms, 120_000);\n assert_eq!(cfg.max_retries, 5);\n assert_eq!(cfg.retry_backoff_ms, 2_000);\n assert_eq!(\n cfg.extra_headers.get(\"X-Custom\"),\n Some(&\"value\".to_string())\n );\n assert!(cfg.extra_body.is_some());\n }\n\n #[test]\n fn test_provider_config_validate_empty_key() {\n let cfg = ProviderConfig::new(\"\");\n assert!(cfg.validate().is_ok());\n }\n\n #[test]\n fn test_provider_config_validate_zero_timeout() {\n let cfg = ProviderConfig::new(\"key\").with_timeout_ms(0);\n assert!(cfg.validate().is_err());\n }\n}\n"}, "24cf803ed6fcd86aaad7f730143ceed2a783dcfb": {"t": 1785757102.8443563, "n": 4832, "h": "5b12c589f3b4b828", "c": "use std::collections::HashMap;\n\nuse serde::{Deserialize, Serialize};\n\nuse tianji_core::error::TianjiError;\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProviderConfig {\n #[serde(default)]\n pub api_key: Option<String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub base_url: Option<String>,\n\n #[serde(default = \"crate::defaults::default_timeout_ms\")]\n pub timeout_ms: u64,\n\n #[serde(default = \"crate::defaults::default_connect_timeout_ms\")]\n pub connect_timeout_ms: u64,\n\n #[serde(default = \"crate::defaults::default_max_retries\")]\n pub max_retries: u32,\n\n #[serde(default = \"crate::defaults::default_retry_backoff_ms\")]\n pub retry_backoff_ms: u64,\n\n #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n pub extra_headers: HashMap<String, String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub extra_body: Option<serde_json::Value>,\n}\n\nimpl ProviderConfig {\n pub fn new(api_key: impl Into<String>) -> Self {\n Self {\n api_key: Some(api_key.into()),\n base_url: None,\n timeout_ms: crate::defaults::default_timeout_ms(),\n connect_timeout_ms: crate::defaults::default_connect_timeout_ms(),\n max_retries: crate::defaults::default_max_retries(),\n retry_backoff_ms: crate::defaults::default_retry_backoff_ms(),\n extra_headers: HashMap::new(),\n extra_body: None,\n }\n }\n\n pub fn with_base_url(mut self, url: impl Into<String>) -> Self {\n self.base_url = Some(url.into());\n self\n }\n\n pub fn with_timeout_ms(mut self, ms: u64) -> Self {\n self.timeout_ms = ms;\n self\n }\n\n pub fn with_connect_timeout_ms(mut self, ms: u64) -> Self {\n self.connect_timeout_ms = ms;\n self\n }\n\n pub fn with_max_retries(mut self, retries: u32) -> Self {\n self.max_retries = retries;\n self\n }\n\n pub fn with_retry_backoff_ms(mut self, ms: u64) -> Self {\n self.retry_backoff_ms = ms;\n self\n }\n\n pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {\n self.extra_headers.insert(key.into(), value.into());\n self\n }\n\n pub fn with_extra_body(mut self, body: serde_json::Value) -> Self {\n self.extra_body = Some(body);\n self\n }\n\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider timeout_ms must be greater than 0\".into(),\n ));\n }\n if self.connect_timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider connect_timeout_ms must be greater than 0\".into(),\n ));\n }\n Ok(())\n }\n\n pub fn resolve_api_key(&self, provider_name: &str) -> Option<String> {\n if let Some(key) = &self.api_key {\n if !key.is_empty() {\n return Some(key.clone());\n }\n }\n let env_key = format!(\"{}_API_KEY\", provider_name.to_uppercase());\n std::env::var(&env_key)\n .ok()\n .or_else(|| std::env::var(\"OPENAI_API_KEY\").ok())\n }\n\n pub fn resolve_base_url(&self, provider_name: &str) -> Option<String> {\n if let Some(url) = &self.base_url {\n if !url.is_empty() {\n return Some(url.clone());\n }\n }\n self.base_url.clone().or_else(|| {\n let env_key = format!(\"{}_BASE_URL\", provider_name.to_uppercase());\n std::env::var(env_key).ok()\n })\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_provider_config_builder() {\n let cfg = ProviderConfig::new(\"sk-test\")\n .with_base_url(\"https://example.com\")\n .with_timeout_ms(120_000)\n .with_max_retries(5)\n .with_retry_backoff_ms(2_000)\n .with_header(\"X-Custom\", \"value\")\n .with_extra_body(serde_json::json!({\"custom_field\": true}));\n\n assert_eq!(cfg.api_key, Some(\"sk-test\".to_string()));\n assert_eq!(cfg.base_url.as_deref(), Some(\"https://example.com\"));\n assert_eq!(cfg.timeout_ms, 120_000);\n assert_eq!(cfg.max_retries, 5);\n assert_eq!(cfg.retry_backoff_ms, 2_000);\n assert_eq!(\n cfg.extra_headers.get(\"X-Custom\"),\n Some(&\"value\".to_string())\n );\n assert!(cfg.extra_body.is_some());\n }\n\n #[test]\n fn test_provider_config_validate_empty_key() {\n let cfg = ProviderConfig::new(\"\");\n assert!(cfg.validate().is_ok());\n }\n\n #[test]\n fn test_provider_config_validate_zero_timeout() {\n let cfg = ProviderConfig::new(\"key\").with_timeout_ms(0);\n assert!(cfg.validate().is_err());\n }\n}\n"}, "5310083d8d4755ac99d6f100b117561ad8a38ddd": {"t": 1785757104.365494, "n": 4136, "h": "d305c2f98fd90638", "c": "use std::collections::HashMap;\n\nuse serde::{Deserialize, Serialize};\n\nuse tianji_core::error::TianjiError;\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProviderConfig {\n pub api_key: String,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub base_url: Option<String>,\n\n #[serde(default = \"crate::defaults::default_timeout_ms\")]\n pub timeout_ms: u64,\n\n #[serde(default = \"crate::defaults::default_connect_timeout_ms\")]\n pub connect_timeout_ms: u64,\n\n #[serde(default = \"crate::defaults::default_max_retries\")]\n pub max_retries: u32,\n\n #[serde(default = \"crate::defaults::default_retry_backoff_ms\")]\n pub retry_backoff_ms: u64,\n\n #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n pub extra_headers: HashMap<String, String>,\n\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub extra_body: Option<serde_json::Value>,\n}\n\nimpl ProviderConfig {\n pub fn new(api_key: impl Into<String>) -> Self {\n Self {\n api_key: api_key.into(),\n base_url: None,\n timeout_ms: crate::defaults::default_timeout_ms(),\n connect_timeout_ms: crate::defaults::default_connect_timeout_ms(),\n max_retries: crate::defaults::default_max_retries(),\n retry_backoff_ms: crate::defaults::default_retry_backoff_ms(),\n extra_headers: HashMap::new(),\n extra_body: None,\n }\n }\n\n pub fn with_base_url(mut self, url: impl Into<String>) -> Self {\n self.base_url = Some(url.into());\n self\n }\n\n pub fn with_timeout_ms(mut self, ms: u64) -> Self {\n self.timeout_ms = ms;\n self\n }\n\n pub fn with_connect_timeout_ms(mut self, ms: u64) -> Self {\n self.connect_timeout_ms = ms;\n self\n }\n\n pub fn with_max_retries(mut self, retries: u32) -> Self {\n self.max_retries = retries;\n self\n }\n\n pub fn with_retry_backoff_ms(mut self, ms: u64) -> Self {\n self.retry_backoff_ms = ms;\n self\n }\n\n pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {\n self.extra_headers.insert(key.into(), value.into());\n self\n }\n\n pub fn with_extra_body(mut self, body: serde_json::Value) -> Self {\n self.extra_body = Some(body);\n self\n }\n\n pub fn validate(&self) -> Result<(), TianjiError> {\n if self.api_key.is_empty() {\n return Err(TianjiError::Config(\n \"provider API key must not be empty\".into(),\n ));\n }\n if self.timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider timeout_ms must be greater than 0\".into(),\n ));\n }\n if self.connect_timeout_ms == 0 {\n return Err(TianjiError::Config(\n \"provider connect_timeout_ms must be greater than 0\".into(),\n ));\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_provider_config_builder() {\n let cfg = ProviderConfig::new(\"sk-test\")\n .with_base_url(\"https://example.com\")\n .with_timeout_ms(120_000)\n .with_max_retries(5)\n .with_retry_backoff_ms(2_000)\n .with_header(\"X-Custom\", \"value\")\n .with_extra_body(serde_json::json!({\"custom_field\": true}));\n\n assert_eq!(cfg.api_key, \"sk-test\");\n assert_eq!(cfg.base_url.as_deref(), Some(\"https://example.com\"));\n assert_eq!(cfg.timeout_ms, 120_000);\n assert_eq!(cfg.max_retries, 5);\n assert_eq!(cfg.retry_backoff_ms, 2_000);\n assert_eq!(\n cfg.extra_headers.get(\"X-Custom\"),\n Some(&\"value\".to_string())\n );\n assert!(cfg.extra_body.is_some());\n }\n\n #[test]\n fn test_provider_config_validate_empty_key() {\n let cfg = ProviderConfig::new(\"\");\n assert!(cfg.validate().is_err());\n }\n\n #[test]\n fn test_provider_config_validate_zero_timeout() {\n let cfg = ProviderConfig::new(\"key\").with_timeout_ms(0);\n assert!(cfg.validate().is_err());\n }\n}\n"}}}} |