Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/blake-john_tianji.json
T

1 line
202 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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"}}}}