From 51ddfcac3439a71ba2db2c138553a3fd70ca9361 Mon Sep 17 00:00:00 2001 From: sky5454 Date: Fri, 20 Feb 2026 18:34:19 +0800 Subject: [PATCH] refactor(config): keep mainline features with pure viper/mapstructure parsing --- README.zh.md | 314 +++++------ config/config.example.toml | 222 +++++--- pkg/config/config.go | 531 ++++++++++-------- pkg/config/config_test.go | 239 +++----- pkg/config/loadconfig_parser_coverage_test.go | 142 +++++ 5 files changed, 826 insertions(+), 622 deletions(-) create mode 100644 pkg/config/loadconfig_parser_coverage_test.go diff --git a/README.zh.md b/README.zh.md index 4d8ee25bb..f68fa2c77 100644 --- a/README.zh.md +++ b/README.zh.md @@ -3,7 +3,7 @@

PicoClaw: 基于Go语言的超高效 AI 助手

-

10$硬件 · 10MB内存 · 1秒启?· 皮皮虾,我们走!

+

10$硬件 · 10MB内存 · 1秒启动 · 皮皮虾,我们走!

Go @@ -19,9 +19,9 @@ --- -🦐 **PicoClaw** 是一个受 [nanobot](https://github.com/HKUDS/nanobot) 启发的超轻量级个?AI 助手。它采用 **Go 语言** 从零重构,经历了一个“自举”过程——即?AI Agent 自身驱动了整个架构迁移和代码优化? +🦐 **PicoClaw** 是一个受 [nanobot](https://github.com/HKUDS/nanobot) 启发的超轻量级个人 AI 助手。它采用 **Go 语言** 从零重构,经历了一个“自举”过程——即由 AI Agent 自身驱动了整个架构迁移和代码优化。 -⚡️ **极致轻量**:可?**10 美元** 的硬件上运行,内存占?**<10MB**。这意味着?OpenClaw 节省 99% 的内存,?Mac mini 便宜 98%? +⚡️ **极致轻量**:可在 **10 美元** 的硬件上运行,内存占用 **<10MB**。这意味着比 OpenClaw 节省 99% 的内存,比 Mac mini 便宜 98%! @@ -38,55 +38,55 @@
-注意:人手有限,中文文档可能略有滞后,请优先查看英文文档? +注意:人手有限,中文文档可能略有滞后,请优先查看英文文档。 > [!CAUTION] > **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明** -> * **无加密货?(NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均?**诈骗**? -> * **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**? -> * **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信? +> * **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。 +> * **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。 +> * **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。 > * **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中 -> * **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较?10~20MB),我们将在功能较为收敛后进行资源占用优化. +> * **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化. ## 📢 新闻 (News) -2026-02-16 🎉 PicoClaw 在一周内突破?2K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维? 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与? +2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与! -2026-02-13 🎉 **PicoClaw ?4 天内突破 5000 Stars?* 感谢社区的支持!由于正值中国春节假期,PR ?Issue 涌入较多,我们正在利用这段时间敲?**项目路线?(Roadmap)** 并组?**开发者群?*,以便加?PicoClaw 的开发? -🚀 **行动号召?* 请在 GitHub Discussions 中提交您的功能请?(Feature Requests)。我们将在接下来的周会上进行审查和优先级排序? +2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。 +🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。 -2026-02-09 🎉 **PicoClaw 正式发布?* 仅用 1 天构建,旨在?AI Agent 带入 10 美元硬件?<10MB 内存的世界。?PicoClaw(皮皮虾),我们走! +2026-02-09 🎉 **PicoClaw 正式发布!** 仅用 1 天构建,旨在将 AI Agent 带入 10 美元硬件与 <10MB 内存的世界。🦐 PicoClaw(皮皮虾),我们走! -## ?特? +## ✨ 特性 -🪶 **超轻量级**: 核心功能内存占用 <10MB ??Clawdbot ?99%? +🪶 **超轻量级**: 核心功能内存占用 <10MB — 比 Clawdbot 小 99%。 -💰 **极低成本**: 高效到足以在 10 美元的硬件上运行 ??Mac mini 便宜 98%? +💰 **极低成本**: 高效到足以在 10 美元的硬件上运行 — 比 Mac mini 便宜 98%。 -⚡️ **闪电启动**: 启动速度?400 倍,即使?0.6GHz 单核处理器上也能?1 秒内启动? +⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。 -🌍 **真正可移?*: ?RISC-V、ARM ?x86 架构的单二进制文件,一键运行! +🌍 **真正可移植**: 跨 RISC-V、ARM 和 x86 架构的单二进制文件,一键运行! -🤖 **AI 自举**: ?Go 语言原生实现 ?95% 的核心代码由 Agent 生成,并经由“人机回?(Human-in-the-loop)”微调? +🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。 | | OpenClaw | NanoBot | **PicoClaw** | | --- | --- | --- | --- | | **语言** | TypeScript | Python | **Go** | | **RAM** | >1GB | >100MB | **< 10MB** | | **启动时间**
(0.8GHz core) | >500s | >30s | **<1s** | -| **成本** | Mac Mini $599 | 大多?Linux 开发板 ~$50 | **任意 Linux 开发板**
**低至 $10** | +| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**
**低至 $10** | PicoClaw ## 🦾 演示 -### 🛠?标准助手工作? +### 🛠️ 标准助手工作流 - - - + + + @@ -94,24 +94,24 @@ - - - + + +

🧩 全栈工程师模?/p>

🗂?日志与规划管?/p>

🔎 网络搜索与学?/p>

🧩 全栈工程师模式

🗂️ 日志与规划管理

🔎 网络搜索与学习

开??部署 ?扩展日程 ?自动??记忆发现 ?洞察 ?趋势开发 • 部署 • 扩展日程 • 自动化 • 记忆发现 • 洞察 • 趋势
### 📱 在手机上轻松运行 -picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指? +picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南: 1. 先去应用商店下载安装Termux -2. 打开后执行指? +2. 打开后执行指令 ```bash -# 注意: 下面的v0.1.1 可以换为你实际看到的最新版? +# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本 wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64 chmod +x picoclaw-linux-arm64 pkg install proot termux-chroot ./picoclaw-linux-arm64 onboard ``` -然后跟随下面的“快速开始”章节继续配置picoclaw即可使用? +然后跟随下面的“快速开始”章节继续配置picoclaw即可使用! PicoClaw @@ -119,21 +119,21 @@ termux-chroot ./picoclaw-linux-arm64 onboard ### 🐜 创新的低占用部署 -PicoClaw 几乎可以部署在任?Linux 设备上! +PicoClaw 几乎可以部署在任何 Linux 设备上! -* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) ?W(WiFi6) 版本,用于极简家庭助手? -* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维? -* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ?$100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控? +* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。 +* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。 +* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。 [https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4](https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4) -🌟 更多部署案例敬请期待? +🌟 更多部署案例敬请期待! ## 📦 安装 ### 使用预编译二进制文件安装 -?[Release 页面](https://github.com/sipeed/picoclaw/releases) 下载适用于您平台的固件? +从 [Release 页面](https://github.com/sipeed/picoclaw/releases) 下载适用于您平台的固件。 ### 从源码安装(获取最新特性,开发推荐) @@ -143,20 +143,20 @@ git clone https://github.com/sipeed/picoclaw.git cd picoclaw make deps -# 构建(无需安装? +# 构建(无需安装) make build # 为多平台构建 make build-all -# 构建并安? +# 构建并安装 make install ``` ## 🐳 Docker Compose -您也可以使用 Docker Compose 运行 PicoClaw,无需在本地安装任何环境? +您也可以使用 Docker Compose 运行 PicoClaw,无需在本地安装任何环境。 ```bash # 1. 克隆仓库 @@ -165,9 +165,9 @@ cd picoclaw # 2. 设置 API Key cp config/config.example.json config/config.json -vim config/config.json # 或使?config.yaml/config.toml 设置 DISCORD_BOT_TOKEN, API keys ? +vim config/config.json # 或使用 config.yaml/config.toml 设置 DISCORD_BOT_TOKEN, API keys 等 -# 3. 构建并启? +# 3. 构建并启动 docker compose --profile gateway up -d # 4. 查看日志 @@ -178,7 +178,7 @@ docker compose --profile gateway down ``` -### Agent 模式 (一次性运? +### Agent 模式 (一次性运行) ```bash # 提问 @@ -197,26 +197,26 @@ docker compose --profile gateway up -d ``` -### 🚀 快速开? +### 🚀 快速开始 > [!TIP] -> ?`~/.picoclaw/config.json`、`~/.picoclaw/config.yaml` ?`~/.picoclaw/config.toml` 中设置您?API Key? -> PicoClaw 支持多种配置格式?*JSON**(默认)?*YAML**?*TOML**,通过 [spf13/viper](https://github.com/spf13/viper) 驱动? +> 在 `~/.picoclaw/config.json`、`~/.picoclaw/config.yaml` 或 `~/.picoclaw/config.toml` 中设置您的 API Key。 +> PicoClaw 支持多种配置格式:**JSON**(默认)、**YAML**、**TOML**,通过 [spf13/viper](https://github.com/spf13/viper) 驱动。 > 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) -> 网络搜索?**可选的** - 获取免费?[Brave Search API](https://brave.com/search/api) (每月 2000 次免费查? +> 网络搜索是 **可选的** - 获取免费的 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询) -**1. 初始?(Initialize)** +**1. 初始化 (Initialize)** ```bash picoclaw onboard ``` -**2. 配置 (Configure)** (`~/.picoclaw/config.json`、`~/.picoclaw/config.yaml` ?`~/.picoclaw/config.toml`) +**2. 配置 (Configure)** (`~/.picoclaw/config.json`、`~/.picoclaw/config.yaml` 或 `~/.picoclaw/config.toml`) 通过 [spf13/viper](https://github.com/spf13/viper) 支持多种配置格式,选择其中任意一种即可: - `config.json` - JSON 格式(默认) -- `config.yaml` ?`config.yml` - YAML 格式 +- `config.yaml` 或 `config.yml` - YAML 格式 - `config.toml` - TOML 格式 ```json @@ -257,14 +257,14 @@ picoclaw onboard ``` -> **新功?*: `model_list` 配置格式支持零代码添?provider。详见[模型配置](#-模型配置-model_list)章节? +> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#-模型配置-model_list)章节。 **3. 获取 API Key** -* **LLM 提供?*: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) -* **网络搜索** (可?: [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/? +* **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) +* **网络搜索** (可选): [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月) -> **注意**: 完整的配置模板请参?`config.example.json`? +> **注意**: 完整的配置模板请参考 `config.example.json`。 **4. 对话 (Chat)** @@ -273,28 +273,28 @@ picoclaw agent -m "2+2 等于几?" ``` -就是这样!您?2 分钟内就拥有了一个可工作?AI 助手? +就是这样!您在 2 分钟内就拥有了一个可工作的 AI 助手。 --- ## 💬 聊天应用集成 (Chat Apps) -通过 Telegram, Discord 或钉钉与您的 PicoClaw 对话? +通过 Telegram, Discord 或钉钉与您的 PicoClaw 对话。 | 渠道 | 设置难度 | | --- | --- | -| **Telegram** | 简?(仅需 token) | -| **Discord** | 简?(bot token + intents) | -| **QQ** | 简?(AppID + AppSecret) | +| **Telegram** | 简单 (仅需 token) | +| **Discord** | 简单 (bot token + intents) | +| **QQ** | 简单 (AppID + AppSecret) | | **钉钉 (DingTalk)** | 中等 (app credentials) |

Telegram (推荐) -**1. 创建机器?* +**1. 创建机器人** -* 打开 Telegram,搜?`@BotFather` -* 发?`/newbot`,按照提示操? +* 打开 Telegram,搜索 `@BotFather` +* 发送 `/newbot`,按照提示操作 * 复制 token **2. 配置** @@ -312,7 +312,7 @@ picoclaw agent -m "2+2 等于几?" ``` -> ?Telegram 上的 `@userinfobot` 获取您的用户 ID? +> 从 Telegram 上的 `@userinfobot` 获取您的用户 ID。 **3. 运行** @@ -326,21 +326,21 @@ picoclaw gateway
Discord -**1. 创建机器?* +**1. 创建机器人** * 前往 [https://discord.com/developers/applications](https://discord.com/developers/applications) -* Create an application ?Bot ?Add Bot +* Create an application → Bot → Add Bot * 复制 bot token -**2. 开?Intents** +**2. 开启 Intents** -* ?Bot 设置中,开?**MESSAGE CONTENT INTENT** -* (可? 如果计划基于成员数据使用白名单,开?**SERVER MEMBERS INTENT** +* 在 Bot 设置中,开启 **MESSAGE CONTENT INTENT** +* (可选) 如果计划基于成员数据使用白名单,开启 **SERVER MEMBERS INTENT** **3. 获取您的 User ID** -* Discord 设置 ?Advanced ?开?**Developer Mode** -* 右键点击您的头像 ?**Copy User ID** +* Discord 设置 → Advanced → 开启 **Developer Mode** +* 右键点击您的头像 → **Copy User ID** **4. 配置** @@ -359,10 +359,10 @@ picoclaw gateway **5. 邀请机器人** -* OAuth2 ?URL Generator +* OAuth2 → URL Generator * Scopes: `bot` * Bot Permissions: `Send Messages`, `Read Message History` -* 打开生成的邀?URL,将机器人添加到您的服务? +* 打开生成的邀请 URL,将机器人添加到您的服务器 **6. 运行** @@ -376,10 +376,10 @@ picoclaw gateway
QQ -**1. 创建机器?* +**1. 创建机器人** * 前往 [QQ 开放平台](https://q.qq.com/#) -* 创建应用 ?获取 **AppID** ?**AppSecret** +* 创建应用 → 获取 **AppID** 和 **AppSecret** **2. 配置** @@ -397,7 +397,7 @@ picoclaw gateway ``` -> ?`allow_from` 设为空以允许所有用户,或指?QQ 号以限制访问? +> 将 `allow_from` 设为空以允许所有用户,或指定 QQ 号以限制访问。 **3. 运行** @@ -411,11 +411,11 @@ picoclaw gateway
钉钉 (DingTalk) -**1. 创建机器?* +**1. 创建机器人** * 前往 [开放平台](https://open.dingtalk.com/) * 创建内部应用 -* 复制 Client ID ?Client Secret +* 复制 Client ID 和 Client Secret **2. 配置** @@ -433,7 +433,7 @@ picoclaw gateway ``` -> ?`allow_from` 设为空以允许所有用户,或指?ID 以限制访问? +> 将 `allow_from` 设为空以允许所有用户,或指定 ID 以限制访问。 **3. 运行** @@ -446,9 +446,9 @@ picoclaw gateway ## ClawdChat 加入 Agent 社交网络 -只需通过 CLI 或任何集成的聊天应用发送一条消息,即可?PicoClaw 连接?Agent 社交网络? +只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。 -**阅读 `https://clawdchat.ai/skill.md` 并按照说明加?[ClawdChat.ai**](https://clawdchat.ai) +**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai**](https://clawdchat.ai) ## ⚙️ 配置详解 @@ -460,13 +460,13 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work ``` ~/.picoclaw/workspace/ -├── sessions/ # 对话会话和历? +├── sessions/ # 对话会话和历史 ├── memory/ # 长期记忆 (MEMORY.md) -├── state/ # 持久化状?(最后一次频道等) -├── cron/ # 定时任务数据? -├── skills/ # 自定义技? +├── state/ # 持久化状态 (最后一次频道等) +├── cron/ # 定时任务数据库 +├── skills/ # 自定义技能 ├── AGENTS.md # Agent 行为指南 -├── HEARTBEAT.md # 周期性任务提示词 (?30 分钟检查一? +├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次) ├── IDENTITY.md # Agent 身份设定 ├── SOUL.md # Agent 灵魂/性格 ├── TOOLS.md # 工具描述 @@ -474,9 +474,9 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work ``` -### 心跳 / 周期性任?(Heartbeat) +### 心跳 / 周期性任务 (Heartbeat) -PicoClaw 可以自动执行周期性任务。在工作区创?`HEARTBEAT.md` 文件? +PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件: ```markdown # Periodic Tasks @@ -487,11 +487,11 @@ PicoClaw 可以自动执行周期性任务。在工作区创 ``` -Agent 将每?30 分钟(可配置)读取此文件,并使用可用工具执行任务? +Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。 -#### 使用 Spawn 的异步任? +#### 使用 Spawn 的异步任务 -对于耗时较长的任务(网络搜索、API 调用),使用 `spawn` 工具创建一?**?Agent (subagent)**? +对于耗时较长的任务(网络搜索、API 调用),使用 `spawn` 工具创建一个 **子 Agent (subagent)**: ```markdown # Periodic Tasks @@ -505,35 +505,35 @@ Agent 将每 ``` -**关键行为?* +**关键行为:** -| 特?| 描述 | +| 特性 | 描述 | | --- | --- | -| **spawn** | 创建异步?Agent,不阻塞主心跳进?| -| **独立上下?* | ?Agent 拥有独立上下文,无会话历?| -| **message tool** | ?Agent 通过 message 工具直接与用户通信 | -| **非阻?* | spawn 后,心跳继续处理下一个任?| +| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 | +| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 | +| **message tool** | 子 Agent 通过 message 工具直接与用户通信 | +| **非阻塞** | spawn 后,心跳继续处理下一个任务 | -#### ?Agent 通信原理 +#### 子 Agent 通信原理 ``` 心跳触发 (Heartbeat triggers) - ? + ↓ Agent 读取 HEARTBEAT.md - ? -对于长任? spawn ?Agent - ? ? -继续下一个任? ?Agent 独立工作 - ? ? -所有任务完? ?Agent 使用 "message" 工具 - ? ? + ↓ +对于长任务: spawn 子 Agent + ↓ ↓ +继续下一个任务 子 Agent 独立工作 + ↓ ↓ +所有任务完成 子 Agent 使用 "message" 工具 + ↓ ↓ 响应 HEARTBEAT_OK 用户直接收到结果 ``` -?Agent 可以访问工具(message, web_search 等),并且无需通过?Agent 即可独立与用户通信? +子 Agent 可以访问工具(message, web_search 等),并且无需通过主 Agent 即可独立与用户通信。 -**配置?* +**配置:** ```json { @@ -545,43 +545,43 @@ Agent 读取 HEARTBEAT.md ``` -| 选项 | 默认?| 描述 | +| 选项 | 默认值 | 描述 | | --- | --- | --- | | `enabled` | `true` | 启用/禁用心跳 | -| `interval` | `30` | 检查间隔,单位分钟 (最? 5) | +| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) | **环境变量:** * `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用 * `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔 -### 提供?(Providers) +### 提供商 (Providers) > [!NOTE] -> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字? +> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。 -| 提供?| 用?| 获取 API Key | +| 提供商 | 用途 | 获取 API Key | | --- | --- | --- | | `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) | | `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) | -| `openrouter(待测?` | LLM (推荐,可访问所有模? | [openrouter.ai](https://openrouter.ai) | -| `anthropic(待测?` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) | -| `openai(待测?` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) | -| `deepseek(待测?` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) | +| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) | +| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) | +| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) | +| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) | | `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) | | `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) | ### 模型配置 (model_list) -> **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新?provider—?*无需修改任何代码?* +> **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!** -该设计同时支?*?Agent 场景**,提供灵活的 Provider 选择? +该设计同时支持**多 Agent 场景**,提供灵活的 Provider 选择: -- **不同 Agent 使用不同 Provider**:每?Agent 可以使用自己?LLM provider -- **模型回退(Fallback?*:配置主模型和备用模型,提高可靠? +- **不同 Agent 使用不同 Provider**:每个 Agent 可以使用自己的 LLM provider +- **模型回退(Fallback)**:配置主模型和备用模型,提高可靠性 - **负载均衡**:在多个 API 端点之间分配请求 -- **集中化配?*:在一个地方管理所?provider +- **集中化配置**:在一个地方管理所有 provider #### 📋 所有支持的厂商 @@ -596,13 +596,13 @@ Agent 读取 HEARTBEAT.md | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) | -| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥?| +| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) | | **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) | -| **神算?* | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | -| **Antigravity** | `antigravity/` | Google Cloud | 自定?| ?OAuth | +| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | #### 基础配置示例 @@ -634,7 +634,7 @@ Agent 读取 HEARTBEAT.md } ``` -#### 各厂商配置示? +#### 各厂商配置示例 **OpenAI** ```json @@ -671,7 +671,7 @@ Agent 读取 HEARTBEAT.md "auth_method": "oauth" } ``` -> 运行 `picoclaw auth login --provider anthropic` 来设?OAuth 凭证? +> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。 **Ollama (本地)** ```json @@ -681,7 +681,7 @@ Agent 读取 HEARTBEAT.md } ``` -**自定义代?API** +**自定义代理/API** ```json { "model_name": "my-custom-model", @@ -693,7 +693,7 @@ Agent 读取 HEARTBEAT.md #### 负载均衡 -为同一个模型名称配置多个端点——PicoClaw 会自动在它们之间轮询? +为同一个模型名称配置多个端点——PicoClaw 会自动在它们之间轮询: ```json { @@ -714,11 +714,11 @@ Agent 读取 HEARTBEAT.md } ``` -#### 从旧?`providers` 配置迁移 +#### 从旧的 `providers` 配置迁移 -旧的 `providers` 配置格式**已弃?*,但为向后兼容仍支持? +旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。 -**旧配置(已弃用)?* +**旧配置(已弃用):** ```json { "providers": { @@ -754,12 +754,12 @@ Agent 读取 HEARTBEAT.md } ``` -详细的迁移指南请参?[docs/migration/model-list-migration.md](docs/migration/model-list-migration.md)? +详细的迁移指南请参考 [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md)。
智谱 (Zhipu) 配置示例 -**1. 获取 API key ?base URL** +**1. 获取 API key 和 base URL** * 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) @@ -862,37 +862,37 @@ picoclaw agent -m "你好"
-## CLI 命令行参? +## CLI 命令行参考 | 命令 | 描述 | | --- | --- | -| `picoclaw onboard` | 初始化配置和工作?| -| `picoclaw agent -m "..."` | ?Agent 对话 | -| `picoclaw agent` | 交互式聊天模?| +| `picoclaw onboard` | 初始化配置和工作区 | +| `picoclaw agent -m "..."` | 与 Agent 对话 | +| `picoclaw agent` | 交互式聊天模式 | | `picoclaw gateway` | 启动网关 (Gateway) | -| `picoclaw status` | 显示状?| -| `picoclaw cron list` | 列出所有定时任?| +| `picoclaw status` | 显示状态 | +| `picoclaw cron list` | 列出所有定时任务 | | `picoclaw cron add ...` | 添加定时任务 | ### 定时任务 / 提醒 (Scheduled Tasks) PicoClaw 通过 `cron` 工具支持定时提醒和重复任务: -* **一次性提?*: "Remind me in 10 minutes" (10分钟后提醒我) ?10分钟后触发一? -* **重复任务**: "Remind me every 2 hours" (?小时提醒? ??小时触发 -* **Cron 表达?*: "Remind me at 9am daily" (每天上午9点提醒我) ?使用 cron 表达? +* **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次 +* **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发 +* **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式 -任务存储?`~/.picoclaw/workspace/cron/` 中并自动处理? +任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。 ## 🤝 贡献与路线图 (Roadmap) -欢迎提交 PR!代码库刻意保持小巧和可读。? +欢迎提交 PR!代码库刻意保持小巧和可读。🤗 -路线图即将发?.. +路线图即将发布... -开发者群组正在组建中,入群门槛:至少合并?1 ?PR? +开发者群组正在组建中,入群门槛:至少合并过 1 个 PR。 -用户群组? +用户群组: Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) @@ -902,12 +902,12 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) ### 网络搜索提示 "API 配置问题" -如果您尚未配置搜?API Key,这是正常的。PicoClaw 会提供手动搜索的帮助链接? +如果您尚未配置搜索 API Key,这是正常的。PicoClaw 会提供手动搜索的帮助链接。 -启用网络搜索? +启用网络搜索: -1. ?[https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查? -2. 添加?`~/.picoclaw/config.json`: +1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询) +2. 添加到 `~/.picoclaw/config.json`: ```json { "tools": { @@ -926,11 +926,11 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) ### 遇到内容过滤错误 (Content Filtering Errors) -某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型? +某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。 ### Telegram bot 提示 "Conflict: terminated by other getUpdates" -这表示有另一个机器人实例正在运行。请确保同一时间只有一?`picoclaw gateway` 进程在运行? +这表示有另一个机器人实例正在运行。请确保同一时间只有一个 `picoclaw gateway` 进程在运行。 --- @@ -938,8 +938,8 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) | 服务 | 免费层级 | 适用场景 | | --- | --- | --- | -| **OpenRouter** | 200K tokens/?| 多模型聚?(Claude, GPT-4 ? | -| **智谱 (Zhipu)** | 200K tokens/?| 最适合中国用户 | -| **Brave Search** | 2000 次查??| 网络搜索功能 | -| **Groq** | 提供免费层级 | 极速推?(Llama, Mixtral) | -| **Cerebras** | 提供免费层级 | 极速推?(Llama, Qwen ? | +| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) | +| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 | +| **Brave Search** | 2000 次查询/月 | 网络搜索功能 | +| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) | +| **Cerebras** | 提供免费层级 | 极速推理 (Llama, Qwen 等) | diff --git a/config/config.example.toml b/config/config.example.toml index 09213d664..7cf848971 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -1,14 +1,48 @@ # PicoClaw Configuration Example - TOML Format -# This is an alternative to config.json for users who prefer TOML format +# Equivalent to config.example.json, for users who prefer TOML. [agents.defaults] workspace = "~/.picoclaw/workspace" restrict_to_workspace = true -model = "glm-4.7" +model = "gpt4" max_tokens = 8192 temperature = 0.7 max_tool_iterations = 20 +[[model_list]] +model_name = "gpt4" +model = "openai/gpt-5.2" +api_key = "sk-your-openai-key" +api_base = "https://api.openai.com/v1" + +[[model_list]] +model_name = "claude-sonnet-4.6" +model = "anthropic/claude-sonnet-4.6" +api_key = "sk-ant-your-key" +api_base = "https://api.anthropic.com/v1" + +[[model_list]] +model_name = "gemini" +model = "antigravity/gemini-2.0-flash" +auth_method = "oauth" + +[[model_list]] +model_name = "deepseek" +model = "deepseek/deepseek-chat" +api_key = "sk-your-deepseek-key" + +[[model_list]] +model_name = "loadbalanced-gpt4" +model = "openai/gpt-5.2" +api_key = "sk-key1" +api_base = "https://api1.example.com/v1" + +[[model_list]] +model_name = "loadbalanced-gpt4" +model = "openai/gpt-5.2" +api_key = "sk-key2" +api_base = "https://api2.example.com/v1" + [channels.telegram] enabled = false token = "YOUR_TELEGRAM_BOT_TOKEN" @@ -20,110 +54,130 @@ enabled = false token = "YOUR_DISCORD_BOT_TOKEN" allow_from = [] -[channels.slack] -enabled = false -token = "YOUR_SLACK_BOT_TOKEN" -allow_from = [] - -[channels.feishu] -enabled = false -token = "YOUR_FEISHU_BOT_TOKEN" -secret = "YOUR_FEISHU_SECRET" -allow_from = [] - -[channels.dingtalk] -enabled = false -token = "YOUR_DINGTALK_BOT_TOKEN" -secret = "YOUR_DINGTALK_SECRET" -allow_from = [] - -[channels.qq] -enabled = false -account = "YOUR_QQ_ACCOUNT" -token = "YOUR_QQ_BOT_TOKEN" -allow_from = [] - -[channels.whatsapp] -enabled = false -phone_number = "YOUR_PHONE_NUMBER" -allow_from = [] - -[channels.line] -enabled = false -token = "YOUR_LINE_CHANNEL_ACCESS_TOKEN" -secret = "YOUR_LINE_CHANNEL_SECRET" -allow_from = [] - -[channels.onebot] -enabled = false -ws_url = "" -allow_from = [] - [channels.maixcam] enabled = false host = "0.0.0.0" port = 18790 allow_from = [] -[providers.anthropic] +[channels.whatsapp] enabled = false -api_key = "YOUR_ANTHROPIC_API_KEY" -api_base = "https://api.anthropic.com" +bridge_url = "ws://localhost:3001" +allow_from = [] + +[channels.feishu] +enabled = false +app_id = "" +app_secret = "" +encrypt_key = "" +verification_token = "" +allow_from = [] + +[channels.dingtalk] +enabled = false +client_id = "YOUR_CLIENT_ID" +client_secret = "YOUR_CLIENT_SECRET" +allow_from = [] + +[channels.slack] +enabled = false +bot_token = "xoxb-YOUR-BOT-TOKEN" +app_token = "xapp-YOUR-APP-TOKEN" +allow_from = [] + +[channels.line] +enabled = false +channel_secret = "YOUR_LINE_CHANNEL_SECRET" +channel_access_token = "YOUR_LINE_CHANNEL_ACCESS_TOKEN" +webhook_host = "0.0.0.0" +webhook_port = 18791 +webhook_path = "/webhook/line" +allow_from = [] + +[channels.onebot] +enabled = false +ws_url = "ws://127.0.0.1:3001" +access_token = "" +reconnect_interval = 5 +group_trigger_prefix = [] +allow_from = [] + +# DEPRECATED: use model_list instead. +[providers.anthropic] +api_key = "" +api_base = "" [providers.openai] -enabled = false -api_key = "YOUR_OPENAI_API_KEY" -api_base = "https://api.openai.com/v1" +api_key = "" +api_base = "" +web_search = true [providers.openrouter] -enabled = false -api_key = "YOUR_OPENROUTER_API_KEY" -api_base = "https://openrouter.ai/api/v1" - -[providers.zhipu] -enabled = false -api_key = "YOUR_ZHIPU_API_KEY" -api_base = "https://open.bigmodel.cn/api/paas/v4" - -[providers.gemini] -enabled = false -api_key = "YOUR_GEMINI_API_KEY" -api_base = "https://generativelanguage.googleapis.com/v1beta/openai/" +api_key = "sk-or-v1-xxx" +api_base = "" [providers.groq] -enabled = false -api_key = "YOUR_GROQ_API_KEY" -api_base = "https://api.groq.com/openai/v1" +api_key = "gsk_xxx" +api_base = "" + +[providers.zhipu] +api_key = "YOUR_ZHIPU_API_KEY" +api_base = "" + +[providers.gemini] +api_key = "" +api_base = "" [providers.vllm] +api_key = "" +api_base = "" + +[providers.nvidia] +api_key = "nvapi-xxx" +api_base = "" +proxy = "http://127.0.0.1:7890" + +[providers.moonshot] +api_key = "sk-xxx" +api_base = "" + +[providers.qwen] +api_key = "sk-xxx" +api_base = "" + +[providers.ollama] +api_key = "" +api_base = "http://localhost:11434/v1" + +[providers.cerebras] +api_key = "" +api_base = "" + +[providers.volcengine] +api_key = "" +api_base = "" + +[tools.web.brave] enabled = false -api_key = "YOUR_VLLM_API_KEY" -api_base = "http://localhost:8000/v1" +api_key = "YOUR_BRAVE_API_KEY" +max_results = 5 -[providers.shengsuan_yun] +[tools.web.perplexity] enabled = false -api_key = "YOUR_SHENGSUAN_YUN_API_KEY" -api_base = "https://api.shengsuan.cloud/v1" +api_key = "pplx-xxx" +max_results = 5 -[gateway] -enabled = true -host = "0.0.0.0" -port = 8080 -cors_enabled = true -cors_origins = ["*"] - -[tools] -enabled = true +[tools.cron] +exec_timeout_minutes = 5 [heartbeat] enabled = true -interval = 60 -timeout = 5 +interval = 30 [devices] -enabled = true +enabled = false +monitor_usb = true -[session] -storage = "memory" -# For persistent storage, set to "sqlite" and provide database_path -# database_path = "~/.picoclaw/session.db" +[gateway] +host = "0.0.0.0" +port = 18790 diff --git a/pkg/config/config.go b/pkg/config/config.go index 5df4121eb..a4a57e779 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "reflect" + "strings" "sync/atomic" "github.com/caarlos0/env/v11" @@ -27,46 +28,17 @@ var configFileNames = []string{ // so allow_from can contain both "123" and 123. type FlexibleStringSlice []string -func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { - // Try []string first - var ss []string - if err := json.Unmarshal(data, &ss); err == nil { - *f = ss - return nil - } - - // Try []interface{} to handle mixed types - var raw []interface{} - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - result := make([]string, 0, len(raw)) - for _, v := range raw { - switch val := v.(type) { - case string: - result = append(result, val) - case float64: - result = append(result, fmt.Sprintf("%.0f", val)) - default: - result = append(result, fmt.Sprintf("%v", val)) - } - } - *f = result - return nil -} - type Config struct { - Agents AgentsConfig `json:"agents"` - Bindings []AgentBinding `json:"bindings,omitempty"` - Session SessionConfig `json:"session,omitempty"` - Channels ChannelsConfig `json:"channels"` - Providers ProvidersConfig `json:"providers,omitempty"` - ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration - Gateway GatewayConfig `json:"gateway"` - Tools ToolsConfig `json:"tools"` - Heartbeat HeartbeatConfig `json:"heartbeat"` - Devices DevicesConfig `json:"devices"` + Agents AgentsConfig `json:"agents" mapstructure:"agents"` + Bindings []AgentBinding `json:"bindings,omitempty" mapstructure:"bindings"` + Session SessionConfig `json:"session,omitempty" mapstructure:"session"` + Channels ChannelsConfig `json:"channels" mapstructure:"channels"` + Providers ProvidersConfig `json:"providers,omitempty" mapstructure:"providers"` + ModelList []ModelConfig `json:"model_list" mapstructure:"model_list"` // New model-centric provider configuration + Gateway GatewayConfig `json:"gateway" mapstructure:"gateway"` + Tools ToolsConfig `json:"tools" mapstructure:"tools"` + Heartbeat HeartbeatConfig `json:"heartbeat" mapstructure:"heartbeat"` + Devices DevicesConfig `json:"devices" mapstructure:"devices"` } // MarshalJSON implements custom JSON marshaling for Config @@ -74,8 +46,8 @@ type Config struct { func (c Config) MarshalJSON() ([]byte, error) { type Alias Config aux := &struct { - Providers *ProvidersConfig `json:"providers,omitempty"` - Session *SessionConfig `json:"session,omitempty"` + Providers *ProvidersConfig `json:"providers,omitempty" mapstructure:"providers"` + Session *SessionConfig `json:"session,omitempty" mapstructure:"session"` *Alias }{ Alias: (*Alias)(&c), @@ -95,216 +67,185 @@ func (c Config) MarshalJSON() ([]byte, error) { } type AgentsConfig struct { - Defaults AgentDefaults `json:"defaults"` - List []AgentConfig `json:"list,omitempty"` + Defaults AgentDefaults `json:"defaults" mapstructure:"defaults"` + List []AgentConfig `json:"list,omitempty" mapstructure:"list"` } // AgentModelConfig supports both string and structured model config. // String format: "gpt-4" (just primary, no fallbacks) // Object format: {"primary": "gpt-4", "fallbacks": ["claude-haiku"]} type AgentModelConfig struct { - Primary string `json:"primary,omitempty"` - Fallbacks []string `json:"fallbacks,omitempty"` -} - -func (m *AgentModelConfig) UnmarshalJSON(data []byte) error { - var s string - if err := json.Unmarshal(data, &s); err == nil { - m.Primary = s - m.Fallbacks = nil - return nil - } - type raw struct { - Primary string `json:"primary"` - Fallbacks []string `json:"fallbacks"` - } - var r raw - if err := json.Unmarshal(data, &r); err != nil { - return err - } - m.Primary = r.Primary - m.Fallbacks = r.Fallbacks - return nil -} - -func (m AgentModelConfig) MarshalJSON() ([]byte, error) { - if len(m.Fallbacks) == 0 && m.Primary != "" { - return json.Marshal(m.Primary) - } - type raw struct { - Primary string `json:"primary,omitempty"` - Fallbacks []string `json:"fallbacks,omitempty"` - } - return json.Marshal(raw{Primary: m.Primary, Fallbacks: m.Fallbacks}) + Primary string `json:"primary,omitempty" mapstructure:"primary"` + Fallbacks []string `json:"fallbacks,omitempty" mapstructure:"fallbacks"` } type AgentConfig struct { - ID string `json:"id"` - Default bool `json:"default,omitempty"` - Name string `json:"name,omitempty"` - Workspace string `json:"workspace,omitempty"` - Model *AgentModelConfig `json:"model,omitempty"` - Skills []string `json:"skills,omitempty"` - Subagents *SubagentsConfig `json:"subagents,omitempty"` + ID string `json:"id" mapstructure:"id"` + Default bool `json:"default,omitempty" mapstructure:"default"` + Name string `json:"name,omitempty" mapstructure:"name"` + Workspace string `json:"workspace,omitempty" mapstructure:"workspace"` + Model *AgentModelConfig `json:"model,omitempty" mapstructure:"model"` + Skills []string `json:"skills,omitempty" mapstructure:"skills"` + Subagents *SubagentsConfig `json:"subagents,omitempty" mapstructure:"subagents"` } type SubagentsConfig struct { - AllowAgents []string `json:"allow_agents,omitempty"` - Model *AgentModelConfig `json:"model,omitempty"` + AllowAgents []string `json:"allow_agents,omitempty" mapstructure:"allow_agents"` + Model *AgentModelConfig `json:"model,omitempty" mapstructure:"model"` } type PeerMatch struct { - Kind string `json:"kind"` - ID string `json:"id"` + Kind string `json:"kind" mapstructure:"kind"` + ID string `json:"id" mapstructure:"id"` } type BindingMatch struct { - Channel string `json:"channel"` - AccountID string `json:"account_id,omitempty"` - Peer *PeerMatch `json:"peer,omitempty"` - GuildID string `json:"guild_id,omitempty"` - TeamID string `json:"team_id,omitempty"` + Channel string `json:"channel" mapstructure:"channel"` + AccountID string `json:"account_id,omitempty" mapstructure:"account_id"` + Peer *PeerMatch `json:"peer,omitempty" mapstructure:"peer"` + GuildID string `json:"guild_id,omitempty" mapstructure:"guild_id"` + TeamID string `json:"team_id,omitempty" mapstructure:"team_id"` } type AgentBinding struct { - AgentID string `json:"agent_id"` - Match BindingMatch `json:"match"` + AgentID string `json:"agent_id" mapstructure:"agent_id"` + Match BindingMatch `json:"match" mapstructure:"match"` } type SessionConfig struct { - DMScope string `json:"dm_scope,omitempty"` - IdentityLinks map[string][]string `json:"identity_links,omitempty"` + DMScope string `json:"dm_scope,omitempty" mapstructure:"dm_scope"` + IdentityLinks map[string][]string `json:"identity_links,omitempty" mapstructure:"identity_links"` } type AgentDefaults struct { - Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` - RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` - Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` - Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` - ModelFallbacks []string `json:"model_fallbacks,omitempty"` - ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` - ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` - MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` - Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` - MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` + Workspace string `json:"workspace" mapstructure:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` + RestrictToWorkspace bool `json:"restrict_to_workspace" mapstructure:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` + Provider string `json:"provider" mapstructure:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` + Model string `json:"model" mapstructure:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` + ModelFallbacks []string `json:"model_fallbacks,omitempty" mapstructure:"model_fallbacks"` + ImageModel string `json:"image_model,omitempty" mapstructure:"image_model" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` + ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty" mapstructure:"image_model_fallbacks"` + MaxTokens int `json:"max_tokens" mapstructure:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` + Temperature *float64 `json:"temperature,omitempty" mapstructure:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` + MaxToolIterations int `json:"max_tool_iterations" mapstructure:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` } type ChannelsConfig struct { - WhatsApp WhatsAppConfig `json:"whatsapp"` - Telegram TelegramConfig `json:"telegram"` - Feishu FeishuConfig `json:"feishu"` - Discord DiscordConfig `json:"discord"` - MaixCam MaixCamConfig `json:"maixcam"` - QQ QQConfig `json:"qq"` - DingTalk DingTalkConfig `json:"dingtalk"` - Slack SlackConfig `json:"slack"` - LINE LINEConfig `json:"line"` - OneBot OneBotConfig `json:"onebot"` + WhatsApp WhatsAppConfig `json:"whatsapp" mapstructure:"whatsapp"` + Telegram TelegramConfig `json:"telegram" mapstructure:"telegram"` + Feishu FeishuConfig `json:"feishu" mapstructure:"feishu"` + Discord DiscordConfig `json:"discord" mapstructure:"discord"` + MaixCam MaixCamConfig `json:"maixcam" mapstructure:"maixcam"` + QQ QQConfig `json:"qq" mapstructure:"qq"` + DingTalk DingTalkConfig `json:"dingtalk" mapstructure:"dingtalk"` + Slack SlackConfig `json:"slack" mapstructure:"slack"` + LINE LINEConfig `json:"line" mapstructure:"line"` + OneBot OneBotConfig `json:"onebot" mapstructure:"onebot"` } type WhatsAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` - BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` + Enabled bool `json:"enabled" mapstructure:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` + BridgeURL string `json:"bridge_url" mapstructure:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` + AllowFrom FlexibleStringSlice `json:"allow_from" mapstructure:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` } type TelegramConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` - Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` + Enabled bool `json:"enabled" mapstructure:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` + Token string `json:"token" mapstructure:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + Proxy string `json:"proxy" mapstructure:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` + AllowFrom FlexibleStringSlice `json:"allow_from" mapstructure:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` } type FeishuConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` - AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` - EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` - VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` + Enabled bool `json:"enabled" mapstructure:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` + AppID string `json:"app_id" mapstructure:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` + AppSecret string `json:"app_secret" mapstructure:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` + EncryptKey string `json:"encrypt_key" mapstructure:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` + VerificationToken string `json:"verification_token" mapstructure:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" mapstructure:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` } type DiscordConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` + Enabled bool `json:"enabled" mapstructure:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` + Token string `json:"token" mapstructure:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" mapstructure:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` } type MaixCamConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` - Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` - Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` + Enabled bool `json:"enabled" mapstructure:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` + Host string `json:"host" mapstructure:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` + Port int `json:"port" mapstructure:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` + AllowFrom FlexibleStringSlice `json:"allow_from" mapstructure:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` } type QQConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` - AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` + Enabled bool `json:"enabled" mapstructure:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` + AppID string `json:"app_id" mapstructure:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` + AppSecret string `json:"app_secret" mapstructure:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` + AllowFrom FlexibleStringSlice `json:"allow_from" mapstructure:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` } type DingTalkConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` - ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` - ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` + Enabled bool `json:"enabled" mapstructure:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` + ClientID string `json:"client_id" mapstructure:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` + ClientSecret string `json:"client_secret" mapstructure:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` + AllowFrom FlexibleStringSlice `json:"allow_from" mapstructure:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` } type SlackConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` - BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` - AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` + Enabled bool `json:"enabled" mapstructure:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` + BotToken string `json:"bot_token" mapstructure:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` + AppToken string `json:"app_token" mapstructure:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" mapstructure:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` } type LINEConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` - ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` - ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` + Enabled bool `json:"enabled" mapstructure:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` + ChannelSecret string `json:"channel_secret" mapstructure:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` + ChannelAccessToken string `json:"channel_access_token" mapstructure:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` + WebhookHost string `json:"webhook_host" mapstructure:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" mapstructure:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" mapstructure:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" mapstructure:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` } type OneBotConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` - WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` - AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` - ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` - GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` + Enabled bool `json:"enabled" mapstructure:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` + WSUrl string `json:"ws_url" mapstructure:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` + AccessToken string `json:"access_token" mapstructure:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` + ReconnectInterval int `json:"reconnect_interval" mapstructure:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` + GroupTriggerPrefix []string `json:"group_trigger_prefix" mapstructure:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` + AllowFrom FlexibleStringSlice `json:"allow_from" mapstructure:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` } type HeartbeatConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` - Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 + Enabled bool `json:"enabled" mapstructure:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` + Interval int `json:"interval" mapstructure:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 } type DevicesConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"` - MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"` + Enabled bool `json:"enabled" mapstructure:"enabled" env:"PICOCLAW_DEVICES_ENABLED"` + MonitorUSB bool `json:"monitor_usb" mapstructure:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"` } type ProvidersConfig struct { - Anthropic ProviderConfig `json:"anthropic"` - OpenAI OpenAIProviderConfig `json:"openai"` - OpenRouter ProviderConfig `json:"openrouter"` - Groq ProviderConfig `json:"groq"` - Zhipu ProviderConfig `json:"zhipu"` - VLLM ProviderConfig `json:"vllm"` - Gemini ProviderConfig `json:"gemini"` - Nvidia ProviderConfig `json:"nvidia"` - Ollama ProviderConfig `json:"ollama"` - Moonshot ProviderConfig `json:"moonshot"` - ShengSuanYun ProviderConfig `json:"shengsuanyun"` - DeepSeek ProviderConfig `json:"deepseek"` - Cerebras ProviderConfig `json:"cerebras"` - VolcEngine ProviderConfig `json:"volcengine"` - GitHubCopilot ProviderConfig `json:"github_copilot"` - Antigravity ProviderConfig `json:"antigravity"` - Qwen ProviderConfig `json:"qwen"` + Anthropic ProviderConfig `json:"anthropic" mapstructure:"anthropic"` + OpenAI OpenAIProviderConfig `json:"openai" mapstructure:"openai"` + OpenRouter ProviderConfig `json:"openrouter" mapstructure:"openrouter"` + Groq ProviderConfig `json:"groq" mapstructure:"groq"` + Zhipu ProviderConfig `json:"zhipu" mapstructure:"zhipu"` + VLLM ProviderConfig `json:"vllm" mapstructure:"vllm"` + Gemini ProviderConfig `json:"gemini" mapstructure:"gemini"` + Nvidia ProviderConfig `json:"nvidia" mapstructure:"nvidia"` + Ollama ProviderConfig `json:"ollama" mapstructure:"ollama"` + Moonshot ProviderConfig `json:"moonshot" mapstructure:"moonshot"` + ShengSuanYun ProviderConfig `json:"shengsuanyun" mapstructure:"shengsuanyun"` + DeepSeek ProviderConfig `json:"deepseek" mapstructure:"deepseek"` + Cerebras ProviderConfig `json:"cerebras" mapstructure:"cerebras"` + VolcEngine ProviderConfig `json:"volcengine" mapstructure:"volcengine"` + GitHubCopilot ProviderConfig `json:"github_copilot" mapstructure:"github_copilot"` + Antigravity ProviderConfig `json:"antigravity" mapstructure:"antigravity"` + Qwen ProviderConfig `json:"qwen" mapstructure:"qwen"` } // IsEmpty checks if all provider configs are empty (no API keys or API bases set) @@ -340,16 +281,16 @@ func (p ProvidersConfig) MarshalJSON() ([]byte, error) { } type ProviderConfig struct { - APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` - APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` - Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"` - AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` - ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc` + APIKey string `json:"api_key" mapstructure:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` + APIBase string `json:"api_base" mapstructure:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` + Proxy string `json:"proxy,omitempty" mapstructure:"proxy" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"` + AuthMethod string `json:"auth_method,omitempty" mapstructure:"auth_method" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` + ConnectMode string `json:"connect_mode,omitempty" mapstructure:"connect_mode" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc` } type OpenAIProviderConfig struct { ProviderConfig - WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"` + WebSearch bool `json:"web_search" mapstructure:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"` } // ModelConfig represents a model-centric provider configuration. @@ -359,22 +300,22 @@ type OpenAIProviderConfig struct { // Default protocol is "openai" if no prefix is specified. type ModelConfig struct { // Required fields - ModelName string `json:"model_name"` // User-facing alias for the model - Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6") + ModelName string `json:"model_name" mapstructure:"model_name"` // User-facing alias for the model + Model string `json:"model" mapstructure:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6") // HTTP-based providers - APIBase string `json:"api_base,omitempty"` // API endpoint URL - APIKey string `json:"api_key"` // API authentication key - Proxy string `json:"proxy,omitempty"` // HTTP proxy URL + APIBase string `json:"api_base,omitempty" mapstructure:"api_base"` // API endpoint URL + APIKey string `json:"api_key" mapstructure:"api_key"` // API authentication key + Proxy string `json:"proxy,omitempty" mapstructure:"proxy"` // HTTP proxy URL // Special providers (CLI-based, OAuth, etc.) - AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token - ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc - Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers + AuthMethod string `json:"auth_method,omitempty" mapstructure:"auth_method"` // Authentication method: oauth, token + ConnectMode string `json:"connect_mode,omitempty" mapstructure:"connect_mode"` // Connection mode: stdio, grpc + Workspace string `json:"workspace,omitempty" mapstructure:"workspace"` // Workspace path for CLI-based providers // Optional optimizations - RPM int `json:"rpm,omitempty"` // Requests per minute limit - MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens") + RPM int `json:"rpm,omitempty" mapstructure:"rpm"` // Requests per minute limit + MaxTokensField string `json:"max_tokens_field,omitempty" mapstructure:"max_tokens_field"` // Field name for max tokens (e.g., "max_completion_tokens") } // Validate checks if the ModelConfig has all required fields. @@ -389,46 +330,46 @@ func (c *ModelConfig) Validate() error { } type GatewayConfig struct { - Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` - Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` + Host string `json:"host" mapstructure:"host" env:"PICOCLAW_GATEWAY_HOST"` + Port int `json:"port" mapstructure:"port" env:"PICOCLAW_GATEWAY_PORT"` } type BraveConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` + Enabled bool `json:"enabled" mapstructure:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` + APIKey string `json:"api_key" mapstructure:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"` + MaxResults int `json:"max_results" mapstructure:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` } type DuckDuckGoConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"` + Enabled bool `json:"enabled" mapstructure:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"` + MaxResults int `json:"max_results" mapstructure:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"` } type PerplexityConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` + Enabled bool `json:"enabled" mapstructure:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` + APIKey string `json:"api_key" mapstructure:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"` + MaxResults int `json:"max_results" mapstructure:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` } type WebToolsConfig struct { - Brave BraveConfig `json:"brave"` - DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"` - Perplexity PerplexityConfig `json:"perplexity"` + Brave BraveConfig `json:"brave" mapstructure:"brave"` + DuckDuckGo DuckDuckGoConfig `json:"duckduckgo" mapstructure:"duckduckgo"` + Perplexity PerplexityConfig `json:"perplexity" mapstructure:"perplexity"` } type CronToolsConfig struct { - ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout + ExecTimeoutMinutes int `json:"exec_timeout_minutes" mapstructure:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout } type ExecConfig struct { - EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"` - CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"` + EnableDenyPatterns bool `json:"enable_deny_patterns" mapstructure:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"` + CustomDenyPatterns []string `json:"custom_deny_patterns" mapstructure:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"` } type ToolsConfig struct { - Web WebToolsConfig `json:"web"` - Cron CronToolsConfig `json:"cron"` - Exec ExecConfig `json:"exec"` + Web WebToolsConfig `json:"web" mapstructure:"web"` + Cron CronToolsConfig `json:"cron" mapstructure:"cron"` + Exec ExecConfig `json:"exec" mapstructure:"exec"` } func LoadConfig(path string) (*Config, error) { @@ -449,13 +390,37 @@ func LoadConfig(path string) (*Config, error) { } if err := v.Unmarshal(cfg, func(dc *mapstructure.DecoderConfig) { - dc.TagName = "json" + dc.TagName = "mapstructure" dc.WeaklyTypedInput = true dc.Squash = true dc.DecodeHook = mapstructure.ComposeDecodeHookFunc( func(_ reflect.Type, t reflect.Type, data interface{}) (interface{}, error) { modelCfgPtr := reflect.TypeOf((*AgentModelConfig)(nil)) modelCfgVal := reflect.TypeOf(AgentModelConfig{}) + fssPtr := reflect.TypeOf((*FlexibleStringSlice)(nil)) + fssVal := reflect.TypeOf(FlexibleStringSlice{}) + if t == fssPtr || t == fssVal { + converted := make(FlexibleStringSlice, 0) + switch raw := data.(type) { + case []string: + converted = append(converted, raw...) + case []interface{}: + for _, item := range raw { + converted = append(converted, fmt.Sprintf("%v", item)) + } + case string: + converted = append(converted, raw) + default: + if data != nil { + converted = append(converted, fmt.Sprintf("%v", data)) + } + } + if t == fssPtr { + return &converted, nil + } + return converted, nil + } + if t != modelCfgPtr && t != modelCfgVal { return data, nil } @@ -533,17 +498,8 @@ func SaveConfig(path string, cfg *Config) error { v.SetConfigType(configType) v.SetConfigFile(path) - settings := map[string]interface{}{} - decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ - TagName: "json", - Result: &settings, - WeaklyTypedInput: true, - Squash: true, - }) + settings, err := structToTaggedMap(cfg) if err != nil { - return fmt.Errorf("failed to create config encoder: %w", err) - } - if err := decoder.Decode(cfg); err != nil { return fmt.Errorf("failed to encode config map: %w", err) } @@ -557,8 +513,8 @@ func SaveConfig(path string, cfg *Config) error { delete(settings, "bindings") } - for key, val := range settings { - v.Set(key, val) + if err := v.MergeConfigMap(settings); err != nil { + return fmt.Errorf("failed to merge config settings: %w", err) } dir := filepath.Dir(path) @@ -576,6 +532,121 @@ func SaveConfig(path string, cfg *Config) error { return nil } +func structToTaggedMap(v interface{}) (map[string]interface{}, error) { + encoded, err := encodeValueByMapstructureTag(reflect.ValueOf(v)) + if err != nil { + return nil, err + } + m, ok := encoded.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("encoded value is not a map") + } + return m, nil +} + +func encodeValueByMapstructureTag(v reflect.Value) (interface{}, error) { + if !v.IsValid() { + return nil, nil + } + + for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface { + if v.IsNil() { + return nil, nil + } + v = v.Elem() + } + + switch v.Kind() { + case reflect.Struct: + t := v.Type() + out := make(map[string]interface{}) + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if f.PkgPath != "" { + continue + } + + name, omitEmpty, skip := parseMapstructureTag(f.Tag.Get("mapstructure"), f.Name) + if skip { + continue + } + + fv := v.Field(i) + if omitEmpty && fv.IsZero() { + continue + } + + encoded, err := encodeValueByMapstructureTag(fv) + if err != nil { + return nil, err + } + if omitEmpty && encoded == nil { + continue + } + out[name] = encoded + } + return out, nil + + case reflect.Map: + if v.IsNil() { + return nil, nil + } + out := make(map[string]interface{}, v.Len()) + iter := v.MapRange() + for iter.Next() { + k := iter.Key() + if k.Kind() != reflect.String { + continue + } + encoded, err := encodeValueByMapstructureTag(iter.Value()) + if err != nil { + return nil, err + } + out[k.String()] = encoded + } + return out, nil + + case reflect.Slice, reflect.Array: + if v.Kind() == reflect.Slice && v.IsNil() { + return nil, nil + } + out := make([]interface{}, v.Len()) + for i := 0; i < v.Len(); i++ { + encoded, err := encodeValueByMapstructureTag(v.Index(i)) + if err != nil { + return nil, err + } + out[i] = encoded + } + return out, nil + + default: + return v.Interface(), nil + } +} + +func parseMapstructureTag(tag string, fallback string) (name string, omitEmpty bool, skip bool) { + if tag == "-" { + return "", false, true + } + if tag == "" { + return fallback, false, false + } + + parts := strings.Split(tag, ",") + name = parts[0] + if name == "" { + name = fallback + } + for i := 1; i < len(parts); i++ { + if parts[i] == "omitempty" { + omitEmpty = true + break + } + } + return name, omitEmpty, false +} + func ResolveConfigPath(home string) string { configDir := filepath.Join(home, ".picoclaw") for _, name := range configFileNames { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index bf8e16b59..310d10350 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1,44 +1,94 @@ package config import ( - "encoding/json" "os" "path/filepath" "runtime" "testing" ) -func TestAgentModelConfig_UnmarshalString(t *testing.T) { - var m AgentModelConfig - if err := json.Unmarshal([]byte(`"gpt-4"`), &m); err != nil { - t.Fatalf("unmarshal string: %v", err) +func TestAgentModelConfig_StringFormatViaLoadConfig(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + jsonData := `{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "max_tool_iterations": 20 + }, + "list": [ + { + "id": "sales", + "default": true, + "name": "Sales Bot", + "model": "gpt-4" + } + ] + } + }` + + if err := os.WriteFile(configPath, []byte(jsonData), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) } - if m.Primary != "gpt-4" { - t.Errorf("Primary = %q, want 'gpt-4'", m.Primary) + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) } - if m.Fallbacks != nil { - t.Errorf("Fallbacks = %v, want nil", m.Fallbacks) + + sales := cfg.Agents.List[0] + if sales.Model == nil || sales.Model.Primary != "gpt-4" { + t.Errorf("sales.Model = %+v", sales.Model) } } -func TestAgentModelConfig_UnmarshalObject(t *testing.T) { - var m AgentModelConfig - data := `{"primary": "claude-opus", "fallbacks": ["gpt-4o-mini", "haiku"]}` - if err := json.Unmarshal([]byte(data), &m); err != nil { - t.Fatalf("unmarshal object: %v", err) +func TestAgentModelConfig_ObjectFormatViaLoadConfig(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + jsonData := `{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "max_tool_iterations": 20 + }, + "list": [ + { + "id": "support", + "name": "Support Bot", + "model": { + "primary": "claude-opus", + "fallbacks": ["gpt-4o-mini", "haiku"] + } + } + ] + } + }` + + if err := os.WriteFile(configPath, []byte(jsonData), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) } - if m.Primary != "claude-opus" { - t.Errorf("Primary = %q, want 'claude-opus'", m.Primary) + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) } - if len(m.Fallbacks) != 2 { - t.Fatalf("Fallbacks len = %d, want 2", len(m.Fallbacks)) + + support := cfg.Agents.List[0] + if support.Model == nil || support.Model.Primary != "claude-opus" { + t.Errorf("support.Model = %+v", support.Model) } - if m.Fallbacks[0] != "gpt-4o-mini" || m.Fallbacks[1] != "haiku" { - t.Errorf("Fallbacks = %v", m.Fallbacks) + if len(support.Model.Fallbacks) != 2 || support.Model.Fallbacks[0] != "gpt-4o-mini" || support.Model.Fallbacks[1] != "haiku" { + t.Errorf("support.Model.Fallbacks = %v", support.Model.Fallbacks) } } -func TestAgentConfig_FullParse(t *testing.T) { +func TestAgentConfig_FullParseViaLoadConfig(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") jsonData := `{ "agents": { "defaults": { @@ -85,61 +135,23 @@ func TestAgentConfig_FullParse(t *testing.T) { } }` - cfg := DefaultConfig() - if err := json.Unmarshal([]byte(jsonData), cfg); err != nil { - t.Fatalf("unmarshal: %v", err) + if err := os.WriteFile(configPath, []byte(jsonData), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) } if len(cfg.Agents.List) != 2 { t.Fatalf("agents.list len = %d, want 2", len(cfg.Agents.List)) } - - sales := cfg.Agents.List[0] - if sales.ID != "sales" || !sales.Default || sales.Name != "Sales Bot" { - t.Errorf("sales = %+v", sales) - } - if sales.Model == nil || sales.Model.Primary != "gpt-4" { - t.Errorf("sales.Model = %+v", sales.Model) - } - - support := cfg.Agents.List[1] - if support.ID != "support" || support.Name != "Support Bot" { - t.Errorf("support = %+v", support) - } - if support.Model == nil || support.Model.Primary != "claude-opus" { - t.Errorf("support.Model = %+v", support.Model) - } - if len(support.Model.Fallbacks) != 1 || support.Model.Fallbacks[0] != "haiku" { - t.Errorf("support.Model.Fallbacks = %v", support.Model.Fallbacks) - } - if support.Subagents == nil || len(support.Subagents.AllowAgents) != 1 { - t.Errorf("support.Subagents = %+v", support.Subagents) - } - - if len(cfg.Bindings) != 1 { - t.Fatalf("bindings len = %d, want 1", len(cfg.Bindings)) - } - binding := cfg.Bindings[0] - if binding.AgentID != "support" || binding.Match.Channel != "telegram" { - t.Errorf("binding = %+v", binding) - } - if binding.Match.Peer == nil || binding.Match.Peer.Kind != "direct" || binding.Match.Peer.ID != "user123" { - t.Errorf("binding.Match.Peer = %+v", binding.Match.Peer) - } - - if cfg.Session.DMScope != "per-peer" { - t.Errorf("Session.DMScope = %q", cfg.Session.DMScope) - } - if len(cfg.Session.IdentityLinks) != 1 { - t.Errorf("Session.IdentityLinks = %v", cfg.Session.IdentityLinks) - } - links := cfg.Session.IdentityLinks["john"] - if len(links) != 2 { - t.Errorf("john links = %v", links) - } } -func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) { +func TestConfig_BackwardCompat_NoAgentsListViaLoadConfig(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") jsonData := `{ "agents": { "defaults": { @@ -151,9 +163,13 @@ func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) { } }` - cfg := DefaultConfig() - if err := json.Unmarshal([]byte(jsonData), cfg); err != nil { - t.Fatalf("unmarshal: %v", err) + if err := os.WriteFile(configPath, []byte(jsonData), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) } if len(cfg.Agents.List) != 0 { @@ -300,85 +316,6 @@ func TestSaveConfig_FilePermissions(t *testing.T) { } } -func TestSaveConfig_AllSupportedFormats(t *testing.T) { - tests := []struct { - name string - fileName string - expectedOut string - }{ - { - name: "json", - fileName: "config.json", - expectedOut: "config.json", - }, - { - name: "yaml", - fileName: "config.yaml", - expectedOut: "config.yaml", - }, - { - name: "yml", - fileName: "config.yml", - expectedOut: "config.yml", - }, - { - name: "toml", - fileName: "config.toml", - expectedOut: "config.toml", - }, - { - name: "no_extension_defaults_to_json", - fileName: "config", - expectedOut: "config.json", - }, - } - - for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { - tmpDir := t.TempDir() - targetPath := filepath.Join(tmpDir, tc.fileName) - outPath := filepath.Join(tmpDir, tc.expectedOut) - - cfg := DefaultConfig() - cfg.Agents.Defaults.Model = "unit-test-model" - cfg.Agents.Defaults.MaxTokens = 1234 - cfg.Gateway.Host = "127.0.0.1" - cfg.Gateway.Port = 19090 - cfg.Providers.Zhipu.APIKey = "test-zhipu-key" - - if err := SaveConfig(targetPath, cfg); err != nil { - t.Fatalf("SaveConfig(%q) failed: %v", targetPath, err) - } - - if _, err := os.Stat(outPath); err != nil { - t.Fatalf("expected output file %q to exist: %v", outPath, err) - } - - loaded, err := LoadConfig(outPath) - if err != nil { - t.Fatalf("LoadConfig(%q) failed: %v", outPath, err) - } - - if loaded.Agents.Defaults.Model != "unit-test-model" { - t.Errorf("Model = %q, want %q", loaded.Agents.Defaults.Model, "unit-test-model") - } - if loaded.Agents.Defaults.MaxTokens != 1234 { - t.Errorf("MaxTokens = %d, want %d", loaded.Agents.Defaults.MaxTokens, 1234) - } - if loaded.Gateway.Host != "127.0.0.1" { - t.Errorf("Gateway.Host = %q, want %q", loaded.Gateway.Host, "127.0.0.1") - } - if loaded.Gateway.Port != 19090 { - t.Errorf("Gateway.Port = %d, want %d", loaded.Gateway.Port, 19090) - } - if loaded.Providers.Zhipu.APIKey != "test-zhipu-key" { - t.Errorf("Providers.Zhipu.APIKey = %q, want %q", loaded.Providers.Zhipu.APIKey, "test-zhipu-key") - } - }) - } -} - // TestConfig_Complete verifies all config fields are set func TestConfig_Complete(t *testing.T) { cfg := DefaultConfig() diff --git a/pkg/config/loadconfig_parser_coverage_test.go b/pkg/config/loadconfig_parser_coverage_test.go new file mode 100644 index 000000000..d5cb8cd9e --- /dev/null +++ b/pkg/config/loadconfig_parser_coverage_test.go @@ -0,0 +1,142 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadConfig_FlexibleStringSlice_MixedArray(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + content := `{ + "channels": { + "telegram": { + "enabled": true, + "token": "x", + "allow_from": ["u1", 123, true] + } + } +}` + + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + + got := cfg.Channels.Telegram.AllowFrom + want := []string{"u1", "123", "true"} + if len(got) != len(want) { + t.Fatalf("allow_from len=%d, want=%d (%v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("allow_from[%d]=%q, want %q", i, got[i], want[i]) + } + } +} + +func TestLoadConfig_FlexibleStringSlice_SingleString(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + content := `{ + "channels": { + "telegram": { + "enabled": true, + "token": "x", + "allow_from": "solo-user" + } + } +}` + + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if len(cfg.Channels.Telegram.AllowFrom) != 1 || cfg.Channels.Telegram.AllowFrom[0] != "solo-user" { + t.Fatalf("allow_from=%v, want [solo-user]", cfg.Channels.Telegram.AllowFrom) + } +} + +func TestLoadConfig_InvalidConfigSyntax(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + invalid := "agents:\n defaults:\n model: [unclosed" + + if err := os.WriteFile(configPath, []byte(invalid), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + _, err := LoadConfig(configPath) + if err == nil { + t.Fatal("LoadConfig() expected error for invalid syntax") + } +} + +func TestLoadConfig_AutoMigrateProvidersToModelList(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + content := `{ + "model_list": [], + "agents": { + "defaults": { + "provider": "openai", + "model": "gpt-5.2" + } + }, + "providers": { + "openai": { + "api_key": "sk-test" + } + } +}` + + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if len(cfg.ModelList) == 0 { + t.Fatal("model_list should be auto-migrated from providers") + } + if cfg.ModelList[0].APIKey != "sk-test" { + t.Fatalf("migrated api_key=%q, want sk-test", cfg.ModelList[0].APIKey) + } +} + +func TestLoadConfig_ValidateModelListError(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + content := `{ + "model_list": [ + { + "model_name": "broken", + "model": "" + } + ] +}` + + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + _, err := LoadConfig(configPath) + if err == nil { + t.Fatal("LoadConfig() expected validation error") + } + if !strings.Contains(err.Error(), "model is required") { + t.Fatalf("error=%v, want contains 'model is required'", err) + } +}