{"repo": "blockcell-labs/blockcell", "files": {"docs/en/02_quickstart.md": {"9a5ff122a3f913415d87b52bf73321403e8f202e": {"t": 1785674078.2776966, "n": 8862, "h": "bb0e8594dadbca1a", "c": "# Article 02: Get Started with blockcell in 5 Minutes — From Installation to Your First Chat\n\n> Series: *In-Depth Analysis of the Open Source Project “blockcell”* — Article 2\n---\n\n## Preface\n\nIn the previous article, we introduced what blockcell is. In this one, we’ll get hands-on and have it running within 5 minutes.\n\n**What you need:**\n- A macOS or Linux computer (Windows is also supported; this article uses macOS as the example)\n- An LLM API key (OpenAI, DeepSeek, Kimi are all fine — we’ll cover how to choose)\n\n---\n\n## The fastest 5-minute path (follow these steps)\n\nIf you only want the quickest successful run, do these 4 steps:\n\n1. Install: run the installer script\n2. Configure: `blockcell setup` (interactive wizard; creates config and validates it)\n3. Start: `blockcell agent` and send a test message\n4. Start: `blockcell gateway`, then open `http://127.0.0.1:18791` for the WebUI\n\nThe rest of this article goes deeper (provider choices, multi-agent layout, common commands, FAQ, deployment suggestions).\n\n---\n\n## Step 1: Install\n\n### Option A: One-line install script (recommended)\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/blockcell-labs/blockcell/refs/heads/main/install.sh | sh\n```\n\nAfter installation, the `blockcell` command will be available under `~/.local/bin/`. If your shell can’t find it, add that path to your `PATH`:\n\n```bash\necho 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.zshrc\nsource ~/.zshrc\n```\n\n### Option B: Build from source\n\nIf you want to compile it yourself (requires Rust 1.85+):\n\n```bash\ngit clone https://github.com/blockcell-labs/blockcell.git\ncd blockcell/blockcell\ncargo build --release\ncp target/release/blockcell ~/.local/bin/\n```\n\n### Verify installation\n\n```bash\nblockcell --version\n# blockcell 0.x.x\n```\n\n---\n\n## Step 2: Configure (the `setup` wizard is recommended)\n\n```bash\nblockcell setup\n```\n\nThis command will:\n1. Create the `~/.blockcell/` directory structure\n2. Let you choose an LLM provider\n3. Save the API key and model\n4. Optionally configure one external channel\n5. Validate the saved provider configuration\n6. Auto-bind a newly configured external channel to the `default` agent when no owner is set yet\n7. If you later want one account / bot on the same channel to use a different agent, add `channelAccountOwners..`\n\nThe directory structure looks like this:\n\n```\n~/.blockcell/\n├── config.json5 # Main config\n├── sessions/ # Session history for the default agent\n├── audit/ # Audit logs for the default agent\n├── workspace/ # Default agent workspace\n│ ├── memory/ # Memory database\n│ ├── skills/ # User-installed skills\n│ ├── media/ # Screenshots, audio, etc.\n└── agents/ # Non-default agents (created on demand)\n └── ops/\n ├── sessions/\n ├── audit/\n └── workspace/\n```\n\n`default` keeps using the root `~/.blockcell/` layout. Additional agents live under `~/.blockcell/agents//`.\n\nIf you prefer the older manual flow, you can still run:\n\n```bash\nblockcell onboard\n```\n\n---\n\n## Step 3: Configure your API key\n\nIf you already completed `blockcell setup`, you can skip this step. Otherwise, open the config file:\n\n```bash\n# macOS\nopen ~/.blockcell/config.json5\n\n# Or use a terminal editor\nnano ~/.blockcell/config.json5\n```\n\nFind the `providers` section and fill in your API key.\n\n### Option A: DeepSeek (recommended for beginners)\n\n`blockcell setup --provider deepseek` currently defaults to `deepseek-v4-pro` and configures a 1M-token `maxContextTokens` value. You can still pass `--model deepseek-chat` explicitly if you want the older chat model.\n\n```json\n{\n \"providers\": {\n \"deepseek\": {\n \"apiKey\": \"sk-your-deepseek-key\",\n \"apiBase\": \"https://api.deepseek.com/v1\"\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"deepseek-v4-pro\",\n \"provider\": \"deepseek\",\n \"maxContextTokens\": 1048576,\n \"reasoningEffort\": \"high\"\n }\n }\n}\n```\n\n### Option B: Kimi/Moonshot (stable access in China)\n\n```json\n{\n \"providers\": {\n \"kimi\": {\n \"apiKey\": \"sk-your-kimi-key\",\n \"apiBase\": \"https://api.moonshot.cn/v1\"\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"kimi-k2.6\"\n }\n }\n}\n```\n\n### Option C: OpenRouter (one key for many models)\n\n```json\n{\n \"providers\": {\n \"openrouter\": {\n \"apiKey\": \"sk-or-your-openrouter-key\",\n \"apiBase\": \"https://openrouter.ai/api/v1\"\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"anthropic/claude-opus-4-8\"\n }\n }\n}\n```\n\n### Option D: Ollama (fully local, free)\n\nIf you already have Ollama installed and pulled a model:\n\n```json\n{\n \"providers\": {\n \"ollama\": {\n \"apiBase\": \"http://localhost:11434\"\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"ollama/qwen3.6\"\n }\n }\n}\n```\n\n---\n\n## Step 4: Check status\n\n```bash\nblockcell status\n```\n\nExample output:\n\n```\n✓ Config loaded\n✓ Provider: deepseek (deepseek-v4-pro)\n✓ Workspace: ~/.blockcell/workspace\n✓ Memory: SQLite (0 items)\n✓ Skills: 0 user skills, 44 builtin skills\n✓ Channels: none configured\n```\n\nIf you see a red ✗, the configuration has an issue — adjust it according to the hints.\n\n---\n\n## Step 5: Start chatting\n\n```bash\nblockcell agent\n```\n\nYou’ll see a welcome screen:\n\n```\n╔══════════════════════════════════════╗\n║ blockcell agent ║\n║ Type /tasks to see background tasks ║\n║ Type /quit to exit ║\n╚══════════════════════════════════════╝\n\nYou:\n```\n\nNow you can start chatting.\n\n---\n\n## Try these commands\n\n### Basic chat\n\n```\nYou: Hi, introduce yourself\n```\n\n### Ask the AI to search the web\n\n```\nYou: Search for today’s AI-related news\n```\n\nThe AI will call `web_search` and then use `web_fetch` to retrieve content.\n\n### Read local files\n\n```\nYou: Read ~/Desktop/report.txt and summarize the key points\n```\n\n> ⚠️ Note: when reading files outside the working directory (`~/.blockcell/workspace`), blockcell will prompt for confirmation and you must type `y`. This is a safety mechanism.\n\n### Run commands\n\n```\nYou: Show me the files in the current directory\n```\n\nThe AI will call the `exec` tool to run `ls`.\n\n### Write a file\n\n```\nYou: Create hello.txt in the working directory with content \"Hello from blockcell\"\n```\n\n---\n\n## Common CLI commands\n\nBesides interactive `agent` mode, blockcell provides many useful commands:\n\n```bash\n# List all available tools\nblockcell tools\n\n# View/manage memory\nblockcell memory list\nblockcell memory search \"stock\"\n\n# View/manage skills\nblockcell skills list\n\n# List scheduled tasks\nblockcell cron list\n\n# Check channel status\nblockcell channels status\n\n# View evolution records\nblockcell evolve list\n\n# View alert rules\nblockcell alerts list\n\n# View real-time streams\nblockcell streams list\n\n# View knowledge graph\nblockcell knowledge stats\n\n# View logs\nblockcell logs\n\n# Self diagnostics\nblockcell doctor\n```\n\n---\n\n## Full config field overview\n\nKey fields in `~/.blockcell/config.json5`:\n\n```json\n{\n \"providers\": {\n \"openai\": {\n \"apiKey\": \"sk-...\",\n \"apiBase\": \"https://api.openai.com/v1\"\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"gpt-5.5\",\n \"maxTokens\": 4096,\n \"temperature\": 0.7\n }\n },\n \"tools\": {\n \"tickIntervalSecs\": 30\n },\n \"gateway\": {\n \"host\": \"0.0.0.0\",\n \"port\": 18790,\n \"webuiPort\": 18791,\n \"apiToken\": \"optional access token\"\n },\n \"channelOwners\": {\n \"telegram\": \"default\"\n },\n \"channelAccountOwners\": {\n \"telegram\": {\n \"bot2\": \"ops\"\n }\n },\n \"channels\": {\n \"telegram\": {\n \"enabled\": true,\n \"token\": \"your bot token\",\n \"allowFrom\": [\"your user id\"]\n }\n }\n}\n```\n\n---\n\n## Running into issues?\n\n### Issue 1: Command not found\n\n```bash\nwhich blockcell\n# If there’s no output, your PATH is not configured correctly\necho 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.zshrc && source ~/.zshrc\n```\n\n### Issue 2: API calls fail\n\n```bash\nblockcell doctor\n# Checks network connectivity and API configuration\n```\n\n### Issue 3: Want to switch models\n\nEdit `agents.defaults.model` in `config.json5`, then restart `blockcell agent`.\n\n### Issue 4: Want to see which tools the AI used\n\nIn the chat, blockcell shows the tool name and parameters for each tool call. For more detailed logs:\n\n```bash\nblockcell logs --tail 50\n```\n\n---\n\n## Summary\n\nAt this point you have:\n- ✅ Installed blockcell\n- ✅ Configured an API key\n- ✅ Started your first chat\n- ✅ Learned the basic commands\n\nNext, we’ll dive into blockcell’s tool system — the heart of making AI actually “do work” with 50+ built-in tools.\n\n---\n\n*Previous: [What is blockcell? A self-evolving AI agent framework](./01_what_is_blockcell.md)*\n*Next: [blockcell’s tool system — enabling AI to really execute tasks](./03_tools_system.md)*\n\n*Repo: https://github.com/blockcell-labs/blockcell*\n*Website: https://blockcell.dev*\n"}}, "docs/18_proxy_and_provider_config.md": {"9a5ff122a3f913415d87b52bf73321403e8f202e": {"t": 1785674504.4551919, "n": 8589, "h": "4757515839544d6f", "c": "# 代理与 LLM Provider 配置\n\n本文档说明如何配置 LLM provider(模型服务商)、API key,以及网络代理设置。\n\n## 配置文件位置\n\n```\n~/.blockcell/config.json5\n```\n\n首次运行 `blockcell onboard` 会自动生成此文件。也可以用 `blockcell config edit` 直接编辑。\n\nWebUI 的完整配置编辑器也会直接读取/保存原始 `config.json5` 文本;保存时由后端校验 JSON5,因此注释和排版可以保留在原始编辑路径中。\n\n---\n\n## 一、Provider 配置\n\n### 配置结构\n\n配置文件的 `providers` 字段是一个 map,key 是 provider 名称,value 包含三个字段:\n\n```json\n{\n \"providers\": {\n \"\": {\n \"apiKey\": \"your-api-key\",\n \"apiBase\": \"https://api.example.com/v1\",\n \"proxy\": \"http://127.0.0.1:7890\"\n }\n }\n}\n```\n\n| 字段 | 类型 | 说明 |\n|------|------|------|\n| `apiKey` | string | API 密钥 |\n| `apiBase` | string(可选) | 自定义 API 地址(覆盖内置默认值) |\n| `proxy` | string(可选) | 该 provider 专用代理,优先级高于全局 `network.proxy`。设为 `\"\"` 可强制该 provider 直连(跳过全局代理) |\n\n### 内置 Provider 列表\n\n以下 provider 在默认配置中已预置,只需填入 `apiKey` 即可使用;其中一部分是 OpenAI 兼容接口,一部分使用专用 provider 实现:\n\n| Provider 名称 | 默认 apiBase | 说明 |\n|---------------|-------------|------|\n| `anthropic` | `https://api.anthropic.com/v1` | Anthropic Claude 系列 |\n| `openai` | `https://api.openai.com/v1` | OpenAI GPT/o1/o3 系列 |\n| `deepseek` | `https://api.deepseek.com/v1` | DeepSeek 系列 |\n| `openrouter` | `https://openrouter.ai/api/v1` | OpenRouter 聚合 |\n| `groq` | `https://api.groq.com/openai/v1` | Groq 加速推理 |\n| `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | 智谱 ChatGLM 系列 |\n| `vllm` | `http://localhost:8000/v1` | 本地 vLLM 服务 |\n| `gemini` | `https://generativelanguage.googleapis.com/v1beta/openai` | Google Gemini 系列(OpenAI-compatible 配置) |\n| `kimi` | `https://api.moonshot.cn/v1` | Kimi / Moonshot 系列 |\n| `xai` | `https://api.x.ai/v1` | xAI / Grok 系列 |\n| `mistral` | `https://api.mistral.ai/v1` | Mistral 系列 |\n| `minimax` | `https://api.minimaxi.com/v1` | MiniMax 系列 |\n| `qwen` | `https://api.qwen.ai/v1` | Qwen 系列 |\n| `glm` | `https://api.z.ai/v1` | GLM 系列 |\n| `siliconflow` | `https://api.siliconflow.cn/v1` | SiliconFlow 聚合 |\n| `ollama` | `http://localhost:11434` | 本地 Ollama 服务(不需要 apiKey) |\n\n### 常用配置示例\n\n**DeepSeek:**\n```json\n{\n \"providers\": {\n \"deepseek\": {\n \"apiKey\": \"sk-xxxxxxxx\"\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"deepseek-v4-pro\",\n \"provider\": \"deepseek\",\n \"maxContextTokens\": 1048576,\n \"reasoningEffort\": \"high\"\n }\n }\n}\n```\n\n**Anthropic Claude:**\n```json\n{\n \"providers\": {\n \"anthropic\": {\n \"apiKey\": \"sk-ant-xxxxxxxx\"\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"claude-3-5-sonnet-20241022\"\n }\n }\n}\n```\n\n**Kimi(Moonshot):**\n```json\n{\n \"providers\": {\n \"kimi\": {\n \"apiKey\": \"sk-xxxxxxxx\"\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"kimi-k2.6\"\n }\n }\n}\n```\n\n**OpenRouter(访问多家模型):**\n```json\n{\n \"providers\": {\n \"openrouter\": {\n \"apiKey\": \"sk-or-xxxxxxxx\"\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"anthropic/claude-3-5-sonnet\"\n }\n }\n}\n```\n\n**本地 Ollama(无需 apiKey):**\n```json\n{\n \"providers\": {\n \"ollama\": {\n \"apiBase\": \"http://localhost:11434\"\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"ollama/qwen3.6\"\n }\n }\n}\n```\n\n**自托管 vLLM / OpenAI 兼容服务:**\n```json\n{\n \"providers\": {\n \"vllm\": {\n \"apiKey\": \"dummy\",\n \"apiBase\": \"http://192.168.1.100:8000/v1\"\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"Qwen/Qwen3.6-35B-A3B\",\n \"provider\": \"vllm\"\n }\n }\n}\n```\n\n---\n\n## 二、Agent 模型配置\n\n`agents.defaults` 控制 Agent 使用的模型和行为参数:\n\n```json\n{\n \"agents\": {\n \"defaults\": {\n \"model\": \"deepseek-v4-pro\",\n \"provider\": null,\n \"maxTokens\": 8192,\n \"temperature\": 0.7,\n \"maxToolIterations\": 30,\n \"llmMaxRetries\": 3,\n \"llmRetryDelayMs\": 2000,\n \"maxContextTokens\": 1048576,\n \"reasoningEffort\": null,\n \"evolutionModel\": null,\n \"evolutionProvider\": null\n }\n }\n}\n```\n\n| 字段 | 默认值 | 说明 |\n|------|--------|------|\n| `model` | `deepseek-v4-pro` | 主模型名称;`setup` 会按所选 provider 写入推荐默认模型 |\n| `provider` | null | 显式指定 provider(不指定则从 model 前缀推断) |\n| `maxTokens` | 8192 | 每次 LLM 调用的最大输出 token 数 |\n| `temperature` | 0.7 | 采样温度(0.0 ~ 1.0) |\n| `maxToolIterations` | 30 | 单次消息处理的最大工具调用轮数 |\n| `llmMaxRetries` | 3 | LLM 调用失败时的最大重试次数 |\n| `llmRetryDelayMs` | 2000 | 重试间隔(毫秒) |\n| `maxContextTokens` | 1048576 | 上下文窗口大小(影响历史压缩);DeepSeek/Gemini 等长上下文模型可使用更高值 |\n| `reasoningEffort` | null | 推理强度控制;DeepSeek thinking mode 支持 `off`、`low`、`medium`、`high`、`max` |\n| `evolutionModel` | null | 技能自进化专用模型(为 null 则使用主模型) |\n| `evolutionProvider` | null | 技能自进化专用 provider |\n\n### Provider 选择逻辑\n\n系统按以下优先级选择 provider:\n\n1. **`agents.defaults.provider` 显式指定**(最高优先级)\n2. **从 `model` 字段前缀推断**:\n - `anthropic/...` 或 `claude-` 开头 → `anthropic`\n - `gemini/...` 或 `gemini-` 开头 → `gemini`\n - `ollama/...` 开头 → `ollama`\n - `kimi` 或 `moonshot` 开头 → `kimi`\n - `openai/...`、`gpt-`、`o1`、`o3` 开头 → `openai`\n - `deepseek` 开头 → `deepseek`\n - `groq/...` 开头 → `groq`\n3. **从 `providers` 中按内置优先顺序找第一个配置了有效 apiKey 的 provider**(fallback)\n\n内置 fallback 顺序目前是:`anthropic` → `openai` → `openrouter` → `deepseek` → `kimi` → `gemini` → `zhipu` → `groq` → `vllm` → `ollama`。\n\n`xai`、`mistral`、`minimax`、`qwen`、`glm`、`siliconflow` 这些 provider 也在默认配置里预置了,但如果你想优先使用它们,通常需要显式指定 `provider` 或在 `model` 上使用能推断出对应 provider 的前缀。\n\n**实际示例:**\n\n```json\n// 情形1:model 前缀自动推断(推荐,无需设置 provider)\n{ \"model\": \"claude-3-5-sonnet-20241022\" }\n// → 自动推断使用 anthropic provider\n\n// 情形2:使用 openrouter 调用 Claude(model 前缀是 anthropic/,但想走 openrouter)\n{ \"model\": \"anthropic/claude-3-5-sonnet\", \"provider\": \"openrouter\" }\n\n// 情形3:本地 Ollama 模型\n{ \"model\": \"ollama/qwen3.6\" }\n// → 自动推断使用 ollama provider\n```\n\n---\n\n## 三、全局网络代理配置\n\n`network` 字段控制全局 HTTP 代理,适用于所有 LLM provider 请求:\n\n```json\n{\n \"network\": {\n \"proxy\": \"http://127.0.0.1:7890\",\n \"noProxy\": [\"localhost\", \"127.0.0.1\", \"::1\", \"*.local\"]\n }\n}\n```\n\n| 字段 | 类型 | 说明 |\n|------|------|------|\n| `proxy` | string(可选) | 全局代理地址,格式:`http://host:port` 或 `socks5://host:port`。留空则直连 |\n| `noProxy` | string[](可选) | 不走代理的域名/IP 列表,支持前缀通配符 `*.example.com` |\n\n### 代理优先级规则\n\n| 优先级 | 配置 | 说明 |\n|--------|------|------|\n| 最高 | `providers..proxy = \"http://...\"` | 该 provider 使用专用代理 |\n| 最高 | `providers..proxy = \"\"` | 该 provider **强制直连**,跳过全局代理 |\n| 中 | `network.proxy` | 全局代理(所有未单独配置的 provider 都走此代理) |\n| 最低 | 不配置 | 直连 |\n\n### 代理配置场景示例\n\n**场景1:全局代理,所有请求都走代理**\n\n```json\n{\n \"network\": {\n \"proxy\": \"http://127.0.0.1:7890\"\n }\n}\n```\n\n**场景2:全局代理,但 Ollama 本地服务直连**\n\n```json\n{\n \"network\": {\n \"proxy\": \"http://127.0.0.1:7890\",\n \"noProxy\": [\"localhost\", \"127.0.0.1\"]\n },\n \"providers\": {\n \"ollama\": {\n \"apiBase\": \"http://localhost:11434\",\n \"proxy\": \"\"\n }\n }\n}\n```\n\n**场景3:全局不代理,仅 Anthropic 走代理(例如需要翻墙)**\n\n```json\n{\n \"providers\": {\n \"anthropic\": {\n \"apiKey\": \"sk-ant-xxxxxxxx\",\n \"proxy\": \"http://127.0.0.1:7890\"\n },\n \"deepseek\": {\n \"apiKey\": \"sk-xxxxxxxx\"\n }\n }\n}\n```\n\n**场景4:SOCKS5 代理**\n\n```json\n{\n \"network\": {\n \"proxy\": \"socks5://127.0.0.1:1080\"\n }\n}\n```\n\n---\n\n## 四、Telegram 渠道代理\n\nTelegram 渠道有独立的代理配置,与 LLM provider 代理分开设置:\n\n```json\n{\n \"channels\": {\n \"telegram\": {\n \"enabled\": true,\n \"token\": \"your-bot-token\",\n \"allowFrom\": [\"123456789\"],\n \"proxy\": \"http://127.0.0.1:7890\"\n }\n }\n}\n```\n\n---\n\n## 五、使用 CLI 快速修改配置\n\n无需手动编辑 JSON5,可用 `config set` 命令修改:\n\n```bash\n# 设置全局代理\nblockcell config set network.proxy \"http://127.0.0.1:7890\"\n\n# 设置模型\nblockcell config set agents.defaults.model \"deepseek-v4-pro\"\n\n# 设置 DeepSeek API Key\nblockcell config set providers.deepseek.apiKey \"sk-xxxxxxxx\"\n\n# 为 Anthropic 设置专用代理\nblockcell config set providers.anthropic.proxy \"http://127.0.0.1:7890\"\n\n# 强制 vllm 直连(跳过全局代理)\nblockcell config set providers.vllm.proxy \"\"\n\n# 查看当前代理设置\nblockcell config get network.proxy\n\n# 查看所有 provider 配置\nblockcell config providers\n```\n\n---\n\n## 六、完整配置示例\n\n以下是一个生产环境常用的完整配置示例:\n\n```json\n{\n \"providers\": {\n \"anthropic\": {\n \"apiKey\": \"sk-ant-xxxxxxxx\"\n },\n \"deepseek\": {\n \"apiKey\": \"sk-xxxxxxxx\"\n },\n \"openrouter\": {\n \"apiKey\": \"sk-or-xxxxxxxx\",\n \"apiBase\": \"https://openrouter.ai/api/v1\"\n },\n \"ollama\": {\n \"apiBase\": \"http://localhost:11434\",\n \"proxy\": \"\"\n }\n },\n \"network\": {\n \"proxy\": \"http://127.0.0.1:7890\",\n \"noProxy\": [\"localhost\", \"127.0.0.1\", \"::1\"]\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"claude-3-5-sonnet-20241022\",\n \"maxTokens\": 8192,\n \"temperature\": 0.7,\n \"maxToolIterations\": 20,\n \"evolutionModel\": \"deepseek-v4-pro\",\n \"evolutionProvider\": \"deepseek\"\n }\n },\n \"gateway\": {\n \"host\": \"0.0.0.0\",\n \"port\": 18790,\n \"apiToken\": \"your-secret-token\"\n }\n}\n```\n\n> **说明:** 此配置使用 Claude 作为主模型(走全局代理),DeepSeek 专门用于技能自进化(更便宜),本地 Ollama 强制直连。\n"}, "27f4d4f01e2c743986e0723b71f12f7bbbdde4d4": {"t": 1785846887.7196302, "n": 8589, "h": "4757515839544d6f", "c": "# 代理与 LLM Provider 配置\n\n本文档说明如何配置 LLM provider(模型服务商)、API key,以及网络代理设置。\n\n## 配置文件位置\n\n```\n~/.blockcell/config.json5\n```\n\n首次运行 `blockcell onboard` 会自动生成此文件。也可以用 `blockcell config edit` 直接编辑。\n\nWebUI 的完整配置编辑器也会直接读取/保存原始 `config.json5` 文本;保存时由后端校验 JSON5,因此注释和排版可以保留在原始编辑路径中。\n\n---\n\n## 一、Provider 配置\n\n### 配置结构\n\n配置文件的 `providers` 字段是一个 map,key 是 provider 名称,value 包含三个字段:\n\n```json\n{\n \"providers\": {\n \"\": {\n \"apiKey\": \"your-api-key\",\n \"apiBase\": \"https://api.example.com/v1\",\n \"proxy\": \"http://127.0.0.1:7890\"\n }\n }\n}\n```\n\n| 字段 | 类型 | 说明 |\n|------|------|------|\n| `apiKey` | string | API 密钥 |\n| `apiBase` | string(可选) | 自定义 API 地址(覆盖内置默认值) |\n| `proxy` | string(可选) | 该 provider 专用代理,优先级高于全局 `network.proxy`。设为 `\"\"` 可强制该 provider 直连(跳过全局代理) |\n\n### 内置 Provider 列表\n\n以下 provider 在默认配置中已预置,只需填入 `apiKey` 即可使用;其中一部分是 OpenAI 兼容接口,一部分使用专用 provider 实现:\n\n| Provider 名称 | 默认 apiBase | 说明 |\n|---------------|-------------|------|\n| `anthropic` | `https://api.anthropic.com/v1` | Anthropic Claude 系列 |\n| `openai` | `https://api.openai.com/v1` | OpenAI GPT/o1/o3 系列 |\n| `deepseek` | `https://api.deepseek.com/v1` | DeepSeek 系列 |\n| `openrouter` | `https://openrouter.ai/api/v1` | OpenRouter 聚合 |\n| `groq` | `https://api.groq.com/openai/v1` | Groq 加速推理 |\n| `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | 智谱 ChatGLM 系列 |\n| `vllm` | `http://localhost:8000/v1` | 本地 vLLM 服务 |\n| `gemini` | `https://generativelanguage.googleapis.com/v1beta/openai` | Google Gemini 系列(OpenAI-compatible 配置) |\n| `kimi` | `https://api.moonshot.cn/v1` | Kimi / Moonshot 系列 |\n| `xai` | `https://api.x.ai/v1` | xAI / Grok 系列 |\n| `mistral` | `https://api.mistral.ai/v1` | Mistral 系列 |\n| `minimax` | `https://api.minimaxi.com/v1` | MiniMax 系列 |\n| `qwen` | `https://api.qwen.ai/v1` | Qwen 系列 |\n| `glm` | `https://api.z.ai/v1` | GLM 系列 |\n| `siliconflow` | `https://api.siliconflow.cn/v1` | SiliconFlow 聚合 |\n| `ollama` | `http://localhost:11434` | 本地 Ollama 服务(不需要 apiKey) |\n\n### 常用配置示例\n\n**DeepSeek:**\n```json\n{\n \"providers\": {\n \"deepseek\": {\n \"apiKey\": \"sk-xxxxxxxx\"\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"deepseek-v4-pro\",\n \"provider\": \"deepseek\",\n \"maxContextTokens\": 1048576,\n \"reasoningEffort\": \"high\"\n }\n }\n}\n```\n\n**Anthropic Claude:**\n```json\n{\n \"providers\": {\n \"anthropic\": {\n \"apiKey\": \"sk-ant-xxxxxxxx\"\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"claude-3-5-sonnet-20241022\"\n }\n }\n}\n```\n\n**Kimi(Moonshot):**\n```json\n{\n \"providers\": {\n \"kimi\": {\n \"apiKey\": \"sk-xxxxxxxx\"\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"kimi-k2.6\"\n }\n }\n}\n```\n\n**OpenRouter(访问多家模型):**\n```json\n{\n \"providers\": {\n \"openrouter\": {\n \"apiKey\": \"sk-or-xxxxxxxx\"\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"anthropic/claude-3-5-sonnet\"\n }\n }\n}\n```\n\n**本地 Ollama(无需 apiKey):**\n```json\n{\n \"providers\": {\n \"ollama\": {\n \"apiBase\": \"http://localhost:11434\"\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"ollama/qwen3.6\"\n }\n }\n}\n```\n\n**自托管 vLLM / OpenAI 兼容服务:**\n```json\n{\n \"providers\": {\n \"vllm\": {\n \"apiKey\": \"dummy\",\n \"apiBase\": \"http://192.168.1.100:8000/v1\"\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"Qwen/Qwen3.6-35B-A3B\",\n \"provider\": \"vllm\"\n }\n }\n}\n```\n\n---\n\n## 二、Agent 模型配置\n\n`agents.defaults` 控制 Agent 使用的模型和行为参数:\n\n```json\n{\n \"agents\": {\n \"defaults\": {\n \"model\": \"deepseek-v4-pro\",\n \"provider\": null,\n \"maxTokens\": 8192,\n \"temperature\": 0.7,\n \"maxToolIterations\": 30,\n \"llmMaxRetries\": 3,\n \"llmRetryDelayMs\": 2000,\n \"maxContextTokens\": 1048576,\n \"reasoningEffort\": null,\n \"evolutionModel\": null,\n \"evolutionProvider\": null\n }\n }\n}\n```\n\n| 字段 | 默认值 | 说明 |\n|------|--------|------|\n| `model` | `deepseek-v4-pro` | 主模型名称;`setup` 会按所选 provider 写入推荐默认模型 |\n| `provider` | null | 显式指定 provider(不指定则从 model 前缀推断) |\n| `maxTokens` | 8192 | 每次 LLM 调用的最大输出 token 数 |\n| `temperature` | 0.7 | 采样温度(0.0 ~ 1.0) |\n| `maxToolIterations` | 30 | 单次消息处理的最大工具调用轮数 |\n| `llmMaxRetries` | 3 | LLM 调用失败时的最大重试次数 |\n| `llmRetryDelayMs` | 2000 | 重试间隔(毫秒) |\n| `maxContextTokens` | 1048576 | 上下文窗口大小(影响历史压缩);DeepSeek/Gemini 等长上下文模型可使用更高值 |\n| `reasoningEffort` | null | 推理强度控制;DeepSeek thinking mode 支持 `off`、`low`、`medium`、`high`、`max` |\n| `evolutionModel` | null | 技能自进化专用模型(为 null 则使用主模型) |\n| `evolutionProvider` | null | 技能自进化专用 provider |\n\n### Provider 选择逻辑\n\n系统按以下优先级选择 provider:\n\n1. **`agents.defaults.provider` 显式指定**(最高优先级)\n2. **从 `model` 字段前缀推断**:\n - `anthropic/...` 或 `claude-` 开头 → `anthropic`\n - `gemini/...` 或 `gemini-` 开头 → `gemini`\n - `ollama/...` 开头 → `ollama`\n - `kimi` 或 `moonshot` 开头 → `kimi`\n - `openai/...`、`gpt-`、`o1`、`o3` 开头 → `openai`\n - `deepseek` 开头 → `deepseek`\n - `groq/...` 开头 → `groq`\n3. **从 `providers` 中按内置优先顺序找第一个配置了有效 apiKey 的 provider**(fallback)\n\n内置 fallback 顺序目前是:`anthropic` → `openai` → `openrouter` → `deepseek` → `kimi` → `gemini` → `zhipu` → `groq` → `vllm` → `ollama`。\n\n`xai`、`mistral`、`minimax`、`qwen`、`glm`、`siliconflow` 这些 provider 也在默认配置里预置了,但如果你想优先使用它们,通常需要显式指定 `provider` 或在 `model` 上使用能推断出对应 provider 的前缀。\n\n**实际示例:**\n\n```json\n// 情形1:model 前缀自动推断(推荐,无需设置 provider)\n{ \"model\": \"claude-3-5-sonnet-20241022\" }\n// → 自动推断使用 anthropic provider\n\n// 情形2:使用 openrouter 调用 Claude(model 前缀是 anthropic/,但想走 openrouter)\n{ \"model\": \"anthropic/claude-3-5-sonnet\", \"provider\": \"openrouter\" }\n\n// 情形3:本地 Ollama 模型\n{ \"model\": \"ollama/qwen3.6\" }\n// → 自动推断使用 ollama provider\n```\n\n---\n\n## 三、全局网络代理配置\n\n`network` 字段控制全局 HTTP 代理,适用于所有 LLM provider 请求:\n\n```json\n{\n \"network\": {\n \"proxy\": \"http://127.0.0.1:7890\",\n \"noProxy\": [\"localhost\", \"127.0.0.1\", \"::1\", \"*.local\"]\n }\n}\n```\n\n| 字段 | 类型 | 说明 |\n|------|------|------|\n| `proxy` | string(可选) | 全局代理地址,格式:`http://host:port` 或 `socks5://host:port`。留空则直连 |\n| `noProxy` | string[](可选) | 不走代理的域名/IP 列表,支持前缀通配符 `*.example.com` |\n\n### 代理优先级规则\n\n| 优先级 | 配置 | 说明 |\n|--------|------|------|\n| 最高 | `providers..proxy = \"http://...\"` | 该 provider 使用专用代理 |\n| 最高 | `providers..proxy = \"\"` | 该 provider **强制直连**,跳过全局代理 |\n| 中 | `network.proxy` | 全局代理(所有未单独配置的 provider 都走此代理) |\n| 最低 | 不配置 | 直连 |\n\n### 代理配置场景示例\n\n**场景1:全局代理,所有请求都走代理**\n\n```json\n{\n \"network\": {\n \"proxy\": \"http://127.0.0.1:7890\"\n }\n}\n```\n\n**场景2:全局代理,但 Ollama 本地服务直连**\n\n```json\n{\n \"network\": {\n \"proxy\": \"http://127.0.0.1:7890\",\n \"noProxy\": [\"localhost\", \"127.0.0.1\"]\n },\n \"providers\": {\n \"ollama\": {\n \"apiBase\": \"http://localhost:11434\",\n \"proxy\": \"\"\n }\n }\n}\n```\n\n**场景3:全局不代理,仅 Anthropic 走代理(例如需要翻墙)**\n\n```json\n{\n \"providers\": {\n \"anthropic\": {\n \"apiKey\": \"sk-ant-xxxxxxxx\",\n \"proxy\": \"http://127.0.0.1:7890\"\n },\n \"deepseek\": {\n \"apiKey\": \"sk-xxxxxxxx\"\n }\n }\n}\n```\n\n**场景4:SOCKS5 代理**\n\n```json\n{\n \"network\": {\n \"proxy\": \"socks5://127.0.0.1:1080\"\n }\n}\n```\n\n---\n\n## 四、Telegram 渠道代理\n\nTelegram 渠道有独立的代理配置,与 LLM provider 代理分开设置:\n\n```json\n{\n \"channels\": {\n \"telegram\": {\n \"enabled\": true,\n \"token\": \"your-bot-token\",\n \"allowFrom\": [\"123456789\"],\n \"proxy\": \"http://127.0.0.1:7890\"\n }\n }\n}\n```\n\n---\n\n## 五、使用 CLI 快速修改配置\n\n无需手动编辑 JSON5,可用 `config set` 命令修改:\n\n```bash\n# 设置全局代理\nblockcell config set network.proxy \"http://127.0.0.1:7890\"\n\n# 设置模型\nblockcell config set agents.defaults.model \"deepseek-v4-pro\"\n\n# 设置 DeepSeek API Key\nblockcell config set providers.deepseek.apiKey \"sk-xxxxxxxx\"\n\n# 为 Anthropic 设置专用代理\nblockcell config set providers.anthropic.proxy \"http://127.0.0.1:7890\"\n\n# 强制 vllm 直连(跳过全局代理)\nblockcell config set providers.vllm.proxy \"\"\n\n# 查看当前代理设置\nblockcell config get network.proxy\n\n# 查看所有 provider 配置\nblockcell config providers\n```\n\n---\n\n## 六、完整配置示例\n\n以下是一个生产环境常用的完整配置示例:\n\n```json\n{\n \"providers\": {\n \"anthropic\": {\n \"apiKey\": \"sk-ant-xxxxxxxx\"\n },\n \"deepseek\": {\n \"apiKey\": \"sk-xxxxxxxx\"\n },\n \"openrouter\": {\n \"apiKey\": \"sk-or-xxxxxxxx\",\n \"apiBase\": \"https://openrouter.ai/api/v1\"\n },\n \"ollama\": {\n \"apiBase\": \"http://localhost:11434\",\n \"proxy\": \"\"\n }\n },\n \"network\": {\n \"proxy\": \"http://127.0.0.1:7890\",\n \"noProxy\": [\"localhost\", \"127.0.0.1\", \"::1\"]\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"claude-3-5-sonnet-20241022\",\n \"maxTokens\": 8192,\n \"temperature\": 0.7,\n \"maxToolIterations\": 20,\n \"evolutionModel\": \"deepseek-v4-pro\",\n \"evolutionProvider\": \"deepseek\"\n }\n },\n \"gateway\": {\n \"host\": \"0.0.0.0\",\n \"port\": 18790,\n \"apiToken\": \"your-secret-token\"\n }\n}\n```\n\n> **说明:** 此配置使用 Claude 作为主模型(走全局代理),DeepSeek 专门用于技能自进化(更便宜),本地 Ollama 强制直连。\n"}}, "bin/blockcell/src/commands/setup.rs": {"bc15f54c767b4a1ca941f3dbb15526d12de269ed": {"t": 1785746222.6476982, "n": 26851, "h": "3bd680b953693936", "c": "use anyhow::{anyhow, bail, Context};\nuse blockcell_core::config::{ModelEntry, ToolCallMode};\nuse blockcell_core::{Config, Paths};\nuse std::io::{self, Write};\n\nuse super::provider_defaults::default_model_for_provider;\n\n/// Interactive first-run setup wizard for provider + optional channel.\npub async fn run(\n force: bool,\n provider: Option,\n api_key: Option,\n model: Option,\n channel: Option,\n skip_provider_test: bool,\n) -> anyhow::Result<()> {\n let paths = Paths::new();\n paths.ensure_dirs()?;\n\n let config_path = paths.config_file();\n let mut config = if config_path.exists() && !force {\n Config::load(&config_path)\n .with_context(|| format!(\"Failed to load {}\", config_path.display()))?\n } else {\n if config_path.exists() && force {\n println!(\"⚠ --force enabled: reset existing config to defaults before setup.\");\n }\n Config::default()\n };\n\n if config.intent_router.is_none() {\n config.intent_router = Config::default().intent_router;\n }\n\n println!(\"blockcell setup\");\n println!(\"==============\");\n println!(\"Config file: {}\", config_path.display());\n println!();\n\n let selected_provider = resolve_provider(provider.as_deref())?;\n let provider_name = if let Some(name) = selected_provider {\n name.to_string()\n } else {\n prompt_provider(&config)?\n };\n\n if provider_name != \"skip\" {\n configure_provider(\n &mut config,\n &provider_name,\n api_key.as_deref(),\n model.as_deref(),\n )?;\n } else {\n println!(\"Skipped LLM provider setup.\");\n }\n\n let selected_channel = resolve_channel(channel.as_deref())?;\n let channel_name = if let Some(name) = selected_channel {\n name.to_string()\n } else {\n prompt_channel()?\n };\n\n if channel_name != \"skip\" {\n configure_channel(&mut config, &channel_name)?;\n ensure_channel_owner(&mut config, &channel_name);\n } else {\n println!(\"Skipped channel setup (WebUI only).\");\n }\n\n config\n .save(&config_path)\n .with_context(|| format!(\"Failed to save {}\", config_path.display()))?;\n\n println!();\n println!(\"✓ Setup completed\");\n println!(\" Config: {}\", config_path.display());\n if provider_name != \"skip\" {\n let selected = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider_name)\n .or_else(|| primary_pool_entry(&config));\n let selected_model = selected\n .map(|e| e.model.as_str())\n .unwrap_or(config.agents.defaults.model.as_str());\n println!(\" Provider: {} Model: {}\", provider_name, selected_model);\n if !skip_provider_test {\n match blockcell_providers::create_provider(\n &config,\n selected_model,\n Some(&provider_name),\n ) {\n Ok(_) => println!(\" ✓ Provider config validation passed\"),\n Err(e) => println!(\" ⚠ Provider validation failed: {}\", e),\n }\n }\n }\n if channel_name != \"skip\" {\n println!(\" Channel: {} (enabled)\", channel_name);\n } else {\n println!(\" Channel: none (use WebUI only)\");\n }\n println!();\n println!(\"Next steps:\");\n println!(\" 1. blockcell status\");\n println!(\" 2. blockcell gateway\");\n println!(\" 3. Open WebUI: http://localhost:18791/\");\n\n Ok(())\n}\n\nfn configure_provider(\n config: &mut Config,\n provider: &str,\n api_key_flag: Option<&str>,\n model_flag: Option<&str>,\n) -> anyhow::Result<()> {\n let entry = config.providers.entry(provider.to_string()).or_default();\n\n if entry.api_base.is_none() {\n entry.api_base = default_api_base_for_provider(provider).map(|s| s.to_string());\n }\n if entry.api_type.is_empty() {\n entry.api_type = default_api_type_for_provider(provider).to_string();\n }\n\n if provider == \"ollama\" {\n if entry.api_key.trim().is_empty() {\n entry.api_key = \"ollama\".to_string();\n }\n if entry.api_base.is_none() {\n entry.api_base = Some(\"http://localhost:11434\".to_string());\n }\n } else {\n let has_existing = !entry.api_key.trim().is_empty() && entry.api_key != \"dummy\";\n let final_key = if let Some(flag) = api_key_flag {\n flag.trim().to_string()\n } else if has_existing {\n prompt_line(\"API key (press Enter to keep existing): \")?\n } else {\n prompt_line(\"API key: \")?\n };\n\n if !final_key.is_empty() {\n entry.api_key = final_key;\n }\n\n if entry.api_key.trim().is_empty() || entry.api_key == \"dummy\" {\n bail!(\"Provider '{}' requires a valid API key.\", provider);\n }\n }\n\n let suggested_model = if let Some(entry) = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider)\n {\n entry.model.clone()\n } else {\n default_model_for_provider(provider).to_string()\n };\n\n let final_model = if let Some(m) = model_flag {\n m.trim().to_string()\n } else {\n prompt_line_with_default(\"Model\", &suggested_model)?\n };\n if final_model.trim().is_empty() {\n bail!(\"Model cannot be empty.\");\n }\n\n // Setup writes provider/model to model_pool first, and also mirrors to legacy\n // single-model fields for backward compatibility with older runtimes.\n config.agents.defaults.model_pool = vec![ModelEntry {\n model: final_model.clone(),\n provider: provider.to_string(),\n weight: 1,\n priority: 1,\n input_price: None,\n output_price: None,\n temperature: None,\n tool_call_mode: ToolCallMode::Native,\n }];\n config.agents.defaults.provider = Some(provider.to_string());\n config.agents.defaults.model = final_model;\n\n Ok(())\n}\n\nfn configure_channel(config: &mut Config, channel: &str) -> anyhow::Result<()> {\n match channel {\n \"telegram\" => {\n let existing = config.channels.telegram.token.clone();\n let prompt = if existing.trim().is_empty() {\n \"Telegram bot token: \"\n } else {\n \"Telegram bot token (press Enter to keep existing): \"\n };\n let token = prompt_line(prompt)?;\n if !token.is_empty() {\n config.channels.telegram.token = token;\n }\n if config.channels.telegram.token.trim().is_empty() {\n bail!(\"Telegram token is required.\");\n }\n config.channels.telegram.enabled = true;\n }\n \"feishu\" => {\n let app_id =\n prompt_optional_with_existing(\"Feishu app_id\", &config.channels.feishu.app_id)?;\n let app_secret = prompt_optional_with_existing(\n \"Feishu app_secret\",\n &config.channels.feishu.app_secret,\n )?;\n\n if !app_id.is_empty() {\n config.channels.feishu.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.feishu.app_secret = app_secret;\n }\n\n if config.channels.feishu.app_id.trim().is_empty()\n || config.channels.feishu.app_secret.trim().is_empty()\n {\n bail!(\"Feishu app_id and app_secret are required.\");\n }\n config.channels.feishu.enabled = true;\n }\n \"wecom\" => {\n let corp_id =\n prompt_optional_with_existing(\"WeCom corp_id\", &config.channels.wecom.corp_id)?;\n let corp_secret = prompt_optional_with_existing(\n \"WeCom corp_secret\",\n &config.channels.wecom.corp_secret,\n )?;\n\n if !corp_id.is_empty() {\n config.channels.wecom.corp_id = corp_id;\n }\n if !corp_secret.is_empty() {\n config.channels.wecom.corp_secret = corp_secret;\n }\n\n if config.channels.wecom.corp_id.trim().is_empty()\n || config.channels.wecom.corp_secret.trim().is_empty()\n {\n bail!(\"WeCom corp_id and corp_secret are required.\");\n }\n\n let existing_agent = if config.channels.wecom.agent_id > 0 {\n config.channels.wecom.agent_id.to_string()\n } else {\n \"1000002\".to_string()\n };\n let agent_id_str = prompt_line_with_default(\"WeCom agent_id\", &existing_agent)?;\n let agent_id = agent_id_str\n .trim()\n .parse::()\n .map_err(|_| anyhow!(\"agent_id must be an integer\"))?;\n config.channels.wecom.agent_id = agent_id;\n config.channels.wecom.enabled = true;\n }\n \"dingtalk\" => {\n let app_key = prompt_optional_with_existing(\n \"DingTalk app_key\",\n &config.channels.dingtalk.app_key,\n )?;\n let app_secret = prompt_optional_with_existing(\n \"DingTalk app_secret\",\n &config.channels.dingtalk.app_secret,\n )?;\n if !app_key.is_empty() {\n config.channels.dingtalk.app_key = app_key;\n }\n if !app_secret.is_empty() {\n config.channels.dingtalk.app_secret = app_secret;\n }\n if config.channels.dingtalk.app_key.trim().is_empty()\n || config.channels.dingtalk.app_secret.trim().is_empty()\n {\n bail!(\"DingTalk app_key and app_secret are required.\");\n }\n config.channels.dingtalk.enabled = true;\n }\n \"lark\" => {\n let app_id =\n prompt_optional_with_existing(\"Lark app_id\", &config.channels.lark.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"Lark app_secret\", &config.channels.lark.app_secret)?;\n if !app_id.is_empty() {\n config.channels.lark.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.lark.app_secret = app_secret;\n }\n if config.channels.lark.app_id.trim().is_empty()\n || config.channels.lark.app_secret.trim().is_empty()\n {\n bail!(\"Lark app_id and app_secret are required.\");\n }\n config.channels.lark.enabled = true;\n }\n \"qq\" => {\n let app_id = prompt_optional_with_existing(\"QQ app_id\", &config.channels.qq.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"QQ app_secret\", &config.channels.qq.app_secret)?;\n if !app_id.is_empty() {\n config.channels.qq.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.qq.app_secret = app_secret;\n }\n if config.channels.qq.app_id.trim().is_empty()\n || config.channels.qq.app_secret.trim().is_empty()\n {\n bail!(\"QQ app_id and app_secret are required.\");\n }\n let environment = prompt_optional_with_existing(\n \"QQ environment (production/sandbox)\",\n &config.channels.qq.environment,\n )?;\n if !environment.is_empty() {\n config.channels.qq.environment = environment;\n }\n if config.channels.qq.environment.trim().is_empty() {\n config.channels.qq.environment = \"production\".to_string();\n }\n config.channels.qq.enabled = true;\n }\n \"napcat\" => {\n let mode_options = [\n \"ws-client (BlockCell connects to NapCatQQ WebSocket server)\",\n \"ws-server (NapCatQQ connects to BlockCell WebSocket server)\",\n ];\n let mode_idx = prompt_select(\"Connection mode\", &mode_options, 0)?;\n let mode = match mode_idx {\n 0 => \"ws-client\",\n 1 => \"ws-server\",\n _ => \"ws-client\",\n }\n .to_string();\n\n // WebSocket URL (only for ws-client mode)\n let ws_url = if mode == \"ws-client\" {\n prompt_line_with_default(\"WebSocket URL\", \"ws://127.0.0.1:3001\")?\n } else {\n String::new()\n };\n\n // Server configuration for ws-server mode\n let (server_host, server_port, server_path) = if mode == \"ws-server\" {\n let host = prompt_line_with_default(\"WebSocket server host\", \"0.0.0.0\")?;\n // NapCatQQ client 默认连接 ws://localhost:13005\n let port_str = prompt_line_with_default(\"WebSocket server port\", \"13005\")?;\n let port = port_str.parse::().unwrap_or(13005);\n // NapCatQQ client 默认连接根路径 /\n let path = prompt_line_with_default(\"WebSocket server path\", \"/\")?;\n (host, port, path)\n } else {\n (String::new(), 0, String::new())\n };\n\n let access_token = prompt_line(\"Access token (optional, press Enter to skip)\")?;\n\n // Group response mode\n let mode_options = [\n \"all - 响应所有群聊消息\",\n \"at_only - 仅响应@我的群聊消息\",\n \"none - 不响应任何群聊消息\",\n ];\n let mode_idx = prompt_select(\"群聊响应模式\", &mode_options, 0)?;\n let group_response_mode = match mode_idx {\n 0 => \"all\",\n 1 => \"at_only\",\n 2 => \"none\",\n _ => \"all\",\n }\n .to_string();\n\n config.channels.napcat.enabled = true;\n config.channels.napcat.mode = mode;\n config.channels.napcat.ws_url = ws_url;\n config.channels.napcat.server_host = server_host;\n config.channels.napcat.server_port = server_port;\n config.channels.napcat.server_path = server_path;\n config.channels.napcat.access_token = access_token;\n config.channels.napcat.group_response_mode = group_response_mode;\n\n // 媒体自动下载配置\n let auto_download_options = [\n \"是 - 自动下载图片、语音、视频、文件等媒体\",\n \"否 - 不自动下载媒体\",\n ];\n let auto_idx = prompt_select(\"是否自动下载媒体文件\", &auto_download_options, 0)?;\n config.channels.napcat.auto_download_media = auto_idx == 0;\n\n if config.channels.napcat.auto_download_media {\n let max_size_str = prompt_line_with_default(\"最大自动下载大小 (字节)\", \"10485760\")?;\n if let Ok(max_size) = max_size_str.parse::() {\n config.channels.napcat.max_auto_download_size = max_size;\n }\n\n let download_dir =\n prompt_line_with_default(\"媒体下载目录 (相对于 workspace)\", \"downloads\")?;\n if !download_dir.trim().is_empty() {\n config.channels.napcat.media_download_dir = download_dir;\n }\n }\n }\n _ => bail!(\"Unsupported channel '{}'\", channel),\n }\n\n Ok(())\n}\n\nfn ensure_channel_owner(config: &mut Config, channel: &str) {\n if config.resolve_channel_owner(channel).is_some() {\n return;\n }\n let owner = config\n .known_agent_ids()\n .into_iter()\n .next()\n .unwrap_or_else(|| \"default\".to_string());\n config\n .channel_owners\n .insert(channel.to_string(), owner.clone());\n println!(\"Assigned channel owner: {} -> {}\", channel, owner);\n}\n\nfn prompt_provider(config: &Config) -> anyhow::Result {\n let current = primary_pool_entry(config)\n .map(|e| e.provider.clone())\n .or_else(|| {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| !e.provider.trim().is_empty())\n .map(|e| e.provider.clone())\n })\n .or_else(|| config.agents.defaults.provider.clone())\n .or_else(|| config.get_api_key().map(|(name, _)| name.to_string()))\n .unwrap_or_else(|| \"none\".to_string());\n\n println!(\"Configure LLM provider (current: {})\", current);\n let options = [\n \"deepseek (recommended)\",\n \"openai\",\n \"kimi\",\n \"anthropic\",\n \"gemini\",\n \"zhipu\",\n \"qwen\",\n \"xai\",\n \"mistral\",\n \"minimax\",\n \"groq\",\n \"siliconflow\",\n \"openrouter\",\n \"ollama (local)\",\n \"skip\",\n ];\n let idx = prompt_select(\"Choose provider\", &options, 0)?;\n let mapped = match idx {\n 0 => \"deepseek\",\n 1 => \"openai\",\n 2 => \"kimi\",\n 3 => \"anthropic\",\n 4 => \"gemini\",\n 5 => \"zhipu\",\n 6 => \"qwen\",\n 7 => \"xai\",\n 8 => \"mistral\",\n 9 => \"minimax\",\n 10 => \"groq\",\n 11 => \"siliconflow\",\n 12 => \"openrouter\",\n 13 => \"ollama\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn primary_pool_entry(config: &Config) -> Option<&ModelEntry> {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .min_by(|a, b| a.priority.cmp(&b.priority).then(b.weight.cmp(&a.weight)))\n}\n\nfn prompt_channel() -> anyhow::Result {\n let options = [\n \"skip (WebUI only)\",\n \"telegram\",\n \"feishu\",\n \"wecom\",\n \"dingtalk\",\n \"lark\",\n \"qq\",\n \"napcat\",\n ];\n let idx = prompt_select(\"Configure one channel (optional)\", &options, 0)?;\n let mapped = match idx {\n 1 => \"telegram\",\n 2 => \"feishu\",\n 3 => \"wecom\",\n 4 => \"dingtalk\",\n 5 => \"lark\",\n 6 => \"qq\",\n 7 => \"napcat\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn resolve_provider(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_provider(v)\n .ok_or_else(|| anyhow!(\"Unsupported provider '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn resolve_channel(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_channel(v)\n .ok_or_else(|| anyhow!(\"Unsupported channel '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn normalize_provider(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"deepseek\" => Some(\"deepseek\"),\n \"openai\" => Some(\"openai\"),\n \"kimi\" | \"moonshot\" => Some(\"kimi\"),\n \"anthropic\" | \"claude\" => Some(\"anthropic\"),\n \"gemini\" => Some(\"gemini\"),\n \"zhipu\" | \"glm\" | \"zai\" | \"z.ai\" => Some(\"zhipu\"),\n \"qwen\" | \"tongyi\" | \"dashscope\" => Some(\"qwen\"),\n \"xai\" | \"grok\" => Some(\"xai\"),\n \"mistral\" => Some(\"mistral\"),\n \"minimax\" => Some(\"minimax\"),\n \"groq\" => Some(\"groq\"),\n \"siliconflow\" => Some(\"siliconflow\"),\n \"openrouter\" => Some(\"openrouter\"),\n \"ollama\" => Some(\"ollama\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn normalize_channel(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"telegram\" => Some(\"telegram\"),\n \"feishu\" => Some(\"feishu\"),\n \"wecom\" | \"wechatwork\" => Some(\"wecom\"),\n \"dingtalk\" => Some(\"dingtalk\"),\n \"lark\" => Some(\"lark\"),\n \"qq\" => Some(\"qq\"),\n \"napcat\" | \"napcatqq\" => Some(\"napcat\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn default_api_base_for_provider(provider: &str) -> Option<&'static str> {\n match provider {\n \"deepseek\" => Some(\"https://api.deepseek.com/v1\"),\n \"openai\" => Some(\"https://api.openai.com/v1\"),\n \"anthropic\" => Some(\"https://api.anthropic.com\"),\n \"kimi\" => Some(\"https://api.moonshot.cn/v1\"),\n \"gemini\" => Some(\"https://generativelanguage.googleapis.com/v1beta/openai\"),\n \"zhipu\" | \"glm\" => Some(\"https://open.bigmodel.cn/api/paas/v4\"),\n \"minimax\" => Some(\"https://api.minimaxi.com/v1\"),\n \"groq\" => Some(\"https://api.groq.com/openai/v1\"),\n \"siliconflow\" => Some(\"https://api.siliconflow.cn/v1\"),\n \"openrouter\" => Some(\"https://openrouter.ai/api/v1\"),\n \"qwen\" => Some(\"https://api.qwen.ai/v1\"),\n \"xai\" => Some(\"https://api.x.ai/v1\"),\n \"mistral\" => Some(\"https://api.mistral.ai/v1\"),\n \"ollama\" => Some(\"http://localhost:11434\"),\n _ => None,\n }\n}\n\nfn default_api_type_for_provider(provider: &str) -> &'static str {\n match provider {\n \"anthropic\" | \"minimax\" => \"anthropic\",\n \"gemini\" => \"openai\",\n \"ollama\" => \"ollama\",\n _ => \"openai\",\n }\n}\n\nfn prompt_select(title: &str, options: &[&str], default_index: usize) -> anyhow::Result {\n println!(\"{}\", title);\n for (i, opt) in options.iter().enumerate() {\n println!(\" {}. {}\", i + 1, opt);\n }\n\n loop {\n let input = prompt_line(&format!(\n \"Enter choice [1-{}] (default {}): \",\n options.len(),\n default_index + 1\n ))?;\n\n if input.trim().is_empty() {\n return Ok(default_index);\n }\n\n if let Ok(n) = input.trim().parse::() {\n if n >= 1 && n <= options.len() {\n return Ok(n - 1);\n }\n }\n println!(\"Invalid choice, please try again.\");\n }\n}\n\nfn prompt_optional_with_existing(label: &str, existing: &str) -> anyhow::Result {\n let prompt = if existing.trim().is_empty() {\n format!(\"{}: \", label)\n } else {\n format!(\"{} (press Enter to keep existing): \", label)\n };\n prompt_line(&prompt)\n}\n\nfn prompt_line_with_default(label: &str, default: &str) -> anyhow::Result {\n let input = prompt_line(&format!(\"{} [{}]: \", label, default))?;\n if input.trim().is_empty() {\n Ok(default.to_string())\n } else {\n Ok(input)\n }\n}\n\nfn prompt_line(prompt: &str) -> anyhow::Result {\n print!(\"{}\", prompt);\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin().read_line(&mut input)?;\n Ok(input.trim().to_string())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_provider_aliases() {\n assert_eq!(normalize_provider(\"moonshot\"), Some(\"kimi\"));\n assert_eq!(normalize_provider(\"claude\"), Some(\"anthropic\"));\n assert_eq!(normalize_provider(\"zhipu\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"glm\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"qwen\"), Some(\"qwen\"));\n assert_eq!(normalize_provider(\"grok\"), Some(\"xai\"));\n assert_eq!(normalize_provider(\"mistral\"), Some(\"mistral\"));\n assert_eq!(normalize_provider(\"minimax\"), Some(\"minimax\"));\n assert_eq!(normalize_provider(\"groq\"), Some(\"groq\"));\n assert_eq!(normalize_provider(\"siliconflow\"), Some(\"siliconflow\"));\n assert_eq!(normalize_provider(\"openrouter\"), Some(\"openrouter\"));\n assert_eq!(normalize_provider(\"none\"), Some(\"skip\"));\n assert_eq!(normalize_provider(\"unknown\"), None);\n }\n\n #[test]\n fn test_normalize_channel_aliases() {\n assert_eq!(normalize_channel(\"wechatwork\"), Some(\"wecom\"));\n assert_eq!(normalize_channel(\"dingtalk\"), Some(\"dingtalk\"));\n assert_eq!(normalize_channel(\"qq\"), Some(\"qq\"));\n assert_eq!(normalize_channel(\"napcat\"), Some(\"napcat\"));\n assert_eq!(normalize_channel(\"napcatqq\"), Some(\"napcat\"));\n assert_eq!(normalize_channel(\"skip\"), Some(\"skip\"));\n assert_eq!(normalize_channel(\"unknown\"), None);\n }\n\n #[test]\n fn test_default_model() {\n assert_eq!(default_model_for_provider(\"deepseek\"), \"deepseek-v4-pro\");\n assert_eq!(default_model_for_provider(\"openai\"), \"gpt-5.5\");\n assert_eq!(default_model_for_provider(\"anthropic\"), \"claude-opus-4-8\");\n assert_eq!(default_model_for_provider(\"kimi\"), \"kimi-k2.6\");\n assert_eq!(default_model_for_provider(\"moonshot\"), \"kimi-k2.6\");\n assert_eq!(\n default_model_for_provider(\"gemini\"),\n \"gemini-3.1-pro-preview\"\n );\n assert_eq!(default_model_for_provider(\"zhipu\"), \"glm-5.2\");\n assert_eq!(default_model_for_provider(\"glm\"), \"glm-5.2\");\n assert_eq!(default_model_for_provider(\"qwen\"), \"qwen3.7-max\");\n assert_eq!(default_model_for_provider(\"xai\"), \"grok-4.3\");\n assert_eq!(default_model_for_provider(\"mistral\"), \"mistral-medium-2604\");\n assert_eq!(default_model_for_provider(\"minimax\"), \"MiniMax-M3\");\n assert_eq!(default_model_for_provider(\"groq\"), \"openai/gpt-oss-120b\");\n assert_eq!(\n default_model_for_provider(\"siliconflow\"),\n \"deepseek-ai/DeepSeek-V4-Pro\"\n );\n assert_eq!(default_model_for_provider(\"openrouter\"), \"openai/gpt-5.5\");\n assert_eq!(default_model_for_provider(\"ollama\"), \"qwen3.6\");\n }\n\n #[test]\n fn test_configure_provider_populates_legacy_single_model_fields_for_compat() {\n let mut config = Config::default();\n config.agents.defaults.model = \"legacy-model\".to_string();\n config.agents.defaults.provider = Some(\"legacy-provider\".to_string());\n\n configure_provider(\n &mut config,\n \"deepseek\",\n Some(\"test-deepseek-key\"),\n Some(\"deepseek-chat\"),\n )\n .expect(\"configure_provider should succeed\");\n\n assert_eq!(\n config.agents.defaults.provider,\n Some(\"deepseek\".to_string())\n );\n assert_eq!(config.agents.defaults.model, \"deepseek-chat\");\n assert_eq!(config.agents.defaults.model_pool.len(), 1);\n let entry = &config.agents.defaults.model_pool[0];\n assert_eq!(entry.provider, \"deepseek\");\n assert_eq!(entry.model, \"deepseek-chat\");\n assert_eq!(entry.weight, 1);\n assert_eq!(entry.priority, 1);\n }\n\n #[test]\n fn test_ensure_channel_owner_defaults_to_default_agent() {\n let mut config = Config::default();\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"default\")\n );\n }\n\n #[test]\n fn test_ensure_channel_owner_keeps_existing() {\n let mut config = Config::default();\n config\n .channel_owners\n .insert(\"telegram\".to_string(), \"ops\".to_string());\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"ops\")\n );\n }\n}\n"}, "cc0ade7c838a45b5a25824c8e5e86e4b8ea9219d": {"t": 1785760739.9664688, "n": 26851, "h": "3bd680b953693936", "c": "use anyhow::{anyhow, bail, Context};\nuse blockcell_core::config::{ModelEntry, ToolCallMode};\nuse blockcell_core::{Config, Paths};\nuse std::io::{self, Write};\n\nuse super::provider_defaults::default_model_for_provider;\n\n/// Interactive first-run setup wizard for provider + optional channel.\npub async fn run(\n force: bool,\n provider: Option,\n api_key: Option,\n model: Option,\n channel: Option,\n skip_provider_test: bool,\n) -> anyhow::Result<()> {\n let paths = Paths::new();\n paths.ensure_dirs()?;\n\n let config_path = paths.config_file();\n let mut config = if config_path.exists() && !force {\n Config::load(&config_path)\n .with_context(|| format!(\"Failed to load {}\", config_path.display()))?\n } else {\n if config_path.exists() && force {\n println!(\"⚠ --force enabled: reset existing config to defaults before setup.\");\n }\n Config::default()\n };\n\n if config.intent_router.is_none() {\n config.intent_router = Config::default().intent_router;\n }\n\n println!(\"blockcell setup\");\n println!(\"==============\");\n println!(\"Config file: {}\", config_path.display());\n println!();\n\n let selected_provider = resolve_provider(provider.as_deref())?;\n let provider_name = if let Some(name) = selected_provider {\n name.to_string()\n } else {\n prompt_provider(&config)?\n };\n\n if provider_name != \"skip\" {\n configure_provider(\n &mut config,\n &provider_name,\n api_key.as_deref(),\n model.as_deref(),\n )?;\n } else {\n println!(\"Skipped LLM provider setup.\");\n }\n\n let selected_channel = resolve_channel(channel.as_deref())?;\n let channel_name = if let Some(name) = selected_channel {\n name.to_string()\n } else {\n prompt_channel()?\n };\n\n if channel_name != \"skip\" {\n configure_channel(&mut config, &channel_name)?;\n ensure_channel_owner(&mut config, &channel_name);\n } else {\n println!(\"Skipped channel setup (WebUI only).\");\n }\n\n config\n .save(&config_path)\n .with_context(|| format!(\"Failed to save {}\", config_path.display()))?;\n\n println!();\n println!(\"✓ Setup completed\");\n println!(\" Config: {}\", config_path.display());\n if provider_name != \"skip\" {\n let selected = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider_name)\n .or_else(|| primary_pool_entry(&config));\n let selected_model = selected\n .map(|e| e.model.as_str())\n .unwrap_or(config.agents.defaults.model.as_str());\n println!(\" Provider: {} Model: {}\", provider_name, selected_model);\n if !skip_provider_test {\n match blockcell_providers::create_provider(\n &config,\n selected_model,\n Some(&provider_name),\n ) {\n Ok(_) => println!(\" ✓ Provider config validation passed\"),\n Err(e) => println!(\" ⚠ Provider validation failed: {}\", e),\n }\n }\n }\n if channel_name != \"skip\" {\n println!(\" Channel: {} (enabled)\", channel_name);\n } else {\n println!(\" Channel: none (use WebUI only)\");\n }\n println!();\n println!(\"Next steps:\");\n println!(\" 1. blockcell status\");\n println!(\" 2. blockcell gateway\");\n println!(\" 3. Open WebUI: http://localhost:18791/\");\n\n Ok(())\n}\n\nfn configure_provider(\n config: &mut Config,\n provider: &str,\n api_key_flag: Option<&str>,\n model_flag: Option<&str>,\n) -> anyhow::Result<()> {\n let entry = config.providers.entry(provider.to_string()).or_default();\n\n if entry.api_base.is_none() {\n entry.api_base = default_api_base_for_provider(provider).map(|s| s.to_string());\n }\n if entry.api_type.is_empty() {\n entry.api_type = default_api_type_for_provider(provider).to_string();\n }\n\n if provider == \"ollama\" {\n if entry.api_key.trim().is_empty() {\n entry.api_key = \"ollama\".to_string();\n }\n if entry.api_base.is_none() {\n entry.api_base = Some(\"http://localhost:11434\".to_string());\n }\n } else {\n let has_existing = !entry.api_key.trim().is_empty() && entry.api_key != \"dummy\";\n let final_key = if let Some(flag) = api_key_flag {\n flag.trim().to_string()\n } else if has_existing {\n prompt_line(\"API key (press Enter to keep existing): \")?\n } else {\n prompt_line(\"API key: \")?\n };\n\n if !final_key.is_empty() {\n entry.api_key = final_key;\n }\n\n if entry.api_key.trim().is_empty() || entry.api_key == \"dummy\" {\n bail!(\"Provider '{}' requires a valid API key.\", provider);\n }\n }\n\n let suggested_model = if let Some(entry) = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider)\n {\n entry.model.clone()\n } else {\n default_model_for_provider(provider).to_string()\n };\n\n let final_model = if let Some(m) = model_flag {\n m.trim().to_string()\n } else {\n prompt_line_with_default(\"Model\", &suggested_model)?\n };\n if final_model.trim().is_empty() {\n bail!(\"Model cannot be empty.\");\n }\n\n // Setup writes provider/model to model_pool first, and also mirrors to legacy\n // single-model fields for backward compatibility with older runtimes.\n config.agents.defaults.model_pool = vec![ModelEntry {\n model: final_model.clone(),\n provider: provider.to_string(),\n weight: 1,\n priority: 1,\n input_price: None,\n output_price: None,\n temperature: None,\n tool_call_mode: ToolCallMode::Native,\n }];\n config.agents.defaults.provider = Some(provider.to_string());\n config.agents.defaults.model = final_model;\n\n Ok(())\n}\n\nfn configure_channel(config: &mut Config, channel: &str) -> anyhow::Result<()> {\n match channel {\n \"telegram\" => {\n let existing = config.channels.telegram.token.clone();\n let prompt = if existing.trim().is_empty() {\n \"Telegram bot token: \"\n } else {\n \"Telegram bot token (press Enter to keep existing): \"\n };\n let token = prompt_line(prompt)?;\n if !token.is_empty() {\n config.channels.telegram.token = token;\n }\n if config.channels.telegram.token.trim().is_empty() {\n bail!(\"Telegram token is required.\");\n }\n config.channels.telegram.enabled = true;\n }\n \"feishu\" => {\n let app_id =\n prompt_optional_with_existing(\"Feishu app_id\", &config.channels.feishu.app_id)?;\n let app_secret = prompt_optional_with_existing(\n \"Feishu app_secret\",\n &config.channels.feishu.app_secret,\n )?;\n\n if !app_id.is_empty() {\n config.channels.feishu.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.feishu.app_secret = app_secret;\n }\n\n if config.channels.feishu.app_id.trim().is_empty()\n || config.channels.feishu.app_secret.trim().is_empty()\n {\n bail!(\"Feishu app_id and app_secret are required.\");\n }\n config.channels.feishu.enabled = true;\n }\n \"wecom\" => {\n let corp_id =\n prompt_optional_with_existing(\"WeCom corp_id\", &config.channels.wecom.corp_id)?;\n let corp_secret = prompt_optional_with_existing(\n \"WeCom corp_secret\",\n &config.channels.wecom.corp_secret,\n )?;\n\n if !corp_id.is_empty() {\n config.channels.wecom.corp_id = corp_id;\n }\n if !corp_secret.is_empty() {\n config.channels.wecom.corp_secret = corp_secret;\n }\n\n if config.channels.wecom.corp_id.trim().is_empty()\n || config.channels.wecom.corp_secret.trim().is_empty()\n {\n bail!(\"WeCom corp_id and corp_secret are required.\");\n }\n\n let existing_agent = if config.channels.wecom.agent_id > 0 {\n config.channels.wecom.agent_id.to_string()\n } else {\n \"1000002\".to_string()\n };\n let agent_id_str = prompt_line_with_default(\"WeCom agent_id\", &existing_agent)?;\n let agent_id = agent_id_str\n .trim()\n .parse::()\n .map_err(|_| anyhow!(\"agent_id must be an integer\"))?;\n config.channels.wecom.agent_id = agent_id;\n config.channels.wecom.enabled = true;\n }\n \"dingtalk\" => {\n let app_key = prompt_optional_with_existing(\n \"DingTalk app_key\",\n &config.channels.dingtalk.app_key,\n )?;\n let app_secret = prompt_optional_with_existing(\n \"DingTalk app_secret\",\n &config.channels.dingtalk.app_secret,\n )?;\n if !app_key.is_empty() {\n config.channels.dingtalk.app_key = app_key;\n }\n if !app_secret.is_empty() {\n config.channels.dingtalk.app_secret = app_secret;\n }\n if config.channels.dingtalk.app_key.trim().is_empty()\n || config.channels.dingtalk.app_secret.trim().is_empty()\n {\n bail!(\"DingTalk app_key and app_secret are required.\");\n }\n config.channels.dingtalk.enabled = true;\n }\n \"lark\" => {\n let app_id =\n prompt_optional_with_existing(\"Lark app_id\", &config.channels.lark.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"Lark app_secret\", &config.channels.lark.app_secret)?;\n if !app_id.is_empty() {\n config.channels.lark.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.lark.app_secret = app_secret;\n }\n if config.channels.lark.app_id.trim().is_empty()\n || config.channels.lark.app_secret.trim().is_empty()\n {\n bail!(\"Lark app_id and app_secret are required.\");\n }\n config.channels.lark.enabled = true;\n }\n \"qq\" => {\n let app_id = prompt_optional_with_existing(\"QQ app_id\", &config.channels.qq.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"QQ app_secret\", &config.channels.qq.app_secret)?;\n if !app_id.is_empty() {\n config.channels.qq.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.qq.app_secret = app_secret;\n }\n if config.channels.qq.app_id.trim().is_empty()\n || config.channels.qq.app_secret.trim().is_empty()\n {\n bail!(\"QQ app_id and app_secret are required.\");\n }\n let environment = prompt_optional_with_existing(\n \"QQ environment (production/sandbox)\",\n &config.channels.qq.environment,\n )?;\n if !environment.is_empty() {\n config.channels.qq.environment = environment;\n }\n if config.channels.qq.environment.trim().is_empty() {\n config.channels.qq.environment = \"production\".to_string();\n }\n config.channels.qq.enabled = true;\n }\n \"napcat\" => {\n let mode_options = [\n \"ws-client (BlockCell connects to NapCatQQ WebSocket server)\",\n \"ws-server (NapCatQQ connects to BlockCell WebSocket server)\",\n ];\n let mode_idx = prompt_select(\"Connection mode\", &mode_options, 0)?;\n let mode = match mode_idx {\n 0 => \"ws-client\",\n 1 => \"ws-server\",\n _ => \"ws-client\",\n }\n .to_string();\n\n // WebSocket URL (only for ws-client mode)\n let ws_url = if mode == \"ws-client\" {\n prompt_line_with_default(\"WebSocket URL\", \"ws://127.0.0.1:3001\")?\n } else {\n String::new()\n };\n\n // Server configuration for ws-server mode\n let (server_host, server_port, server_path) = if mode == \"ws-server\" {\n let host = prompt_line_with_default(\"WebSocket server host\", \"0.0.0.0\")?;\n // NapCatQQ client 默认连接 ws://localhost:13005\n let port_str = prompt_line_with_default(\"WebSocket server port\", \"13005\")?;\n let port = port_str.parse::().unwrap_or(13005);\n // NapCatQQ client 默认连接根路径 /\n let path = prompt_line_with_default(\"WebSocket server path\", \"/\")?;\n (host, port, path)\n } else {\n (String::new(), 0, String::new())\n };\n\n let access_token = prompt_line(\"Access token (optional, press Enter to skip)\")?;\n\n // Group response mode\n let mode_options = [\n \"all - 响应所有群聊消息\",\n \"at_only - 仅响应@我的群聊消息\",\n \"none - 不响应任何群聊消息\",\n ];\n let mode_idx = prompt_select(\"群聊响应模式\", &mode_options, 0)?;\n let group_response_mode = match mode_idx {\n 0 => \"all\",\n 1 => \"at_only\",\n 2 => \"none\",\n _ => \"all\",\n }\n .to_string();\n\n config.channels.napcat.enabled = true;\n config.channels.napcat.mode = mode;\n config.channels.napcat.ws_url = ws_url;\n config.channels.napcat.server_host = server_host;\n config.channels.napcat.server_port = server_port;\n config.channels.napcat.server_path = server_path;\n config.channels.napcat.access_token = access_token;\n config.channels.napcat.group_response_mode = group_response_mode;\n\n // 媒体自动下载配置\n let auto_download_options = [\n \"是 - 自动下载图片、语音、视频、文件等媒体\",\n \"否 - 不自动下载媒体\",\n ];\n let auto_idx = prompt_select(\"是否自动下载媒体文件\", &auto_download_options, 0)?;\n config.channels.napcat.auto_download_media = auto_idx == 0;\n\n if config.channels.napcat.auto_download_media {\n let max_size_str = prompt_line_with_default(\"最大自动下载大小 (字节)\", \"10485760\")?;\n if let Ok(max_size) = max_size_str.parse::() {\n config.channels.napcat.max_auto_download_size = max_size;\n }\n\n let download_dir =\n prompt_line_with_default(\"媒体下载目录 (相对于 workspace)\", \"downloads\")?;\n if !download_dir.trim().is_empty() {\n config.channels.napcat.media_download_dir = download_dir;\n }\n }\n }\n _ => bail!(\"Unsupported channel '{}'\", channel),\n }\n\n Ok(())\n}\n\nfn ensure_channel_owner(config: &mut Config, channel: &str) {\n if config.resolve_channel_owner(channel).is_some() {\n return;\n }\n let owner = config\n .known_agent_ids()\n .into_iter()\n .next()\n .unwrap_or_else(|| \"default\".to_string());\n config\n .channel_owners\n .insert(channel.to_string(), owner.clone());\n println!(\"Assigned channel owner: {} -> {}\", channel, owner);\n}\n\nfn prompt_provider(config: &Config) -> anyhow::Result {\n let current = primary_pool_entry(config)\n .map(|e| e.provider.clone())\n .or_else(|| {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| !e.provider.trim().is_empty())\n .map(|e| e.provider.clone())\n })\n .or_else(|| config.agents.defaults.provider.clone())\n .or_else(|| config.get_api_key().map(|(name, _)| name.to_string()))\n .unwrap_or_else(|| \"none\".to_string());\n\n println!(\"Configure LLM provider (current: {})\", current);\n let options = [\n \"deepseek (recommended)\",\n \"openai\",\n \"kimi\",\n \"anthropic\",\n \"gemini\",\n \"zhipu\",\n \"qwen\",\n \"xai\",\n \"mistral\",\n \"minimax\",\n \"groq\",\n \"siliconflow\",\n \"openrouter\",\n \"ollama (local)\",\n \"skip\",\n ];\n let idx = prompt_select(\"Choose provider\", &options, 0)?;\n let mapped = match idx {\n 0 => \"deepseek\",\n 1 => \"openai\",\n 2 => \"kimi\",\n 3 => \"anthropic\",\n 4 => \"gemini\",\n 5 => \"zhipu\",\n 6 => \"qwen\",\n 7 => \"xai\",\n 8 => \"mistral\",\n 9 => \"minimax\",\n 10 => \"groq\",\n 11 => \"siliconflow\",\n 12 => \"openrouter\",\n 13 => \"ollama\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn primary_pool_entry(config: &Config) -> Option<&ModelEntry> {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .min_by(|a, b| a.priority.cmp(&b.priority).then(b.weight.cmp(&a.weight)))\n}\n\nfn prompt_channel() -> anyhow::Result {\n let options = [\n \"skip (WebUI only)\",\n \"telegram\",\n \"feishu\",\n \"wecom\",\n \"dingtalk\",\n \"lark\",\n \"qq\",\n \"napcat\",\n ];\n let idx = prompt_select(\"Configure one channel (optional)\", &options, 0)?;\n let mapped = match idx {\n 1 => \"telegram\",\n 2 => \"feishu\",\n 3 => \"wecom\",\n 4 => \"dingtalk\",\n 5 => \"lark\",\n 6 => \"qq\",\n 7 => \"napcat\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn resolve_provider(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_provider(v)\n .ok_or_else(|| anyhow!(\"Unsupported provider '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn resolve_channel(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_channel(v)\n .ok_or_else(|| anyhow!(\"Unsupported channel '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn normalize_provider(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"deepseek\" => Some(\"deepseek\"),\n \"openai\" => Some(\"openai\"),\n \"kimi\" | \"moonshot\" => Some(\"kimi\"),\n \"anthropic\" | \"claude\" => Some(\"anthropic\"),\n \"gemini\" => Some(\"gemini\"),\n \"zhipu\" | \"glm\" | \"zai\" | \"z.ai\" => Some(\"zhipu\"),\n \"qwen\" | \"tongyi\" | \"dashscope\" => Some(\"qwen\"),\n \"xai\" | \"grok\" => Some(\"xai\"),\n \"mistral\" => Some(\"mistral\"),\n \"minimax\" => Some(\"minimax\"),\n \"groq\" => Some(\"groq\"),\n \"siliconflow\" => Some(\"siliconflow\"),\n \"openrouter\" => Some(\"openrouter\"),\n \"ollama\" => Some(\"ollama\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn normalize_channel(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"telegram\" => Some(\"telegram\"),\n \"feishu\" => Some(\"feishu\"),\n \"wecom\" | \"wechatwork\" => Some(\"wecom\"),\n \"dingtalk\" => Some(\"dingtalk\"),\n \"lark\" => Some(\"lark\"),\n \"qq\" => Some(\"qq\"),\n \"napcat\" | \"napcatqq\" => Some(\"napcat\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn default_api_base_for_provider(provider: &str) -> Option<&'static str> {\n match provider {\n \"deepseek\" => Some(\"https://api.deepseek.com/v1\"),\n \"openai\" => Some(\"https://api.openai.com/v1\"),\n \"anthropic\" => Some(\"https://api.anthropic.com\"),\n \"kimi\" => Some(\"https://api.moonshot.cn/v1\"),\n \"gemini\" => Some(\"https://generativelanguage.googleapis.com/v1beta/openai\"),\n \"zhipu\" | \"glm\" => Some(\"https://open.bigmodel.cn/api/paas/v4\"),\n \"minimax\" => Some(\"https://api.minimaxi.com/v1\"),\n \"groq\" => Some(\"https://api.groq.com/openai/v1\"),\n \"siliconflow\" => Some(\"https://api.siliconflow.cn/v1\"),\n \"openrouter\" => Some(\"https://openrouter.ai/api/v1\"),\n \"qwen\" => Some(\"https://api.qwen.ai/v1\"),\n \"xai\" => Some(\"https://api.x.ai/v1\"),\n \"mistral\" => Some(\"https://api.mistral.ai/v1\"),\n \"ollama\" => Some(\"http://localhost:11434\"),\n _ => None,\n }\n}\n\nfn default_api_type_for_provider(provider: &str) -> &'static str {\n match provider {\n \"anthropic\" | \"minimax\" => \"anthropic\",\n \"gemini\" => \"openai\",\n \"ollama\" => \"ollama\",\n _ => \"openai\",\n }\n}\n\nfn prompt_select(title: &str, options: &[&str], default_index: usize) -> anyhow::Result {\n println!(\"{}\", title);\n for (i, opt) in options.iter().enumerate() {\n println!(\" {}. {}\", i + 1, opt);\n }\n\n loop {\n let input = prompt_line(&format!(\n \"Enter choice [1-{}] (default {}): \",\n options.len(),\n default_index + 1\n ))?;\n\n if input.trim().is_empty() {\n return Ok(default_index);\n }\n\n if let Ok(n) = input.trim().parse::() {\n if n >= 1 && n <= options.len() {\n return Ok(n - 1);\n }\n }\n println!(\"Invalid choice, please try again.\");\n }\n}\n\nfn prompt_optional_with_existing(label: &str, existing: &str) -> anyhow::Result {\n let prompt = if existing.trim().is_empty() {\n format!(\"{}: \", label)\n } else {\n format!(\"{} (press Enter to keep existing): \", label)\n };\n prompt_line(&prompt)\n}\n\nfn prompt_line_with_default(label: &str, default: &str) -> anyhow::Result {\n let input = prompt_line(&format!(\"{} [{}]: \", label, default))?;\n if input.trim().is_empty() {\n Ok(default.to_string())\n } else {\n Ok(input)\n }\n}\n\nfn prompt_line(prompt: &str) -> anyhow::Result {\n print!(\"{}\", prompt);\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin().read_line(&mut input)?;\n Ok(input.trim().to_string())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_provider_aliases() {\n assert_eq!(normalize_provider(\"moonshot\"), Some(\"kimi\"));\n assert_eq!(normalize_provider(\"claude\"), Some(\"anthropic\"));\n assert_eq!(normalize_provider(\"zhipu\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"glm\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"qwen\"), Some(\"qwen\"));\n assert_eq!(normalize_provider(\"grok\"), Some(\"xai\"));\n assert_eq!(normalize_provider(\"mistral\"), Some(\"mistral\"));\n assert_eq!(normalize_provider(\"minimax\"), Some(\"minimax\"));\n assert_eq!(normalize_provider(\"groq\"), Some(\"groq\"));\n assert_eq!(normalize_provider(\"siliconflow\"), Some(\"siliconflow\"));\n assert_eq!(normalize_provider(\"openrouter\"), Some(\"openrouter\"));\n assert_eq!(normalize_provider(\"none\"), Some(\"skip\"));\n assert_eq!(normalize_provider(\"unknown\"), None);\n }\n\n #[test]\n fn test_normalize_channel_aliases() {\n assert_eq!(normalize_channel(\"wechatwork\"), Some(\"wecom\"));\n assert_eq!(normalize_channel(\"dingtalk\"), Some(\"dingtalk\"));\n assert_eq!(normalize_channel(\"qq\"), Some(\"qq\"));\n assert_eq!(normalize_channel(\"napcat\"), Some(\"napcat\"));\n assert_eq!(normalize_channel(\"napcatqq\"), Some(\"napcat\"));\n assert_eq!(normalize_channel(\"skip\"), Some(\"skip\"));\n assert_eq!(normalize_channel(\"unknown\"), None);\n }\n\n #[test]\n fn test_default_model() {\n assert_eq!(default_model_for_provider(\"deepseek\"), \"deepseek-v4-pro\");\n assert_eq!(default_model_for_provider(\"openai\"), \"gpt-5.5\");\n assert_eq!(default_model_for_provider(\"anthropic\"), \"claude-opus-4-8\");\n assert_eq!(default_model_for_provider(\"kimi\"), \"kimi-k2.6\");\n assert_eq!(default_model_for_provider(\"moonshot\"), \"kimi-k2.6\");\n assert_eq!(\n default_model_for_provider(\"gemini\"),\n \"gemini-3.1-pro-preview\"\n );\n assert_eq!(default_model_for_provider(\"zhipu\"), \"glm-5.2\");\n assert_eq!(default_model_for_provider(\"glm\"), \"glm-5.2\");\n assert_eq!(default_model_for_provider(\"qwen\"), \"qwen3.7-max\");\n assert_eq!(default_model_for_provider(\"xai\"), \"grok-4.3\");\n assert_eq!(default_model_for_provider(\"mistral\"), \"mistral-medium-2604\");\n assert_eq!(default_model_for_provider(\"minimax\"), \"MiniMax-M3\");\n assert_eq!(default_model_for_provider(\"groq\"), \"openai/gpt-oss-120b\");\n assert_eq!(\n default_model_for_provider(\"siliconflow\"),\n \"deepseek-ai/DeepSeek-V4-Pro\"\n );\n assert_eq!(default_model_for_provider(\"openrouter\"), \"openai/gpt-5.5\");\n assert_eq!(default_model_for_provider(\"ollama\"), \"qwen3.6\");\n }\n\n #[test]\n fn test_configure_provider_populates_legacy_single_model_fields_for_compat() {\n let mut config = Config::default();\n config.agents.defaults.model = \"legacy-model\".to_string();\n config.agents.defaults.provider = Some(\"legacy-provider\".to_string());\n\n configure_provider(\n &mut config,\n \"deepseek\",\n Some(\"test-deepseek-key\"),\n Some(\"deepseek-chat\"),\n )\n .expect(\"configure_provider should succeed\");\n\n assert_eq!(\n config.agents.defaults.provider,\n Some(\"deepseek\".to_string())\n );\n assert_eq!(config.agents.defaults.model, \"deepseek-chat\");\n assert_eq!(config.agents.defaults.model_pool.len(), 1);\n let entry = &config.agents.defaults.model_pool[0];\n assert_eq!(entry.provider, \"deepseek\");\n assert_eq!(entry.model, \"deepseek-chat\");\n assert_eq!(entry.weight, 1);\n assert_eq!(entry.priority, 1);\n }\n\n #[test]\n fn test_ensure_channel_owner_defaults_to_default_agent() {\n let mut config = Config::default();\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"default\")\n );\n }\n\n #[test]\n fn test_ensure_channel_owner_keeps_existing() {\n let mut config = Config::default();\n config\n .channel_owners\n .insert(\"telegram\".to_string(), \"ops\".to_string());\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"ops\")\n );\n }\n}\n"}, "e22deaf8566bbf571de4771d8001d8407bd58418": {"t": 1785760741.3687146, "n": 26852, "h": "1e1eef41be258165", "c": "use anyhow::{anyhow, bail, Context};\nuse blockcell_core::config::{ModelEntry, ToolCallMode};\nuse blockcell_core::{Config, Paths};\nuse std::io::{self, Write};\n\nuse super::provider_defaults::default_model_for_provider;\n\n/// Interactive first-run setup wizard for provider + optional channel.\npub async fn run(\n force: bool,\n provider: Option,\n api_key: Option,\n model: Option,\n channel: Option,\n skip_provider_test: bool,\n) -> anyhow::Result<()> {\n let paths = Paths::new();\n paths.ensure_dirs()?;\n\n let config_path = paths.config_file();\n let mut config = if config_path.exists() && !force {\n Config::load(&config_path)\n .with_context(|| format!(\"Failed to load {}\", config_path.display()))?\n } else {\n if config_path.exists() && force {\n println!(\"⚠ --force enabled: reset existing config to defaults before setup.\");\n }\n Config::default()\n };\n\n if config.intent_router.is_none() {\n config.intent_router = Config::default().intent_router;\n }\n\n println!(\"blockcell setup\");\n println!(\"==============\");\n println!(\"Config file: {}\", config_path.display());\n println!();\n\n let selected_provider = resolve_provider(provider.as_deref())?;\n let provider_name = if let Some(name) = selected_provider {\n name.to_string()\n } else {\n prompt_provider(&config)?\n };\n\n if provider_name != \"skip\" {\n configure_provider(\n &mut config,\n &provider_name,\n api_key.as_deref(),\n model.as_deref(),\n )?;\n } else {\n println!(\"Skipped LLM provider setup.\");\n }\n\n let selected_channel = resolve_channel(channel.as_deref())?;\n let channel_name = if let Some(name) = selected_channel {\n name.to_string()\n } else {\n prompt_channel()?\n };\n\n if channel_name != \"skip\" {\n configure_channel(&mut config, &channel_name)?;\n ensure_channel_owner(&mut config, &channel_name);\n } else {\n println!(\"Skipped channel setup (WebUI only).\");\n }\n\n config\n .save(&config_path)\n .with_context(|| format!(\"Failed to save {}\", config_path.display()))?;\n\n println!();\n println!(\"✓ Setup completed\");\n println!(\" Config: {}\", config_path.display());\n if provider_name != \"skip\" {\n let selected = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider_name)\n .or_else(|| primary_pool_entry(&config));\n let selected_model = selected\n .map(|e| e.model.as_str())\n .unwrap_or(config.agents.defaults.model.as_str());\n println!(\" Provider: {} Model: {}\", provider_name, selected_model);\n if !skip_provider_test {\n match blockcell_providers::create_provider(\n &config,\n selected_model,\n Some(&provider_name),\n ) {\n Ok(_) => println!(\" ✓ Provider config validation passed\"),\n Err(e) => println!(\" ⚠ Provider validation failed: {}\", e),\n }\n }\n }\n if channel_name != \"skip\" {\n println!(\" Channel: {} (enabled)\", channel_name);\n } else {\n println!(\" Channel: none (use WebUI only)\");\n }\n println!();\n println!(\"Next steps:\");\n println!(\" 1. blockcell status\");\n println!(\" 2. blockcell gateway\");\n println!(\" 3. Open WebUI: http://localhost:18791/\");\n\n Ok(())\n}\n\nfn configure_provider(\n config: &mut Config,\n provider: &str,\n api_key_flag: Option<&str>,\n model_flag: Option<&str>,\n) -> anyhow::Result<()> {\n let entry = config.providers.entry(provider.to_string()).or_default();\n\n if entry.api_base.is_none() {\n entry.api_base = default_api_base_for_provider(provider).map(|s| s.to_string());\n }\n if entry.api_type.is_empty() {\n entry.api_type = default_api_type_for_provider(provider).to_string();\n }\n\n if provider == \"ollama\" {\n if entry.api_key.trim().is_empty() {\n entry.api_key = \"ollama\".to_string();\n }\n if entry.api_base.is_none() {\n entry.api_base = Some(\"http://localhost:11434\".to_string());\n }\n } else {\n let has_existing = !entry.api_key.trim().is_empty() && entry.api_key != \"dummy\";\n let final_key = if let Some(flag) = api_key_flag {\n flag.trim().to_string()\n } else if has_existing {\n prompt_line(\"API key (press Enter to keep existing): \")?\n } else {\n prompt_line(\"API key: \")?\n };\n\n if !final_key.is_empty() {\n entry.api_key = final_key;\n }\n\n if entry.api_key.trim().is_empty() || entry.api_key == \"dummy\" {\n bail!(\"Provider '{}' requires a valid API key.\", provider);\n }\n }\n\n let suggested_model = if let Some(entry) = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider)\n {\n entry.model.clone()\n } else {\n default_model_for_provider(provider).to_string()\n };\n\n let final_model = if let Some(m) = model_flag {\n m.trim().to_string()\n } else {\n prompt_line_with_default(\"Model\", &suggested_model)?\n };\n if final_model.trim().is_empty() {\n bail!(\"Model cannot be empty.\");\n }\n\n // Setup writes provider/model to model_pool first, and also mirrors to legacy\n // single-model fields for backward compatibility with older runtimes.\n config.agents.defaults.model_pool = vec![ModelEntry {\n model: final_model.clone(),\n provider: provider.to_string(),\n weight: 1,\n priority: 1,\n input_price: None,\n output_price: None,\n temperature: None,\n tool_call_mode: ToolCallMode::Native,\n }];\n config.agents.defaults.provider = Some(provider.to_string());\n config.agents.defaults.model = final_model;\n\n Ok(())\n}\n\nfn configure_channel(config: &mut Config, channel: &str) -> anyhow::Result<()> {\n match channel {\n \"telegram\" => {\n let existing = config.channels.telegram.token.clone();\n let prompt = if existing.trim().is_empty() {\n \"Telegram bot token: \"\n } else {\n \"Telegram bot token (press Enter to keep existing): \"\n };\n let token = prompt_line(prompt)?;\n if !token.is_empty() {\n config.channels.telegram.token = token;\n }\n if config.channels.telegram.token.trim().is_empty() {\n bail!(\"Telegram token is required.\");\n }\n config.channels.telegram.enabled = true;\n }\n \"feishu\" => {\n let app_id =\n prompt_optional_with_existing(\"Feishu app_id\", &config.channels.feishu.app_id)?;\n let app_secret = prompt_optional_with_existing(\n \"Feishu app_secret\",\n &config.channels.feishu.app_secret,\n )?;\n\n if !app_id.is_empty() {\n config.channels.feishu.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.feishu.app_secret = app_secret;\n }\n\n if config.channels.feishu.app_id.trim().is_empty()\n || config.channels.feishu.app_secret.trim().is_empty()\n {\n bail!(\"Feishu app_id and app_secret are required.\");\n }\n config.channels.feishu.enabled = true;\n }\n \"wecom\" => {\n let corp_id =\n prompt_optional_with_existing(\"WeCom corp_id\", &config.channels.wecom.corp_id)?;\n let corp_secret = prompt_optional_with_existing(\n \"WeCom corp_secret\",\n &config.channels.wecom.corp_secret,\n )?;\n\n if !corp_id.is_empty() {\n config.channels.wecom.corp_id = corp_id;\n }\n if !corp_secret.is_empty() {\n config.channels.wecom.corp_secret = corp_secret;\n }\n\n if config.channels.wecom.corp_id.trim().is_empty()\n || config.channels.wecom.corp_secret.trim().is_empty()\n {\n bail!(\"WeCom corp_id and corp_secret are required.\");\n }\n\n let existing_agent = if config.channels.wecom.agent_id > 0 {\n config.channels.wecom.agent_id.to_string()\n } else {\n \"1000002\".to_string()\n };\n let agent_id_str = prompt_line_with_default(\"WeCom agent_id\", &existing_agent)?;\n let agent_id = agent_id_str\n .trim()\n .parse::()\n .map_err(|_| anyhow!(\"agent_id must be an integer\"))?;\n config.channels.wecom.agent_id = agent_id;\n config.channels.wecom.enabled = true;\n }\n \"dingtalk\" => {\n let app_key = prompt_optional_with_existing(\n \"DingTalk app_key\",\n &config.channels.dingtalk.app_key,\n )?;\n let app_secret = prompt_optional_with_existing(\n \"DingTalk app_secret\",\n &config.channels.dingtalk.app_secret,\n )?;\n if !app_key.is_empty() {\n config.channels.dingtalk.app_key = app_key;\n }\n if !app_secret.is_empty() {\n config.channels.dingtalk.app_secret = app_secret;\n }\n if config.channels.dingtalk.app_key.trim().is_empty()\n || config.channels.dingtalk.app_secret.trim().is_empty()\n {\n bail!(\"DingTalk app_key and app_secret are required.\");\n }\n config.channels.dingtalk.enabled = true;\n }\n \"lark\" => {\n let app_id =\n prompt_optional_with_existing(\"Lark app_id\", &config.channels.lark.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"Lark app_secret\", &config.channels.lark.app_secret)?;\n if !app_id.is_empty() {\n config.channels.lark.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.lark.app_secret = app_secret;\n }\n if config.channels.lark.app_id.trim().is_empty()\n || config.channels.lark.app_secret.trim().is_empty()\n {\n bail!(\"Lark app_id and app_secret are required.\");\n }\n config.channels.lark.enabled = true;\n }\n \"qq\" => {\n let app_id = prompt_optional_with_existing(\"QQ app_id\", &config.channels.qq.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"QQ app_secret\", &config.channels.qq.app_secret)?;\n if !app_id.is_empty() {\n config.channels.qq.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.qq.app_secret = app_secret;\n }\n if config.channels.qq.app_id.trim().is_empty()\n || config.channels.qq.app_secret.trim().is_empty()\n {\n bail!(\"QQ app_id and app_secret are required.\");\n }\n let environment = prompt_optional_with_existing(\n \"QQ environment (production/sandbox)\",\n &config.channels.qq.environment,\n )?;\n if !environment.is_empty() {\n config.channels.qq.environment = environment;\n }\n if config.channels.qq.environment.trim().is_empty() {\n config.channels.qq.environment = \"production\".to_string();\n }\n config.channels.qq.enabled = true;\n }\n \"napcat\" => {\n let mode_options = [\n \"ws-client (BlockCell connects to NapCatQQ WebSocket server)\",\n \"ws-server (NapCatQQ connects to BlockCell WebSocket server)\",\n ];\n let mode_idx = prompt_select(\"Connection mode\", &mode_options, 0)?;\n let mode = match mode_idx {\n 0 => \"ws-client\",\n 1 => \"ws-server\",\n _ => \"ws-client\",\n }\n .to_string();\n\n // WebSocket URL (only for ws-client mode)\n let ws_url = if mode == \"ws-client\" {\n prompt_line_with_default(\"WebSocket URL\", \"ws://127.0.0.1:3001\")?\n } else {\n String::new()\n };\n\n // Server configuration for ws-server mode\n let (server_host, server_port, server_path) = if mode == \"ws-server\" {\n let host = prompt_line_with_default(\"WebSocket server host\", \"0.0.0.0\")?;\n // NapCatQQ client 默认连接 ws://localhost:13005\n let port_str = prompt_line_with_default(\"WebSocket server port\", \"13005\")?;\n let port = port_str.parse::().unwrap_or(13005);\n // NapCatQQ client 默认连接根路径 /\n let path = prompt_line_with_default(\"WebSocket server path\", \"/\")?;\n (host, port, path)\n } else {\n (String::new(), 0, String::new())\n };\n\n let access_token = prompt_line(\"Access token (optional, press Enter to skip)\")?;\n\n // Group response mode\n let mode_options = [\n \"all - 响应所有群聊消息\",\n \"at_only - 仅响应@我的群聊消息\",\n \"none - 不响应任何群聊消息\",\n ];\n let mode_idx = prompt_select(\"群聊响应模式\", &mode_options, 0)?;\n let group_response_mode = match mode_idx {\n 0 => \"all\",\n 1 => \"at_only\",\n 2 => \"none\",\n _ => \"all\",\n }\n .to_string();\n\n config.channels.napcat.enabled = true;\n config.channels.napcat.mode = mode;\n config.channels.napcat.ws_url = ws_url;\n config.channels.napcat.server_host = server_host;\n config.channels.napcat.server_port = server_port;\n config.channels.napcat.server_path = server_path;\n config.channels.napcat.access_token = access_token;\n config.channels.napcat.group_response_mode = group_response_mode;\n\n // 媒体自动下载配置\n let auto_download_options = [\n \"是 - 自动下载图片、语音、视频、文件等媒体\",\n \"否 - 不自动下载媒体\",\n ];\n let auto_idx = prompt_select(\"是否自动下载媒体文件\", &auto_download_options, 0)?;\n config.channels.napcat.auto_download_media = auto_idx == 0;\n\n if config.channels.napcat.auto_download_media {\n let max_size_str = prompt_line_with_default(\"最大自动下载大小 (字节)\", \"10485760\")?;\n if let Ok(max_size) = max_size_str.parse::() {\n config.channels.napcat.max_auto_download_size = max_size;\n }\n\n let download_dir =\n prompt_line_with_default(\"媒体下载目录 (相对于 workspace)\", \"downloads\")?;\n if !download_dir.trim().is_empty() {\n config.channels.napcat.media_download_dir = download_dir;\n }\n }\n }\n _ => bail!(\"Unsupported channel '{}'\", channel),\n }\n\n Ok(())\n}\n\nfn ensure_channel_owner(config: &mut Config, channel: &str) {\n if config.resolve_channel_owner(channel).is_some() {\n return;\n }\n let owner = config\n .known_agent_ids()\n .into_iter()\n .next()\n .unwrap_or_else(|| \"default\".to_string());\n config\n .channel_owners\n .insert(channel.to_string(), owner.clone());\n println!(\"Assigned channel owner: {} -> {}\", channel, owner);\n}\n\nfn prompt_provider(config: &Config) -> anyhow::Result {\n let current = primary_pool_entry(config)\n .map(|e| e.provider.clone())\n .or_else(|| {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| !e.provider.trim().is_empty())\n .map(|e| e.provider.clone())\n })\n .or_else(|| config.agents.defaults.provider.clone())\n .or_else(|| config.get_api_key().map(|(name, _)| name.to_string()))\n .unwrap_or_else(|| \"none\".to_string());\n\n println!(\"Configure LLM provider (current: {})\", current);\n let options = [\n \"deepseek (recommended)\",\n \"openai\",\n \"kimi\",\n \"anthropic\",\n \"gemini\",\n \"zhipu\",\n \"qwen\",\n \"xai\",\n \"mistral\",\n \"minimax\",\n \"groq\",\n \"siliconflow\",\n \"openrouter\",\n \"ollama (local)\",\n \"skip\",\n ];\n let idx = prompt_select(\"Choose provider\", &options, 0)?;\n let mapped = match idx {\n 0 => \"deepseek\",\n 1 => \"openai\",\n 2 => \"kimi\",\n 3 => \"anthropic\",\n 4 => \"gemini\",\n 5 => \"zhipu\",\n 6 => \"qwen\",\n 7 => \"xai\",\n 8 => \"mistral\",\n 9 => \"minimax\",\n 10 => \"groq\",\n 11 => \"siliconflow\",\n 12 => \"openrouter\",\n 13 => \"ollama\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn primary_pool_entry(config: &Config) -> Option<&ModelEntry> {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .min_by(|a, b| a.priority.cmp(&b.priority).then(b.weight.cmp(&a.weight)))\n}\n\nfn prompt_channel() -> anyhow::Result {\n let options = [\n \"skip (WebUI only)\",\n \"telegram\",\n \"feishu\",\n \"wecom\",\n \"dingtalk\",\n \"lark\",\n \"qq\",\n \"napcat\",\n ];\n let idx = prompt_select(\"Configure one channel (optional)\", &options, 0)?;\n let mapped = match idx {\n 1 => \"telegram\",\n 2 => \"feishu\",\n 3 => \"wecom\",\n 4 => \"dingtalk\",\n 5 => \"lark\",\n 6 => \"qq\",\n 7 => \"napcat\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn resolve_provider(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_provider(v)\n .ok_or_else(|| anyhow!(\"Unsupported provider '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn resolve_channel(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_channel(v)\n .ok_or_else(|| anyhow!(\"Unsupported channel '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn normalize_provider(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"deepseek\" => Some(\"deepseek\"),\n \"openai\" => Some(\"openai\"),\n \"kimi\" | \"moonshot\" => Some(\"kimi\"),\n \"anthropic\" | \"claude\" => Some(\"anthropic\"),\n \"gemini\" => Some(\"gemini\"),\n \"zhipu\" | \"glm\" | \"zai\" | \"z.ai\" => Some(\"zhipu\"),\n \"qwen\" | \"tongyi\" | \"dashscope\" => Some(\"qwen\"),\n \"xai\" | \"grok\" => Some(\"xai\"),\n \"mistral\" => Some(\"mistral\"),\n \"minimax\" => Some(\"minimax\"),\n \"groq\" => Some(\"groq\"),\n \"siliconflow\" => Some(\"siliconflow\"),\n \"openrouter\" => Some(\"openrouter\"),\n \"ollama\" => Some(\"ollama\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn normalize_channel(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"telegram\" => Some(\"telegram\"),\n \"feishu\" => Some(\"feishu\"),\n \"wecom\" | \"wechatwork\" => Some(\"wecom\"),\n \"dingtalk\" => Some(\"dingtalk\"),\n \"lark\" => Some(\"lark\"),\n \"qq\" => Some(\"qq\"),\n \"napcat\" | \"napcatqq\" => Some(\"napcat\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn default_api_base_for_provider(provider: &str) -> Option<&'static str> {\n match provider {\n \"deepseek\" => Some(\"https://api.deepseek.com/v1\"),\n \"openai\" => Some(\"https://api.openai.com/v1\"),\n \"anthropic\" => Some(\"https://api.anthropic.com\"),\n \"kimi\" => Some(\"https://api.moonshot.cn/v1\"),\n \"gemini\" => Some(\"https://generativelanguage.googleapis.com/v1beta/openai\"),\n \"zhipu\" | \"glm\" => Some(\"https://open.bigmodel.cn/api/paas/v4\"),\n \"minimax\" => Some(\"https://api.minimaxi.com/v1\"),\n \"groq\" => Some(\"https://api.groq.com/openai/v1\"),\n \"siliconflow\" => Some(\"https://api.siliconflow.cn/v1\"),\n \"openrouter\" => Some(\"https://openrouter.ai/api/v1\"),\n \"qwen\" => Some(\"https://api.qwen.ai/v1\"),\n \"xai\" => Some(\"https://api.x.ai/v1\"),\n \"mistral\" => Some(\"https://api.mistral.ai/v1\"),\n \"ollama\" => Some(\"http://localhost:11434\"),\n _ => None,\n }\n}\n\nfn default_api_type_for_provider(provider: &str) -> &'static str {\n match provider {\n \"anthropic\" | \"minimax\" => \"anthropic\",\n \"gemini\" => \"openai\",\n \"ollama\" => \"ollama\",\n _ => \"openai\",\n }\n}\n\nfn prompt_select(title: &str, options: &[&str], default_index: usize) -> anyhow::Result {\n println!(\"{}\", title);\n for (i, opt) in options.iter().enumerate() {\n println!(\" {}. {}\", i + 1, opt);\n }\n\n loop {\n let input = prompt_line(&format!(\n \"Enter choice [1-{}] (default {}): \",\n options.len(),\n default_index + 1\n ))?;\n\n if input.trim().is_empty() {\n return Ok(default_index);\n }\n\n if let Ok(n) = input.trim().parse::() {\n if n >= 1 && n <= options.len() {\n return Ok(n - 1);\n }\n }\n println!(\"Invalid choice, please try again.\");\n }\n}\n\nfn prompt_optional_with_existing(label: &str, existing: &str) -> anyhow::Result {\n let prompt = if existing.trim().is_empty() {\n format!(\"{}: \", label)\n } else {\n format!(\"{} (press Enter to keep existing): \", label)\n };\n prompt_line(&prompt)\n}\n\nfn prompt_line_with_default(label: &str, default: &str) -> anyhow::Result {\n let input = prompt_line(&format!(\"{} [{}]: \", label, default))?;\n if input.trim().is_empty() {\n Ok(default.to_string())\n } else {\n Ok(input)\n }\n}\n\nfn prompt_line(prompt: &str) -> anyhow::Result {\n print!(\"{}\", prompt);\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin().read_line(&mut input)?;\n Ok(input.trim().to_string())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_provider_aliases() {\n assert_eq!(normalize_provider(\"moonshot\"), Some(\"kimi\"));\n assert_eq!(normalize_provider(\"claude\"), Some(\"anthropic\"));\n assert_eq!(normalize_provider(\"zhipu\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"glm\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"qwen\"), Some(\"qwen\"));\n assert_eq!(normalize_provider(\"grok\"), Some(\"xai\"));\n assert_eq!(normalize_provider(\"mistral\"), Some(\"mistral\"));\n assert_eq!(normalize_provider(\"minimax\"), Some(\"minimax\"));\n assert_eq!(normalize_provider(\"groq\"), Some(\"groq\"));\n assert_eq!(normalize_provider(\"siliconflow\"), Some(\"siliconflow\"));\n assert_eq!(normalize_provider(\"openrouter\"), Some(\"openrouter\"));\n assert_eq!(normalize_provider(\"none\"), Some(\"skip\"));\n assert_eq!(normalize_provider(\"unknown\"), None);\n }\n\n #[test]\n fn test_normalize_channel_aliases() {\n assert_eq!(normalize_channel(\"wechatwork\"), Some(\"wecom\"));\n assert_eq!(normalize_channel(\"dingtalk\"), Some(\"dingtalk\"));\n assert_eq!(normalize_channel(\"qq\"), Some(\"qq\"));\n assert_eq!(normalize_channel(\"napcat\"), Some(\"napcat\"));\n assert_eq!(normalize_channel(\"napcatqq\"), Some(\"napcat\"));\n assert_eq!(normalize_channel(\"skip\"), Some(\"skip\"));\n assert_eq!(normalize_channel(\"unknown\"), None);\n }\n\n #[test]\n fn test_default_model() {\n assert_eq!(default_model_for_provider(\"deepseek\"), \"deepseek-v4-pro\");\n assert_eq!(default_model_for_provider(\"openai\"), \"gpt-5.5\");\n assert_eq!(default_model_for_provider(\"anthropic\"), \"claude-opus-4-8\");\n assert_eq!(default_model_for_provider(\"kimi\"), \"kimi-k2.6\");\n assert_eq!(default_model_for_provider(\"moonshot\"), \"kimi-k2.6\");\n assert_eq!(\n default_model_for_provider(\"gemini\"),\n \"gemini-3.1-pro-preview\"\n );\n assert_eq!(default_model_for_provider(\"zhipu\"), \"glm-5.2\");\n assert_eq!(default_model_for_provider(\"glm\"), \"glm-5.2\");\n assert_eq!(default_model_for_provider(\"qwen\"), \"qwen3.7-max\");\n assert_eq!(default_model_for_provider(\"xai\"), \"grok-4.3\");\n assert_eq!(default_model_for_provider(\"mistral\"), \"mistral-medium-2604\");\n assert_eq!(default_model_for_provider(\"minimax\"), \"MiniMax-M3\");\n assert_eq!(default_model_for_provider(\"groq\"), \"openai/gpt-oss-120b\");\n assert_eq!(\n default_model_for_provider(\"siliconflow\"),\n \"deepseek-ai/DeepSeek-V4-Pro\"\n );\n assert_eq!(default_model_for_provider(\"openrouter\"), \"openai/gpt-5.5\");\n assert_eq!(default_model_for_provider(\"ollama\"), \"llama3.3\");\n }\n\n #[test]\n fn test_configure_provider_populates_legacy_single_model_fields_for_compat() {\n let mut config = Config::default();\n config.agents.defaults.model = \"legacy-model\".to_string();\n config.agents.defaults.provider = Some(\"legacy-provider\".to_string());\n\n configure_provider(\n &mut config,\n \"deepseek\",\n Some(\"test-deepseek-key\"),\n Some(\"deepseek-chat\"),\n )\n .expect(\"configure_provider should succeed\");\n\n assert_eq!(\n config.agents.defaults.provider,\n Some(\"deepseek\".to_string())\n );\n assert_eq!(config.agents.defaults.model, \"deepseek-chat\");\n assert_eq!(config.agents.defaults.model_pool.len(), 1);\n let entry = &config.agents.defaults.model_pool[0];\n assert_eq!(entry.provider, \"deepseek\");\n assert_eq!(entry.model, \"deepseek-chat\");\n assert_eq!(entry.weight, 1);\n assert_eq!(entry.priority, 1);\n }\n\n #[test]\n fn test_ensure_channel_owner_defaults_to_default_agent() {\n let mut config = Config::default();\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"default\")\n );\n }\n\n #[test]\n fn test_ensure_channel_owner_keeps_existing() {\n let mut config = Config::default();\n config\n .channel_owners\n .insert(\"telegram\".to_string(), \"ops\".to_string());\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"ops\")\n );\n }\n}\n"}, "5fb0b513024d6af592e8b621c912c110baff803b": {"t": 1785760743.3652897, "n": 24953, "h": "fb9f328b2d4ca609", "c": "use anyhow::{anyhow, bail, Context};\nuse blockcell_core::config::{ModelEntry, ToolCallMode};\nuse blockcell_core::{Config, Paths};\nuse std::io::{self, Write};\n\n/// Interactive first-run setup wizard for provider + optional channel.\npub async fn run(\n force: bool,\n provider: Option,\n api_key: Option,\n model: Option,\n channel: Option,\n skip_provider_test: bool,\n) -> anyhow::Result<()> {\n let paths = Paths::new();\n paths.ensure_dirs()?;\n\n let config_path = paths.config_file();\n let mut config = if config_path.exists() && !force {\n Config::load(&config_path)\n .with_context(|| format!(\"Failed to load {}\", config_path.display()))?\n } else {\n if config_path.exists() && force {\n println!(\"⚠ --force enabled: reset existing config to defaults before setup.\");\n }\n Config::default()\n };\n\n if config.intent_router.is_none() {\n config.intent_router = Config::default().intent_router;\n }\n\n println!(\"blockcell setup\");\n println!(\"==============\");\n println!(\"Config file: {}\", config_path.display());\n println!();\n\n let selected_provider = resolve_provider(provider.as_deref())?;\n let provider_name = if let Some(name) = selected_provider {\n name.to_string()\n } else {\n prompt_provider(&config)?\n };\n\n if provider_name != \"skip\" {\n configure_provider(\n &mut config,\n &provider_name,\n api_key.as_deref(),\n model.as_deref(),\n )?;\n } else {\n println!(\"Skipped LLM provider setup.\");\n }\n\n let selected_channel = resolve_channel(channel.as_deref())?;\n let channel_name = if let Some(name) = selected_channel {\n name.to_string()\n } else {\n prompt_channel()?\n };\n\n if channel_name != \"skip\" {\n configure_channel(&mut config, &channel_name)?;\n ensure_channel_owner(&mut config, &channel_name);\n } else {\n println!(\"Skipped channel setup (WebUI only).\");\n }\n\n config\n .save(&config_path)\n .with_context(|| format!(\"Failed to save {}\", config_path.display()))?;\n\n println!();\n println!(\"✓ Setup completed\");\n println!(\" Config: {}\", config_path.display());\n if provider_name != \"skip\" {\n let selected = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider_name)\n .or_else(|| primary_pool_entry(&config));\n let selected_model = selected\n .map(|e| e.model.as_str())\n .unwrap_or(config.agents.defaults.model.as_str());\n println!(\" Provider: {} Model: {}\", provider_name, selected_model);\n if !skip_provider_test {\n match blockcell_providers::create_provider(\n &config,\n selected_model,\n Some(&provider_name),\n ) {\n Ok(_) => println!(\" ✓ Provider config validation passed\"),\n Err(e) => println!(\" ⚠ Provider validation failed: {}\", e),\n }\n }\n }\n if channel_name != \"skip\" {\n println!(\" Channel: {} (enabled)\", channel_name);\n } else {\n println!(\" Channel: none (use WebUI only)\");\n }\n println!();\n println!(\"Next steps:\");\n println!(\" 1. blockcell status\");\n println!(\" 2. blockcell gateway\");\n println!(\" 3. Open WebUI: http://localhost:18791/\");\n\n Ok(())\n}\n\nfn configure_provider(\n config: &mut Config,\n provider: &str,\n api_key_flag: Option<&str>,\n model_flag: Option<&str>,\n) -> anyhow::Result<()> {\n let entry = config.providers.entry(provider.to_string()).or_default();\n\n if entry.api_base.is_none() {\n entry.api_base = default_api_base_for_provider(provider).map(|s| s.to_string());\n }\n if entry.api_type.is_empty() {\n entry.api_type = default_api_type_for_provider(provider).to_string();\n }\n\n if provider == \"ollama\" {\n if entry.api_key.trim().is_empty() {\n entry.api_key = \"ollama\".to_string();\n }\n if entry.api_base.is_none() {\n entry.api_base = Some(\"http://localhost:11434\".to_string());\n }\n } else {\n let has_existing = !entry.api_key.trim().is_empty() && entry.api_key != \"dummy\";\n let final_key = if let Some(flag) = api_key_flag {\n flag.trim().to_string()\n } else if has_existing {\n prompt_line(\"API key (press Enter to keep existing): \")?\n } else {\n prompt_line(\"API key: \")?\n };\n\n if !final_key.is_empty() {\n entry.api_key = final_key;\n }\n\n if entry.api_key.trim().is_empty() || entry.api_key == \"dummy\" {\n bail!(\"Provider '{}' requires a valid API key.\", provider);\n }\n }\n\n let suggested_model = if let Some(entry) = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider)\n {\n entry.model.clone()\n } else {\n default_model_for_provider(provider).to_string()\n };\n\n let final_model = if let Some(m) = model_flag {\n m.trim().to_string()\n } else {\n prompt_line_with_default(\"Model\", &suggested_model)?\n };\n if final_model.trim().is_empty() {\n bail!(\"Model cannot be empty.\");\n }\n\n // Setup writes provider/model to model_pool first, and also mirrors to legacy\n // single-model fields for backward compatibility with older runtimes.\n config.agents.defaults.model_pool = vec![ModelEntry {\n model: final_model.clone(),\n provider: provider.to_string(),\n weight: 1,\n priority: 1,\n input_price: None,\n output_price: None,\n temperature: None,\n tool_call_mode: ToolCallMode::Native,\n }];\n config.agents.defaults.provider = Some(provider.to_string());\n config.agents.defaults.model = final_model;\n\n Ok(())\n}\n\nfn configure_channel(config: &mut Config, channel: &str) -> anyhow::Result<()> {\n match channel {\n \"telegram\" => {\n let existing = config.channels.telegram.token.clone();\n let prompt = if existing.trim().is_empty() {\n \"Telegram bot token: \"\n } else {\n \"Telegram bot token (press Enter to keep existing): \"\n };\n let token = prompt_line(prompt)?;\n if !token.is_empty() {\n config.channels.telegram.token = token;\n }\n if config.channels.telegram.token.trim().is_empty() {\n bail!(\"Telegram token is required.\");\n }\n config.channels.telegram.enabled = true;\n }\n \"feishu\" => {\n let app_id =\n prompt_optional_with_existing(\"Feishu app_id\", &config.channels.feishu.app_id)?;\n let app_secret = prompt_optional_with_existing(\n \"Feishu app_secret\",\n &config.channels.feishu.app_secret,\n )?;\n\n if !app_id.is_empty() {\n config.channels.feishu.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.feishu.app_secret = app_secret;\n }\n\n if config.channels.feishu.app_id.trim().is_empty()\n || config.channels.feishu.app_secret.trim().is_empty()\n {\n bail!(\"Feishu app_id and app_secret are required.\");\n }\n config.channels.feishu.enabled = true;\n }\n \"wecom\" => {\n let corp_id =\n prompt_optional_with_existing(\"WeCom corp_id\", &config.channels.wecom.corp_id)?;\n let corp_secret = prompt_optional_with_existing(\n \"WeCom corp_secret\",\n &config.channels.wecom.corp_secret,\n )?;\n\n if !corp_id.is_empty() {\n config.channels.wecom.corp_id = corp_id;\n }\n if !corp_secret.is_empty() {\n config.channels.wecom.corp_secret = corp_secret;\n }\n\n if config.channels.wecom.corp_id.trim().is_empty()\n || config.channels.wecom.corp_secret.trim().is_empty()\n {\n bail!(\"WeCom corp_id and corp_secret are required.\");\n }\n\n let existing_agent = if config.channels.wecom.agent_id > 0 {\n config.channels.wecom.agent_id.to_string()\n } else {\n \"1000002\".to_string()\n };\n let agent_id_str = prompt_line_with_default(\"WeCom agent_id\", &existing_agent)?;\n let agent_id = agent_id_str\n .trim()\n .parse::()\n .map_err(|_| anyhow!(\"agent_id must be an integer\"))?;\n config.channels.wecom.agent_id = agent_id;\n config.channels.wecom.enabled = true;\n }\n \"dingtalk\" => {\n let app_key = prompt_optional_with_existing(\n \"DingTalk app_key\",\n &config.channels.dingtalk.app_key,\n )?;\n let app_secret = prompt_optional_with_existing(\n \"DingTalk app_secret\",\n &config.channels.dingtalk.app_secret,\n )?;\n if !app_key.is_empty() {\n config.channels.dingtalk.app_key = app_key;\n }\n if !app_secret.is_empty() {\n config.channels.dingtalk.app_secret = app_secret;\n }\n if config.channels.dingtalk.app_key.trim().is_empty()\n || config.channels.dingtalk.app_secret.trim().is_empty()\n {\n bail!(\"DingTalk app_key and app_secret are required.\");\n }\n config.channels.dingtalk.enabled = true;\n }\n \"lark\" => {\n let app_id =\n prompt_optional_with_existing(\"Lark app_id\", &config.channels.lark.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"Lark app_secret\", &config.channels.lark.app_secret)?;\n if !app_id.is_empty() {\n config.channels.lark.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.lark.app_secret = app_secret;\n }\n if config.channels.lark.app_id.trim().is_empty()\n || config.channels.lark.app_secret.trim().is_empty()\n {\n bail!(\"Lark app_id and app_secret are required.\");\n }\n config.channels.lark.enabled = true;\n }\n \"qq\" => {\n let app_id = prompt_optional_with_existing(\"QQ app_id\", &config.channels.qq.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"QQ app_secret\", &config.channels.qq.app_secret)?;\n if !app_id.is_empty() {\n config.channels.qq.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.qq.app_secret = app_secret;\n }\n if config.channels.qq.app_id.trim().is_empty()\n || config.channels.qq.app_secret.trim().is_empty()\n {\n bail!(\"QQ app_id and app_secret are required.\");\n }\n let environment = prompt_optional_with_existing(\n \"QQ environment (production/sandbox)\",\n &config.channels.qq.environment,\n )?;\n if !environment.is_empty() {\n config.channels.qq.environment = environment;\n }\n if config.channels.qq.environment.trim().is_empty() {\n config.channels.qq.environment = \"production\".to_string();\n }\n config.channels.qq.enabled = true;\n }\n \"napcat\" => {\n let mode_options = [\n \"ws-client (BlockCell connects to NapCatQQ WebSocket server)\",\n \"ws-server (NapCatQQ connects to BlockCell WebSocket server)\",\n ];\n let mode_idx = prompt_select(\"Connection mode\", &mode_options, 0)?;\n let mode = match mode_idx {\n 0 => \"ws-client\",\n 1 => \"ws-server\",\n _ => \"ws-client\",\n }\n .to_string();\n\n // WebSocket URL (only for ws-client mode)\n let ws_url = if mode == \"ws-client\" {\n prompt_line_with_default(\"WebSocket URL\", \"ws://127.0.0.1:3001\")?\n } else {\n String::new()\n };\n\n // Server configuration for ws-server mode\n let (server_host, server_port, server_path) = if mode == \"ws-server\" {\n let host = prompt_line_with_default(\"WebSocket server host\", \"0.0.0.0\")?;\n // NapCatQQ client 默认连接 ws://localhost:13005\n let port_str = prompt_line_with_default(\"WebSocket server port\", \"13005\")?;\n let port = port_str.parse::().unwrap_or(13005);\n // NapCatQQ client 默认连接根路径 /\n let path = prompt_line_with_default(\"WebSocket server path\", \"/\")?;\n (host, port, path)\n } else {\n (String::new(), 0, String::new())\n };\n\n let access_token = prompt_line(\"Access token (optional, press Enter to skip)\")?;\n\n // Group response mode\n let mode_options = [\n \"all - 响应所有群聊消息\",\n \"at_only - 仅响应@我的群聊消息\",\n \"none - 不响应任何群聊消息\",\n ];\n let mode_idx = prompt_select(\"群聊响应模式\", &mode_options, 0)?;\n let group_response_mode = match mode_idx {\n 0 => \"all\",\n 1 => \"at_only\",\n 2 => \"none\",\n _ => \"all\",\n }\n .to_string();\n\n config.channels.napcat.enabled = true;\n config.channels.napcat.mode = mode;\n config.channels.napcat.ws_url = ws_url;\n config.channels.napcat.server_host = server_host;\n config.channels.napcat.server_port = server_port;\n config.channels.napcat.server_path = server_path;\n config.channels.napcat.access_token = access_token;\n config.channels.napcat.group_response_mode = group_response_mode;\n\n // 媒体自动下载配置\n let auto_download_options = [\n \"是 - 自动下载图片、语音、视频、文件等媒体\",\n \"否 - 不自动下载媒体\",\n ];\n let auto_idx = prompt_select(\"是否自动下载媒体文件\", &auto_download_options, 0)?;\n config.channels.napcat.auto_download_media = auto_idx == 0;\n\n if config.channels.napcat.auto_download_media {\n let max_size_str = prompt_line_with_default(\"最大自动下载大小 (字节)\", \"10485760\")?;\n if let Ok(max_size) = max_size_str.parse::() {\n config.channels.napcat.max_auto_download_size = max_size;\n }\n\n let download_dir =\n prompt_line_with_default(\"媒体下载目录 (相对于 workspace)\", \"downloads\")?;\n if !download_dir.trim().is_empty() {\n config.channels.napcat.media_download_dir = download_dir;\n }\n }\n }\n _ => bail!(\"Unsupported channel '{}'\", channel),\n }\n\n Ok(())\n}\n\nfn ensure_channel_owner(config: &mut Config, channel: &str) {\n if config.resolve_channel_owner(channel).is_some() {\n return;\n }\n let owner = config\n .known_agent_ids()\n .into_iter()\n .next()\n .unwrap_or_else(|| \"default\".to_string());\n config\n .channel_owners\n .insert(channel.to_string(), owner.clone());\n println!(\"Assigned channel owner: {} -> {}\", channel, owner);\n}\n\nfn prompt_provider(config: &Config) -> anyhow::Result {\n let current = primary_pool_entry(config)\n .map(|e| e.provider.clone())\n .or_else(|| {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| !e.provider.trim().is_empty())\n .map(|e| e.provider.clone())\n })\n .or_else(|| config.agents.defaults.provider.clone())\n .or_else(|| config.get_api_key().map(|(name, _)| name.to_string()))\n .unwrap_or_else(|| \"none\".to_string());\n\n println!(\"Configure LLM provider (current: {})\", current);\n let options = [\n \"deepseek (recommended)\",\n \"openai\",\n \"kimi\",\n \"anthropic\",\n \"gemini\",\n \"zhipu\",\n \"minimax\",\n \"ollama (local)\",\n \"skip\",\n ];\n let idx = prompt_select(\"Choose provider\", &options, 0)?;\n let mapped = match idx {\n 0 => \"deepseek\",\n 1 => \"openai\",\n 2 => \"kimi\",\n 3 => \"anthropic\",\n 4 => \"gemini\",\n 5 => \"zhipu\",\n 6 => \"minimax\",\n 7 => \"ollama\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn primary_pool_entry(config: &Config) -> Option<&ModelEntry> {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .min_by(|a, b| a.priority.cmp(&b.priority).then(b.weight.cmp(&a.weight)))\n}\n\nfn prompt_channel() -> anyhow::Result {\n let options = [\n \"skip (WebUI only)\",\n \"telegram\",\n \"feishu\",\n \"wecom\",\n \"dingtalk\",\n \"lark\",\n \"qq\",\n \"napcat\",\n ];\n let idx = prompt_select(\"Configure one channel (optional)\", &options, 0)?;\n let mapped = match idx {\n 1 => \"telegram\",\n 2 => \"feishu\",\n 3 => \"wecom\",\n 4 => \"dingtalk\",\n 5 => \"lark\",\n 6 => \"qq\",\n 7 => \"napcat\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn resolve_provider(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_provider(v)\n .ok_or_else(|| anyhow!(\"Unsupported provider '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn resolve_channel(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_channel(v)\n .ok_or_else(|| anyhow!(\"Unsupported channel '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn normalize_provider(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"deepseek\" => Some(\"deepseek\"),\n \"openai\" => Some(\"openai\"),\n \"kimi\" | \"moonshot\" => Some(\"kimi\"),\n \"anthropic\" | \"claude\" => Some(\"anthropic\"),\n \"gemini\" => Some(\"gemini\"),\n \"zhipu\" => Some(\"zhipu\"),\n \"minimax\" => Some(\"minimax\"),\n \"ollama\" => Some(\"ollama\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn normalize_channel(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"telegram\" => Some(\"telegram\"),\n \"feishu\" => Some(\"feishu\"),\n \"wecom\" | \"wechatwork\" => Some(\"wecom\"),\n \"dingtalk\" => Some(\"dingtalk\"),\n \"lark\" => Some(\"lark\"),\n \"qq\" => Some(\"qq\"),\n \"napcat\" | \"napcatqq\" => Some(\"napcat\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn default_model_for_provider(provider: &str) -> &'static str {\n match provider {\n \"deepseek\" => \"deepseek-v4-pro\",\n \"openai\" => \"gpt-4o\",\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"kimi\" => \"moonshot-v1-8k\",\n \"gemini\" => \"gemini-2.0-flash\",\n \"zhipu\" => \"glm-4\",\n \"minimax\" => \"minimax-text-01\",\n \"ollama\" => \"llama3\",\n _ => \"gpt-4o\",\n }\n}\n\nfn default_api_base_for_provider(provider: &str) -> Option<&'static str> {\n match provider {\n \"deepseek\" => Some(\"https://api.deepseek.com/v1\"),\n \"openai\" => Some(\"https://api.openai.com/v1\"),\n \"anthropic\" => Some(\"https://api.anthropic.com\"),\n \"kimi\" => Some(\"https://api.moonshot.cn/v1\"),\n \"gemini\" => Some(\"https://generativelanguage.googleapis.com/v1beta/openai\"),\n \"zhipu\" => Some(\"https://open.bigmodel.cn/api/paas/v4\"),\n \"minimax\" => Some(\"https://api.minimaxi.com/v1\"),\n \"ollama\" => Some(\"http://localhost:11434\"),\n _ => None,\n }\n}\n\nfn default_api_type_for_provider(provider: &str) -> &'static str {\n match provider {\n \"anthropic\" | \"minimax\" => \"anthropic\",\n \"gemini\" => \"openai\",\n \"ollama\" => \"ollama\",\n _ => \"openai\",\n }\n}\n\nfn prompt_select(title: &str, options: &[&str], default_index: usize) -> anyhow::Result {\n println!(\"{}\", title);\n for (i, opt) in options.iter().enumerate() {\n println!(\" {}. {}\", i + 1, opt);\n }\n\n loop {\n let input = prompt_line(&format!(\n \"Enter choice [1-{}] (default {}): \",\n options.len(),\n default_index + 1\n ))?;\n\n if input.trim().is_empty() {\n return Ok(default_index);\n }\n\n if let Ok(n) = input.trim().parse::() {\n if n >= 1 && n <= options.len() {\n return Ok(n - 1);\n }\n }\n println!(\"Invalid choice, please try again.\");\n }\n}\n\nfn prompt_optional_with_existing(label: &str, existing: &str) -> anyhow::Result {\n let prompt = if existing.trim().is_empty() {\n format!(\"{}: \", label)\n } else {\n format!(\"{} (press Enter to keep existing): \", label)\n };\n prompt_line(&prompt)\n}\n\nfn prompt_line_with_default(label: &str, default: &str) -> anyhow::Result {\n let input = prompt_line(&format!(\"{} [{}]: \", label, default))?;\n if input.trim().is_empty() {\n Ok(default.to_string())\n } else {\n Ok(input)\n }\n}\n\nfn prompt_line(prompt: &str) -> anyhow::Result {\n print!(\"{}\", prompt);\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin().read_line(&mut input)?;\n Ok(input.trim().to_string())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_provider_aliases() {\n assert_eq!(normalize_provider(\"moonshot\"), Some(\"kimi\"));\n assert_eq!(normalize_provider(\"claude\"), Some(\"anthropic\"));\n assert_eq!(normalize_provider(\"zhipu\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"minimax\"), Some(\"minimax\"));\n assert_eq!(normalize_provider(\"none\"), Some(\"skip\"));\n assert_eq!(normalize_provider(\"unknown\"), None);\n }\n\n #[test]\n fn test_normalize_channel_aliases() {\n assert_eq!(normalize_channel(\"wechatwork\"), Some(\"wecom\"));\n assert_eq!(normalize_channel(\"dingtalk\"), Some(\"dingtalk\"));\n assert_eq!(normalize_channel(\"qq\"), Some(\"qq\"));\n assert_eq!(normalize_channel(\"napcat\"), Some(\"napcat\"));\n assert_eq!(normalize_channel(\"napcatqq\"), Some(\"napcat\"));\n assert_eq!(normalize_channel(\"skip\"), Some(\"skip\"));\n assert_eq!(normalize_channel(\"unknown\"), None);\n }\n\n #[test]\n fn test_default_model() {\n assert_eq!(default_model_for_provider(\"deepseek\"), \"deepseek-v4-pro\");\n assert_eq!(default_model_for_provider(\"openai\"), \"gpt-4o\");\n assert_eq!(default_model_for_provider(\"kimi\"), \"moonshot-v1-8k\");\n assert_eq!(default_model_for_provider(\"zhipu\"), \"glm-4\");\n assert_eq!(default_model_for_provider(\"minimax\"), \"minimax-text-01\");\n }\n\n #[test]\n fn test_configure_provider_populates_legacy_single_model_fields_for_compat() {\n let mut config = Config::default();\n config.agents.defaults.model = \"legacy-model\".to_string();\n config.agents.defaults.provider = Some(\"legacy-provider\".to_string());\n\n configure_provider(\n &mut config,\n \"deepseek\",\n Some(\"test-deepseek-key\"),\n Some(\"deepseek-chat\"),\n )\n .expect(\"configure_provider should succeed\");\n\n assert_eq!(\n config.agents.defaults.provider,\n Some(\"deepseek\".to_string())\n );\n assert_eq!(config.agents.defaults.model, \"deepseek-chat\");\n assert_eq!(config.agents.defaults.model_pool.len(), 1);\n let entry = &config.agents.defaults.model_pool[0];\n assert_eq!(entry.provider, \"deepseek\");\n assert_eq!(entry.model, \"deepseek-chat\");\n assert_eq!(entry.weight, 1);\n assert_eq!(entry.priority, 1);\n }\n\n #[test]\n fn test_ensure_channel_owner_defaults_to_default_agent() {\n let mut config = Config::default();\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"default\")\n );\n }\n\n #[test]\n fn test_ensure_channel_owner_keeps_existing() {\n let mut config = Config::default();\n config\n .channel_owners\n .insert(\"telegram\".to_string(), \"ops\".to_string());\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"ops\")\n );\n }\n}\n"}, "714f05ec57e336cdba2e96d9ea17db54b7ca2a61": {"t": 1785760744.8626475, "n": 24949, "h": "56a1a57486863d20", "c": "use anyhow::{anyhow, bail, Context};\nuse blockcell_core::config::{ModelEntry, ToolCallMode};\nuse blockcell_core::{Config, Paths};\nuse std::io::{self, Write};\n\n/// Interactive first-run setup wizard for provider + optional channel.\npub async fn run(\n force: bool,\n provider: Option,\n api_key: Option,\n model: Option,\n channel: Option,\n skip_provider_test: bool,\n) -> anyhow::Result<()> {\n let paths = Paths::new();\n paths.ensure_dirs()?;\n\n let config_path = paths.config_file();\n let mut config = if config_path.exists() && !force {\n Config::load(&config_path)\n .with_context(|| format!(\"Failed to load {}\", config_path.display()))?\n } else {\n if config_path.exists() && force {\n println!(\"⚠ --force enabled: reset existing config to defaults before setup.\");\n }\n Config::default()\n };\n\n if config.intent_router.is_none() {\n config.intent_router = Config::default().intent_router;\n }\n\n println!(\"blockcell setup\");\n println!(\"==============\");\n println!(\"Config file: {}\", config_path.display());\n println!();\n\n let selected_provider = resolve_provider(provider.as_deref())?;\n let provider_name = if let Some(name) = selected_provider {\n name.to_string()\n } else {\n prompt_provider(&config)?\n };\n\n if provider_name != \"skip\" {\n configure_provider(\n &mut config,\n &provider_name,\n api_key.as_deref(),\n model.as_deref(),\n )?;\n } else {\n println!(\"Skipped LLM provider setup.\");\n }\n\n let selected_channel = resolve_channel(channel.as_deref())?;\n let channel_name = if let Some(name) = selected_channel {\n name.to_string()\n } else {\n prompt_channel()?\n };\n\n if channel_name != \"skip\" {\n configure_channel(&mut config, &channel_name)?;\n ensure_channel_owner(&mut config, &channel_name);\n } else {\n println!(\"Skipped channel setup (WebUI only).\");\n }\n\n config\n .save(&config_path)\n .with_context(|| format!(\"Failed to save {}\", config_path.display()))?;\n\n println!();\n println!(\"✓ Setup completed\");\n println!(\" Config: {}\", config_path.display());\n if provider_name != \"skip\" {\n let selected = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider_name)\n .or_else(|| primary_pool_entry(&config));\n let selected_model = selected\n .map(|e| e.model.as_str())\n .unwrap_or(config.agents.defaults.model.as_str());\n println!(\" Provider: {} Model: {}\", provider_name, selected_model);\n if !skip_provider_test {\n match blockcell_providers::create_provider(\n &config,\n selected_model,\n Some(&provider_name),\n ) {\n Ok(_) => println!(\" ✓ Provider config validation passed\"),\n Err(e) => println!(\" ⚠ Provider validation failed: {}\", e),\n }\n }\n }\n if channel_name != \"skip\" {\n println!(\" Channel: {} (enabled)\", channel_name);\n } else {\n println!(\" Channel: none (use WebUI only)\");\n }\n println!();\n println!(\"Next steps:\");\n println!(\" 1. blockcell status\");\n println!(\" 2. blockcell gateway\");\n println!(\" 3. Open WebUI: http://localhost:18791/\");\n\n Ok(())\n}\n\nfn configure_provider(\n config: &mut Config,\n provider: &str,\n api_key_flag: Option<&str>,\n model_flag: Option<&str>,\n) -> anyhow::Result<()> {\n let entry = config.providers.entry(provider.to_string()).or_default();\n\n if entry.api_base.is_none() {\n entry.api_base = default_api_base_for_provider(provider).map(|s| s.to_string());\n }\n if entry.api_type.is_empty() {\n entry.api_type = default_api_type_for_provider(provider).to_string();\n }\n\n if provider == \"ollama\" {\n if entry.api_key.trim().is_empty() {\n entry.api_key = \"ollama\".to_string();\n }\n if entry.api_base.is_none() {\n entry.api_base = Some(\"http://localhost:11434\".to_string());\n }\n } else {\n let has_existing = !entry.api_key.trim().is_empty() && entry.api_key != \"dummy\";\n let final_key = if let Some(flag) = api_key_flag {\n flag.trim().to_string()\n } else if has_existing {\n prompt_line(\"API key (press Enter to keep existing): \")?\n } else {\n prompt_line(\"API key: \")?\n };\n\n if !final_key.is_empty() {\n entry.api_key = final_key;\n }\n\n if entry.api_key.trim().is_empty() || entry.api_key == \"dummy\" {\n bail!(\"Provider '{}' requires a valid API key.\", provider);\n }\n }\n\n let suggested_model = if let Some(entry) = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider)\n {\n entry.model.clone()\n } else {\n default_model_for_provider(provider).to_string()\n };\n\n let final_model = if let Some(m) = model_flag {\n m.trim().to_string()\n } else {\n prompt_line_with_default(\"Model\", &suggested_model)?\n };\n if final_model.trim().is_empty() {\n bail!(\"Model cannot be empty.\");\n }\n\n // Setup writes provider/model to model_pool first, and also mirrors to legacy\n // single-model fields for backward compatibility with older runtimes.\n config.agents.defaults.model_pool = vec![ModelEntry {\n model: final_model.clone(),\n provider: provider.to_string(),\n weight: 1,\n priority: 1,\n input_price: None,\n output_price: None,\n temperature: None,\n tool_call_mode: ToolCallMode::Native,\n }];\n config.agents.defaults.provider = Some(provider.to_string());\n config.agents.defaults.model = final_model;\n\n Ok(())\n}\n\nfn configure_channel(config: &mut Config, channel: &str) -> anyhow::Result<()> {\n match channel {\n \"telegram\" => {\n let existing = config.channels.telegram.token.clone();\n let prompt = if existing.trim().is_empty() {\n \"Telegram bot token: \"\n } else {\n \"Telegram bot token (press Enter to keep existing): \"\n };\n let token = prompt_line(prompt)?;\n if !token.is_empty() {\n config.channels.telegram.token = token;\n }\n if config.channels.telegram.token.trim().is_empty() {\n bail!(\"Telegram token is required.\");\n }\n config.channels.telegram.enabled = true;\n }\n \"feishu\" => {\n let app_id =\n prompt_optional_with_existing(\"Feishu app_id\", &config.channels.feishu.app_id)?;\n let app_secret = prompt_optional_with_existing(\n \"Feishu app_secret\",\n &config.channels.feishu.app_secret,\n )?;\n\n if !app_id.is_empty() {\n config.channels.feishu.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.feishu.app_secret = app_secret;\n }\n\n if config.channels.feishu.app_id.trim().is_empty()\n || config.channels.feishu.app_secret.trim().is_empty()\n {\n bail!(\"Feishu app_id and app_secret are required.\");\n }\n config.channels.feishu.enabled = true;\n }\n \"wecom\" => {\n let corp_id =\n prompt_optional_with_existing(\"WeCom corp_id\", &config.channels.wecom.corp_id)?;\n let corp_secret = prompt_optional_with_existing(\n \"WeCom corp_secret\",\n &config.channels.wecom.corp_secret,\n )?;\n\n if !corp_id.is_empty() {\n config.channels.wecom.corp_id = corp_id;\n }\n if !corp_secret.is_empty() {\n config.channels.wecom.corp_secret = corp_secret;\n }\n\n if config.channels.wecom.corp_id.trim().is_empty()\n || config.channels.wecom.corp_secret.trim().is_empty()\n {\n bail!(\"WeCom corp_id and corp_secret are required.\");\n }\n\n let existing_agent = if config.channels.wecom.agent_id > 0 {\n config.channels.wecom.agent_id.to_string()\n } else {\n \"1000002\".to_string()\n };\n let agent_id_str = prompt_line_with_default(\"WeCom agent_id\", &existing_agent)?;\n let agent_id = agent_id_str\n .trim()\n .parse::()\n .map_err(|_| anyhow!(\"agent_id must be an integer\"))?;\n config.channels.wecom.agent_id = agent_id;\n config.channels.wecom.enabled = true;\n }\n \"dingtalk\" => {\n let app_key = prompt_optional_with_existing(\n \"DingTalk app_key\",\n &config.channels.dingtalk.app_key,\n )?;\n let app_secret = prompt_optional_with_existing(\n \"DingTalk app_secret\",\n &config.channels.dingtalk.app_secret,\n )?;\n if !app_key.is_empty() {\n config.channels.dingtalk.app_key = app_key;\n }\n if !app_secret.is_empty() {\n config.channels.dingtalk.app_secret = app_secret;\n }\n if config.channels.dingtalk.app_key.trim().is_empty()\n || config.channels.dingtalk.app_secret.trim().is_empty()\n {\n bail!(\"DingTalk app_key and app_secret are required.\");\n }\n config.channels.dingtalk.enabled = true;\n }\n \"lark\" => {\n let app_id =\n prompt_optional_with_existing(\"Lark app_id\", &config.channels.lark.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"Lark app_secret\", &config.channels.lark.app_secret)?;\n if !app_id.is_empty() {\n config.channels.lark.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.lark.app_secret = app_secret;\n }\n if config.channels.lark.app_id.trim().is_empty()\n || config.channels.lark.app_secret.trim().is_empty()\n {\n bail!(\"Lark app_id and app_secret are required.\");\n }\n config.channels.lark.enabled = true;\n }\n \"qq\" => {\n let app_id = prompt_optional_with_existing(\"QQ app_id\", &config.channels.qq.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"QQ app_secret\", &config.channels.qq.app_secret)?;\n if !app_id.is_empty() {\n config.channels.qq.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.qq.app_secret = app_secret;\n }\n if config.channels.qq.app_id.trim().is_empty()\n || config.channels.qq.app_secret.trim().is_empty()\n {\n bail!(\"QQ app_id and app_secret are required.\");\n }\n let environment = prompt_optional_with_existing(\n \"QQ environment (production/sandbox)\",\n &config.channels.qq.environment,\n )?;\n if !environment.is_empty() {\n config.channels.qq.environment = environment;\n }\n if config.channels.qq.environment.trim().is_empty() {\n config.channels.qq.environment = \"production\".to_string();\n }\n config.channels.qq.enabled = true;\n }\n \"napcat\" => {\n let mode_options = [\n \"ws-client (BlockCell connects to NapCatQQ WebSocket server)\",\n \"ws-server (NapCatQQ connects to BlockCell WebSocket server)\",\n ];\n let mode_idx = prompt_select(\"Connection mode\", &mode_options, 0)?;\n let mode = match mode_idx {\n 0 => \"ws-client\",\n 1 => \"ws-server\",\n _ => \"ws-client\",\n }\n .to_string();\n\n // WebSocket URL (only for ws-client mode)\n let ws_url = if mode == \"ws-client\" {\n prompt_line_with_default(\"WebSocket URL\", \"ws://127.0.0.1:3001\")?\n } else {\n String::new()\n };\n\n // Server configuration for ws-server mode\n let (server_host, server_port, server_path) = if mode == \"ws-server\" {\n let host = prompt_line_with_default(\"WebSocket server host\", \"0.0.0.0\")?;\n // NapCatQQ client 默认连接 ws://localhost:13005\n let port_str = prompt_line_with_default(\"WebSocket server port\", \"13005\")?;\n let port = port_str.parse::().unwrap_or(13005);\n // NapCatQQ client 默认连接根路径 /\n let path = prompt_line_with_default(\"WebSocket server path\", \"/\")?;\n (host, port, path)\n } else {\n (String::new(), 0, String::new())\n };\n\n let access_token = prompt_line(\"Access token (optional, press Enter to skip)\")?;\n\n // Group response mode\n let mode_options = [\n \"all - 响应所有群聊消息\",\n \"at_only - 仅响应@我的群聊消息\",\n \"none - 不响应任何群聊消息\",\n ];\n let mode_idx = prompt_select(\"群聊响应模式\", &mode_options, 0)?;\n let group_response_mode = match mode_idx {\n 0 => \"all\",\n 1 => \"at_only\",\n 2 => \"none\",\n _ => \"all\",\n }\n .to_string();\n\n config.channels.napcat.enabled = true;\n config.channels.napcat.mode = mode;\n config.channels.napcat.ws_url = ws_url;\n config.channels.napcat.server_host = server_host;\n config.channels.napcat.server_port = server_port;\n config.channels.napcat.server_path = server_path;\n config.channels.napcat.access_token = access_token;\n config.channels.napcat.group_response_mode = group_response_mode;\n\n // 媒体自动下载配置\n let auto_download_options = [\n \"是 - 自动下载图片、语音、视频、文件等媒体\",\n \"否 - 不自动下载媒体\",\n ];\n let auto_idx = prompt_select(\"是否自动下载媒体文件\", &auto_download_options, 0)?;\n config.channels.napcat.auto_download_media = auto_idx == 0;\n\n if config.channels.napcat.auto_download_media {\n let max_size_str = prompt_line_with_default(\"最大自动下载大小 (字节)\", \"10485760\")?;\n if let Ok(max_size) = max_size_str.parse::() {\n config.channels.napcat.max_auto_download_size = max_size;\n }\n\n let download_dir =\n prompt_line_with_default(\"媒体下载目录 (相对于 workspace)\", \"downloads\")?;\n if !download_dir.trim().is_empty() {\n config.channels.napcat.media_download_dir = download_dir;\n }\n }\n }\n _ => bail!(\"Unsupported channel '{}'\", channel),\n }\n\n Ok(())\n}\n\nfn ensure_channel_owner(config: &mut Config, channel: &str) {\n if config.resolve_channel_owner(channel).is_some() {\n return;\n }\n let owner = config\n .known_agent_ids()\n .into_iter()\n .next()\n .unwrap_or_else(|| \"default\".to_string());\n config\n .channel_owners\n .insert(channel.to_string(), owner.clone());\n println!(\"Assigned channel owner: {} -> {}\", channel, owner);\n}\n\nfn prompt_provider(config: &Config) -> anyhow::Result {\n let current = primary_pool_entry(config)\n .map(|e| e.provider.clone())\n .or_else(|| {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| !e.provider.trim().is_empty())\n .map(|e| e.provider.clone())\n })\n .or_else(|| config.agents.defaults.provider.clone())\n .or_else(|| config.get_api_key().map(|(name, _)| name.to_string()))\n .unwrap_or_else(|| \"none\".to_string());\n\n println!(\"Configure LLM provider (current: {})\", current);\n let options = [\n \"deepseek (recommended)\",\n \"openai\",\n \"kimi\",\n \"anthropic\",\n \"gemini\",\n \"zhipu\",\n \"minimax\",\n \"ollama (local)\",\n \"skip\",\n ];\n let idx = prompt_select(\"Choose provider\", &options, 0)?;\n let mapped = match idx {\n 0 => \"deepseek\",\n 1 => \"openai\",\n 2 => \"kimi\",\n 3 => \"anthropic\",\n 4 => \"gemini\",\n 5 => \"zhipu\",\n 6 => \"minimax\",\n 7 => \"ollama\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn primary_pool_entry(config: &Config) -> Option<&ModelEntry> {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .min_by(|a, b| a.priority.cmp(&b.priority).then(b.weight.cmp(&a.weight)))\n}\n\nfn prompt_channel() -> anyhow::Result {\n let options = [\n \"skip (WebUI only)\",\n \"telegram\",\n \"feishu\",\n \"wecom\",\n \"dingtalk\",\n \"lark\",\n \"qq\",\n \"napcat\",\n ];\n let idx = prompt_select(\"Configure one channel (optional)\", &options, 0)?;\n let mapped = match idx {\n 1 => \"telegram\",\n 2 => \"feishu\",\n 3 => \"wecom\",\n 4 => \"dingtalk\",\n 5 => \"lark\",\n 6 => \"qq\",\n 7 => \"napcat\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn resolve_provider(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_provider(v)\n .ok_or_else(|| anyhow!(\"Unsupported provider '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn resolve_channel(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_channel(v)\n .ok_or_else(|| anyhow!(\"Unsupported channel '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn normalize_provider(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"deepseek\" => Some(\"deepseek\"),\n \"openai\" => Some(\"openai\"),\n \"kimi\" | \"moonshot\" => Some(\"kimi\"),\n \"anthropic\" | \"claude\" => Some(\"anthropic\"),\n \"gemini\" => Some(\"gemini\"),\n \"zhipu\" => Some(\"zhipu\"),\n \"minimax\" => Some(\"minimax\"),\n \"ollama\" => Some(\"ollama\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn normalize_channel(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"telegram\" => Some(\"telegram\"),\n \"feishu\" => Some(\"feishu\"),\n \"wecom\" | \"wechatwork\" => Some(\"wecom\"),\n \"dingtalk\" => Some(\"dingtalk\"),\n \"lark\" => Some(\"lark\"),\n \"qq\" => Some(\"qq\"),\n \"napcat\" | \"napcatqq\" => Some(\"napcat\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn default_model_for_provider(provider: &str) -> &'static str {\n match provider {\n \"deepseek\" => \"deepseek-chat\",\n \"openai\" => \"gpt-4o\",\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"kimi\" => \"moonshot-v1-8k\",\n \"gemini\" => \"gemini-2.0-flash\",\n \"zhipu\" => \"glm-4\",\n \"minimax\" => \"minimax-text-01\",\n \"ollama\" => \"llama3\",\n _ => \"gpt-4o\",\n }\n}\n\nfn default_api_base_for_provider(provider: &str) -> Option<&'static str> {\n match provider {\n \"deepseek\" => Some(\"https://api.deepseek.com/v1\"),\n \"openai\" => Some(\"https://api.openai.com/v1\"),\n \"anthropic\" => Some(\"https://api.anthropic.com\"),\n \"kimi\" => Some(\"https://api.moonshot.cn/v1\"),\n \"gemini\" => Some(\"https://generativelanguage.googleapis.com/v1beta/openai\"),\n \"zhipu\" => Some(\"https://open.bigmodel.cn/api/paas/v4\"),\n \"minimax\" => Some(\"https://api.minimaxi.com/v1\"),\n \"ollama\" => Some(\"http://localhost:11434\"),\n _ => None,\n }\n}\n\nfn default_api_type_for_provider(provider: &str) -> &'static str {\n match provider {\n \"anthropic\" | \"minimax\" => \"anthropic\",\n \"gemini\" => \"openai\",\n \"ollama\" => \"ollama\",\n _ => \"openai\",\n }\n}\n\nfn prompt_select(title: &str, options: &[&str], default_index: usize) -> anyhow::Result {\n println!(\"{}\", title);\n for (i, opt) in options.iter().enumerate() {\n println!(\" {}. {}\", i + 1, opt);\n }\n\n loop {\n let input = prompt_line(&format!(\n \"Enter choice [1-{}] (default {}): \",\n options.len(),\n default_index + 1\n ))?;\n\n if input.trim().is_empty() {\n return Ok(default_index);\n }\n\n if let Ok(n) = input.trim().parse::() {\n if n >= 1 && n <= options.len() {\n return Ok(n - 1);\n }\n }\n println!(\"Invalid choice, please try again.\");\n }\n}\n\nfn prompt_optional_with_existing(label: &str, existing: &str) -> anyhow::Result {\n let prompt = if existing.trim().is_empty() {\n format!(\"{}: \", label)\n } else {\n format!(\"{} (press Enter to keep existing): \", label)\n };\n prompt_line(&prompt)\n}\n\nfn prompt_line_with_default(label: &str, default: &str) -> anyhow::Result {\n let input = prompt_line(&format!(\"{} [{}]: \", label, default))?;\n if input.trim().is_empty() {\n Ok(default.to_string())\n } else {\n Ok(input)\n }\n}\n\nfn prompt_line(prompt: &str) -> anyhow::Result {\n print!(\"{}\", prompt);\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin().read_line(&mut input)?;\n Ok(input.trim().to_string())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_provider_aliases() {\n assert_eq!(normalize_provider(\"moonshot\"), Some(\"kimi\"));\n assert_eq!(normalize_provider(\"claude\"), Some(\"anthropic\"));\n assert_eq!(normalize_provider(\"zhipu\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"minimax\"), Some(\"minimax\"));\n assert_eq!(normalize_provider(\"none\"), Some(\"skip\"));\n assert_eq!(normalize_provider(\"unknown\"), None);\n }\n\n #[test]\n fn test_normalize_channel_aliases() {\n assert_eq!(normalize_channel(\"wechatwork\"), Some(\"wecom\"));\n assert_eq!(normalize_channel(\"dingtalk\"), Some(\"dingtalk\"));\n assert_eq!(normalize_channel(\"qq\"), Some(\"qq\"));\n assert_eq!(normalize_channel(\"napcat\"), Some(\"napcat\"));\n assert_eq!(normalize_channel(\"napcatqq\"), Some(\"napcat\"));\n assert_eq!(normalize_channel(\"skip\"), Some(\"skip\"));\n assert_eq!(normalize_channel(\"unknown\"), None);\n }\n\n #[test]\n fn test_default_model() {\n assert_eq!(default_model_for_provider(\"deepseek\"), \"deepseek-chat\");\n assert_eq!(default_model_for_provider(\"openai\"), \"gpt-4o\");\n assert_eq!(default_model_for_provider(\"kimi\"), \"moonshot-v1-8k\");\n assert_eq!(default_model_for_provider(\"zhipu\"), \"glm-4\");\n assert_eq!(default_model_for_provider(\"minimax\"), \"minimax-text-01\");\n }\n\n #[test]\n fn test_configure_provider_populates_legacy_single_model_fields_for_compat() {\n let mut config = Config::default();\n config.agents.defaults.model = \"legacy-model\".to_string();\n config.agents.defaults.provider = Some(\"legacy-provider\".to_string());\n\n configure_provider(\n &mut config,\n \"deepseek\",\n Some(\"test-deepseek-key\"),\n Some(\"deepseek-chat\"),\n )\n .expect(\"configure_provider should succeed\");\n\n assert_eq!(\n config.agents.defaults.provider,\n Some(\"deepseek\".to_string())\n );\n assert_eq!(config.agents.defaults.model, \"deepseek-chat\");\n assert_eq!(config.agents.defaults.model_pool.len(), 1);\n let entry = &config.agents.defaults.model_pool[0];\n assert_eq!(entry.provider, \"deepseek\");\n assert_eq!(entry.model, \"deepseek-chat\");\n assert_eq!(entry.weight, 1);\n assert_eq!(entry.priority, 1);\n }\n\n #[test]\n fn test_ensure_channel_owner_defaults_to_default_agent() {\n let mut config = Config::default();\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"default\")\n );\n }\n\n #[test]\n fn test_ensure_channel_owner_keeps_existing() {\n let mut config = Config::default();\n config\n .channel_owners\n .insert(\"telegram\".to_string(), \"ops\".to_string());\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"ops\")\n );\n }\n}\n"}, "45dca16ffc6d6d3ee141b872bf2e4490b465b5c5": {"t": 1785760746.264327, "n": 25077, "h": "2be86c5e040ebf67", "c": "use anyhow::{anyhow, bail, Context};\nuse blockcell_core::config::{ModelEntry, ToolCallMode};\nuse blockcell_core::{Config, Paths};\nuse std::io::{self, Write};\n\n/// Interactive first-run setup wizard for provider + optional channel.\npub async fn run(\n force: bool,\n provider: Option,\n api_key: Option,\n model: Option,\n channel: Option,\n skip_provider_test: bool,\n) -> anyhow::Result<()> {\n let paths = Paths::new();\n paths.ensure_dirs()?;\n\n let config_path = paths.config_file();\n let mut config = if config_path.exists() && !force {\n Config::load(&config_path)\n .with_context(|| format!(\"Failed to load {}\", config_path.display()))?\n } else {\n if config_path.exists() && force {\n println!(\"⚠ --force enabled: reset existing config to defaults before setup.\");\n }\n Config::default()\n };\n\n if config.intent_router.is_none() {\n config.intent_router = Config::default().intent_router;\n }\n\n println!(\"blockcell setup\");\n println!(\"==============\");\n println!(\"Config file: {}\", config_path.display());\n println!();\n\n let selected_provider = resolve_provider(provider.as_deref())?;\n let provider_name = if let Some(name) = selected_provider {\n name.to_string()\n } else {\n prompt_provider(&config)?\n };\n\n if provider_name != \"skip\" {\n configure_provider(\n &mut config,\n &provider_name,\n api_key.as_deref(),\n model.as_deref(),\n )?;\n } else {\n println!(\"Skipped LLM provider setup.\");\n }\n\n let selected_channel = resolve_channel(channel.as_deref())?;\n let channel_name = if let Some(name) = selected_channel {\n name.to_string()\n } else {\n prompt_channel()?\n };\n\n if channel_name != \"skip\" {\n configure_channel(&mut config, &channel_name)?;\n ensure_channel_owner(&mut config, &channel_name);\n } else {\n println!(\"Skipped channel setup (WebUI only).\");\n }\n\n config\n .save(&config_path)\n .with_context(|| format!(\"Failed to save {}\", config_path.display()))?;\n\n println!();\n println!(\"✓ Setup completed\");\n println!(\" Config: {}\", config_path.display());\n if provider_name != \"skip\" {\n let selected = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider_name)\n .or_else(|| primary_pool_entry(&config));\n let selected_model = selected\n .map(|e| e.model.as_str())\n .unwrap_or(config.agents.defaults.model.as_str());\n println!(\" Provider: {} Model: {}\", provider_name, selected_model);\n if !skip_provider_test {\n match blockcell_providers::create_provider(\n &config,\n selected_model,\n Some(&provider_name),\n ) {\n Ok(_) => println!(\" ✓ Provider config validation passed\"),\n Err(e) => println!(\" ⚠ Provider validation failed: {}\", e),\n }\n }\n }\n if channel_name != \"skip\" {\n println!(\" Channel: {} (enabled)\", channel_name);\n } else {\n println!(\" Channel: none (use WebUI only)\");\n }\n println!();\n println!(\"Next steps:\");\n println!(\" 1. blockcell status\");\n println!(\" 2. blockcell gateway\");\n println!(\" 3. Open WebUI: http://localhost:18791/\");\n\n Ok(())\n}\n\nfn configure_provider(\n config: &mut Config,\n provider: &str,\n api_key_flag: Option<&str>,\n model_flag: Option<&str>,\n) -> anyhow::Result<()> {\n let entry = config\n .providers\n .entry(provider.to_string())\n .or_default();\n\n if entry.api_base.is_none() {\n entry.api_base = default_api_base_for_provider(provider).map(|s| s.to_string());\n }\n if entry.api_type.is_empty() {\n entry.api_type = default_api_type_for_provider(provider).to_string();\n }\n\n if provider == \"ollama\" {\n if entry.api_key.trim().is_empty() {\n entry.api_key = \"ollama\".to_string();\n }\n if entry.api_base.is_none() {\n entry.api_base = Some(\"http://localhost:11434\".to_string());\n }\n } else {\n let has_existing = !entry.api_key.trim().is_empty() && entry.api_key != \"dummy\";\n let final_key = if let Some(flag) = api_key_flag {\n flag.trim().to_string()\n } else if has_existing {\n prompt_line(\"API key (press Enter to keep existing): \")?\n } else {\n prompt_line(\"API key: \")?\n };\n\n if !final_key.is_empty() {\n entry.api_key = final_key;\n }\n\n if entry.api_key.trim().is_empty() || entry.api_key == \"dummy\" {\n bail!(\"Provider '{}' requires a valid API key.\", provider);\n }\n }\n\n let suggested_model = if let Some(entry) = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider)\n {\n entry.model.clone()\n } else {\n default_model_for_provider(provider).to_string()\n };\n\n let final_model = if let Some(m) = model_flag {\n m.trim().to_string()\n } else {\n prompt_line_with_default(\"Model\", &suggested_model)?\n };\n if final_model.trim().is_empty() {\n bail!(\"Model cannot be empty.\");\n }\n\n // Setup writes provider/model to model_pool first, and also mirrors to legacy\n // single-model fields for backward compatibility with older runtimes.\n config.agents.defaults.model_pool = vec![ModelEntry {\n model: final_model.clone(),\n provider: provider.to_string(),\n weight: 1,\n priority: 1,\n input_price: None,\n output_price: None,\n temperature: None,\n tool_call_mode: ToolCallMode::Native,\n }];\n config.agents.defaults.provider = Some(provider.to_string());\n config.agents.defaults.model = final_model;\n\n Ok(())\n}\n\nfn configure_channel(config: &mut Config, channel: &str) -> anyhow::Result<()> {\n match channel {\n \"telegram\" => {\n let existing = config.channels.telegram.token.clone();\n let prompt = if existing.trim().is_empty() {\n \"Telegram bot token: \"\n } else {\n \"Telegram bot token (press Enter to keep existing): \"\n };\n let token = prompt_line(prompt)?;\n if !token.is_empty() {\n config.channels.telegram.token = token;\n }\n if config.channels.telegram.token.trim().is_empty() {\n bail!(\"Telegram token is required.\");\n }\n config.channels.telegram.enabled = true;\n }\n \"feishu\" => {\n let app_id =\n prompt_optional_with_existing(\"Feishu app_id\", &config.channels.feishu.app_id)?;\n let app_secret = prompt_optional_with_existing(\n \"Feishu app_secret\",\n &config.channels.feishu.app_secret,\n )?;\n\n if !app_id.is_empty() {\n config.channels.feishu.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.feishu.app_secret = app_secret;\n }\n\n if config.channels.feishu.app_id.trim().is_empty()\n || config.channels.feishu.app_secret.trim().is_empty()\n {\n bail!(\"Feishu app_id and app_secret are required.\");\n }\n config.channels.feishu.enabled = true;\n }\n \"wecom\" => {\n let corp_id =\n prompt_optional_with_existing(\"WeCom corp_id\", &config.channels.wecom.corp_id)?;\n let corp_secret = prompt_optional_with_existing(\n \"WeCom corp_secret\",\n &config.channels.wecom.corp_secret,\n )?;\n\n if !corp_id.is_empty() {\n config.channels.wecom.corp_id = corp_id;\n }\n if !corp_secret.is_empty() {\n config.channels.wecom.corp_secret = corp_secret;\n }\n\n if config.channels.wecom.corp_id.trim().is_empty()\n || config.channels.wecom.corp_secret.trim().is_empty()\n {\n bail!(\"WeCom corp_id and corp_secret are required.\");\n }\n\n let existing_agent = if config.channels.wecom.agent_id > 0 {\n config.channels.wecom.agent_id.to_string()\n } else {\n \"1000002\".to_string()\n };\n let agent_id_str = prompt_line_with_default(\"WeCom agent_id\", &existing_agent)?;\n let agent_id = agent_id_str\n .trim()\n .parse::()\n .map_err(|_| anyhow!(\"agent_id must be an integer\"))?;\n config.channels.wecom.agent_id = agent_id;\n config.channels.wecom.enabled = true;\n }\n \"dingtalk\" => {\n let app_key = prompt_optional_with_existing(\n \"DingTalk app_key\",\n &config.channels.dingtalk.app_key,\n )?;\n let app_secret = prompt_optional_with_existing(\n \"DingTalk app_secret\",\n &config.channels.dingtalk.app_secret,\n )?;\n if !app_key.is_empty() {\n config.channels.dingtalk.app_key = app_key;\n }\n if !app_secret.is_empty() {\n config.channels.dingtalk.app_secret = app_secret;\n }\n if config.channels.dingtalk.app_key.trim().is_empty()\n || config.channels.dingtalk.app_secret.trim().is_empty()\n {\n bail!(\"DingTalk app_key and app_secret are required.\");\n }\n config.channels.dingtalk.enabled = true;\n }\n \"lark\" => {\n let app_id =\n prompt_optional_with_existing(\"Lark app_id\", &config.channels.lark.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"Lark app_secret\", &config.channels.lark.app_secret)?;\n if !app_id.is_empty() {\n config.channels.lark.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.lark.app_secret = app_secret;\n }\n if config.channels.lark.app_id.trim().is_empty()\n || config.channels.lark.app_secret.trim().is_empty()\n {\n bail!(\"Lark app_id and app_secret are required.\");\n }\n config.channels.lark.enabled = true;\n }\n \"qq\" => {\n let app_id =\n prompt_optional_with_existing(\"QQ app_id\", &config.channels.qq.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"QQ app_secret\", &config.channels.qq.app_secret)?;\n if !app_id.is_empty() {\n config.channels.qq.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.qq.app_secret = app_secret;\n }\n if config.channels.qq.app_id.trim().is_empty()\n || config.channels.qq.app_secret.trim().is_empty()\n {\n bail!(\"QQ app_id and app_secret are required.\");\n }\n let environment = prompt_optional_with_existing(\n \"QQ environment (production/sandbox)\",\n &config.channels.qq.environment,\n )?;\n if !environment.is_empty() {\n config.channels.qq.environment = environment;\n }\n if config.channels.qq.environment.trim().is_empty() {\n config.channels.qq.environment = \"production\".to_string();\n }\n config.channels.qq.enabled = true;\n }\n \"napcat\" => {\n let mode_options = [\n \"ws-client (BlockCell connects to NapCatQQ WebSocket server)\",\n \"ws-server (NapCatQQ connects to BlockCell WebSocket server)\",\n ];\n let mode_idx = prompt_select(\"Connection mode\", &mode_options, 0)?;\n let mode = match mode_idx {\n 0 => \"ws-client\",\n 1 => \"ws-server\",\n _ => \"ws-client\",\n }.to_string();\n\n // WebSocket URL (only for ws-client mode)\n let ws_url = if mode == \"ws-client\" {\n prompt_line_with_default(\"WebSocket URL\", \"ws://127.0.0.1:3001\")?\n } else {\n String::new()\n };\n\n // Server configuration for ws-server mode\n let (server_host, server_port, server_path) = if mode == \"ws-server\" {\n let host = prompt_line_with_default(\"WebSocket server host\", \"0.0.0.0\")?;\n // NapCatQQ client 默认连接 ws://localhost:13005\n let port_str = prompt_line_with_default(\"WebSocket server port\", \"13005\")?;\n let port = port_str.parse::().unwrap_or(13005);\n // NapCatQQ client 默认连接根路径 /\n let path = prompt_line_with_default(\"WebSocket server path\", \"/\")?;\n (host, port, path)\n } else {\n (String::new(), 0, String::new())\n };\n\n let access_token = prompt_line(\"Access token (optional, press Enter to skip)\")?;\n\n // Group response mode\n let mode_options = [\n \"all - 响应所有群聊消息\",\n \"at_only - 仅响应@我的群聊消息\",\n \"none - 不响应任何群聊消息\",\n ];\n let mode_idx = prompt_select(\"群聊响应模式\", &mode_options, 0)?;\n let group_response_mode = match mode_idx {\n 0 => \"all\",\n 1 => \"at_only\",\n 2 => \"none\",\n _ => \"all\",\n }\n .to_string();\n\n config.channels.napcat.enabled = true;\n config.channels.napcat.mode = mode;\n config.channels.napcat.ws_url = ws_url;\n config.channels.napcat.server_host = server_host;\n config.channels.napcat.server_port = server_port;\n config.channels.napcat.server_path = server_path;\n config.channels.napcat.access_token = access_token;\n config.channels.napcat.group_response_mode = group_response_mode;\n\n // 媒体自动下载配置\n let auto_download_options = [\n \"是 - 自动下载图片、语音、视频、文件等媒体\",\n \"否 - 不自动下载媒体\",\n ];\n let auto_idx = prompt_select(\"是否自动下载媒体文件\", &auto_download_options, 0)?;\n config.channels.napcat.auto_download_media = auto_idx == 0;\n\n if config.channels.napcat.auto_download_media {\n let max_size_str = prompt_line_with_default(\n \"最大自动下载大小 (字节)\",\n \"10485760\",\n )?;\n if let Ok(max_size) = max_size_str.parse::() {\n config.channels.napcat.max_auto_download_size = max_size;\n }\n\n let download_dir = prompt_line_with_default(\n \"媒体下载目录 (相对于 workspace)\",\n \"downloads\",\n )?;\n if !download_dir.trim().is_empty() {\n config.channels.napcat.media_download_dir = download_dir;\n }\n }\n }\n _ => bail!(\"Unsupported channel '{}'\", channel),\n }\n\n Ok(())\n}\n\nfn ensure_channel_owner(config: &mut Config, channel: &str) {\n if config.resolve_channel_owner(channel).is_some() {\n return;\n }\n let owner = config\n .known_agent_ids()\n .into_iter()\n .next()\n .unwrap_or_else(|| \"default\".to_string());\n config\n .channel_owners\n .insert(channel.to_string(), owner.clone());\n println!(\"Assigned channel owner: {} -> {}\", channel, owner);\n}\n\nfn prompt_provider(config: &Config) -> anyhow::Result {\n let current = primary_pool_entry(config)\n .map(|e| e.provider.clone())\n .or_else(|| {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| !e.provider.trim().is_empty())\n .map(|e| e.provider.clone())\n })\n .or_else(|| config.agents.defaults.provider.clone())\n .or_else(|| config.get_api_key().map(|(name, _)| name.to_string()))\n .unwrap_or_else(|| \"none\".to_string());\n\n println!(\"Configure LLM provider (current: {})\", current);\n let options = [\n \"deepseek (recommended)\",\n \"openai\",\n \"kimi\",\n \"anthropic\",\n \"gemini\",\n \"zhipu\",\n \"minimax\",\n \"ollama (local)\",\n \"skip\",\n ];\n let idx = prompt_select(\"Choose provider\", &options, 0)?;\n let mapped = match idx {\n 0 => \"deepseek\",\n 1 => \"openai\",\n 2 => \"kimi\",\n 3 => \"anthropic\",\n 4 => \"gemini\",\n 5 => \"zhipu\",\n 6 => \"minimax\",\n 7 => \"ollama\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn primary_pool_entry(config: &Config) -> Option<&ModelEntry> {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .min_by(|a, b| a.priority.cmp(&b.priority).then(b.weight.cmp(&a.weight)))\n}\n\nfn prompt_channel() -> anyhow::Result {\n let options = [\n \"skip (WebUI only)\",\n \"telegram\",\n \"feishu\",\n \"wecom\",\n \"dingtalk\",\n \"lark\",\n \"qq\",\n \"napcat\",\n ];\n let idx = prompt_select(\"Configure one channel (optional)\", &options, 0)?;\n let mapped = match idx {\n 1 => \"telegram\",\n 2 => \"feishu\",\n 3 => \"wecom\",\n 4 => \"dingtalk\",\n 5 => \"lark\",\n 6 => \"qq\",\n 7 => \"napcat\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn resolve_provider(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_provider(v)\n .ok_or_else(|| anyhow!(\"Unsupported provider '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn resolve_channel(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_channel(v)\n .ok_or_else(|| anyhow!(\"Unsupported channel '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn normalize_provider(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"deepseek\" => Some(\"deepseek\"),\n \"openai\" => Some(\"openai\"),\n \"kimi\" | \"moonshot\" => Some(\"kimi\"),\n \"anthropic\" | \"claude\" => Some(\"anthropic\"),\n \"gemini\" => Some(\"gemini\"),\n \"zhipu\" => Some(\"zhipu\"),\n \"minimax\" => Some(\"minimax\"),\n \"ollama\" => Some(\"ollama\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn normalize_channel(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"telegram\" => Some(\"telegram\"),\n \"feishu\" => Some(\"feishu\"),\n \"wecom\" | \"wechatwork\" => Some(\"wecom\"),\n \"dingtalk\" => Some(\"dingtalk\"),\n \"lark\" => Some(\"lark\"),\n \"qq\" => Some(\"qq\"),\n \"napcat\" | \"napcatqq\" => Some(\"napcat\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn default_model_for_provider(provider: &str) -> &'static str {\n match provider {\n \"deepseek\" => \"deepseek-chat\",\n \"openai\" => \"gpt-4o\",\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"kimi\" => \"moonshot-v1-8k\",\n \"gemini\" => \"gemini-2.0-flash\",\n \"zhipu\" => \"glm-4\",\n \"minimax\" => \"minimax-text-01\",\n \"ollama\" => \"llama3\",\n _ => \"gpt-4o\",\n }\n}\n\nfn default_api_base_for_provider(provider: &str) -> Option<&'static str> {\n match provider {\n \"deepseek\" => Some(\"https://api.deepseek.com/v1\"),\n \"openai\" => Some(\"https://api.openai.com/v1\"),\n \"anthropic\" => Some(\"https://api.anthropic.com\"),\n \"kimi\" => Some(\"https://api.moonshot.cn/v1\"),\n \"gemini\" => Some(\"https://generativelanguage.googleapis.com/v1beta/openai\"),\n \"zhipu\" => Some(\"https://open.bigmodel.cn/api/paas/v4\"),\n \"minimax\" => Some(\"https://api.minimaxi.com/v1\"),\n \"ollama\" => Some(\"http://localhost:11434\"),\n _ => None,\n }\n}\n\nfn default_api_type_for_provider(provider: &str) -> &'static str {\n match provider {\n \"anthropic\" | \"minimax\" => \"anthropic\",\n \"gemini\" => \"openai\",\n \"ollama\" => \"ollama\",\n _ => \"openai\",\n }\n}\n\nfn prompt_select(title: &str, options: &[&str], default_index: usize) -> anyhow::Result {\n println!(\"{}\", title);\n for (i, opt) in options.iter().enumerate() {\n println!(\" {}. {}\", i + 1, opt);\n }\n\n loop {\n let input = prompt_line(&format!(\n \"Enter choice [1-{}] (default {}): \",\n options.len(),\n default_index + 1\n ))?;\n\n if input.trim().is_empty() {\n return Ok(default_index);\n }\n\n if let Ok(n) = input.trim().parse::() {\n if n >= 1 && n <= options.len() {\n return Ok(n - 1);\n }\n }\n println!(\"Invalid choice, please try again.\");\n }\n}\n\nfn prompt_optional_with_existing(label: &str, existing: &str) -> anyhow::Result {\n let prompt = if existing.trim().is_empty() {\n format!(\"{}: \", label)\n } else {\n format!(\"{} (press Enter to keep existing): \", label)\n };\n prompt_line(&prompt)\n}\n\nfn prompt_line_with_default(label: &str, default: &str) -> anyhow::Result {\n let input = prompt_line(&format!(\"{} [{}]: \", label, default))?;\n if input.trim().is_empty() {\n Ok(default.to_string())\n } else {\n Ok(input)\n }\n}\n\nfn prompt_line(prompt: &str) -> anyhow::Result {\n print!(\"{}\", prompt);\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin().read_line(&mut input)?;\n Ok(input.trim().to_string())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_provider_aliases() {\n assert_eq!(normalize_provider(\"moonshot\"), Some(\"kimi\"));\n assert_eq!(normalize_provider(\"claude\"), Some(\"anthropic\"));\n assert_eq!(normalize_provider(\"zhipu\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"minimax\"), Some(\"minimax\"));\n assert_eq!(normalize_provider(\"none\"), Some(\"skip\"));\n assert_eq!(normalize_provider(\"unknown\"), None);\n }\n\n #[test]\n fn test_normalize_channel_aliases() {\n assert_eq!(normalize_channel(\"wechatwork\"), Some(\"wecom\"));\n assert_eq!(normalize_channel(\"dingtalk\"), Some(\"dingtalk\"));\n assert_eq!(normalize_channel(\"qq\"), Some(\"qq\"));\n assert_eq!(normalize_channel(\"napcat\"), Some(\"napcat\"));\n assert_eq!(normalize_channel(\"napcatqq\"), Some(\"napcat\"));\n assert_eq!(normalize_channel(\"skip\"), Some(\"skip\"));\n assert_eq!(normalize_channel(\"unknown\"), None);\n }\n\n #[test]\n fn test_default_model() {\n assert_eq!(default_model_for_provider(\"deepseek\"), \"deepseek-chat\");\n assert_eq!(default_model_for_provider(\"openai\"), \"gpt-4o\");\n assert_eq!(default_model_for_provider(\"kimi\"), \"moonshot-v1-8k\");\n assert_eq!(default_model_for_provider(\"zhipu\"), \"glm-4\");\n assert_eq!(default_model_for_provider(\"minimax\"), \"minimax-text-01\");\n }\n\n #[test]\n fn test_configure_provider_populates_legacy_single_model_fields_for_compat() {\n let mut config = Config::default();\n config.agents.defaults.model = \"legacy-model\".to_string();\n config.agents.defaults.provider = Some(\"legacy-provider\".to_string());\n\n configure_provider(\n &mut config,\n \"deepseek\",\n Some(\"test-deepseek-key\"),\n Some(\"deepseek-chat\"),\n )\n .expect(\"configure_provider should succeed\");\n\n assert_eq!(\n config.agents.defaults.provider,\n Some(\"deepseek\".to_string())\n );\n assert_eq!(config.agents.defaults.model, \"deepseek-chat\");\n assert_eq!(config.agents.defaults.model_pool.len(), 1);\n let entry = &config.agents.defaults.model_pool[0];\n assert_eq!(entry.provider, \"deepseek\");\n assert_eq!(entry.model, \"deepseek-chat\");\n assert_eq!(entry.weight, 1);\n assert_eq!(entry.priority, 1);\n }\n\n #[test]\n fn test_ensure_channel_owner_defaults_to_default_agent() {\n let mut config = Config::default();\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"default\")\n );\n }\n\n #[test]\n fn test_ensure_channel_owner_keeps_existing() {\n let mut config = Config::default();\n config\n .channel_owners\n .insert(\"telegram\".to_string(), \"ops\".to_string());\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"ops\")\n );\n }\n}\n"}, "eb666a90228c2ac16d0b4cd5cc2962d3a23eb837": {"t": 1785760747.6750836, "n": 25050, "h": "31ad99f5ea138d38", "c": "use anyhow::{anyhow, bail, Context};\nuse blockcell_core::config::{ModelEntry, ToolCallMode};\nuse blockcell_core::{Config, Paths};\nuse std::io::{self, Write};\n\n/// Interactive first-run setup wizard for provider + optional channel.\npub async fn run(\n force: bool,\n provider: Option,\n api_key: Option,\n model: Option,\n channel: Option,\n skip_provider_test: bool,\n) -> anyhow::Result<()> {\n let paths = Paths::new();\n paths.ensure_dirs()?;\n\n let config_path = paths.config_file();\n let mut config = if config_path.exists() && !force {\n Config::load(&config_path)\n .with_context(|| format!(\"Failed to load {}\", config_path.display()))?\n } else {\n if config_path.exists() && force {\n println!(\"⚠ --force enabled: reset existing config to defaults before setup.\");\n }\n Config::default()\n };\n\n if config.intent_router.is_none() {\n config.intent_router = Config::default().intent_router;\n }\n\n println!(\"blockcell setup\");\n println!(\"==============\");\n println!(\"Config file: {}\", config_path.display());\n println!();\n\n let selected_provider = resolve_provider(provider.as_deref())?;\n let provider_name = if let Some(name) = selected_provider {\n name.to_string()\n } else {\n prompt_provider(&config)?\n };\n\n if provider_name != \"skip\" {\n configure_provider(\n &mut config,\n &provider_name,\n api_key.as_deref(),\n model.as_deref(),\n )?;\n } else {\n println!(\"Skipped LLM provider setup.\");\n }\n\n let selected_channel = resolve_channel(channel.as_deref())?;\n let channel_name = if let Some(name) = selected_channel {\n name.to_string()\n } else {\n prompt_channel()?\n };\n\n if channel_name != \"skip\" {\n configure_channel(&mut config, &channel_name)?;\n ensure_channel_owner(&mut config, &channel_name);\n } else {\n println!(\"Skipped channel setup (WebUI only).\");\n }\n\n config\n .save(&config_path)\n .with_context(|| format!(\"Failed to save {}\", config_path.display()))?;\n\n println!();\n println!(\"✓ Setup completed\");\n println!(\" Config: {}\", config_path.display());\n if provider_name != \"skip\" {\n let selected = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider_name)\n .or_else(|| primary_pool_entry(&config));\n let selected_model = selected\n .map(|e| e.model.as_str())\n .unwrap_or(config.agents.defaults.model.as_str());\n println!(\" Provider: {} Model: {}\", provider_name, selected_model);\n if !skip_provider_test {\n match blockcell_providers::create_provider(\n &config,\n selected_model,\n Some(&provider_name),\n ) {\n Ok(_) => println!(\" ✓ Provider config validation passed\"),\n Err(e) => println!(\" ⚠ Provider validation failed: {}\", e),\n }\n }\n }\n if channel_name != \"skip\" {\n println!(\" Channel: {} (enabled)\", channel_name);\n } else {\n println!(\" Channel: none (use WebUI only)\");\n }\n println!();\n println!(\"Next steps:\");\n println!(\" 1. blockcell status\");\n println!(\" 2. blockcell gateway\");\n println!(\" 3. Open WebUI: http://localhost:18791/\");\n\n Ok(())\n}\n\nfn configure_provider(\n config: &mut Config,\n provider: &str,\n api_key_flag: Option<&str>,\n model_flag: Option<&str>,\n) -> anyhow::Result<()> {\n let entry = config\n .providers\n .entry(provider.to_string())\n .or_default();\n\n if entry.api_base.is_none() {\n entry.api_base = default_api_base_for_provider(provider).map(|s| s.to_string());\n }\n if entry.api_type.is_empty() {\n entry.api_type = default_api_type_for_provider(provider).to_string();\n }\n\n if provider == \"ollama\" {\n if entry.api_key.trim().is_empty() {\n entry.api_key = \"ollama\".to_string();\n }\n if entry.api_base.is_none() {\n entry.api_base = Some(\"http://localhost:11434\".to_string());\n }\n } else {\n let has_existing = !entry.api_key.trim().is_empty() && entry.api_key != \"dummy\";\n let final_key = if let Some(flag) = api_key_flag {\n flag.trim().to_string()\n } else if has_existing {\n prompt_line(\"API key (press Enter to keep existing): \")?\n } else {\n prompt_line(\"API key: \")?\n };\n\n if !final_key.is_empty() {\n entry.api_key = final_key;\n }\n\n if entry.api_key.trim().is_empty() || entry.api_key == \"dummy\" {\n bail!(\"Provider '{}' requires a valid API key.\", provider);\n }\n }\n\n let suggested_model = if let Some(entry) = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider)\n {\n entry.model.clone()\n } else {\n default_model_for_provider(provider).to_string()\n };\n\n let final_model = if let Some(m) = model_flag {\n m.trim().to_string()\n } else {\n prompt_line_with_default(\"Model\", &suggested_model)?\n };\n if final_model.trim().is_empty() {\n bail!(\"Model cannot be empty.\");\n }\n\n // Setup writes provider/model to model_pool first, and also mirrors to legacy\n // single-model fields for backward compatibility with older runtimes.\n config.agents.defaults.model_pool = vec![ModelEntry {\n model: final_model.clone(),\n provider: provider.to_string(),\n weight: 1,\n priority: 1,\n input_price: None,\n output_price: None,\n tool_call_mode: ToolCallMode::Native,\n }];\n config.agents.defaults.provider = Some(provider.to_string());\n config.agents.defaults.model = final_model;\n\n Ok(())\n}\n\nfn configure_channel(config: &mut Config, channel: &str) -> anyhow::Result<()> {\n match channel {\n \"telegram\" => {\n let existing = config.channels.telegram.token.clone();\n let prompt = if existing.trim().is_empty() {\n \"Telegram bot token: \"\n } else {\n \"Telegram bot token (press Enter to keep existing): \"\n };\n let token = prompt_line(prompt)?;\n if !token.is_empty() {\n config.channels.telegram.token = token;\n }\n if config.channels.telegram.token.trim().is_empty() {\n bail!(\"Telegram token is required.\");\n }\n config.channels.telegram.enabled = true;\n }\n \"feishu\" => {\n let app_id =\n prompt_optional_with_existing(\"Feishu app_id\", &config.channels.feishu.app_id)?;\n let app_secret = prompt_optional_with_existing(\n \"Feishu app_secret\",\n &config.channels.feishu.app_secret,\n )?;\n\n if !app_id.is_empty() {\n config.channels.feishu.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.feishu.app_secret = app_secret;\n }\n\n if config.channels.feishu.app_id.trim().is_empty()\n || config.channels.feishu.app_secret.trim().is_empty()\n {\n bail!(\"Feishu app_id and app_secret are required.\");\n }\n config.channels.feishu.enabled = true;\n }\n \"wecom\" => {\n let corp_id =\n prompt_optional_with_existing(\"WeCom corp_id\", &config.channels.wecom.corp_id)?;\n let corp_secret = prompt_optional_with_existing(\n \"WeCom corp_secret\",\n &config.channels.wecom.corp_secret,\n )?;\n\n if !corp_id.is_empty() {\n config.channels.wecom.corp_id = corp_id;\n }\n if !corp_secret.is_empty() {\n config.channels.wecom.corp_secret = corp_secret;\n }\n\n if config.channels.wecom.corp_id.trim().is_empty()\n || config.channels.wecom.corp_secret.trim().is_empty()\n {\n bail!(\"WeCom corp_id and corp_secret are required.\");\n }\n\n let existing_agent = if config.channels.wecom.agent_id > 0 {\n config.channels.wecom.agent_id.to_string()\n } else {\n \"1000002\".to_string()\n };\n let agent_id_str = prompt_line_with_default(\"WeCom agent_id\", &existing_agent)?;\n let agent_id = agent_id_str\n .trim()\n .parse::()\n .map_err(|_| anyhow!(\"agent_id must be an integer\"))?;\n config.channels.wecom.agent_id = agent_id;\n config.channels.wecom.enabled = true;\n }\n \"dingtalk\" => {\n let app_key = prompt_optional_with_existing(\n \"DingTalk app_key\",\n &config.channels.dingtalk.app_key,\n )?;\n let app_secret = prompt_optional_with_existing(\n \"DingTalk app_secret\",\n &config.channels.dingtalk.app_secret,\n )?;\n if !app_key.is_empty() {\n config.channels.dingtalk.app_key = app_key;\n }\n if !app_secret.is_empty() {\n config.channels.dingtalk.app_secret = app_secret;\n }\n if config.channels.dingtalk.app_key.trim().is_empty()\n || config.channels.dingtalk.app_secret.trim().is_empty()\n {\n bail!(\"DingTalk app_key and app_secret are required.\");\n }\n config.channels.dingtalk.enabled = true;\n }\n \"lark\" => {\n let app_id =\n prompt_optional_with_existing(\"Lark app_id\", &config.channels.lark.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"Lark app_secret\", &config.channels.lark.app_secret)?;\n if !app_id.is_empty() {\n config.channels.lark.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.lark.app_secret = app_secret;\n }\n if config.channels.lark.app_id.trim().is_empty()\n || config.channels.lark.app_secret.trim().is_empty()\n {\n bail!(\"Lark app_id and app_secret are required.\");\n }\n config.channels.lark.enabled = true;\n }\n \"qq\" => {\n let app_id =\n prompt_optional_with_existing(\"QQ app_id\", &config.channels.qq.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"QQ app_secret\", &config.channels.qq.app_secret)?;\n if !app_id.is_empty() {\n config.channels.qq.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.qq.app_secret = app_secret;\n }\n if config.channels.qq.app_id.trim().is_empty()\n || config.channels.qq.app_secret.trim().is_empty()\n {\n bail!(\"QQ app_id and app_secret are required.\");\n }\n let environment = prompt_optional_with_existing(\n \"QQ environment (production/sandbox)\",\n &config.channels.qq.environment,\n )?;\n if !environment.is_empty() {\n config.channels.qq.environment = environment;\n }\n if config.channels.qq.environment.trim().is_empty() {\n config.channels.qq.environment = \"production\".to_string();\n }\n config.channels.qq.enabled = true;\n }\n \"napcat\" => {\n let mode_options = [\n \"ws-client (BlockCell connects to NapCatQQ WebSocket server)\",\n \"ws-server (NapCatQQ connects to BlockCell WebSocket server)\",\n ];\n let mode_idx = prompt_select(\"Connection mode\", &mode_options, 0)?;\n let mode = match mode_idx {\n 0 => \"ws-client\",\n 1 => \"ws-server\",\n _ => \"ws-client\",\n }.to_string();\n\n // WebSocket URL (only for ws-client mode)\n let ws_url = if mode == \"ws-client\" {\n prompt_line_with_default(\"WebSocket URL\", \"ws://127.0.0.1:3001\")?\n } else {\n String::new()\n };\n\n // Server configuration for ws-server mode\n let (server_host, server_port, server_path) = if mode == \"ws-server\" {\n let host = prompt_line_with_default(\"WebSocket server host\", \"0.0.0.0\")?;\n // NapCatQQ client 默认连接 ws://localhost:13005\n let port_str = prompt_line_with_default(\"WebSocket server port\", \"13005\")?;\n let port = port_str.parse::().unwrap_or(13005);\n // NapCatQQ client 默认连接根路径 /\n let path = prompt_line_with_default(\"WebSocket server path\", \"/\")?;\n (host, port, path)\n } else {\n (String::new(), 0, String::new())\n };\n\n let access_token = prompt_line(\"Access token (optional, press Enter to skip)\")?;\n\n // Group response mode\n let mode_options = [\n \"all - 响应所有群聊消息\",\n \"at_only - 仅响应@我的群聊消息\",\n \"none - 不响应任何群聊消息\",\n ];\n let mode_idx = prompt_select(\"群聊响应模式\", &mode_options, 0)?;\n let group_response_mode = match mode_idx {\n 0 => \"all\",\n 1 => \"at_only\",\n 2 => \"none\",\n _ => \"all\",\n }\n .to_string();\n\n config.channels.napcat.enabled = true;\n config.channels.napcat.mode = mode;\n config.channels.napcat.ws_url = ws_url;\n config.channels.napcat.server_host = server_host;\n config.channels.napcat.server_port = server_port;\n config.channels.napcat.server_path = server_path;\n config.channels.napcat.access_token = access_token;\n config.channels.napcat.group_response_mode = group_response_mode;\n\n // 媒体自动下载配置\n let auto_download_options = [\n \"是 - 自动下载图片、语音、视频、文件等媒体\",\n \"否 - 不自动下载媒体\",\n ];\n let auto_idx = prompt_select(\"是否自动下载媒体文件\", &auto_download_options, 0)?;\n config.channels.napcat.auto_download_media = auto_idx == 0;\n\n if config.channels.napcat.auto_download_media {\n let max_size_str = prompt_line_with_default(\n \"最大自动下载大小 (字节)\",\n \"10485760\",\n )?;\n if let Ok(max_size) = max_size_str.parse::() {\n config.channels.napcat.max_auto_download_size = max_size;\n }\n\n let download_dir = prompt_line_with_default(\n \"媒体下载目录 (相对于 workspace)\",\n \"downloads\",\n )?;\n if !download_dir.trim().is_empty() {\n config.channels.napcat.media_download_dir = download_dir;\n }\n }\n }\n _ => bail!(\"Unsupported channel '{}'\", channel),\n }\n\n Ok(())\n}\n\nfn ensure_channel_owner(config: &mut Config, channel: &str) {\n if config.resolve_channel_owner(channel).is_some() {\n return;\n }\n let owner = config\n .known_agent_ids()\n .into_iter()\n .next()\n .unwrap_or_else(|| \"default\".to_string());\n config\n .channel_owners\n .insert(channel.to_string(), owner.clone());\n println!(\"Assigned channel owner: {} -> {}\", channel, owner);\n}\n\nfn prompt_provider(config: &Config) -> anyhow::Result {\n let current = primary_pool_entry(config)\n .map(|e| e.provider.clone())\n .or_else(|| {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| !e.provider.trim().is_empty())\n .map(|e| e.provider.clone())\n })\n .or_else(|| config.agents.defaults.provider.clone())\n .or_else(|| config.get_api_key().map(|(name, _)| name.to_string()))\n .unwrap_or_else(|| \"none\".to_string());\n\n println!(\"Configure LLM provider (current: {})\", current);\n let options = [\n \"deepseek (recommended)\",\n \"openai\",\n \"kimi\",\n \"anthropic\",\n \"gemini\",\n \"zhipu\",\n \"minimax\",\n \"ollama (local)\",\n \"skip\",\n ];\n let idx = prompt_select(\"Choose provider\", &options, 0)?;\n let mapped = match idx {\n 0 => \"deepseek\",\n 1 => \"openai\",\n 2 => \"kimi\",\n 3 => \"anthropic\",\n 4 => \"gemini\",\n 5 => \"zhipu\",\n 6 => \"minimax\",\n 7 => \"ollama\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn primary_pool_entry(config: &Config) -> Option<&ModelEntry> {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .min_by(|a, b| a.priority.cmp(&b.priority).then(b.weight.cmp(&a.weight)))\n}\n\nfn prompt_channel() -> anyhow::Result {\n let options = [\n \"skip (WebUI only)\",\n \"telegram\",\n \"feishu\",\n \"wecom\",\n \"dingtalk\",\n \"lark\",\n \"qq\",\n \"napcat\",\n ];\n let idx = prompt_select(\"Configure one channel (optional)\", &options, 0)?;\n let mapped = match idx {\n 1 => \"telegram\",\n 2 => \"feishu\",\n 3 => \"wecom\",\n 4 => \"dingtalk\",\n 5 => \"lark\",\n 6 => \"qq\",\n 7 => \"napcat\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn resolve_provider(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_provider(v)\n .ok_or_else(|| anyhow!(\"Unsupported provider '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn resolve_channel(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_channel(v)\n .ok_or_else(|| anyhow!(\"Unsupported channel '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn normalize_provider(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"deepseek\" => Some(\"deepseek\"),\n \"openai\" => Some(\"openai\"),\n \"kimi\" | \"moonshot\" => Some(\"kimi\"),\n \"anthropic\" | \"claude\" => Some(\"anthropic\"),\n \"gemini\" => Some(\"gemini\"),\n \"zhipu\" => Some(\"zhipu\"),\n \"minimax\" => Some(\"minimax\"),\n \"ollama\" => Some(\"ollama\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn normalize_channel(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"telegram\" => Some(\"telegram\"),\n \"feishu\" => Some(\"feishu\"),\n \"wecom\" | \"wechatwork\" => Some(\"wecom\"),\n \"dingtalk\" => Some(\"dingtalk\"),\n \"lark\" => Some(\"lark\"),\n \"qq\" => Some(\"qq\"),\n \"napcat\" | \"napcatqq\" => Some(\"napcat\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn default_model_for_provider(provider: &str) -> &'static str {\n match provider {\n \"deepseek\" => \"deepseek-chat\",\n \"openai\" => \"gpt-4o\",\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"kimi\" => \"moonshot-v1-8k\",\n \"gemini\" => \"gemini-2.0-flash\",\n \"zhipu\" => \"glm-4\",\n \"minimax\" => \"minimax-text-01\",\n \"ollama\" => \"llama3\",\n _ => \"gpt-4o\",\n }\n}\n\nfn default_api_base_for_provider(provider: &str) -> Option<&'static str> {\n match provider {\n \"deepseek\" => Some(\"https://api.deepseek.com/v1\"),\n \"openai\" => Some(\"https://api.openai.com/v1\"),\n \"anthropic\" => Some(\"https://api.anthropic.com\"),\n \"kimi\" => Some(\"https://api.moonshot.cn/v1\"),\n \"gemini\" => Some(\"https://generativelanguage.googleapis.com/v1beta/openai\"),\n \"zhipu\" => Some(\"https://open.bigmodel.cn/api/paas/v4\"),\n \"minimax\" => Some(\"https://api.minimaxi.com/v1\"),\n \"ollama\" => Some(\"http://localhost:11434\"),\n _ => None,\n }\n}\n\nfn default_api_type_for_provider(provider: &str) -> &'static str {\n match provider {\n \"anthropic\" | \"minimax\" => \"anthropic\",\n \"gemini\" => \"openai\",\n \"ollama\" => \"ollama\",\n _ => \"openai\",\n }\n}\n\nfn prompt_select(title: &str, options: &[&str], default_index: usize) -> anyhow::Result {\n println!(\"{}\", title);\n for (i, opt) in options.iter().enumerate() {\n println!(\" {}. {}\", i + 1, opt);\n }\n\n loop {\n let input = prompt_line(&format!(\n \"Enter choice [1-{}] (default {}): \",\n options.len(),\n default_index + 1\n ))?;\n\n if input.trim().is_empty() {\n return Ok(default_index);\n }\n\n if let Ok(n) = input.trim().parse::() {\n if n >= 1 && n <= options.len() {\n return Ok(n - 1);\n }\n }\n println!(\"Invalid choice, please try again.\");\n }\n}\n\nfn prompt_optional_with_existing(label: &str, existing: &str) -> anyhow::Result {\n let prompt = if existing.trim().is_empty() {\n format!(\"{}: \", label)\n } else {\n format!(\"{} (press Enter to keep existing): \", label)\n };\n prompt_line(&prompt)\n}\n\nfn prompt_line_with_default(label: &str, default: &str) -> anyhow::Result {\n let input = prompt_line(&format!(\"{} [{}]: \", label, default))?;\n if input.trim().is_empty() {\n Ok(default.to_string())\n } else {\n Ok(input)\n }\n}\n\nfn prompt_line(prompt: &str) -> anyhow::Result {\n print!(\"{}\", prompt);\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin().read_line(&mut input)?;\n Ok(input.trim().to_string())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_provider_aliases() {\n assert_eq!(normalize_provider(\"moonshot\"), Some(\"kimi\"));\n assert_eq!(normalize_provider(\"claude\"), Some(\"anthropic\"));\n assert_eq!(normalize_provider(\"zhipu\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"minimax\"), Some(\"minimax\"));\n assert_eq!(normalize_provider(\"none\"), Some(\"skip\"));\n assert_eq!(normalize_provider(\"unknown\"), None);\n }\n\n #[test]\n fn test_normalize_channel_aliases() {\n assert_eq!(normalize_channel(\"wechatwork\"), Some(\"wecom\"));\n assert_eq!(normalize_channel(\"dingtalk\"), Some(\"dingtalk\"));\n assert_eq!(normalize_channel(\"qq\"), Some(\"qq\"));\n assert_eq!(normalize_channel(\"napcat\"), Some(\"napcat\"));\n assert_eq!(normalize_channel(\"napcatqq\"), Some(\"napcat\"));\n assert_eq!(normalize_channel(\"skip\"), Some(\"skip\"));\n assert_eq!(normalize_channel(\"unknown\"), None);\n }\n\n #[test]\n fn test_default_model() {\n assert_eq!(default_model_for_provider(\"deepseek\"), \"deepseek-chat\");\n assert_eq!(default_model_for_provider(\"openai\"), \"gpt-4o\");\n assert_eq!(default_model_for_provider(\"kimi\"), \"moonshot-v1-8k\");\n assert_eq!(default_model_for_provider(\"zhipu\"), \"glm-4\");\n assert_eq!(default_model_for_provider(\"minimax\"), \"minimax-text-01\");\n }\n\n #[test]\n fn test_configure_provider_populates_legacy_single_model_fields_for_compat() {\n let mut config = Config::default();\n config.agents.defaults.model = \"legacy-model\".to_string();\n config.agents.defaults.provider = Some(\"legacy-provider\".to_string());\n\n configure_provider(\n &mut config,\n \"deepseek\",\n Some(\"test-deepseek-key\"),\n Some(\"deepseek-chat\"),\n )\n .expect(\"configure_provider should succeed\");\n\n assert_eq!(\n config.agents.defaults.provider,\n Some(\"deepseek\".to_string())\n );\n assert_eq!(config.agents.defaults.model, \"deepseek-chat\");\n assert_eq!(config.agents.defaults.model_pool.len(), 1);\n let entry = &config.agents.defaults.model_pool[0];\n assert_eq!(entry.provider, \"deepseek\");\n assert_eq!(entry.model, \"deepseek-chat\");\n assert_eq!(entry.weight, 1);\n assert_eq!(entry.priority, 1);\n }\n\n #[test]\n fn test_ensure_channel_owner_defaults_to_default_agent() {\n let mut config = Config::default();\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"default\")\n );\n }\n\n #[test]\n fn test_ensure_channel_owner_keeps_existing() {\n let mut config = Config::default();\n config\n .channel_owners\n .insert(\"telegram\".to_string(), \"ops\".to_string());\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"ops\")\n );\n }\n}\n"}, "83297d3b134ec5a375413978c5d637e6e5ec1e88": {"t": 1785760749.090145, "n": 21325, "h": "373917c2106c0def", "c": "use anyhow::{anyhow, bail, Context};\nuse blockcell_core::config::{ModelEntry, ToolCallMode};\nuse blockcell_core::{Config, Paths};\nuse std::io::{self, Write};\n\n/// Interactive first-run setup wizard for provider + optional channel.\npub async fn run(\n force: bool,\n provider: Option,\n api_key: Option,\n model: Option,\n channel: Option,\n skip_provider_test: bool,\n) -> anyhow::Result<()> {\n let paths = Paths::new();\n paths.ensure_dirs()?;\n\n let config_path = paths.config_file();\n let mut config = if config_path.exists() && !force {\n Config::load(&config_path)\n .with_context(|| format!(\"Failed to load {}\", config_path.display()))?\n } else {\n if config_path.exists() && force {\n println!(\"⚠ --force enabled: reset existing config to defaults before setup.\");\n }\n Config::default()\n };\n\n if config.intent_router.is_none() {\n config.intent_router = Config::default().intent_router;\n }\n\n println!(\"blockcell setup\");\n println!(\"==============\");\n println!(\"Config file: {}\", config_path.display());\n println!();\n\n let selected_provider = resolve_provider(provider.as_deref())?;\n let provider_name = if let Some(name) = selected_provider {\n name.to_string()\n } else {\n prompt_provider(&config)?\n };\n\n if provider_name != \"skip\" {\n configure_provider(\n &mut config,\n &provider_name,\n api_key.as_deref(),\n model.as_deref(),\n )?;\n } else {\n println!(\"Skipped LLM provider setup.\");\n }\n\n let selected_channel = resolve_channel(channel.as_deref())?;\n let channel_name = if let Some(name) = selected_channel {\n name.to_string()\n } else {\n prompt_channel()?\n };\n\n if channel_name != \"skip\" {\n configure_channel(&mut config, &channel_name)?;\n ensure_channel_owner(&mut config, &channel_name);\n } else {\n println!(\"Skipped channel setup (WebUI only).\");\n }\n\n config\n .save(&config_path)\n .with_context(|| format!(\"Failed to save {}\", config_path.display()))?;\n\n println!();\n println!(\"✓ Setup completed\");\n println!(\" Config: {}\", config_path.display());\n if provider_name != \"skip\" {\n let selected = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider_name)\n .or_else(|| primary_pool_entry(&config));\n let selected_model = selected\n .map(|e| e.model.as_str())\n .unwrap_or(config.agents.defaults.model.as_str());\n println!(\" Provider: {} Model: {}\", provider_name, selected_model);\n if !skip_provider_test {\n match blockcell_providers::create_provider(\n &config,\n selected_model,\n Some(&provider_name),\n ) {\n Ok(_) => println!(\" ✓ Provider config validation passed\"),\n Err(e) => println!(\" ⚠ Provider validation failed: {}\", e),\n }\n }\n }\n if channel_name != \"skip\" {\n println!(\" Channel: {} (enabled)\", channel_name);\n } else {\n println!(\" Channel: none (use WebUI only)\");\n }\n println!();\n println!(\"Next steps:\");\n println!(\" 1. blockcell status\");\n println!(\" 2. blockcell gateway\");\n println!(\" 3. Open WebUI: http://localhost:18791/\");\n\n Ok(())\n}\n\nfn configure_provider(\n config: &mut Config,\n provider: &str,\n api_key_flag: Option<&str>,\n model_flag: Option<&str>,\n) -> anyhow::Result<()> {\n let entry = config\n .providers\n .entry(provider.to_string())\n .or_default();\n\n if entry.api_base.is_none() {\n entry.api_base = default_api_base_for_provider(provider).map(|s| s.to_string());\n }\n if entry.api_type.is_empty() {\n entry.api_type = default_api_type_for_provider(provider).to_string();\n }\n\n if provider == \"ollama\" {\n if entry.api_key.trim().is_empty() {\n entry.api_key = \"ollama\".to_string();\n }\n if entry.api_base.is_none() {\n entry.api_base = Some(\"http://localhost:11434\".to_string());\n }\n } else {\n let has_existing = !entry.api_key.trim().is_empty() && entry.api_key != \"dummy\";\n let final_key = if let Some(flag) = api_key_flag {\n flag.trim().to_string()\n } else if has_existing {\n prompt_line(\"API key (press Enter to keep existing): \")?\n } else {\n prompt_line(\"API key: \")?\n };\n\n if !final_key.is_empty() {\n entry.api_key = final_key;\n }\n\n if entry.api_key.trim().is_empty() || entry.api_key == \"dummy\" {\n bail!(\"Provider '{}' requires a valid API key.\", provider);\n }\n }\n\n let suggested_model = if let Some(entry) = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider)\n {\n entry.model.clone()\n } else {\n default_model_for_provider(provider).to_string()\n };\n\n let final_model = if let Some(m) = model_flag {\n m.trim().to_string()\n } else {\n prompt_line_with_default(\"Model\", &suggested_model)?\n };\n if final_model.trim().is_empty() {\n bail!(\"Model cannot be empty.\");\n }\n\n // Setup writes provider/model to model_pool first, and also mirrors to legacy\n // single-model fields for backward compatibility with older runtimes.\n config.agents.defaults.model_pool = vec![ModelEntry {\n model: final_model.clone(),\n provider: provider.to_string(),\n weight: 1,\n priority: 1,\n input_price: None,\n output_price: None,\n tool_call_mode: ToolCallMode::Native,\n }];\n config.agents.defaults.provider = Some(provider.to_string());\n config.agents.defaults.model = final_model;\n\n Ok(())\n}\n\nfn configure_channel(config: &mut Config, channel: &str) -> anyhow::Result<()> {\n match channel {\n \"telegram\" => {\n let existing = config.channels.telegram.token.clone();\n let prompt = if existing.trim().is_empty() {\n \"Telegram bot token: \"\n } else {\n \"Telegram bot token (press Enter to keep existing): \"\n };\n let token = prompt_line(prompt)?;\n if !token.is_empty() {\n config.channels.telegram.token = token;\n }\n if config.channels.telegram.token.trim().is_empty() {\n bail!(\"Telegram token is required.\");\n }\n config.channels.telegram.enabled = true;\n }\n \"feishu\" => {\n let app_id =\n prompt_optional_with_existing(\"Feishu app_id\", &config.channels.feishu.app_id)?;\n let app_secret = prompt_optional_with_existing(\n \"Feishu app_secret\",\n &config.channels.feishu.app_secret,\n )?;\n\n if !app_id.is_empty() {\n config.channels.feishu.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.feishu.app_secret = app_secret;\n }\n\n if config.channels.feishu.app_id.trim().is_empty()\n || config.channels.feishu.app_secret.trim().is_empty()\n {\n bail!(\"Feishu app_id and app_secret are required.\");\n }\n config.channels.feishu.enabled = true;\n }\n \"wecom\" => {\n let corp_id =\n prompt_optional_with_existing(\"WeCom corp_id\", &config.channels.wecom.corp_id)?;\n let corp_secret = prompt_optional_with_existing(\n \"WeCom corp_secret\",\n &config.channels.wecom.corp_secret,\n )?;\n\n if !corp_id.is_empty() {\n config.channels.wecom.corp_id = corp_id;\n }\n if !corp_secret.is_empty() {\n config.channels.wecom.corp_secret = corp_secret;\n }\n\n if config.channels.wecom.corp_id.trim().is_empty()\n || config.channels.wecom.corp_secret.trim().is_empty()\n {\n bail!(\"WeCom corp_id and corp_secret are required.\");\n }\n\n let existing_agent = if config.channels.wecom.agent_id > 0 {\n config.channels.wecom.agent_id.to_string()\n } else {\n \"1000002\".to_string()\n };\n let agent_id_str = prompt_line_with_default(\"WeCom agent_id\", &existing_agent)?;\n let agent_id = agent_id_str\n .trim()\n .parse::()\n .map_err(|_| anyhow!(\"agent_id must be an integer\"))?;\n config.channels.wecom.agent_id = agent_id;\n config.channels.wecom.enabled = true;\n }\n \"dingtalk\" => {\n let app_key = prompt_optional_with_existing(\n \"DingTalk app_key\",\n &config.channels.dingtalk.app_key,\n )?;\n let app_secret = prompt_optional_with_existing(\n \"DingTalk app_secret\",\n &config.channels.dingtalk.app_secret,\n )?;\n if !app_key.is_empty() {\n config.channels.dingtalk.app_key = app_key;\n }\n if !app_secret.is_empty() {\n config.channels.dingtalk.app_secret = app_secret;\n }\n if config.channels.dingtalk.app_key.trim().is_empty()\n || config.channels.dingtalk.app_secret.trim().is_empty()\n {\n bail!(\"DingTalk app_key and app_secret are required.\");\n }\n config.channels.dingtalk.enabled = true;\n }\n \"lark\" => {\n let app_id =\n prompt_optional_with_existing(\"Lark app_id\", &config.channels.lark.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"Lark app_secret\", &config.channels.lark.app_secret)?;\n if !app_id.is_empty() {\n config.channels.lark.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.lark.app_secret = app_secret;\n }\n if config.channels.lark.app_id.trim().is_empty()\n || config.channels.lark.app_secret.trim().is_empty()\n {\n bail!(\"Lark app_id and app_secret are required.\");\n }\n config.channels.lark.enabled = true;\n }\n \"qq\" => {\n let app_id =\n prompt_optional_with_existing(\"QQ app_id\", &config.channels.qq.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"QQ app_secret\", &config.channels.qq.app_secret)?;\n if !app_id.is_empty() {\n config.channels.qq.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.qq.app_secret = app_secret;\n }\n if config.channels.qq.app_id.trim().is_empty()\n || config.channels.qq.app_secret.trim().is_empty()\n {\n bail!(\"QQ app_id and app_secret are required.\");\n }\n let environment = prompt_optional_with_existing(\n \"QQ environment (production/sandbox)\",\n &config.channels.qq.environment,\n )?;\n if !environment.is_empty() {\n config.channels.qq.environment = environment;\n }\n if config.channels.qq.environment.trim().is_empty() {\n config.channels.qq.environment = \"production\".to_string();\n }\n config.channels.qq.enabled = true;\n }\n _ => bail!(\"Unsupported channel '{}'\", channel),\n }\n\n Ok(())\n}\n\nfn ensure_channel_owner(config: &mut Config, channel: &str) {\n if config.resolve_channel_owner(channel).is_some() {\n return;\n }\n let owner = config\n .known_agent_ids()\n .into_iter()\n .next()\n .unwrap_or_else(|| \"default\".to_string());\n config\n .channel_owners\n .insert(channel.to_string(), owner.clone());\n println!(\"Assigned channel owner: {} -> {}\", channel, owner);\n}\n\nfn prompt_provider(config: &Config) -> anyhow::Result {\n let current = primary_pool_entry(config)\n .map(|e| e.provider.clone())\n .or_else(|| {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| !e.provider.trim().is_empty())\n .map(|e| e.provider.clone())\n })\n .or_else(|| config.agents.defaults.provider.clone())\n .or_else(|| config.get_api_key().map(|(name, _)| name.to_string()))\n .unwrap_or_else(|| \"none\".to_string());\n\n println!(\"Configure LLM provider (current: {})\", current);\n let options = [\n \"deepseek (recommended)\",\n \"openai\",\n \"kimi\",\n \"anthropic\",\n \"gemini\",\n \"zhipu\",\n \"minimax\",\n \"ollama (local)\",\n \"skip\",\n ];\n let idx = prompt_select(\"Choose provider\", &options, 0)?;\n let mapped = match idx {\n 0 => \"deepseek\",\n 1 => \"openai\",\n 2 => \"kimi\",\n 3 => \"anthropic\",\n 4 => \"gemini\",\n 5 => \"zhipu\",\n 6 => \"minimax\",\n 7 => \"ollama\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn primary_pool_entry(config: &Config) -> Option<&ModelEntry> {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .min_by(|a, b| a.priority.cmp(&b.priority).then(b.weight.cmp(&a.weight)))\n}\n\nfn prompt_channel() -> anyhow::Result {\n let options = [\n \"skip (WebUI only)\",\n \"telegram\",\n \"feishu\",\n \"wecom\",\n \"dingtalk\",\n \"lark\",\n \"qq\",\n ];\n let idx = prompt_select(\"Configure one channel (optional)\", &options, 0)?;\n let mapped = match idx {\n 1 => \"telegram\",\n 2 => \"feishu\",\n 3 => \"wecom\",\n 4 => \"dingtalk\",\n 5 => \"lark\",\n 6 => \"qq\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn resolve_provider(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_provider(v)\n .ok_or_else(|| anyhow!(\"Unsupported provider '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn resolve_channel(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_channel(v)\n .ok_or_else(|| anyhow!(\"Unsupported channel '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn normalize_provider(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"deepseek\" => Some(\"deepseek\"),\n \"openai\" => Some(\"openai\"),\n \"kimi\" | \"moonshot\" => Some(\"kimi\"),\n \"anthropic\" | \"claude\" => Some(\"anthropic\"),\n \"gemini\" => Some(\"gemini\"),\n \"zhipu\" => Some(\"zhipu\"),\n \"minimax\" => Some(\"minimax\"),\n \"ollama\" => Some(\"ollama\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn normalize_channel(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"telegram\" => Some(\"telegram\"),\n \"feishu\" => Some(\"feishu\"),\n \"wecom\" | \"wechatwork\" => Some(\"wecom\"),\n \"dingtalk\" => Some(\"dingtalk\"),\n \"lark\" => Some(\"lark\"),\n \"qq\" => Some(\"qq\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn default_model_for_provider(provider: &str) -> &'static str {\n match provider {\n \"deepseek\" => \"deepseek-chat\",\n \"openai\" => \"gpt-4o\",\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"kimi\" => \"moonshot-v1-8k\",\n \"gemini\" => \"gemini-2.0-flash\",\n \"zhipu\" => \"glm-4\",\n \"minimax\" => \"minimax-text-01\",\n \"ollama\" => \"llama3\",\n _ => \"gpt-4o\",\n }\n}\n\nfn default_api_base_for_provider(provider: &str) -> Option<&'static str> {\n match provider {\n \"deepseek\" => Some(\"https://api.deepseek.com/v1\"),\n \"openai\" => Some(\"https://api.openai.com/v1\"),\n \"anthropic\" => Some(\"https://api.anthropic.com\"),\n \"kimi\" => Some(\"https://api.moonshot.cn/v1\"),\n \"gemini\" => Some(\"https://generativelanguage.googleapis.com/v1beta/openai\"),\n \"zhipu\" => Some(\"https://open.bigmodel.cn/api/paas/v4\"),\n \"minimax\" => Some(\"https://api.minimaxi.com/v1\"),\n \"ollama\" => Some(\"http://localhost:11434\"),\n _ => None,\n }\n}\n\nfn default_api_type_for_provider(provider: &str) -> &'static str {\n match provider {\n \"anthropic\" | \"minimax\" => \"anthropic\",\n \"gemini\" => \"openai\",\n \"ollama\" => \"ollama\",\n _ => \"openai\",\n }\n}\n\nfn prompt_select(title: &str, options: &[&str], default_index: usize) -> anyhow::Result {\n println!(\"{}\", title);\n for (i, opt) in options.iter().enumerate() {\n println!(\" {}. {}\", i + 1, opt);\n }\n\n loop {\n let input = prompt_line(&format!(\n \"Enter choice [1-{}] (default {}): \",\n options.len(),\n default_index + 1\n ))?;\n\n if input.trim().is_empty() {\n return Ok(default_index);\n }\n\n if let Ok(n) = input.trim().parse::() {\n if n >= 1 && n <= options.len() {\n return Ok(n - 1);\n }\n }\n println!(\"Invalid choice, please try again.\");\n }\n}\n\nfn prompt_optional_with_existing(label: &str, existing: &str) -> anyhow::Result {\n let prompt = if existing.trim().is_empty() {\n format!(\"{}: \", label)\n } else {\n format!(\"{} (press Enter to keep existing): \", label)\n };\n prompt_line(&prompt)\n}\n\nfn prompt_line_with_default(label: &str, default: &str) -> anyhow::Result {\n let input = prompt_line(&format!(\"{} [{}]: \", label, default))?;\n if input.trim().is_empty() {\n Ok(default.to_string())\n } else {\n Ok(input)\n }\n}\n\nfn prompt_line(prompt: &str) -> anyhow::Result {\n print!(\"{}\", prompt);\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin().read_line(&mut input)?;\n Ok(input.trim().to_string())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_provider_aliases() {\n assert_eq!(normalize_provider(\"moonshot\"), Some(\"kimi\"));\n assert_eq!(normalize_provider(\"claude\"), Some(\"anthropic\"));\n assert_eq!(normalize_provider(\"zhipu\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"minimax\"), Some(\"minimax\"));\n assert_eq!(normalize_provider(\"none\"), Some(\"skip\"));\n assert_eq!(normalize_provider(\"unknown\"), None);\n }\n\n #[test]\n fn test_normalize_channel_aliases() {\n assert_eq!(normalize_channel(\"wechatwork\"), Some(\"wecom\"));\n assert_eq!(normalize_channel(\"dingtalk\"), Some(\"dingtalk\"));\n assert_eq!(normalize_channel(\"qq\"), Some(\"qq\"));\n assert_eq!(normalize_channel(\"skip\"), Some(\"skip\"));\n assert_eq!(normalize_channel(\"unknown\"), None);\n }\n\n #[test]\n fn test_default_model() {\n assert_eq!(default_model_for_provider(\"deepseek\"), \"deepseek-chat\");\n assert_eq!(default_model_for_provider(\"openai\"), \"gpt-4o\");\n assert_eq!(default_model_for_provider(\"kimi\"), \"moonshot-v1-8k\");\n assert_eq!(default_model_for_provider(\"zhipu\"), \"glm-4\");\n assert_eq!(default_model_for_provider(\"minimax\"), \"minimax-text-01\");\n }\n\n #[test]\n fn test_configure_provider_populates_legacy_single_model_fields_for_compat() {\n let mut config = Config::default();\n config.agents.defaults.model = \"legacy-model\".to_string();\n config.agents.defaults.provider = Some(\"legacy-provider\".to_string());\n\n configure_provider(\n &mut config,\n \"deepseek\",\n Some(\"test-deepseek-key\"),\n Some(\"deepseek-chat\"),\n )\n .expect(\"configure_provider should succeed\");\n\n assert_eq!(\n config.agents.defaults.provider,\n Some(\"deepseek\".to_string())\n );\n assert_eq!(config.agents.defaults.model, \"deepseek-chat\");\n assert_eq!(config.agents.defaults.model_pool.len(), 1);\n let entry = &config.agents.defaults.model_pool[0];\n assert_eq!(entry.provider, \"deepseek\");\n assert_eq!(entry.model, \"deepseek-chat\");\n assert_eq!(entry.weight, 1);\n assert_eq!(entry.priority, 1);\n }\n\n #[test]\n fn test_ensure_channel_owner_defaults_to_default_agent() {\n let mut config = Config::default();\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"default\")\n );\n }\n\n #[test]\n fn test_ensure_channel_owner_keeps_existing() {\n let mut config = Config::default();\n config\n .channel_owners\n .insert(\"telegram\".to_string(), \"ops\".to_string());\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"ops\")\n );\n }\n}\n"}, "d701fa20676be4c216de98976393bcdc243045e4": {"t": 1785760750.5111058, "n": 21368, "h": "dba973ed73d2faff", "c": "use anyhow::{anyhow, bail, Context};\nuse blockcell_core::config::{ModelEntry, ProviderConfig, ToolCallMode};\nuse blockcell_core::{Config, Paths};\nuse std::io::{self, Write};\n\n/// Interactive first-run setup wizard for provider + optional channel.\npub async fn run(\n force: bool,\n provider: Option,\n api_key: Option,\n model: Option,\n channel: Option,\n skip_provider_test: bool,\n) -> anyhow::Result<()> {\n let paths = Paths::new();\n paths.ensure_dirs()?;\n\n let config_path = paths.config_file();\n let mut config = if config_path.exists() && !force {\n Config::load(&config_path)\n .with_context(|| format!(\"Failed to load {}\", config_path.display()))?\n } else {\n if config_path.exists() && force {\n println!(\"⚠ --force enabled: reset existing config to defaults before setup.\");\n }\n Config::default()\n };\n\n if config.intent_router.is_none() {\n config.intent_router = Config::default().intent_router;\n }\n\n println!(\"blockcell setup\");\n println!(\"==============\");\n println!(\"Config file: {}\", config_path.display());\n println!();\n\n let selected_provider = resolve_provider(provider.as_deref())?;\n let provider_name = if let Some(name) = selected_provider {\n name.to_string()\n } else {\n prompt_provider(&config)?\n };\n\n if provider_name != \"skip\" {\n configure_provider(\n &mut config,\n &provider_name,\n api_key.as_deref(),\n model.as_deref(),\n )?;\n } else {\n println!(\"Skipped LLM provider setup.\");\n }\n\n let selected_channel = resolve_channel(channel.as_deref())?;\n let channel_name = if let Some(name) = selected_channel {\n name.to_string()\n } else {\n prompt_channel()?\n };\n\n if channel_name != \"skip\" {\n configure_channel(&mut config, &channel_name)?;\n ensure_channel_owner(&mut config, &channel_name);\n } else {\n println!(\"Skipped channel setup (WebUI only).\");\n }\n\n config\n .save(&config_path)\n .with_context(|| format!(\"Failed to save {}\", config_path.display()))?;\n\n println!();\n println!(\"✓ Setup completed\");\n println!(\" Config: {}\", config_path.display());\n if provider_name != \"skip\" {\n let selected = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider_name)\n .or_else(|| primary_pool_entry(&config));\n let selected_model = selected\n .map(|e| e.model.as_str())\n .unwrap_or(config.agents.defaults.model.as_str());\n println!(\" Provider: {} Model: {}\", provider_name, selected_model);\n if !skip_provider_test {\n match blockcell_providers::create_provider(\n &config,\n selected_model,\n Some(&provider_name),\n ) {\n Ok(_) => println!(\" ✓ Provider config validation passed\"),\n Err(e) => println!(\" ⚠ Provider validation failed: {}\", e),\n }\n }\n }\n if channel_name != \"skip\" {\n println!(\" Channel: {} (enabled)\", channel_name);\n } else {\n println!(\" Channel: none (use WebUI only)\");\n }\n println!();\n println!(\"Next steps:\");\n println!(\" 1. blockcell status\");\n println!(\" 2. blockcell gateway\");\n println!(\" 3. Open WebUI: http://localhost:18791/\");\n\n Ok(())\n}\n\nfn configure_provider(\n config: &mut Config,\n provider: &str,\n api_key_flag: Option<&str>,\n model_flag: Option<&str>,\n) -> anyhow::Result<()> {\n let entry = config\n .providers\n .entry(provider.to_string())\n .or_insert_with(ProviderConfig::default);\n\n if entry.api_base.is_none() {\n entry.api_base = default_api_base_for_provider(provider).map(|s| s.to_string());\n }\n if entry.api_type.is_empty() {\n entry.api_type = default_api_type_for_provider(provider).to_string();\n }\n\n if provider == \"ollama\" {\n if entry.api_key.trim().is_empty() {\n entry.api_key = \"ollama\".to_string();\n }\n if entry.api_base.is_none() {\n entry.api_base = Some(\"http://localhost:11434\".to_string());\n }\n } else {\n let has_existing = !entry.api_key.trim().is_empty() && entry.api_key != \"dummy\";\n let final_key = if let Some(flag) = api_key_flag {\n flag.trim().to_string()\n } else if has_existing {\n prompt_line(\"API key (press Enter to keep existing): \")?\n } else {\n prompt_line(\"API key: \")?\n };\n\n if !final_key.is_empty() {\n entry.api_key = final_key;\n }\n\n if entry.api_key.trim().is_empty() || entry.api_key == \"dummy\" {\n bail!(\"Provider '{}' requires a valid API key.\", provider);\n }\n }\n\n let suggested_model = if let Some(entry) = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider)\n {\n entry.model.clone()\n } else {\n default_model_for_provider(provider).to_string()\n };\n\n let final_model = if let Some(m) = model_flag {\n m.trim().to_string()\n } else {\n prompt_line_with_default(\"Model\", &suggested_model)?\n };\n if final_model.trim().is_empty() {\n bail!(\"Model cannot be empty.\");\n }\n\n // Setup writes provider/model to model_pool first, and also mirrors to legacy\n // single-model fields for backward compatibility with older runtimes.\n config.agents.defaults.model_pool = vec![ModelEntry {\n model: final_model.clone(),\n provider: provider.to_string(),\n weight: 1,\n priority: 1,\n input_price: None,\n output_price: None,\n tool_call_mode: ToolCallMode::Native,\n }];\n config.agents.defaults.provider = Some(provider.to_string());\n config.agents.defaults.model = final_model;\n\n Ok(())\n}\n\nfn configure_channel(config: &mut Config, channel: &str) -> anyhow::Result<()> {\n match channel {\n \"telegram\" => {\n let existing = config.channels.telegram.token.clone();\n let prompt = if existing.trim().is_empty() {\n \"Telegram bot token: \"\n } else {\n \"Telegram bot token (press Enter to keep existing): \"\n };\n let token = prompt_line(prompt)?;\n if !token.is_empty() {\n config.channels.telegram.token = token;\n }\n if config.channels.telegram.token.trim().is_empty() {\n bail!(\"Telegram token is required.\");\n }\n config.channels.telegram.enabled = true;\n }\n \"feishu\" => {\n let app_id =\n prompt_optional_with_existing(\"Feishu app_id\", &config.channels.feishu.app_id)?;\n let app_secret = prompt_optional_with_existing(\n \"Feishu app_secret\",\n &config.channels.feishu.app_secret,\n )?;\n\n if !app_id.is_empty() {\n config.channels.feishu.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.feishu.app_secret = app_secret;\n }\n\n if config.channels.feishu.app_id.trim().is_empty()\n || config.channels.feishu.app_secret.trim().is_empty()\n {\n bail!(\"Feishu app_id and app_secret are required.\");\n }\n config.channels.feishu.enabled = true;\n }\n \"wecom\" => {\n let corp_id =\n prompt_optional_with_existing(\"WeCom corp_id\", &config.channels.wecom.corp_id)?;\n let corp_secret = prompt_optional_with_existing(\n \"WeCom corp_secret\",\n &config.channels.wecom.corp_secret,\n )?;\n\n if !corp_id.is_empty() {\n config.channels.wecom.corp_id = corp_id;\n }\n if !corp_secret.is_empty() {\n config.channels.wecom.corp_secret = corp_secret;\n }\n\n if config.channels.wecom.corp_id.trim().is_empty()\n || config.channels.wecom.corp_secret.trim().is_empty()\n {\n bail!(\"WeCom corp_id and corp_secret are required.\");\n }\n\n let existing_agent = if config.channels.wecom.agent_id > 0 {\n config.channels.wecom.agent_id.to_string()\n } else {\n \"1000002\".to_string()\n };\n let agent_id_str = prompt_line_with_default(\"WeCom agent_id\", &existing_agent)?;\n let agent_id = agent_id_str\n .trim()\n .parse::()\n .map_err(|_| anyhow!(\"agent_id must be an integer\"))?;\n config.channels.wecom.agent_id = agent_id;\n config.channels.wecom.enabled = true;\n }\n \"dingtalk\" => {\n let app_key = prompt_optional_with_existing(\n \"DingTalk app_key\",\n &config.channels.dingtalk.app_key,\n )?;\n let app_secret = prompt_optional_with_existing(\n \"DingTalk app_secret\",\n &config.channels.dingtalk.app_secret,\n )?;\n if !app_key.is_empty() {\n config.channels.dingtalk.app_key = app_key;\n }\n if !app_secret.is_empty() {\n config.channels.dingtalk.app_secret = app_secret;\n }\n if config.channels.dingtalk.app_key.trim().is_empty()\n || config.channels.dingtalk.app_secret.trim().is_empty()\n {\n bail!(\"DingTalk app_key and app_secret are required.\");\n }\n config.channels.dingtalk.enabled = true;\n }\n \"lark\" => {\n let app_id =\n prompt_optional_with_existing(\"Lark app_id\", &config.channels.lark.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"Lark app_secret\", &config.channels.lark.app_secret)?;\n if !app_id.is_empty() {\n config.channels.lark.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.lark.app_secret = app_secret;\n }\n if config.channels.lark.app_id.trim().is_empty()\n || config.channels.lark.app_secret.trim().is_empty()\n {\n bail!(\"Lark app_id and app_secret are required.\");\n }\n config.channels.lark.enabled = true;\n }\n \"qq\" => {\n let app_id =\n prompt_optional_with_existing(\"QQ app_id\", &config.channels.qq.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"QQ app_secret\", &config.channels.qq.app_secret)?;\n if !app_id.is_empty() {\n config.channels.qq.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.qq.app_secret = app_secret;\n }\n if config.channels.qq.app_id.trim().is_empty()\n || config.channels.qq.app_secret.trim().is_empty()\n {\n bail!(\"QQ app_id and app_secret are required.\");\n }\n let environment = prompt_optional_with_existing(\n \"QQ environment (production/sandbox)\",\n &config.channels.qq.environment,\n )?;\n if !environment.is_empty() {\n config.channels.qq.environment = environment;\n }\n if config.channels.qq.environment.trim().is_empty() {\n config.channels.qq.environment = \"production\".to_string();\n }\n config.channels.qq.enabled = true;\n }\n _ => bail!(\"Unsupported channel '{}'\", channel),\n }\n\n Ok(())\n}\n\nfn ensure_channel_owner(config: &mut Config, channel: &str) {\n if config.resolve_channel_owner(channel).is_some() {\n return;\n }\n let owner = config\n .known_agent_ids()\n .into_iter()\n .next()\n .unwrap_or_else(|| \"default\".to_string());\n config\n .channel_owners\n .insert(channel.to_string(), owner.clone());\n println!(\"Assigned channel owner: {} -> {}\", channel, owner);\n}\n\nfn prompt_provider(config: &Config) -> anyhow::Result {\n let current = primary_pool_entry(config)\n .map(|e| e.provider.clone())\n .or_else(|| {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| !e.provider.trim().is_empty())\n .map(|e| e.provider.clone())\n })\n .or_else(|| config.agents.defaults.provider.clone())\n .or_else(|| config.get_api_key().map(|(name, _)| name.to_string()))\n .unwrap_or_else(|| \"none\".to_string());\n\n println!(\"Configure LLM provider (current: {})\", current);\n let options = [\n \"deepseek (recommended)\",\n \"openai\",\n \"kimi\",\n \"anthropic\",\n \"gemini\",\n \"zhipu\",\n \"minimax\",\n \"ollama (local)\",\n \"skip\",\n ];\n let idx = prompt_select(\"Choose provider\", &options, 0)?;\n let mapped = match idx {\n 0 => \"deepseek\",\n 1 => \"openai\",\n 2 => \"kimi\",\n 3 => \"anthropic\",\n 4 => \"gemini\",\n 5 => \"zhipu\",\n 6 => \"minimax\",\n 7 => \"ollama\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn primary_pool_entry(config: &Config) -> Option<&ModelEntry> {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .min_by(|a, b| a.priority.cmp(&b.priority).then(b.weight.cmp(&a.weight)))\n}\n\nfn prompt_channel() -> anyhow::Result {\n let options = [\n \"skip (WebUI only)\",\n \"telegram\",\n \"feishu\",\n \"wecom\",\n \"dingtalk\",\n \"lark\",\n \"qq\",\n ];\n let idx = prompt_select(\"Configure one channel (optional)\", &options, 0)?;\n let mapped = match idx {\n 1 => \"telegram\",\n 2 => \"feishu\",\n 3 => \"wecom\",\n 4 => \"dingtalk\",\n 5 => \"lark\",\n 6 => \"qq\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn resolve_provider(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_provider(v)\n .ok_or_else(|| anyhow!(\"Unsupported provider '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn resolve_channel(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_channel(v)\n .ok_or_else(|| anyhow!(\"Unsupported channel '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn normalize_provider(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"deepseek\" => Some(\"deepseek\"),\n \"openai\" => Some(\"openai\"),\n \"kimi\" | \"moonshot\" => Some(\"kimi\"),\n \"anthropic\" | \"claude\" => Some(\"anthropic\"),\n \"gemini\" => Some(\"gemini\"),\n \"zhipu\" => Some(\"zhipu\"),\n \"minimax\" => Some(\"minimax\"),\n \"ollama\" => Some(\"ollama\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn normalize_channel(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"telegram\" => Some(\"telegram\"),\n \"feishu\" => Some(\"feishu\"),\n \"wecom\" | \"wechatwork\" => Some(\"wecom\"),\n \"dingtalk\" => Some(\"dingtalk\"),\n \"lark\" => Some(\"lark\"),\n \"qq\" => Some(\"qq\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn default_model_for_provider(provider: &str) -> &'static str {\n match provider {\n \"deepseek\" => \"deepseek-chat\",\n \"openai\" => \"gpt-4o\",\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"kimi\" => \"moonshot-v1-8k\",\n \"gemini\" => \"gemini-2.0-flash\",\n \"zhipu\" => \"glm-4\",\n \"minimax\" => \"minimax-text-01\",\n \"ollama\" => \"llama3\",\n _ => \"gpt-4o\",\n }\n}\n\nfn default_api_base_for_provider(provider: &str) -> Option<&'static str> {\n match provider {\n \"deepseek\" => Some(\"https://api.deepseek.com/v1\"),\n \"openai\" => Some(\"https://api.openai.com/v1\"),\n \"anthropic\" => Some(\"https://api.anthropic.com\"),\n \"kimi\" => Some(\"https://api.moonshot.cn/v1\"),\n \"gemini\" => Some(\"https://generativelanguage.googleapis.com/v1beta/openai\"),\n \"zhipu\" => Some(\"https://open.bigmodel.cn/api/paas/v4\"),\n \"minimax\" => Some(\"https://api.minimaxi.com/v1\"),\n \"ollama\" => Some(\"http://localhost:11434\"),\n _ => None,\n }\n}\n\nfn default_api_type_for_provider(provider: &str) -> &'static str {\n match provider {\n \"anthropic\" | \"minimax\" => \"anthropic\",\n \"gemini\" => \"openai\",\n \"ollama\" => \"ollama\",\n _ => \"openai\",\n }\n}\n\nfn prompt_select(title: &str, options: &[&str], default_index: usize) -> anyhow::Result {\n println!(\"{}\", title);\n for (i, opt) in options.iter().enumerate() {\n println!(\" {}. {}\", i + 1, opt);\n }\n\n loop {\n let input = prompt_line(&format!(\n \"Enter choice [1-{}] (default {}): \",\n options.len(),\n default_index + 1\n ))?;\n\n if input.trim().is_empty() {\n return Ok(default_index);\n }\n\n if let Ok(n) = input.trim().parse::() {\n if n >= 1 && n <= options.len() {\n return Ok(n - 1);\n }\n }\n println!(\"Invalid choice, please try again.\");\n }\n}\n\nfn prompt_optional_with_existing(label: &str, existing: &str) -> anyhow::Result {\n let prompt = if existing.trim().is_empty() {\n format!(\"{}: \", label)\n } else {\n format!(\"{} (press Enter to keep existing): \", label)\n };\n prompt_line(&prompt)\n}\n\nfn prompt_line_with_default(label: &str, default: &str) -> anyhow::Result {\n let input = prompt_line(&format!(\"{} [{}]: \", label, default))?;\n if input.trim().is_empty() {\n Ok(default.to_string())\n } else {\n Ok(input)\n }\n}\n\nfn prompt_line(prompt: &str) -> anyhow::Result {\n print!(\"{}\", prompt);\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin().read_line(&mut input)?;\n Ok(input.trim().to_string())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_provider_aliases() {\n assert_eq!(normalize_provider(\"moonshot\"), Some(\"kimi\"));\n assert_eq!(normalize_provider(\"claude\"), Some(\"anthropic\"));\n assert_eq!(normalize_provider(\"zhipu\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"minimax\"), Some(\"minimax\"));\n assert_eq!(normalize_provider(\"none\"), Some(\"skip\"));\n assert_eq!(normalize_provider(\"unknown\"), None);\n }\n\n #[test]\n fn test_normalize_channel_aliases() {\n assert_eq!(normalize_channel(\"wechatwork\"), Some(\"wecom\"));\n assert_eq!(normalize_channel(\"dingtalk\"), Some(\"dingtalk\"));\n assert_eq!(normalize_channel(\"qq\"), Some(\"qq\"));\n assert_eq!(normalize_channel(\"skip\"), Some(\"skip\"));\n assert_eq!(normalize_channel(\"unknown\"), None);\n }\n\n #[test]\n fn test_default_model() {\n assert_eq!(default_model_for_provider(\"deepseek\"), \"deepseek-chat\");\n assert_eq!(default_model_for_provider(\"openai\"), \"gpt-4o\");\n assert_eq!(default_model_for_provider(\"kimi\"), \"moonshot-v1-8k\");\n assert_eq!(default_model_for_provider(\"zhipu\"), \"glm-4\");\n assert_eq!(default_model_for_provider(\"minimax\"), \"minimax-text-01\");\n }\n\n #[test]\n fn test_configure_provider_populates_legacy_single_model_fields_for_compat() {\n let mut config = Config::default();\n config.agents.defaults.model = \"legacy-model\".to_string();\n config.agents.defaults.provider = Some(\"legacy-provider\".to_string());\n\n configure_provider(\n &mut config,\n \"deepseek\",\n Some(\"test-deepseek-key\"),\n Some(\"deepseek-chat\"),\n )\n .expect(\"configure_provider should succeed\");\n\n assert_eq!(\n config.agents.defaults.provider,\n Some(\"deepseek\".to_string())\n );\n assert_eq!(config.agents.defaults.model, \"deepseek-chat\");\n assert_eq!(config.agents.defaults.model_pool.len(), 1);\n let entry = &config.agents.defaults.model_pool[0];\n assert_eq!(entry.provider, \"deepseek\");\n assert_eq!(entry.model, \"deepseek-chat\");\n assert_eq!(entry.weight, 1);\n assert_eq!(entry.priority, 1);\n }\n\n #[test]\n fn test_ensure_channel_owner_defaults_to_default_agent() {\n let mut config = Config::default();\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"default\")\n );\n }\n\n #[test]\n fn test_ensure_channel_owner_keeps_existing() {\n let mut config = Config::default();\n config\n .channel_owners\n .insert(\"telegram\".to_string(), \"ops\".to_string());\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"ops\")\n );\n }\n}\n"}, "241bdd8fabd2cd46aa272df974bfcbd05ef9b034": {"t": 1785760753.014973, "n": 21325, "h": "439c939fcb80e71e", "c": "use anyhow::{anyhow, bail, Context};\nuse blockcell_core::config::{ModelEntry, ToolCallMode};\nuse blockcell_core::{Config, Paths};\nuse std::io::{self, Write};\n\n/// Interactive first-run setup wizard for provider + optional channel.\npub async fn run(\n force: bool,\n provider: Option,\n api_key: Option,\n model: Option,\n channel: Option,\n skip_provider_test: bool,\n) -> anyhow::Result<()> {\n let paths = Paths::new();\n paths.ensure_dirs()?;\n\n let config_path = paths.config_file();\n let mut config = if config_path.exists() && !force {\n Config::load(&config_path)\n .with_context(|| format!(\"Failed to load {}\", config_path.display()))?\n } else {\n if config_path.exists() && force {\n println!(\"⚠ --force enabled: reset existing config to defaults before setup.\");\n }\n Config::default()\n };\n\n if config.intent_router.is_none() {\n config.intent_router = Config::default().intent_router;\n }\n\n println!(\"blockcell setup\");\n println!(\"==============\");\n println!(\"Config file: {}\", config_path.display());\n println!();\n\n let selected_provider = resolve_provider(provider.as_deref())?;\n let provider_name = if let Some(name) = selected_provider {\n name.to_string()\n } else {\n prompt_provider(&config)?\n };\n\n if provider_name != \"skip\" {\n configure_provider(\n &mut config,\n &provider_name,\n api_key.as_deref(),\n model.as_deref(),\n )?;\n } else {\n println!(\"Skipped LLM provider setup.\");\n }\n\n let selected_channel = resolve_channel(channel.as_deref())?;\n let channel_name = if let Some(name) = selected_channel {\n name.to_string()\n } else {\n prompt_channel()?\n };\n\n if channel_name != \"skip\" {\n configure_channel(&mut config, &channel_name)?;\n ensure_channel_owner(&mut config, &channel_name);\n } else {\n println!(\"Skipped channel setup (WebUI only).\");\n }\n\n config\n .save(&config_path)\n .with_context(|| format!(\"Failed to save {}\", config_path.display()))?;\n\n println!();\n println!(\"✓ Setup completed\");\n println!(\" Config: {}\", config_path.display());\n if provider_name != \"skip\" {\n let selected = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider_name)\n .or_else(|| primary_pool_entry(&config));\n let selected_model = selected\n .map(|e| e.model.as_str())\n .unwrap_or(config.agents.defaults.model.as_str());\n println!(\" Provider: {} Model: {}\", provider_name, selected_model);\n if !skip_provider_test {\n match blockcell_providers::create_provider(\n &config,\n selected_model,\n Some(&provider_name),\n ) {\n Ok(_) => println!(\" ✓ Provider config validation passed\"),\n Err(e) => println!(\" ⚠ Provider validation failed: {}\", e),\n }\n }\n }\n if channel_name != \"skip\" {\n println!(\" Channel: {} (enabled)\", channel_name);\n } else {\n println!(\" Channel: none (use WebUI only)\");\n }\n println!();\n println!(\"Next steps:\");\n println!(\" 1. blockcell status\");\n println!(\" 2. blockcell gateway\");\n println!(\" 3. Open WebUI: http://localhost:18791/\");\n\n Ok(())\n}\n\nfn configure_provider(\n config: &mut Config,\n provider: &str,\n api_key_flag: Option<&str>,\n model_flag: Option<&str>,\n) -> anyhow::Result<()> {\n let entry = config\n .providers\n .entry(provider.to_string())\n .or_default();\n\n if entry.api_base.is_none() {\n entry.api_base = default_api_base_for_provider(provider).map(|s| s.to_string());\n }\n if entry.api_type.is_empty() {\n entry.api_type = default_api_type_for_provider(provider).to_string();\n }\n\n if provider == \"ollama\" {\n if entry.api_key.trim().is_empty() {\n entry.api_key = \"ollama\".to_string();\n }\n if entry.api_base.is_none() {\n entry.api_base = Some(\"http://localhost:11434\".to_string());\n }\n } else {\n let has_existing = !entry.api_key.trim().is_empty() && entry.api_key != \"dummy\";\n let final_key = if let Some(flag) = api_key_flag {\n flag.trim().to_string()\n } else if has_existing {\n prompt_line(\"API key (press Enter to keep existing): \")?\n } else {\n prompt_line(\"API key: \")?\n };\n\n if !final_key.is_empty() {\n entry.api_key = final_key;\n }\n\n if entry.api_key.trim().is_empty() || entry.api_key == \"dummy\" {\n bail!(\"Provider '{}' requires a valid API key.\", provider);\n }\n }\n\n let suggested_model = if let Some(entry) = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider)\n {\n entry.model.clone()\n } else {\n default_model_for_provider(provider).to_string()\n };\n\n let final_model = if let Some(m) = model_flag {\n m.trim().to_string()\n } else {\n prompt_line_with_default(\"Model\", &suggested_model)?\n };\n if final_model.trim().is_empty() {\n bail!(\"Model cannot be empty.\");\n }\n\n // Setup writes provider/model to model_pool first, and also mirrors to legacy\n // single-model fields for backward compatibility with older runtimes.\n config.agents.defaults.model_pool = vec![ModelEntry {\n model: final_model.clone(),\n provider: provider.to_string(),\n weight: 1,\n priority: 1,\n input_price: None,\n output_price: None,\n tool_call_mode: ToolCallMode::Native,\n }];\n config.agents.defaults.provider = Some(provider.to_string());\n config.agents.defaults.model = final_model;\n\n Ok(())\n}\n\nfn configure_channel(config: &mut Config, channel: &str) -> anyhow::Result<()> {\n match channel {\n \"telegram\" => {\n let existing = config.channels.telegram.token.clone();\n let prompt = if existing.trim().is_empty() {\n \"Telegram bot token: \"\n } else {\n \"Telegram bot token (press Enter to keep existing): \"\n };\n let token = prompt_line(prompt)?;\n if !token.is_empty() {\n config.channels.telegram.token = token;\n }\n if config.channels.telegram.token.trim().is_empty() {\n bail!(\"Telegram token is required.\");\n }\n config.channels.telegram.enabled = true;\n }\n \"feishu\" => {\n let app_id =\n prompt_optional_with_existing(\"Feishu app_id\", &config.channels.feishu.app_id)?;\n let app_secret = prompt_optional_with_existing(\n \"Feishu app_secret\",\n &config.channels.feishu.app_secret,\n )?;\n\n if !app_id.is_empty() {\n config.channels.feishu.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.feishu.app_secret = app_secret;\n }\n\n if config.channels.feishu.app_id.trim().is_empty()\n || config.channels.feishu.app_secret.trim().is_empty()\n {\n bail!(\"Feishu app_id and app_secret are required.\");\n }\n config.channels.feishu.enabled = true;\n }\n \"wecom\" => {\n let corp_id =\n prompt_optional_with_existing(\"WeCom corp_id\", &config.channels.wecom.corp_id)?;\n let corp_secret = prompt_optional_with_existing(\n \"WeCom corp_secret\",\n &config.channels.wecom.corp_secret,\n )?;\n\n if !corp_id.is_empty() {\n config.channels.wecom.corp_id = corp_id;\n }\n if !corp_secret.is_empty() {\n config.channels.wecom.corp_secret = corp_secret;\n }\n\n if config.channels.wecom.corp_id.trim().is_empty()\n || config.channels.wecom.corp_secret.trim().is_empty()\n {\n bail!(\"WeCom corp_id and corp_secret are required.\");\n }\n\n let existing_agent = if config.channels.wecom.agent_id > 0 {\n config.channels.wecom.agent_id.to_string()\n } else {\n \"1000002\".to_string()\n };\n let agent_id_str = prompt_line_with_default(\"WeCom agent_id\", &existing_agent)?;\n let agent_id = agent_id_str\n .trim()\n .parse::()\n .map_err(|_| anyhow!(\"agent_id must be an integer\"))?;\n config.channels.wecom.agent_id = agent_id;\n config.channels.wecom.enabled = true;\n }\n \"dingtalk\" => {\n let app_key = prompt_optional_with_existing(\n \"DingTalk app_key\",\n &config.channels.dingtalk.app_key,\n )?;\n let app_secret = prompt_optional_with_existing(\n \"DingTalk app_secret\",\n &config.channels.dingtalk.app_secret,\n )?;\n if !app_key.is_empty() {\n config.channels.dingtalk.app_key = app_key;\n }\n if !app_secret.is_empty() {\n config.channels.dingtalk.app_secret = app_secret;\n }\n if config.channels.dingtalk.app_key.trim().is_empty()\n || config.channels.dingtalk.app_secret.trim().is_empty()\n {\n bail!(\"DingTalk app_key and app_secret are required.\");\n }\n config.channels.dingtalk.enabled = true;\n }\n \"lark\" => {\n let app_id =\n prompt_optional_with_existing(\"Lark app_id\", &config.channels.lark.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"Lark app_secret\", &config.channels.lark.app_secret)?;\n if !app_id.is_empty() {\n config.channels.lark.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.lark.app_secret = app_secret;\n }\n if config.channels.lark.app_id.trim().is_empty()\n || config.channels.lark.app_secret.trim().is_empty()\n {\n bail!(\"Lark app_id and app_secret are required.\");\n }\n config.channels.lark.enabled = true;\n }\n \"qq\" => {\n let app_id =\n prompt_optional_with_existing(\"QQ app_id\", &config.channels.qq.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"QQ app_secret\", &config.channels.qq.app_secret)?;\n if !app_id.is_empty() {\n config.channels.qq.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.qq.app_secret = app_secret;\n }\n if config.channels.qq.app_id.trim().is_empty()\n || config.channels.qq.app_secret.trim().is_empty()\n {\n bail!(\"QQ app_id and app_secret are required.\");\n }\n let environment = prompt_optional_with_existing(\n \"QQ environment (production/sandbox)\",\n &config.channels.qq.environment,\n )?;\n if !environment.is_empty() {\n config.channels.qq.environment = environment;\n }\n if config.channels.qq.environment.trim().is_empty() {\n config.channels.qq.environment = \"production\".to_string();\n }\n config.channels.qq.enabled = true;\n }\n _ => bail!(\"Unsupported channel '{}'\", channel),\n }\n\n Ok(())\n}\n\nfn ensure_channel_owner(config: &mut Config, channel: &str) {\n if config.resolve_channel_owner(channel).is_some() {\n return;\n }\n let owner = config\n .known_agent_ids()\n .into_iter()\n .next()\n .unwrap_or_else(|| \"default\".to_string());\n config\n .channel_owners\n .insert(channel.to_string(), owner.clone());\n println!(\"Assigned channel owner: {} -> {}\", channel, owner);\n}\n\nfn prompt_provider(config: &Config) -> anyhow::Result {\n let current = primary_pool_entry(config)\n .map(|e| e.provider.clone())\n .or_else(|| {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| !e.provider.trim().is_empty())\n .map(|e| e.provider.clone())\n })\n .or_else(|| config.agents.defaults.provider.clone())\n .or_else(|| config.get_api_key().map(|(name, _)| name.to_string()))\n .unwrap_or_else(|| \"none\".to_string());\n\n println!(\"Configure LLM provider (current: {})\", current);\n let options = [\n \"deepseek (recommended)\",\n \"openai\",\n \"kimi\",\n \"anthropic\",\n \"gemini\",\n \"zhipu\",\n \"minimax\",\n \"ollama (local)\",\n \"skip\",\n ];\n let idx = prompt_select(\"Choose provider\", &options, 0)?;\n let mapped = match idx {\n 0 => \"deepseek\",\n 1 => \"openai\",\n 2 => \"kimi\",\n 3 => \"anthropic\",\n 4 => \"gemini\",\n 5 => \"zhipu\",\n 6 => \"minimax\",\n 7 => \"ollama\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn primary_pool_entry(config: &Config) -> Option<&ModelEntry> {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .min_by(|a, b| a.priority.cmp(&b.priority).then(b.weight.cmp(&a.weight)))\n}\n\nfn prompt_channel() -> anyhow::Result {\n let options = [\n \"skip (WebUI only)\",\n \"telegram\",\n \"feishu\",\n \"wecom\",\n \"dingtalk\",\n \"lark\",\n \"qq\",\n ];\n let idx = prompt_select(\"Configure one channel (optional)\", &options, 0)?;\n let mapped = match idx {\n 1 => \"telegram\",\n 2 => \"feishu\",\n 3 => \"wecom\",\n 4 => \"dingtalk\",\n 5 => \"lark\",\n 6 => \"qq\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn resolve_provider(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_provider(v)\n .ok_or_else(|| anyhow!(\"Unsupported provider '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn resolve_channel(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_channel(v)\n .ok_or_else(|| anyhow!(\"Unsupported channel '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn normalize_provider(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"deepseek\" => Some(\"deepseek\"),\n \"openai\" => Some(\"openai\"),\n \"kimi\" | \"moonshot\" => Some(\"kimi\"),\n \"anthropic\" | \"claude\" => Some(\"anthropic\"),\n \"gemini\" => Some(\"gemini\"),\n \"zhipu\" => Some(\"zhipu\"),\n \"minimax\" => Some(\"minimax\"),\n \"ollama\" => Some(\"ollama\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn normalize_channel(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"telegram\" => Some(\"telegram\"),\n \"feishu\" => Some(\"feishu\"),\n \"wecom\" | \"wechatwork\" => Some(\"wecom\"),\n \"dingtalk\" => Some(\"dingtalk\"),\n \"lark\" => Some(\"lark\"),\n \"qq\" => Some(\"qq\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn default_model_for_provider(provider: &str) -> &'static str {\n match provider {\n \"deepseek\" => \"deepseek-chat\",\n \"openai\" => \"gpt-4o\",\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"kimi\" => \"moonshot-v1-8k\",\n \"gemini\" => \"gemini-2.0-flash\",\n \"zhipu\" => \"glm-4\",\n \"minimax\" => \"minimax-text-01\",\n \"ollama\" => \"llama3\",\n _ => \"gpt-4o\",\n }\n}\n\nfn default_api_base_for_provider(provider: &str) -> Option<&'static str> {\n match provider {\n \"deepseek\" => Some(\"https://api.deepseek.com/v1\"),\n \"openai\" => Some(\"https://api.openai.com/v1\"),\n \"anthropic\" => Some(\"https://api.anthropic.com\"),\n \"kimi\" => Some(\"https://api.moonshot.cn/v1\"),\n \"gemini\" => Some(\"https://generativelanguage.googleapis.com/v1beta/openai\"),\n \"zhipu\" => Some(\"https://open.bigmodel.cn/api/paas/v4\"),\n \"minimax\" => Some(\"https://api.minimax.chat/v1\"),\n \"ollama\" => Some(\"http://localhost:11434\"),\n _ => None,\n }\n}\n\nfn default_api_type_for_provider(provider: &str) -> &'static str {\n match provider {\n \"anthropic\" | \"minimax\" => \"anthropic\",\n \"gemini\" => \"openai\",\n \"ollama\" => \"ollama\",\n _ => \"openai\",\n }\n}\n\nfn prompt_select(title: &str, options: &[&str], default_index: usize) -> anyhow::Result {\n println!(\"{}\", title);\n for (i, opt) in options.iter().enumerate() {\n println!(\" {}. {}\", i + 1, opt);\n }\n\n loop {\n let input = prompt_line(&format!(\n \"Enter choice [1-{}] (default {}): \",\n options.len(),\n default_index + 1\n ))?;\n\n if input.trim().is_empty() {\n return Ok(default_index);\n }\n\n if let Ok(n) = input.trim().parse::() {\n if n >= 1 && n <= options.len() {\n return Ok(n - 1);\n }\n }\n println!(\"Invalid choice, please try again.\");\n }\n}\n\nfn prompt_optional_with_existing(label: &str, existing: &str) -> anyhow::Result {\n let prompt = if existing.trim().is_empty() {\n format!(\"{}: \", label)\n } else {\n format!(\"{} (press Enter to keep existing): \", label)\n };\n prompt_line(&prompt)\n}\n\nfn prompt_line_with_default(label: &str, default: &str) -> anyhow::Result {\n let input = prompt_line(&format!(\"{} [{}]: \", label, default))?;\n if input.trim().is_empty() {\n Ok(default.to_string())\n } else {\n Ok(input)\n }\n}\n\nfn prompt_line(prompt: &str) -> anyhow::Result {\n print!(\"{}\", prompt);\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin().read_line(&mut input)?;\n Ok(input.trim().to_string())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_provider_aliases() {\n assert_eq!(normalize_provider(\"moonshot\"), Some(\"kimi\"));\n assert_eq!(normalize_provider(\"claude\"), Some(\"anthropic\"));\n assert_eq!(normalize_provider(\"zhipu\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"minimax\"), Some(\"minimax\"));\n assert_eq!(normalize_provider(\"none\"), Some(\"skip\"));\n assert_eq!(normalize_provider(\"unknown\"), None);\n }\n\n #[test]\n fn test_normalize_channel_aliases() {\n assert_eq!(normalize_channel(\"wechatwork\"), Some(\"wecom\"));\n assert_eq!(normalize_channel(\"dingtalk\"), Some(\"dingtalk\"));\n assert_eq!(normalize_channel(\"qq\"), Some(\"qq\"));\n assert_eq!(normalize_channel(\"skip\"), Some(\"skip\"));\n assert_eq!(normalize_channel(\"unknown\"), None);\n }\n\n #[test]\n fn test_default_model() {\n assert_eq!(default_model_for_provider(\"deepseek\"), \"deepseek-chat\");\n assert_eq!(default_model_for_provider(\"openai\"), \"gpt-4o\");\n assert_eq!(default_model_for_provider(\"kimi\"), \"moonshot-v1-8k\");\n assert_eq!(default_model_for_provider(\"zhipu\"), \"glm-4\");\n assert_eq!(default_model_for_provider(\"minimax\"), \"minimax-text-01\");\n }\n\n #[test]\n fn test_configure_provider_populates_legacy_single_model_fields_for_compat() {\n let mut config = Config::default();\n config.agents.defaults.model = \"legacy-model\".to_string();\n config.agents.defaults.provider = Some(\"legacy-provider\".to_string());\n\n configure_provider(\n &mut config,\n \"deepseek\",\n Some(\"test-deepseek-key\"),\n Some(\"deepseek-chat\"),\n )\n .expect(\"configure_provider should succeed\");\n\n assert_eq!(\n config.agents.defaults.provider,\n Some(\"deepseek\".to_string())\n );\n assert_eq!(config.agents.defaults.model, \"deepseek-chat\");\n assert_eq!(config.agents.defaults.model_pool.len(), 1);\n let entry = &config.agents.defaults.model_pool[0];\n assert_eq!(entry.provider, \"deepseek\");\n assert_eq!(entry.model, \"deepseek-chat\");\n assert_eq!(entry.weight, 1);\n assert_eq!(entry.priority, 1);\n }\n\n #[test]\n fn test_ensure_channel_owner_defaults_to_default_agent() {\n let mut config = Config::default();\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"default\")\n );\n }\n\n #[test]\n fn test_ensure_channel_owner_keeps_existing() {\n let mut config = Config::default();\n config\n .channel_owners\n .insert(\"telegram\".to_string(), \"ops\".to_string());\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"ops\")\n );\n }\n}\n"}, "84fb059c773c96bf73d281534830d01e2755a1fa": {"t": 1785760754.5488217, "n": 20004, "h": "5afde47d9f0d573c", "c": "use anyhow::{anyhow, bail, Context};\nuse blockcell_core::config::{ModelEntry, ToolCallMode};\nuse blockcell_core::{Config, Paths};\nuse std::io::{self, Write};\n\n/// Interactive first-run setup wizard for provider + optional channel.\npub async fn run(\n force: bool,\n provider: Option,\n api_key: Option,\n model: Option,\n channel: Option,\n skip_provider_test: bool,\n) -> anyhow::Result<()> {\n let paths = Paths::new();\n paths.ensure_dirs()?;\n\n let config_path = paths.config_file();\n let mut config = if config_path.exists() && !force {\n Config::load(&config_path)\n .with_context(|| format!(\"Failed to load {}\", config_path.display()))?\n } else {\n if config_path.exists() && force {\n println!(\"⚠ --force enabled: reset existing config to defaults before setup.\");\n }\n Config::default()\n };\n\n if config.intent_router.is_none() {\n config.intent_router = Config::default().intent_router;\n }\n\n println!(\"blockcell setup\");\n println!(\"==============\");\n println!(\"Config file: {}\", config_path.display());\n println!();\n\n let selected_provider = resolve_provider(provider.as_deref())?;\n let provider_name = if let Some(name) = selected_provider {\n name.to_string()\n } else {\n prompt_provider(&config)?\n };\n\n if provider_name != \"skip\" {\n configure_provider(\n &mut config,\n &provider_name,\n api_key.as_deref(),\n model.as_deref(),\n )?;\n } else {\n println!(\"Skipped LLM provider setup.\");\n }\n\n let selected_channel = resolve_channel(channel.as_deref())?;\n let channel_name = if let Some(name) = selected_channel {\n name.to_string()\n } else {\n prompt_channel()?\n };\n\n if channel_name != \"skip\" {\n configure_channel(&mut config, &channel_name)?;\n ensure_channel_owner(&mut config, &channel_name);\n } else {\n println!(\"Skipped channel setup (WebUI only).\");\n }\n\n config\n .save(&config_path)\n .with_context(|| format!(\"Failed to save {}\", config_path.display()))?;\n\n println!();\n println!(\"✓ Setup completed\");\n println!(\" Config: {}\", config_path.display());\n if provider_name != \"skip\" {\n let selected = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider_name)\n .or_else(|| primary_pool_entry(&config));\n let selected_model = selected\n .map(|e| e.model.as_str())\n .unwrap_or(config.agents.defaults.model.as_str());\n println!(\" Provider: {} Model: {}\", provider_name, selected_model);\n if !skip_provider_test {\n match blockcell_providers::create_provider(\n &config,\n selected_model,\n Some(&provider_name),\n ) {\n Ok(_) => println!(\" ✓ Provider config validation passed\"),\n Err(e) => println!(\" ⚠ Provider validation failed: {}\", e),\n }\n }\n }\n if channel_name != \"skip\" {\n println!(\" Channel: {} (enabled)\", channel_name);\n } else {\n println!(\" Channel: none (use WebUI only)\");\n }\n println!();\n println!(\"Next steps:\");\n println!(\" 1. blockcell status\");\n println!(\" 2. blockcell gateway\");\n println!(\" 3. Open WebUI: http://localhost:18791/\");\n\n Ok(())\n}\n\nfn configure_provider(\n config: &mut Config,\n provider: &str,\n api_key_flag: Option<&str>,\n model_flag: Option<&str>,\n) -> anyhow::Result<()> {\n let entry = config\n .providers\n .entry(provider.to_string())\n .or_default();\n\n if entry.api_base.is_none() {\n entry.api_base = default_api_base_for_provider(provider).map(|s| s.to_string());\n }\n if entry.api_type.is_empty() {\n entry.api_type = default_api_type_for_provider(provider).to_string();\n }\n\n if provider == \"ollama\" {\n if entry.api_key.trim().is_empty() {\n entry.api_key = \"ollama\".to_string();\n }\n if entry.api_base.is_none() {\n entry.api_base = Some(\"http://localhost:11434\".to_string());\n }\n } else {\n let has_existing = !entry.api_key.trim().is_empty() && entry.api_key != \"dummy\";\n let final_key = if let Some(flag) = api_key_flag {\n flag.trim().to_string()\n } else if has_existing {\n prompt_line(\"API key (press Enter to keep existing): \")?\n } else {\n prompt_line(\"API key: \")?\n };\n\n if !final_key.is_empty() {\n entry.api_key = final_key;\n }\n\n if entry.api_key.trim().is_empty() || entry.api_key == \"dummy\" {\n bail!(\"Provider '{}' requires a valid API key.\", provider);\n }\n }\n\n let suggested_model = if let Some(entry) = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider)\n {\n entry.model.clone()\n } else {\n default_model_for_provider(provider).to_string()\n };\n\n let final_model = if let Some(m) = model_flag {\n m.trim().to_string()\n } else {\n prompt_line_with_default(\"Model\", &suggested_model)?\n };\n if final_model.trim().is_empty() {\n bail!(\"Model cannot be empty.\");\n }\n\n // Setup writes provider/model to model_pool first, and also mirrors to legacy\n // single-model fields for backward compatibility with older runtimes.\n config.agents.defaults.model_pool = vec![ModelEntry {\n model: final_model.clone(),\n provider: provider.to_string(),\n weight: 1,\n priority: 1,\n input_price: None,\n output_price: None,\n tool_call_mode: ToolCallMode::Native,\n }];\n config.agents.defaults.provider = Some(provider.to_string());\n config.agents.defaults.model = final_model;\n\n Ok(())\n}\n\nfn configure_channel(config: &mut Config, channel: &str) -> anyhow::Result<()> {\n match channel {\n \"telegram\" => {\n let existing = config.channels.telegram.token.clone();\n let prompt = if existing.trim().is_empty() {\n \"Telegram bot token: \"\n } else {\n \"Telegram bot token (press Enter to keep existing): \"\n };\n let token = prompt_line(prompt)?;\n if !token.is_empty() {\n config.channels.telegram.token = token;\n }\n if config.channels.telegram.token.trim().is_empty() {\n bail!(\"Telegram token is required.\");\n }\n config.channels.telegram.enabled = true;\n }\n \"feishu\" => {\n let app_id =\n prompt_optional_with_existing(\"Feishu app_id\", &config.channels.feishu.app_id)?;\n let app_secret = prompt_optional_with_existing(\n \"Feishu app_secret\",\n &config.channels.feishu.app_secret,\n )?;\n\n if !app_id.is_empty() {\n config.channels.feishu.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.feishu.app_secret = app_secret;\n }\n\n if config.channels.feishu.app_id.trim().is_empty()\n || config.channels.feishu.app_secret.trim().is_empty()\n {\n bail!(\"Feishu app_id and app_secret are required.\");\n }\n config.channels.feishu.enabled = true;\n }\n \"wecom\" => {\n let corp_id =\n prompt_optional_with_existing(\"WeCom corp_id\", &config.channels.wecom.corp_id)?;\n let corp_secret = prompt_optional_with_existing(\n \"WeCom corp_secret\",\n &config.channels.wecom.corp_secret,\n )?;\n\n if !corp_id.is_empty() {\n config.channels.wecom.corp_id = corp_id;\n }\n if !corp_secret.is_empty() {\n config.channels.wecom.corp_secret = corp_secret;\n }\n\n if config.channels.wecom.corp_id.trim().is_empty()\n || config.channels.wecom.corp_secret.trim().is_empty()\n {\n bail!(\"WeCom corp_id and corp_secret are required.\");\n }\n\n let existing_agent = if config.channels.wecom.agent_id > 0 {\n config.channels.wecom.agent_id.to_string()\n } else {\n \"1000002\".to_string()\n };\n let agent_id_str = prompt_line_with_default(\"WeCom agent_id\", &existing_agent)?;\n let agent_id = agent_id_str\n .trim()\n .parse::()\n .map_err(|_| anyhow!(\"agent_id must be an integer\"))?;\n config.channels.wecom.agent_id = agent_id;\n config.channels.wecom.enabled = true;\n }\n \"dingtalk\" => {\n let app_key = prompt_optional_with_existing(\n \"DingTalk app_key\",\n &config.channels.dingtalk.app_key,\n )?;\n let app_secret = prompt_optional_with_existing(\n \"DingTalk app_secret\",\n &config.channels.dingtalk.app_secret,\n )?;\n if !app_key.is_empty() {\n config.channels.dingtalk.app_key = app_key;\n }\n if !app_secret.is_empty() {\n config.channels.dingtalk.app_secret = app_secret;\n }\n if config.channels.dingtalk.app_key.trim().is_empty()\n || config.channels.dingtalk.app_secret.trim().is_empty()\n {\n bail!(\"DingTalk app_key and app_secret are required.\");\n }\n config.channels.dingtalk.enabled = true;\n }\n \"lark\" => {\n let app_id =\n prompt_optional_with_existing(\"Lark app_id\", &config.channels.lark.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"Lark app_secret\", &config.channels.lark.app_secret)?;\n if !app_id.is_empty() {\n config.channels.lark.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.lark.app_secret = app_secret;\n }\n if config.channels.lark.app_id.trim().is_empty()\n || config.channels.lark.app_secret.trim().is_empty()\n {\n bail!(\"Lark app_id and app_secret are required.\");\n }\n config.channels.lark.enabled = true;\n }\n _ => bail!(\"Unsupported channel '{}'\", channel),\n }\n\n Ok(())\n}\n\nfn ensure_channel_owner(config: &mut Config, channel: &str) {\n if config.resolve_channel_owner(channel).is_some() {\n return;\n }\n let owner = config\n .known_agent_ids()\n .into_iter()\n .next()\n .unwrap_or_else(|| \"default\".to_string());\n config\n .channel_owners\n .insert(channel.to_string(), owner.clone());\n println!(\"Assigned channel owner: {} -> {}\", channel, owner);\n}\n\nfn prompt_provider(config: &Config) -> anyhow::Result {\n let current = primary_pool_entry(config)\n .map(|e| e.provider.clone())\n .or_else(|| {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| !e.provider.trim().is_empty())\n .map(|e| e.provider.clone())\n })\n .or_else(|| config.agents.defaults.provider.clone())\n .or_else(|| config.get_api_key().map(|(name, _)| name.to_string()))\n .unwrap_or_else(|| \"none\".to_string());\n\n println!(\"Configure LLM provider (current: {})\", current);\n let options = [\n \"deepseek (recommended)\",\n \"openai\",\n \"kimi\",\n \"anthropic\",\n \"gemini\",\n \"zhipu\",\n \"minimax\",\n \"ollama (local)\",\n \"skip\",\n ];\n let idx = prompt_select(\"Choose provider\", &options, 0)?;\n let mapped = match idx {\n 0 => \"deepseek\",\n 1 => \"openai\",\n 2 => \"kimi\",\n 3 => \"anthropic\",\n 4 => \"gemini\",\n 5 => \"zhipu\",\n 6 => \"minimax\",\n 7 => \"ollama\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn primary_pool_entry(config: &Config) -> Option<&ModelEntry> {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .min_by(|a, b| a.priority.cmp(&b.priority).then(b.weight.cmp(&a.weight)))\n}\n\nfn prompt_channel() -> anyhow::Result {\n let options = [\n \"skip (WebUI only)\",\n \"telegram\",\n \"feishu\",\n \"wecom\",\n \"dingtalk\",\n \"lark\",\n ];\n let idx = prompt_select(\"Configure one channel (optional)\", &options, 0)?;\n let mapped = match idx {\n 1 => \"telegram\",\n 2 => \"feishu\",\n 3 => \"wecom\",\n 4 => \"dingtalk\",\n 5 => \"lark\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn resolve_provider(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_provider(v)\n .ok_or_else(|| anyhow!(\"Unsupported provider '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn resolve_channel(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_channel(v)\n .ok_or_else(|| anyhow!(\"Unsupported channel '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn normalize_provider(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"deepseek\" => Some(\"deepseek\"),\n \"openai\" => Some(\"openai\"),\n \"kimi\" | \"moonshot\" => Some(\"kimi\"),\n \"anthropic\" | \"claude\" => Some(\"anthropic\"),\n \"gemini\" => Some(\"gemini\"),\n \"zhipu\" => Some(\"zhipu\"),\n \"minimax\" => Some(\"minimax\"),\n \"ollama\" => Some(\"ollama\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn normalize_channel(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"telegram\" => Some(\"telegram\"),\n \"feishu\" => Some(\"feishu\"),\n \"wecom\" | \"wechatwork\" => Some(\"wecom\"),\n \"dingtalk\" => Some(\"dingtalk\"),\n \"lark\" => Some(\"lark\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn default_model_for_provider(provider: &str) -> &'static str {\n match provider {\n \"deepseek\" => \"deepseek-chat\",\n \"openai\" => \"gpt-4o\",\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"kimi\" => \"moonshot-v1-8k\",\n \"gemini\" => \"gemini-2.0-flash\",\n \"zhipu\" => \"glm-4\",\n \"minimax\" => \"minimax-text-01\",\n \"ollama\" => \"llama3\",\n _ => \"gpt-4o\",\n }\n}\n\nfn default_api_base_for_provider(provider: &str) -> Option<&'static str> {\n match provider {\n \"deepseek\" => Some(\"https://api.deepseek.com/v1\"),\n \"openai\" => Some(\"https://api.openai.com/v1\"),\n \"anthropic\" => Some(\"https://api.anthropic.com\"),\n \"kimi\" => Some(\"https://api.moonshot.cn/v1\"),\n \"gemini\" => Some(\"https://generativelanguage.googleapis.com/v1beta/openai\"),\n \"zhipu\" => Some(\"https://open.bigmodel.cn/api/paas/v4\"),\n \"minimax\" => Some(\"https://api.minimax.chat/v1\"),\n \"ollama\" => Some(\"http://localhost:11434\"),\n _ => None,\n }\n}\n\nfn default_api_type_for_provider(provider: &str) -> &'static str {\n match provider {\n \"anthropic\" | \"minimax\" => \"anthropic\",\n \"gemini\" => \"openai\",\n \"ollama\" => \"ollama\",\n _ => \"openai\",\n }\n}\n\nfn prompt_select(title: &str, options: &[&str], default_index: usize) -> anyhow::Result {\n println!(\"{}\", title);\n for (i, opt) in options.iter().enumerate() {\n println!(\" {}. {}\", i + 1, opt);\n }\n\n loop {\n let input = prompt_line(&format!(\n \"Enter choice [1-{}] (default {}): \",\n options.len(),\n default_index + 1\n ))?;\n\n if input.trim().is_empty() {\n return Ok(default_index);\n }\n\n if let Ok(n) = input.trim().parse::() {\n if n >= 1 && n <= options.len() {\n return Ok(n - 1);\n }\n }\n println!(\"Invalid choice, please try again.\");\n }\n}\n\nfn prompt_optional_with_existing(label: &str, existing: &str) -> anyhow::Result {\n let prompt = if existing.trim().is_empty() {\n format!(\"{}: \", label)\n } else {\n format!(\"{} (press Enter to keep existing): \", label)\n };\n prompt_line(&prompt)\n}\n\nfn prompt_line_with_default(label: &str, default: &str) -> anyhow::Result {\n let input = prompt_line(&format!(\"{} [{}]: \", label, default))?;\n if input.trim().is_empty() {\n Ok(default.to_string())\n } else {\n Ok(input)\n }\n}\n\nfn prompt_line(prompt: &str) -> anyhow::Result {\n print!(\"{}\", prompt);\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin().read_line(&mut input)?;\n Ok(input.trim().to_string())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_provider_aliases() {\n assert_eq!(normalize_provider(\"moonshot\"), Some(\"kimi\"));\n assert_eq!(normalize_provider(\"claude\"), Some(\"anthropic\"));\n assert_eq!(normalize_provider(\"zhipu\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"minimax\"), Some(\"minimax\"));\n assert_eq!(normalize_provider(\"none\"), Some(\"skip\"));\n assert_eq!(normalize_provider(\"unknown\"), None);\n }\n\n #[test]\n fn test_normalize_channel_aliases() {\n assert_eq!(normalize_channel(\"wechatwork\"), Some(\"wecom\"));\n assert_eq!(normalize_channel(\"dingtalk\"), Some(\"dingtalk\"));\n assert_eq!(normalize_channel(\"skip\"), Some(\"skip\"));\n assert_eq!(normalize_channel(\"unknown\"), None);\n }\n\n #[test]\n fn test_default_model() {\n assert_eq!(default_model_for_provider(\"deepseek\"), \"deepseek-chat\");\n assert_eq!(default_model_for_provider(\"openai\"), \"gpt-4o\");\n assert_eq!(default_model_for_provider(\"kimi\"), \"moonshot-v1-8k\");\n assert_eq!(default_model_for_provider(\"zhipu\"), \"glm-4\");\n assert_eq!(default_model_for_provider(\"minimax\"), \"minimax-text-01\");\n }\n\n #[test]\n fn test_configure_provider_populates_legacy_single_model_fields_for_compat() {\n let mut config = Config::default();\n config.agents.defaults.model = \"legacy-model\".to_string();\n config.agents.defaults.provider = Some(\"legacy-provider\".to_string());\n\n configure_provider(\n &mut config,\n \"deepseek\",\n Some(\"test-deepseek-key\"),\n Some(\"deepseek-chat\"),\n )\n .expect(\"configure_provider should succeed\");\n\n assert_eq!(\n config.agents.defaults.provider,\n Some(\"deepseek\".to_string())\n );\n assert_eq!(config.agents.defaults.model, \"deepseek-chat\");\n assert_eq!(config.agents.defaults.model_pool.len(), 1);\n let entry = &config.agents.defaults.model_pool[0];\n assert_eq!(entry.provider, \"deepseek\");\n assert_eq!(entry.model, \"deepseek-chat\");\n assert_eq!(entry.weight, 1);\n assert_eq!(entry.priority, 1);\n }\n\n #[test]\n fn test_ensure_channel_owner_defaults_to_default_agent() {\n let mut config = Config::default();\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"default\")\n );\n }\n\n #[test]\n fn test_ensure_channel_owner_keeps_existing() {\n let mut config = Config::default();\n config\n .channel_owners\n .insert(\"telegram\".to_string(), \"ops\".to_string());\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"ops\")\n );\n }\n}\n"}, "562bbd542e852dcc6a9ca2e92c3b3c9ec6256196": {"t": 1785760756.0385087, "n": 21368, "h": "935ef248d766562e", "c": "use anyhow::{anyhow, bail, Context};\nuse blockcell_core::config::{ModelEntry, ProviderConfig, ToolCallMode};\nuse blockcell_core::{Config, Paths};\nuse std::io::{self, Write};\n\n/// Interactive first-run setup wizard for provider + optional channel.\npub async fn run(\n force: bool,\n provider: Option,\n api_key: Option,\n model: Option,\n channel: Option,\n skip_provider_test: bool,\n) -> anyhow::Result<()> {\n let paths = Paths::new();\n paths.ensure_dirs()?;\n\n let config_path = paths.config_file();\n let mut config = if config_path.exists() && !force {\n Config::load(&config_path)\n .with_context(|| format!(\"Failed to load {}\", config_path.display()))?\n } else {\n if config_path.exists() && force {\n println!(\"⚠ --force enabled: reset existing config to defaults before setup.\");\n }\n Config::default()\n };\n\n if config.intent_router.is_none() {\n config.intent_router = Config::default().intent_router;\n }\n\n println!(\"blockcell setup\");\n println!(\"==============\");\n println!(\"Config file: {}\", config_path.display());\n println!();\n\n let selected_provider = resolve_provider(provider.as_deref())?;\n let provider_name = if let Some(name) = selected_provider {\n name.to_string()\n } else {\n prompt_provider(&config)?\n };\n\n if provider_name != \"skip\" {\n configure_provider(\n &mut config,\n &provider_name,\n api_key.as_deref(),\n model.as_deref(),\n )?;\n } else {\n println!(\"Skipped LLM provider setup.\");\n }\n\n let selected_channel = resolve_channel(channel.as_deref())?;\n let channel_name = if let Some(name) = selected_channel {\n name.to_string()\n } else {\n prompt_channel()?\n };\n\n if channel_name != \"skip\" {\n configure_channel(&mut config, &channel_name)?;\n ensure_channel_owner(&mut config, &channel_name);\n } else {\n println!(\"Skipped channel setup (WebUI only).\");\n }\n\n config\n .save(&config_path)\n .with_context(|| format!(\"Failed to save {}\", config_path.display()))?;\n\n println!();\n println!(\"✓ Setup completed\");\n println!(\" Config: {}\", config_path.display());\n if provider_name != \"skip\" {\n let selected = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider_name)\n .or_else(|| primary_pool_entry(&config));\n let selected_model = selected\n .map(|e| e.model.as_str())\n .unwrap_or(config.agents.defaults.model.as_str());\n println!(\" Provider: {} Model: {}\", provider_name, selected_model);\n if !skip_provider_test {\n match blockcell_providers::create_provider(\n &config,\n selected_model,\n Some(&provider_name),\n ) {\n Ok(_) => println!(\" ✓ Provider config validation passed\"),\n Err(e) => println!(\" ⚠ Provider validation failed: {}\", e),\n }\n }\n }\n if channel_name != \"skip\" {\n println!(\" Channel: {} (enabled)\", channel_name);\n } else {\n println!(\" Channel: none (use WebUI only)\");\n }\n println!();\n println!(\"Next steps:\");\n println!(\" 1. blockcell status\");\n println!(\" 2. blockcell gateway\");\n println!(\" 3. Open WebUI: http://localhost:18791/\");\n\n Ok(())\n}\n\nfn configure_provider(\n config: &mut Config,\n provider: &str,\n api_key_flag: Option<&str>,\n model_flag: Option<&str>,\n) -> anyhow::Result<()> {\n let entry = config\n .providers\n .entry(provider.to_string())\n .or_insert_with(ProviderConfig::default);\n\n if entry.api_base.is_none() {\n entry.api_base = default_api_base_for_provider(provider).map(|s| s.to_string());\n }\n if entry.api_type.is_empty() {\n entry.api_type = default_api_type_for_provider(provider).to_string();\n }\n\n if provider == \"ollama\" {\n if entry.api_key.trim().is_empty() {\n entry.api_key = \"ollama\".to_string();\n }\n if entry.api_base.is_none() {\n entry.api_base = Some(\"http://localhost:11434\".to_string());\n }\n } else {\n let has_existing = !entry.api_key.trim().is_empty() && entry.api_key != \"dummy\";\n let final_key = if let Some(flag) = api_key_flag {\n flag.trim().to_string()\n } else if has_existing {\n prompt_line(\"API key (press Enter to keep existing): \")?\n } else {\n prompt_line(\"API key: \")?\n };\n\n if !final_key.is_empty() {\n entry.api_key = final_key;\n }\n\n if entry.api_key.trim().is_empty() || entry.api_key == \"dummy\" {\n bail!(\"Provider '{}' requires a valid API key.\", provider);\n }\n }\n\n let suggested_model = if let Some(entry) = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider)\n {\n entry.model.clone()\n } else {\n default_model_for_provider(provider).to_string()\n };\n\n let final_model = if let Some(m) = model_flag {\n m.trim().to_string()\n } else {\n prompt_line_with_default(\"Model\", &suggested_model)?\n };\n if final_model.trim().is_empty() {\n bail!(\"Model cannot be empty.\");\n }\n\n // Setup writes provider/model to model_pool first, and also mirrors to legacy\n // single-model fields for backward compatibility with older runtimes.\n config.agents.defaults.model_pool = vec![ModelEntry {\n model: final_model.clone(),\n provider: provider.to_string(),\n weight: 1,\n priority: 1,\n input_price: None,\n output_price: None,\n tool_call_mode: ToolCallMode::Native,\n }];\n config.agents.defaults.provider = Some(provider.to_string());\n config.agents.defaults.model = final_model;\n\n Ok(())\n}\n\nfn configure_channel(config: &mut Config, channel: &str) -> anyhow::Result<()> {\n match channel {\n \"telegram\" => {\n let existing = config.channels.telegram.token.clone();\n let prompt = if existing.trim().is_empty() {\n \"Telegram bot token: \"\n } else {\n \"Telegram bot token (press Enter to keep existing): \"\n };\n let token = prompt_line(prompt)?;\n if !token.is_empty() {\n config.channels.telegram.token = token;\n }\n if config.channels.telegram.token.trim().is_empty() {\n bail!(\"Telegram token is required.\");\n }\n config.channels.telegram.enabled = true;\n }\n \"feishu\" => {\n let app_id =\n prompt_optional_with_existing(\"Feishu app_id\", &config.channels.feishu.app_id)?;\n let app_secret = prompt_optional_with_existing(\n \"Feishu app_secret\",\n &config.channels.feishu.app_secret,\n )?;\n\n if !app_id.is_empty() {\n config.channels.feishu.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.feishu.app_secret = app_secret;\n }\n\n if config.channels.feishu.app_id.trim().is_empty()\n || config.channels.feishu.app_secret.trim().is_empty()\n {\n bail!(\"Feishu app_id and app_secret are required.\");\n }\n config.channels.feishu.enabled = true;\n }\n \"wecom\" => {\n let corp_id =\n prompt_optional_with_existing(\"WeCom corp_id\", &config.channels.wecom.corp_id)?;\n let corp_secret = prompt_optional_with_existing(\n \"WeCom corp_secret\",\n &config.channels.wecom.corp_secret,\n )?;\n\n if !corp_id.is_empty() {\n config.channels.wecom.corp_id = corp_id;\n }\n if !corp_secret.is_empty() {\n config.channels.wecom.corp_secret = corp_secret;\n }\n\n if config.channels.wecom.corp_id.trim().is_empty()\n || config.channels.wecom.corp_secret.trim().is_empty()\n {\n bail!(\"WeCom corp_id and corp_secret are required.\");\n }\n\n let existing_agent = if config.channels.wecom.agent_id > 0 {\n config.channels.wecom.agent_id.to_string()\n } else {\n \"1000002\".to_string()\n };\n let agent_id_str = prompt_line_with_default(\"WeCom agent_id\", &existing_agent)?;\n let agent_id = agent_id_str\n .trim()\n .parse::()\n .map_err(|_| anyhow!(\"agent_id must be an integer\"))?;\n config.channels.wecom.agent_id = agent_id;\n config.channels.wecom.enabled = true;\n }\n \"dingtalk\" => {\n let app_key = prompt_optional_with_existing(\n \"DingTalk app_key\",\n &config.channels.dingtalk.app_key,\n )?;\n let app_secret = prompt_optional_with_existing(\n \"DingTalk app_secret\",\n &config.channels.dingtalk.app_secret,\n )?;\n if !app_key.is_empty() {\n config.channels.dingtalk.app_key = app_key;\n }\n if !app_secret.is_empty() {\n config.channels.dingtalk.app_secret = app_secret;\n }\n if config.channels.dingtalk.app_key.trim().is_empty()\n || config.channels.dingtalk.app_secret.trim().is_empty()\n {\n bail!(\"DingTalk app_key and app_secret are required.\");\n }\n config.channels.dingtalk.enabled = true;\n }\n \"lark\" => {\n let app_id =\n prompt_optional_with_existing(\"Lark app_id\", &config.channels.lark.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"Lark app_secret\", &config.channels.lark.app_secret)?;\n if !app_id.is_empty() {\n config.channels.lark.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.lark.app_secret = app_secret;\n }\n if config.channels.lark.app_id.trim().is_empty()\n || config.channels.lark.app_secret.trim().is_empty()\n {\n bail!(\"Lark app_id and app_secret are required.\");\n }\n config.channels.lark.enabled = true;\n }\n \"qq\" => {\n let app_id =\n prompt_optional_with_existing(\"QQ app_id\", &config.channels.qq.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"QQ app_secret\", &config.channels.qq.app_secret)?;\n if !app_id.is_empty() {\n config.channels.qq.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.qq.app_secret = app_secret;\n }\n if config.channels.qq.app_id.trim().is_empty()\n || config.channels.qq.app_secret.trim().is_empty()\n {\n bail!(\"QQ app_id and app_secret are required.\");\n }\n let environment = prompt_optional_with_existing(\n \"QQ environment (production/sandbox)\",\n &config.channels.qq.environment,\n )?;\n if !environment.is_empty() {\n config.channels.qq.environment = environment;\n }\n if config.channels.qq.environment.trim().is_empty() {\n config.channels.qq.environment = \"production\".to_string();\n }\n config.channels.qq.enabled = true;\n }\n _ => bail!(\"Unsupported channel '{}'\", channel),\n }\n\n Ok(())\n}\n\nfn ensure_channel_owner(config: &mut Config, channel: &str) {\n if config.resolve_channel_owner(channel).is_some() {\n return;\n }\n let owner = config\n .known_agent_ids()\n .into_iter()\n .next()\n .unwrap_or_else(|| \"default\".to_string());\n config\n .channel_owners\n .insert(channel.to_string(), owner.clone());\n println!(\"Assigned channel owner: {} -> {}\", channel, owner);\n}\n\nfn prompt_provider(config: &Config) -> anyhow::Result {\n let current = primary_pool_entry(config)\n .map(|e| e.provider.clone())\n .or_else(|| {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| !e.provider.trim().is_empty())\n .map(|e| e.provider.clone())\n })\n .or_else(|| config.agents.defaults.provider.clone())\n .or_else(|| config.get_api_key().map(|(name, _)| name.to_string()))\n .unwrap_or_else(|| \"none\".to_string());\n\n println!(\"Configure LLM provider (current: {})\", current);\n let options = [\n \"deepseek (recommended)\",\n \"openai\",\n \"kimi\",\n \"anthropic\",\n \"gemini\",\n \"zhipu\",\n \"minimax\",\n \"ollama (local)\",\n \"skip\",\n ];\n let idx = prompt_select(\"Choose provider\", &options, 0)?;\n let mapped = match idx {\n 0 => \"deepseek\",\n 1 => \"openai\",\n 2 => \"kimi\",\n 3 => \"anthropic\",\n 4 => \"gemini\",\n 5 => \"zhipu\",\n 6 => \"minimax\",\n 7 => \"ollama\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn primary_pool_entry(config: &Config) -> Option<&ModelEntry> {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .min_by(|a, b| a.priority.cmp(&b.priority).then(b.weight.cmp(&a.weight)))\n}\n\nfn prompt_channel() -> anyhow::Result {\n let options = [\n \"skip (WebUI only)\",\n \"telegram\",\n \"feishu\",\n \"wecom\",\n \"dingtalk\",\n \"lark\",\n \"qq\",\n ];\n let idx = prompt_select(\"Configure one channel (optional)\", &options, 0)?;\n let mapped = match idx {\n 1 => \"telegram\",\n 2 => \"feishu\",\n 3 => \"wecom\",\n 4 => \"dingtalk\",\n 5 => \"lark\",\n 6 => \"qq\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn resolve_provider(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_provider(v)\n .ok_or_else(|| anyhow!(\"Unsupported provider '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn resolve_channel(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_channel(v)\n .ok_or_else(|| anyhow!(\"Unsupported channel '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn normalize_provider(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"deepseek\" => Some(\"deepseek\"),\n \"openai\" => Some(\"openai\"),\n \"kimi\" | \"moonshot\" => Some(\"kimi\"),\n \"anthropic\" | \"claude\" => Some(\"anthropic\"),\n \"gemini\" => Some(\"gemini\"),\n \"zhipu\" => Some(\"zhipu\"),\n \"minimax\" => Some(\"minimax\"),\n \"ollama\" => Some(\"ollama\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn normalize_channel(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"telegram\" => Some(\"telegram\"),\n \"feishu\" => Some(\"feishu\"),\n \"wecom\" | \"wechatwork\" => Some(\"wecom\"),\n \"dingtalk\" => Some(\"dingtalk\"),\n \"lark\" => Some(\"lark\"),\n \"qq\" => Some(\"qq\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn default_model_for_provider(provider: &str) -> &'static str {\n match provider {\n \"deepseek\" => \"deepseek-chat\",\n \"openai\" => \"gpt-4o\",\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"kimi\" => \"moonshot-v1-8k\",\n \"gemini\" => \"gemini-2.0-flash\",\n \"zhipu\" => \"glm-4\",\n \"minimax\" => \"minimax-text-01\",\n \"ollama\" => \"llama3\",\n _ => \"gpt-4o\",\n }\n}\n\nfn default_api_base_for_provider(provider: &str) -> Option<&'static str> {\n match provider {\n \"deepseek\" => Some(\"https://api.deepseek.com/v1\"),\n \"openai\" => Some(\"https://api.openai.com/v1\"),\n \"anthropic\" => Some(\"https://api.anthropic.com\"),\n \"kimi\" => Some(\"https://api.moonshot.cn/v1\"),\n \"gemini\" => Some(\"https://generativelanguage.googleapis.com/v1beta/openai\"),\n \"zhipu\" => Some(\"https://open.bigmodel.cn/api/paas/v4\"),\n \"minimax\" => Some(\"https://api.minimax.chat/v1\"),\n \"ollama\" => Some(\"http://localhost:11434\"),\n _ => None,\n }\n}\n\nfn default_api_type_for_provider(provider: &str) -> &'static str {\n match provider {\n \"anthropic\" | \"minimax\" => \"anthropic\",\n \"gemini\" => \"openai\",\n \"ollama\" => \"ollama\",\n _ => \"openai\",\n }\n}\n\nfn prompt_select(title: &str, options: &[&str], default_index: usize) -> anyhow::Result {\n println!(\"{}\", title);\n for (i, opt) in options.iter().enumerate() {\n println!(\" {}. {}\", i + 1, opt);\n }\n\n loop {\n let input = prompt_line(&format!(\n \"Enter choice [1-{}] (default {}): \",\n options.len(),\n default_index + 1\n ))?;\n\n if input.trim().is_empty() {\n return Ok(default_index);\n }\n\n if let Ok(n) = input.trim().parse::() {\n if n >= 1 && n <= options.len() {\n return Ok(n - 1);\n }\n }\n println!(\"Invalid choice, please try again.\");\n }\n}\n\nfn prompt_optional_with_existing(label: &str, existing: &str) -> anyhow::Result {\n let prompt = if existing.trim().is_empty() {\n format!(\"{}: \", label)\n } else {\n format!(\"{} (press Enter to keep existing): \", label)\n };\n prompt_line(&prompt)\n}\n\nfn prompt_line_with_default(label: &str, default: &str) -> anyhow::Result {\n let input = prompt_line(&format!(\"{} [{}]: \", label, default))?;\n if input.trim().is_empty() {\n Ok(default.to_string())\n } else {\n Ok(input)\n }\n}\n\nfn prompt_line(prompt: &str) -> anyhow::Result {\n print!(\"{}\", prompt);\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin().read_line(&mut input)?;\n Ok(input.trim().to_string())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_provider_aliases() {\n assert_eq!(normalize_provider(\"moonshot\"), Some(\"kimi\"));\n assert_eq!(normalize_provider(\"claude\"), Some(\"anthropic\"));\n assert_eq!(normalize_provider(\"zhipu\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"minimax\"), Some(\"minimax\"));\n assert_eq!(normalize_provider(\"none\"), Some(\"skip\"));\n assert_eq!(normalize_provider(\"unknown\"), None);\n }\n\n #[test]\n fn test_normalize_channel_aliases() {\n assert_eq!(normalize_channel(\"wechatwork\"), Some(\"wecom\"));\n assert_eq!(normalize_channel(\"dingtalk\"), Some(\"dingtalk\"));\n assert_eq!(normalize_channel(\"qq\"), Some(\"qq\"));\n assert_eq!(normalize_channel(\"skip\"), Some(\"skip\"));\n assert_eq!(normalize_channel(\"unknown\"), None);\n }\n\n #[test]\n fn test_default_model() {\n assert_eq!(default_model_for_provider(\"deepseek\"), \"deepseek-chat\");\n assert_eq!(default_model_for_provider(\"openai\"), \"gpt-4o\");\n assert_eq!(default_model_for_provider(\"kimi\"), \"moonshot-v1-8k\");\n assert_eq!(default_model_for_provider(\"zhipu\"), \"glm-4\");\n assert_eq!(default_model_for_provider(\"minimax\"), \"minimax-text-01\");\n }\n\n #[test]\n fn test_configure_provider_populates_legacy_single_model_fields_for_compat() {\n let mut config = Config::default();\n config.agents.defaults.model = \"legacy-model\".to_string();\n config.agents.defaults.provider = Some(\"legacy-provider\".to_string());\n\n configure_provider(\n &mut config,\n \"deepseek\",\n Some(\"test-deepseek-key\"),\n Some(\"deepseek-chat\"),\n )\n .expect(\"configure_provider should succeed\");\n\n assert_eq!(\n config.agents.defaults.provider,\n Some(\"deepseek\".to_string())\n );\n assert_eq!(config.agents.defaults.model, \"deepseek-chat\");\n assert_eq!(config.agents.defaults.model_pool.len(), 1);\n let entry = &config.agents.defaults.model_pool[0];\n assert_eq!(entry.provider, \"deepseek\");\n assert_eq!(entry.model, \"deepseek-chat\");\n assert_eq!(entry.weight, 1);\n assert_eq!(entry.priority, 1);\n }\n\n #[test]\n fn test_ensure_channel_owner_defaults_to_default_agent() {\n let mut config = Config::default();\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"default\")\n );\n }\n\n #[test]\n fn test_ensure_channel_owner_keeps_existing() {\n let mut config = Config::default();\n config\n .channel_owners\n .insert(\"telegram\".to_string(), \"ops\".to_string());\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"ops\")\n );\n }\n}\n"}, "487f177cde203a2b6f401b6d4214f426996dd426": {"t": 1785760757.6910121, "n": 20047, "h": "4385b197d7d97ca5", "c": "use anyhow::{anyhow, bail, Context};\nuse blockcell_core::config::{ModelEntry, ProviderConfig, ToolCallMode};\nuse blockcell_core::{Config, Paths};\nuse std::io::{self, Write};\n\n/// Interactive first-run setup wizard for provider + optional channel.\npub async fn run(\n force: bool,\n provider: Option,\n api_key: Option,\n model: Option,\n channel: Option,\n skip_provider_test: bool,\n) -> anyhow::Result<()> {\n let paths = Paths::new();\n paths.ensure_dirs()?;\n\n let config_path = paths.config_file();\n let mut config = if config_path.exists() && !force {\n Config::load(&config_path)\n .with_context(|| format!(\"Failed to load {}\", config_path.display()))?\n } else {\n if config_path.exists() && force {\n println!(\"⚠ --force enabled: reset existing config to defaults before setup.\");\n }\n Config::default()\n };\n\n if config.intent_router.is_none() {\n config.intent_router = Config::default().intent_router;\n }\n\n println!(\"blockcell setup\");\n println!(\"==============\");\n println!(\"Config file: {}\", config_path.display());\n println!();\n\n let selected_provider = resolve_provider(provider.as_deref())?;\n let provider_name = if let Some(name) = selected_provider {\n name.to_string()\n } else {\n prompt_provider(&config)?\n };\n\n if provider_name != \"skip\" {\n configure_provider(\n &mut config,\n &provider_name,\n api_key.as_deref(),\n model.as_deref(),\n )?;\n } else {\n println!(\"Skipped LLM provider setup.\");\n }\n\n let selected_channel = resolve_channel(channel.as_deref())?;\n let channel_name = if let Some(name) = selected_channel {\n name.to_string()\n } else {\n prompt_channel()?\n };\n\n if channel_name != \"skip\" {\n configure_channel(&mut config, &channel_name)?;\n ensure_channel_owner(&mut config, &channel_name);\n } else {\n println!(\"Skipped channel setup (WebUI only).\");\n }\n\n config\n .save(&config_path)\n .with_context(|| format!(\"Failed to save {}\", config_path.display()))?;\n\n println!();\n println!(\"✓ Setup completed\");\n println!(\" Config: {}\", config_path.display());\n if provider_name != \"skip\" {\n let selected = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider_name)\n .or_else(|| primary_pool_entry(&config));\n let selected_model = selected\n .map(|e| e.model.as_str())\n .unwrap_or(config.agents.defaults.model.as_str());\n println!(\" Provider: {} Model: {}\", provider_name, selected_model);\n if !skip_provider_test {\n match blockcell_providers::create_provider(\n &config,\n selected_model,\n Some(&provider_name),\n ) {\n Ok(_) => println!(\" ✓ Provider config validation passed\"),\n Err(e) => println!(\" ⚠ Provider validation failed: {}\", e),\n }\n }\n }\n if channel_name != \"skip\" {\n println!(\" Channel: {} (enabled)\", channel_name);\n } else {\n println!(\" Channel: none (use WebUI only)\");\n }\n println!();\n println!(\"Next steps:\");\n println!(\" 1. blockcell status\");\n println!(\" 2. blockcell gateway\");\n println!(\" 3. Open WebUI: http://localhost:18791/\");\n\n Ok(())\n}\n\nfn configure_provider(\n config: &mut Config,\n provider: &str,\n api_key_flag: Option<&str>,\n model_flag: Option<&str>,\n) -> anyhow::Result<()> {\n let entry = config\n .providers\n .entry(provider.to_string())\n .or_insert_with(ProviderConfig::default);\n\n if entry.api_base.is_none() {\n entry.api_base = default_api_base_for_provider(provider).map(|s| s.to_string());\n }\n if entry.api_type.is_empty() {\n entry.api_type = default_api_type_for_provider(provider).to_string();\n }\n\n if provider == \"ollama\" {\n if entry.api_key.trim().is_empty() {\n entry.api_key = \"ollama\".to_string();\n }\n if entry.api_base.is_none() {\n entry.api_base = Some(\"http://localhost:11434\".to_string());\n }\n } else {\n let has_existing = !entry.api_key.trim().is_empty() && entry.api_key != \"dummy\";\n let final_key = if let Some(flag) = api_key_flag {\n flag.trim().to_string()\n } else if has_existing {\n prompt_line(\"API key (press Enter to keep existing): \")?\n } else {\n prompt_line(\"API key: \")?\n };\n\n if !final_key.is_empty() {\n entry.api_key = final_key;\n }\n\n if entry.api_key.trim().is_empty() || entry.api_key == \"dummy\" {\n bail!(\"Provider '{}' requires a valid API key.\", provider);\n }\n }\n\n let suggested_model = if let Some(entry) = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider)\n {\n entry.model.clone()\n } else {\n default_model_for_provider(provider).to_string()\n };\n\n let final_model = if let Some(m) = model_flag {\n m.trim().to_string()\n } else {\n prompt_line_with_default(\"Model\", &suggested_model)?\n };\n if final_model.trim().is_empty() {\n bail!(\"Model cannot be empty.\");\n }\n\n // Setup writes provider/model to model_pool first, and also mirrors to legacy\n // single-model fields for backward compatibility with older runtimes.\n config.agents.defaults.model_pool = vec![ModelEntry {\n model: final_model.clone(),\n provider: provider.to_string(),\n weight: 1,\n priority: 1,\n input_price: None,\n output_price: None,\n tool_call_mode: ToolCallMode::Native,\n }];\n config.agents.defaults.provider = Some(provider.to_string());\n config.agents.defaults.model = final_model;\n\n Ok(())\n}\n\nfn configure_channel(config: &mut Config, channel: &str) -> anyhow::Result<()> {\n match channel {\n \"telegram\" => {\n let existing = config.channels.telegram.token.clone();\n let prompt = if existing.trim().is_empty() {\n \"Telegram bot token: \"\n } else {\n \"Telegram bot token (press Enter to keep existing): \"\n };\n let token = prompt_line(prompt)?;\n if !token.is_empty() {\n config.channels.telegram.token = token;\n }\n if config.channels.telegram.token.trim().is_empty() {\n bail!(\"Telegram token is required.\");\n }\n config.channels.telegram.enabled = true;\n }\n \"feishu\" => {\n let app_id =\n prompt_optional_with_existing(\"Feishu app_id\", &config.channels.feishu.app_id)?;\n let app_secret = prompt_optional_with_existing(\n \"Feishu app_secret\",\n &config.channels.feishu.app_secret,\n )?;\n\n if !app_id.is_empty() {\n config.channels.feishu.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.feishu.app_secret = app_secret;\n }\n\n if config.channels.feishu.app_id.trim().is_empty()\n || config.channels.feishu.app_secret.trim().is_empty()\n {\n bail!(\"Feishu app_id and app_secret are required.\");\n }\n config.channels.feishu.enabled = true;\n }\n \"wecom\" => {\n let corp_id =\n prompt_optional_with_existing(\"WeCom corp_id\", &config.channels.wecom.corp_id)?;\n let corp_secret = prompt_optional_with_existing(\n \"WeCom corp_secret\",\n &config.channels.wecom.corp_secret,\n )?;\n\n if !corp_id.is_empty() {\n config.channels.wecom.corp_id = corp_id;\n }\n if !corp_secret.is_empty() {\n config.channels.wecom.corp_secret = corp_secret;\n }\n\n if config.channels.wecom.corp_id.trim().is_empty()\n || config.channels.wecom.corp_secret.trim().is_empty()\n {\n bail!(\"WeCom corp_id and corp_secret are required.\");\n }\n\n let existing_agent = if config.channels.wecom.agent_id > 0 {\n config.channels.wecom.agent_id.to_string()\n } else {\n \"1000002\".to_string()\n };\n let agent_id_str = prompt_line_with_default(\"WeCom agent_id\", &existing_agent)?;\n let agent_id = agent_id_str\n .trim()\n .parse::()\n .map_err(|_| anyhow!(\"agent_id must be an integer\"))?;\n config.channels.wecom.agent_id = agent_id;\n config.channels.wecom.enabled = true;\n }\n \"dingtalk\" => {\n let app_key = prompt_optional_with_existing(\n \"DingTalk app_key\",\n &config.channels.dingtalk.app_key,\n )?;\n let app_secret = prompt_optional_with_existing(\n \"DingTalk app_secret\",\n &config.channels.dingtalk.app_secret,\n )?;\n if !app_key.is_empty() {\n config.channels.dingtalk.app_key = app_key;\n }\n if !app_secret.is_empty() {\n config.channels.dingtalk.app_secret = app_secret;\n }\n if config.channels.dingtalk.app_key.trim().is_empty()\n || config.channels.dingtalk.app_secret.trim().is_empty()\n {\n bail!(\"DingTalk app_key and app_secret are required.\");\n }\n config.channels.dingtalk.enabled = true;\n }\n \"lark\" => {\n let app_id =\n prompt_optional_with_existing(\"Lark app_id\", &config.channels.lark.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"Lark app_secret\", &config.channels.lark.app_secret)?;\n if !app_id.is_empty() {\n config.channels.lark.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.lark.app_secret = app_secret;\n }\n if config.channels.lark.app_id.trim().is_empty()\n || config.channels.lark.app_secret.trim().is_empty()\n {\n bail!(\"Lark app_id and app_secret are required.\");\n }\n config.channels.lark.enabled = true;\n }\n _ => bail!(\"Unsupported channel '{}'\", channel),\n }\n\n Ok(())\n}\n\nfn ensure_channel_owner(config: &mut Config, channel: &str) {\n if config.resolve_channel_owner(channel).is_some() {\n return;\n }\n let owner = config\n .known_agent_ids()\n .into_iter()\n .next()\n .unwrap_or_else(|| \"default\".to_string());\n config\n .channel_owners\n .insert(channel.to_string(), owner.clone());\n println!(\"Assigned channel owner: {} -> {}\", channel, owner);\n}\n\nfn prompt_provider(config: &Config) -> anyhow::Result {\n let current = primary_pool_entry(config)\n .map(|e| e.provider.clone())\n .or_else(|| {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| !e.provider.trim().is_empty())\n .map(|e| e.provider.clone())\n })\n .or_else(|| config.agents.defaults.provider.clone())\n .or_else(|| config.get_api_key().map(|(name, _)| name.to_string()))\n .unwrap_or_else(|| \"none\".to_string());\n\n println!(\"Configure LLM provider (current: {})\", current);\n let options = [\n \"deepseek (recommended)\",\n \"openai\",\n \"kimi\",\n \"anthropic\",\n \"gemini\",\n \"zhipu\",\n \"minimax\",\n \"ollama (local)\",\n \"skip\",\n ];\n let idx = prompt_select(\"Choose provider\", &options, 0)?;\n let mapped = match idx {\n 0 => \"deepseek\",\n 1 => \"openai\",\n 2 => \"kimi\",\n 3 => \"anthropic\",\n 4 => \"gemini\",\n 5 => \"zhipu\",\n 6 => \"minimax\",\n 7 => \"ollama\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn primary_pool_entry(config: &Config) -> Option<&ModelEntry> {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .min_by(|a, b| a.priority.cmp(&b.priority).then(b.weight.cmp(&a.weight)))\n}\n\nfn prompt_channel() -> anyhow::Result {\n let options = [\n \"skip (WebUI only)\",\n \"telegram\",\n \"feishu\",\n \"wecom\",\n \"dingtalk\",\n \"lark\",\n ];\n let idx = prompt_select(\"Configure one channel (optional)\", &options, 0)?;\n let mapped = match idx {\n 1 => \"telegram\",\n 2 => \"feishu\",\n 3 => \"wecom\",\n 4 => \"dingtalk\",\n 5 => \"lark\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn resolve_provider(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_provider(v)\n .ok_or_else(|| anyhow!(\"Unsupported provider '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn resolve_channel(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_channel(v)\n .ok_or_else(|| anyhow!(\"Unsupported channel '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn normalize_provider(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"deepseek\" => Some(\"deepseek\"),\n \"openai\" => Some(\"openai\"),\n \"kimi\" | \"moonshot\" => Some(\"kimi\"),\n \"anthropic\" | \"claude\" => Some(\"anthropic\"),\n \"gemini\" => Some(\"gemini\"),\n \"zhipu\" => Some(\"zhipu\"),\n \"minimax\" => Some(\"minimax\"),\n \"ollama\" => Some(\"ollama\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn normalize_channel(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"telegram\" => Some(\"telegram\"),\n \"feishu\" => Some(\"feishu\"),\n \"wecom\" | \"wechatwork\" => Some(\"wecom\"),\n \"dingtalk\" => Some(\"dingtalk\"),\n \"lark\" => Some(\"lark\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn default_model_for_provider(provider: &str) -> &'static str {\n match provider {\n \"deepseek\" => \"deepseek-chat\",\n \"openai\" => \"gpt-4o\",\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"kimi\" => \"moonshot-v1-8k\",\n \"gemini\" => \"gemini-2.0-flash\",\n \"zhipu\" => \"glm-4\",\n \"minimax\" => \"minimax-text-01\",\n \"ollama\" => \"llama3\",\n _ => \"gpt-4o\",\n }\n}\n\nfn default_api_base_for_provider(provider: &str) -> Option<&'static str> {\n match provider {\n \"deepseek\" => Some(\"https://api.deepseek.com/v1\"),\n \"openai\" => Some(\"https://api.openai.com/v1\"),\n \"anthropic\" => Some(\"https://api.anthropic.com\"),\n \"kimi\" => Some(\"https://api.moonshot.cn/v1\"),\n \"gemini\" => Some(\"https://generativelanguage.googleapis.com/v1beta/openai\"),\n \"zhipu\" => Some(\"https://open.bigmodel.cn/api/paas/v4\"),\n \"minimax\" => Some(\"https://api.minimax.chat/v1\"),\n \"ollama\" => Some(\"http://localhost:11434\"),\n _ => None,\n }\n}\n\nfn default_api_type_for_provider(provider: &str) -> &'static str {\n match provider {\n \"anthropic\" | \"minimax\" => \"anthropic\",\n \"gemini\" => \"openai\",\n \"ollama\" => \"ollama\",\n _ => \"openai\",\n }\n}\n\nfn prompt_select(title: &str, options: &[&str], default_index: usize) -> anyhow::Result {\n println!(\"{}\", title);\n for (i, opt) in options.iter().enumerate() {\n println!(\" {}. {}\", i + 1, opt);\n }\n\n loop {\n let input = prompt_line(&format!(\n \"Enter choice [1-{}] (default {}): \",\n options.len(),\n default_index + 1\n ))?;\n\n if input.trim().is_empty() {\n return Ok(default_index);\n }\n\n if let Ok(n) = input.trim().parse::() {\n if n >= 1 && n <= options.len() {\n return Ok(n - 1);\n }\n }\n println!(\"Invalid choice, please try again.\");\n }\n}\n\nfn prompt_optional_with_existing(label: &str, existing: &str) -> anyhow::Result {\n let prompt = if existing.trim().is_empty() {\n format!(\"{}: \", label)\n } else {\n format!(\"{} (press Enter to keep existing): \", label)\n };\n prompt_line(&prompt)\n}\n\nfn prompt_line_with_default(label: &str, default: &str) -> anyhow::Result {\n let input = prompt_line(&format!(\"{} [{}]: \", label, default))?;\n if input.trim().is_empty() {\n Ok(default.to_string())\n } else {\n Ok(input)\n }\n}\n\nfn prompt_line(prompt: &str) -> anyhow::Result {\n print!(\"{}\", prompt);\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin().read_line(&mut input)?;\n Ok(input.trim().to_string())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_provider_aliases() {\n assert_eq!(normalize_provider(\"moonshot\"), Some(\"kimi\"));\n assert_eq!(normalize_provider(\"claude\"), Some(\"anthropic\"));\n assert_eq!(normalize_provider(\"zhipu\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"minimax\"), Some(\"minimax\"));\n assert_eq!(normalize_provider(\"none\"), Some(\"skip\"));\n assert_eq!(normalize_provider(\"unknown\"), None);\n }\n\n #[test]\n fn test_normalize_channel_aliases() {\n assert_eq!(normalize_channel(\"wechatwork\"), Some(\"wecom\"));\n assert_eq!(normalize_channel(\"dingtalk\"), Some(\"dingtalk\"));\n assert_eq!(normalize_channel(\"skip\"), Some(\"skip\"));\n assert_eq!(normalize_channel(\"unknown\"), None);\n }\n\n #[test]\n fn test_default_model() {\n assert_eq!(default_model_for_provider(\"deepseek\"), \"deepseek-chat\");\n assert_eq!(default_model_for_provider(\"openai\"), \"gpt-4o\");\n assert_eq!(default_model_for_provider(\"kimi\"), \"moonshot-v1-8k\");\n assert_eq!(default_model_for_provider(\"zhipu\"), \"glm-4\");\n assert_eq!(default_model_for_provider(\"minimax\"), \"minimax-text-01\");\n }\n\n #[test]\n fn test_configure_provider_populates_legacy_single_model_fields_for_compat() {\n let mut config = Config::default();\n config.agents.defaults.model = \"legacy-model\".to_string();\n config.agents.defaults.provider = Some(\"legacy-provider\".to_string());\n\n configure_provider(\n &mut config,\n \"deepseek\",\n Some(\"test-deepseek-key\"),\n Some(\"deepseek-chat\"),\n )\n .expect(\"configure_provider should succeed\");\n\n assert_eq!(\n config.agents.defaults.provider,\n Some(\"deepseek\".to_string())\n );\n assert_eq!(config.agents.defaults.model, \"deepseek-chat\");\n assert_eq!(config.agents.defaults.model_pool.len(), 1);\n let entry = &config.agents.defaults.model_pool[0];\n assert_eq!(entry.provider, \"deepseek\");\n assert_eq!(entry.model, \"deepseek-chat\");\n assert_eq!(entry.weight, 1);\n assert_eq!(entry.priority, 1);\n }\n\n #[test]\n fn test_ensure_channel_owner_defaults_to_default_agent() {\n let mut config = Config::default();\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"default\")\n );\n }\n\n #[test]\n fn test_ensure_channel_owner_keeps_existing() {\n let mut config = Config::default();\n config\n .channel_owners\n .insert(\"telegram\".to_string(), \"ops\".to_string());\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"ops\")\n );\n }\n}\n"}, "a0c0100c1ac433ec0ec45ae732bc276c0d9ee38c": {"t": 1785760759.114373, "n": 19987, "h": "86639f2399859836", "c": "use anyhow::{anyhow, bail, Context};\nuse blockcell_core::config::{ModelEntry, ProviderConfig};\nuse blockcell_core::{Config, Paths};\nuse std::io::{self, Write};\n\n/// Interactive first-run setup wizard for provider + optional channel.\npub async fn run(\n force: bool,\n provider: Option,\n api_key: Option,\n model: Option,\n channel: Option,\n skip_provider_test: bool,\n) -> anyhow::Result<()> {\n let paths = Paths::new();\n paths.ensure_dirs()?;\n\n let config_path = paths.config_file();\n let mut config = if config_path.exists() && !force {\n Config::load(&config_path)\n .with_context(|| format!(\"Failed to load {}\", config_path.display()))?\n } else {\n if config_path.exists() && force {\n println!(\"⚠ --force enabled: reset existing config to defaults before setup.\");\n }\n Config::default()\n };\n\n if config.intent_router.is_none() {\n config.intent_router = Config::default().intent_router;\n }\n\n println!(\"blockcell setup\");\n println!(\"==============\");\n println!(\"Config file: {}\", config_path.display());\n println!();\n\n let selected_provider = resolve_provider(provider.as_deref())?;\n let provider_name = if let Some(name) = selected_provider {\n name.to_string()\n } else {\n prompt_provider(&config)?\n };\n\n if provider_name != \"skip\" {\n configure_provider(\n &mut config,\n &provider_name,\n api_key.as_deref(),\n model.as_deref(),\n )?;\n } else {\n println!(\"Skipped LLM provider setup.\");\n }\n\n let selected_channel = resolve_channel(channel.as_deref())?;\n let channel_name = if let Some(name) = selected_channel {\n name.to_string()\n } else {\n prompt_channel()?\n };\n\n if channel_name != \"skip\" {\n configure_channel(&mut config, &channel_name)?;\n ensure_channel_owner(&mut config, &channel_name);\n } else {\n println!(\"Skipped channel setup (WebUI only).\");\n }\n\n config\n .save(&config_path)\n .with_context(|| format!(\"Failed to save {}\", config_path.display()))?;\n\n println!();\n println!(\"✓ Setup completed\");\n println!(\" Config: {}\", config_path.display());\n if provider_name != \"skip\" {\n let selected = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider_name)\n .or_else(|| primary_pool_entry(&config));\n let selected_model = selected\n .map(|e| e.model.as_str())\n .unwrap_or(config.agents.defaults.model.as_str());\n println!(\" Provider: {} Model: {}\", provider_name, selected_model);\n if !skip_provider_test {\n match blockcell_providers::create_provider(\n &config,\n selected_model,\n Some(&provider_name),\n ) {\n Ok(_) => println!(\" ✓ Provider config validation passed\"),\n Err(e) => println!(\" ⚠ Provider validation failed: {}\", e),\n }\n }\n }\n if channel_name != \"skip\" {\n println!(\" Channel: {} (enabled)\", channel_name);\n } else {\n println!(\" Channel: none (use WebUI only)\");\n }\n println!();\n println!(\"Next steps:\");\n println!(\" 1. blockcell status\");\n println!(\" 2. blockcell gateway\");\n println!(\" 3. Open WebUI: http://localhost:18791/\");\n\n Ok(())\n}\n\nfn configure_provider(\n config: &mut Config,\n provider: &str,\n api_key_flag: Option<&str>,\n model_flag: Option<&str>,\n) -> anyhow::Result<()> {\n let entry = config\n .providers\n .entry(provider.to_string())\n .or_insert_with(ProviderConfig::default);\n\n if entry.api_base.is_none() {\n entry.api_base = default_api_base_for_provider(provider).map(|s| s.to_string());\n }\n if entry.api_type.is_empty() {\n entry.api_type = default_api_type_for_provider(provider).to_string();\n }\n\n if provider == \"ollama\" {\n if entry.api_key.trim().is_empty() {\n entry.api_key = \"ollama\".to_string();\n }\n if entry.api_base.is_none() {\n entry.api_base = Some(\"http://localhost:11434\".to_string());\n }\n } else {\n let has_existing = !entry.api_key.trim().is_empty() && entry.api_key != \"dummy\";\n let final_key = if let Some(flag) = api_key_flag {\n flag.trim().to_string()\n } else if has_existing {\n prompt_line(\"API key (press Enter to keep existing): \")?\n } else {\n prompt_line(\"API key: \")?\n };\n\n if !final_key.is_empty() {\n entry.api_key = final_key;\n }\n\n if entry.api_key.trim().is_empty() || entry.api_key == \"dummy\" {\n bail!(\"Provider '{}' requires a valid API key.\", provider);\n }\n }\n\n let suggested_model = if let Some(entry) = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider)\n {\n entry.model.clone()\n } else {\n default_model_for_provider(provider).to_string()\n };\n\n let final_model = if let Some(m) = model_flag {\n m.trim().to_string()\n } else {\n prompt_line_with_default(\"Model\", &suggested_model)?\n };\n if final_model.trim().is_empty() {\n bail!(\"Model cannot be empty.\");\n }\n\n // Setup writes provider/model to model_pool first, and also mirrors to legacy\n // single-model fields for backward compatibility with older runtimes.\n config.agents.defaults.model_pool = vec![ModelEntry {\n model: final_model.clone(),\n provider: provider.to_string(),\n weight: 1,\n priority: 1,\n input_price: None,\n output_price: None,\n }];\n config.agents.defaults.provider = Some(provider.to_string());\n config.agents.defaults.model = final_model;\n\n Ok(())\n}\n\nfn configure_channel(config: &mut Config, channel: &str) -> anyhow::Result<()> {\n match channel {\n \"telegram\" => {\n let existing = config.channels.telegram.token.clone();\n let prompt = if existing.trim().is_empty() {\n \"Telegram bot token: \"\n } else {\n \"Telegram bot token (press Enter to keep existing): \"\n };\n let token = prompt_line(prompt)?;\n if !token.is_empty() {\n config.channels.telegram.token = token;\n }\n if config.channels.telegram.token.trim().is_empty() {\n bail!(\"Telegram token is required.\");\n }\n config.channels.telegram.enabled = true;\n }\n \"feishu\" => {\n let app_id =\n prompt_optional_with_existing(\"Feishu app_id\", &config.channels.feishu.app_id)?;\n let app_secret = prompt_optional_with_existing(\n \"Feishu app_secret\",\n &config.channels.feishu.app_secret,\n )?;\n\n if !app_id.is_empty() {\n config.channels.feishu.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.feishu.app_secret = app_secret;\n }\n\n if config.channels.feishu.app_id.trim().is_empty()\n || config.channels.feishu.app_secret.trim().is_empty()\n {\n bail!(\"Feishu app_id and app_secret are required.\");\n }\n config.channels.feishu.enabled = true;\n }\n \"wecom\" => {\n let corp_id =\n prompt_optional_with_existing(\"WeCom corp_id\", &config.channels.wecom.corp_id)?;\n let corp_secret = prompt_optional_with_existing(\n \"WeCom corp_secret\",\n &config.channels.wecom.corp_secret,\n )?;\n\n if !corp_id.is_empty() {\n config.channels.wecom.corp_id = corp_id;\n }\n if !corp_secret.is_empty() {\n config.channels.wecom.corp_secret = corp_secret;\n }\n\n if config.channels.wecom.corp_id.trim().is_empty()\n || config.channels.wecom.corp_secret.trim().is_empty()\n {\n bail!(\"WeCom corp_id and corp_secret are required.\");\n }\n\n let existing_agent = if config.channels.wecom.agent_id > 0 {\n config.channels.wecom.agent_id.to_string()\n } else {\n \"1000002\".to_string()\n };\n let agent_id_str = prompt_line_with_default(\"WeCom agent_id\", &existing_agent)?;\n let agent_id = agent_id_str\n .trim()\n .parse::()\n .map_err(|_| anyhow!(\"agent_id must be an integer\"))?;\n config.channels.wecom.agent_id = agent_id;\n config.channels.wecom.enabled = true;\n }\n \"dingtalk\" => {\n let app_key = prompt_optional_with_existing(\n \"DingTalk app_key\",\n &config.channels.dingtalk.app_key,\n )?;\n let app_secret = prompt_optional_with_existing(\n \"DingTalk app_secret\",\n &config.channels.dingtalk.app_secret,\n )?;\n if !app_key.is_empty() {\n config.channels.dingtalk.app_key = app_key;\n }\n if !app_secret.is_empty() {\n config.channels.dingtalk.app_secret = app_secret;\n }\n if config.channels.dingtalk.app_key.trim().is_empty()\n || config.channels.dingtalk.app_secret.trim().is_empty()\n {\n bail!(\"DingTalk app_key and app_secret are required.\");\n }\n config.channels.dingtalk.enabled = true;\n }\n \"lark\" => {\n let app_id =\n prompt_optional_with_existing(\"Lark app_id\", &config.channels.lark.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"Lark app_secret\", &config.channels.lark.app_secret)?;\n if !app_id.is_empty() {\n config.channels.lark.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.lark.app_secret = app_secret;\n }\n if config.channels.lark.app_id.trim().is_empty()\n || config.channels.lark.app_secret.trim().is_empty()\n {\n bail!(\"Lark app_id and app_secret are required.\");\n }\n config.channels.lark.enabled = true;\n }\n _ => bail!(\"Unsupported channel '{}'\", channel),\n }\n\n Ok(())\n}\n\nfn ensure_channel_owner(config: &mut Config, channel: &str) {\n if config.resolve_channel_owner(channel).is_some() {\n return;\n }\n let owner = config\n .known_agent_ids()\n .into_iter()\n .next()\n .unwrap_or_else(|| \"default\".to_string());\n config\n .channel_owners\n .insert(channel.to_string(), owner.clone());\n println!(\"Assigned channel owner: {} -> {}\", channel, owner);\n}\n\nfn prompt_provider(config: &Config) -> anyhow::Result {\n let current = primary_pool_entry(config)\n .map(|e| e.provider.clone())\n .or_else(|| {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| !e.provider.trim().is_empty())\n .map(|e| e.provider.clone())\n })\n .or_else(|| config.agents.defaults.provider.clone())\n .or_else(|| config.get_api_key().map(|(name, _)| name.to_string()))\n .unwrap_or_else(|| \"none\".to_string());\n\n println!(\"Configure LLM provider (current: {})\", current);\n let options = [\n \"deepseek (recommended)\",\n \"openai\",\n \"kimi\",\n \"anthropic\",\n \"gemini\",\n \"zhipu\",\n \"minimax\",\n \"ollama (local)\",\n \"skip\",\n ];\n let idx = prompt_select(\"Choose provider\", &options, 0)?;\n let mapped = match idx {\n 0 => \"deepseek\",\n 1 => \"openai\",\n 2 => \"kimi\",\n 3 => \"anthropic\",\n 4 => \"gemini\",\n 5 => \"zhipu\",\n 6 => \"minimax\",\n 7 => \"ollama\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn primary_pool_entry(config: &Config) -> Option<&ModelEntry> {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .min_by(|a, b| a.priority.cmp(&b.priority).then(b.weight.cmp(&a.weight)))\n}\n\nfn prompt_channel() -> anyhow::Result {\n let options = [\n \"skip (WebUI only)\",\n \"telegram\",\n \"feishu\",\n \"wecom\",\n \"dingtalk\",\n \"lark\",\n ];\n let idx = prompt_select(\"Configure one channel (optional)\", &options, 0)?;\n let mapped = match idx {\n 1 => \"telegram\",\n 2 => \"feishu\",\n 3 => \"wecom\",\n 4 => \"dingtalk\",\n 5 => \"lark\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn resolve_provider(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_provider(v)\n .ok_or_else(|| anyhow!(\"Unsupported provider '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn resolve_channel(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_channel(v)\n .ok_or_else(|| anyhow!(\"Unsupported channel '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn normalize_provider(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"deepseek\" => Some(\"deepseek\"),\n \"openai\" => Some(\"openai\"),\n \"kimi\" | \"moonshot\" => Some(\"kimi\"),\n \"anthropic\" | \"claude\" => Some(\"anthropic\"),\n \"gemini\" => Some(\"gemini\"),\n \"zhipu\" => Some(\"zhipu\"),\n \"minimax\" => Some(\"minimax\"),\n \"ollama\" => Some(\"ollama\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn normalize_channel(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"telegram\" => Some(\"telegram\"),\n \"feishu\" => Some(\"feishu\"),\n \"wecom\" | \"wechatwork\" => Some(\"wecom\"),\n \"dingtalk\" => Some(\"dingtalk\"),\n \"lark\" => Some(\"lark\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn default_model_for_provider(provider: &str) -> &'static str {\n match provider {\n \"deepseek\" => \"deepseek-chat\",\n \"openai\" => \"gpt-4o\",\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"kimi\" => \"moonshot-v1-8k\",\n \"gemini\" => \"gemini-2.0-flash\",\n \"zhipu\" => \"glm-4\",\n \"minimax\" => \"minimax-text-01\",\n \"ollama\" => \"llama3\",\n _ => \"gpt-4o\",\n }\n}\n\nfn default_api_base_for_provider(provider: &str) -> Option<&'static str> {\n match provider {\n \"deepseek\" => Some(\"https://api.deepseek.com/v1\"),\n \"openai\" => Some(\"https://api.openai.com/v1\"),\n \"anthropic\" => Some(\"https://api.anthropic.com\"),\n \"kimi\" => Some(\"https://api.moonshot.cn/v1\"),\n \"gemini\" => Some(\"https://generativelanguage.googleapis.com/v1beta/openai\"),\n \"zhipu\" => Some(\"https://open.bigmodel.cn/api/paas/v4\"),\n \"minimax\" => Some(\"https://api.minimax.chat/v1\"),\n \"ollama\" => Some(\"http://localhost:11434\"),\n _ => None,\n }\n}\n\nfn default_api_type_for_provider(provider: &str) -> &'static str {\n match provider {\n \"anthropic\" | \"minimax\" => \"anthropic\",\n \"gemini\" => \"openai\",\n \"ollama\" => \"ollama\",\n _ => \"openai\",\n }\n}\n\nfn prompt_select(title: &str, options: &[&str], default_index: usize) -> anyhow::Result {\n println!(\"{}\", title);\n for (i, opt) in options.iter().enumerate() {\n println!(\" {}. {}\", i + 1, opt);\n }\n\n loop {\n let input = prompt_line(&format!(\n \"Enter choice [1-{}] (default {}): \",\n options.len(),\n default_index + 1\n ))?;\n\n if input.trim().is_empty() {\n return Ok(default_index);\n }\n\n if let Ok(n) = input.trim().parse::() {\n if n >= 1 && n <= options.len() {\n return Ok(n - 1);\n }\n }\n println!(\"Invalid choice, please try again.\");\n }\n}\n\nfn prompt_optional_with_existing(label: &str, existing: &str) -> anyhow::Result {\n let prompt = if existing.trim().is_empty() {\n format!(\"{}: \", label)\n } else {\n format!(\"{} (press Enter to keep existing): \", label)\n };\n prompt_line(&prompt)\n}\n\nfn prompt_line_with_default(label: &str, default: &str) -> anyhow::Result {\n let input = prompt_line(&format!(\"{} [{}]: \", label, default))?;\n if input.trim().is_empty() {\n Ok(default.to_string())\n } else {\n Ok(input)\n }\n}\n\nfn prompt_line(prompt: &str) -> anyhow::Result {\n print!(\"{}\", prompt);\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin().read_line(&mut input)?;\n Ok(input.trim().to_string())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_provider_aliases() {\n assert_eq!(normalize_provider(\"moonshot\"), Some(\"kimi\"));\n assert_eq!(normalize_provider(\"claude\"), Some(\"anthropic\"));\n assert_eq!(normalize_provider(\"zhipu\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"minimax\"), Some(\"minimax\"));\n assert_eq!(normalize_provider(\"none\"), Some(\"skip\"));\n assert_eq!(normalize_provider(\"unknown\"), None);\n }\n\n #[test]\n fn test_normalize_channel_aliases() {\n assert_eq!(normalize_channel(\"wechatwork\"), Some(\"wecom\"));\n assert_eq!(normalize_channel(\"dingtalk\"), Some(\"dingtalk\"));\n assert_eq!(normalize_channel(\"skip\"), Some(\"skip\"));\n assert_eq!(normalize_channel(\"unknown\"), None);\n }\n\n #[test]\n fn test_default_model() {\n assert_eq!(default_model_for_provider(\"deepseek\"), \"deepseek-chat\");\n assert_eq!(default_model_for_provider(\"openai\"), \"gpt-4o\");\n assert_eq!(default_model_for_provider(\"kimi\"), \"moonshot-v1-8k\");\n assert_eq!(default_model_for_provider(\"zhipu\"), \"glm-4\");\n assert_eq!(default_model_for_provider(\"minimax\"), \"minimax-text-01\");\n }\n\n #[test]\n fn test_configure_provider_populates_legacy_single_model_fields_for_compat() {\n let mut config = Config::default();\n config.agents.defaults.model = \"legacy-model\".to_string();\n config.agents.defaults.provider = Some(\"legacy-provider\".to_string());\n\n configure_provider(\n &mut config,\n \"deepseek\",\n Some(\"test-deepseek-key\"),\n Some(\"deepseek-chat\"),\n )\n .expect(\"configure_provider should succeed\");\n\n assert_eq!(\n config.agents.defaults.provider,\n Some(\"deepseek\".to_string())\n );\n assert_eq!(config.agents.defaults.model, \"deepseek-chat\");\n assert_eq!(config.agents.defaults.model_pool.len(), 1);\n let entry = &config.agents.defaults.model_pool[0];\n assert_eq!(entry.provider, \"deepseek\");\n assert_eq!(entry.model, \"deepseek-chat\");\n assert_eq!(entry.weight, 1);\n assert_eq!(entry.priority, 1);\n }\n\n #[test]\n fn test_ensure_channel_owner_defaults_to_default_agent() {\n let mut config = Config::default();\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"default\")\n );\n }\n\n #[test]\n fn test_ensure_channel_owner_keeps_existing() {\n let mut config = Config::default();\n config\n .channel_owners\n .insert(\"telegram\".to_string(), \"ops\".to_string());\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"ops\")\n );\n }\n}\n"}, "cdf41c8a555327753c594c3e1ce6f46444dcdaeb": {"t": 1785760761.1162698, "n": 19987, "h": "fdd26bc79901bacf", "c": "use anyhow::{anyhow, bail, Context};\nuse blockcell_core::config::{ModelEntry, ProviderConfig};\nuse blockcell_core::{Config, Paths};\nuse std::io::{self, Write};\n\n/// Interactive first-run setup wizard for provider + optional channel.\npub async fn run(\n force: bool,\n provider: Option,\n api_key: Option,\n model: Option,\n channel: Option,\n skip_provider_test: bool,\n) -> anyhow::Result<()> {\n let paths = Paths::new();\n paths.ensure_dirs()?;\n\n let config_path = paths.config_file();\n let mut config = if config_path.exists() && !force {\n Config::load(&config_path)\n .with_context(|| format!(\"Failed to load {}\", config_path.display()))?\n } else {\n if config_path.exists() && force {\n println!(\"⚠ --force enabled: reset existing config to defaults before setup.\");\n }\n Config::default()\n };\n\n if config.intent_router.is_none() {\n config.intent_router = Config::default().intent_router;\n }\n\n println!(\"blockcell setup\");\n println!(\"==============\");\n println!(\"Config file: {}\", config_path.display());\n println!();\n\n let selected_provider = resolve_provider(provider.as_deref())?;\n let provider_name = if let Some(name) = selected_provider {\n name.to_string()\n } else {\n prompt_provider(&config)?\n };\n\n if provider_name != \"skip\" {\n configure_provider(\n &mut config,\n &provider_name,\n api_key.as_deref(),\n model.as_deref(),\n )?;\n } else {\n println!(\"Skipped LLM provider setup.\");\n }\n\n let selected_channel = resolve_channel(channel.as_deref())?;\n let channel_name = if let Some(name) = selected_channel {\n name.to_string()\n } else {\n prompt_channel()?\n };\n\n if channel_name != \"skip\" {\n configure_channel(&mut config, &channel_name)?;\n ensure_channel_owner(&mut config, &channel_name);\n } else {\n println!(\"Skipped channel setup (WebUI only).\");\n }\n\n config\n .save(&config_path)\n .with_context(|| format!(\"Failed to save {}\", config_path.display()))?;\n\n println!();\n println!(\"✓ Setup completed\");\n println!(\" Config: {}\", config_path.display());\n if provider_name != \"skip\" {\n let selected = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider_name)\n .or_else(|| primary_pool_entry(&config));\n let selected_model = selected\n .map(|e| e.model.as_str())\n .unwrap_or(config.agents.defaults.model.as_str());\n println!(\" Provider: {} Model: {}\", provider_name, selected_model);\n if !skip_provider_test {\n match blockcell_providers::create_provider(\n &config,\n selected_model,\n Some(&provider_name),\n ) {\n Ok(_) => println!(\" ✓ Provider config validation passed\"),\n Err(e) => println!(\" ⚠ Provider validation failed: {}\", e),\n }\n }\n }\n if channel_name != \"skip\" {\n println!(\" Channel: {} (enabled)\", channel_name);\n } else {\n println!(\" Channel: none (use WebUI only)\");\n }\n println!();\n println!(\"Next steps:\");\n println!(\" 1. blockcell status\");\n println!(\" 2. blockcell gateway\");\n println!(\" 3. Open WebUI: http://localhost:18791/\");\n\n Ok(())\n}\n\nfn configure_provider(\n config: &mut Config,\n provider: &str,\n api_key_flag: Option<&str>,\n model_flag: Option<&str>,\n) -> anyhow::Result<()> {\n let entry = config\n .providers\n .entry(provider.to_string())\n .or_insert_with(ProviderConfig::default);\n\n if entry.api_base.is_none() {\n entry.api_base = default_api_base_for_provider(provider).map(|s| s.to_string());\n }\n if entry.api_type.is_empty() {\n entry.api_type = default_api_type_for_provider(provider).to_string();\n }\n\n if provider == \"ollama\" {\n if entry.api_key.trim().is_empty() {\n entry.api_key = \"ollama\".to_string();\n }\n if entry.api_base.is_none() {\n entry.api_base = Some(\"http://localhost:11434\".to_string());\n }\n } else {\n let has_existing = !entry.api_key.trim().is_empty() && entry.api_key != \"dummy\";\n let final_key = if let Some(flag) = api_key_flag {\n flag.trim().to_string()\n } else if has_existing {\n prompt_line(\"API key (press Enter to keep existing): \")?\n } else {\n prompt_line(\"API key: \")?\n };\n\n if !final_key.is_empty() {\n entry.api_key = final_key;\n }\n\n if entry.api_key.trim().is_empty() || entry.api_key == \"dummy\" {\n bail!(\"Provider '{}' requires a valid API key.\", provider);\n }\n }\n\n let suggested_model = if let Some(entry) = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider)\n {\n entry.model.clone()\n } else {\n default_model_for_provider(provider).to_string()\n };\n\n let final_model = if let Some(m) = model_flag {\n m.trim().to_string()\n } else {\n prompt_line_with_default(\"Model\", &suggested_model)?\n };\n if final_model.trim().is_empty() {\n bail!(\"Model cannot be empty.\");\n }\n\n // Setup writes provider/model to model_pool first, and also mirrors to legacy\n // single-model fields for backward compatibility with older runtimes.\n config.agents.defaults.model_pool = vec![ModelEntry {\n model: final_model.clone(),\n provider: provider.to_string(),\n weight: 1,\n priority: 1,\n input_price: None,\n output_price: None,\n }];\n config.agents.defaults.provider = Some(provider.to_string());\n config.agents.defaults.model = final_model;\n\n Ok(())\n}\n\nfn configure_channel(config: &mut Config, channel: &str) -> anyhow::Result<()> {\n match channel {\n \"telegram\" => {\n let existing = config.channels.telegram.token.clone();\n let prompt = if existing.trim().is_empty() {\n \"Telegram bot token: \"\n } else {\n \"Telegram bot token (press Enter to keep existing): \"\n };\n let token = prompt_line(prompt)?;\n if !token.is_empty() {\n config.channels.telegram.token = token;\n }\n if config.channels.telegram.token.trim().is_empty() {\n bail!(\"Telegram token is required.\");\n }\n config.channels.telegram.enabled = true;\n }\n \"feishu\" => {\n let app_id =\n prompt_optional_with_existing(\"Feishu app_id\", &config.channels.feishu.app_id)?;\n let app_secret = prompt_optional_with_existing(\n \"Feishu app_secret\",\n &config.channels.feishu.app_secret,\n )?;\n\n if !app_id.is_empty() {\n config.channels.feishu.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.feishu.app_secret = app_secret;\n }\n\n if config.channels.feishu.app_id.trim().is_empty()\n || config.channels.feishu.app_secret.trim().is_empty()\n {\n bail!(\"Feishu app_id and app_secret are required.\");\n }\n config.channels.feishu.enabled = true;\n }\n \"wecom\" => {\n let corp_id =\n prompt_optional_with_existing(\"WeCom corp_id\", &config.channels.wecom.corp_id)?;\n let corp_secret = prompt_optional_with_existing(\n \"WeCom corp_secret\",\n &config.channels.wecom.corp_secret,\n )?;\n\n if !corp_id.is_empty() {\n config.channels.wecom.corp_id = corp_id;\n }\n if !corp_secret.is_empty() {\n config.channels.wecom.corp_secret = corp_secret;\n }\n\n if config.channels.wecom.corp_id.trim().is_empty()\n || config.channels.wecom.corp_secret.trim().is_empty()\n {\n bail!(\"WeCom corp_id and corp_secret are required.\");\n }\n\n let existing_agent = if config.channels.wecom.agent_id > 0 {\n config.channels.wecom.agent_id.to_string()\n } else {\n \"1000002\".to_string()\n };\n let agent_id_str = prompt_line_with_default(\"WeCom agent_id\", &existing_agent)?;\n let agent_id = agent_id_str\n .trim()\n .parse::()\n .map_err(|_| anyhow!(\"agent_id must be an integer\"))?;\n config.channels.wecom.agent_id = agent_id;\n config.channels.wecom.enabled = true;\n }\n \"dingtalk\" => {\n let app_key = prompt_optional_with_existing(\n \"DingTalk app_key\",\n &config.channels.dingtalk.app_key,\n )?;\n let app_secret = prompt_optional_with_existing(\n \"DingTalk app_secret\",\n &config.channels.dingtalk.app_secret,\n )?;\n if !app_key.is_empty() {\n config.channels.dingtalk.app_key = app_key;\n }\n if !app_secret.is_empty() {\n config.channels.dingtalk.app_secret = app_secret;\n }\n if config.channels.dingtalk.app_key.trim().is_empty()\n || config.channels.dingtalk.app_secret.trim().is_empty()\n {\n bail!(\"DingTalk app_key and app_secret are required.\");\n }\n config.channels.dingtalk.enabled = true;\n }\n \"lark\" => {\n let app_id =\n prompt_optional_with_existing(\"Lark app_id\", &config.channels.lark.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"Lark app_secret\", &config.channels.lark.app_secret)?;\n if !app_id.is_empty() {\n config.channels.lark.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.lark.app_secret = app_secret;\n }\n if config.channels.lark.app_id.trim().is_empty()\n || config.channels.lark.app_secret.trim().is_empty()\n {\n bail!(\"Lark app_id and app_secret are required.\");\n }\n config.channels.lark.enabled = true;\n }\n _ => bail!(\"Unsupported channel '{}'\", channel),\n }\n\n Ok(())\n}\n\nfn ensure_channel_owner(config: &mut Config, channel: &str) {\n if config.resolve_channel_owner(channel).is_some() {\n return;\n }\n let owner = config\n .known_agent_ids()\n .into_iter()\n .next()\n .unwrap_or_else(|| \"default\".to_string());\n config\n .channel_owners\n .insert(channel.to_string(), owner.clone());\n println!(\"Assigned channel owner: {} -> {}\", channel, owner);\n}\n\nfn prompt_provider(config: &Config) -> anyhow::Result {\n let current = primary_pool_entry(config)\n .map(|e| e.provider.clone())\n .or_else(|| {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| !e.provider.trim().is_empty())\n .map(|e| e.provider.clone())\n })\n .or_else(|| config.agents.defaults.provider.clone())\n .or_else(|| config.get_api_key().map(|(name, _)| name.to_string()))\n .unwrap_or_else(|| \"none\".to_string());\n\n println!(\"Configure LLM provider (current: {})\", current);\n let options = [\n \"deepseek (recommended)\",\n \"openai\",\n \"kimi\",\n \"anthropic\",\n \"gemini\",\n \"zhipu\",\n \"minimax\",\n \"ollama (local)\",\n \"skip\",\n ];\n let idx = prompt_select(\"Choose provider\", &options, 0)?;\n let mapped = match idx {\n 0 => \"deepseek\",\n 1 => \"openai\",\n 2 => \"kimi\",\n 3 => \"anthropic\",\n 4 => \"gemini\",\n 5 => \"zhipu\",\n 6 => \"minimax\",\n 7 => \"ollama\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn primary_pool_entry(config: &Config) -> Option<&ModelEntry> {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .min_by(|a, b| a.priority.cmp(&b.priority).then(b.weight.cmp(&a.weight)))\n}\n\nfn prompt_channel() -> anyhow::Result {\n let options = [\n \"skip (WebUI only)\",\n \"telegram\",\n \"feishu\",\n \"wecom\",\n \"dingtalk\",\n \"lark\",\n ];\n let idx = prompt_select(\"Configure one channel (optional)\", &options, 0)?;\n let mapped = match idx {\n 1 => \"telegram\",\n 2 => \"feishu\",\n 3 => \"wecom\",\n 4 => \"dingtalk\",\n 5 => \"lark\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn resolve_provider(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_provider(v)\n .ok_or_else(|| anyhow!(\"Unsupported provider '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn resolve_channel(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_channel(v)\n .ok_or_else(|| anyhow!(\"Unsupported channel '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn normalize_provider(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"deepseek\" => Some(\"deepseek\"),\n \"openai\" => Some(\"openai\"),\n \"kimi\" | \"moonshot\" => Some(\"kimi\"),\n \"anthropic\" | \"claude\" => Some(\"anthropic\"),\n \"gemini\" => Some(\"gemini\"),\n \"zhipu\" => Some(\"zhipu\"),\n \"minimax\" => Some(\"minimax\"),\n \"ollama\" => Some(\"ollama\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn normalize_channel(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"telegram\" => Some(\"telegram\"),\n \"feishu\" => Some(\"feishu\"),\n \"wecom\" | \"wechatwork\" => Some(\"wecom\"),\n \"dingtalk\" => Some(\"dingtalk\"),\n \"lark\" => Some(\"lark\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn default_model_for_provider(provider: &str) -> &'static str {\n match provider {\n \"deepseek\" => \"deepseek-chat\",\n \"openai\" => \"gpt-4o\",\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"kimi\" => \"moonshot-v1-8k\",\n \"gemini\" => \"gemini-2.0-flash\",\n \"zhipu\" => \"glm-4\",\n \"minimax\" => \"minimax-text-01\",\n \"ollama\" => \"llama3\",\n _ => \"gpt-4o\",\n }\n}\n\nfn default_api_base_for_provider(provider: &str) -> Option<&'static str> {\n match provider {\n \"deepseek\" => Some(\"https://api.deepseek.com/v1\"),\n \"openai\" => Some(\"https://api.openai.com/v1\"),\n \"anthropic\" => Some(\"https://api.anthropic.com\"),\n \"kimi\" => Some(\"https://api.moonshot.ai/v1\"),\n \"gemini\" => Some(\"https://generativelanguage.googleapis.com/v1beta/openai\"),\n \"zhipu\" => Some(\"https://open.bigmodel.cn/api/paas/v4\"),\n \"minimax\" => Some(\"https://api.minimax.chat/v1\"),\n \"ollama\" => Some(\"http://localhost:11434\"),\n _ => None,\n }\n}\n\nfn default_api_type_for_provider(provider: &str) -> &'static str {\n match provider {\n \"anthropic\" | \"minimax\" => \"anthropic\",\n \"gemini\" => \"openai\",\n \"ollama\" => \"ollama\",\n _ => \"openai\",\n }\n}\n\nfn prompt_select(title: &str, options: &[&str], default_index: usize) -> anyhow::Result {\n println!(\"{}\", title);\n for (i, opt) in options.iter().enumerate() {\n println!(\" {}. {}\", i + 1, opt);\n }\n\n loop {\n let input = prompt_line(&format!(\n \"Enter choice [1-{}] (default {}): \",\n options.len(),\n default_index + 1\n ))?;\n\n if input.trim().is_empty() {\n return Ok(default_index);\n }\n\n if let Ok(n) = input.trim().parse::() {\n if n >= 1 && n <= options.len() {\n return Ok(n - 1);\n }\n }\n println!(\"Invalid choice, please try again.\");\n }\n}\n\nfn prompt_optional_with_existing(label: &str, existing: &str) -> anyhow::Result {\n let prompt = if existing.trim().is_empty() {\n format!(\"{}: \", label)\n } else {\n format!(\"{} (press Enter to keep existing): \", label)\n };\n prompt_line(&prompt)\n}\n\nfn prompt_line_with_default(label: &str, default: &str) -> anyhow::Result {\n let input = prompt_line(&format!(\"{} [{}]: \", label, default))?;\n if input.trim().is_empty() {\n Ok(default.to_string())\n } else {\n Ok(input)\n }\n}\n\nfn prompt_line(prompt: &str) -> anyhow::Result {\n print!(\"{}\", prompt);\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin().read_line(&mut input)?;\n Ok(input.trim().to_string())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_provider_aliases() {\n assert_eq!(normalize_provider(\"moonshot\"), Some(\"kimi\"));\n assert_eq!(normalize_provider(\"claude\"), Some(\"anthropic\"));\n assert_eq!(normalize_provider(\"zhipu\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"minimax\"), Some(\"minimax\"));\n assert_eq!(normalize_provider(\"none\"), Some(\"skip\"));\n assert_eq!(normalize_provider(\"unknown\"), None);\n }\n\n #[test]\n fn test_normalize_channel_aliases() {\n assert_eq!(normalize_channel(\"wechatwork\"), Some(\"wecom\"));\n assert_eq!(normalize_channel(\"dingtalk\"), Some(\"dingtalk\"));\n assert_eq!(normalize_channel(\"skip\"), Some(\"skip\"));\n assert_eq!(normalize_channel(\"unknown\"), None);\n }\n\n #[test]\n fn test_default_model() {\n assert_eq!(default_model_for_provider(\"deepseek\"), \"deepseek-chat\");\n assert_eq!(default_model_for_provider(\"openai\"), \"gpt-4o\");\n assert_eq!(default_model_for_provider(\"kimi\"), \"moonshot-v1-8k\");\n assert_eq!(default_model_for_provider(\"zhipu\"), \"glm-4\");\n assert_eq!(default_model_for_provider(\"minimax\"), \"minimax-text-01\");\n }\n\n #[test]\n fn test_configure_provider_populates_legacy_single_model_fields_for_compat() {\n let mut config = Config::default();\n config.agents.defaults.model = \"legacy-model\".to_string();\n config.agents.defaults.provider = Some(\"legacy-provider\".to_string());\n\n configure_provider(\n &mut config,\n \"deepseek\",\n Some(\"test-deepseek-key\"),\n Some(\"deepseek-chat\"),\n )\n .expect(\"configure_provider should succeed\");\n\n assert_eq!(\n config.agents.defaults.provider,\n Some(\"deepseek\".to_string())\n );\n assert_eq!(config.agents.defaults.model, \"deepseek-chat\");\n assert_eq!(config.agents.defaults.model_pool.len(), 1);\n let entry = &config.agents.defaults.model_pool[0];\n assert_eq!(entry.provider, \"deepseek\");\n assert_eq!(entry.model, \"deepseek-chat\");\n assert_eq!(entry.weight, 1);\n assert_eq!(entry.priority, 1);\n }\n\n #[test]\n fn test_ensure_channel_owner_defaults_to_default_agent() {\n let mut config = Config::default();\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"default\")\n );\n }\n\n #[test]\n fn test_ensure_channel_owner_keeps_existing() {\n let mut config = Config::default();\n config\n .channel_owners\n .insert(\"telegram\".to_string(), \"ops\".to_string());\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"ops\")\n );\n }\n}\n"}, "3e34b9c096cfedfd9e06c0862ba70b17c6ecc7b9": {"t": 1785760762.3654466, "n": 18634, "h": "11e3c213a3775194", "c": "use anyhow::{anyhow, bail, Context};\nuse blockcell_core::config::{ModelEntry, ProviderConfig};\nuse blockcell_core::{Config, Paths};\nuse std::io::{self, Write};\n\n/// Interactive first-run setup wizard for provider + optional channel.\npub async fn run(\n force: bool,\n provider: Option,\n api_key: Option,\n model: Option,\n channel: Option,\n skip_provider_test: bool,\n) -> anyhow::Result<()> {\n let paths = Paths::new();\n paths.ensure_dirs()?;\n\n let config_path = paths.config_file();\n let mut config = if config_path.exists() && !force {\n Config::load(&config_path)\n .with_context(|| format!(\"Failed to load {}\", config_path.display()))?\n } else {\n if config_path.exists() && force {\n println!(\"⚠ --force enabled: reset existing config to defaults before setup.\");\n }\n Config::default()\n };\n\n println!(\"blockcell setup\");\n println!(\"==============\");\n println!(\"Config file: {}\", config_path.display());\n println!();\n\n let selected_provider = resolve_provider(provider.as_deref())?;\n let provider_name = if let Some(name) = selected_provider {\n name.to_string()\n } else {\n prompt_provider(&config)?\n };\n\n if provider_name != \"skip\" {\n configure_provider(\n &mut config,\n &provider_name,\n api_key.as_deref(),\n model.as_deref(),\n )?;\n } else {\n println!(\"Skipped LLM provider setup.\");\n }\n\n let selected_channel = resolve_channel(channel.as_deref())?;\n let channel_name = if let Some(name) = selected_channel {\n name.to_string()\n } else {\n prompt_channel()?\n };\n\n if channel_name != \"skip\" {\n configure_channel(&mut config, &channel_name)?;\n } else {\n println!(\"Skipped channel setup (WebUI only).\");\n }\n\n config\n .save(&config_path)\n .with_context(|| format!(\"Failed to save {}\", config_path.display()))?;\n\n println!();\n println!(\"✓ Setup completed\");\n println!(\" Config: {}\", config_path.display());\n if provider_name != \"skip\" {\n let selected = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider_name)\n .or_else(|| primary_pool_entry(&config));\n let selected_model = selected\n .map(|e| e.model.as_str())\n .unwrap_or(config.agents.defaults.model.as_str());\n println!(\" Provider: {} Model: {}\", provider_name, selected_model);\n if !skip_provider_test {\n match blockcell_providers::create_provider(\n &config,\n selected_model,\n Some(&provider_name),\n ) {\n Ok(_) => println!(\" ✓ Provider config validation passed\"),\n Err(e) => println!(\" ⚠ Provider validation failed: {}\", e),\n }\n }\n }\n if channel_name != \"skip\" {\n println!(\" Channel: {} (enabled)\", channel_name);\n } else {\n println!(\" Channel: none (use WebUI only)\");\n }\n println!();\n println!(\"Next steps:\");\n println!(\" 1. blockcell status\");\n println!(\" 2. blockcell gateway\");\n println!(\" 3. Open WebUI: http://localhost:18791/\");\n\n Ok(())\n}\n\nfn configure_provider(\n config: &mut Config,\n provider: &str,\n api_key_flag: Option<&str>,\n model_flag: Option<&str>,\n) -> anyhow::Result<()> {\n let entry = config\n .providers\n .entry(provider.to_string())\n .or_insert_with(ProviderConfig::default);\n\n if entry.api_base.is_none() {\n entry.api_base = default_api_base_for_provider(provider).map(|s| s.to_string());\n }\n if entry.api_type.is_empty() {\n entry.api_type = default_api_type_for_provider(provider).to_string();\n }\n\n if provider == \"ollama\" {\n if entry.api_key.trim().is_empty() {\n entry.api_key = \"ollama\".to_string();\n }\n if entry.api_base.is_none() {\n entry.api_base = Some(\"http://localhost:11434\".to_string());\n }\n } else {\n let has_existing = !entry.api_key.trim().is_empty() && entry.api_key != \"dummy\";\n let final_key = if let Some(flag) = api_key_flag {\n flag.trim().to_string()\n } else if has_existing {\n prompt_line(\"API key (press Enter to keep existing): \")?\n } else {\n prompt_line(\"API key: \")?\n };\n\n if !final_key.is_empty() {\n entry.api_key = final_key;\n }\n\n if entry.api_key.trim().is_empty() || entry.api_key == \"dummy\" {\n bail!(\"Provider '{}' requires a valid API key.\", provider);\n }\n }\n\n let suggested_model = if let Some(entry) = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider)\n {\n entry.model.clone()\n } else {\n default_model_for_provider(provider).to_string()\n };\n\n let final_model = if let Some(m) = model_flag {\n m.trim().to_string()\n } else {\n prompt_line_with_default(\"Model\", &suggested_model)?\n };\n if final_model.trim().is_empty() {\n bail!(\"Model cannot be empty.\");\n }\n\n // Setup writes provider/model to model_pool first, and also mirrors to legacy\n // single-model fields for backward compatibility with older runtimes.\n config.agents.defaults.model_pool = vec![ModelEntry {\n model: final_model.clone(),\n provider: provider.to_string(),\n weight: 1,\n priority: 1,\n input_price: None,\n output_price: None,\n }];\n config.agents.defaults.provider = Some(provider.to_string());\n config.agents.defaults.model = final_model;\n\n Ok(())\n}\n\nfn configure_channel(config: &mut Config, channel: &str) -> anyhow::Result<()> {\n match channel {\n \"telegram\" => {\n let existing = config.channels.telegram.token.clone();\n let prompt = if existing.trim().is_empty() {\n \"Telegram bot token: \"\n } else {\n \"Telegram bot token (press Enter to keep existing): \"\n };\n let token = prompt_line(prompt)?;\n if !token.is_empty() {\n config.channels.telegram.token = token;\n }\n if config.channels.telegram.token.trim().is_empty() {\n bail!(\"Telegram token is required.\");\n }\n config.channels.telegram.enabled = true;\n }\n \"feishu\" => {\n let app_id =\n prompt_optional_with_existing(\"Feishu app_id\", &config.channels.feishu.app_id)?;\n let app_secret = prompt_optional_with_existing(\n \"Feishu app_secret\",\n &config.channels.feishu.app_secret,\n )?;\n\n if !app_id.is_empty() {\n config.channels.feishu.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.feishu.app_secret = app_secret;\n }\n\n if config.channels.feishu.app_id.trim().is_empty()\n || config.channels.feishu.app_secret.trim().is_empty()\n {\n bail!(\"Feishu app_id and app_secret are required.\");\n }\n config.channels.feishu.enabled = true;\n }\n \"wecom\" => {\n let corp_id =\n prompt_optional_with_existing(\"WeCom corp_id\", &config.channels.wecom.corp_id)?;\n let corp_secret = prompt_optional_with_existing(\n \"WeCom corp_secret\",\n &config.channels.wecom.corp_secret,\n )?;\n\n if !corp_id.is_empty() {\n config.channels.wecom.corp_id = corp_id;\n }\n if !corp_secret.is_empty() {\n config.channels.wecom.corp_secret = corp_secret;\n }\n\n if config.channels.wecom.corp_id.trim().is_empty()\n || config.channels.wecom.corp_secret.trim().is_empty()\n {\n bail!(\"WeCom corp_id and corp_secret are required.\");\n }\n\n let existing_agent = if config.channels.wecom.agent_id > 0 {\n config.channels.wecom.agent_id.to_string()\n } else {\n \"1000002\".to_string()\n };\n let agent_id_str = prompt_line_with_default(\"WeCom agent_id\", &existing_agent)?;\n let agent_id = agent_id_str\n .trim()\n .parse::()\n .map_err(|_| anyhow!(\"agent_id must be an integer\"))?;\n config.channels.wecom.agent_id = agent_id;\n config.channels.wecom.enabled = true;\n }\n \"dingtalk\" => {\n let app_key = prompt_optional_with_existing(\n \"DingTalk app_key\",\n &config.channels.dingtalk.app_key,\n )?;\n let app_secret = prompt_optional_with_existing(\n \"DingTalk app_secret\",\n &config.channels.dingtalk.app_secret,\n )?;\n if !app_key.is_empty() {\n config.channels.dingtalk.app_key = app_key;\n }\n if !app_secret.is_empty() {\n config.channels.dingtalk.app_secret = app_secret;\n }\n if config.channels.dingtalk.app_key.trim().is_empty()\n || config.channels.dingtalk.app_secret.trim().is_empty()\n {\n bail!(\"DingTalk app_key and app_secret are required.\");\n }\n config.channels.dingtalk.enabled = true;\n }\n \"lark\" => {\n let app_id =\n prompt_optional_with_existing(\"Lark app_id\", &config.channels.lark.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"Lark app_secret\", &config.channels.lark.app_secret)?;\n if !app_id.is_empty() {\n config.channels.lark.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.lark.app_secret = app_secret;\n }\n if config.channels.lark.app_id.trim().is_empty()\n || config.channels.lark.app_secret.trim().is_empty()\n {\n bail!(\"Lark app_id and app_secret are required.\");\n }\n config.channels.lark.enabled = true;\n }\n _ => bail!(\"Unsupported channel '{}'\", channel),\n }\n\n Ok(())\n}\n\nfn prompt_provider(config: &Config) -> anyhow::Result {\n let current = primary_pool_entry(config)\n .map(|e| e.provider.clone())\n .or_else(|| {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| !e.provider.trim().is_empty())\n .map(|e| e.provider.clone())\n })\n .or_else(|| config.agents.defaults.provider.clone())\n .or_else(|| config.get_api_key().map(|(name, _)| name.to_string()))\n .unwrap_or_else(|| \"none\".to_string());\n\n println!(\"Configure LLM provider (current: {})\", current);\n let options = [\n \"deepseek (recommended)\",\n \"openai\",\n \"kimi\",\n \"anthropic\",\n \"gemini\",\n \"zhipu\",\n \"minimax\",\n \"ollama (local)\",\n \"skip\",\n ];\n let idx = prompt_select(\"Choose provider\", &options, 0)?;\n let mapped = match idx {\n 0 => \"deepseek\",\n 1 => \"openai\",\n 2 => \"kimi\",\n 3 => \"anthropic\",\n 4 => \"gemini\",\n 5 => \"zhipu\",\n 6 => \"minimax\",\n 7 => \"ollama\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn primary_pool_entry(config: &Config) -> Option<&ModelEntry> {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .min_by(|a, b| a.priority.cmp(&b.priority).then(b.weight.cmp(&a.weight)))\n}\n\nfn prompt_channel() -> anyhow::Result {\n let options = [\n \"skip (WebUI only)\",\n \"telegram\",\n \"feishu\",\n \"wecom\",\n \"dingtalk\",\n \"lark\",\n ];\n let idx = prompt_select(\"Configure one channel (optional)\", &options, 0)?;\n let mapped = match idx {\n 1 => \"telegram\",\n 2 => \"feishu\",\n 3 => \"wecom\",\n 4 => \"dingtalk\",\n 5 => \"lark\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn resolve_provider(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_provider(v)\n .ok_or_else(|| anyhow!(\"Unsupported provider '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn resolve_channel(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_channel(v)\n .ok_or_else(|| anyhow!(\"Unsupported channel '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn normalize_provider(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"deepseek\" => Some(\"deepseek\"),\n \"openai\" => Some(\"openai\"),\n \"kimi\" | \"moonshot\" => Some(\"kimi\"),\n \"anthropic\" | \"claude\" => Some(\"anthropic\"),\n \"gemini\" => Some(\"gemini\"),\n \"zhipu\" => Some(\"zhipu\"),\n \"minimax\" => Some(\"minimax\"),\n \"ollama\" => Some(\"ollama\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn normalize_channel(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"telegram\" => Some(\"telegram\"),\n \"feishu\" => Some(\"feishu\"),\n \"wecom\" | \"wechatwork\" => Some(\"wecom\"),\n \"dingtalk\" => Some(\"dingtalk\"),\n \"lark\" => Some(\"lark\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn default_model_for_provider(provider: &str) -> &'static str {\n match provider {\n \"deepseek\" => \"deepseek-chat\",\n \"openai\" => \"gpt-4o\",\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"kimi\" => \"moonshot-v1-8k\",\n \"gemini\" => \"gemini-2.0-flash\",\n \"zhipu\" => \"glm-4\",\n \"minimax\" => \"minimax-text-01\",\n \"ollama\" => \"llama3\",\n _ => \"gpt-4o\",\n }\n}\n\nfn default_api_base_for_provider(provider: &str) -> Option<&'static str> {\n match provider {\n \"deepseek\" => Some(\"https://api.deepseek.com/v1\"),\n \"openai\" => Some(\"https://api.openai.com/v1\"),\n \"anthropic\" => Some(\"https://api.anthropic.com\"),\n \"kimi\" => Some(\"https://api.moonshot.ai/v1\"),\n \"gemini\" => Some(\"https://generativelanguage.googleapis.com/v1beta/openai\"),\n \"zhipu\" => Some(\"https://open.bigmodel.cn/api/paas/v4\"),\n \"minimax\" => Some(\"https://api.minimax.chat/v1\"),\n \"ollama\" => Some(\"http://localhost:11434\"),\n _ => None,\n }\n}\n\nfn default_api_type_for_provider(provider: &str) -> &'static str {\n match provider {\n \"anthropic\" | \"minimax\" => \"anthropic\",\n \"gemini\" => \"openai\",\n \"ollama\" => \"ollama\",\n _ => \"openai\",\n }\n}\n\nfn prompt_select(title: &str, options: &[&str], default_index: usize) -> anyhow::Result {\n println!(\"{}\", title);\n for (i, opt) in options.iter().enumerate() {\n println!(\" {}. {}\", i + 1, opt);\n }\n\n loop {\n let input = prompt_line(&format!(\n \"Enter choice [1-{}] (default {}): \",\n options.len(),\n default_index + 1\n ))?;\n\n if input.trim().is_empty() {\n return Ok(default_index);\n }\n\n if let Ok(n) = input.trim().parse::() {\n if n >= 1 && n <= options.len() {\n return Ok(n - 1);\n }\n }\n println!(\"Invalid choice, please try again.\");\n }\n}\n\nfn prompt_optional_with_existing(label: &str, existing: &str) -> anyhow::Result {\n let prompt = if existing.trim().is_empty() {\n format!(\"{}: \", label)\n } else {\n format!(\"{} (press Enter to keep existing): \", label)\n };\n prompt_line(&prompt)\n}\n\nfn prompt_line_with_default(label: &str, default: &str) -> anyhow::Result {\n let input = prompt_line(&format!(\"{} [{}]: \", label, default))?;\n if input.trim().is_empty() {\n Ok(default.to_string())\n } else {\n Ok(input)\n }\n}\n\nfn prompt_line(prompt: &str) -> anyhow::Result {\n print!(\"{}\", prompt);\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin().read_line(&mut input)?;\n Ok(input.trim().to_string())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_provider_aliases() {\n assert_eq!(normalize_provider(\"moonshot\"), Some(\"kimi\"));\n assert_eq!(normalize_provider(\"claude\"), Some(\"anthropic\"));\n assert_eq!(normalize_provider(\"zhipu\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"minimax\"), Some(\"minimax\"));\n assert_eq!(normalize_provider(\"none\"), Some(\"skip\"));\n assert_eq!(normalize_provider(\"unknown\"), None);\n }\n\n #[test]\n fn test_normalize_channel_aliases() {\n assert_eq!(normalize_channel(\"wechatwork\"), Some(\"wecom\"));\n assert_eq!(normalize_channel(\"dingtalk\"), Some(\"dingtalk\"));\n assert_eq!(normalize_channel(\"skip\"), Some(\"skip\"));\n assert_eq!(normalize_channel(\"unknown\"), None);\n }\n\n #[test]\n fn test_default_model() {\n assert_eq!(default_model_for_provider(\"deepseek\"), \"deepseek-chat\");\n assert_eq!(default_model_for_provider(\"openai\"), \"gpt-4o\");\n assert_eq!(default_model_for_provider(\"kimi\"), \"moonshot-v1-8k\");\n assert_eq!(default_model_for_provider(\"zhipu\"), \"glm-4\");\n assert_eq!(default_model_for_provider(\"minimax\"), \"minimax-text-01\");\n }\n\n #[test]\n fn test_configure_provider_populates_legacy_single_model_fields_for_compat() {\n let mut config = Config::default();\n config.agents.defaults.model = \"legacy-model\".to_string();\n config.agents.defaults.provider = Some(\"legacy-provider\".to_string());\n\n configure_provider(\n &mut config,\n \"deepseek\",\n Some(\"test-deepseek-key\"),\n Some(\"deepseek-chat\"),\n )\n .expect(\"configure_provider should succeed\");\n\n assert_eq!(config.agents.defaults.provider, Some(\"deepseek\".to_string()));\n assert_eq!(config.agents.defaults.model, \"deepseek-chat\");\n assert_eq!(config.agents.defaults.model_pool.len(), 1);\n let entry = &config.agents.defaults.model_pool[0];\n assert_eq!(entry.provider, \"deepseek\");\n assert_eq!(entry.model, \"deepseek-chat\");\n assert_eq!(entry.weight, 1);\n assert_eq!(entry.priority, 1);\n }\n}\n"}, "9a5ff122a3f913415d87b52bf73321403e8f202e": {"t": 1786067643.359642, "n": 26851, "h": "3bd680b953693936", "c": "use anyhow::{anyhow, bail, Context};\nuse blockcell_core::config::{ModelEntry, ToolCallMode};\nuse blockcell_core::{Config, Paths};\nuse std::io::{self, Write};\n\nuse super::provider_defaults::default_model_for_provider;\n\n/// Interactive first-run setup wizard for provider + optional channel.\npub async fn run(\n force: bool,\n provider: Option,\n api_key: Option,\n model: Option,\n channel: Option,\n skip_provider_test: bool,\n) -> anyhow::Result<()> {\n let paths = Paths::new();\n paths.ensure_dirs()?;\n\n let config_path = paths.config_file();\n let mut config = if config_path.exists() && !force {\n Config::load(&config_path)\n .with_context(|| format!(\"Failed to load {}\", config_path.display()))?\n } else {\n if config_path.exists() && force {\n println!(\"⚠ --force enabled: reset existing config to defaults before setup.\");\n }\n Config::default()\n };\n\n if config.intent_router.is_none() {\n config.intent_router = Config::default().intent_router;\n }\n\n println!(\"blockcell setup\");\n println!(\"==============\");\n println!(\"Config file: {}\", config_path.display());\n println!();\n\n let selected_provider = resolve_provider(provider.as_deref())?;\n let provider_name = if let Some(name) = selected_provider {\n name.to_string()\n } else {\n prompt_provider(&config)?\n };\n\n if provider_name != \"skip\" {\n configure_provider(\n &mut config,\n &provider_name,\n api_key.as_deref(),\n model.as_deref(),\n )?;\n } else {\n println!(\"Skipped LLM provider setup.\");\n }\n\n let selected_channel = resolve_channel(channel.as_deref())?;\n let channel_name = if let Some(name) = selected_channel {\n name.to_string()\n } else {\n prompt_channel()?\n };\n\n if channel_name != \"skip\" {\n configure_channel(&mut config, &channel_name)?;\n ensure_channel_owner(&mut config, &channel_name);\n } else {\n println!(\"Skipped channel setup (WebUI only).\");\n }\n\n config\n .save(&config_path)\n .with_context(|| format!(\"Failed to save {}\", config_path.display()))?;\n\n println!();\n println!(\"✓ Setup completed\");\n println!(\" Config: {}\", config_path.display());\n if provider_name != \"skip\" {\n let selected = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider_name)\n .or_else(|| primary_pool_entry(&config));\n let selected_model = selected\n .map(|e| e.model.as_str())\n .unwrap_or(config.agents.defaults.model.as_str());\n println!(\" Provider: {} Model: {}\", provider_name, selected_model);\n if !skip_provider_test {\n match blockcell_providers::create_provider(\n &config,\n selected_model,\n Some(&provider_name),\n ) {\n Ok(_) => println!(\" ✓ Provider config validation passed\"),\n Err(e) => println!(\" ⚠ Provider validation failed: {}\", e),\n }\n }\n }\n if channel_name != \"skip\" {\n println!(\" Channel: {} (enabled)\", channel_name);\n } else {\n println!(\" Channel: none (use WebUI only)\");\n }\n println!();\n println!(\"Next steps:\");\n println!(\" 1. blockcell status\");\n println!(\" 2. blockcell gateway\");\n println!(\" 3. Open WebUI: http://localhost:18791/\");\n\n Ok(())\n}\n\nfn configure_provider(\n config: &mut Config,\n provider: &str,\n api_key_flag: Option<&str>,\n model_flag: Option<&str>,\n) -> anyhow::Result<()> {\n let entry = config.providers.entry(provider.to_string()).or_default();\n\n if entry.api_base.is_none() {\n entry.api_base = default_api_base_for_provider(provider).map(|s| s.to_string());\n }\n if entry.api_type.is_empty() {\n entry.api_type = default_api_type_for_provider(provider).to_string();\n }\n\n if provider == \"ollama\" {\n if entry.api_key.trim().is_empty() {\n entry.api_key = \"ollama\".to_string();\n }\n if entry.api_base.is_none() {\n entry.api_base = Some(\"http://localhost:11434\".to_string());\n }\n } else {\n let has_existing = !entry.api_key.trim().is_empty() && entry.api_key != \"dummy\";\n let final_key = if let Some(flag) = api_key_flag {\n flag.trim().to_string()\n } else if has_existing {\n prompt_line(\"API key (press Enter to keep existing): \")?\n } else {\n prompt_line(\"API key: \")?\n };\n\n if !final_key.is_empty() {\n entry.api_key = final_key;\n }\n\n if entry.api_key.trim().is_empty() || entry.api_key == \"dummy\" {\n bail!(\"Provider '{}' requires a valid API key.\", provider);\n }\n }\n\n let suggested_model = if let Some(entry) = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider)\n {\n entry.model.clone()\n } else {\n default_model_for_provider(provider).to_string()\n };\n\n let final_model = if let Some(m) = model_flag {\n m.trim().to_string()\n } else {\n prompt_line_with_default(\"Model\", &suggested_model)?\n };\n if final_model.trim().is_empty() {\n bail!(\"Model cannot be empty.\");\n }\n\n // Setup writes provider/model to model_pool first, and also mirrors to legacy\n // single-model fields for backward compatibility with older runtimes.\n config.agents.defaults.model_pool = vec![ModelEntry {\n model: final_model.clone(),\n provider: provider.to_string(),\n weight: 1,\n priority: 1,\n input_price: None,\n output_price: None,\n temperature: None,\n tool_call_mode: ToolCallMode::Native,\n }];\n config.agents.defaults.provider = Some(provider.to_string());\n config.agents.defaults.model = final_model;\n\n Ok(())\n}\n\nfn configure_channel(config: &mut Config, channel: &str) -> anyhow::Result<()> {\n match channel {\n \"telegram\" => {\n let existing = config.channels.telegram.token.clone();\n let prompt = if existing.trim().is_empty() {\n \"Telegram bot token: \"\n } else {\n \"Telegram bot token (press Enter to keep existing): \"\n };\n let token = prompt_line(prompt)?;\n if !token.is_empty() {\n config.channels.telegram.token = token;\n }\n if config.channels.telegram.token.trim().is_empty() {\n bail!(\"Telegram token is required.\");\n }\n config.channels.telegram.enabled = true;\n }\n \"feishu\" => {\n let app_id =\n prompt_optional_with_existing(\"Feishu app_id\", &config.channels.feishu.app_id)?;\n let app_secret = prompt_optional_with_existing(\n \"Feishu app_secret\",\n &config.channels.feishu.app_secret,\n )?;\n\n if !app_id.is_empty() {\n config.channels.feishu.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.feishu.app_secret = app_secret;\n }\n\n if config.channels.feishu.app_id.trim().is_empty()\n || config.channels.feishu.app_secret.trim().is_empty()\n {\n bail!(\"Feishu app_id and app_secret are required.\");\n }\n config.channels.feishu.enabled = true;\n }\n \"wecom\" => {\n let corp_id =\n prompt_optional_with_existing(\"WeCom corp_id\", &config.channels.wecom.corp_id)?;\n let corp_secret = prompt_optional_with_existing(\n \"WeCom corp_secret\",\n &config.channels.wecom.corp_secret,\n )?;\n\n if !corp_id.is_empty() {\n config.channels.wecom.corp_id = corp_id;\n }\n if !corp_secret.is_empty() {\n config.channels.wecom.corp_secret = corp_secret;\n }\n\n if config.channels.wecom.corp_id.trim().is_empty()\n || config.channels.wecom.corp_secret.trim().is_empty()\n {\n bail!(\"WeCom corp_id and corp_secret are required.\");\n }\n\n let existing_agent = if config.channels.wecom.agent_id > 0 {\n config.channels.wecom.agent_id.to_string()\n } else {\n \"1000002\".to_string()\n };\n let agent_id_str = prompt_line_with_default(\"WeCom agent_id\", &existing_agent)?;\n let agent_id = agent_id_str\n .trim()\n .parse::()\n .map_err(|_| anyhow!(\"agent_id must be an integer\"))?;\n config.channels.wecom.agent_id = agent_id;\n config.channels.wecom.enabled = true;\n }\n \"dingtalk\" => {\n let app_key = prompt_optional_with_existing(\n \"DingTalk app_key\",\n &config.channels.dingtalk.app_key,\n )?;\n let app_secret = prompt_optional_with_existing(\n \"DingTalk app_secret\",\n &config.channels.dingtalk.app_secret,\n )?;\n if !app_key.is_empty() {\n config.channels.dingtalk.app_key = app_key;\n }\n if !app_secret.is_empty() {\n config.channels.dingtalk.app_secret = app_secret;\n }\n if config.channels.dingtalk.app_key.trim().is_empty()\n || config.channels.dingtalk.app_secret.trim().is_empty()\n {\n bail!(\"DingTalk app_key and app_secret are required.\");\n }\n config.channels.dingtalk.enabled = true;\n }\n \"lark\" => {\n let app_id =\n prompt_optional_with_existing(\"Lark app_id\", &config.channels.lark.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"Lark app_secret\", &config.channels.lark.app_secret)?;\n if !app_id.is_empty() {\n config.channels.lark.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.lark.app_secret = app_secret;\n }\n if config.channels.lark.app_id.trim().is_empty()\n || config.channels.lark.app_secret.trim().is_empty()\n {\n bail!(\"Lark app_id and app_secret are required.\");\n }\n config.channels.lark.enabled = true;\n }\n \"qq\" => {\n let app_id = prompt_optional_with_existing(\"QQ app_id\", &config.channels.qq.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"QQ app_secret\", &config.channels.qq.app_secret)?;\n if !app_id.is_empty() {\n config.channels.qq.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.qq.app_secret = app_secret;\n }\n if config.channels.qq.app_id.trim().is_empty()\n || config.channels.qq.app_secret.trim().is_empty()\n {\n bail!(\"QQ app_id and app_secret are required.\");\n }\n let environment = prompt_optional_with_existing(\n \"QQ environment (production/sandbox)\",\n &config.channels.qq.environment,\n )?;\n if !environment.is_empty() {\n config.channels.qq.environment = environment;\n }\n if config.channels.qq.environment.trim().is_empty() {\n config.channels.qq.environment = \"production\".to_string();\n }\n config.channels.qq.enabled = true;\n }\n \"napcat\" => {\n let mode_options = [\n \"ws-client (BlockCell connects to NapCatQQ WebSocket server)\",\n \"ws-server (NapCatQQ connects to BlockCell WebSocket server)\",\n ];\n let mode_idx = prompt_select(\"Connection mode\", &mode_options, 0)?;\n let mode = match mode_idx {\n 0 => \"ws-client\",\n 1 => \"ws-server\",\n _ => \"ws-client\",\n }\n .to_string();\n\n // WebSocket URL (only for ws-client mode)\n let ws_url = if mode == \"ws-client\" {\n prompt_line_with_default(\"WebSocket URL\", \"ws://127.0.0.1:3001\")?\n } else {\n String::new()\n };\n\n // Server configuration for ws-server mode\n let (server_host, server_port, server_path) = if mode == \"ws-server\" {\n let host = prompt_line_with_default(\"WebSocket server host\", \"0.0.0.0\")?;\n // NapCatQQ client 默认连接 ws://localhost:13005\n let port_str = prompt_line_with_default(\"WebSocket server port\", \"13005\")?;\n let port = port_str.parse::().unwrap_or(13005);\n // NapCatQQ client 默认连接根路径 /\n let path = prompt_line_with_default(\"WebSocket server path\", \"/\")?;\n (host, port, path)\n } else {\n (String::new(), 0, String::new())\n };\n\n let access_token = prompt_line(\"Access token (optional, press Enter to skip)\")?;\n\n // Group response mode\n let mode_options = [\n \"all - 响应所有群聊消息\",\n \"at_only - 仅响应@我的群聊消息\",\n \"none - 不响应任何群聊消息\",\n ];\n let mode_idx = prompt_select(\"群聊响应模式\", &mode_options, 0)?;\n let group_response_mode = match mode_idx {\n 0 => \"all\",\n 1 => \"at_only\",\n 2 => \"none\",\n _ => \"all\",\n }\n .to_string();\n\n config.channels.napcat.enabled = true;\n config.channels.napcat.mode = mode;\n config.channels.napcat.ws_url = ws_url;\n config.channels.napcat.server_host = server_host;\n config.channels.napcat.server_port = server_port;\n config.channels.napcat.server_path = server_path;\n config.channels.napcat.access_token = access_token;\n config.channels.napcat.group_response_mode = group_response_mode;\n\n // 媒体自动下载配置\n let auto_download_options = [\n \"是 - 自动下载图片、语音、视频、文件等媒体\",\n \"否 - 不自动下载媒体\",\n ];\n let auto_idx = prompt_select(\"是否自动下载媒体文件\", &auto_download_options, 0)?;\n config.channels.napcat.auto_download_media = auto_idx == 0;\n\n if config.channels.napcat.auto_download_media {\n let max_size_str = prompt_line_with_default(\"最大自动下载大小 (字节)\", \"10485760\")?;\n if let Ok(max_size) = max_size_str.parse::() {\n config.channels.napcat.max_auto_download_size = max_size;\n }\n\n let download_dir =\n prompt_line_with_default(\"媒体下载目录 (相对于 workspace)\", \"downloads\")?;\n if !download_dir.trim().is_empty() {\n config.channels.napcat.media_download_dir = download_dir;\n }\n }\n }\n _ => bail!(\"Unsupported channel '{}'\", channel),\n }\n\n Ok(())\n}\n\nfn ensure_channel_owner(config: &mut Config, channel: &str) {\n if config.resolve_channel_owner(channel).is_some() {\n return;\n }\n let owner = config\n .known_agent_ids()\n .into_iter()\n .next()\n .unwrap_or_else(|| \"default\".to_string());\n config\n .channel_owners\n .insert(channel.to_string(), owner.clone());\n println!(\"Assigned channel owner: {} -> {}\", channel, owner);\n}\n\nfn prompt_provider(config: &Config) -> anyhow::Result {\n let current = primary_pool_entry(config)\n .map(|e| e.provider.clone())\n .or_else(|| {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| !e.provider.trim().is_empty())\n .map(|e| e.provider.clone())\n })\n .or_else(|| config.agents.defaults.provider.clone())\n .or_else(|| config.get_api_key().map(|(name, _)| name.to_string()))\n .unwrap_or_else(|| \"none\".to_string());\n\n println!(\"Configure LLM provider (current: {})\", current);\n let options = [\n \"deepseek (recommended)\",\n \"openai\",\n \"kimi\",\n \"anthropic\",\n \"gemini\",\n \"zhipu\",\n \"qwen\",\n \"xai\",\n \"mistral\",\n \"minimax\",\n \"groq\",\n \"siliconflow\",\n \"openrouter\",\n \"ollama (local)\",\n \"skip\",\n ];\n let idx = prompt_select(\"Choose provider\", &options, 0)?;\n let mapped = match idx {\n 0 => \"deepseek\",\n 1 => \"openai\",\n 2 => \"kimi\",\n 3 => \"anthropic\",\n 4 => \"gemini\",\n 5 => \"zhipu\",\n 6 => \"qwen\",\n 7 => \"xai\",\n 8 => \"mistral\",\n 9 => \"minimax\",\n 10 => \"groq\",\n 11 => \"siliconflow\",\n 12 => \"openrouter\",\n 13 => \"ollama\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn primary_pool_entry(config: &Config) -> Option<&ModelEntry> {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .min_by(|a, b| a.priority.cmp(&b.priority).then(b.weight.cmp(&a.weight)))\n}\n\nfn prompt_channel() -> anyhow::Result {\n let options = [\n \"skip (WebUI only)\",\n \"telegram\",\n \"feishu\",\n \"wecom\",\n \"dingtalk\",\n \"lark\",\n \"qq\",\n \"napcat\",\n ];\n let idx = prompt_select(\"Configure one channel (optional)\", &options, 0)?;\n let mapped = match idx {\n 1 => \"telegram\",\n 2 => \"feishu\",\n 3 => \"wecom\",\n 4 => \"dingtalk\",\n 5 => \"lark\",\n 6 => \"qq\",\n 7 => \"napcat\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn resolve_provider(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_provider(v)\n .ok_or_else(|| anyhow!(\"Unsupported provider '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn resolve_channel(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_channel(v)\n .ok_or_else(|| anyhow!(\"Unsupported channel '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn normalize_provider(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"deepseek\" => Some(\"deepseek\"),\n \"openai\" => Some(\"openai\"),\n \"kimi\" | \"moonshot\" => Some(\"kimi\"),\n \"anthropic\" | \"claude\" => Some(\"anthropic\"),\n \"gemini\" => Some(\"gemini\"),\n \"zhipu\" | \"glm\" | \"zai\" | \"z.ai\" => Some(\"zhipu\"),\n \"qwen\" | \"tongyi\" | \"dashscope\" => Some(\"qwen\"),\n \"xai\" | \"grok\" => Some(\"xai\"),\n \"mistral\" => Some(\"mistral\"),\n \"minimax\" => Some(\"minimax\"),\n \"groq\" => Some(\"groq\"),\n \"siliconflow\" => Some(\"siliconflow\"),\n \"openrouter\" => Some(\"openrouter\"),\n \"ollama\" => Some(\"ollama\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn normalize_channel(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"telegram\" => Some(\"telegram\"),\n \"feishu\" => Some(\"feishu\"),\n \"wecom\" | \"wechatwork\" => Some(\"wecom\"),\n \"dingtalk\" => Some(\"dingtalk\"),\n \"lark\" => Some(\"lark\"),\n \"qq\" => Some(\"qq\"),\n \"napcat\" | \"napcatqq\" => Some(\"napcat\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn default_api_base_for_provider(provider: &str) -> Option<&'static str> {\n match provider {\n \"deepseek\" => Some(\"https://api.deepseek.com/v1\"),\n \"openai\" => Some(\"https://api.openai.com/v1\"),\n \"anthropic\" => Some(\"https://api.anthropic.com\"),\n \"kimi\" => Some(\"https://api.moonshot.cn/v1\"),\n \"gemini\" => Some(\"https://generativelanguage.googleapis.com/v1beta/openai\"),\n \"zhipu\" | \"glm\" => Some(\"https://open.bigmodel.cn/api/paas/v4\"),\n \"minimax\" => Some(\"https://api.minimaxi.com/v1\"),\n \"groq\" => Some(\"https://api.groq.com/openai/v1\"),\n \"siliconflow\" => Some(\"https://api.siliconflow.cn/v1\"),\n \"openrouter\" => Some(\"https://openrouter.ai/api/v1\"),\n \"qwen\" => Some(\"https://api.qwen.ai/v1\"),\n \"xai\" => Some(\"https://api.x.ai/v1\"),\n \"mistral\" => Some(\"https://api.mistral.ai/v1\"),\n \"ollama\" => Some(\"http://localhost:11434\"),\n _ => None,\n }\n}\n\nfn default_api_type_for_provider(provider: &str) -> &'static str {\n match provider {\n \"anthropic\" | \"minimax\" => \"anthropic\",\n \"gemini\" => \"openai\",\n \"ollama\" => \"ollama\",\n _ => \"openai\",\n }\n}\n\nfn prompt_select(title: &str, options: &[&str], default_index: usize) -> anyhow::Result {\n println!(\"{}\", title);\n for (i, opt) in options.iter().enumerate() {\n println!(\" {}. {}\", i + 1, opt);\n }\n\n loop {\n let input = prompt_line(&format!(\n \"Enter choice [1-{}] (default {}): \",\n options.len(),\n default_index + 1\n ))?;\n\n if input.trim().is_empty() {\n return Ok(default_index);\n }\n\n if let Ok(n) = input.trim().parse::() {\n if n >= 1 && n <= options.len() {\n return Ok(n - 1);\n }\n }\n println!(\"Invalid choice, please try again.\");\n }\n}\n\nfn prompt_optional_with_existing(label: &str, existing: &str) -> anyhow::Result {\n let prompt = if existing.trim().is_empty() {\n format!(\"{}: \", label)\n } else {\n format!(\"{} (press Enter to keep existing): \", label)\n };\n prompt_line(&prompt)\n}\n\nfn prompt_line_with_default(label: &str, default: &str) -> anyhow::Result {\n let input = prompt_line(&format!(\"{} [{}]: \", label, default))?;\n if input.trim().is_empty() {\n Ok(default.to_string())\n } else {\n Ok(input)\n }\n}\n\nfn prompt_line(prompt: &str) -> anyhow::Result {\n print!(\"{}\", prompt);\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin().read_line(&mut input)?;\n Ok(input.trim().to_string())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_provider_aliases() {\n assert_eq!(normalize_provider(\"moonshot\"), Some(\"kimi\"));\n assert_eq!(normalize_provider(\"claude\"), Some(\"anthropic\"));\n assert_eq!(normalize_provider(\"zhipu\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"glm\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"qwen\"), Some(\"qwen\"));\n assert_eq!(normalize_provider(\"grok\"), Some(\"xai\"));\n assert_eq!(normalize_provider(\"mistral\"), Some(\"mistral\"));\n assert_eq!(normalize_provider(\"minimax\"), Some(\"minimax\"));\n assert_eq!(normalize_provider(\"groq\"), Some(\"groq\"));\n assert_eq!(normalize_provider(\"siliconflow\"), Some(\"siliconflow\"));\n assert_eq!(normalize_provider(\"openrouter\"), Some(\"openrouter\"));\n assert_eq!(normalize_provider(\"none\"), Some(\"skip\"));\n assert_eq!(normalize_provider(\"unknown\"), None);\n }\n\n #[test]\n fn test_normalize_channel_aliases() {\n assert_eq!(normalize_channel(\"wechatwork\"), Some(\"wecom\"));\n assert_eq!(normalize_channel(\"dingtalk\"), Some(\"dingtalk\"));\n assert_eq!(normalize_channel(\"qq\"), Some(\"qq\"));\n assert_eq!(normalize_channel(\"napcat\"), Some(\"napcat\"));\n assert_eq!(normalize_channel(\"napcatqq\"), Some(\"napcat\"));\n assert_eq!(normalize_channel(\"skip\"), Some(\"skip\"));\n assert_eq!(normalize_channel(\"unknown\"), None);\n }\n\n #[test]\n fn test_default_model() {\n assert_eq!(default_model_for_provider(\"deepseek\"), \"deepseek-v4-pro\");\n assert_eq!(default_model_for_provider(\"openai\"), \"gpt-5.5\");\n assert_eq!(default_model_for_provider(\"anthropic\"), \"claude-opus-4-8\");\n assert_eq!(default_model_for_provider(\"kimi\"), \"kimi-k2.6\");\n assert_eq!(default_model_for_provider(\"moonshot\"), \"kimi-k2.6\");\n assert_eq!(\n default_model_for_provider(\"gemini\"),\n \"gemini-3.1-pro-preview\"\n );\n assert_eq!(default_model_for_provider(\"zhipu\"), \"glm-5.2\");\n assert_eq!(default_model_for_provider(\"glm\"), \"glm-5.2\");\n assert_eq!(default_model_for_provider(\"qwen\"), \"qwen3.7-max\");\n assert_eq!(default_model_for_provider(\"xai\"), \"grok-4.3\");\n assert_eq!(default_model_for_provider(\"mistral\"), \"mistral-medium-2604\");\n assert_eq!(default_model_for_provider(\"minimax\"), \"MiniMax-M3\");\n assert_eq!(default_model_for_provider(\"groq\"), \"openai/gpt-oss-120b\");\n assert_eq!(\n default_model_for_provider(\"siliconflow\"),\n \"deepseek-ai/DeepSeek-V4-Pro\"\n );\n assert_eq!(default_model_for_provider(\"openrouter\"), \"openai/gpt-5.5\");\n assert_eq!(default_model_for_provider(\"ollama\"), \"qwen3.6\");\n }\n\n #[test]\n fn test_configure_provider_populates_legacy_single_model_fields_for_compat() {\n let mut config = Config::default();\n config.agents.defaults.model = \"legacy-model\".to_string();\n config.agents.defaults.provider = Some(\"legacy-provider\".to_string());\n\n configure_provider(\n &mut config,\n \"deepseek\",\n Some(\"test-deepseek-key\"),\n Some(\"deepseek-chat\"),\n )\n .expect(\"configure_provider should succeed\");\n\n assert_eq!(\n config.agents.defaults.provider,\n Some(\"deepseek\".to_string())\n );\n assert_eq!(config.agents.defaults.model, \"deepseek-chat\");\n assert_eq!(config.agents.defaults.model_pool.len(), 1);\n let entry = &config.agents.defaults.model_pool[0];\n assert_eq!(entry.provider, \"deepseek\");\n assert_eq!(entry.model, \"deepseek-chat\");\n assert_eq!(entry.weight, 1);\n assert_eq!(entry.priority, 1);\n }\n\n #[test]\n fn test_ensure_channel_owner_defaults_to_default_agent() {\n let mut config = Config::default();\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"default\")\n );\n }\n\n #[test]\n fn test_ensure_channel_owner_keeps_existing() {\n let mut config = Config::default();\n config\n .channel_owners\n .insert(\"telegram\".to_string(), \"ops\".to_string());\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"ops\")\n );\n }\n}\n"}, "27f4d4f01e2c743986e0723b71f12f7bbbdde4d4": {"t": 1786068394.1179678, "n": 26851, "h": "3bd680b953693936", "c": "use anyhow::{anyhow, bail, Context};\nuse blockcell_core::config::{ModelEntry, ToolCallMode};\nuse blockcell_core::{Config, Paths};\nuse std::io::{self, Write};\n\nuse super::provider_defaults::default_model_for_provider;\n\n/// Interactive first-run setup wizard for provider + optional channel.\npub async fn run(\n force: bool,\n provider: Option,\n api_key: Option,\n model: Option,\n channel: Option,\n skip_provider_test: bool,\n) -> anyhow::Result<()> {\n let paths = Paths::new();\n paths.ensure_dirs()?;\n\n let config_path = paths.config_file();\n let mut config = if config_path.exists() && !force {\n Config::load(&config_path)\n .with_context(|| format!(\"Failed to load {}\", config_path.display()))?\n } else {\n if config_path.exists() && force {\n println!(\"⚠ --force enabled: reset existing config to defaults before setup.\");\n }\n Config::default()\n };\n\n if config.intent_router.is_none() {\n config.intent_router = Config::default().intent_router;\n }\n\n println!(\"blockcell setup\");\n println!(\"==============\");\n println!(\"Config file: {}\", config_path.display());\n println!();\n\n let selected_provider = resolve_provider(provider.as_deref())?;\n let provider_name = if let Some(name) = selected_provider {\n name.to_string()\n } else {\n prompt_provider(&config)?\n };\n\n if provider_name != \"skip\" {\n configure_provider(\n &mut config,\n &provider_name,\n api_key.as_deref(),\n model.as_deref(),\n )?;\n } else {\n println!(\"Skipped LLM provider setup.\");\n }\n\n let selected_channel = resolve_channel(channel.as_deref())?;\n let channel_name = if let Some(name) = selected_channel {\n name.to_string()\n } else {\n prompt_channel()?\n };\n\n if channel_name != \"skip\" {\n configure_channel(&mut config, &channel_name)?;\n ensure_channel_owner(&mut config, &channel_name);\n } else {\n println!(\"Skipped channel setup (WebUI only).\");\n }\n\n config\n .save(&config_path)\n .with_context(|| format!(\"Failed to save {}\", config_path.display()))?;\n\n println!();\n println!(\"✓ Setup completed\");\n println!(\" Config: {}\", config_path.display());\n if provider_name != \"skip\" {\n let selected = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider_name)\n .or_else(|| primary_pool_entry(&config));\n let selected_model = selected\n .map(|e| e.model.as_str())\n .unwrap_or(config.agents.defaults.model.as_str());\n println!(\" Provider: {} Model: {}\", provider_name, selected_model);\n if !skip_provider_test {\n match blockcell_providers::create_provider(\n &config,\n selected_model,\n Some(&provider_name),\n ) {\n Ok(_) => println!(\" ✓ Provider config validation passed\"),\n Err(e) => println!(\" ⚠ Provider validation failed: {}\", e),\n }\n }\n }\n if channel_name != \"skip\" {\n println!(\" Channel: {} (enabled)\", channel_name);\n } else {\n println!(\" Channel: none (use WebUI only)\");\n }\n println!();\n println!(\"Next steps:\");\n println!(\" 1. blockcell status\");\n println!(\" 2. blockcell gateway\");\n println!(\" 3. Open WebUI: http://localhost:18791/\");\n\n Ok(())\n}\n\nfn configure_provider(\n config: &mut Config,\n provider: &str,\n api_key_flag: Option<&str>,\n model_flag: Option<&str>,\n) -> anyhow::Result<()> {\n let entry = config.providers.entry(provider.to_string()).or_default();\n\n if entry.api_base.is_none() {\n entry.api_base = default_api_base_for_provider(provider).map(|s| s.to_string());\n }\n if entry.api_type.is_empty() {\n entry.api_type = default_api_type_for_provider(provider).to_string();\n }\n\n if provider == \"ollama\" {\n if entry.api_key.trim().is_empty() {\n entry.api_key = \"ollama\".to_string();\n }\n if entry.api_base.is_none() {\n entry.api_base = Some(\"http://localhost:11434\".to_string());\n }\n } else {\n let has_existing = !entry.api_key.trim().is_empty() && entry.api_key != \"dummy\";\n let final_key = if let Some(flag) = api_key_flag {\n flag.trim().to_string()\n } else if has_existing {\n prompt_line(\"API key (press Enter to keep existing): \")?\n } else {\n prompt_line(\"API key: \")?\n };\n\n if !final_key.is_empty() {\n entry.api_key = final_key;\n }\n\n if entry.api_key.trim().is_empty() || entry.api_key == \"dummy\" {\n bail!(\"Provider '{}' requires a valid API key.\", provider);\n }\n }\n\n let suggested_model = if let Some(entry) = config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| e.provider == provider)\n {\n entry.model.clone()\n } else {\n default_model_for_provider(provider).to_string()\n };\n\n let final_model = if let Some(m) = model_flag {\n m.trim().to_string()\n } else {\n prompt_line_with_default(\"Model\", &suggested_model)?\n };\n if final_model.trim().is_empty() {\n bail!(\"Model cannot be empty.\");\n }\n\n // Setup writes provider/model to model_pool first, and also mirrors to legacy\n // single-model fields for backward compatibility with older runtimes.\n config.agents.defaults.model_pool = vec![ModelEntry {\n model: final_model.clone(),\n provider: provider.to_string(),\n weight: 1,\n priority: 1,\n input_price: None,\n output_price: None,\n temperature: None,\n tool_call_mode: ToolCallMode::Native,\n }];\n config.agents.defaults.provider = Some(provider.to_string());\n config.agents.defaults.model = final_model;\n\n Ok(())\n}\n\nfn configure_channel(config: &mut Config, channel: &str) -> anyhow::Result<()> {\n match channel {\n \"telegram\" => {\n let existing = config.channels.telegram.token.clone();\n let prompt = if existing.trim().is_empty() {\n \"Telegram bot token: \"\n } else {\n \"Telegram bot token (press Enter to keep existing): \"\n };\n let token = prompt_line(prompt)?;\n if !token.is_empty() {\n config.channels.telegram.token = token;\n }\n if config.channels.telegram.token.trim().is_empty() {\n bail!(\"Telegram token is required.\");\n }\n config.channels.telegram.enabled = true;\n }\n \"feishu\" => {\n let app_id =\n prompt_optional_with_existing(\"Feishu app_id\", &config.channels.feishu.app_id)?;\n let app_secret = prompt_optional_with_existing(\n \"Feishu app_secret\",\n &config.channels.feishu.app_secret,\n )?;\n\n if !app_id.is_empty() {\n config.channels.feishu.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.feishu.app_secret = app_secret;\n }\n\n if config.channels.feishu.app_id.trim().is_empty()\n || config.channels.feishu.app_secret.trim().is_empty()\n {\n bail!(\"Feishu app_id and app_secret are required.\");\n }\n config.channels.feishu.enabled = true;\n }\n \"wecom\" => {\n let corp_id =\n prompt_optional_with_existing(\"WeCom corp_id\", &config.channels.wecom.corp_id)?;\n let corp_secret = prompt_optional_with_existing(\n \"WeCom corp_secret\",\n &config.channels.wecom.corp_secret,\n )?;\n\n if !corp_id.is_empty() {\n config.channels.wecom.corp_id = corp_id;\n }\n if !corp_secret.is_empty() {\n config.channels.wecom.corp_secret = corp_secret;\n }\n\n if config.channels.wecom.corp_id.trim().is_empty()\n || config.channels.wecom.corp_secret.trim().is_empty()\n {\n bail!(\"WeCom corp_id and corp_secret are required.\");\n }\n\n let existing_agent = if config.channels.wecom.agent_id > 0 {\n config.channels.wecom.agent_id.to_string()\n } else {\n \"1000002\".to_string()\n };\n let agent_id_str = prompt_line_with_default(\"WeCom agent_id\", &existing_agent)?;\n let agent_id = agent_id_str\n .trim()\n .parse::()\n .map_err(|_| anyhow!(\"agent_id must be an integer\"))?;\n config.channels.wecom.agent_id = agent_id;\n config.channels.wecom.enabled = true;\n }\n \"dingtalk\" => {\n let app_key = prompt_optional_with_existing(\n \"DingTalk app_key\",\n &config.channels.dingtalk.app_key,\n )?;\n let app_secret = prompt_optional_with_existing(\n \"DingTalk app_secret\",\n &config.channels.dingtalk.app_secret,\n )?;\n if !app_key.is_empty() {\n config.channels.dingtalk.app_key = app_key;\n }\n if !app_secret.is_empty() {\n config.channels.dingtalk.app_secret = app_secret;\n }\n if config.channels.dingtalk.app_key.trim().is_empty()\n || config.channels.dingtalk.app_secret.trim().is_empty()\n {\n bail!(\"DingTalk app_key and app_secret are required.\");\n }\n config.channels.dingtalk.enabled = true;\n }\n \"lark\" => {\n let app_id =\n prompt_optional_with_existing(\"Lark app_id\", &config.channels.lark.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"Lark app_secret\", &config.channels.lark.app_secret)?;\n if !app_id.is_empty() {\n config.channels.lark.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.lark.app_secret = app_secret;\n }\n if config.channels.lark.app_id.trim().is_empty()\n || config.channels.lark.app_secret.trim().is_empty()\n {\n bail!(\"Lark app_id and app_secret are required.\");\n }\n config.channels.lark.enabled = true;\n }\n \"qq\" => {\n let app_id = prompt_optional_with_existing(\"QQ app_id\", &config.channels.qq.app_id)?;\n let app_secret =\n prompt_optional_with_existing(\"QQ app_secret\", &config.channels.qq.app_secret)?;\n if !app_id.is_empty() {\n config.channels.qq.app_id = app_id;\n }\n if !app_secret.is_empty() {\n config.channels.qq.app_secret = app_secret;\n }\n if config.channels.qq.app_id.trim().is_empty()\n || config.channels.qq.app_secret.trim().is_empty()\n {\n bail!(\"QQ app_id and app_secret are required.\");\n }\n let environment = prompt_optional_with_existing(\n \"QQ environment (production/sandbox)\",\n &config.channels.qq.environment,\n )?;\n if !environment.is_empty() {\n config.channels.qq.environment = environment;\n }\n if config.channels.qq.environment.trim().is_empty() {\n config.channels.qq.environment = \"production\".to_string();\n }\n config.channels.qq.enabled = true;\n }\n \"napcat\" => {\n let mode_options = [\n \"ws-client (BlockCell connects to NapCatQQ WebSocket server)\",\n \"ws-server (NapCatQQ connects to BlockCell WebSocket server)\",\n ];\n let mode_idx = prompt_select(\"Connection mode\", &mode_options, 0)?;\n let mode = match mode_idx {\n 0 => \"ws-client\",\n 1 => \"ws-server\",\n _ => \"ws-client\",\n }\n .to_string();\n\n // WebSocket URL (only for ws-client mode)\n let ws_url = if mode == \"ws-client\" {\n prompt_line_with_default(\"WebSocket URL\", \"ws://127.0.0.1:3001\")?\n } else {\n String::new()\n };\n\n // Server configuration for ws-server mode\n let (server_host, server_port, server_path) = if mode == \"ws-server\" {\n let host = prompt_line_with_default(\"WebSocket server host\", \"0.0.0.0\")?;\n // NapCatQQ client 默认连接 ws://localhost:13005\n let port_str = prompt_line_with_default(\"WebSocket server port\", \"13005\")?;\n let port = port_str.parse::().unwrap_or(13005);\n // NapCatQQ client 默认连接根路径 /\n let path = prompt_line_with_default(\"WebSocket server path\", \"/\")?;\n (host, port, path)\n } else {\n (String::new(), 0, String::new())\n };\n\n let access_token = prompt_line(\"Access token (optional, press Enter to skip)\")?;\n\n // Group response mode\n let mode_options = [\n \"all - 响应所有群聊消息\",\n \"at_only - 仅响应@我的群聊消息\",\n \"none - 不响应任何群聊消息\",\n ];\n let mode_idx = prompt_select(\"群聊响应模式\", &mode_options, 0)?;\n let group_response_mode = match mode_idx {\n 0 => \"all\",\n 1 => \"at_only\",\n 2 => \"none\",\n _ => \"all\",\n }\n .to_string();\n\n config.channels.napcat.enabled = true;\n config.channels.napcat.mode = mode;\n config.channels.napcat.ws_url = ws_url;\n config.channels.napcat.server_host = server_host;\n config.channels.napcat.server_port = server_port;\n config.channels.napcat.server_path = server_path;\n config.channels.napcat.access_token = access_token;\n config.channels.napcat.group_response_mode = group_response_mode;\n\n // 媒体自动下载配置\n let auto_download_options = [\n \"是 - 自动下载图片、语音、视频、文件等媒体\",\n \"否 - 不自动下载媒体\",\n ];\n let auto_idx = prompt_select(\"是否自动下载媒体文件\", &auto_download_options, 0)?;\n config.channels.napcat.auto_download_media = auto_idx == 0;\n\n if config.channels.napcat.auto_download_media {\n let max_size_str = prompt_line_with_default(\"最大自动下载大小 (字节)\", \"10485760\")?;\n if let Ok(max_size) = max_size_str.parse::() {\n config.channels.napcat.max_auto_download_size = max_size;\n }\n\n let download_dir =\n prompt_line_with_default(\"媒体下载目录 (相对于 workspace)\", \"downloads\")?;\n if !download_dir.trim().is_empty() {\n config.channels.napcat.media_download_dir = download_dir;\n }\n }\n }\n _ => bail!(\"Unsupported channel '{}'\", channel),\n }\n\n Ok(())\n}\n\nfn ensure_channel_owner(config: &mut Config, channel: &str) {\n if config.resolve_channel_owner(channel).is_some() {\n return;\n }\n let owner = config\n .known_agent_ids()\n .into_iter()\n .next()\n .unwrap_or_else(|| \"default\".to_string());\n config\n .channel_owners\n .insert(channel.to_string(), owner.clone());\n println!(\"Assigned channel owner: {} -> {}\", channel, owner);\n}\n\nfn prompt_provider(config: &Config) -> anyhow::Result {\n let current = primary_pool_entry(config)\n .map(|e| e.provider.clone())\n .or_else(|| {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .find(|e| !e.provider.trim().is_empty())\n .map(|e| e.provider.clone())\n })\n .or_else(|| config.agents.defaults.provider.clone())\n .or_else(|| config.get_api_key().map(|(name, _)| name.to_string()))\n .unwrap_or_else(|| \"none\".to_string());\n\n println!(\"Configure LLM provider (current: {})\", current);\n let options = [\n \"deepseek (recommended)\",\n \"openai\",\n \"kimi\",\n \"anthropic\",\n \"gemini\",\n \"zhipu\",\n \"qwen\",\n \"xai\",\n \"mistral\",\n \"minimax\",\n \"groq\",\n \"siliconflow\",\n \"openrouter\",\n \"ollama (local)\",\n \"skip\",\n ];\n let idx = prompt_select(\"Choose provider\", &options, 0)?;\n let mapped = match idx {\n 0 => \"deepseek\",\n 1 => \"openai\",\n 2 => \"kimi\",\n 3 => \"anthropic\",\n 4 => \"gemini\",\n 5 => \"zhipu\",\n 6 => \"qwen\",\n 7 => \"xai\",\n 8 => \"mistral\",\n 9 => \"minimax\",\n 10 => \"groq\",\n 11 => \"siliconflow\",\n 12 => \"openrouter\",\n 13 => \"ollama\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn primary_pool_entry(config: &Config) -> Option<&ModelEntry> {\n config\n .agents\n .defaults\n .model_pool\n .iter()\n .min_by(|a, b| a.priority.cmp(&b.priority).then(b.weight.cmp(&a.weight)))\n}\n\nfn prompt_channel() -> anyhow::Result {\n let options = [\n \"skip (WebUI only)\",\n \"telegram\",\n \"feishu\",\n \"wecom\",\n \"dingtalk\",\n \"lark\",\n \"qq\",\n \"napcat\",\n ];\n let idx = prompt_select(\"Configure one channel (optional)\", &options, 0)?;\n let mapped = match idx {\n 1 => \"telegram\",\n 2 => \"feishu\",\n 3 => \"wecom\",\n 4 => \"dingtalk\",\n 5 => \"lark\",\n 6 => \"qq\",\n 7 => \"napcat\",\n _ => \"skip\",\n };\n Ok(mapped.to_string())\n}\n\nfn resolve_provider(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_provider(v)\n .ok_or_else(|| anyhow!(\"Unsupported provider '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn resolve_channel(input: Option<&str>) -> anyhow::Result> {\n match input {\n Some(v) => normalize_channel(v)\n .ok_or_else(|| anyhow!(\"Unsupported channel '{}'\", v))\n .map(Some),\n None => Ok(None),\n }\n}\n\nfn normalize_provider(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"deepseek\" => Some(\"deepseek\"),\n \"openai\" => Some(\"openai\"),\n \"kimi\" | \"moonshot\" => Some(\"kimi\"),\n \"anthropic\" | \"claude\" => Some(\"anthropic\"),\n \"gemini\" => Some(\"gemini\"),\n \"zhipu\" | \"glm\" | \"zai\" | \"z.ai\" => Some(\"zhipu\"),\n \"qwen\" | \"tongyi\" | \"dashscope\" => Some(\"qwen\"),\n \"xai\" | \"grok\" => Some(\"xai\"),\n \"mistral\" => Some(\"mistral\"),\n \"minimax\" => Some(\"minimax\"),\n \"groq\" => Some(\"groq\"),\n \"siliconflow\" => Some(\"siliconflow\"),\n \"openrouter\" => Some(\"openrouter\"),\n \"ollama\" => Some(\"ollama\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn normalize_channel(input: &str) -> Option<&'static str> {\n match input.trim().to_lowercase().as_str() {\n \"telegram\" => Some(\"telegram\"),\n \"feishu\" => Some(\"feishu\"),\n \"wecom\" | \"wechatwork\" => Some(\"wecom\"),\n \"dingtalk\" => Some(\"dingtalk\"),\n \"lark\" => Some(\"lark\"),\n \"qq\" => Some(\"qq\"),\n \"napcat\" | \"napcatqq\" => Some(\"napcat\"),\n \"none\" | \"skip\" => Some(\"skip\"),\n _ => None,\n }\n}\n\nfn default_api_base_for_provider(provider: &str) -> Option<&'static str> {\n match provider {\n \"deepseek\" => Some(\"https://api.deepseek.com/v1\"),\n \"openai\" => Some(\"https://api.openai.com/v1\"),\n \"anthropic\" => Some(\"https://api.anthropic.com\"),\n \"kimi\" => Some(\"https://api.moonshot.cn/v1\"),\n \"gemini\" => Some(\"https://generativelanguage.googleapis.com/v1beta/openai\"),\n \"zhipu\" | \"glm\" => Some(\"https://open.bigmodel.cn/api/paas/v4\"),\n \"minimax\" => Some(\"https://api.minimaxi.com/v1\"),\n \"groq\" => Some(\"https://api.groq.com/openai/v1\"),\n \"siliconflow\" => Some(\"https://api.siliconflow.cn/v1\"),\n \"openrouter\" => Some(\"https://openrouter.ai/api/v1\"),\n \"qwen\" => Some(\"https://api.qwen.ai/v1\"),\n \"xai\" => Some(\"https://api.x.ai/v1\"),\n \"mistral\" => Some(\"https://api.mistral.ai/v1\"),\n \"ollama\" => Some(\"http://localhost:11434\"),\n _ => None,\n }\n}\n\nfn default_api_type_for_provider(provider: &str) -> &'static str {\n match provider {\n \"anthropic\" | \"minimax\" => \"anthropic\",\n \"gemini\" => \"openai\",\n \"ollama\" => \"ollama\",\n _ => \"openai\",\n }\n}\n\nfn prompt_select(title: &str, options: &[&str], default_index: usize) -> anyhow::Result {\n println!(\"{}\", title);\n for (i, opt) in options.iter().enumerate() {\n println!(\" {}. {}\", i + 1, opt);\n }\n\n loop {\n let input = prompt_line(&format!(\n \"Enter choice [1-{}] (default {}): \",\n options.len(),\n default_index + 1\n ))?;\n\n if input.trim().is_empty() {\n return Ok(default_index);\n }\n\n if let Ok(n) = input.trim().parse::() {\n if n >= 1 && n <= options.len() {\n return Ok(n - 1);\n }\n }\n println!(\"Invalid choice, please try again.\");\n }\n}\n\nfn prompt_optional_with_existing(label: &str, existing: &str) -> anyhow::Result {\n let prompt = if existing.trim().is_empty() {\n format!(\"{}: \", label)\n } else {\n format!(\"{} (press Enter to keep existing): \", label)\n };\n prompt_line(&prompt)\n}\n\nfn prompt_line_with_default(label: &str, default: &str) -> anyhow::Result {\n let input = prompt_line(&format!(\"{} [{}]: \", label, default))?;\n if input.trim().is_empty() {\n Ok(default.to_string())\n } else {\n Ok(input)\n }\n}\n\nfn prompt_line(prompt: &str) -> anyhow::Result {\n print!(\"{}\", prompt);\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin().read_line(&mut input)?;\n Ok(input.trim().to_string())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_provider_aliases() {\n assert_eq!(normalize_provider(\"moonshot\"), Some(\"kimi\"));\n assert_eq!(normalize_provider(\"claude\"), Some(\"anthropic\"));\n assert_eq!(normalize_provider(\"zhipu\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"glm\"), Some(\"zhipu\"));\n assert_eq!(normalize_provider(\"qwen\"), Some(\"qwen\"));\n assert_eq!(normalize_provider(\"grok\"), Some(\"xai\"));\n assert_eq!(normalize_provider(\"mistral\"), Some(\"mistral\"));\n assert_eq!(normalize_provider(\"minimax\"), Some(\"minimax\"));\n assert_eq!(normalize_provider(\"groq\"), Some(\"groq\"));\n assert_eq!(normalize_provider(\"siliconflow\"), Some(\"siliconflow\"));\n assert_eq!(normalize_provider(\"openrouter\"), Some(\"openrouter\"));\n assert_eq!(normalize_provider(\"none\"), Some(\"skip\"));\n assert_eq!(normalize_provider(\"unknown\"), None);\n }\n\n #[test]\n fn test_normalize_channel_aliases() {\n assert_eq!(normalize_channel(\"wechatwork\"), Some(\"wecom\"));\n assert_eq!(normalize_channel(\"dingtalk\"), Some(\"dingtalk\"));\n assert_eq!(normalize_channel(\"qq\"), Some(\"qq\"));\n assert_eq!(normalize_channel(\"napcat\"), Some(\"napcat\"));\n assert_eq!(normalize_channel(\"napcatqq\"), Some(\"napcat\"));\n assert_eq!(normalize_channel(\"skip\"), Some(\"skip\"));\n assert_eq!(normalize_channel(\"unknown\"), None);\n }\n\n #[test]\n fn test_default_model() {\n assert_eq!(default_model_for_provider(\"deepseek\"), \"deepseek-v4-pro\");\n assert_eq!(default_model_for_provider(\"openai\"), \"gpt-5.5\");\n assert_eq!(default_model_for_provider(\"anthropic\"), \"claude-opus-4-8\");\n assert_eq!(default_model_for_provider(\"kimi\"), \"kimi-k2.6\");\n assert_eq!(default_model_for_provider(\"moonshot\"), \"kimi-k2.6\");\n assert_eq!(\n default_model_for_provider(\"gemini\"),\n \"gemini-3.1-pro-preview\"\n );\n assert_eq!(default_model_for_provider(\"zhipu\"), \"glm-5.2\");\n assert_eq!(default_model_for_provider(\"glm\"), \"glm-5.2\");\n assert_eq!(default_model_for_provider(\"qwen\"), \"qwen3.7-max\");\n assert_eq!(default_model_for_provider(\"xai\"), \"grok-4.3\");\n assert_eq!(default_model_for_provider(\"mistral\"), \"mistral-medium-2604\");\n assert_eq!(default_model_for_provider(\"minimax\"), \"MiniMax-M3\");\n assert_eq!(default_model_for_provider(\"groq\"), \"openai/gpt-oss-120b\");\n assert_eq!(\n default_model_for_provider(\"siliconflow\"),\n \"deepseek-ai/DeepSeek-V4-Pro\"\n );\n assert_eq!(default_model_for_provider(\"openrouter\"), \"openai/gpt-5.5\");\n assert_eq!(default_model_for_provider(\"ollama\"), \"qwen3.6\");\n }\n\n #[test]\n fn test_configure_provider_populates_legacy_single_model_fields_for_compat() {\n let mut config = Config::default();\n config.agents.defaults.model = \"legacy-model\".to_string();\n config.agents.defaults.provider = Some(\"legacy-provider\".to_string());\n\n configure_provider(\n &mut config,\n \"deepseek\",\n Some(\"test-deepseek-key\"),\n Some(\"deepseek-chat\"),\n )\n .expect(\"configure_provider should succeed\");\n\n assert_eq!(\n config.agents.defaults.provider,\n Some(\"deepseek\".to_string())\n );\n assert_eq!(config.agents.defaults.model, \"deepseek-chat\");\n assert_eq!(config.agents.defaults.model_pool.len(), 1);\n let entry = &config.agents.defaults.model_pool[0];\n assert_eq!(entry.provider, \"deepseek\");\n assert_eq!(entry.model, \"deepseek-chat\");\n assert_eq!(entry.weight, 1);\n assert_eq!(entry.priority, 1);\n }\n\n #[test]\n fn test_ensure_channel_owner_defaults_to_default_agent() {\n let mut config = Config::default();\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"default\")\n );\n }\n\n #[test]\n fn test_ensure_channel_owner_keeps_existing() {\n let mut config = Config::default();\n config\n .channel_owners\n .insert(\"telegram\".to_string(), \"ops\".to_string());\n ensure_channel_owner(&mut config, \"telegram\");\n assert_eq!(\n config.channel_owners.get(\"telegram\").map(|s| s.as_str()),\n Some(\"ops\")\n );\n }\n}\n"}}, "crates/providers/src/factory.rs": {"bc15f54c767b4a1ca941f3dbb15526d12de269ed": {"t": 1785746223.1848447, "n": 14845, "h": "aa3ee122f590654c", "c": "use blockcell_core::config::ToolCallMode;\nuse blockcell_core::Config;\n\nuse crate::{\n AnthropicProvider, GeminiProvider, OllamaProvider, OpenAIProvider, OpenAIResponsesProvider,\n Provider,\n};\n\n/// 默认的 OpenAI 兼容 provider 的 api_base\npub(crate) fn default_api_base(provider_name: &str) -> &'static str {\n match provider_name {\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"deepseek\" => \"https://api.deepseek.com/v1\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"zhipu\" | \"glm\" => \"https://open.bigmodel.cn/api/paas/v4\",\n \"kimi\" | \"moonshot\" => \"https://api.moonshot.cn/v1\",\n \"qwen\" => \"https://api.qwen.ai/v1\",\n \"xai\" => \"https://api.x.ai/v1\",\n \"mistral\" => \"https://api.mistral.ai/v1\",\n \"minimax\" => \"https://api.minimaxi.com/v1\",\n \"siliconflow\" => \"https://api.siliconflow.cn/v1\",\n _ => \"https://api.openai.com/v1\",\n }\n}\n\n/// 从 model 字符串前缀推断 provider 名字\n/// 返回 None 表示无法从前缀推断(需要 fallback)\npub fn infer_provider_from_model(model: &str) -> Option<&'static str> {\n let model = model.trim();\n let lower = model.to_ascii_lowercase();\n\n if lower.starts_with(\"anthropic/\") || lower.starts_with(\"claude-\") {\n Some(\"anthropic\")\n } else if lower.starts_with(\"gemini/\") || lower.starts_with(\"gemini-\") {\n Some(\"gemini\")\n } else if lower.starts_with(\"ollama/\") {\n Some(\"ollama\")\n } else if lower.starts_with(\"kimi\") || lower.starts_with(\"moonshot\") {\n Some(\"kimi\")\n } else if lower.starts_with(\"openai/\")\n || lower.starts_with(\"gpt-\")\n || lower.starts_with(\"o1\")\n || lower.starts_with(\"o3\")\n || lower.starts_with(\"o4\")\n {\n Some(\"openai\")\n } else if lower.starts_with(\"deepseek-ai/\")\n || lower.starts_with(\"zai-org/\")\n || lower.starts_with(\"qwen/\")\n {\n Some(\"siliconflow\")\n } else if lower.starts_with(\"deepseek\") {\n Some(\"deepseek\")\n } else if lower.starts_with(\"grok-\") {\n Some(\"xai\")\n } else if lower.starts_with(\"mistral\") {\n Some(\"mistral\")\n } else if lower.starts_with(\"glm-\") {\n Some(\"zhipu\")\n } else if lower.starts_with(\"qwen\") {\n Some(\"qwen\")\n } else if lower.starts_with(\"minimax\") {\n Some(\"minimax\")\n } else if lower.starts_with(\"groq/\") {\n Some(\"groq\")\n } else {\n None\n }\n}\n\n/// 从 config 中找到第一个配置了 api_key 的 provider 名字作为 fallback\n/// 这比旧的 get_api_key() 更合理:先按 model 前缀确认优先 provider\nfn fallback_provider_name(config: &Config) -> Option<&str> {\n let priority = [\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"deepseek\",\n \"kimi\",\n \"gemini\",\n \"zhipu\",\n \"qwen\",\n \"xai\",\n \"mistral\",\n \"minimax\",\n \"groq\",\n \"siliconflow\",\n \"vllm\",\n \"ollama\",\n ];\n for name in priority {\n if let Some(p) = config.providers.get(name) {\n if !p.api_key.is_empty() && p.api_key != \"dummy\" {\n return Some(name);\n }\n }\n }\n // ollama 特殊处理:不需要真实 api_key\n if config.providers.contains_key(\"ollama\") {\n return Some(\"ollama\");\n }\n None\n}\n\n/// 统一的 provider 创建入口。\n///\n/// 解析优先级:\n/// 1. `explicit_provider` 参数(来自 config.agents.defaults.provider 或 evolution_provider)\n/// 2. model 字符串前缀推断(如 \"anthropic/claude-...\" → anthropic)\n/// 3. config 中第一个有效 api_key 的 provider(fallback)\n///\n/// 对于 explicit_provider:\n/// - 如果 config.providers 中找不到对应配置,返回 Err\n/// - ollama 不需要 api_key,不报错\npub fn create_provider(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n) -> anyhow::Result> {\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\npub fn create_provider_with_tool_mode(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n tool_call_mode: Option,\n temperature_override: Option,\n) -> anyhow::Result> {\n let max_tokens = config.agents.defaults.max_tokens;\n let temperature = temperature_override.unwrap_or(config.agents.defaults.temperature);\n let reasoning_effort = config.agents.defaults.reasoning_effort.as_deref();\n\n // 优先级1:显式指定\n // 优先级2:model 前缀推断\n // 优先级3:config fallback\n let effective_provider: &str = if let Some(ep) = explicit_provider {\n ep\n } else if let Some(inferred) = infer_provider_from_model(model) {\n inferred\n } else if let Some(fallback) = fallback_provider_name(config) {\n fallback\n } else {\n return Err(anyhow::anyhow!(\n \"No LLM provider configured. Set 'provider' in config, use a recognized model prefix \\\n (e.g. 'anthropic/claude-...', 'gpt-5.5', 'gemini-...'), or add an API key to providers section.\"\n ));\n };\n\n // 获取 provider 配置\n let provider_cfg = config.providers.get(effective_provider);\n\n // 对于显式指定的 provider(非 ollama),必须有配置且有 api_key\n if explicit_provider.is_some() && effective_provider != \"ollama\" {\n match provider_cfg {\n None => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but not found in providers section\",\n effective_provider\n ));\n }\n Some(cfg) if cfg.api_key.is_empty() || cfg.api_key == \"dummy\" => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but has no API key\",\n effective_provider\n ));\n }\n _ => {}\n }\n }\n\n let empty_cfg = blockcell_core::config::ProviderConfig::default();\n let resolved_cfg = provider_cfg.unwrap_or(&empty_cfg);\n\n // 计算代理参数:\n // provider_proxy = providers..proxy(None=未配置,Some(\"\")=强制直连,Some(url)=该provider专用)\n // global_proxy = network.proxy\n // no_proxy = network.no_proxy\n let provider_proxy = resolved_cfg.proxy.as_deref();\n let global_proxy = config.network.proxy.as_deref();\n let no_proxy = &config.network.no_proxy;\n\n match effective_provider {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box)\n }\n _ => {\n // 对于自定义 provider 名(非内置),用 api_type 决定使用哪种协议实现\n match resolved_cfg.api_type.as_str() {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box)\n }\n \"openai_responses\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIResponsesProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n )?) as Box)\n }\n _ => {\n // 默认:OpenAI 兼容(openrouter, openai, deepseek, groq, zhipu, vllm, kimi 等)\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n effective_provider,\n reasoning_effort,\n )?) as Box)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result> {\n let model = &config.agents.defaults.model;\n let explicit_provider = config.agents.defaults.provider.as_deref();\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\n/// 为自进化创建独立的 provider\n/// 优先级:evolutionProvider > evolution_model 前缀 > provider > model 前缀 > fallback\npub fn create_evolution_provider(config: &Config) -> anyhow::Result> {\n let model = config\n .agents\n .defaults\n .evolution_model\n .as_deref()\n .unwrap_or(&config.agents.defaults.model);\n\n // evolution_provider 显式 > 主 provider 显式(作为 evolution fallback)\n let explicit_provider = config\n .agents\n .defaults\n .evolution_provider\n .as_deref()\n .or(config.agents.defaults.provider.as_deref());\n\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_infer_provider_from_model() {\n assert_eq!(\n infer_provider_from_model(\"anthropic/claude-sonnet-4\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"claude-3-5-sonnet\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini-3.5-flash\"),\n Some(\"gemini\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini/gemini-pro\"),\n Some(\"gemini\")\n );\n assert_eq!(infer_provider_from_model(\"ollama/qwen3.6\"), Some(\"ollama\"));\n assert_eq!(infer_provider_from_model(\"kimi-k2.6\"), Some(\"kimi\"));\n assert_eq!(infer_provider_from_model(\"gpt-5.5\"), Some(\"openai\"));\n assert_eq!(infer_provider_from_model(\"o4-mini\"), Some(\"openai\"));\n assert_eq!(infer_provider_from_model(\"grok-4.3\"), Some(\"xai\"));\n assert_eq!(\n infer_provider_from_model(\"mistral-medium-2604\"),\n Some(\"mistral\")\n );\n assert_eq!(infer_provider_from_model(\"glm-5.2\"), Some(\"zhipu\"));\n assert_eq!(infer_provider_from_model(\"qwen3.7-max\"), Some(\"qwen\"));\n assert_eq!(infer_provider_from_model(\"MiniMax-M3\"), Some(\"minimax\"));\n assert_eq!(\n infer_provider_from_model(\"deepseek-ai/DeepSeek-V4-Pro\"),\n Some(\"siliconflow\")\n );\n assert_eq!(\n infer_provider_from_model(\"deepseek-coder\"),\n Some(\"deepseek\")\n );\n assert_eq!(infer_provider_from_model(\"some-unknown-model\"), None);\n }\n\n #[test]\n fn test_create_provider_explicit_wins() {\n let mut config = Config::default();\n // 配置 openai provider\n config.providers.get_mut(\"openai\").unwrap().api_key = \"sk-test\".to_string();\n // model 是 anthropic 前缀,但显式指定 openai\n let result = create_provider(&config, \"anthropic/claude-sonnet-4\", Some(\"openai\"));\n assert!(result.is_ok(), \"显式 provider 应该覆盖 model 前缀\");\n }\n\n #[test]\n fn test_create_provider_model_prefix() {\n let mut config = Config::default();\n config.providers.get_mut(\"anthropic\").unwrap().api_key = \"sk-ant-test\".to_string();\n let result = create_provider(&config, \"claude-3-5-sonnet\", None);\n assert!(result.is_ok(), \"model 前缀应该推断 anthropic\");\n }\n\n #[test]\n fn test_create_provider_ollama_no_key_needed() {\n let config = Config::default();\n // ollama 不需要 api_key\n let result = create_provider(&config, \"qwen3.6\", Some(\"ollama\"));\n assert!(result.is_ok(), \"ollama 不需要 api_key\");\n }\n\n #[test]\n fn test_create_provider_no_config_fails() {\n let mut config = Config::default();\n // 清空所有 api_key,并移除 ollama(ollama 不需要 api_key 会被作为 fallback)\n for p in config.providers.values_mut() {\n p.api_key = String::new();\n }\n config.providers.remove(\"ollama\");\n let result = create_provider(&config, \"some-unknown-model\", None);\n assert!(result.is_err(), \"没有任何配置时应报错\");\n }\n\n #[test]\n fn test_create_provider_explicit_missing_fails() {\n let config = Config::default(); // anthropic key 为空\n let result = create_provider(&config, \"gpt-5.5\", Some(\"anthropic\"));\n assert!(result.is_err(), \"显式指定但无 api_key 时应报错\");\n }\n}\n"}, "cc0ade7c838a45b5a25824c8e5e86e4b8ea9219d": {"t": 1785760739.7699983, "n": 14845, "h": "aa3ee122f590654c", "c": "use blockcell_core::config::ToolCallMode;\nuse blockcell_core::Config;\n\nuse crate::{\n AnthropicProvider, GeminiProvider, OllamaProvider, OpenAIProvider, OpenAIResponsesProvider,\n Provider,\n};\n\n/// 默认的 OpenAI 兼容 provider 的 api_base\npub(crate) fn default_api_base(provider_name: &str) -> &'static str {\n match provider_name {\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"deepseek\" => \"https://api.deepseek.com/v1\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"zhipu\" | \"glm\" => \"https://open.bigmodel.cn/api/paas/v4\",\n \"kimi\" | \"moonshot\" => \"https://api.moonshot.cn/v1\",\n \"qwen\" => \"https://api.qwen.ai/v1\",\n \"xai\" => \"https://api.x.ai/v1\",\n \"mistral\" => \"https://api.mistral.ai/v1\",\n \"minimax\" => \"https://api.minimaxi.com/v1\",\n \"siliconflow\" => \"https://api.siliconflow.cn/v1\",\n _ => \"https://api.openai.com/v1\",\n }\n}\n\n/// 从 model 字符串前缀推断 provider 名字\n/// 返回 None 表示无法从前缀推断(需要 fallback)\npub fn infer_provider_from_model(model: &str) -> Option<&'static str> {\n let model = model.trim();\n let lower = model.to_ascii_lowercase();\n\n if lower.starts_with(\"anthropic/\") || lower.starts_with(\"claude-\") {\n Some(\"anthropic\")\n } else if lower.starts_with(\"gemini/\") || lower.starts_with(\"gemini-\") {\n Some(\"gemini\")\n } else if lower.starts_with(\"ollama/\") {\n Some(\"ollama\")\n } else if lower.starts_with(\"kimi\") || lower.starts_with(\"moonshot\") {\n Some(\"kimi\")\n } else if lower.starts_with(\"openai/\")\n || lower.starts_with(\"gpt-\")\n || lower.starts_with(\"o1\")\n || lower.starts_with(\"o3\")\n || lower.starts_with(\"o4\")\n {\n Some(\"openai\")\n } else if lower.starts_with(\"deepseek-ai/\")\n || lower.starts_with(\"zai-org/\")\n || lower.starts_with(\"qwen/\")\n {\n Some(\"siliconflow\")\n } else if lower.starts_with(\"deepseek\") {\n Some(\"deepseek\")\n } else if lower.starts_with(\"grok-\") {\n Some(\"xai\")\n } else if lower.starts_with(\"mistral\") {\n Some(\"mistral\")\n } else if lower.starts_with(\"glm-\") {\n Some(\"zhipu\")\n } else if lower.starts_with(\"qwen\") {\n Some(\"qwen\")\n } else if lower.starts_with(\"minimax\") {\n Some(\"minimax\")\n } else if lower.starts_with(\"groq/\") {\n Some(\"groq\")\n } else {\n None\n }\n}\n\n/// 从 config 中找到第一个配置了 api_key 的 provider 名字作为 fallback\n/// 这比旧的 get_api_key() 更合理:先按 model 前缀确认优先 provider\nfn fallback_provider_name(config: &Config) -> Option<&str> {\n let priority = [\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"deepseek\",\n \"kimi\",\n \"gemini\",\n \"zhipu\",\n \"qwen\",\n \"xai\",\n \"mistral\",\n \"minimax\",\n \"groq\",\n \"siliconflow\",\n \"vllm\",\n \"ollama\",\n ];\n for name in priority {\n if let Some(p) = config.providers.get(name) {\n if !p.api_key.is_empty() && p.api_key != \"dummy\" {\n return Some(name);\n }\n }\n }\n // ollama 特殊处理:不需要真实 api_key\n if config.providers.contains_key(\"ollama\") {\n return Some(\"ollama\");\n }\n None\n}\n\n/// 统一的 provider 创建入口。\n///\n/// 解析优先级:\n/// 1. `explicit_provider` 参数(来自 config.agents.defaults.provider 或 evolution_provider)\n/// 2. model 字符串前缀推断(如 \"anthropic/claude-...\" → anthropic)\n/// 3. config 中第一个有效 api_key 的 provider(fallback)\n///\n/// 对于 explicit_provider:\n/// - 如果 config.providers 中找不到对应配置,返回 Err\n/// - ollama 不需要 api_key,不报错\npub fn create_provider(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n) -> anyhow::Result> {\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\npub fn create_provider_with_tool_mode(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n tool_call_mode: Option,\n temperature_override: Option,\n) -> anyhow::Result> {\n let max_tokens = config.agents.defaults.max_tokens;\n let temperature = temperature_override.unwrap_or(config.agents.defaults.temperature);\n let reasoning_effort = config.agents.defaults.reasoning_effort.as_deref();\n\n // 优先级1:显式指定\n // 优先级2:model 前缀推断\n // 优先级3:config fallback\n let effective_provider: &str = if let Some(ep) = explicit_provider {\n ep\n } else if let Some(inferred) = infer_provider_from_model(model) {\n inferred\n } else if let Some(fallback) = fallback_provider_name(config) {\n fallback\n } else {\n return Err(anyhow::anyhow!(\n \"No LLM provider configured. Set 'provider' in config, use a recognized model prefix \\\n (e.g. 'anthropic/claude-...', 'gpt-5.5', 'gemini-...'), or add an API key to providers section.\"\n ));\n };\n\n // 获取 provider 配置\n let provider_cfg = config.providers.get(effective_provider);\n\n // 对于显式指定的 provider(非 ollama),必须有配置且有 api_key\n if explicit_provider.is_some() && effective_provider != \"ollama\" {\n match provider_cfg {\n None => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but not found in providers section\",\n effective_provider\n ));\n }\n Some(cfg) if cfg.api_key.is_empty() || cfg.api_key == \"dummy\" => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but has no API key\",\n effective_provider\n ));\n }\n _ => {}\n }\n }\n\n let empty_cfg = blockcell_core::config::ProviderConfig::default();\n let resolved_cfg = provider_cfg.unwrap_or(&empty_cfg);\n\n // 计算代理参数:\n // provider_proxy = providers..proxy(None=未配置,Some(\"\")=强制直连,Some(url)=该provider专用)\n // global_proxy = network.proxy\n // no_proxy = network.no_proxy\n let provider_proxy = resolved_cfg.proxy.as_deref();\n let global_proxy = config.network.proxy.as_deref();\n let no_proxy = &config.network.no_proxy;\n\n match effective_provider {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box)\n }\n _ => {\n // 对于自定义 provider 名(非内置),用 api_type 决定使用哪种协议实现\n match resolved_cfg.api_type.as_str() {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box)\n }\n \"openai_responses\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIResponsesProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n )?) as Box)\n }\n _ => {\n // 默认:OpenAI 兼容(openrouter, openai, deepseek, groq, zhipu, vllm, kimi 等)\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n effective_provider,\n reasoning_effort,\n )?) as Box)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result> {\n let model = &config.agents.defaults.model;\n let explicit_provider = config.agents.defaults.provider.as_deref();\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\n/// 为自进化创建独立的 provider\n/// 优先级:evolutionProvider > evolution_model 前缀 > provider > model 前缀 > fallback\npub fn create_evolution_provider(config: &Config) -> anyhow::Result> {\n let model = config\n .agents\n .defaults\n .evolution_model\n .as_deref()\n .unwrap_or(&config.agents.defaults.model);\n\n // evolution_provider 显式 > 主 provider 显式(作为 evolution fallback)\n let explicit_provider = config\n .agents\n .defaults\n .evolution_provider\n .as_deref()\n .or(config.agents.defaults.provider.as_deref());\n\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_infer_provider_from_model() {\n assert_eq!(\n infer_provider_from_model(\"anthropic/claude-sonnet-4\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"claude-3-5-sonnet\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini-3.5-flash\"),\n Some(\"gemini\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini/gemini-pro\"),\n Some(\"gemini\")\n );\n assert_eq!(infer_provider_from_model(\"ollama/qwen3.6\"), Some(\"ollama\"));\n assert_eq!(infer_provider_from_model(\"kimi-k2.6\"), Some(\"kimi\"));\n assert_eq!(infer_provider_from_model(\"gpt-5.5\"), Some(\"openai\"));\n assert_eq!(infer_provider_from_model(\"o4-mini\"), Some(\"openai\"));\n assert_eq!(infer_provider_from_model(\"grok-4.3\"), Some(\"xai\"));\n assert_eq!(\n infer_provider_from_model(\"mistral-medium-2604\"),\n Some(\"mistral\")\n );\n assert_eq!(infer_provider_from_model(\"glm-5.2\"), Some(\"zhipu\"));\n assert_eq!(infer_provider_from_model(\"qwen3.7-max\"), Some(\"qwen\"));\n assert_eq!(infer_provider_from_model(\"MiniMax-M3\"), Some(\"minimax\"));\n assert_eq!(\n infer_provider_from_model(\"deepseek-ai/DeepSeek-V4-Pro\"),\n Some(\"siliconflow\")\n );\n assert_eq!(\n infer_provider_from_model(\"deepseek-coder\"),\n Some(\"deepseek\")\n );\n assert_eq!(infer_provider_from_model(\"some-unknown-model\"), None);\n }\n\n #[test]\n fn test_create_provider_explicit_wins() {\n let mut config = Config::default();\n // 配置 openai provider\n config.providers.get_mut(\"openai\").unwrap().api_key = \"sk-test\".to_string();\n // model 是 anthropic 前缀,但显式指定 openai\n let result = create_provider(&config, \"anthropic/claude-sonnet-4\", Some(\"openai\"));\n assert!(result.is_ok(), \"显式 provider 应该覆盖 model 前缀\");\n }\n\n #[test]\n fn test_create_provider_model_prefix() {\n let mut config = Config::default();\n config.providers.get_mut(\"anthropic\").unwrap().api_key = \"sk-ant-test\".to_string();\n let result = create_provider(&config, \"claude-3-5-sonnet\", None);\n assert!(result.is_ok(), \"model 前缀应该推断 anthropic\");\n }\n\n #[test]\n fn test_create_provider_ollama_no_key_needed() {\n let config = Config::default();\n // ollama 不需要 api_key\n let result = create_provider(&config, \"qwen3.6\", Some(\"ollama\"));\n assert!(result.is_ok(), \"ollama 不需要 api_key\");\n }\n\n #[test]\n fn test_create_provider_no_config_fails() {\n let mut config = Config::default();\n // 清空所有 api_key,并移除 ollama(ollama 不需要 api_key 会被作为 fallback)\n for p in config.providers.values_mut() {\n p.api_key = String::new();\n }\n config.providers.remove(\"ollama\");\n let result = create_provider(&config, \"some-unknown-model\", None);\n assert!(result.is_err(), \"没有任何配置时应报错\");\n }\n\n #[test]\n fn test_create_provider_explicit_missing_fails() {\n let config = Config::default(); // anthropic key 为空\n let result = create_provider(&config, \"gpt-5.5\", Some(\"anthropic\"));\n assert!(result.is_err(), \"显式指定但无 api_key 时应报错\");\n }\n}\n"}, "e22deaf8566bbf571de4771d8001d8407bd58418": {"t": 1785760743.6060085, "n": 14843, "h": "ef1605aa648fc35d", "c": "use blockcell_core::config::ToolCallMode;\nuse blockcell_core::Config;\n\nuse crate::{\n AnthropicProvider, GeminiProvider, OllamaProvider, OpenAIProvider, OpenAIResponsesProvider,\n Provider,\n};\n\n/// 默认的 OpenAI 兼容 provider 的 api_base\npub(crate) fn default_api_base(provider_name: &str) -> &'static str {\n match provider_name {\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"deepseek\" => \"https://api.deepseek.com/v1\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"zhipu\" | \"glm\" => \"https://open.bigmodel.cn/api/paas/v4\",\n \"kimi\" | \"moonshot\" => \"https://api.moonshot.cn/v1\",\n \"qwen\" => \"https://api.qwen.ai/v1\",\n \"xai\" => \"https://api.x.ai/v1\",\n \"mistral\" => \"https://api.mistral.ai/v1\",\n \"minimax\" => \"https://api.minimaxi.com/v1\",\n \"siliconflow\" => \"https://api.siliconflow.cn/v1\",\n _ => \"https://api.openai.com/v1\",\n }\n}\n\n/// 从 model 字符串前缀推断 provider 名字\n/// 返回 None 表示无法从前缀推断(需要 fallback)\npub fn infer_provider_from_model(model: &str) -> Option<&'static str> {\n let model = model.trim();\n let lower = model.to_ascii_lowercase();\n\n if lower.starts_with(\"anthropic/\") || lower.starts_with(\"claude-\") {\n Some(\"anthropic\")\n } else if lower.starts_with(\"gemini/\") || lower.starts_with(\"gemini-\") {\n Some(\"gemini\")\n } else if lower.starts_with(\"ollama/\") {\n Some(\"ollama\")\n } else if lower.starts_with(\"kimi\") || lower.starts_with(\"moonshot\") {\n Some(\"kimi\")\n } else if lower.starts_with(\"openai/\")\n || lower.starts_with(\"gpt-\")\n || lower.starts_with(\"o1\")\n || lower.starts_with(\"o3\")\n || lower.starts_with(\"o4\")\n {\n Some(\"openai\")\n } else if lower.starts_with(\"deepseek-ai/\")\n || lower.starts_with(\"zai-org/\")\n || lower.starts_with(\"qwen/\")\n {\n Some(\"siliconflow\")\n } else if lower.starts_with(\"deepseek\") {\n Some(\"deepseek\")\n } else if lower.starts_with(\"grok-\") {\n Some(\"xai\")\n } else if lower.starts_with(\"mistral\") {\n Some(\"mistral\")\n } else if lower.starts_with(\"glm-\") {\n Some(\"zhipu\")\n } else if lower.starts_with(\"qwen\") {\n Some(\"qwen\")\n } else if lower.starts_with(\"minimax\") {\n Some(\"minimax\")\n } else if lower.starts_with(\"groq/\") {\n Some(\"groq\")\n } else {\n None\n }\n}\n\n/// 从 config 中找到第一个配置了 api_key 的 provider 名字作为 fallback\n/// 这比旧的 get_api_key() 更合理:先按 model 前缀确认优先 provider\nfn fallback_provider_name(config: &Config) -> Option<&str> {\n let priority = [\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"deepseek\",\n \"kimi\",\n \"gemini\",\n \"zhipu\",\n \"qwen\",\n \"xai\",\n \"mistral\",\n \"minimax\",\n \"groq\",\n \"siliconflow\",\n \"vllm\",\n \"ollama\",\n ];\n for name in priority {\n if let Some(p) = config.providers.get(name) {\n if !p.api_key.is_empty() && p.api_key != \"dummy\" {\n return Some(name);\n }\n }\n }\n // ollama 特殊处理:不需要真实 api_key\n if config.providers.contains_key(\"ollama\") {\n return Some(\"ollama\");\n }\n None\n}\n\n/// 统一的 provider 创建入口。\n///\n/// 解析优先级:\n/// 1. `explicit_provider` 参数(来自 config.agents.defaults.provider 或 evolution_provider)\n/// 2. model 字符串前缀推断(如 \"anthropic/claude-...\" → anthropic)\n/// 3. config 中第一个有效 api_key 的 provider(fallback)\n///\n/// 对于 explicit_provider:\n/// - 如果 config.providers 中找不到对应配置,返回 Err\n/// - ollama 不需要 api_key,不报错\npub fn create_provider(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n) -> anyhow::Result> {\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\npub fn create_provider_with_tool_mode(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n tool_call_mode: Option,\n temperature_override: Option,\n) -> anyhow::Result> {\n let max_tokens = config.agents.defaults.max_tokens;\n let temperature = temperature_override.unwrap_or(config.agents.defaults.temperature);\n let reasoning_effort = config.agents.defaults.reasoning_effort.as_deref();\n\n // 优先级1:显式指定\n // 优先级2:model 前缀推断\n // 优先级3:config fallback\n let effective_provider: &str = if let Some(ep) = explicit_provider {\n ep\n } else if let Some(inferred) = infer_provider_from_model(model) {\n inferred\n } else if let Some(fallback) = fallback_provider_name(config) {\n fallback\n } else {\n return Err(anyhow::anyhow!(\n \"No LLM provider configured. Set 'provider' in config, use a recognized model prefix \\\n (e.g. 'anthropic/claude-...', 'gpt-5.5', 'gemini-...'), or add an API key to providers section.\"\n ));\n };\n\n // 获取 provider 配置\n let provider_cfg = config.providers.get(effective_provider);\n\n // 对于显式指定的 provider(非 ollama),必须有配置且有 api_key\n if explicit_provider.is_some() && effective_provider != \"ollama\" {\n match provider_cfg {\n None => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but not found in providers section\",\n effective_provider\n ));\n }\n Some(cfg) if cfg.api_key.is_empty() || cfg.api_key == \"dummy\" => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but has no API key\",\n effective_provider\n ));\n }\n _ => {}\n }\n }\n\n let empty_cfg = blockcell_core::config::ProviderConfig::default();\n let resolved_cfg = provider_cfg.unwrap_or(&empty_cfg);\n\n // 计算代理参数:\n // provider_proxy = providers..proxy(None=未配置,Some(\"\")=强制直连,Some(url)=该provider专用)\n // global_proxy = network.proxy\n // no_proxy = network.no_proxy\n let provider_proxy = resolved_cfg.proxy.as_deref();\n let global_proxy = config.network.proxy.as_deref();\n let no_proxy = &config.network.no_proxy;\n\n match effective_provider {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box)\n }\n _ => {\n // 对于自定义 provider 名(非内置),用 api_type 决定使用哪种协议实现\n match resolved_cfg.api_type.as_str() {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box)\n }\n \"openai_responses\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIResponsesProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n )?) as Box)\n }\n _ => {\n // 默认:OpenAI 兼容(openrouter, openai, deepseek, groq, zhipu, vllm, kimi 等)\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n effective_provider,\n reasoning_effort,\n )?) as Box)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result> {\n let model = &config.agents.defaults.model;\n let explicit_provider = config.agents.defaults.provider.as_deref();\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\n/// 为自进化创建独立的 provider\n/// 优先级:evolutionProvider > evolution_model 前缀 > provider > model 前缀 > fallback\npub fn create_evolution_provider(config: &Config) -> anyhow::Result> {\n let model = config\n .agents\n .defaults\n .evolution_model\n .as_deref()\n .unwrap_or(&config.agents.defaults.model);\n\n // evolution_provider 显式 > 主 provider 显式(作为 evolution fallback)\n let explicit_provider = config\n .agents\n .defaults\n .evolution_provider\n .as_deref()\n .or(config.agents.defaults.provider.as_deref());\n\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_infer_provider_from_model() {\n assert_eq!(\n infer_provider_from_model(\"anthropic/claude-sonnet-4\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"claude-3-5-sonnet\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini-3.5-flash\"),\n Some(\"gemini\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini/gemini-pro\"),\n Some(\"gemini\")\n );\n assert_eq!(infer_provider_from_model(\"ollama/llama3\"), Some(\"ollama\"));\n assert_eq!(infer_provider_from_model(\"kimi-k2.6\"), Some(\"kimi\"));\n assert_eq!(infer_provider_from_model(\"gpt-5.5\"), Some(\"openai\"));\n assert_eq!(infer_provider_from_model(\"o4-mini\"), Some(\"openai\"));\n assert_eq!(infer_provider_from_model(\"grok-4.3\"), Some(\"xai\"));\n assert_eq!(\n infer_provider_from_model(\"mistral-medium-2604\"),\n Some(\"mistral\")\n );\n assert_eq!(infer_provider_from_model(\"glm-5.2\"), Some(\"zhipu\"));\n assert_eq!(infer_provider_from_model(\"qwen3.7-max\"), Some(\"qwen\"));\n assert_eq!(infer_provider_from_model(\"MiniMax-M3\"), Some(\"minimax\"));\n assert_eq!(\n infer_provider_from_model(\"deepseek-ai/DeepSeek-V4-Pro\"),\n Some(\"siliconflow\")\n );\n assert_eq!(\n infer_provider_from_model(\"deepseek-coder\"),\n Some(\"deepseek\")\n );\n assert_eq!(infer_provider_from_model(\"some-unknown-model\"), None);\n }\n\n #[test]\n fn test_create_provider_explicit_wins() {\n let mut config = Config::default();\n // 配置 openai provider\n config.providers.get_mut(\"openai\").unwrap().api_key = \"sk-test\".to_string();\n // model 是 anthropic 前缀,但显式指定 openai\n let result = create_provider(&config, \"anthropic/claude-sonnet-4\", Some(\"openai\"));\n assert!(result.is_ok(), \"显式 provider 应该覆盖 model 前缀\");\n }\n\n #[test]\n fn test_create_provider_model_prefix() {\n let mut config = Config::default();\n config.providers.get_mut(\"anthropic\").unwrap().api_key = \"sk-ant-test\".to_string();\n let result = create_provider(&config, \"claude-3-5-sonnet\", None);\n assert!(result.is_ok(), \"model 前缀应该推断 anthropic\");\n }\n\n #[test]\n fn test_create_provider_ollama_no_key_needed() {\n let config = Config::default();\n // ollama 不需要 api_key\n let result = create_provider(&config, \"llama3\", Some(\"ollama\"));\n assert!(result.is_ok(), \"ollama 不需要 api_key\");\n }\n\n #[test]\n fn test_create_provider_no_config_fails() {\n let mut config = Config::default();\n // 清空所有 api_key,并移除 ollama(ollama 不需要 api_key 会被作为 fallback)\n for p in config.providers.values_mut() {\n p.api_key = String::new();\n }\n config.providers.remove(\"ollama\");\n let result = create_provider(&config, \"some-unknown-model\", None);\n assert!(result.is_err(), \"没有任何配置时应报错\");\n }\n\n #[test]\n fn test_create_provider_explicit_missing_fails() {\n let config = Config::default(); // anthropic key 为空\n let result = create_provider(&config, \"gpt-5.5\", Some(\"anthropic\"));\n assert!(result.is_err(), \"显式指定但无 api_key 时应报错\");\n }\n}\n"}, "9789c1c57769ce76b47e6d60780a01b7bcb080c1": {"t": 1785760744.9475083, "n": 13277, "h": "6709813b3fa4e669", "c": "use blockcell_core::config::ToolCallMode;\nuse blockcell_core::Config;\n\nuse crate::{\n AnthropicProvider, GeminiProvider, OllamaProvider, OpenAIProvider, OpenAIResponsesProvider,\n Provider,\n};\n\n/// 默认的 OpenAI 兼容 provider 的 api_base\npub(crate) fn default_api_base(provider_name: &str) -> &'static str {\n match provider_name {\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"deepseek\" => \"https://api.deepseek.com/v1\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"zhipu\" => \"https://open.bigmodel.cn/api/paas/v4\",\n \"kimi\" | \"moonshot\" => \"https://api.moonshot.cn/v1\",\n _ => \"https://api.openai.com/v1\",\n }\n}\n\n/// 从 model 字符串前缀推断 provider 名字\n/// 返回 None 表示无法从前缀推断(需要 fallback)\npub fn infer_provider_from_model(model: &str) -> Option<&'static str> {\n if model.starts_with(\"anthropic/\") || model.starts_with(\"claude-\") {\n Some(\"anthropic\")\n } else if model.starts_with(\"gemini/\") || model.starts_with(\"gemini-\") {\n Some(\"gemini\")\n } else if model.starts_with(\"ollama/\") {\n Some(\"ollama\")\n } else if model.starts_with(\"kimi\") || model.starts_with(\"moonshot\") {\n Some(\"kimi\")\n } else if model.starts_with(\"openai/\")\n || model.starts_with(\"gpt-\")\n || model.starts_with(\"o1\")\n || model.starts_with(\"o3\")\n {\n Some(\"openai\")\n } else if model.starts_with(\"deepseek\") {\n Some(\"deepseek\")\n } else if model.starts_with(\"groq/\") {\n Some(\"groq\")\n } else {\n None\n }\n}\n\n/// 从 config 中找到第一个配置了 api_key 的 provider 名字作为 fallback\n/// 这比旧的 get_api_key() 更合理:先按 model 前缀确认优先 provider\nfn fallback_provider_name(config: &Config) -> Option<&str> {\n let priority = [\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"deepseek\",\n \"kimi\",\n \"gemini\",\n \"zhipu\",\n \"groq\",\n \"vllm\",\n \"ollama\",\n ];\n for name in priority {\n if let Some(p) = config.providers.get(name) {\n if !p.api_key.is_empty() && p.api_key != \"dummy\" {\n return Some(name);\n }\n }\n }\n // ollama 特殊处理:不需要真实 api_key\n if config.providers.contains_key(\"ollama\") {\n return Some(\"ollama\");\n }\n None\n}\n\n/// 统一的 provider 创建入口。\n///\n/// 解析优先级:\n/// 1. `explicit_provider` 参数(来自 config.agents.defaults.provider 或 evolution_provider)\n/// 2. model 字符串前缀推断(如 \"anthropic/claude-...\" → anthropic)\n/// 3. config 中第一个有效 api_key 的 provider(fallback)\n///\n/// 对于 explicit_provider:\n/// - 如果 config.providers 中找不到对应配置,返回 Err\n/// - ollama 不需要 api_key,不报错\npub fn create_provider(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n) -> anyhow::Result> {\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\npub fn create_provider_with_tool_mode(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n tool_call_mode: Option,\n temperature_override: Option,\n) -> anyhow::Result> {\n let max_tokens = config.agents.defaults.max_tokens;\n let temperature = temperature_override.unwrap_or(config.agents.defaults.temperature);\n let reasoning_effort = config.agents.defaults.reasoning_effort.as_deref();\n\n // 优先级1:显式指定\n // 优先级2:model 前缀推断\n // 优先级3:config fallback\n let effective_provider: &str = if let Some(ep) = explicit_provider {\n ep\n } else if let Some(inferred) = infer_provider_from_model(model) {\n inferred\n } else if let Some(fallback) = fallback_provider_name(config) {\n fallback\n } else {\n return Err(anyhow::anyhow!(\n \"No LLM provider configured. Set 'provider' in config, use a recognized model prefix \\\n (e.g. 'anthropic/claude-...', 'gpt-4o', 'gemini-...'), or add an API key to providers section.\"\n ));\n };\n\n // 获取 provider 配置\n let provider_cfg = config.providers.get(effective_provider);\n\n // 对于显式指定的 provider(非 ollama),必须有配置且有 api_key\n if explicit_provider.is_some() && effective_provider != \"ollama\" {\n match provider_cfg {\n None => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but not found in providers section\",\n effective_provider\n ));\n }\n Some(cfg) if cfg.api_key.is_empty() || cfg.api_key == \"dummy\" => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but has no API key\",\n effective_provider\n ));\n }\n _ => {}\n }\n }\n\n let empty_cfg = blockcell_core::config::ProviderConfig::default();\n let resolved_cfg = provider_cfg.unwrap_or(&empty_cfg);\n\n // 计算代理参数:\n // provider_proxy = providers..proxy(None=未配置,Some(\"\")=强制直连,Some(url)=该provider专用)\n // global_proxy = network.proxy\n // no_proxy = network.no_proxy\n let provider_proxy = resolved_cfg.proxy.as_deref();\n let global_proxy = config.network.proxy.as_deref();\n let no_proxy = &config.network.no_proxy;\n\n match effective_provider {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box)\n }\n _ => {\n // 对于自定义 provider 名(非内置),用 api_type 决定使用哪种协议实现\n match resolved_cfg.api_type.as_str() {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box)\n }\n \"openai_responses\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIResponsesProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n )?) as Box)\n }\n _ => {\n // 默认:OpenAI 兼容(openrouter, openai, deepseek, groq, zhipu, vllm, kimi 等)\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n effective_provider,\n reasoning_effort,\n )?) as Box)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result> {\n let model = &config.agents.defaults.model;\n let explicit_provider = config.agents.defaults.provider.as_deref();\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\n/// 为自进化创建独立的 provider\n/// 优先级:evolutionProvider > evolution_model 前缀 > provider > model 前缀 > fallback\npub fn create_evolution_provider(config: &Config) -> anyhow::Result> {\n let model = config\n .agents\n .defaults\n .evolution_model\n .as_deref()\n .unwrap_or(&config.agents.defaults.model);\n\n // evolution_provider 显式 > 主 provider 显式(作为 evolution fallback)\n let explicit_provider = config\n .agents\n .defaults\n .evolution_provider\n .as_deref()\n .or(config.agents.defaults.provider.as_deref());\n\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_infer_provider_from_model() {\n assert_eq!(\n infer_provider_from_model(\"anthropic/claude-sonnet-4\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"claude-3-5-sonnet\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini-2.0-flash\"),\n Some(\"gemini\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini/gemini-pro\"),\n Some(\"gemini\")\n );\n assert_eq!(infer_provider_from_model(\"ollama/llama3\"), Some(\"ollama\"));\n assert_eq!(infer_provider_from_model(\"kimi-moonshot-v1\"), Some(\"kimi\"));\n assert_eq!(infer_provider_from_model(\"gpt-4o\"), Some(\"openai\"));\n assert_eq!(\n infer_provider_from_model(\"deepseek-coder\"),\n Some(\"deepseek\")\n );\n assert_eq!(infer_provider_from_model(\"some-unknown-model\"), None);\n }\n\n #[test]\n fn test_create_provider_explicit_wins() {\n let mut config = Config::default();\n // 配置 openai provider\n config.providers.get_mut(\"openai\").unwrap().api_key = \"sk-test\".to_string();\n // model 是 anthropic 前缀,但显式指定 openai\n let result = create_provider(&config, \"anthropic/claude-sonnet-4\", Some(\"openai\"));\n assert!(result.is_ok(), \"显式 provider 应该覆盖 model 前缀\");\n }\n\n #[test]\n fn test_create_provider_model_prefix() {\n let mut config = Config::default();\n config.providers.get_mut(\"anthropic\").unwrap().api_key = \"sk-ant-test\".to_string();\n let result = create_provider(&config, \"claude-3-5-sonnet\", None);\n assert!(result.is_ok(), \"model 前缀应该推断 anthropic\");\n }\n\n #[test]\n fn test_create_provider_ollama_no_key_needed() {\n let config = Config::default();\n // ollama 不需要 api_key\n let result = create_provider(&config, \"llama3\", Some(\"ollama\"));\n assert!(result.is_ok(), \"ollama 不需要 api_key\");\n }\n\n #[test]\n fn test_create_provider_no_config_fails() {\n let mut config = Config::default();\n // 清空所有 api_key,并移除 ollama(ollama 不需要 api_key 会被作为 fallback)\n for p in config.providers.values_mut() {\n p.api_key = String::new();\n }\n config.providers.remove(\"ollama\");\n let result = create_provider(&config, \"some-unknown-model\", None);\n assert!(result.is_err(), \"没有任何配置时应报错\");\n }\n\n #[test]\n fn test_create_provider_explicit_missing_fails() {\n let config = Config::default(); // anthropic key 为空\n let result = create_provider(&config, \"gpt-4o\", Some(\"anthropic\"));\n assert!(result.is_err(), \"显式指定但无 api_key 时应报错\");\n }\n}\n"}, "5fb0b513024d6af592e8b621c912c110baff803b": {"t": 1785760747.4275463, "n": 13269, "h": "f406cd174f0a84ab", "c": "use blockcell_core::config::ToolCallMode;\nuse blockcell_core::Config;\n\nuse crate::{\n AnthropicProvider, GeminiProvider, OllamaProvider, OpenAIProvider, OpenAIResponsesProvider,\n Provider,\n};\n\n/// 默认的 OpenAI 兼容 provider 的 api_base\npub(crate) fn default_api_base(provider_name: &str) -> &'static str {\n match provider_name {\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"deepseek\" => \"https://api.deepseek.com/v1\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"zhipu\" => \"https://open.bigmodel.cn/api/paas/v4\",\n \"kimi\" | \"moonshot\" => \"https://api.moonshot.cn/v1\",\n _ => \"https://api.openai.com/v1\",\n }\n}\n\n/// 从 model 字符串前缀推断 provider 名字\n/// 返回 None 表示无法从前缀推断(需要 fallback)\npub fn infer_provider_from_model(model: &str) -> Option<&'static str> {\n if model.starts_with(\"anthropic/\") || model.starts_with(\"claude-\") {\n Some(\"anthropic\")\n } else if model.starts_with(\"gemini/\") || model.starts_with(\"gemini-\") {\n Some(\"gemini\")\n } else if model.starts_with(\"ollama/\") {\n Some(\"ollama\")\n } else if model.starts_with(\"kimi\") || model.starts_with(\"moonshot\") {\n Some(\"kimi\")\n } else if model.starts_with(\"openai/\")\n || model.starts_with(\"gpt-\")\n || model.starts_with(\"o1\")\n || model.starts_with(\"o3\")\n {\n Some(\"openai\")\n } else if model.starts_with(\"deepseek\") {\n Some(\"deepseek\")\n } else if model.starts_with(\"groq/\") {\n Some(\"groq\")\n } else {\n None\n }\n}\n\n/// 从 config 中找到第一个配置了 api_key 的 provider 名字作为 fallback\n/// 这比旧的 get_api_key() 更合理:先按 model 前缀确认优先 provider\nfn fallback_provider_name(config: &Config) -> Option<&str> {\n let priority = [\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"deepseek\",\n \"kimi\",\n \"gemini\",\n \"zhipu\",\n \"groq\",\n \"vllm\",\n \"ollama\",\n ];\n for name in priority {\n if let Some(p) = config.providers.get(name) {\n if !p.api_key.is_empty() && p.api_key != \"dummy\" {\n return Some(name);\n }\n }\n }\n // ollama 特殊处理:不需要真实 api_key\n if config.providers.contains_key(\"ollama\") {\n return Some(\"ollama\");\n }\n None\n}\n\n/// 统一的 provider 创建入口。\n///\n/// 解析优先级:\n/// 1. `explicit_provider` 参数(来自 config.agents.defaults.provider 或 evolution_provider)\n/// 2. model 字符串前缀推断(如 \"anthropic/claude-...\" → anthropic)\n/// 3. config 中第一个有效 api_key 的 provider(fallback)\n///\n/// 对于 explicit_provider:\n/// - 如果 config.providers 中找不到对应配置,返回 Err\n/// - ollama 不需要 api_key,不报错\npub fn create_provider(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n) -> anyhow::Result> {\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\npub fn create_provider_with_tool_mode(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n tool_call_mode: Option,\n temperature_override: Option,\n) -> anyhow::Result> {\n let max_tokens = config.agents.defaults.max_tokens;\n let temperature = temperature_override.unwrap_or(config.agents.defaults.temperature);\n let reasoning_effort = config.agents.defaults.reasoning_effort.as_deref();\n\n // 优先级1:显式指定\n // 优先级2:model 前缀推断\n // 优先级3:config fallback\n let effective_provider: &str = if let Some(ep) = explicit_provider {\n ep\n } else if let Some(inferred) = infer_provider_from_model(model) {\n inferred\n } else if let Some(fallback) = fallback_provider_name(config) {\n fallback\n } else {\n return Err(anyhow::anyhow!(\n \"No LLM provider configured. Set 'provider' in config, use a recognized model prefix \\\n (e.g. 'anthropic/claude-...', 'gpt-4o', 'gemini-...'), or add an API key to providers section.\"\n ));\n };\n\n // 获取 provider 配置\n let provider_cfg = config.providers.get(effective_provider);\n\n // 对于显式指定的 provider(非 ollama),必须有配置且有 api_key\n if explicit_provider.is_some() && effective_provider != \"ollama\" {\n match provider_cfg {\n None => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but not found in providers section\",\n effective_provider\n ));\n }\n Some(cfg) if cfg.api_key.is_empty() || cfg.api_key == \"dummy\" => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but has no API key\",\n effective_provider\n ));\n }\n _ => {}\n }\n }\n\n let empty_cfg = blockcell_core::config::ProviderConfig::default();\n let resolved_cfg = provider_cfg.unwrap_or(&empty_cfg);\n\n // 计算代理参数:\n // provider_proxy = providers..proxy(None=未配置,Some(\"\")=强制直连,Some(url)=该provider专用)\n // global_proxy = network.proxy\n // no_proxy = network.no_proxy\n let provider_proxy = resolved_cfg.proxy.as_deref();\n let global_proxy = config.network.proxy.as_deref();\n let no_proxy = &config.network.no_proxy;\n\n match effective_provider {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n _ => {\n // 对于自定义 provider 名(非内置),用 api_type 决定使用哪种协议实现\n match resolved_cfg.api_type.as_str() {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n \"openai_responses\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIResponsesProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n )) as Box)\n }\n _ => {\n // 默认:OpenAI 兼容(openrouter, openai, deepseek, groq, zhipu, vllm, kimi 等)\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n effective_provider,\n reasoning_effort,\n )) as Box)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result> {\n let model = &config.agents.defaults.model;\n let explicit_provider = config.agents.defaults.provider.as_deref();\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\n/// 为自进化创建独立的 provider\n/// 优先级:evolutionProvider > evolution_model 前缀 > provider > model 前缀 > fallback\npub fn create_evolution_provider(config: &Config) -> anyhow::Result> {\n let model = config\n .agents\n .defaults\n .evolution_model\n .as_deref()\n .unwrap_or(&config.agents.defaults.model);\n\n // evolution_provider 显式 > 主 provider 显式(作为 evolution fallback)\n let explicit_provider = config\n .agents\n .defaults\n .evolution_provider\n .as_deref()\n .or(config.agents.defaults.provider.as_deref());\n\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_infer_provider_from_model() {\n assert_eq!(\n infer_provider_from_model(\"anthropic/claude-sonnet-4\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"claude-3-5-sonnet\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini-2.0-flash\"),\n Some(\"gemini\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini/gemini-pro\"),\n Some(\"gemini\")\n );\n assert_eq!(infer_provider_from_model(\"ollama/llama3\"), Some(\"ollama\"));\n assert_eq!(infer_provider_from_model(\"kimi-moonshot-v1\"), Some(\"kimi\"));\n assert_eq!(infer_provider_from_model(\"gpt-4o\"), Some(\"openai\"));\n assert_eq!(\n infer_provider_from_model(\"deepseek-coder\"),\n Some(\"deepseek\")\n );\n assert_eq!(infer_provider_from_model(\"some-unknown-model\"), None);\n }\n\n #[test]\n fn test_create_provider_explicit_wins() {\n let mut config = Config::default();\n // 配置 openai provider\n config.providers.get_mut(\"openai\").unwrap().api_key = \"sk-test\".to_string();\n // model 是 anthropic 前缀,但显式指定 openai\n let result = create_provider(&config, \"anthropic/claude-sonnet-4\", Some(\"openai\"));\n assert!(result.is_ok(), \"显式 provider 应该覆盖 model 前缀\");\n }\n\n #[test]\n fn test_create_provider_model_prefix() {\n let mut config = Config::default();\n config.providers.get_mut(\"anthropic\").unwrap().api_key = \"sk-ant-test\".to_string();\n let result = create_provider(&config, \"claude-3-5-sonnet\", None);\n assert!(result.is_ok(), \"model 前缀应该推断 anthropic\");\n }\n\n #[test]\n fn test_create_provider_ollama_no_key_needed() {\n let config = Config::default();\n // ollama 不需要 api_key\n let result = create_provider(&config, \"llama3\", Some(\"ollama\"));\n assert!(result.is_ok(), \"ollama 不需要 api_key\");\n }\n\n #[test]\n fn test_create_provider_no_config_fails() {\n let mut config = Config::default();\n // 清空所有 api_key,并移除 ollama(ollama 不需要 api_key 会被作为 fallback)\n for p in config.providers.values_mut() {\n p.api_key = String::new();\n }\n config.providers.remove(\"ollama\");\n let result = create_provider(&config, \"some-unknown-model\", None);\n assert!(result.is_err(), \"没有任何配置时应报错\");\n }\n\n #[test]\n fn test_create_provider_explicit_missing_fails() {\n let config = Config::default(); // anthropic key 为空\n let result = create_provider(&config, \"gpt-4o\", Some(\"anthropic\"));\n assert!(result.is_err(), \"显式指定但无 api_key 时应报错\");\n }\n}\n"}, "45dca16ffc6d6d3ee141b872bf2e4490b465b5c5": {"t": 1785760748.8328495, "n": 13104, "h": "6088d6b900019ad9", "c": "use blockcell_core::config::ToolCallMode;\nuse blockcell_core::Config;\n\nuse crate::{\n AnthropicProvider, GeminiProvider, OllamaProvider, OpenAIProvider, OpenAIResponsesProvider,\n Provider,\n};\n\n/// 默认的 OpenAI 兼容 provider 的 api_base\npub(crate) fn default_api_base(provider_name: &str) -> &'static str {\n match provider_name {\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"deepseek\" => \"https://api.deepseek.com/v1\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"zhipu\" => \"https://open.bigmodel.cn/api/paas/v4\",\n \"kimi\" | \"moonshot\" => \"https://api.moonshot.cn/v1\",\n _ => \"https://api.openai.com/v1\",\n }\n}\n\n/// 从 model 字符串前缀推断 provider 名字\n/// 返回 None 表示无法从前缀推断(需要 fallback)\npub fn infer_provider_from_model(model: &str) -> Option<&'static str> {\n if model.starts_with(\"anthropic/\") || model.starts_with(\"claude-\") {\n Some(\"anthropic\")\n } else if model.starts_with(\"gemini/\") || model.starts_with(\"gemini-\") {\n Some(\"gemini\")\n } else if model.starts_with(\"ollama/\") {\n Some(\"ollama\")\n } else if model.starts_with(\"kimi\") || model.starts_with(\"moonshot\") {\n Some(\"kimi\")\n } else if model.starts_with(\"openai/\")\n || model.starts_with(\"gpt-\")\n || model.starts_with(\"o1\")\n || model.starts_with(\"o3\")\n {\n Some(\"openai\")\n } else if model.starts_with(\"deepseek\") {\n Some(\"deepseek\")\n } else if model.starts_with(\"groq/\") {\n Some(\"groq\")\n } else {\n None\n }\n}\n\n/// 从 config 中找到第一个配置了 api_key 的 provider 名字作为 fallback\n/// 这比旧的 get_api_key() 更合理:先按 model 前缀确认优先 provider\nfn fallback_provider_name(config: &Config) -> Option<&str> {\n let priority = [\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"deepseek\",\n \"kimi\",\n \"gemini\",\n \"zhipu\",\n \"groq\",\n \"vllm\",\n \"ollama\",\n ];\n for name in priority {\n if let Some(p) = config.providers.get(name) {\n if !p.api_key.is_empty() && p.api_key != \"dummy\" {\n return Some(name);\n }\n }\n }\n // ollama 特殊处理:不需要真实 api_key\n if config.providers.contains_key(\"ollama\") {\n return Some(\"ollama\");\n }\n None\n}\n\n/// 统一的 provider 创建入口。\n///\n/// 解析优先级:\n/// 1. `explicit_provider` 参数(来自 config.agents.defaults.provider 或 evolution_provider)\n/// 2. model 字符串前缀推断(如 \"anthropic/claude-...\" → anthropic)\n/// 3. config 中第一个有效 api_key 的 provider(fallback)\n///\n/// 对于 explicit_provider:\n/// - 如果 config.providers 中找不到对应配置,返回 Err\n/// - ollama 不需要 api_key,不报错\npub fn create_provider(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n) -> anyhow::Result> {\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\npub fn create_provider_with_tool_mode(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n tool_call_mode: Option,\n temperature_override: Option,\n) -> anyhow::Result> {\n let max_tokens = config.agents.defaults.max_tokens;\n let temperature = temperature_override.unwrap_or(config.agents.defaults.temperature);\n\n // 优先级1:显式指定\n // 优先级2:model 前缀推断\n // 优先级3:config fallback\n let effective_provider: &str = if let Some(ep) = explicit_provider {\n ep\n } else if let Some(inferred) = infer_provider_from_model(model) {\n inferred\n } else if let Some(fallback) = fallback_provider_name(config) {\n fallback\n } else {\n return Err(anyhow::anyhow!(\n \"No LLM provider configured. Set 'provider' in config, use a recognized model prefix \\\n (e.g. 'anthropic/claude-...', 'gpt-4o', 'gemini-...'), or add an API key to providers section.\"\n ));\n };\n\n // 获取 provider 配置\n let provider_cfg = config.providers.get(effective_provider);\n\n // 对于显式指定的 provider(非 ollama),必须有配置且有 api_key\n if explicit_provider.is_some() && effective_provider != \"ollama\" {\n match provider_cfg {\n None => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but not found in providers section\",\n effective_provider\n ));\n }\n Some(cfg) if cfg.api_key.is_empty() || cfg.api_key == \"dummy\" => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but has no API key\",\n effective_provider\n ));\n }\n _ => {}\n }\n }\n\n let empty_cfg = blockcell_core::config::ProviderConfig::default();\n let resolved_cfg = provider_cfg.unwrap_or(&empty_cfg);\n\n // 计算代理参数:\n // provider_proxy = providers..proxy(None=未配置,Some(\"\")=强制直连,Some(url)=该provider专用)\n // global_proxy = network.proxy\n // no_proxy = network.no_proxy\n let provider_proxy = resolved_cfg.proxy.as_deref();\n let global_proxy = config.network.proxy.as_deref();\n let no_proxy = &config.network.no_proxy;\n\n match effective_provider {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n _ => {\n // 对于自定义 provider 名(非内置),用 api_type 决定使用哪种协议实现\n match resolved_cfg.api_type.as_str() {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n \"openai_responses\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIResponsesProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n )) as Box)\n }\n _ => {\n // 默认:OpenAI 兼容(openrouter, openai, deepseek, groq, zhipu, vllm, kimi 等)\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n )) as Box)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result> {\n let model = &config.agents.defaults.model;\n let explicit_provider = config.agents.defaults.provider.as_deref();\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\n/// 为自进化创建独立的 provider\n/// 优先级:evolutionProvider > evolution_model 前缀 > provider > model 前缀 > fallback\npub fn create_evolution_provider(config: &Config) -> anyhow::Result> {\n let model = config\n .agents\n .defaults\n .evolution_model\n .as_deref()\n .unwrap_or(&config.agents.defaults.model);\n\n // evolution_provider 显式 > 主 provider 显式(作为 evolution fallback)\n let explicit_provider = config\n .agents\n .defaults\n .evolution_provider\n .as_deref()\n .or(config.agents.defaults.provider.as_deref());\n\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_infer_provider_from_model() {\n assert_eq!(\n infer_provider_from_model(\"anthropic/claude-sonnet-4\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"claude-3-5-sonnet\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini-2.0-flash\"),\n Some(\"gemini\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini/gemini-pro\"),\n Some(\"gemini\")\n );\n assert_eq!(infer_provider_from_model(\"ollama/llama3\"), Some(\"ollama\"));\n assert_eq!(infer_provider_from_model(\"kimi-moonshot-v1\"), Some(\"kimi\"));\n assert_eq!(infer_provider_from_model(\"gpt-4o\"), Some(\"openai\"));\n assert_eq!(\n infer_provider_from_model(\"deepseek-coder\"),\n Some(\"deepseek\")\n );\n assert_eq!(infer_provider_from_model(\"some-unknown-model\"), None);\n }\n\n #[test]\n fn test_create_provider_explicit_wins() {\n let mut config = Config::default();\n // 配置 openai provider\n config.providers.get_mut(\"openai\").unwrap().api_key = \"sk-test\".to_string();\n // model 是 anthropic 前缀,但显式指定 openai\n let result = create_provider(&config, \"anthropic/claude-sonnet-4\", Some(\"openai\"));\n assert!(result.is_ok(), \"显式 provider 应该覆盖 model 前缀\");\n }\n\n #[test]\n fn test_create_provider_model_prefix() {\n let mut config = Config::default();\n config.providers.get_mut(\"anthropic\").unwrap().api_key = \"sk-ant-test\".to_string();\n let result = create_provider(&config, \"claude-3-5-sonnet\", None);\n assert!(result.is_ok(), \"model 前缀应该推断 anthropic\");\n }\n\n #[test]\n fn test_create_provider_ollama_no_key_needed() {\n let config = Config::default();\n // ollama 不需要 api_key\n let result = create_provider(&config, \"llama3\", Some(\"ollama\"));\n assert!(result.is_ok(), \"ollama 不需要 api_key\");\n }\n\n #[test]\n fn test_create_provider_no_config_fails() {\n let mut config = Config::default();\n // 清空所有 api_key,并移除 ollama(ollama 不需要 api_key 会被作为 fallback)\n for p in config.providers.values_mut() {\n p.api_key = String::new();\n }\n config.providers.remove(\"ollama\");\n let result = create_provider(&config, \"some-unknown-model\", None);\n assert!(result.is_err(), \"没有任何配置时应报错\");\n }\n\n #[test]\n fn test_create_provider_explicit_missing_fails() {\n let config = Config::default(); // anthropic key 为空\n let result = create_provider(&config, \"gpt-4o\", Some(\"anthropic\"));\n assert!(result.is_err(), \"显式指定但无 api_key 时应报错\");\n }\n}\n"}, "0dd85826fd822abd7da1b543c78440f6310729b8": {"t": 1785760750.0294752, "n": 13015, "h": "3964650f7d48fff4", "c": "use blockcell_core::config::ToolCallMode;\nuse blockcell_core::Config;\n\nuse crate::{\n AnthropicProvider, GeminiProvider, OllamaProvider, OpenAIProvider, OpenAIResponsesProvider,\n Provider,\n};\n\n/// 默认的 OpenAI 兼容 provider 的 api_base\npub(crate) fn default_api_base(provider_name: &str) -> &'static str {\n match provider_name {\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"deepseek\" => \"https://api.deepseek.com/v1\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"zhipu\" => \"https://open.bigmodel.cn/api/paas/v4\",\n \"kimi\" | \"moonshot\" => \"https://api.moonshot.cn/v1\",\n _ => \"https://api.openai.com/v1\",\n }\n}\n\n/// 从 model 字符串前缀推断 provider 名字\n/// 返回 None 表示无法从前缀推断(需要 fallback)\npub fn infer_provider_from_model(model: &str) -> Option<&'static str> {\n if model.starts_with(\"anthropic/\") || model.starts_with(\"claude-\") {\n Some(\"anthropic\")\n } else if model.starts_with(\"gemini/\") || model.starts_with(\"gemini-\") {\n Some(\"gemini\")\n } else if model.starts_with(\"ollama/\") {\n Some(\"ollama\")\n } else if model.starts_with(\"kimi\") || model.starts_with(\"moonshot\") {\n Some(\"kimi\")\n } else if model.starts_with(\"openai/\")\n || model.starts_with(\"gpt-\")\n || model.starts_with(\"o1\")\n || model.starts_with(\"o3\")\n {\n Some(\"openai\")\n } else if model.starts_with(\"deepseek\") {\n Some(\"deepseek\")\n } else if model.starts_with(\"groq/\") {\n Some(\"groq\")\n } else {\n None\n }\n}\n\n/// 从 config 中找到第一个配置了 api_key 的 provider 名字作为 fallback\n/// 这比旧的 get_api_key() 更合理:先按 model 前缀确认优先 provider\nfn fallback_provider_name(config: &Config) -> Option<&str> {\n let priority = [\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"deepseek\",\n \"kimi\",\n \"gemini\",\n \"zhipu\",\n \"groq\",\n \"vllm\",\n \"ollama\",\n ];\n for name in priority {\n if let Some(p) = config.providers.get(name) {\n if !p.api_key.is_empty() && p.api_key != \"dummy\" {\n return Some(name);\n }\n }\n }\n // ollama 特殊处理:不需要真实 api_key\n if config.providers.contains_key(\"ollama\") {\n return Some(\"ollama\");\n }\n None\n}\n\n/// 统一的 provider 创建入口。\n///\n/// 解析优先级:\n/// 1. `explicit_provider` 参数(来自 config.agents.defaults.provider 或 evolution_provider)\n/// 2. model 字符串前缀推断(如 \"anthropic/claude-...\" → anthropic)\n/// 3. config 中第一个有效 api_key 的 provider(fallback)\n///\n/// 对于 explicit_provider:\n/// - 如果 config.providers 中找不到对应配置,返回 Err\n/// - ollama 不需要 api_key,不报错\npub fn create_provider(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n) -> anyhow::Result> {\n create_provider_with_tool_mode(config, model, explicit_provider, None)\n}\n\npub fn create_provider_with_tool_mode(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n tool_call_mode: Option,\n) -> anyhow::Result> {\n let max_tokens = config.agents.defaults.max_tokens;\n let temperature = config.agents.defaults.temperature;\n\n // 优先级1:显式指定\n // 优先级2:model 前缀推断\n // 优先级3:config fallback\n let effective_provider: &str = if let Some(ep) = explicit_provider {\n ep\n } else if let Some(inferred) = infer_provider_from_model(model) {\n inferred\n } else if let Some(fallback) = fallback_provider_name(config) {\n fallback\n } else {\n return Err(anyhow::anyhow!(\n \"No LLM provider configured. Set 'provider' in config, use a recognized model prefix \\\n (e.g. 'anthropic/claude-...', 'gpt-4o', 'gemini-...'), or add an API key to providers section.\"\n ));\n };\n\n // 获取 provider 配置\n let provider_cfg = config.providers.get(effective_provider);\n\n // 对于显式指定的 provider(非 ollama),必须有配置且有 api_key\n if explicit_provider.is_some() && effective_provider != \"ollama\" {\n match provider_cfg {\n None => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but not found in providers section\",\n effective_provider\n ));\n }\n Some(cfg) if cfg.api_key.is_empty() || cfg.api_key == \"dummy\" => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but has no API key\",\n effective_provider\n ));\n }\n _ => {}\n }\n }\n\n let empty_cfg = blockcell_core::config::ProviderConfig::default();\n let resolved_cfg = provider_cfg.unwrap_or(&empty_cfg);\n\n // 计算代理参数:\n // provider_proxy = providers..proxy(None=未配置,Some(\"\")=强制直连,Some(url)=该provider专用)\n // global_proxy = network.proxy\n // no_proxy = network.no_proxy\n let provider_proxy = resolved_cfg.proxy.as_deref();\n let global_proxy = config.network.proxy.as_deref();\n let no_proxy = &config.network.no_proxy;\n\n match effective_provider {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n _ => {\n // 对于自定义 provider 名(非内置),用 api_type 决定使用哪种协议实现\n match resolved_cfg.api_type.as_str() {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n \"openai_responses\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIResponsesProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n )) as Box)\n }\n _ => {\n // 默认:OpenAI 兼容(openrouter, openai, deepseek, groq, zhipu, vllm, kimi 等)\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n )) as Box)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result> {\n let model = &config.agents.defaults.model;\n let explicit_provider = config.agents.defaults.provider.as_deref();\n create_provider_with_tool_mode(config, model, explicit_provider, None)\n}\n\n/// 为自进化创建独立的 provider\n/// 优先级:evolutionProvider > evolution_model 前缀 > provider > model 前缀 > fallback\npub fn create_evolution_provider(config: &Config) -> anyhow::Result> {\n let model = config\n .agents\n .defaults\n .evolution_model\n .as_deref()\n .unwrap_or(&config.agents.defaults.model);\n\n // evolution_provider 显式 > 主 provider 显式(作为 evolution fallback)\n let explicit_provider = config\n .agents\n .defaults\n .evolution_provider\n .as_deref()\n .or(config.agents.defaults.provider.as_deref());\n\n create_provider_with_tool_mode(config, model, explicit_provider, None)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_infer_provider_from_model() {\n assert_eq!(\n infer_provider_from_model(\"anthropic/claude-sonnet-4\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"claude-3-5-sonnet\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini-2.0-flash\"),\n Some(\"gemini\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini/gemini-pro\"),\n Some(\"gemini\")\n );\n assert_eq!(infer_provider_from_model(\"ollama/llama3\"), Some(\"ollama\"));\n assert_eq!(infer_provider_from_model(\"kimi-moonshot-v1\"), Some(\"kimi\"));\n assert_eq!(infer_provider_from_model(\"gpt-4o\"), Some(\"openai\"));\n assert_eq!(\n infer_provider_from_model(\"deepseek-coder\"),\n Some(\"deepseek\")\n );\n assert_eq!(infer_provider_from_model(\"some-unknown-model\"), None);\n }\n\n #[test]\n fn test_create_provider_explicit_wins() {\n let mut config = Config::default();\n // 配置 openai provider\n config.providers.get_mut(\"openai\").unwrap().api_key = \"sk-test\".to_string();\n // model 是 anthropic 前缀,但显式指定 openai\n let result = create_provider(&config, \"anthropic/claude-sonnet-4\", Some(\"openai\"));\n assert!(result.is_ok(), \"显式 provider 应该覆盖 model 前缀\");\n }\n\n #[test]\n fn test_create_provider_model_prefix() {\n let mut config = Config::default();\n config.providers.get_mut(\"anthropic\").unwrap().api_key = \"sk-ant-test\".to_string();\n let result = create_provider(&config, \"claude-3-5-sonnet\", None);\n assert!(result.is_ok(), \"model 前缀应该推断 anthropic\");\n }\n\n #[test]\n fn test_create_provider_ollama_no_key_needed() {\n let config = Config::default();\n // ollama 不需要 api_key\n let result = create_provider(&config, \"llama3\", Some(\"ollama\"));\n assert!(result.is_ok(), \"ollama 不需要 api_key\");\n }\n\n #[test]\n fn test_create_provider_no_config_fails() {\n let mut config = Config::default();\n // 清空所有 api_key,并移除 ollama(ollama 不需要 api_key 会被作为 fallback)\n for p in config.providers.values_mut() {\n p.api_key = String::new();\n }\n config.providers.remove(\"ollama\");\n let result = create_provider(&config, \"some-unknown-model\", None);\n assert!(result.is_err(), \"没有任何配置时应报错\");\n }\n\n #[test]\n fn test_create_provider_explicit_missing_fails() {\n let config = Config::default(); // anthropic key 为空\n let result = create_provider(&config, \"gpt-4o\", Some(\"anthropic\"));\n assert!(result.is_err(), \"显式指定但无 api_key 时应报错\");\n }\n}\n"}, "1d229c3dddb0c0214bd53384c71fe785645d9aa3": {"t": 1785760751.5472605, "n": 13004, "h": "cffb40e754f2d7be", "c": "use blockcell_core::config::ToolCallMode;\nuse blockcell_core::Config;\n\nuse crate::{\n AnthropicProvider, GeminiProvider, OllamaProvider, OpenAIProvider, OpenAIResponsesProvider,\n Provider,\n};\n\n/// 默认的 OpenAI 兼容 provider 的 api_base\nfn default_api_base(provider_name: &str) -> &'static str {\n match provider_name {\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"deepseek\" => \"https://api.deepseek.com/v1\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"zhipu\" => \"https://open.bigmodel.cn/api/paas/v4\",\n \"kimi\" | \"moonshot\" => \"https://api.moonshot.cn/v1\",\n _ => \"https://api.openai.com/v1\",\n }\n}\n\n/// 从 model 字符串前缀推断 provider 名字\n/// 返回 None 表示无法从前缀推断(需要 fallback)\npub fn infer_provider_from_model(model: &str) -> Option<&'static str> {\n if model.starts_with(\"anthropic/\") || model.starts_with(\"claude-\") {\n Some(\"anthropic\")\n } else if model.starts_with(\"gemini/\") || model.starts_with(\"gemini-\") {\n Some(\"gemini\")\n } else if model.starts_with(\"ollama/\") {\n Some(\"ollama\")\n } else if model.starts_with(\"kimi\") || model.starts_with(\"moonshot\") {\n Some(\"kimi\")\n } else if model.starts_with(\"openai/\")\n || model.starts_with(\"gpt-\")\n || model.starts_with(\"o1\")\n || model.starts_with(\"o3\")\n {\n Some(\"openai\")\n } else if model.starts_with(\"deepseek\") {\n Some(\"deepseek\")\n } else if model.starts_with(\"groq/\") {\n Some(\"groq\")\n } else {\n None\n }\n}\n\n/// 从 config 中找到第一个配置了 api_key 的 provider 名字作为 fallback\n/// 这比旧的 get_api_key() 更合理:先按 model 前缀确认优先 provider\nfn fallback_provider_name(config: &Config) -> Option<&str> {\n let priority = [\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"deepseek\",\n \"kimi\",\n \"gemini\",\n \"zhipu\",\n \"groq\",\n \"vllm\",\n \"ollama\",\n ];\n for name in priority {\n if let Some(p) = config.providers.get(name) {\n if !p.api_key.is_empty() && p.api_key != \"dummy\" {\n return Some(name);\n }\n }\n }\n // ollama 特殊处理:不需要真实 api_key\n if config.providers.contains_key(\"ollama\") {\n return Some(\"ollama\");\n }\n None\n}\n\n/// 统一的 provider 创建入口。\n///\n/// 解析优先级:\n/// 1. `explicit_provider` 参数(来自 config.agents.defaults.provider 或 evolution_provider)\n/// 2. model 字符串前缀推断(如 \"anthropic/claude-...\" → anthropic)\n/// 3. config 中第一个有效 api_key 的 provider(fallback)\n///\n/// 对于 explicit_provider:\n/// - 如果 config.providers 中找不到对应配置,返回 Err\n/// - ollama 不需要 api_key,不报错\npub fn create_provider(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n) -> anyhow::Result> {\n create_provider_with_tool_mode(config, model, explicit_provider, None)\n}\n\npub fn create_provider_with_tool_mode(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n tool_call_mode: Option,\n) -> anyhow::Result> {\n let max_tokens = config.agents.defaults.max_tokens;\n let temperature = config.agents.defaults.temperature;\n\n // 优先级1:显式指定\n // 优先级2:model 前缀推断\n // 优先级3:config fallback\n let effective_provider: &str = if let Some(ep) = explicit_provider {\n ep\n } else if let Some(inferred) = infer_provider_from_model(model) {\n inferred\n } else if let Some(fallback) = fallback_provider_name(config) {\n fallback\n } else {\n return Err(anyhow::anyhow!(\n \"No LLM provider configured. Set 'provider' in config, use a recognized model prefix \\\n (e.g. 'anthropic/claude-...', 'gpt-4o', 'gemini-...'), or add an API key to providers section.\"\n ));\n };\n\n // 获取 provider 配置\n let provider_cfg = config.providers.get(effective_provider);\n\n // 对于显式指定的 provider(非 ollama),必须有配置且有 api_key\n if explicit_provider.is_some() && effective_provider != \"ollama\" {\n match provider_cfg {\n None => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but not found in providers section\",\n effective_provider\n ));\n }\n Some(cfg) if cfg.api_key.is_empty() || cfg.api_key == \"dummy\" => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but has no API key\",\n effective_provider\n ));\n }\n _ => {}\n }\n }\n\n let empty_cfg = blockcell_core::config::ProviderConfig::default();\n let resolved_cfg = provider_cfg.unwrap_or(&empty_cfg);\n\n // 计算代理参数:\n // provider_proxy = providers..proxy(None=未配置,Some(\"\")=强制直连,Some(url)=该provider专用)\n // global_proxy = network.proxy\n // no_proxy = network.no_proxy\n let provider_proxy = resolved_cfg.proxy.as_deref();\n let global_proxy = config.network.proxy.as_deref();\n let no_proxy = &config.network.no_proxy;\n\n match effective_provider {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n _ => {\n // 对于自定义 provider 名(非内置),用 api_type 决定使用哪种协议实现\n match resolved_cfg.api_type.as_str() {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n \"openai_responses\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIResponsesProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n )) as Box)\n }\n _ => {\n // 默认:OpenAI 兼容(openrouter, openai, deepseek, groq, zhipu, vllm, kimi 等)\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n )) as Box)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result> {\n let model = &config.agents.defaults.model;\n let explicit_provider = config.agents.defaults.provider.as_deref();\n create_provider_with_tool_mode(config, model, explicit_provider, None)\n}\n\n/// 为自进化创建独立的 provider\n/// 优先级:evolutionProvider > evolution_model 前缀 > provider > model 前缀 > fallback\npub fn create_evolution_provider(config: &Config) -> anyhow::Result> {\n let model = config\n .agents\n .defaults\n .evolution_model\n .as_deref()\n .unwrap_or(&config.agents.defaults.model);\n\n // evolution_provider 显式 > 主 provider 显式(作为 evolution fallback)\n let explicit_provider = config\n .agents\n .defaults\n .evolution_provider\n .as_deref()\n .or(config.agents.defaults.provider.as_deref());\n\n create_provider_with_tool_mode(config, model, explicit_provider, None)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_infer_provider_from_model() {\n assert_eq!(\n infer_provider_from_model(\"anthropic/claude-sonnet-4\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"claude-3-5-sonnet\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini-2.0-flash\"),\n Some(\"gemini\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini/gemini-pro\"),\n Some(\"gemini\")\n );\n assert_eq!(infer_provider_from_model(\"ollama/llama3\"), Some(\"ollama\"));\n assert_eq!(infer_provider_from_model(\"kimi-moonshot-v1\"), Some(\"kimi\"));\n assert_eq!(infer_provider_from_model(\"gpt-4o\"), Some(\"openai\"));\n assert_eq!(\n infer_provider_from_model(\"deepseek-coder\"),\n Some(\"deepseek\")\n );\n assert_eq!(infer_provider_from_model(\"some-unknown-model\"), None);\n }\n\n #[test]\n fn test_create_provider_explicit_wins() {\n let mut config = Config::default();\n // 配置 openai provider\n config.providers.get_mut(\"openai\").unwrap().api_key = \"sk-test\".to_string();\n // model 是 anthropic 前缀,但显式指定 openai\n let result = create_provider(&config, \"anthropic/claude-sonnet-4\", Some(\"openai\"));\n assert!(result.is_ok(), \"显式 provider 应该覆盖 model 前缀\");\n }\n\n #[test]\n fn test_create_provider_model_prefix() {\n let mut config = Config::default();\n config.providers.get_mut(\"anthropic\").unwrap().api_key = \"sk-ant-test\".to_string();\n let result = create_provider(&config, \"claude-3-5-sonnet\", None);\n assert!(result.is_ok(), \"model 前缀应该推断 anthropic\");\n }\n\n #[test]\n fn test_create_provider_ollama_no_key_needed() {\n let config = Config::default();\n // ollama 不需要 api_key\n let result = create_provider(&config, \"llama3\", Some(\"ollama\"));\n assert!(result.is_ok(), \"ollama 不需要 api_key\");\n }\n\n #[test]\n fn test_create_provider_no_config_fails() {\n let mut config = Config::default();\n // 清空所有 api_key,并移除 ollama(ollama 不需要 api_key 会被作为 fallback)\n for p in config.providers.values_mut() {\n p.api_key = String::new();\n }\n config.providers.remove(\"ollama\");\n let result = create_provider(&config, \"some-unknown-model\", None);\n assert!(result.is_err(), \"没有任何配置时应报错\");\n }\n\n #[test]\n fn test_create_provider_explicit_missing_fails() {\n let config = Config::default(); // anthropic key 为空\n let result = create_provider(&config, \"gpt-4o\", Some(\"anthropic\"));\n assert!(result.is_err(), \"显式指定但无 api_key 时应报错\");\n }\n}\n"}, "487f177cde203a2b6f401b6d4214f426996dd426": {"t": 1785760753.3540056, "n": 13004, "h": "0753457cc21de7fe", "c": "use blockcell_core::config::ToolCallMode;\nuse blockcell_core::Config;\n\nuse crate::{\n AnthropicProvider, GeminiProvider, OllamaProvider, OpenAIProvider,\n OpenAIResponsesProvider, Provider,\n};\n\n/// 默认的 OpenAI 兼容 provider 的 api_base\nfn default_api_base(provider_name: &str) -> &'static str {\n match provider_name {\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"deepseek\" => \"https://api.deepseek.com/v1\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"zhipu\" => \"https://open.bigmodel.cn/api/paas/v4\",\n \"kimi\" | \"moonshot\" => \"https://api.moonshot.cn/v1\",\n _ => \"https://api.openai.com/v1\",\n }\n}\n\n/// 从 model 字符串前缀推断 provider 名字\n/// 返回 None 表示无法从前缀推断(需要 fallback)\npub fn infer_provider_from_model(model: &str) -> Option<&'static str> {\n if model.starts_with(\"anthropic/\") || model.starts_with(\"claude-\") {\n Some(\"anthropic\")\n } else if model.starts_with(\"gemini/\") || model.starts_with(\"gemini-\") {\n Some(\"gemini\")\n } else if model.starts_with(\"ollama/\") {\n Some(\"ollama\")\n } else if model.starts_with(\"kimi\") || model.starts_with(\"moonshot\") {\n Some(\"kimi\")\n } else if model.starts_with(\"openai/\")\n || model.starts_with(\"gpt-\")\n || model.starts_with(\"o1\")\n || model.starts_with(\"o3\")\n {\n Some(\"openai\")\n } else if model.starts_with(\"deepseek\") {\n Some(\"deepseek\")\n } else if model.starts_with(\"groq/\") {\n Some(\"groq\")\n } else {\n None\n }\n}\n\n/// 从 config 中找到第一个配置了 api_key 的 provider 名字作为 fallback\n/// 这比旧的 get_api_key() 更合理:先按 model 前缀确认优先 provider\nfn fallback_provider_name(config: &Config) -> Option<&str> {\n let priority = [\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"deepseek\",\n \"kimi\",\n \"gemini\",\n \"zhipu\",\n \"groq\",\n \"vllm\",\n \"ollama\",\n ];\n for name in priority {\n if let Some(p) = config.providers.get(name) {\n if !p.api_key.is_empty() && p.api_key != \"dummy\" {\n return Some(name);\n }\n }\n }\n // ollama 特殊处理:不需要真实 api_key\n if config.providers.contains_key(\"ollama\") {\n return Some(\"ollama\");\n }\n None\n}\n\n/// 统一的 provider 创建入口。\n///\n/// 解析优先级:\n/// 1. `explicit_provider` 参数(来自 config.agents.defaults.provider 或 evolution_provider)\n/// 2. model 字符串前缀推断(如 \"anthropic/claude-...\" → anthropic)\n/// 3. config 中第一个有效 api_key 的 provider(fallback)\n///\n/// 对于 explicit_provider:\n/// - 如果 config.providers 中找不到对应配置,返回 Err\n/// - ollama 不需要 api_key,不报错\npub fn create_provider(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n) -> anyhow::Result> {\n create_provider_with_tool_mode(config, model, explicit_provider, None)\n}\n\npub fn create_provider_with_tool_mode(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n tool_call_mode: Option,\n) -> anyhow::Result> {\n let max_tokens = config.agents.defaults.max_tokens;\n let temperature = config.agents.defaults.temperature;\n\n // 优先级1:显式指定\n // 优先级2:model 前缀推断\n // 优先级3:config fallback\n let effective_provider: &str = if let Some(ep) = explicit_provider {\n ep\n } else if let Some(inferred) = infer_provider_from_model(model) {\n inferred\n } else if let Some(fallback) = fallback_provider_name(config) {\n fallback\n } else {\n return Err(anyhow::anyhow!(\n \"No LLM provider configured. Set 'provider' in config, use a recognized model prefix \\\n (e.g. 'anthropic/claude-...', 'gpt-4o', 'gemini-...'), or add an API key to providers section.\"\n ));\n };\n\n // 获取 provider 配置\n let provider_cfg = config.providers.get(effective_provider);\n\n // 对于显式指定的 provider(非 ollama),必须有配置且有 api_key\n if explicit_provider.is_some() && effective_provider != \"ollama\" {\n match provider_cfg {\n None => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but not found in providers section\",\n effective_provider\n ));\n }\n Some(cfg) if cfg.api_key.is_empty() || cfg.api_key == \"dummy\" => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but has no API key\",\n effective_provider\n ));\n }\n _ => {}\n }\n }\n\n let empty_cfg = blockcell_core::config::ProviderConfig::default();\n let resolved_cfg = provider_cfg.unwrap_or(&empty_cfg);\n\n // 计算代理参数:\n // provider_proxy = providers..proxy(None=未配置,Some(\"\")=强制直连,Some(url)=该provider专用)\n // global_proxy = network.proxy\n // no_proxy = network.no_proxy\n let provider_proxy = resolved_cfg.proxy.as_deref();\n let global_proxy = config.network.proxy.as_deref();\n let no_proxy = &config.network.no_proxy;\n\n match effective_provider {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n _ => {\n // 对于自定义 provider 名(非内置),用 api_type 决定使用哪种协议实现\n match resolved_cfg.api_type.as_str() {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n \"openai_responses\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIResponsesProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n )) as Box)\n }\n _ => {\n // 默认:OpenAI 兼容(openrouter, openai, deepseek, groq, zhipu, vllm, kimi 等)\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n )) as Box)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result> {\n let model = &config.agents.defaults.model;\n let explicit_provider = config.agents.defaults.provider.as_deref();\n create_provider_with_tool_mode(config, model, explicit_provider, None)\n}\n\n/// 为自进化创建独立的 provider\n/// 优先级:evolutionProvider > evolution_model 前缀 > provider > model 前缀 > fallback\npub fn create_evolution_provider(config: &Config) -> anyhow::Result> {\n let model = config\n .agents\n .defaults\n .evolution_model\n .as_deref()\n .unwrap_or(&config.agents.defaults.model);\n\n // evolution_provider 显式 > 主 provider 显式(作为 evolution fallback)\n let explicit_provider = config\n .agents\n .defaults\n .evolution_provider\n .as_deref()\n .or(config.agents.defaults.provider.as_deref());\n\n create_provider_with_tool_mode(config, model, explicit_provider, None)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_infer_provider_from_model() {\n assert_eq!(\n infer_provider_from_model(\"anthropic/claude-sonnet-4\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"claude-3-5-sonnet\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini-2.0-flash\"),\n Some(\"gemini\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini/gemini-pro\"),\n Some(\"gemini\")\n );\n assert_eq!(infer_provider_from_model(\"ollama/llama3\"), Some(\"ollama\"));\n assert_eq!(infer_provider_from_model(\"kimi-moonshot-v1\"), Some(\"kimi\"));\n assert_eq!(infer_provider_from_model(\"gpt-4o\"), Some(\"openai\"));\n assert_eq!(\n infer_provider_from_model(\"deepseek-coder\"),\n Some(\"deepseek\")\n );\n assert_eq!(infer_provider_from_model(\"some-unknown-model\"), None);\n }\n\n #[test]\n fn test_create_provider_explicit_wins() {\n let mut config = Config::default();\n // 配置 openai provider\n config.providers.get_mut(\"openai\").unwrap().api_key = \"sk-test\".to_string();\n // model 是 anthropic 前缀,但显式指定 openai\n let result = create_provider(&config, \"anthropic/claude-sonnet-4\", Some(\"openai\"));\n assert!(result.is_ok(), \"显式 provider 应该覆盖 model 前缀\");\n }\n\n #[test]\n fn test_create_provider_model_prefix() {\n let mut config = Config::default();\n config.providers.get_mut(\"anthropic\").unwrap().api_key = \"sk-ant-test\".to_string();\n let result = create_provider(&config, \"claude-3-5-sonnet\", None);\n assert!(result.is_ok(), \"model 前缀应该推断 anthropic\");\n }\n\n #[test]\n fn test_create_provider_ollama_no_key_needed() {\n let config = Config::default();\n // ollama 不需要 api_key\n let result = create_provider(&config, \"llama3\", Some(\"ollama\"));\n assert!(result.is_ok(), \"ollama 不需要 api_key\");\n }\n\n #[test]\n fn test_create_provider_no_config_fails() {\n let mut config = Config::default();\n // 清空所有 api_key,并移除 ollama(ollama 不需要 api_key 会被作为 fallback)\n for p in config.providers.values_mut() {\n p.api_key = String::new();\n }\n config.providers.remove(\"ollama\");\n let result = create_provider(&config, \"some-unknown-model\", None);\n assert!(result.is_err(), \"没有任何配置时应报错\");\n }\n\n #[test]\n fn test_create_provider_explicit_missing_fails() {\n let config = Config::default(); // anthropic key 为空\n let result = create_provider(&config, \"gpt-4o\", Some(\"anthropic\"));\n assert!(result.is_err(), \"显式指定但无 api_key 时应报错\");\n }\n}\n"}, "e81f15ab9d3121d52e9ef25a396d5df380ee2b95": {"t": 1785760754.6279993, "n": 12501, "h": "0259d2f3a09264a3", "c": "use blockcell_core::Config;\n\nuse crate::{\n AnthropicProvider, GeminiProvider, OllamaProvider, OpenAIProvider,\n OpenAIResponsesProvider, Provider,\n};\n\n/// 默认的 OpenAI 兼容 provider 的 api_base\nfn default_api_base(provider_name: &str) -> &'static str {\n match provider_name {\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"deepseek\" => \"https://api.deepseek.com/v1\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"zhipu\" => \"https://open.bigmodel.cn/api/paas/v4\",\n \"kimi\" | \"moonshot\" => \"https://api.moonshot.cn/v1\",\n _ => \"https://api.openai.com/v1\",\n }\n}\n\n/// 从 model 字符串前缀推断 provider 名字\n/// 返回 None 表示无法从前缀推断(需要 fallback)\npub fn infer_provider_from_model(model: &str) -> Option<&'static str> {\n if model.starts_with(\"anthropic/\") || model.starts_with(\"claude-\") {\n Some(\"anthropic\")\n } else if model.starts_with(\"gemini/\") || model.starts_with(\"gemini-\") {\n Some(\"gemini\")\n } else if model.starts_with(\"ollama/\") {\n Some(\"ollama\")\n } else if model.starts_with(\"kimi\") || model.starts_with(\"moonshot\") {\n Some(\"kimi\")\n } else if model.starts_with(\"openai/\")\n || model.starts_with(\"gpt-\")\n || model.starts_with(\"o1\")\n || model.starts_with(\"o3\")\n {\n Some(\"openai\")\n } else if model.starts_with(\"deepseek\") {\n Some(\"deepseek\")\n } else if model.starts_with(\"groq/\") {\n Some(\"groq\")\n } else {\n None\n }\n}\n\n/// 从 config 中找到第一个配置了 api_key 的 provider 名字作为 fallback\n/// 这比旧的 get_api_key() 更合理:先按 model 前缀确认优先 provider\nfn fallback_provider_name(config: &Config) -> Option<&str> {\n let priority = [\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"deepseek\",\n \"kimi\",\n \"gemini\",\n \"zhipu\",\n \"groq\",\n \"vllm\",\n \"ollama\",\n ];\n for name in priority {\n if let Some(p) = config.providers.get(name) {\n if !p.api_key.is_empty() && p.api_key != \"dummy\" {\n return Some(name);\n }\n }\n }\n // ollama 特殊处理:不需要真实 api_key\n if config.providers.contains_key(\"ollama\") {\n return Some(\"ollama\");\n }\n None\n}\n\n/// 统一的 provider 创建入口。\n///\n/// 解析优先级:\n/// 1. `explicit_provider` 参数(来自 config.agents.defaults.provider 或 evolution_provider)\n/// 2. model 字符串前缀推断(如 \"anthropic/claude-...\" → anthropic)\n/// 3. config 中第一个有效 api_key 的 provider(fallback)\n///\n/// 对于 explicit_provider:\n/// - 如果 config.providers 中找不到对应配置,返回 Err\n/// - ollama 不需要 api_key,不报错\npub fn create_provider(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n) -> anyhow::Result> {\n let max_tokens = config.agents.defaults.max_tokens;\n let temperature = config.agents.defaults.temperature;\n\n // 优先级1:显式指定\n // 优先级2:model 前缀推断\n // 优先级3:config fallback\n let effective_provider: &str = if let Some(ep) = explicit_provider {\n ep\n } else if let Some(inferred) = infer_provider_from_model(model) {\n inferred\n } else if let Some(fallback) = fallback_provider_name(config) {\n fallback\n } else {\n return Err(anyhow::anyhow!(\n \"No LLM provider configured. Set 'provider' in config, use a recognized model prefix \\\n (e.g. 'anthropic/claude-...', 'gpt-4o', 'gemini-...'), or add an API key to providers section.\"\n ));\n };\n\n // 获取 provider 配置\n let provider_cfg = config.providers.get(effective_provider);\n\n // 对于显式指定的 provider(非 ollama),必须有配置且有 api_key\n if explicit_provider.is_some() && effective_provider != \"ollama\" {\n match provider_cfg {\n None => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but not found in providers section\",\n effective_provider\n ));\n }\n Some(cfg) if cfg.api_key.is_empty() || cfg.api_key == \"dummy\" => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but has no API key\",\n effective_provider\n ));\n }\n _ => {}\n }\n }\n\n let empty_cfg = blockcell_core::config::ProviderConfig::default();\n let resolved_cfg = provider_cfg.unwrap_or(&empty_cfg);\n\n // 计算代理参数:\n // provider_proxy = providers..proxy(None=未配置,Some(\"\")=强制直连,Some(url)=该provider专用)\n // global_proxy = network.proxy\n // no_proxy = network.no_proxy\n let provider_proxy = resolved_cfg.proxy.as_deref();\n let global_proxy = config.network.proxy.as_deref();\n let no_proxy = &config.network.no_proxy;\n\n match effective_provider {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n _ => {\n // 对于自定义 provider 名(非内置),用 api_type 决定使用哪种协议实现\n match resolved_cfg.api_type.as_str() {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n \"openai_responses\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIResponsesProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n _ => {\n // 默认:OpenAI 兼容(openrouter, openai, deepseek, groq, zhipu, vllm, kimi 等)\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result> {\n let model = &config.agents.defaults.model;\n let explicit_provider = config.agents.defaults.provider.as_deref();\n create_provider(config, model, explicit_provider)\n}\n\n/// 为自进化创建独立的 provider\n/// 优先级:evolutionProvider > evolution_model 前缀 > provider > model 前缀 > fallback\npub fn create_evolution_provider(config: &Config) -> anyhow::Result> {\n let model = config\n .agents\n .defaults\n .evolution_model\n .as_deref()\n .unwrap_or(&config.agents.defaults.model);\n\n // evolution_provider 显式 > 主 provider 显式(作为 evolution fallback)\n let explicit_provider = config\n .agents\n .defaults\n .evolution_provider\n .as_deref()\n .or(config.agents.defaults.provider.as_deref());\n\n create_provider(config, model, explicit_provider)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_infer_provider_from_model() {\n assert_eq!(\n infer_provider_from_model(\"anthropic/claude-sonnet-4\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"claude-3-5-sonnet\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini-2.0-flash\"),\n Some(\"gemini\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini/gemini-pro\"),\n Some(\"gemini\")\n );\n assert_eq!(infer_provider_from_model(\"ollama/llama3\"), Some(\"ollama\"));\n assert_eq!(infer_provider_from_model(\"kimi-moonshot-v1\"), Some(\"kimi\"));\n assert_eq!(infer_provider_from_model(\"gpt-4o\"), Some(\"openai\"));\n assert_eq!(\n infer_provider_from_model(\"deepseek-coder\"),\n Some(\"deepseek\")\n );\n assert_eq!(infer_provider_from_model(\"some-unknown-model\"), None);\n }\n\n #[test]\n fn test_create_provider_explicit_wins() {\n let mut config = Config::default();\n // 配置 openai provider\n config.providers.get_mut(\"openai\").unwrap().api_key = \"sk-test\".to_string();\n // model 是 anthropic 前缀,但显式指定 openai\n let result = create_provider(&config, \"anthropic/claude-sonnet-4\", Some(\"openai\"));\n assert!(result.is_ok(), \"显式 provider 应该覆盖 model 前缀\");\n }\n\n #[test]\n fn test_create_provider_model_prefix() {\n let mut config = Config::default();\n config.providers.get_mut(\"anthropic\").unwrap().api_key = \"sk-ant-test\".to_string();\n let result = create_provider(&config, \"claude-3-5-sonnet\", None);\n assert!(result.is_ok(), \"model 前缀应该推断 anthropic\");\n }\n\n #[test]\n fn test_create_provider_ollama_no_key_needed() {\n let config = Config::default();\n // ollama 不需要 api_key\n let result = create_provider(&config, \"llama3\", Some(\"ollama\"));\n assert!(result.is_ok(), \"ollama 不需要 api_key\");\n }\n\n #[test]\n fn test_create_provider_no_config_fails() {\n let mut config = Config::default();\n // 清空所有 api_key,并移除 ollama(ollama 不需要 api_key 会被作为 fallback)\n for p in config.providers.values_mut() {\n p.api_key = String::new();\n }\n config.providers.remove(\"ollama\");\n let result = create_provider(&config, \"some-unknown-model\", None);\n assert!(result.is_err(), \"没有任何配置时应报错\");\n }\n\n #[test]\n fn test_create_provider_explicit_missing_fails() {\n let config = Config::default(); // anthropic key 为空\n let result = create_provider(&config, \"gpt-4o\", Some(\"anthropic\"));\n assert!(result.is_err(), \"显式指定但无 api_key 时应报错\");\n }\n}\n"}, "cdf41c8a555327753c594c3e1ce6f46444dcdaeb": {"t": 1785760756.2807295, "n": 11786, "h": "f6265264f5ef8a57", "c": "use blockcell_core::Config;\n\nuse crate::{AnthropicProvider, GeminiProvider, OllamaProvider, OpenAIProvider, Provider};\n\n/// 默认的 OpenAI 兼容 provider 的 api_base\nfn default_api_base(provider_name: &str) -> &'static str {\n match provider_name {\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"deepseek\" => \"https://api.deepseek.com/v1\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"zhipu\" => \"https://open.bigmodel.cn/api/paas/v4\",\n \"kimi\" | \"moonshot\" => \"https://api.moonshot.cn/v1\",\n _ => \"https://api.openai.com/v1\",\n }\n}\n\n/// 从 model 字符串前缀推断 provider 名字\n/// 返回 None 表示无法从前缀推断(需要 fallback)\npub fn infer_provider_from_model(model: &str) -> Option<&'static str> {\n if model.starts_with(\"anthropic/\") || model.starts_with(\"claude-\") {\n Some(\"anthropic\")\n } else if model.starts_with(\"gemini/\") || model.starts_with(\"gemini-\") {\n Some(\"gemini\")\n } else if model.starts_with(\"ollama/\") {\n Some(\"ollama\")\n } else if model.starts_with(\"kimi\") || model.starts_with(\"moonshot\") {\n Some(\"kimi\")\n } else if model.starts_with(\"openai/\")\n || model.starts_with(\"gpt-\")\n || model.starts_with(\"o1\")\n || model.starts_with(\"o3\")\n {\n Some(\"openai\")\n } else if model.starts_with(\"deepseek\") {\n Some(\"deepseek\")\n } else if model.starts_with(\"groq/\") {\n Some(\"groq\")\n } else {\n None\n }\n}\n\n/// 从 config 中找到第一个配置了 api_key 的 provider 名字作为 fallback\n/// 这比旧的 get_api_key() 更合理:先按 model 前缀确认优先 provider\nfn fallback_provider_name(config: &Config) -> Option<&str> {\n let priority = [\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"deepseek\",\n \"kimi\",\n \"gemini\",\n \"zhipu\",\n \"groq\",\n \"vllm\",\n \"ollama\",\n ];\n for name in priority {\n if let Some(p) = config.providers.get(name) {\n if !p.api_key.is_empty() && p.api_key != \"dummy\" {\n return Some(name);\n }\n }\n }\n // ollama 特殊处理:不需要真实 api_key\n if config.providers.contains_key(\"ollama\") {\n return Some(\"ollama\");\n }\n None\n}\n\n/// 统一的 provider 创建入口。\n///\n/// 解析优先级:\n/// 1. `explicit_provider` 参数(来自 config.agents.defaults.provider 或 evolution_provider)\n/// 2. model 字符串前缀推断(如 \"anthropic/claude-...\" → anthropic)\n/// 3. config 中第一个有效 api_key 的 provider(fallback)\n///\n/// 对于 explicit_provider:\n/// - 如果 config.providers 中找不到对应配置,返回 Err\n/// - ollama 不需要 api_key,不报错\npub fn create_provider(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n) -> anyhow::Result> {\n let max_tokens = config.agents.defaults.max_tokens;\n let temperature = config.agents.defaults.temperature;\n\n // 优先级1:显式指定\n // 优先级2:model 前缀推断\n // 优先级3:config fallback\n let effective_provider: &str = if let Some(ep) = explicit_provider {\n ep\n } else if let Some(inferred) = infer_provider_from_model(model) {\n inferred\n } else if let Some(fallback) = fallback_provider_name(config) {\n fallback\n } else {\n return Err(anyhow::anyhow!(\n \"No LLM provider configured. Set 'provider' in config, use a recognized model prefix \\\n (e.g. 'anthropic/claude-...', 'gpt-4o', 'gemini-...'), or add an API key to providers section.\"\n ));\n };\n\n // 获取 provider 配置\n let provider_cfg = config.providers.get(effective_provider);\n\n // 对于显式指定的 provider(非 ollama),必须有配置且有 api_key\n if explicit_provider.is_some() && effective_provider != \"ollama\" {\n match provider_cfg {\n None => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but not found in providers section\",\n effective_provider\n ));\n }\n Some(cfg) if cfg.api_key.is_empty() || cfg.api_key == \"dummy\" => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but has no API key\",\n effective_provider\n ));\n }\n _ => {}\n }\n }\n\n let empty_cfg = blockcell_core::config::ProviderConfig::default();\n let resolved_cfg = provider_cfg.unwrap_or(&empty_cfg);\n\n // 计算代理参数:\n // provider_proxy = providers..proxy(None=未配置,Some(\"\")=强制直连,Some(url)=该provider专用)\n // global_proxy = network.proxy\n // no_proxy = network.no_proxy\n let provider_proxy = resolved_cfg.proxy.as_deref();\n let global_proxy = config.network.proxy.as_deref();\n let no_proxy = &config.network.no_proxy;\n\n match effective_provider {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n _ => {\n // 对于自定义 provider 名(非内置),用 api_type 决定使用哪种协议实现\n match resolved_cfg.api_type.as_str() {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n _ => {\n // 默认:OpenAI 兼容(openrouter, openai, deepseek, groq, zhipu, vllm, kimi 等)\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result> {\n let model = &config.agents.defaults.model;\n let explicit_provider = config.agents.defaults.provider.as_deref();\n create_provider(config, model, explicit_provider)\n}\n\n/// 为自进化创建独立的 provider\n/// 优先级:evolutionProvider > evolution_model 前缀 > provider > model 前缀 > fallback\npub fn create_evolution_provider(config: &Config) -> anyhow::Result> {\n let model = config\n .agents\n .defaults\n .evolution_model\n .as_deref()\n .unwrap_or(&config.agents.defaults.model);\n\n // evolution_provider 显式 > 主 provider 显式(作为 evolution fallback)\n let explicit_provider = config\n .agents\n .defaults\n .evolution_provider\n .as_deref()\n .or(config.agents.defaults.provider.as_deref());\n\n create_provider(config, model, explicit_provider)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_infer_provider_from_model() {\n assert_eq!(\n infer_provider_from_model(\"anthropic/claude-sonnet-4\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"claude-3-5-sonnet\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini-2.0-flash\"),\n Some(\"gemini\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini/gemini-pro\"),\n Some(\"gemini\")\n );\n assert_eq!(infer_provider_from_model(\"ollama/llama3\"), Some(\"ollama\"));\n assert_eq!(infer_provider_from_model(\"kimi-moonshot-v1\"), Some(\"kimi\"));\n assert_eq!(infer_provider_from_model(\"gpt-4o\"), Some(\"openai\"));\n assert_eq!(\n infer_provider_from_model(\"deepseek-coder\"),\n Some(\"deepseek\")\n );\n assert_eq!(infer_provider_from_model(\"some-unknown-model\"), None);\n }\n\n #[test]\n fn test_create_provider_explicit_wins() {\n let mut config = Config::default();\n // 配置 openai provider\n config.providers.get_mut(\"openai\").unwrap().api_key = \"sk-test\".to_string();\n // model 是 anthropic 前缀,但显式指定 openai\n let result = create_provider(&config, \"anthropic/claude-sonnet-4\", Some(\"openai\"));\n assert!(result.is_ok(), \"显式 provider 应该覆盖 model 前缀\");\n }\n\n #[test]\n fn test_create_provider_model_prefix() {\n let mut config = Config::default();\n config.providers.get_mut(\"anthropic\").unwrap().api_key = \"sk-ant-test\".to_string();\n let result = create_provider(&config, \"claude-3-5-sonnet\", None);\n assert!(result.is_ok(), \"model 前缀应该推断 anthropic\");\n }\n\n #[test]\n fn test_create_provider_ollama_no_key_needed() {\n let config = Config::default();\n // ollama 不需要 api_key\n let result = create_provider(&config, \"llama3\", Some(\"ollama\"));\n assert!(result.is_ok(), \"ollama 不需要 api_key\");\n }\n\n #[test]\n fn test_create_provider_no_config_fails() {\n let mut config = Config::default();\n // 清空所有 api_key,并移除 ollama(ollama 不需要 api_key 会被作为 fallback)\n for p in config.providers.values_mut() {\n p.api_key = String::new();\n }\n config.providers.remove(\"ollama\");\n let result = create_provider(&config, \"some-unknown-model\", None);\n assert!(result.is_err(), \"没有任何配置时应报错\");\n }\n\n #[test]\n fn test_create_provider_explicit_missing_fails() {\n let config = Config::default(); // anthropic key 为空\n let result = create_provider(&config, \"gpt-4o\", Some(\"anthropic\"));\n assert!(result.is_err(), \"显式指定但无 api_key 时应报错\");\n }\n}\n"}, "e153cdd0bf0e592f1e25c8ed441f36a57116a175": {"t": 1785760759.7292688, "n": 11586, "h": "1d1fae5eedf18bfd", "c": "use blockcell_core::Config;\n\nuse crate::{AnthropicProvider, GeminiProvider, OllamaProvider, OpenAIProvider, Provider};\n\n/// 默认的 OpenAI 兼容 provider 的 api_base\nfn default_api_base(provider_name: &str) -> &'static str {\n match provider_name {\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"deepseek\" => \"https://api.deepseek.com/v1\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"zhipu\" => \"https://open.bigmodel.cn/api/paas/v4\",\n \"kimi\" | \"moonshot\" => \"https://api.moonshot.cn/v1\",\n _ => \"https://api.openai.com/v1\",\n }\n}\n\n/// 从 model 字符串前缀推断 provider 名字\n/// 返回 None 表示无法从前缀推断(需要 fallback)\npub fn infer_provider_from_model(model: &str) -> Option<&'static str> {\n if model.starts_with(\"anthropic/\") || model.starts_with(\"claude-\") {\n Some(\"anthropic\")\n } else if model.starts_with(\"gemini/\") || model.starts_with(\"gemini-\") {\n Some(\"gemini\")\n } else if model.starts_with(\"ollama/\") {\n Some(\"ollama\")\n } else if model.starts_with(\"kimi\") || model.starts_with(\"moonshot\") {\n Some(\"kimi\")\n } else if model.starts_with(\"openai/\") || model.starts_with(\"gpt-\") || model.starts_with(\"o1\") || model.starts_with(\"o3\") {\n Some(\"openai\")\n } else if model.starts_with(\"deepseek\") {\n Some(\"deepseek\")\n } else if model.starts_with(\"groq/\") {\n Some(\"groq\")\n } else {\n None\n }\n}\n\n/// 从 config 中找到第一个配置了 api_key 的 provider 名字作为 fallback\n/// 这比旧的 get_api_key() 更合理:先按 model 前缀确认优先 provider\nfn fallback_provider_name(config: &Config) -> Option<&str> {\n let priority = [\n \"anthropic\", \"openai\", \"openrouter\", \"deepseek\", \"kimi\",\n \"gemini\", \"zhipu\", \"groq\", \"vllm\", \"ollama\",\n ];\n for name in priority {\n if let Some(p) = config.providers.get(name) {\n if !p.api_key.is_empty() && p.api_key != \"dummy\" {\n return Some(name);\n }\n }\n }\n // ollama 特殊处理:不需要真实 api_key\n if config.providers.contains_key(\"ollama\") {\n return Some(\"ollama\");\n }\n None\n}\n\n/// 统一的 provider 创建入口。\n///\n/// 解析优先级:\n/// 1. `explicit_provider` 参数(来自 config.agents.defaults.provider 或 evolution_provider)\n/// 2. model 字符串前缀推断(如 \"anthropic/claude-...\" → anthropic)\n/// 3. config 中第一个有效 api_key 的 provider(fallback)\n///\n/// 对于 explicit_provider:\n/// - 如果 config.providers 中找不到对应配置,返回 Err\n/// - ollama 不需要 api_key,不报错\npub fn create_provider(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n) -> anyhow::Result> {\n let max_tokens = config.agents.defaults.max_tokens;\n let temperature = config.agents.defaults.temperature;\n\n // 优先级1:显式指定\n // 优先级2:model 前缀推断\n // 优先级3:config fallback\n let effective_provider: &str = if let Some(ep) = explicit_provider {\n ep\n } else if let Some(inferred) = infer_provider_from_model(model) {\n inferred\n } else if let Some(fallback) = fallback_provider_name(config) {\n fallback\n } else {\n return Err(anyhow::anyhow!(\n \"No LLM provider configured. Set 'provider' in config, use a recognized model prefix \\\n (e.g. 'anthropic/claude-...', 'gpt-4o', 'gemini-...'), or add an API key to providers section.\"\n ));\n };\n\n // 获取 provider 配置\n let provider_cfg = config.providers.get(effective_provider);\n\n // 对于显式指定的 provider(非 ollama),必须有配置且有 api_key\n if explicit_provider.is_some() && effective_provider != \"ollama\" {\n match provider_cfg {\n None => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but not found in providers section\",\n effective_provider\n ));\n }\n Some(cfg) if cfg.api_key.is_empty() || cfg.api_key == \"dummy\" => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but has no API key\",\n effective_provider\n ));\n }\n _ => {}\n }\n }\n\n let empty_cfg = blockcell_core::config::ProviderConfig::default();\n let resolved_cfg = provider_cfg.unwrap_or(&empty_cfg);\n\n // 计算代理参数:\n // provider_proxy = providers..proxy(None=未配置,Some(\"\")=强制直连,Some(url)=该provider专用)\n // global_proxy = network.proxy\n // no_proxy = network.no_proxy\n let provider_proxy = resolved_cfg.proxy.as_deref();\n let global_proxy = config.network.proxy.as_deref();\n let no_proxy = &config.network.no_proxy;\n\n match effective_provider {\n \"anthropic\" => {\n Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n \"gemini\" => {\n Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n \"ollama\" => {\n let api_base = resolved_cfg.api_base.as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n _ => {\n // 对于自定义 provider 名(非内置),用 api_type 决定使用哪种协议实现\n match resolved_cfg.api_type.as_str() {\n \"anthropic\" => {\n Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n \"gemini\" => {\n Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n \"ollama\" => {\n let api_base = resolved_cfg.api_base.as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n _ => {\n // 默认:OpenAI 兼容(openrouter, openai, deepseek, groq, zhipu, vllm, kimi 等)\n let api_base = resolved_cfg.api_base.as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result> {\n let model = &config.agents.defaults.model;\n let explicit_provider = config.agents.defaults.provider.as_deref();\n create_provider(config, model, explicit_provider)\n}\n\n/// 为自进化创建独立的 provider\n/// 优先级:evolutionProvider > evolution_model 前缀 > provider > model 前缀 > fallback\npub fn create_evolution_provider(config: &Config) -> anyhow::Result> {\n let model = config.agents.defaults.evolution_model.as_deref()\n .unwrap_or(&config.agents.defaults.model);\n\n // evolution_provider 显式 > 主 provider 显式(作为 evolution fallback)\n let explicit_provider = config.agents.defaults.evolution_provider.as_deref()\n .or(config.agents.defaults.provider.as_deref());\n\n create_provider(config, model, explicit_provider)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_infer_provider_from_model() {\n assert_eq!(infer_provider_from_model(\"anthropic/claude-sonnet-4\"), Some(\"anthropic\"));\n assert_eq!(infer_provider_from_model(\"claude-3-5-sonnet\"), Some(\"anthropic\"));\n assert_eq!(infer_provider_from_model(\"gemini-2.0-flash\"), Some(\"gemini\"));\n assert_eq!(infer_provider_from_model(\"gemini/gemini-pro\"), Some(\"gemini\"));\n assert_eq!(infer_provider_from_model(\"ollama/llama3\"), Some(\"ollama\"));\n assert_eq!(infer_provider_from_model(\"kimi-moonshot-v1\"), Some(\"kimi\"));\n assert_eq!(infer_provider_from_model(\"gpt-4o\"), Some(\"openai\"));\n assert_eq!(infer_provider_from_model(\"deepseek-coder\"), Some(\"deepseek\"));\n assert_eq!(infer_provider_from_model(\"some-unknown-model\"), None);\n }\n\n #[test]\n fn test_create_provider_explicit_wins() {\n let mut config = Config::default();\n // 配置 openai provider\n config.providers.get_mut(\"openai\").unwrap().api_key = \"sk-test\".to_string();\n // model 是 anthropic 前缀,但显式指定 openai\n let result = create_provider(&config, \"anthropic/claude-sonnet-4\", Some(\"openai\"));\n assert!(result.is_ok(), \"显式 provider 应该覆盖 model 前缀\");\n }\n\n #[test]\n fn test_create_provider_model_prefix() {\n let mut config = Config::default();\n config.providers.get_mut(\"anthropic\").unwrap().api_key = \"sk-ant-test\".to_string();\n let result = create_provider(&config, \"claude-3-5-sonnet\", None);\n assert!(result.is_ok(), \"model 前缀应该推断 anthropic\");\n }\n\n #[test]\n fn test_create_provider_ollama_no_key_needed() {\n let config = Config::default();\n // ollama 不需要 api_key\n let result = create_provider(&config, \"llama3\", Some(\"ollama\"));\n assert!(result.is_ok(), \"ollama 不需要 api_key\");\n }\n\n #[test]\n fn test_create_provider_no_config_fails() {\n let mut config = Config::default();\n // 清空所有 api_key,并移除 ollama(ollama 不需要 api_key 会被作为 fallback)\n for p in config.providers.values_mut() {\n p.api_key = String::new();\n }\n config.providers.remove(\"ollama\");\n let result = create_provider(&config, \"some-unknown-model\", None);\n assert!(result.is_err(), \"没有任何配置时应报错\");\n }\n\n #[test]\n fn test_create_provider_explicit_missing_fails() {\n let config = Config::default(); // anthropic key 为空\n let result = create_provider(&config, \"gpt-4o\", Some(\"anthropic\"));\n assert!(result.is_err(), \"显式指定但无 api_key 时应报错\");\n }\n}\n"}, "d8b2f11b1fc312c8ae8fba0295eecc4621eb3c13": {"t": 1785760762.1013916, "n": 9825, "h": "19770309fcbb72db", "c": "use blockcell_core::Config;\n\nuse crate::{AnthropicProvider, GeminiProvider, OllamaProvider, OpenAIProvider, Provider};\n\n/// 默认的 OpenAI 兼容 provider 的 api_base\nfn default_api_base(provider_name: &str) -> &'static str {\n match provider_name {\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"deepseek\" => \"https://api.deepseek.com/v1\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"zhipu\" => \"https://open.bigmodel.cn/api/paas/v4\",\n \"kimi\" | \"moonshot\" => \"https://api.moonshot.cn/v1\",\n _ => \"https://api.openai.com/v1\",\n }\n}\n\n/// 从 model 字符串前缀推断 provider 名字\n/// 返回 None 表示无法从前缀推断(需要 fallback)\npub fn infer_provider_from_model(model: &str) -> Option<&'static str> {\n if model.starts_with(\"anthropic/\") || model.starts_with(\"claude-\") {\n Some(\"anthropic\")\n } else if model.starts_with(\"gemini/\") || model.starts_with(\"gemini-\") {\n Some(\"gemini\")\n } else if model.starts_with(\"ollama/\") {\n Some(\"ollama\")\n } else if model.starts_with(\"kimi\") || model.starts_with(\"moonshot\") {\n Some(\"kimi\")\n } else if model.starts_with(\"openai/\") || model.starts_with(\"gpt-\") || model.starts_with(\"o1\") || model.starts_with(\"o3\") {\n Some(\"openai\")\n } else if model.starts_with(\"deepseek\") {\n Some(\"deepseek\")\n } else if model.starts_with(\"groq/\") {\n Some(\"groq\")\n } else {\n None\n }\n}\n\n/// 从 config 中找到第一个配置了 api_key 的 provider 名字作为 fallback\n/// 这比旧的 get_api_key() 更合理:先按 model 前缀确认优先 provider\nfn fallback_provider_name(config: &Config) -> Option<&str> {\n let priority = [\n \"anthropic\", \"openai\", \"openrouter\", \"deepseek\", \"kimi\",\n \"gemini\", \"zhipu\", \"groq\", \"vllm\", \"ollama\",\n ];\n for name in priority {\n if let Some(p) = config.providers.get(name) {\n if !p.api_key.is_empty() && p.api_key != \"dummy\" {\n return Some(name);\n }\n }\n }\n // ollama 特殊处理:不需要真实 api_key\n if config.providers.contains_key(\"ollama\") {\n return Some(\"ollama\");\n }\n None\n}\n\n/// 统一的 provider 创建入口。\n///\n/// 解析优先级:\n/// 1. `explicit_provider` 参数(来自 config.agents.defaults.provider 或 evolution_provider)\n/// 2. model 字符串前缀推断(如 \"anthropic/claude-...\" → anthropic)\n/// 3. config 中第一个有效 api_key 的 provider(fallback)\n///\n/// 对于 explicit_provider:\n/// - 如果 config.providers 中找不到对应配置,返回 Err\n/// - ollama 不需要 api_key,不报错\npub fn create_provider(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n) -> anyhow::Result> {\n let max_tokens = config.agents.defaults.max_tokens;\n let temperature = config.agents.defaults.temperature;\n\n // 优先级1:显式指定\n // 优先级2:model 前缀推断\n // 优先级3:config fallback\n let effective_provider: &str = if let Some(ep) = explicit_provider {\n ep\n } else if let Some(inferred) = infer_provider_from_model(model) {\n inferred\n } else if let Some(fallback) = fallback_provider_name(config) {\n fallback\n } else {\n return Err(anyhow::anyhow!(\n \"No LLM provider configured. Set 'provider' in config, use a recognized model prefix \\\n (e.g. 'anthropic/claude-...', 'gpt-4o', 'gemini-...'), or add an API key to providers section.\"\n ));\n };\n\n // 获取 provider 配置\n let provider_cfg = config.providers.get(effective_provider);\n\n // 对于显式指定的 provider(非 ollama),必须有配置且有 api_key\n if explicit_provider.is_some() && effective_provider != \"ollama\" {\n match provider_cfg {\n None => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but not found in providers section\",\n effective_provider\n ));\n }\n Some(cfg) if cfg.api_key.is_empty() || cfg.api_key == \"dummy\" => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but has no API key\",\n effective_provider\n ));\n }\n _ => {}\n }\n }\n\n let empty_cfg = blockcell_core::config::ProviderConfig::default();\n let resolved_cfg = provider_cfg.unwrap_or(&empty_cfg);\n\n // 计算代理参数:\n // provider_proxy = providers..proxy(None=未配置,Some(\"\")=强制直连,Some(url)=该provider专用)\n // global_proxy = network.proxy\n // no_proxy = network.no_proxy\n let provider_proxy = resolved_cfg.proxy.as_deref();\n let global_proxy = config.network.proxy.as_deref();\n let no_proxy = &config.network.no_proxy;\n\n match effective_provider {\n \"anthropic\" => {\n Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n \"gemini\" => {\n Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n \"ollama\" => {\n let api_base = resolved_cfg.api_base.as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n _ => {\n // OpenAI 兼容:openrouter, openai, deepseek, groq, zhipu, vllm, kimi, moonshot 等\n let api_base = resolved_cfg.api_base.as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )) as Box)\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result> {\n let model = &config.agents.defaults.model;\n let explicit_provider = config.agents.defaults.provider.as_deref();\n create_provider(config, model, explicit_provider)\n}\n\n/// 为自进化创建独立的 provider\n/// 优先级:evolutionProvider > evolution_model 前缀 > provider > model 前缀 > fallback\npub fn create_evolution_provider(config: &Config) -> anyhow::Result> {\n let model = config.agents.defaults.evolution_model.as_deref()\n .unwrap_or(&config.agents.defaults.model);\n\n // evolution_provider 显式 > 主 provider 显式(作为 evolution fallback)\n let explicit_provider = config.agents.defaults.evolution_provider.as_deref()\n .or(config.agents.defaults.provider.as_deref());\n\n create_provider(config, model, explicit_provider)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_infer_provider_from_model() {\n assert_eq!(infer_provider_from_model(\"anthropic/claude-sonnet-4\"), Some(\"anthropic\"));\n assert_eq!(infer_provider_from_model(\"claude-3-5-sonnet\"), Some(\"anthropic\"));\n assert_eq!(infer_provider_from_model(\"gemini-2.0-flash\"), Some(\"gemini\"));\n assert_eq!(infer_provider_from_model(\"gemini/gemini-pro\"), Some(\"gemini\"));\n assert_eq!(infer_provider_from_model(\"ollama/llama3\"), Some(\"ollama\"));\n assert_eq!(infer_provider_from_model(\"kimi-moonshot-v1\"), Some(\"kimi\"));\n assert_eq!(infer_provider_from_model(\"gpt-4o\"), Some(\"openai\"));\n assert_eq!(infer_provider_from_model(\"deepseek-coder\"), Some(\"deepseek\"));\n assert_eq!(infer_provider_from_model(\"some-unknown-model\"), None);\n }\n\n #[test]\n fn test_create_provider_explicit_wins() {\n let mut config = Config::default();\n // 配置 openai provider\n config.providers.get_mut(\"openai\").unwrap().api_key = \"sk-test\".to_string();\n // model 是 anthropic 前缀,但显式指定 openai\n let result = create_provider(&config, \"anthropic/claude-sonnet-4\", Some(\"openai\"));\n assert!(result.is_ok(), \"显式 provider 应该覆盖 model 前缀\");\n }\n\n #[test]\n fn test_create_provider_model_prefix() {\n let mut config = Config::default();\n config.providers.get_mut(\"anthropic\").unwrap().api_key = \"sk-ant-test\".to_string();\n let result = create_provider(&config, \"claude-3-5-sonnet\", None);\n assert!(result.is_ok(), \"model 前缀应该推断 anthropic\");\n }\n\n #[test]\n fn test_create_provider_ollama_no_key_needed() {\n let config = Config::default();\n // ollama 不需要 api_key\n let result = create_provider(&config, \"llama3\", Some(\"ollama\"));\n assert!(result.is_ok(), \"ollama 不需要 api_key\");\n }\n\n #[test]\n fn test_create_provider_no_config_fails() {\n let mut config = Config::default();\n // 清空所有 api_key,并移除 ollama(ollama 不需要 api_key 会被作为 fallback)\n for p in config.providers.values_mut() {\n p.api_key = String::new();\n }\n config.providers.remove(\"ollama\");\n let result = create_provider(&config, \"some-unknown-model\", None);\n assert!(result.is_err(), \"没有任何配置时应报错\");\n }\n\n #[test]\n fn test_create_provider_explicit_missing_fails() {\n let config = Config::default(); // anthropic key 为空\n let result = create_provider(&config, \"gpt-4o\", Some(\"anthropic\"));\n assert!(result.is_err(), \"显式指定但无 api_key 时应报错\");\n }\n}\n"}, "204e5fc0a33a73eca1d4172832df7f2525d47f7a": {"t": 1785760763.44831, "n": 8994, "h": "dbc507f66e0537aa", "c": "use blockcell_core::Config;\n\nuse crate::{AnthropicProvider, GeminiProvider, OllamaProvider, OpenAIProvider, Provider};\n\n/// 默认的 OpenAI 兼容 provider 的 api_base\nfn default_api_base(provider_name: &str) -> &'static str {\n match provider_name {\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"deepseek\" => \"https://api.deepseek.com/v1\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"zhipu\" => \"https://open.bigmodel.cn/api/paas/v4\",\n \"kimi\" | \"moonshot\" => \"https://api.moonshot.cn/v1\",\n _ => \"https://api.openai.com/v1\",\n }\n}\n\n/// 从 model 字符串前缀推断 provider 名字\n/// 返回 None 表示无法从前缀推断(需要 fallback)\npub fn infer_provider_from_model(model: &str) -> Option<&'static str> {\n if model.starts_with(\"anthropic/\") || model.starts_with(\"claude-\") {\n Some(\"anthropic\")\n } else if model.starts_with(\"gemini/\") || model.starts_with(\"gemini-\") {\n Some(\"gemini\")\n } else if model.starts_with(\"ollama/\") {\n Some(\"ollama\")\n } else if model.starts_with(\"kimi\") || model.starts_with(\"moonshot\") {\n Some(\"kimi\")\n } else if model.starts_with(\"openai/\") || model.starts_with(\"gpt-\") || model.starts_with(\"o1\") || model.starts_with(\"o3\") {\n Some(\"openai\")\n } else if model.starts_with(\"deepseek\") {\n Some(\"deepseek\")\n } else if model.starts_with(\"groq/\") {\n Some(\"groq\")\n } else {\n None\n }\n}\n\n/// 从 config 中找到第一个配置了 api_key 的 provider 名字作为 fallback\n/// 这比旧的 get_api_key() 更合理:先按 model 前缀确认优先 provider\nfn fallback_provider_name(config: &Config) -> Option<&str> {\n let priority = [\n \"anthropic\", \"openai\", \"openrouter\", \"deepseek\", \"kimi\",\n \"gemini\", \"zhipu\", \"groq\", \"vllm\", \"ollama\",\n ];\n for name in priority {\n if let Some(p) = config.providers.get(name) {\n if !p.api_key.is_empty() && p.api_key != \"dummy\" {\n return Some(name);\n }\n }\n }\n // ollama 特殊处理:不需要真实 api_key\n if config.providers.contains_key(\"ollama\") {\n return Some(\"ollama\");\n }\n None\n}\n\n/// 统一的 provider 创建入口。\n///\n/// 解析优先级:\n/// 1. `explicit_provider` 参数(来自 config.agents.defaults.provider 或 evolution_provider)\n/// 2. model 字符串前缀推断(如 \"anthropic/claude-...\" → anthropic)\n/// 3. config 中第一个有效 api_key 的 provider(fallback)\n///\n/// 对于 explicit_provider:\n/// - 如果 config.providers 中找不到对应配置,返回 Err\n/// - ollama 不需要 api_key,不报错\npub fn create_provider(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n) -> anyhow::Result> {\n let max_tokens = config.agents.defaults.max_tokens;\n let temperature = config.agents.defaults.temperature;\n\n // 优先级1:显式指定\n // 优先级2:model 前缀推断\n // 优先级3:config fallback\n let effective_provider: &str = if let Some(ep) = explicit_provider {\n ep\n } else if let Some(inferred) = infer_provider_from_model(model) {\n inferred\n } else if let Some(fallback) = fallback_provider_name(config) {\n fallback\n } else {\n return Err(anyhow::anyhow!(\n \"No LLM provider configured. Set 'provider' in config, use a recognized model prefix \\\n (e.g. 'anthropic/claude-...', 'gpt-4o', 'gemini-...'), or add an API key to providers section.\"\n ));\n };\n\n // 获取 provider 配置\n let provider_cfg = config.providers.get(effective_provider);\n\n // 对于显式指定的 provider(非 ollama),必须有配置且有 api_key\n if explicit_provider.is_some() && effective_provider != \"ollama\" {\n match provider_cfg {\n None => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but not found in providers section\",\n effective_provider\n ));\n }\n Some(cfg) if cfg.api_key.is_empty() || cfg.api_key == \"dummy\" => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but has no API key\",\n effective_provider\n ));\n }\n _ => {}\n }\n }\n\n let empty_cfg = blockcell_core::config::ProviderConfig::default();\n let resolved_cfg = provider_cfg.unwrap_or(&empty_cfg);\n\n match effective_provider {\n \"anthropic\" => {\n Ok(Box::new(AnthropicProvider::new(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n )))\n }\n \"gemini\" => {\n Ok(Box::new(GeminiProvider::new(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n )))\n }\n \"ollama\" => {\n let api_base = resolved_cfg.api_base.as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new(\n api_base,\n model,\n max_tokens,\n temperature,\n )))\n }\n _ => {\n // OpenAI 兼容:openrouter, openai, deepseek, groq, zhipu, vllm, kimi, moonshot 等\n let api_base = resolved_cfg.api_base.as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIProvider::new(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n )))\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result> {\n let model = &config.agents.defaults.model;\n let explicit_provider = config.agents.defaults.provider.as_deref();\n create_provider(config, model, explicit_provider)\n}\n\n/// 为自进化创建独立的 provider\n/// 优先级:evolutionProvider > evolution_model 前缀 > provider > model 前缀 > fallback\npub fn create_evolution_provider(config: &Config) -> anyhow::Result> {\n let model = config.agents.defaults.evolution_model.as_deref()\n .unwrap_or(&config.agents.defaults.model);\n\n // evolution_provider 显式 > 主 provider 显式(作为 evolution fallback)\n let explicit_provider = config.agents.defaults.evolution_provider.as_deref()\n .or(config.agents.defaults.provider.as_deref());\n\n create_provider(config, model, explicit_provider)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_infer_provider_from_model() {\n assert_eq!(infer_provider_from_model(\"anthropic/claude-sonnet-4\"), Some(\"anthropic\"));\n assert_eq!(infer_provider_from_model(\"claude-3-5-sonnet\"), Some(\"anthropic\"));\n assert_eq!(infer_provider_from_model(\"gemini-2.0-flash\"), Some(\"gemini\"));\n assert_eq!(infer_provider_from_model(\"gemini/gemini-pro\"), Some(\"gemini\"));\n assert_eq!(infer_provider_from_model(\"ollama/llama3\"), Some(\"ollama\"));\n assert_eq!(infer_provider_from_model(\"kimi-moonshot-v1\"), Some(\"kimi\"));\n assert_eq!(infer_provider_from_model(\"gpt-4o\"), Some(\"openai\"));\n assert_eq!(infer_provider_from_model(\"deepseek-coder\"), Some(\"deepseek\"));\n assert_eq!(infer_provider_from_model(\"some-unknown-model\"), None);\n }\n\n #[test]\n fn test_create_provider_explicit_wins() {\n let mut config = Config::default();\n // 配置 openai provider\n config.providers.get_mut(\"openai\").unwrap().api_key = \"sk-test\".to_string();\n // model 是 anthropic 前缀,但显式指定 openai\n let result = create_provider(&config, \"anthropic/claude-sonnet-4\", Some(\"openai\"));\n assert!(result.is_ok(), \"显式 provider 应该覆盖 model 前缀\");\n }\n\n #[test]\n fn test_create_provider_model_prefix() {\n let mut config = Config::default();\n config.providers.get_mut(\"anthropic\").unwrap().api_key = \"sk-ant-test\".to_string();\n let result = create_provider(&config, \"claude-3-5-sonnet\", None);\n assert!(result.is_ok(), \"model 前缀应该推断 anthropic\");\n }\n\n #[test]\n fn test_create_provider_ollama_no_key_needed() {\n let config = Config::default();\n // ollama 不需要 api_key\n let result = create_provider(&config, \"llama3\", Some(\"ollama\"));\n assert!(result.is_ok(), \"ollama 不需要 api_key\");\n }\n\n #[test]\n fn test_create_provider_no_config_fails() {\n let mut config = Config::default();\n // 清空所有 api_key,并移除 ollama(ollama 不需要 api_key 会被作为 fallback)\n for p in config.providers.values_mut() {\n p.api_key = String::new();\n }\n config.providers.remove(\"ollama\");\n let result = create_provider(&config, \"some-unknown-model\", None);\n assert!(result.is_err(), \"没有任何配置时应报错\");\n }\n\n #[test]\n fn test_create_provider_explicit_missing_fails() {\n let config = Config::default(); // anthropic key 为空\n let result = create_provider(&config, \"gpt-4o\", Some(\"anthropic\"));\n assert!(result.is_err(), \"显式指定但无 api_key 时应报错\");\n }\n}\n"}, "9a5ff122a3f913415d87b52bf73321403e8f202e": {"t": 1786067643.222788, "n": 14845, "h": "aa3ee122f590654c", "c": "use blockcell_core::config::ToolCallMode;\nuse blockcell_core::Config;\n\nuse crate::{\n AnthropicProvider, GeminiProvider, OllamaProvider, OpenAIProvider, OpenAIResponsesProvider,\n Provider,\n};\n\n/// 默认的 OpenAI 兼容 provider 的 api_base\npub(crate) fn default_api_base(provider_name: &str) -> &'static str {\n match provider_name {\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"deepseek\" => \"https://api.deepseek.com/v1\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"zhipu\" | \"glm\" => \"https://open.bigmodel.cn/api/paas/v4\",\n \"kimi\" | \"moonshot\" => \"https://api.moonshot.cn/v1\",\n \"qwen\" => \"https://api.qwen.ai/v1\",\n \"xai\" => \"https://api.x.ai/v1\",\n \"mistral\" => \"https://api.mistral.ai/v1\",\n \"minimax\" => \"https://api.minimaxi.com/v1\",\n \"siliconflow\" => \"https://api.siliconflow.cn/v1\",\n _ => \"https://api.openai.com/v1\",\n }\n}\n\n/// 从 model 字符串前缀推断 provider 名字\n/// 返回 None 表示无法从前缀推断(需要 fallback)\npub fn infer_provider_from_model(model: &str) -> Option<&'static str> {\n let model = model.trim();\n let lower = model.to_ascii_lowercase();\n\n if lower.starts_with(\"anthropic/\") || lower.starts_with(\"claude-\") {\n Some(\"anthropic\")\n } else if lower.starts_with(\"gemini/\") || lower.starts_with(\"gemini-\") {\n Some(\"gemini\")\n } else if lower.starts_with(\"ollama/\") {\n Some(\"ollama\")\n } else if lower.starts_with(\"kimi\") || lower.starts_with(\"moonshot\") {\n Some(\"kimi\")\n } else if lower.starts_with(\"openai/\")\n || lower.starts_with(\"gpt-\")\n || lower.starts_with(\"o1\")\n || lower.starts_with(\"o3\")\n || lower.starts_with(\"o4\")\n {\n Some(\"openai\")\n } else if lower.starts_with(\"deepseek-ai/\")\n || lower.starts_with(\"zai-org/\")\n || lower.starts_with(\"qwen/\")\n {\n Some(\"siliconflow\")\n } else if lower.starts_with(\"deepseek\") {\n Some(\"deepseek\")\n } else if lower.starts_with(\"grok-\") {\n Some(\"xai\")\n } else if lower.starts_with(\"mistral\") {\n Some(\"mistral\")\n } else if lower.starts_with(\"glm-\") {\n Some(\"zhipu\")\n } else if lower.starts_with(\"qwen\") {\n Some(\"qwen\")\n } else if lower.starts_with(\"minimax\") {\n Some(\"minimax\")\n } else if lower.starts_with(\"groq/\") {\n Some(\"groq\")\n } else {\n None\n }\n}\n\n/// 从 config 中找到第一个配置了 api_key 的 provider 名字作为 fallback\n/// 这比旧的 get_api_key() 更合理:先按 model 前缀确认优先 provider\nfn fallback_provider_name(config: &Config) -> Option<&str> {\n let priority = [\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"deepseek\",\n \"kimi\",\n \"gemini\",\n \"zhipu\",\n \"qwen\",\n \"xai\",\n \"mistral\",\n \"minimax\",\n \"groq\",\n \"siliconflow\",\n \"vllm\",\n \"ollama\",\n ];\n for name in priority {\n if let Some(p) = config.providers.get(name) {\n if !p.api_key.is_empty() && p.api_key != \"dummy\" {\n return Some(name);\n }\n }\n }\n // ollama 特殊处理:不需要真实 api_key\n if config.providers.contains_key(\"ollama\") {\n return Some(\"ollama\");\n }\n None\n}\n\n/// 统一的 provider 创建入口。\n///\n/// 解析优先级:\n/// 1. `explicit_provider` 参数(来自 config.agents.defaults.provider 或 evolution_provider)\n/// 2. model 字符串前缀推断(如 \"anthropic/claude-...\" → anthropic)\n/// 3. config 中第一个有效 api_key 的 provider(fallback)\n///\n/// 对于 explicit_provider:\n/// - 如果 config.providers 中找不到对应配置,返回 Err\n/// - ollama 不需要 api_key,不报错\npub fn create_provider(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n) -> anyhow::Result> {\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\npub fn create_provider_with_tool_mode(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n tool_call_mode: Option,\n temperature_override: Option,\n) -> anyhow::Result> {\n let max_tokens = config.agents.defaults.max_tokens;\n let temperature = temperature_override.unwrap_or(config.agents.defaults.temperature);\n let reasoning_effort = config.agents.defaults.reasoning_effort.as_deref();\n\n // 优先级1:显式指定\n // 优先级2:model 前缀推断\n // 优先级3:config fallback\n let effective_provider: &str = if let Some(ep) = explicit_provider {\n ep\n } else if let Some(inferred) = infer_provider_from_model(model) {\n inferred\n } else if let Some(fallback) = fallback_provider_name(config) {\n fallback\n } else {\n return Err(anyhow::anyhow!(\n \"No LLM provider configured. Set 'provider' in config, use a recognized model prefix \\\n (e.g. 'anthropic/claude-...', 'gpt-5.5', 'gemini-...'), or add an API key to providers section.\"\n ));\n };\n\n // 获取 provider 配置\n let provider_cfg = config.providers.get(effective_provider);\n\n // 对于显式指定的 provider(非 ollama),必须有配置且有 api_key\n if explicit_provider.is_some() && effective_provider != \"ollama\" {\n match provider_cfg {\n None => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but not found in providers section\",\n effective_provider\n ));\n }\n Some(cfg) if cfg.api_key.is_empty() || cfg.api_key == \"dummy\" => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but has no API key\",\n effective_provider\n ));\n }\n _ => {}\n }\n }\n\n let empty_cfg = blockcell_core::config::ProviderConfig::default();\n let resolved_cfg = provider_cfg.unwrap_or(&empty_cfg);\n\n // 计算代理参数:\n // provider_proxy = providers..proxy(None=未配置,Some(\"\")=强制直连,Some(url)=该provider专用)\n // global_proxy = network.proxy\n // no_proxy = network.no_proxy\n let provider_proxy = resolved_cfg.proxy.as_deref();\n let global_proxy = config.network.proxy.as_deref();\n let no_proxy = &config.network.no_proxy;\n\n match effective_provider {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box)\n }\n _ => {\n // 对于自定义 provider 名(非内置),用 api_type 决定使用哪种协议实现\n match resolved_cfg.api_type.as_str() {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box)\n }\n \"openai_responses\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIResponsesProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n )?) as Box)\n }\n _ => {\n // 默认:OpenAI 兼容(openrouter, openai, deepseek, groq, zhipu, vllm, kimi 等)\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n effective_provider,\n reasoning_effort,\n )?) as Box)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result> {\n let model = &config.agents.defaults.model;\n let explicit_provider = config.agents.defaults.provider.as_deref();\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\n/// 为自进化创建独立的 provider\n/// 优先级:evolutionProvider > evolution_model 前缀 > provider > model 前缀 > fallback\npub fn create_evolution_provider(config: &Config) -> anyhow::Result> {\n let model = config\n .agents\n .defaults\n .evolution_model\n .as_deref()\n .unwrap_or(&config.agents.defaults.model);\n\n // evolution_provider 显式 > 主 provider 显式(作为 evolution fallback)\n let explicit_provider = config\n .agents\n .defaults\n .evolution_provider\n .as_deref()\n .or(config.agents.defaults.provider.as_deref());\n\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_infer_provider_from_model() {\n assert_eq!(\n infer_provider_from_model(\"anthropic/claude-sonnet-4\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"claude-3-5-sonnet\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini-3.5-flash\"),\n Some(\"gemini\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini/gemini-pro\"),\n Some(\"gemini\")\n );\n assert_eq!(infer_provider_from_model(\"ollama/qwen3.6\"), Some(\"ollama\"));\n assert_eq!(infer_provider_from_model(\"kimi-k2.6\"), Some(\"kimi\"));\n assert_eq!(infer_provider_from_model(\"gpt-5.5\"), Some(\"openai\"));\n assert_eq!(infer_provider_from_model(\"o4-mini\"), Some(\"openai\"));\n assert_eq!(infer_provider_from_model(\"grok-4.3\"), Some(\"xai\"));\n assert_eq!(\n infer_provider_from_model(\"mistral-medium-2604\"),\n Some(\"mistral\")\n );\n assert_eq!(infer_provider_from_model(\"glm-5.2\"), Some(\"zhipu\"));\n assert_eq!(infer_provider_from_model(\"qwen3.7-max\"), Some(\"qwen\"));\n assert_eq!(infer_provider_from_model(\"MiniMax-M3\"), Some(\"minimax\"));\n assert_eq!(\n infer_provider_from_model(\"deepseek-ai/DeepSeek-V4-Pro\"),\n Some(\"siliconflow\")\n );\n assert_eq!(\n infer_provider_from_model(\"deepseek-coder\"),\n Some(\"deepseek\")\n );\n assert_eq!(infer_provider_from_model(\"some-unknown-model\"), None);\n }\n\n #[test]\n fn test_create_provider_explicit_wins() {\n let mut config = Config::default();\n // 配置 openai provider\n config.providers.get_mut(\"openai\").unwrap().api_key = \"sk-test\".to_string();\n // model 是 anthropic 前缀,但显式指定 openai\n let result = create_provider(&config, \"anthropic/claude-sonnet-4\", Some(\"openai\"));\n assert!(result.is_ok(), \"显式 provider 应该覆盖 model 前缀\");\n }\n\n #[test]\n fn test_create_provider_model_prefix() {\n let mut config = Config::default();\n config.providers.get_mut(\"anthropic\").unwrap().api_key = \"sk-ant-test\".to_string();\n let result = create_provider(&config, \"claude-3-5-sonnet\", None);\n assert!(result.is_ok(), \"model 前缀应该推断 anthropic\");\n }\n\n #[test]\n fn test_create_provider_ollama_no_key_needed() {\n let config = Config::default();\n // ollama 不需要 api_key\n let result = create_provider(&config, \"qwen3.6\", Some(\"ollama\"));\n assert!(result.is_ok(), \"ollama 不需要 api_key\");\n }\n\n #[test]\n fn test_create_provider_no_config_fails() {\n let mut config = Config::default();\n // 清空所有 api_key,并移除 ollama(ollama 不需要 api_key 会被作为 fallback)\n for p in config.providers.values_mut() {\n p.api_key = String::new();\n }\n config.providers.remove(\"ollama\");\n let result = create_provider(&config, \"some-unknown-model\", None);\n assert!(result.is_err(), \"没有任何配置时应报错\");\n }\n\n #[test]\n fn test_create_provider_explicit_missing_fails() {\n let config = Config::default(); // anthropic key 为空\n let result = create_provider(&config, \"gpt-5.5\", Some(\"anthropic\"));\n assert!(result.is_err(), \"显式指定但无 api_key 时应报错\");\n }\n}\n"}, "27f4d4f01e2c743986e0723b71f12f7bbbdde4d4": {"t": 1786068393.7373753, "n": 14845, "h": "aa3ee122f590654c", "c": "use blockcell_core::config::ToolCallMode;\nuse blockcell_core::Config;\n\nuse crate::{\n AnthropicProvider, GeminiProvider, OllamaProvider, OpenAIProvider, OpenAIResponsesProvider,\n Provider,\n};\n\n/// 默认的 OpenAI 兼容 provider 的 api_base\npub(crate) fn default_api_base(provider_name: &str) -> &'static str {\n match provider_name {\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"deepseek\" => \"https://api.deepseek.com/v1\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"zhipu\" | \"glm\" => \"https://open.bigmodel.cn/api/paas/v4\",\n \"kimi\" | \"moonshot\" => \"https://api.moonshot.cn/v1\",\n \"qwen\" => \"https://api.qwen.ai/v1\",\n \"xai\" => \"https://api.x.ai/v1\",\n \"mistral\" => \"https://api.mistral.ai/v1\",\n \"minimax\" => \"https://api.minimaxi.com/v1\",\n \"siliconflow\" => \"https://api.siliconflow.cn/v1\",\n _ => \"https://api.openai.com/v1\",\n }\n}\n\n/// 从 model 字符串前缀推断 provider 名字\n/// 返回 None 表示无法从前缀推断(需要 fallback)\npub fn infer_provider_from_model(model: &str) -> Option<&'static str> {\n let model = model.trim();\n let lower = model.to_ascii_lowercase();\n\n if lower.starts_with(\"anthropic/\") || lower.starts_with(\"claude-\") {\n Some(\"anthropic\")\n } else if lower.starts_with(\"gemini/\") || lower.starts_with(\"gemini-\") {\n Some(\"gemini\")\n } else if lower.starts_with(\"ollama/\") {\n Some(\"ollama\")\n } else if lower.starts_with(\"kimi\") || lower.starts_with(\"moonshot\") {\n Some(\"kimi\")\n } else if lower.starts_with(\"openai/\")\n || lower.starts_with(\"gpt-\")\n || lower.starts_with(\"o1\")\n || lower.starts_with(\"o3\")\n || lower.starts_with(\"o4\")\n {\n Some(\"openai\")\n } else if lower.starts_with(\"deepseek-ai/\")\n || lower.starts_with(\"zai-org/\")\n || lower.starts_with(\"qwen/\")\n {\n Some(\"siliconflow\")\n } else if lower.starts_with(\"deepseek\") {\n Some(\"deepseek\")\n } else if lower.starts_with(\"grok-\") {\n Some(\"xai\")\n } else if lower.starts_with(\"mistral\") {\n Some(\"mistral\")\n } else if lower.starts_with(\"glm-\") {\n Some(\"zhipu\")\n } else if lower.starts_with(\"qwen\") {\n Some(\"qwen\")\n } else if lower.starts_with(\"minimax\") {\n Some(\"minimax\")\n } else if lower.starts_with(\"groq/\") {\n Some(\"groq\")\n } else {\n None\n }\n}\n\n/// 从 config 中找到第一个配置了 api_key 的 provider 名字作为 fallback\n/// 这比旧的 get_api_key() 更合理:先按 model 前缀确认优先 provider\nfn fallback_provider_name(config: &Config) -> Option<&str> {\n let priority = [\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"deepseek\",\n \"kimi\",\n \"gemini\",\n \"zhipu\",\n \"qwen\",\n \"xai\",\n \"mistral\",\n \"minimax\",\n \"groq\",\n \"siliconflow\",\n \"vllm\",\n \"ollama\",\n ];\n for name in priority {\n if let Some(p) = config.providers.get(name) {\n if !p.api_key.is_empty() && p.api_key != \"dummy\" {\n return Some(name);\n }\n }\n }\n // ollama 特殊处理:不需要真实 api_key\n if config.providers.contains_key(\"ollama\") {\n return Some(\"ollama\");\n }\n None\n}\n\n/// 统一的 provider 创建入口。\n///\n/// 解析优先级:\n/// 1. `explicit_provider` 参数(来自 config.agents.defaults.provider 或 evolution_provider)\n/// 2. model 字符串前缀推断(如 \"anthropic/claude-...\" → anthropic)\n/// 3. config 中第一个有效 api_key 的 provider(fallback)\n///\n/// 对于 explicit_provider:\n/// - 如果 config.providers 中找不到对应配置,返回 Err\n/// - ollama 不需要 api_key,不报错\npub fn create_provider(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n) -> anyhow::Result> {\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\npub fn create_provider_with_tool_mode(\n config: &Config,\n model: &str,\n explicit_provider: Option<&str>,\n tool_call_mode: Option,\n temperature_override: Option,\n) -> anyhow::Result> {\n let max_tokens = config.agents.defaults.max_tokens;\n let temperature = temperature_override.unwrap_or(config.agents.defaults.temperature);\n let reasoning_effort = config.agents.defaults.reasoning_effort.as_deref();\n\n // 优先级1:显式指定\n // 优先级2:model 前缀推断\n // 优先级3:config fallback\n let effective_provider: &str = if let Some(ep) = explicit_provider {\n ep\n } else if let Some(inferred) = infer_provider_from_model(model) {\n inferred\n } else if let Some(fallback) = fallback_provider_name(config) {\n fallback\n } else {\n return Err(anyhow::anyhow!(\n \"No LLM provider configured. Set 'provider' in config, use a recognized model prefix \\\n (e.g. 'anthropic/claude-...', 'gpt-5.5', 'gemini-...'), or add an API key to providers section.\"\n ));\n };\n\n // 获取 provider 配置\n let provider_cfg = config.providers.get(effective_provider);\n\n // 对于显式指定的 provider(非 ollama),必须有配置且有 api_key\n if explicit_provider.is_some() && effective_provider != \"ollama\" {\n match provider_cfg {\n None => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but not found in providers section\",\n effective_provider\n ));\n }\n Some(cfg) if cfg.api_key.is_empty() || cfg.api_key == \"dummy\" => {\n return Err(anyhow::anyhow!(\n \"Provider '{}' is explicitly configured but has no API key\",\n effective_provider\n ));\n }\n _ => {}\n }\n }\n\n let empty_cfg = blockcell_core::config::ProviderConfig::default();\n let resolved_cfg = provider_cfg.unwrap_or(&empty_cfg);\n\n // 计算代理参数:\n // provider_proxy = providers..proxy(None=未配置,Some(\"\")=强制直连,Some(url)=该provider专用)\n // global_proxy = network.proxy\n // no_proxy = network.no_proxy\n let provider_proxy = resolved_cfg.proxy.as_deref();\n let global_proxy = config.network.proxy.as_deref();\n let no_proxy = &config.network.no_proxy;\n\n match effective_provider {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box)\n }\n _ => {\n // 对于自定义 provider 名(非内置),用 api_type 决定使用哪种协议实现\n match resolved_cfg.api_type.as_str() {\n \"anthropic\" => Ok(Box::new(AnthropicProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"gemini\" => Ok(Box::new(GeminiProvider::new_with_proxy(\n &resolved_cfg.api_key,\n resolved_cfg.api_base.as_deref(),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box),\n \"ollama\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .or(Some(\"http://localhost:11434\"));\n Ok(Box::new(OllamaProvider::new_with_proxy(\n api_base,\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n )?) as Box)\n }\n \"openai_responses\" => {\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIResponsesProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n )?) as Box)\n }\n _ => {\n // 默认:OpenAI 兼容(openrouter, openai, deepseek, groq, zhipu, vllm, kimi 等)\n let api_base = resolved_cfg\n .api_base\n .as_deref()\n .unwrap_or_else(|| default_api_base(effective_provider));\n Ok(Box::new(OpenAIProvider::new_with_proxy(\n &resolved_cfg.api_key,\n Some(api_base),\n model,\n max_tokens,\n temperature,\n provider_proxy,\n global_proxy,\n no_proxy,\n tool_call_mode.unwrap_or(ToolCallMode::Native),\n effective_provider,\n reasoning_effort,\n )?) as Box)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result> {\n let model = &config.agents.defaults.model;\n let explicit_provider = config.agents.defaults.provider.as_deref();\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\n/// 为自进化创建独立的 provider\n/// 优先级:evolutionProvider > evolution_model 前缀 > provider > model 前缀 > fallback\npub fn create_evolution_provider(config: &Config) -> anyhow::Result> {\n let model = config\n .agents\n .defaults\n .evolution_model\n .as_deref()\n .unwrap_or(&config.agents.defaults.model);\n\n // evolution_provider 显式 > 主 provider 显式(作为 evolution fallback)\n let explicit_provider = config\n .agents\n .defaults\n .evolution_provider\n .as_deref()\n .or(config.agents.defaults.provider.as_deref());\n\n create_provider_with_tool_mode(config, model, explicit_provider, None, None)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_infer_provider_from_model() {\n assert_eq!(\n infer_provider_from_model(\"anthropic/claude-sonnet-4\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"claude-3-5-sonnet\"),\n Some(\"anthropic\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini-3.5-flash\"),\n Some(\"gemini\")\n );\n assert_eq!(\n infer_provider_from_model(\"gemini/gemini-pro\"),\n Some(\"gemini\")\n );\n assert_eq!(infer_provider_from_model(\"ollama/qwen3.6\"), Some(\"ollama\"));\n assert_eq!(infer_provider_from_model(\"kimi-k2.6\"), Some(\"kimi\"));\n assert_eq!(infer_provider_from_model(\"gpt-5.5\"), Some(\"openai\"));\n assert_eq!(infer_provider_from_model(\"o4-mini\"), Some(\"openai\"));\n assert_eq!(infer_provider_from_model(\"grok-4.3\"), Some(\"xai\"));\n assert_eq!(\n infer_provider_from_model(\"mistral-medium-2604\"),\n Some(\"mistral\")\n );\n assert_eq!(infer_provider_from_model(\"glm-5.2\"), Some(\"zhipu\"));\n assert_eq!(infer_provider_from_model(\"qwen3.7-max\"), Some(\"qwen\"));\n assert_eq!(infer_provider_from_model(\"MiniMax-M3\"), Some(\"minimax\"));\n assert_eq!(\n infer_provider_from_model(\"deepseek-ai/DeepSeek-V4-Pro\"),\n Some(\"siliconflow\")\n );\n assert_eq!(\n infer_provider_from_model(\"deepseek-coder\"),\n Some(\"deepseek\")\n );\n assert_eq!(infer_provider_from_model(\"some-unknown-model\"), None);\n }\n\n #[test]\n fn test_create_provider_explicit_wins() {\n let mut config = Config::default();\n // 配置 openai provider\n config.providers.get_mut(\"openai\").unwrap().api_key = \"sk-test\".to_string();\n // model 是 anthropic 前缀,但显式指定 openai\n let result = create_provider(&config, \"anthropic/claude-sonnet-4\", Some(\"openai\"));\n assert!(result.is_ok(), \"显式 provider 应该覆盖 model 前缀\");\n }\n\n #[test]\n fn test_create_provider_model_prefix() {\n let mut config = Config::default();\n config.providers.get_mut(\"anthropic\").unwrap().api_key = \"sk-ant-test\".to_string();\n let result = create_provider(&config, \"claude-3-5-sonnet\", None);\n assert!(result.is_ok(), \"model 前缀应该推断 anthropic\");\n }\n\n #[test]\n fn test_create_provider_ollama_no_key_needed() {\n let config = Config::default();\n // ollama 不需要 api_key\n let result = create_provider(&config, \"qwen3.6\", Some(\"ollama\"));\n assert!(result.is_ok(), \"ollama 不需要 api_key\");\n }\n\n #[test]\n fn test_create_provider_no_config_fails() {\n let mut config = Config::default();\n // 清空所有 api_key,并移除 ollama(ollama 不需要 api_key 会被作为 fallback)\n for p in config.providers.values_mut() {\n p.api_key = String::new();\n }\n config.providers.remove(\"ollama\");\n let result = create_provider(&config, \"some-unknown-model\", None);\n assert!(result.is_err(), \"没有任何配置时应报错\");\n }\n\n #[test]\n fn test_create_provider_explicit_missing_fails() {\n let config = Config::default(); // anthropic key 为空\n let result = create_provider(&config, \"gpt-5.5\", Some(\"anthropic\"));\n assert!(result.is_err(), \"显式指定但无 api_key 时应报错\");\n }\n}\n"}}}}