Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/blockcell-labs_blockcell.json
T
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
691 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "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.<channel>.<accountId>`\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/<ID>/`.\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 \"<provider-name>\": {\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.<name>.proxy = \"http://...\"` | 该 provider 使用专用代理 |\n| 最高 | `providers.<name>.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 \"<provider-name>\": {\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.<name>.proxy = \"http://...\"` | 该 provider 使用专用代理 |\n| 最高 | `providers.<name>.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<String>,\n api_key: Option<String>,\n model: Option<String>,\n channel: Option<String>,\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::<i64>()\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::<u16>().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::<u64>() {\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<String> {\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<String> {\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<Option<&'static str>> {\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<Option<&'static str>> {\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<usize> {\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::<usize>() {\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<String> {\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<String> {\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<String> {\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<String>,\n api_key: Option<String>,\n model: Option<String>,\n channel: Option<String>,\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::<i64>()\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::<u16>().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::<u64>() {\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<String> {\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<String> {\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<Option<&'static str>> {\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<Option<&'static str>> {\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<usize> {\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::<usize>() {\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<String> {\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<String> {\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<String> {\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<String>,\n api_key: Option<String>,\n model: Option<String>,\n channel: Option<String>,\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::<i64>()\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::<u16>().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::<u64>() {\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<String> {\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<String> {\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<Option<&'static str>> {\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<Option<&'static str>> {\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<usize> {\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::<usize>() {\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<String> {\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<String> {\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<String> {\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<String>,\n api_key: Option<String>,\n model: Option<String>,\n channel: Option<String>,\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::<i64>()\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::<u16>().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::<u64>() {\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<String> {\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<String> {\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<Option<&'static str>> {\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<Option<&'static str>> {\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<usize> {\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::<usize>() {\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<String> {\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<String> {\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<String> {\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<String>,\n api_key: Option<String>,\n model: Option<String>,\n channel: Option<String>,\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::<i64>()\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::<u16>().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::<u64>() {\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<String> {\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<String> {\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<Option<&'static str>> {\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<Option<&'static str>> {\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<usize> {\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::<usize>() {\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<String> {\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<String> {\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<String> {\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<String>,\n api_key: Option<String>,\n model: Option<String>,\n channel: Option<String>,\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::<i64>()\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::<u16>().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::<u64>() {\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<String> {\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<String> {\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<Option<&'static str>> {\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<Option<&'static str>> {\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<usize> {\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::<usize>() {\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<String> {\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<String> {\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<String> {\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<String>,\n api_key: Option<String>,\n model: Option<String>,\n channel: Option<String>,\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::<i64>()\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::<u16>().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::<u64>() {\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<String> {\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<String> {\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<Option<&'static str>> {\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<Option<&'static str>> {\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<usize> {\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::<usize>() {\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<String> {\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<String> {\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<String> {\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<String>,\n api_key: Option<String>,\n model: Option<String>,\n channel: Option<String>,\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::<i64>()\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<String> {\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<String> {\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<Option<&'static str>> {\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<Option<&'static str>> {\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<usize> {\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::<usize>() {\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<String> {\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<String> {\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<String> {\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<String>,\n api_key: Option<String>,\n model: Option<String>,\n channel: Option<String>,\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::<i64>()\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<String> {\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<String> {\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<Option<&'static str>> {\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<Option<&'static str>> {\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<usize> {\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::<usize>() {\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<String> {\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<String> {\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<String> {\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<String>,\n api_key: Option<String>,\n model: Option<String>,\n channel: Option<String>,\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::<i64>()\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<String> {\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<String> {\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<Option<&'static str>> {\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<Option<&'static str>> {\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<usize> {\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::<usize>() {\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<String> {\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<String> {\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<String> {\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<String>,\n api_key: Option<String>,\n model: Option<String>,\n channel: Option<String>,\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::<i64>()\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<String> {\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<String> {\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<Option<&'static str>> {\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<Option<&'static str>> {\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<usize> {\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::<usize>() {\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<String> {\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<String> {\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<String> {\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<String>,\n api_key: Option<String>,\n model: Option<String>,\n channel: Option<String>,\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::<i64>()\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<String> {\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<String> {\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<Option<&'static str>> {\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<Option<&'static str>> {\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<usize> {\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::<usize>() {\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<String> {\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<String> {\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<String> {\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<String>,\n api_key: Option<String>,\n model: Option<String>,\n channel: Option<String>,\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::<i64>()\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<String> {\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<String> {\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<Option<&'static str>> {\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<Option<&'static str>> {\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<usize> {\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::<usize>() {\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<String> {\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<String> {\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<String> {\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<String>,\n api_key: Option<String>,\n model: Option<String>,\n channel: Option<String>,\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::<i64>()\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<String> {\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<String> {\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<Option<&'static str>> {\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<Option<&'static str>> {\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<usize> {\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::<usize>() {\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<String> {\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<String> {\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<String> {\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<String>,\n api_key: Option<String>,\n model: Option<String>,\n channel: Option<String>,\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::<i64>()\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<String> {\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<String> {\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<Option<&'static str>> {\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<Option<&'static str>> {\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<usize> {\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::<usize>() {\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<String> {\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<String> {\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<String> {\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<String>,\n api_key: Option<String>,\n model: Option<String>,\n channel: Option<String>,\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::<i64>()\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<String> {\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<String> {\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<Option<&'static str>> {\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<Option<&'static str>> {\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<usize> {\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::<usize>() {\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<String> {\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<String> {\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<String> {\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<String>,\n api_key: Option<String>,\n model: Option<String>,\n channel: Option<String>,\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::<i64>()\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::<u16>().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::<u64>() {\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<String> {\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<String> {\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<Option<&'static str>> {\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<Option<&'static str>> {\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<usize> {\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::<usize>() {\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<String> {\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<String> {\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<String> {\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<String>,\n api_key: Option<String>,\n model: Option<String>,\n channel: Option<String>,\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::<i64>()\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::<u16>().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::<u64>() {\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<String> {\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<String> {\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<Option<&'static str>> {\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<Option<&'static str>> {\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<usize> {\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::<usize>() {\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<String> {\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<String> {\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<String> {\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<Box<dyn Provider>> {\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<ToolCallMode>,\n temperature_override: Option<f32>,\n) -> anyhow::Result<Box<dyn Provider>> {\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.<name>.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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>)\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<dyn Provider>)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<ToolCallMode>,\n temperature_override: Option<f32>,\n) -> anyhow::Result<Box<dyn Provider>> {\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.<name>.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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>)\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<dyn Provider>)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<ToolCallMode>,\n temperature_override: Option<f32>,\n) -> anyhow::Result<Box<dyn Provider>> {\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.<name>.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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>)\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<dyn Provider>)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<ToolCallMode>,\n temperature_override: Option<f32>,\n) -> anyhow::Result<Box<dyn Provider>> {\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.<name>.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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>)\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<dyn Provider>)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<ToolCallMode>,\n temperature_override: Option<f32>,\n) -> anyhow::Result<Box<dyn Provider>> {\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.<name>.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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>)\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<dyn Provider>)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<ToolCallMode>,\n temperature_override: Option<f32>,\n) -> anyhow::Result<Box<dyn Provider>> {\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.<name>.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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>)\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<dyn Provider>)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<ToolCallMode>,\n) -> anyhow::Result<Box<dyn Provider>> {\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.<name>.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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>)\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<dyn Provider>)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<ToolCallMode>,\n) -> anyhow::Result<Box<dyn Provider>> {\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.<name>.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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>)\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<dyn Provider>)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<ToolCallMode>,\n) -> anyhow::Result<Box<dyn Provider>> {\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.<name>.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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>)\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<dyn Provider>)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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.<name>.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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>)\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<dyn Provider>)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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.<name>.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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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.<name>.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<dyn Provider>)\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<dyn Provider>)\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<dyn Provider>)\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<dyn Provider>)\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<dyn Provider>)\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<dyn Provider>)\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<dyn Provider>)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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.<name>.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<dyn Provider>)\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<dyn Provider>)\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<dyn Provider>)\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<dyn Provider>)\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<ToolCallMode>,\n temperature_override: Option<f32>,\n) -> anyhow::Result<Box<dyn Provider>> {\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.<name>.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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>)\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<dyn Provider>)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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<ToolCallMode>,\n temperature_override: Option<f32>,\n) -> anyhow::Result<Box<dyn Provider>> {\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.<name>.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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>),\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<dyn Provider>),\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<dyn Provider>)\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<dyn Provider>)\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<dyn Provider>)\n }\n }\n }\n }\n}\n\n/// 为主对话创建 provider\npub fn create_main_provider(config: &Config) -> anyhow::Result<Box<dyn Provider>> {\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<Box<dyn Provider>> {\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"}}}}