Merge upstream/main into feat/config-viper-multiformat-fixes
This commit is contained in:
commit
41e6982f8d
66 changed files with 9390 additions and 1867 deletions
|
|
@ -5,6 +5,7 @@
|
||||||
# ANTHROPIC_API_KEY=sk-ant-xxx
|
# ANTHROPIC_API_KEY=sk-ant-xxx
|
||||||
# OPENAI_API_KEY=sk-xxx
|
# OPENAI_API_KEY=sk-xxx
|
||||||
# GEMINI_API_KEY=xxx
|
# GEMINI_API_KEY=xxx
|
||||||
|
# CEREBRAS_API_KEY=xxx
|
||||||
|
|
||||||
# ── Chat Channel ──────────────────────────
|
# ── Chat Channel ──────────────────────────
|
||||||
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
||||||
|
|
|
||||||
1038
README.fr.md
Normal file
1038
README.fr.md
Normal file
File diff suppressed because it is too large
Load diff
179
README.ja.md
179
README.ja.md
|
|
@ -12,7 +12,7 @@
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md)
|
[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -215,7 +215,7 @@ picoclaw onboard
|
||||||
|
|
||||||
**3. API キーの取得**
|
**3. API キーの取得**
|
||||||
|
|
||||||
- **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)
|
- **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) · [Qwen](https://dashscope.console.aliyun.com)
|
||||||
- **Web 検索**(任意): [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
|
- **Web 検索**(任意): [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
|
||||||
|
|
||||||
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
|
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
|
||||||
|
|
@ -627,6 +627,22 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化
|
- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化
|
||||||
- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔変更
|
- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔変更
|
||||||
|
|
||||||
|
### プロバイダー
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、Telegram の音声メッセージが自動的に文字起こしされます。
|
||||||
|
|
||||||
|
| プロバイダー | 用途 | API キー取得先 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `gemini` | LLM(Gemini 直接) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
|
| `zhipu` | LLM(Zhipu 直接) | [bigmodel.cn](https://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) |
|
||||||
|
| `groq` | LLM + **音声文字起こし**(Whisper) | [console.groq.com](https://console.groq.com) |
|
||||||
|
| `cerebras` | LLM(Cerebras 直接) | [cerebras.ai](https://cerebras.ai) |
|
||||||
|
|
||||||
### 基本設定
|
### 基本設定
|
||||||
|
|
||||||
1. **設定ファイルの作成:**
|
1. **設定ファイルの作成:**
|
||||||
|
|
@ -720,6 +736,163 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### モデル設定 (model_list)
|
||||||
|
|
||||||
|
> **新機能!** PicoClaw は現在 **モデル中心** の設定アプローチを採用しています。`ベンダー/モデル` 形式(例: `zhipu/glm-4.7`)を指定するだけで、新しいプロバイダーを追加できます—**コードの変更は一切不要!**
|
||||||
|
|
||||||
|
この設計は、柔軟なプロバイダー選択による **マルチエージェントサポート** も可能にします:
|
||||||
|
|
||||||
|
- **異なるエージェント、異なるプロバイダー** : 各エージェントは独自の LLM プロバイダーを使用可能
|
||||||
|
- **フォールバックモデル** : 耐障性のため、プライマリモデルとフォールバックモデルを設定可能
|
||||||
|
- **ロードバランシング** : 複数のエンドポイントにリクエストを分散
|
||||||
|
- **集中設定管理** : すべてのプロバイダーを一箇所で管理
|
||||||
|
|
||||||
|
#### 📋 サポートされているすべてのベンダー
|
||||||
|
|
||||||
|
| ベンダー | `model` プレフィックス | デフォルト API Base | プロトコル | API キー |
|
||||||
|
|-------------|-----------------|---------------------|----------|---------|
|
||||||
|
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [キーを取得](https://platform.openai.com) |
|
||||||
|
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [キーを取得](https://console.anthropic.com) |
|
||||||
|
| **Zhipu AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [キーを取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||||
|
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [キーを取得](https://platform.deepseek.com) |
|
||||||
|
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [キーを取得](https://aistudio.google.com/api-keys) |
|
||||||
|
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [キーを取得](https://console.groq.com) |
|
||||||
|
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [キーを取得](https://platform.moonshot.cn) |
|
||||||
|
| **Qwen (Alibaba)** | `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 | ローカル(キー不要) |
|
||||||
|
| **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** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
|
||||||
|
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ |
|
||||||
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
||||||
|
#### 基本設定
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_key": "sk-your-openai-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
|
"api_key": "sk-ant-your-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "glm-4.7",
|
||||||
|
"model": "zhipu/glm-4.7",
|
||||||
|
"api_key": "your-zhipu-key"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "gpt-5.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ベンダー別の例
|
||||||
|
|
||||||
|
**OpenAI**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zhipu AI (GLM)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "glm-4.7",
|
||||||
|
"model": "zhipu/glm-4.7",
|
||||||
|
"api_key": "your-key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Anthropic (OAuth使用)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
|
"auth_method": "oauth"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。
|
||||||
|
|
||||||
|
#### ロードバランシング
|
||||||
|
|
||||||
|
同じモデル名で複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_base": "https://api1.example.com/v1",
|
||||||
|
"api_key": "sk-key1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_base": "https://api2.example.com/v1",
|
||||||
|
"api_key": "sk-key2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 従来の `providers` 設定からの移行
|
||||||
|
|
||||||
|
古い `providers` 設定は**非推奨**ですが、後方互換性のためにサポートされています。
|
||||||
|
|
||||||
|
**旧設定(非推奨):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"zhipu": {
|
||||||
|
"api_key": "your-key",
|
||||||
|
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "zhipu",
|
||||||
|
"model": "glm-4.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**新設定(推奨):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "glm-4.7",
|
||||||
|
"model": "zhipu/glm-4.7",
|
||||||
|
"api_key": "your-key"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "glm-4.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
詳細な移行ガイドは、[docs/migration/model-list-migration.md](docs/migration/model-list-migration.md) を参照してください。
|
||||||
|
|
||||||
## CLI リファレンス
|
## CLI リファレンス
|
||||||
|
|
||||||
| コマンド | 説明 |
|
| コマンド | 説明 |
|
||||||
|
|
@ -777,5 +950,7 @@ Web 検索を有効にするには:
|
||||||
|---------|--------|------------|
|
|---------|--------|------------|
|
||||||
| **OpenRouter** | 月 200K トークン | 複数モデル(Claude, GPT-4 など) |
|
| **OpenRouter** | 月 200K トークン | 複数モデル(Claude, GPT-4 など) |
|
||||||
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
|
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
|
||||||
|
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
|
||||||
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
|
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
|
||||||
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
||||||
|
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
|
||||||
|
|
|
||||||
209
README.md
209
README.md
|
|
@ -14,7 +14,7 @@
|
||||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **English**
|
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -215,18 +215,24 @@ PicoClaw supports multiple configuration formats via [spf13/viper](https://githu
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.picoclaw/workspace",
|
"workspace": "~/.picoclaw/workspace",
|
||||||
"model": "glm-4.7",
|
"model": "gpt4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20
|
"max_tool_iterations": 20
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"model_list": [
|
||||||
"openrouter": {
|
{
|
||||||
"api_key": "xxx",
|
"model_name": "gpt4",
|
||||||
"api_base": "https://openrouter.ai/api/v1"
|
"model": "openai/gpt-5.2",
|
||||||
}
|
"api_key": "your-api-key"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
|
"api_key": "your-anthropic-key"
|
||||||
|
}
|
||||||
|
],
|
||||||
"tools": {
|
"tools": {
|
||||||
"web": {
|
"web": {
|
||||||
"brave": {
|
"brave": {
|
||||||
|
|
@ -243,6 +249,8 @@ PicoClaw supports multiple configuration formats via [spf13/viper](https://githu
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#-model-configuration) for details.
|
||||||
|
|
||||||
**3. Get API Keys**
|
**3. Get API Keys**
|
||||||
|
|
||||||
* **LLM Provider**: [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)
|
* **LLM Provider**: [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)
|
||||||
|
|
@ -683,7 +691,193 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
||||||
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||||
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||||
|
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||||
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||||
|
| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
|
||||||
|
|
||||||
|
### Model Configuration (model_list)
|
||||||
|
|
||||||
|
> **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers—**zero code changes required!**
|
||||||
|
|
||||||
|
This design also enables **multi-agent support** with flexible provider selection:
|
||||||
|
|
||||||
|
- **Different agents, different providers**: Each agent can use its own LLM provider
|
||||||
|
- **Model fallbacks**: Configure primary and fallback models for resilience
|
||||||
|
- **Load balancing**: Distribute requests across multiple endpoints
|
||||||
|
- **Centralized configuration**: Manage all providers in one place
|
||||||
|
|
||||||
|
#### 📋 All Supported Vendors
|
||||||
|
|
||||||
|
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
||||||
|
|--------|----------------|------------------|----------|---------|
|
||||||
|
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
||||||
|
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||||
|
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||||
|
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
|
||||||
|
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
|
||||||
|
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
|
||||||
|
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
|
||||||
|
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
|
||||||
|
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
|
||||||
|
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
|
||||||
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
|
||||||
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
|
||||||
|
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
|
||||||
|
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
||||||
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
||||||
|
#### Basic Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_key": "sk-your-openai-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
|
"api_key": "sk-ant-your-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "glm-4.7",
|
||||||
|
"model": "zhipu/glm-4.7",
|
||||||
|
"api_key": "your-zhipu-key"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "gpt-5.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Vendor-Specific Examples
|
||||||
|
|
||||||
|
**OpenAI**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**智谱 AI (GLM)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "glm-4.7",
|
||||||
|
"model": "zhipu/glm-4.7",
|
||||||
|
"api_key": "your-key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**DeepSeek**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "deepseek-chat",
|
||||||
|
"model": "deepseek/deepseek-chat",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Anthropic (with OAuth)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
|
"auth_method": "oauth"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> Run `picoclaw auth login --provider anthropic` to set up OAuth credentials.
|
||||||
|
|
||||||
|
**Ollama (local)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "llama3",
|
||||||
|
"model": "ollama/llama3"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Custom Proxy/API**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Load Balancing
|
||||||
|
|
||||||
|
Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_base": "https://api1.example.com/v1",
|
||||||
|
"api_key": "sk-key1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_base": "https://api2.example.com/v1",
|
||||||
|
"api_key": "sk-key2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Migration from Legacy `providers` Config
|
||||||
|
|
||||||
|
The old `providers` configuration is **deprecated** but still supported for backward compatibility.
|
||||||
|
|
||||||
|
**Old Config (deprecated):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"zhipu": {
|
||||||
|
"api_key": "your-key",
|
||||||
|
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "zhipu",
|
||||||
|
"model": "glm-4.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**New Config (recommended):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "glm-4.7",
|
||||||
|
"model": "zhipu/glm-4.7",
|
||||||
|
"api_key": "your-key"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "glm-4.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For detailed migration guide, see [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
|
||||||
|
|
||||||
### Provider Architecture
|
### Provider Architecture
|
||||||
|
|
||||||
|
|
@ -889,3 +1083,4 @@ This happens when another instance of the bot is running. Make sure only one `pi
|
||||||
| **Zhipu** | 200K tokens/month | Best for Chinese users |
|
| **Zhipu** | 200K tokens/month | Best for Chinese users |
|
||||||
| **Brave Search** | 2000 queries/month | Web search functionality |
|
| **Brave Search** | 2000 queries/month | Web search functionality |
|
||||||
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
||||||
|
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
||||||
|
|
|
||||||
159
README.pt-br.md
159
README.pt-br.md
|
|
@ -14,7 +14,7 @@
|
||||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md) | **Português**
|
[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -801,6 +801,163 @@ picoclaw agent -m "Ola, como vai?"
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### Configuração de Modelo (model_list)
|
||||||
|
|
||||||
|
> **Novidade!** PicoClaw agora usa uma abordagem de configuração **centrada no modelo**. Basta especificar o formato `fornecedor/modelo` (ex: `zhipu/glm-4.7`) para adicionar novos provedores—**nenhuma alteração de código necessária!**
|
||||||
|
|
||||||
|
Este design também possibilita o **suporte multi-agent** com seleção flexível de provedores:
|
||||||
|
|
||||||
|
- **Diferentes agentes, diferentes provedores** : Cada agente pode usar seu próprio provedor LLM
|
||||||
|
- **Modelos de fallback** : Configure modelos primários e de reserva para resiliência
|
||||||
|
- **Balanceamento de carga** : Distribua solicitações entre múltiplos endpoints
|
||||||
|
- **Configuração centralizada** : Gerencie todos os provedores em um só lugar
|
||||||
|
|
||||||
|
#### 📋 Todos os Fornecedores Suportados
|
||||||
|
|
||||||
|
| Fornecedor | Prefixo `model` | API Base Padrão | Protocolo | Chave API |
|
||||||
|
|-------------|-----------------|------------------|----------|-----------|
|
||||||
|
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Obter Chave](https://platform.openai.com) |
|
||||||
|
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obter Chave](https://console.anthropic.com) |
|
||||||
|
| **Zhipu AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obter Chave](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||||
|
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obter Chave](https://platform.deepseek.com) |
|
||||||
|
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obter Chave](https://aistudio.google.com/api-keys) |
|
||||||
|
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obter Chave](https://console.groq.com) |
|
||||||
|
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Obter Chave](https://platform.moonshot.cn) |
|
||||||
|
| **Qwen (Alibaba)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obter Chave](https://dashscope.console.aliyun.com) |
|
||||||
|
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Obter Chave](https://build.nvidia.com) |
|
||||||
|
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (sem chave necessária) |
|
||||||
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter Chave](https://openrouter.ai/keys) |
|
||||||
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) |
|
||||||
|
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) |
|
||||||
|
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth |
|
||||||
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
||||||
|
#### Configuração Básica
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_key": "sk-your-openai-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
|
"api_key": "sk-ant-your-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "glm-4.7",
|
||||||
|
"model": "zhipu/glm-4.7",
|
||||||
|
"api_key": "your-zhipu-key"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "gpt-5.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Exemplos por Fornecedor
|
||||||
|
|
||||||
|
**OpenAI**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zhipu AI (GLM)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "glm-4.7",
|
||||||
|
"model": "zhipu/glm-4.7",
|
||||||
|
"api_key": "your-key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Anthropic (com OAuth)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
|
"auth_method": "oauth"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> Execute `picoclaw auth login --provider anthropic` para configurar credenciais OAuth.
|
||||||
|
|
||||||
|
#### Balanceamento de Carga
|
||||||
|
|
||||||
|
Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-robin automaticamente entre eles:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_base": "https://api1.example.com/v1",
|
||||||
|
"api_key": "sk-key1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_base": "https://api2.example.com/v1",
|
||||||
|
"api_key": "sk-key2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Migração da Configuração Legada `providers`
|
||||||
|
|
||||||
|
A configuração antiga `providers` está **descontinuada** mas ainda é suportada para compatibilidade reversa.
|
||||||
|
|
||||||
|
**Configuração Antiga (descontinuada):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"zhipu": {
|
||||||
|
"api_key": "your-key",
|
||||||
|
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "zhipu",
|
||||||
|
"model": "glm-4.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nova Configuração (recomendada):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "glm-4.7",
|
||||||
|
"model": "zhipu/glm-4.7",
|
||||||
|
"api_key": "your-key"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "glm-4.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Para o guia de migração detalhado, consulte [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
|
||||||
|
|
||||||
## Referência CLI
|
## Referência CLI
|
||||||
|
|
||||||
| Comando | Descrição |
|
| Comando | Descrição |
|
||||||
|
|
|
||||||
159
README.vi.md
159
README.vi.md
|
|
@ -14,7 +14,7 @@
|
||||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Tiếng Việt** | [中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [English](README.md)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -782,6 +782,163 @@ picoclaw agent -m "Xin chào"
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### Cấu hình Mô hình (model_list)
|
||||||
|
|
||||||
|
> **Tính năng mới!** PicoClaw hiện sử dụng phương pháp cấu hình **đặt mô hình vào trung tâm**. Chỉ cần chỉ định dạng `nhà cung cấp/mô hình` (ví dụ: `zhipu/glm-4.7`) để thêm nhà cung cấp mới—**không cần thay đổi mã!**
|
||||||
|
|
||||||
|
Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa chọn nhà cung cấp linh hoạt:
|
||||||
|
|
||||||
|
- **Tác nhân khác nhau, nhà cung cấp khác nhau** : Mỗi tác nhân có thể sử dụng nhà cung cấp LLM riêng
|
||||||
|
- **Mô hình dự phòng** : Cấu hình mô hình chính và dự phòng để tăng độ tin cậy
|
||||||
|
- **Cân bằng tải** : Phân phối yêu cầu trên nhiều endpoint khác nhau
|
||||||
|
- **Cấu hình tập trung** : Quản lý tất cả nhà cung cấp ở một nơi
|
||||||
|
|
||||||
|
#### 📋 Tất cả Nhà cung cấp được Hỗ trợ
|
||||||
|
|
||||||
|
| Nhà cung cấp | Prefix `model` | API Base Mặc định | Giao thức | Khóa API |
|
||||||
|
|-------------|----------------|-------------------|-----------|----------|
|
||||||
|
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Lấy Khóa](https://platform.openai.com) |
|
||||||
|
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Lấy Khóa](https://console.anthropic.com) |
|
||||||
|
| **Zhipu AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Lấy Khóa](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||||
|
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Lấy Khóa](https://platform.deepseek.com) |
|
||||||
|
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Lấy Khóa](https://aistudio.google.com/api-keys) |
|
||||||
|
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Lấy Khóa](https://console.groq.com) |
|
||||||
|
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Lấy Khóa](https://platform.moonshot.cn) |
|
||||||
|
| **Qwen (Alibaba)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Lấy Khóa](https://dashscope.console.aliyun.com) |
|
||||||
|
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Lấy Khóa](https://build.nvidia.com) |
|
||||||
|
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (không cần khóa) |
|
||||||
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy Khóa](https://openrouter.ai/keys) |
|
||||||
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) |
|
||||||
|
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) |
|
||||||
|
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth |
|
||||||
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
||||||
|
#### Cấu hình Cơ bản
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_key": "sk-your-openai-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
|
"api_key": "sk-ant-your-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "glm-4.7",
|
||||||
|
"model": "zhipu/glm-4.7",
|
||||||
|
"api_key": "your-zhipu-key"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "gpt-5.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Ví dụ theo Nhà cung cấp
|
||||||
|
|
||||||
|
**OpenAI**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zhipu AI (GLM)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "glm-4.7",
|
||||||
|
"model": "zhipu/glm-4.7",
|
||||||
|
"api_key": "your-key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Anthropic (với OAuth)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
|
"auth_method": "oauth"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> Chạy `picoclaw auth login --provider anthropic` để thiết lập thông tin xác thực OAuth.
|
||||||
|
|
||||||
|
#### Cân bằng Tải tải
|
||||||
|
|
||||||
|
Định cấu hình nhiều endpoint cho cùng một tên mô hình—PicoClaw sẽ tự động phân phối round-robin giữa chúng:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_base": "https://api1.example.com/v1",
|
||||||
|
"api_key": "sk-key1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_base": "https://api2.example.com/v1",
|
||||||
|
"api_key": "sk-key2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Chuyển đổi từ Cấu hình `providers` Cũ
|
||||||
|
|
||||||
|
Cấu hình `providers` cũ đã **ngừng sử dụng** nhưng vẫn được hỗ trợ để tương thích ngược.
|
||||||
|
|
||||||
|
**Cấu hình Cũ (đã ngừng sử dụng):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"zhipu": {
|
||||||
|
"api_key": "your-key",
|
||||||
|
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "zhipu",
|
||||||
|
"model": "glm-4.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cấu hình Mới (khuyến nghị):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "glm-4.7",
|
||||||
|
"model": "zhipu/glm-4.7",
|
||||||
|
"api_key": "your-key"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "glm-4.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Xem hướng dẫn chuyển đổi chi tiết tại [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
|
||||||
|
|
||||||
## Tham chiếu CLI
|
## Tham chiếu CLI
|
||||||
|
|
||||||
| Lệnh | Mô tả |
|
| Lệnh | Mô tả |
|
||||||
|
|
|
||||||
488
README.zh.md
488
README.zh.md
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
|
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
|
||||||
|
|
||||||
<h3>10$硬件 · 10MB内存 · 1秒启动 · 皮皮虾,我们走!</h3>
|
<h3>10$遑ャ莉カ ツキ 10MB蜀<42>ュ<EFBFBD> ツキ 1遘貞星蜉?ツキ 逧ョ逧ョ陌セ<E9998C>梧<EFBFBD>莉ャ襍ー<E8A58D><EFBDB0></h3>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
|
|
@ -14,14 +14,14 @@
|
||||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md)
|
**荳ュ譁<EFBFBD>** | [譌・譛ャ隱枉(README.ja.md) | [Portuguテェs](README.pt-br.md) | [Ti蘯ソng Vi盻㏄](README.vi.md) | [Franテァais](README.fr.md) | [English](README.md)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
🦐 **PicoClaw** 是一个受 [nanobot](https://github.com/HKUDS/nanobot) 启发的超轻量级个人 AI 助手。它采用 **Go 语言** 从零重构,经历了一个“自举”过程——即由 AI Agent 自身驱动了整个架构迁移和代码优化。
|
<EFBFBD>ヲ<EFBFBD> **PicoClaw** 譏ッ荳荳ェ蜿<EFBDAA> [nanobot](https://github.com/HKUDS/nanobot) 蜷ッ蜿醍噪雜<E599AA>スサ驥冗コァ荳ェ莠?AI 蜉ゥ謇九ょョ<E38287>㊦逕ィ **Go 隸ュ險** 莉朱峺驥肴桷<E882B4>檎サ丞紙莠<E7B499>ク荳ェ窶懆<E7AAB6>荳セ窶晁ソ<E69981>ィ銀披泌叉逕?AI Agent 閾ェ霄ォ鬩ア蜉ィ莠<EFBDA8>紛荳ェ譫カ譫<EFBDB6>ソ∫ァサ蜥御サ」遐∽シ伜喧縲?
|
||||||
|
|
||||||
⚡️ **极致轻量**:可在 **10 美元** 的硬件上运行,内存占用 **<10MB**。这意味着比 OpenClaw 节省 99% 的内存,比 Mac mini 便宜 98%!
|
笞。<EFBFBD><EFBFBD> **譫∬<E8ADAB>霓サ驥<EFBDBB>**<EFBFBD>壼庄蝨?**10 鄒主<E98492>** 逧<>。ャ莉カ荳願ソ占。鯉シ悟<EFBDBC>蟄伜頃逕?**<10MB**縲りソ呎э蜻ウ逹豈?OpenClaw 闃ら怐 99% 逧<EFBFBD><EFBFBD>蟄假シ梧ッ?Mac mini 萓ソ螳<EFBFBD> 98%<25>?
|
||||||
|
|
||||||
<table align="center">
|
<table align="center">
|
||||||
<tr align="center">
|
<tr align="center">
|
||||||
|
|
@ -38,55 +38,55 @@
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
注意:人手有限,中文文档可能略有滞后,请优先查看英文文档。
|
豕ィ諢擾シ壻ココ謇区怏髯撰シ御クュ譁<EFBFBD>枚譯」蜿ッ閭ス逡・譛画サ槫錘<EFBFBD>瑚ッキ莨伜<EFBFBD>譟・逵玖恭譁<EFBFBD>枚譯」縲?
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
||||||
> * **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
|
> * **譌<EFBFBD>蜉<EFBFBD>蟇<EFBFBD>エァ蟶?(NO CRYPTO):** PicoClaw **豐。譛<EFBDA1>** 蜿題。御ササ菴募ョ俶婿莉」蟶√ゝoken 謌冶劒諡溯エァ蟶√よ園譛牙惠 `pump.fun` 謌門<E8AC8C>莉紋コ、譏灘ケウ蜿ー荳顔噪逶ク蜈ウ螢ー遘ー蝮<EFBDB0>ク?**隸磯ェ<E7A3AF>**縲?
|
||||||
> * **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
|
> * **螳俶婿蝓溷錐:** 蜚ッ荳逧<C280>ョ俶婿鄂醍ォ呎弍 **[picoclaw.io](https://picoclaw.io)**<2A>悟<EFBFBD>蜿ク螳倡ス第弍 **[sipeed.com](https://sipeed.com)**縲?
|
||||||
> * **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
|
> * **隴ヲ諠<EFBFBD>:** 隶ク螟<EFBDB8> `.ai/.org/.com/.net/...` 蜷守シ逧<C280>沺蜷崎「ォ隨ャ荳画婿謚「豕ィ<E8B195>瑚ッキ蜍ソ霓サ菫。縲?
|
||||||
> * **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中
|
> * **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中
|
||||||
> * **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化.
|
> * **豕ィ諢<EFBFBD>:** picoclaw譛霑大粋蟷カ莠<EFBDB6>、ァ驥襲Rs<52>瑚ソ第悄迚域悽蜿ッ閭ス蜀<EFBDBD>ュ伜頃逕ィ霎<EFBDA8>、?10~20MB)<29>梧<EFBFBD>莉ャ蟆<EFBDAC>惠蜉溯<E89C89>霎<EFBFBD>クコ謾カ謨帛錘霑幄。瑚オ<E7919A>コ仙頃逕ィ莨伜喧.
|
||||||
|
|
||||||
|
|
||||||
## 📢 新闻 (News)
|
## 📢 新闻 (News)
|
||||||
2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与!
|
2026-02-16 <EFBFBD>脂 PicoClaw 蝨ィ荳蜻ィ蜀<EFBDA8>ェ∫<EFBDAA>エ莠?2K star! 諢溯ー「螟ァ螳カ逧<EFBDB6><E980A7>豕ィ<E8B195> ̄icoClaw 逧<><E980A7>髟ソ騾溷コヲ雜<EFBDA6>ケ取<EFBDB9>莉ャ鬚<EFBDAC>悄. 逕ア莠傘R謨ー驥冗噪蠢ォ騾溯<E9A8BE>閭<E996AD>梧<EFBFBD>莉ャ莠滄怙遉セ蛹コ蠑蜿題<E9A18C>盾荳守サエ謚? 謌台サャ髴隕∫噪蠢玲<E8A0A2>閠<EFBFBD>ァ定牡蜥罫oadmap蟾イ扈丞書蟶<E69BB8>芦莠<E88AA6>霑咎㈹](docs/picoclaw_community_roadmap_260216.md), 譛溷セ<E6BAB7>ス<EFBFBD>逧<EFBFBD>盾荳趣シ?
|
||||||
|
|
||||||
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
|
2026-02-13 <EFBFBD>脂 **PicoClaw 蝨?4 螟ゥ蜀<EFBDA9>ェ∫<EFBDAA>エ 5000 Stars<72>?* 諢溯ー「遉セ蛹コ逧<EFBDBA>髪謖<E9ABAA>シ∫罰莠取ュ」蛟シ荳ュ蝗ス譏・闃ょ∞譛滂シ訓R 蜥?Issue 豸悟<E8B1B8>霎<EFBFBD>、夲シ梧<EFBDBC>莉ャ豁」蝨ィ蛻ゥ逕ィ霑呎ョオ譌カ髣エ謨イ螳?**鬘ケ逶ョ霍ッ郤ソ蝗?(Roadmap)** 蟷カ扈<EFBDB6>サ?**蠑蜿題<E9A18C>セ、扈?*<2A>御サ・萓ソ蜉<EFBDBF>騾?PicoClaw 逧<>シ蜿代?
|
||||||
🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
|
<EFBFBD>噫 **陦悟勘蜿キ蜿ャ<E89CBF>?* 隸キ蝨ィ GitHub Discussions 荳ュ謠蝉コ、謔ィ逧<EFBDA8>粥閭ス隸キ豎?(Feature Requests)縲よ<E7B8B2>莉ャ蟆<EFBDAC>惠謗・荳区擂逧<E69382>捉莨壻ク願ソ幄。悟ョ。譟・蜥御シ伜<EFBDBC>郤ァ謗貞コ上?
|
||||||
|
|
||||||
2026-02-09 🎉 **PicoClaw 正式发布!** 仅用 1 天构建,旨在将 AI Agent 带入 10 美元硬件与 <10MB 内存的世界。🦐 PicoClaw(皮皮虾),我们走!
|
2026-02-09 <EFBFBD>脂 **PicoClaw 豁」蠑丞書蟶<E69BB8>シ?* 莉<>畑 1 螟ゥ譫<EFBDA9>サコ<EFBDBB>梧葎蝨ィ蟆?AI Agent 蟶ヲ蜈・ 10 鄒主<E98492>遑ャ莉カ荳?<10MB 蜀<EFBFBD>ュ倡噪荳也阜縲を洶?PicoClaw<EFBFBD>育坩逧ョ陌セ<EFBFBD>会シ梧<EFBFBD>莉ャ襍ー<EFBFBD><EFBFBD>
|
||||||
|
|
||||||
## ✨ 特性
|
## 笨?迚ケ諤?
|
||||||
|
|
||||||
🪶 **超轻量级**: 核心功能内存占用 <10MB — 比 Clawdbot 小 99%。
|
<EFBFBD>ェカ **雜<>スサ驥冗コァ**: 譬ク蠢<EFBDB8>粥閭ス蜀<EFBDBD>ュ伜頃逕ィ <10MB 窶?豈?Clawdbot 蟆?99%縲?
|
||||||
|
|
||||||
💰 **极低成本**: 高效到足以在 10 美元的硬件上运行 — 比 Mac mini 便宜 98%。
|
<EFBFBD>腸 **譫∽ス取<EFBDBD>譛ャ**: 鬮俶譜蛻ー雜ウ莉・蝨ィ 10 鄒主<E98492>逧<EFBFBD>。ャ莉カ荳願ソ占。<E58DA0> 窶?豈?Mac mini 萓ソ螳<EFBDBF> 98%縲?
|
||||||
|
|
||||||
⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。
|
笞。<EFBFBD><EFBFBD> **髣ェ逕オ蜷ッ蜉ィ**: 蜷ッ蜉ィ騾溷コヲ蠢?400 蛟搾シ悟叉菴ソ蝨?0.6GHz 蜊墓<E89C8A>ク螟<EFBDB8>炊蝎ィ荳贋ケ溯<EFBDB9>蝨?1 遘貞<E98198>蜷ッ蜉ィ縲?
|
||||||
|
|
||||||
🌍 **真正可移植**: 跨 RISC-V、ARM 和 x86 架构的单二进制文件,一键运行!
|
<EFBFBD>訣 **逵滓ュ」蜿ッ遘サ讀?*: 霍?RISC-V縲、RM 蜥?x86 譫カ譫<EFBDB6>噪蜊穂コ瑚ソ帛宛譁<E5AE9B>サカ<EFBDBB>御ク髞ョ霑占。鯉シ<E9AF89>
|
||||||
|
|
||||||
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
|
<EFBFBD>、<EFBFBD> **AI 閾ェ荳セ**: 郤?Go 隸ュ險蜴溽函螳樒鴫 窶?95% 逧<><E980A7>ク蠢<EFBDB8>サ」遐∫罰 Agent 逕滓<E98095><E6BB93>悟ケカ扈冗罰窶應ココ譛コ蝗樒<E89D97>?(Human-in-the-loop)窶晏セョ隹<EFBDAE>?
|
||||||
|
|
||||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| **语言** | TypeScript | Python | **Go** |
|
| **语言** | TypeScript | Python | **Go** |
|
||||||
| **RAM** | >1GB | >100MB | **< 10MB** |
|
| **RAM** | >1GB | >100MB | **< 10MB** |
|
||||||
| **启动时间**</br>(0.8GHz core) | >500s | >30s | **<1s** |
|
| **启动时间**</br>(0.8GHz core) | >500s | >30s | **<1s** |
|
||||||
| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**</br>**低至 $10** |
|
| **謌先悽** | Mac Mini $599 | 螟ァ螟壽<E89E9F>?Linux 蠑蜿第攸 ~$50 | **莉サ諢<EFBDBB> Linux 蠑蜿第攸**</br>**菴手<E88FB4> $10** |
|
||||||
|
|
||||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
## 🦾 演示
|
## 🦾 演示
|
||||||
|
|
||||||
### 🛠️ 标准助手工作流
|
### <EFBFBD>屏<EFBFBD>?譬<>㊥蜉ゥ謇句キ・菴懈オ?
|
||||||
|
|
||||||
<table align="center">
|
<table align="center">
|
||||||
<tr align="center">
|
<tr align="center">
|
||||||
<th><p align="center">🧩 全栈工程师模式</p></th>
|
<th><p align="center"><EFBFBD>ァゥ 蜈ィ譬亥キ・遞句ク域ィ。蠑?/p></th>
|
||||||
<th><p align="center">🗂️ 日志与规划管理</p></th>
|
<th><p align="center"><EFBFBD>翌<EFBFBD>?譌・蠢嶺ク手ァ<E6898B><EFBDA7>邂。逅?/p></th>
|
||||||
<th><p align="center">🔎 网络搜索与学习</p></th>
|
<th><p align="center"><EFBFBD>博 鄂醍サ懈頗邏「荳主ュヲ荵?/p></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||||
|
|
@ -94,24 +94,24 @@
|
||||||
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">开发 • 部署 • 扩展</td>
|
<td align="center">蠑蜿?窶?驛ィ鄂イ 窶?謇ゥ螻<EFBDA9></td>
|
||||||
<td align="center">日程 • 自动化 • 记忆</td>
|
<td align="center">譌・遞<EFBFBD> 窶?閾ェ蜉ィ蛹?窶?隶ー蠢<EFBDB0></td>
|
||||||
<td align="center">发现 • 洞察 • 趋势</td>
|
<td align="center">蜿醍鴫 窶?豢槫ッ<E6A7AB> 窶?雜句漢</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
### 📱 在手机上轻松运行
|
### 📱 在手机上轻松运行
|
||||||
picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南:
|
picoclaw 蜿ッ莉・蟆<EFBFBD>ス<EFBFBD>10蟷エ蜑咲噪閠∵立謇区惻蠎溽黄蛻ゥ逕ィ<EFBFBD>悟序霄ォ謌蝉クコ菴<EFBFBD>逧БI蜉ゥ逅<EFBFBD>シ∝ソォ騾滓欠蜊?
|
||||||
1. 先去应用商店下载安装Termux
|
1. 先去应用商店下载安装Termux
|
||||||
2. 打开后执行指令
|
2. 謇灘シ蜷取鴬陦梧欠莉?
|
||||||
```bash
|
```bash
|
||||||
# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本
|
# 豕ィ諢<EFBFBD>: 荳矩擇逧ё0.1.1 蜿ッ莉・謐「荳コ菴<EFBDBA>螳樣刔逵句芦逧<E88AA6>怙譁ー迚域<E8BF9A>?
|
||||||
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
|
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
|
||||||
chmod +x picoclaw-linux-arm64
|
chmod +x picoclaw-linux-arm64
|
||||||
pkg install proot
|
pkg install proot
|
||||||
termux-chroot ./picoclaw-linux-arm64 onboard
|
termux-chroot ./picoclaw-linux-arm64 onboard
|
||||||
```
|
```
|
||||||
然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
|
辟カ蜷手キ滄囂荳矩擇逧<EFBFBD>懷ソォ騾溷シ蟋銀晉ォ<EFBFBD>闃らサァ扈ュ驟咲スョpicoclaw蜊ウ蜿ッ菴ソ逕ィ<EFBFBD>?
|
||||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -119,21 +119,21 @@ termux-chroot ./picoclaw-linux-arm64 onboard
|
||||||
|
|
||||||
### 🐜 创新的低占用部署
|
### 🐜 创新的低占用部署
|
||||||
|
|
||||||
PicoClaw 几乎可以部署在任何 Linux 设备上!
|
PicoClaw 蜃<EFBFBD>荵主庄莉・驛ィ鄂イ蝨ィ莉サ菴?Linux 隶セ螟<EFBDBE>ク奇シ<E5A587>
|
||||||
|
|
||||||
* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。
|
* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(鄂大哨) 謌?W(WiFi6) 迚域悽<E59F9F>檎畑莠取栫邂螳カ蠎ュ蜉ゥ謇九?
|
||||||
* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。
|
* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html)<EFBFBD>梧<EFBFBD> $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html)<29>檎畑莠手<E88EA0>蜉ィ蛹匁恪蜉。蝎ィ霑千サエ縲?
|
||||||
* $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),用于智能监控。
|
* $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)<EFBFBD>檎畑莠取匱閭ス逶第而縲?
|
||||||
|
|
||||||
[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)
|
[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)
|
||||||
|
|
||||||
🌟 更多部署案例敬请期待!
|
<EFBFBD>検 譖エ螟夐Κ鄂イ譯井セ区噴隸キ譛溷セ<E6BAB7>シ?
|
||||||
|
|
||||||
## 📦 安装
|
## 📦 安装
|
||||||
|
|
||||||
### 使用预编译二进制文件安装
|
### 使用预编译二进制文件安装
|
||||||
|
|
||||||
从 [Release 页面](https://github.com/sipeed/picoclaw/releases) 下载适用于您平台的固件。
|
莉?[Release 鬘オ髱「](https://github.com/sipeed/picoclaw/releases) 荳玖スス騾ら畑莠取お蟷ウ蜿ー逧<EFBDB0>崋莉カ縲?
|
||||||
|
|
||||||
### 从源码安装(获取最新特性,开发推荐)
|
### 从源码安装(获取最新特性,开发推荐)
|
||||||
|
|
||||||
|
|
@ -143,20 +143,20 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# 构建(无需安装)
|
# 譫<EFBFBD>サコ<EFBFBD>域裏髴螳芽」<EFBFBD>シ?
|
||||||
make build
|
make build
|
||||||
|
|
||||||
# 为多平台构建
|
# 为多平台构建
|
||||||
make build-all
|
make build-all
|
||||||
|
|
||||||
# 构建并安装
|
# 譫<EFBFBD>サコ蟷カ螳芽」?
|
||||||
make install
|
make install
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🐳 Docker Compose
|
## 🐳 Docker Compose
|
||||||
|
|
||||||
您也可以使用 Docker Compose 运行 PicoClaw,无需在本地安装任何环境。
|
謔ィ荵溷庄莉・菴ソ逕ィ Docker Compose 霑占。<E58DA0> PicoClaw<61>梧裏髴蝨ィ譛ャ蝨ー螳芽」<E88ABD>ササ菴慕識蠅<E8AD98>?
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 克隆仓库
|
# 1. 克隆仓库
|
||||||
|
|
@ -165,9 +165,9 @@ cd picoclaw
|
||||||
|
|
||||||
# 2. 设置 API Key
|
# 2. 设置 API Key
|
||||||
cp config/config.example.json config/config.json
|
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. 譫<EFBFBD>サコ蟷カ蜷ッ蜉?
|
||||||
docker compose --profile gateway up -d
|
docker compose --profile gateway up -d
|
||||||
|
|
||||||
# 4. 查看日志
|
# 4. 查看日志
|
||||||
|
|
@ -178,7 +178,7 @@ docker compose --profile gateway down
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Agent 模式 (一次性运行)
|
### Agent 讓。蠑<EFBFBD> (荳谺。諤ァ霑占。?
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 提问
|
# 提问
|
||||||
|
|
@ -197,26 +197,26 @@ docker compose --profile gateway up -d
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 快速开始
|
### <EFBFBD>噫 蠢ォ騾溷シ蟋?
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 在 `~/.picoclaw/config.json`、`~/.picoclaw/config.yaml` 或 `~/.picoclaw/config.toml` 中设置您的 API Key。
|
> 蝨?`~/.picoclaw/config.json`縲~~/.picoclaw/config.yaml` 謌?`~/.picoclaw/config.toml` 荳ュ隶セ鄂ョ謔ィ逧?API Key縲?
|
||||||
> PicoClaw 支持多种配置格式:**JSON**(默认)、**YAML**、**TOML**,通过 [spf13/viper](https://github.com/spf13/viper) 驱动。
|
> PicoClaw 謾ッ謖∝、夂ァ埼<EFBFBD>鄂ョ譬シ蠑擾シ?*JSON**<2A>磯サ倩ョ、<EFBDAE>峨?*YAML**縲?*TOML**<2A>碁夊ソ<E5A48A> [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)
|
> 获取 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 次免费查询)
|
> 鄂醍サ懈頗邏「譏?**蜿ッ騾臥噪** - 闔キ蜿門<E89CBF>雍ケ逧?[Brave Search API](https://brave.com/search/api) (豈乗怦 2000 谺。蜈崎エケ譟・隸?
|
||||||
|
|
||||||
**1. 初始化 (Initialize)**
|
**1. 蛻晏ァ句<EFBFBD>?(Initialize)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
picoclaw onboard
|
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) 支持多种配置格式,选择其中任意一种即可:
|
通过 [spf13/viper](https://github.com/spf13/viper) 支持多种配置格式,选择其中任意一种即可:
|
||||||
- `config.json` - JSON 格式(默认)
|
- `config.json` - JSON 格式(默认)
|
||||||
- `config.yaml` 或 `config.yml` - YAML 格式
|
- `config.yaml` 謌?`config.yml` - YAML 譬シ蠑<EFBDBC>
|
||||||
- `config.toml` - TOML 格式
|
- `config.toml` - TOML 格式
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -224,18 +224,24 @@ picoclaw onboard
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.picoclaw/workspace",
|
"workspace": "~/.picoclaw/workspace",
|
||||||
"model": "glm-4.7",
|
"model": "gpt4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20
|
"max_tool_iterations": 20
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"model_list": [
|
||||||
"openrouter": {
|
{
|
||||||
"api_key": "xxx",
|
"model_name": "gpt4",
|
||||||
"api_base": "https://openrouter.ai/api/v1"
|
"model": "openai/gpt-5.2",
|
||||||
}
|
"api_key": "your-api-key"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
|
"api_key": "your-anthropic-key"
|
||||||
|
}
|
||||||
|
],
|
||||||
"tools": {
|
"tools": {
|
||||||
"web": {
|
"web": {
|
||||||
"search": {
|
"search": {
|
||||||
|
|
@ -251,12 +257,14 @@ picoclaw onboard
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **譁ー蜉溯<E89C89>?*: `model_list` 驟咲スョ譬シ蠑乗髪謖<E9ABAA>峺莉」遐∵キサ蜉?provider縲りッヲ隗ー讓。蝙矩<E89D99>鄂ョ](#-讓。蝙矩<E89D99>鄂ョ-model_list)遶<>闃ゅ?
|
||||||
|
|
||||||
**3. 获取 API Key**
|
**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)
|
* **LLM 謠蝉セ帛<EFBDBE>?*: [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 请求/月)
|
* **鄂醍サ懈頗邏「** (蜿ッ騾?: [Brave Search](https://brave.com/search/api) - 謠蝉セ帛<EFBDBE>雍ケ螻らコァ (2000 隸キ豎<EFBDB7>/譛?
|
||||||
|
|
||||||
> **注意**: 完整的配置模板请参考 `config.example.json`。
|
> **豕ィ諢<EFBFBD>**: 螳梧紛逧<E7B49B><E980A7>鄂ョ讓。譚ソ隸キ蜿り?`config.example.json`縲?
|
||||||
|
|
||||||
**4. 对话 (Chat)**
|
**4. 对话 (Chat)**
|
||||||
|
|
||||||
|
|
@ -265,28 +273,28 @@ picoclaw agent -m "2+2 等于几?"
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
就是这样!您在 2 分钟内就拥有了一个可工作的 AI 助手。
|
蟆ア譏ッ霑呎<EFBFBD>キ<EFBFBD>∵お蝨?2 蛻<>帖蜀<E5B896>ーア諡・譛我コ<E68891>ク荳ェ蜿ッ蟾・菴懃<E88FB4>?AI 蜉ゥ謇九?
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 💬 聊天应用集成 (Chat Apps)
|
## 💬 聊天应用集成 (Chat Apps)
|
||||||
|
|
||||||
通过 Telegram, Discord 或钉钉与您的 PicoClaw 对话。
|
騾夊ソ<EFBFBD> Telegram, Discord 謌夜忠髓我ク取お逧<E3818A> PicoClaw 蟇ケ隸昴?
|
||||||
|
|
||||||
| 渠道 | 设置难度 |
|
| 渠道 | 设置难度 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Telegram** | 简单 (仅需 token) |
|
| **Telegram** | 邂蜊?(莉<>怙 token) |
|
||||||
| **Discord** | 简单 (bot token + intents) |
|
| **Discord** | 邂蜊?(bot token + intents) |
|
||||||
| **QQ** | 简单 (AppID + AppSecret) |
|
| **QQ** | 邂蜊?(AppID + AppSecret) |
|
||||||
| **钉钉 (DingTalk)** | 中等 (app credentials) |
|
| **钉钉 (DingTalk)** | 中等 (app credentials) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (推荐)</summary>
|
<summary><b>Telegram</b> (推荐)</summary>
|
||||||
|
|
||||||
**1. 创建机器人**
|
**1. 蛻帛サコ譛コ蝎ィ莠?*
|
||||||
|
|
||||||
* 打开 Telegram,搜索 `@BotFather`
|
* 謇灘シ Telegram<61>梧頗邏?`@BotFather`
|
||||||
* 发送 `/newbot`,按照提示操作
|
* 蜿鷹?`/newbot`<EFBFBD>梧潔辣ァ謠千、コ謫堺ス?
|
||||||
* 复制 token
|
* 复制 token
|
||||||
|
|
||||||
**2. 配置**
|
**2. 配置**
|
||||||
|
|
@ -304,7 +312,7 @@ picoclaw agent -m "2+2 等于几?"
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> 从 Telegram 上的 `@userinfobot` 获取您的用户 ID。
|
> 莉?Telegram 荳顔噪 `@userinfobot` 闔キ蜿匁お逧<E3818A>畑謌キ ID縲?
|
||||||
|
|
||||||
**3. 运行**
|
**3. 运行**
|
||||||
|
|
||||||
|
|
@ -318,21 +326,21 @@ picoclaw gateway
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Discord</b></summary>
|
<summary><b>Discord</b></summary>
|
||||||
|
|
||||||
**1. 创建机器人**
|
**1. 蛻帛サコ譛コ蝎ィ莠?*
|
||||||
|
|
||||||
* 前往 [https://discord.com/developers/applications](https://discord.com/developers/applications)
|
* 前往 [https://discord.com/developers/applications](https://discord.com/developers/applications)
|
||||||
* Create an application → Bot → Add Bot
|
* Create an application 竊?Bot 竊?Add Bot
|
||||||
* 复制 bot token
|
* 复制 bot token
|
||||||
|
|
||||||
**2. 开启 Intents**
|
**2. 蠑蜷?Intents**
|
||||||
|
|
||||||
* 在 Bot 设置中,开启 **MESSAGE CONTENT INTENT**
|
* 蝨?Bot 隶セ鄂ョ荳ュ<E88DB3>悟シ蜷?**MESSAGE CONTENT INTENT**
|
||||||
* (可选) 如果计划基于成员数据使用白名单,开启 **SERVER MEMBERS INTENT**
|
* (蜿ッ騾? 螯よ棡隶。蛻貞渕莠取<E88EA0>蜻俶焚謐ョ菴ソ逕ィ逋ス蜷榊黒<E6A68A>悟シ蜷?**SERVER MEMBERS INTENT**
|
||||||
|
|
||||||
**3. 获取您的 User ID**
|
**3. 获取您的 User ID**
|
||||||
|
|
||||||
* Discord 设置 → Advanced → 开启 **Developer Mode**
|
* Discord 隶セ鄂ョ 竊?Advanced 竊?蠑蜷?**Developer Mode**
|
||||||
* 右键点击您的头像 → **Copy User ID**
|
* 蜿ウ髞ョ轤ケ蜃サ謔ィ逧<EFBFBD>、エ蜒<EFBFBD> 竊?**Copy User ID**
|
||||||
|
|
||||||
**4. 配置**
|
**4. 配置**
|
||||||
|
|
||||||
|
|
@ -351,10 +359,10 @@ picoclaw gateway
|
||||||
|
|
||||||
**5. 邀请机器人**
|
**5. 邀请机器人**
|
||||||
|
|
||||||
* OAuth2 → URL Generator
|
* OAuth2 竊?URL Generator
|
||||||
* Scopes: `bot`
|
* Scopes: `bot`
|
||||||
* Bot Permissions: `Send Messages`, `Read Message History`
|
* Bot Permissions: `Send Messages`, `Read Message History`
|
||||||
* 打开生成的邀请 URL,将机器人添加到您的服务器
|
* 謇灘シ逕滓<EFBFBD>逧<EFBFBD><EFBFBD>隸?URL<52>悟ー<E6829F>惻蝎ィ莠コ豺サ蜉<EFBDBB>蛻ー謔ィ逧<EFBDA8>恪蜉。蝎?
|
||||||
|
|
||||||
**6. 运行**
|
**6. 运行**
|
||||||
|
|
||||||
|
|
@ -368,10 +376,10 @@ picoclaw gateway
|
||||||
<details>
|
<details>
|
||||||
<summary><b>QQ</b></summary>
|
<summary><b>QQ</b></summary>
|
||||||
|
|
||||||
**1. 创建机器人**
|
**1. 蛻帛サコ譛コ蝎ィ莠?*
|
||||||
|
|
||||||
* 前往 [QQ 开放平台](https://q.qq.com/#)
|
* 前往 [QQ 开放平台](https://q.qq.com/#)
|
||||||
* 创建应用 → 获取 **AppID** 和 **AppSecret**
|
* 蛻帛サコ蠎皮畑 竊?闔キ蜿<EFBDB7> **AppID** 蜥?**AppSecret**
|
||||||
|
|
||||||
**2. 配置**
|
**2. 配置**
|
||||||
|
|
||||||
|
|
@ -389,7 +397,7 @@ picoclaw gateway
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> 将 `allow_from` 设为空以允许所有用户,或指定 QQ 号以限制访问。
|
> 蟆?`allow_from` 隶セ荳コ遨コ莉・蜈∬ョク謇譛臥畑謌キ<E8AC8C>梧<EFBFBD>謖<EFBFBD>ョ?QQ 蜿キ莉・髯仙宛隶ソ髣ョ縲?
|
||||||
|
|
||||||
**3. 运行**
|
**3. 运行**
|
||||||
|
|
||||||
|
|
@ -403,11 +411,11 @@ picoclaw gateway
|
||||||
<details>
|
<details>
|
||||||
<summary><b>钉钉 (DingTalk)</b></summary>
|
<summary><b>钉钉 (DingTalk)</b></summary>
|
||||||
|
|
||||||
**1. 创建机器人**
|
**1. 蛻帛サコ譛コ蝎ィ莠?*
|
||||||
|
|
||||||
* 前往 [开放平台](https://open.dingtalk.com/)
|
* 前往 [开放平台](https://open.dingtalk.com/)
|
||||||
* 创建内部应用
|
* 创建内部应用
|
||||||
* 复制 Client ID 和 Client Secret
|
* 螟榊宛 Client ID 蜥?Client Secret
|
||||||
|
|
||||||
**2. 配置**
|
**2. 配置**
|
||||||
|
|
||||||
|
|
@ -425,7 +433,7 @@ picoclaw gateway
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> 将 `allow_from` 设为空以允许所有用户,或指定 ID 以限制访问。
|
> 蟆?`allow_from` 隶セ荳コ遨コ莉・蜈∬ョク謇譛臥畑謌キ<E8AC8C>梧<EFBFBD>謖<EFBFBD>ョ?ID 莉・髯仙宛隶ソ髣ョ縲?
|
||||||
|
|
||||||
**3. 运行**
|
**3. 运行**
|
||||||
|
|
||||||
|
|
@ -438,9 +446,9 @@ picoclaw gateway
|
||||||
|
|
||||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
||||||
|
|
||||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
蜿ェ髴騾夊ソ<EFBFBD> CLI 謌紋ササ菴暮寔謌千噪閨雁、ゥ蠎皮畑蜿鷹∽ク譚。豸域<E8B1B8><E59F9F>悟叉蜿ッ蟆?PicoClaw 霑樊磁蛻?Agent 遉セ莠、鄂醍サ懊?
|
||||||
|
|
||||||
**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai**](https://clawdchat.ai)
|
**髦<EFBFBD>ッサ `https://clawdchat.ai/skill.md` 蟷カ謖臥<E8AC96>隸エ譏主刈蜈?[ClawdChat.ai**](https://clawdchat.ai)
|
||||||
|
|
||||||
## ⚙️ 配置详解
|
## ⚙️ 配置详解
|
||||||
|
|
||||||
|
|
@ -452,13 +460,13 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.picoclaw/workspace/
|
~/.picoclaw/workspace/
|
||||||
├── sessions/ # 对话会话和历史
|
笏懌楳笏 sessions/ # 蟇ケ隸昜シ夊ッ晏柱蜴<E69FB1><E89CB4>?
|
||||||
├── memory/ # 长期记忆 (MEMORY.md)
|
├── memory/ # 长期记忆 (MEMORY.md)
|
||||||
├── state/ # 持久化状态 (最后一次频道等)
|
笏懌楳笏 state/ # 謖∽ケ<E288BD>喧迥カ諤?(譛蜷惹ク谺。鬚鷹%遲<EFBC85>)
|
||||||
├── cron/ # 定时任务数据库
|
笏懌楳笏 cron/ # 螳壽慮莉サ蜉。謨ー謐ョ蠎?
|
||||||
├── skills/ # 自定义技能
|
笏懌楳笏 skills/ # 閾ェ螳壻ケ画橿閭?
|
||||||
├── AGENTS.md # Agent 行为指南
|
├── AGENTS.md # Agent 行为指南
|
||||||
├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次)
|
笏懌楳笏 HEARTBEAT.md # 蜻ィ譛滓ァ莉サ蜉。謠千、コ隸<EFBDBA> (豈?30 蛻<>帖譽譟・荳谺?
|
||||||
├── IDENTITY.md # Agent 身份设定
|
├── IDENTITY.md # Agent 身份设定
|
||||||
├── SOUL.md # Agent 灵魂/性格
|
├── SOUL.md # Agent 灵魂/性格
|
||||||
├── TOOLS.md # 工具描述
|
├── TOOLS.md # 工具描述
|
||||||
|
|
@ -466,9 +474,9 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 心跳 / 周期性任务 (Heartbeat)
|
### 蠢<EFBFBD>キウ / 蜻ィ譛滓ァ莉サ蜉?(Heartbeat)
|
||||||
|
|
||||||
PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件:
|
PicoClaw 蜿ッ莉・閾ェ蜉ィ謇ァ陦悟捉譛滓ァ莉サ蜉。縲ょ惠蟾・菴懷玄蛻帛サ?`HEARTBEAT.md` 譁<>サカ<EFBDBB>?
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# Periodic Tasks
|
# Periodic Tasks
|
||||||
|
|
@ -479,11 +487,11 @@ PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md`
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。
|
Agent 蟆<EFBFBD>ッ城<EFBFBD>?30 蛻<>帖<EFBFBD>亥庄驟咲スョ<EFBDBD>芽ッサ蜿匁ュ、譁<EFBDA4>サカ<EFBDBB>悟ケカ菴ソ逕ィ蜿ッ逕ィ蟾・蜈キ謇ァ陦御ササ蜉。縲?
|
||||||
|
|
||||||
#### 使用 Spawn 的异步任务
|
#### 菴ソ逕ィ Spawn 逧<>シよュ・莉サ蜉?
|
||||||
|
|
||||||
对于耗时较长的任务(网络搜索、API 调用),使用 `spawn` 工具创建一个 **子 Agent (subagent)**:
|
蟇ケ莠手玲慮霎<EFBFBD>柄逧<EFBFBD>ササ蜉。<EFBFBD>育ス醍サ懈頗邏「縲、PI 隹<>畑<EFBFBD>会シ御スソ逕ィ `spawn` 蟾・蜈キ蛻帛サコ荳荳?**蟄?Agent (subagent)**<2A>?
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# Periodic Tasks
|
# Periodic Tasks
|
||||||
|
|
@ -497,35 +505,35 @@ Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**关键行为:**
|
**蜈ウ髞ョ陦御クコ<EFBFBD>?*
|
||||||
|
|
||||||
| 特性 | 描述 |
|
| 迚ケ諤?| 謠剰ソー |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 |
|
| **spawn** | 蛻帛サコ蠑よュ・蟄?Agent<6E>御ク埼仆蝪樔クサ蠢<EFBDBB>キウ霑帷ィ?|
|
||||||
| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 |
|
| **迢ャ遶倶ク贋ク区<EFBDB8>?* | 蟄?Agent 諡・譛臥峡遶倶ク贋ク区枚<E58CBA>梧裏莨夊ッ晏紙蜿?|
|
||||||
| **message tool** | 子 Agent 通过 message 工具直接与用户通信 |
|
| **message tool** | 蟄?Agent 騾夊ソ<E5A48A> message 蟾・蜈キ逶エ謗・荳守畑謌キ騾壻ソ。 |
|
||||||
| **非阻塞** | spawn 后,心跳继续处理下一个任务 |
|
| **髱樣仆蝪?* | spawn 蜷趣シ悟ソ<E6829F>キウ扈ァ扈ュ螟<EFBDAD>炊荳倶ク荳ェ莉サ蜉?|
|
||||||
|
|
||||||
#### 子 Agent 通信原理
|
#### 蟄?Agent 騾壻ソ。蜴溽炊
|
||||||
|
|
||||||
```
|
```
|
||||||
心跳触发 (Heartbeat triggers)
|
心跳触发 (Heartbeat triggers)
|
||||||
↓
|
竊?
|
||||||
Agent 读取 HEARTBEAT.md
|
Agent 读取 HEARTBEAT.md
|
||||||
↓
|
竊?
|
||||||
对于长任务: spawn 子 Agent
|
蟇ケ莠朱柄莉サ蜉? spawn 蟄?Agent
|
||||||
↓ ↓
|
竊? 竊?
|
||||||
继续下一个任务 子 Agent 独立工作
|
扈ァ扈ュ荳倶ク荳ェ莉サ蜉? 蟄?Agent 迢ャ遶句キ・菴<EFBDA5>
|
||||||
↓ ↓
|
竊? 竊?
|
||||||
所有任务完成 子 Agent 使用 "message" 工具
|
謇譛我ササ蜉。螳梧<EFBFBD>? 蟄?Agent 菴ソ逕ィ "message" 蟾・蜈キ
|
||||||
↓ ↓
|
竊? 竊?
|
||||||
响应 HEARTBEAT_OK 用户直接收到结果
|
响应 HEARTBEAT_OK 用户直接收到结果
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
子 Agent 可以访问工具(message, web_search 等),并且无需通过主 Agent 即可独立与用户通信。
|
蟄?Agent 蜿ッ莉・隶ソ髣ョ蟾・蜈キ<E89C88><EFBDB7>essage, web_search 遲会シ会シ悟ケカ荳疲裏髴騾夊ソ<E5A48A>ク?Agent 蜊ウ蜿ッ迢ャ遶倶ク守畑謌キ騾壻ソ。縲?
|
||||||
|
|
||||||
**配置:**
|
**驟咲スョ<EFBFBD>?*
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -537,35 +545,221 @@ Agent 读取 HEARTBEAT.md
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| 选项 | 默认值 | 描述 |
|
| 騾蛾。ケ | 鮟倩ョ、蛟?| 謠剰ソー |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `enabled` | `true` | 启用/禁用心跳 |
|
| `enabled` | `true` | 启用/禁用心跳 |
|
||||||
| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) |
|
| `interval` | `30` | 譽譟・髣エ髫費シ悟黒菴榊<EFBFBD>髓<EFBFBD> (譛蟆? 5) |
|
||||||
|
|
||||||
**环境变量:**
|
**环境变量:**
|
||||||
|
|
||||||
* `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
|
* `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
|
||||||
* `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
|
* `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
|
||||||
|
|
||||||
### 提供商 (Providers)
|
### 謠蝉セ帛<EFBFBD>?(Providers)
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。
|
> Groq 騾夊ソ<EFBFBD> Whisper 謠蝉セ帛<EFBDBE>雍ケ逧<EFBDB9>ッュ髻ウ霓ャ蠖輔ょヲよ棡驟咲スョ莠<EFBDAE> Groq<6F>卦elegram 隸ュ髻ウ豸域<E8B1B8>蟆<EFBFBD>「ォ閾ェ蜉ィ霓ャ蠖穂クコ譁<EFBDBA>ュ励?
|
||||||
|
|
||||||
| 提供商 | 用途 | 获取 API Key |
|
| 謠蝉セ帛<EFBFBD>?| 逕ィ騾?| 闔キ蜿<EFBDB7> API Key |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
|
| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
|
| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
|
||||||
| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
|
| `openrouter(蠕<>オ玖ッ?` | LLM (謗ィ闕撰シ悟庄隶ソ髣ョ謇譛画ィ。蝙? | [openrouter.ai](https://openrouter.ai) |
|
||||||
| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
|
| `anthropic(蠕<>オ玖ッ?` | LLM (Claude 逶エ霑<EFBDB4>) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
|
| `openai(蠕<>オ玖ッ?` | LLM (GPT 逶エ霑<EFBDB4>) | [platform.openai.com](https://platform.openai.com) |
|
||||||
| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
|
| `deepseek(蠕<>オ玖ッ?` | LLM (DeepSeek 逶エ霑<EFBDB4>) | [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) |
|
| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||||
|
| `cerebras` | LLM (Cerebras 逶エ霑<EFBDB4>) | [cerebras.ai](https://cerebras.ai) |
|
||||||
|
|
||||||
|
### 讓。蝙矩<E89D99>鄂ョ (model_list)
|
||||||
|
|
||||||
|
> **譁ー蜉溯<E89C89><E6BAAF><EFBFBD>** PicoClaw 邇ー蝨ィ驥<EFBDA8>畑**莉・讓。蝙倶クコ荳ュ蠢<EFBDAD>**逧<><E980A7>鄂ョ譁ケ蠑上ょ宵髴菴ソ逕ィ `蜴ょ膚/讓。蝙義 譬シ蠑擾シ亥ヲ<E4BAA5> `zhipu/glm-4.7`<60>牙叉蜿ッ豺サ蜉<EFBDBB>譁ー逧?provider窶披?*譌<>髴菫ョ謾ケ莉サ菴穂サ」遐<EFBDA3>シ?*
|
||||||
|
|
||||||
|
隸・隶セ隶。蜷梧慮謾ッ謖?*螟?Agent 蝨コ譎ッ**<2A>梧署萓帷<E89093>豢サ逧<EFBDBB> Provider 騾画叫<E794BB>?
|
||||||
|
|
||||||
|
- **荳榊酔 Agent 菴ソ逕ィ荳榊酔 Provider**<EFBFBD>壽ッ丈ク?Agent 蜿ッ莉・菴ソ逕ィ閾ェ蟾ア逧?LLM provider
|
||||||
|
- **讓。蝙句屓騾<E9A8BE><C280>allback<63>?*<2A>夐<EFBFBD>鄂ョ荳サ讓。蝙句柱螟<E69FB1>畑讓。蝙具シ梧署鬮伜庄髱<E5BA84>諤?
|
||||||
|
- **雍溯スス蝮<EFBDBD>。。**<EFBFBD>壼惠螟壻クェ API 遶ッ轤ケ荵矩龍蛻<E9BE8D><E89BBB>隸キ豎<EFBDB7>
|
||||||
|
- **髮<>クュ蛹夜<E89BB9>鄂?*<2A>壼惠荳荳ェ蝨ー譁ケ邂。逅<EFBDA1>園譛?provider
|
||||||
|
|
||||||
|
#### <20>搭 謇譛画髪謖∫噪蜴ょ膚
|
||||||
|
|
||||||
|
| 蜴ょ膚 | `model` 蜑咲シ | 鮟倩ョ、 API Base | 蜊剰ョョ | 闔キ蜿<EFBDB7> API Key |
|
||||||
|
|------|-------------|---------------|------|--------------|
|
||||||
|
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [闔キ蜿門ッ<EFBFBD>徴](https://platform.openai.com) |
|
||||||
|
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [闔キ蜿門ッ<EFBFBD>徴](https://console.anthropic.com) |
|
||||||
|
| **譎コ隹ア AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [闔キ蜿門ッ<EFBFBD>徴](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||||
|
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [闔キ蜿門ッ<EFBFBD>徴](https://platform.deepseek.com) |
|
||||||
|
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [闔キ蜿門ッ<EFBFBD>徴](https://aistudio.google.com/api-keys) |
|
||||||
|
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [闔キ蜿門ッ<EFBFBD>徴](https://console.groq.com) |
|
||||||
|
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [闔キ蜿門ッ<EFBFBD>徴](https://platform.moonshot.cn) |
|
||||||
|
| **騾壻ケ牙鴻髣ョ (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [闔キ蜿門ッ<EFBFBD>徴](https://dashscope.console.aliyun.com) |
|
||||||
|
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [闔キ蜿門ッ<EFBFBD>徴](https://build.nvidia.com) |
|
||||||
|
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 譛ャ蝨ー<E89DA8>域裏髴蟇<C280>徴<EFBFBD>?|
|
||||||
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [闔キ蜿門ッ<EFBFBD>徴](https://openrouter.ai/keys) |
|
||||||
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 譛ャ蝨ー |
|
||||||
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [闔キ蜿門ッ<EFBFBD>徴](https://cerebras.ai) |
|
||||||
|
| **轣ォ螻ア蠑墓梼** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [闔キ蜿門ッ<EFBFBD>徴](https://console.volcengine.com) |
|
||||||
|
| **逾樒ョ嶺コ?* | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **Antigravity** | `antigravity/` | Google Cloud | 閾ェ螳壻ケ?| 莉?OAuth |
|
||||||
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
||||||
|
#### 蝓コ遑驟咲スョ遉コ萓<EFBDBA>
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_key": "sk-your-openai-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
|
"api_key": "sk-ant-your-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "glm-4.7",
|
||||||
|
"model": "zhipu/glm-4.7",
|
||||||
|
"api_key": "your-zhipu-key"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "gpt-5.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 蜷<>獅蝠<E78D85><E89DA0>鄂ョ遉コ萓?
|
||||||
|
|
||||||
|
**OpenAI**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**譎コ隹ア AI (GLM)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "glm-4.7",
|
||||||
|
"model": "zhipu/glm-4.7",
|
||||||
|
"api_key": "your-key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**DeepSeek**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "deepseek-chat",
|
||||||
|
"model": "deepseek/deepseek-chat",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Anthropic (菴ソ逕ィ OAuth)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
|
"auth_method": "oauth"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> 霑占。<E58DA0> `picoclaw auth login --provider anthropic` 譚・隶セ鄂?OAuth 蜃ュ隸√?
|
||||||
|
|
||||||
|
**Ollama (譛ャ蝨ー)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "llama3",
|
||||||
|
"model": "ollama/llama3"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**閾ェ螳壻ケ我サ」逅?API**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 雍溯スス蝮<EFBDBD>。。
|
||||||
|
|
||||||
|
荳コ蜷御ク荳ェ讓。蝙句錐遘ー驟咲スョ螟壻クェ遶ッ轤ケ窶披捻icoClaw 莨夊<E88EA8>蜉ィ蝨ィ螳<EFBDA8>サャ荵矩龍霓ョ隸「<E99AB8>?
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_base": "https://api1.example.com/v1",
|
||||||
|
"api_key": "sk-key1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_base": "https://api2.example.com/v1",
|
||||||
|
"api_key": "sk-key2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 莉取立逧?`providers` 驟咲スョ霑∫ァサ
|
||||||
|
|
||||||
|
譌ァ逧<EFBFBD> `providers` 驟咲スョ譬シ蠑<EFBDBC>**蟾イ蠑<EFBDB2><E8A091>?*<2A>御ス<E5BEA1>クコ蜷大錘蜈シ螳ケ莉肴髪謖√?
|
||||||
|
|
||||||
|
**譌ァ驟咲スョ<EFBDBD>亥キイ蠑<EFBDB2>畑<EFBFBD>会シ?*
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"zhipu": {
|
||||||
|
"api_key": "your-key",
|
||||||
|
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "zhipu",
|
||||||
|
"model": "glm-4.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**譁ー驟咲スョ<EFBDBD>域耳闕撰シ会シ<E4BC9A>**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "glm-4.7",
|
||||||
|
"model": "zhipu/glm-4.7",
|
||||||
|
"api_key": "your-key"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "glm-4.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
隸ヲ扈<EFBFBD>噪霑∫ァサ謖<EFBFBD>漉隸キ蜿り?[docs/migration/model-list-migration.md](docs/migration/model-list-migration.md)縲?
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>智谱 (Zhipu) 配置示例</b></summary>
|
<summary><b>智谱 (Zhipu) 配置示例</b></summary>
|
||||||
|
|
||||||
**1. 获取 API key 和 base URL**
|
**1. 闔キ蜿<EFBFBD> API key 蜥?base URL**
|
||||||
|
|
||||||
* 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
* 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
||||||
|
|
||||||
|
|
@ -668,37 +862,37 @@ picoclaw agent -m "你好"
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## CLI 命令行参考
|
## CLI 蜻ス莉、陦悟盾閠?
|
||||||
|
|
||||||
| 命令 | 描述 |
|
| 命令 | 描述 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `picoclaw onboard` | 初始化配置和工作区 |
|
| `picoclaw onboard` | 蛻晏ァ句喧驟咲スョ蜥悟キ・菴懷<EFBFBD>?|
|
||||||
| `picoclaw agent -m "..."` | 与 Agent 对话 |
|
| `picoclaw agent -m "..."` | 荳?Agent 蟇ケ隸<EFBDB9> |
|
||||||
| `picoclaw agent` | 交互式聊天模式 |
|
| `picoclaw agent` | 莠、莠貞シ剰♀螟ゥ讓。蠑?|
|
||||||
| `picoclaw gateway` | 启动网关 (Gateway) |
|
| `picoclaw gateway` | 启动网关 (Gateway) |
|
||||||
| `picoclaw status` | 显示状态 |
|
| `picoclaw status` | 譏セ遉コ迥カ諤?|
|
||||||
| `picoclaw cron list` | 列出所有定时任务 |
|
| `picoclaw cron list` | 蛻怜<EFBFBD>謇譛牙ョ壽慮莉サ蜉?|
|
||||||
| `picoclaw cron add ...` | 添加定时任务 |
|
| `picoclaw cron add ...` | 添加定时任务 |
|
||||||
|
|
||||||
### 定时任务 / 提醒 (Scheduled Tasks)
|
### 定时任务 / 提醒 (Scheduled Tasks)
|
||||||
|
|
||||||
PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
|
PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
|
||||||
|
|
||||||
* **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次
|
* **荳谺。諤ァ謠宣<E8ACA0>?*: "Remind me in 10 minutes" (10蛻<30>帖蜷取署驢呈<E9A9A2>) 竊?10蛻<30>帖蜷手ァヲ蜿台ク谺?
|
||||||
* **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发
|
* **驥榊、堺ササ蜉。**: "Remind me every 2 hours" (豈?蟆乗慮謠宣<E8ACA0>謌? 竊?豈?蟆乗慮隗ヲ蜿<EFBDA6>
|
||||||
* **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式
|
* **Cron 陦ィ霎セ蠑?*: "Remind me at 9am daily" (豈丞、ゥ荳雁壕9轤ケ謠宣<E8ACA0>謌<EFBFBD>) 竊?菴ソ逕ィ cron 陦ィ霎セ蠑?
|
||||||
|
|
||||||
任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。
|
莉サ蜉。蟄伜お蝨?`~/.picoclaw/workspace/cron/` 荳ュ蟷カ閾ェ蜉ィ螟<EFBDA8>炊縲?
|
||||||
|
|
||||||
## 🤝 贡献与路线图 (Roadmap)
|
## 🤝 贡献与路线图 (Roadmap)
|
||||||
|
|
||||||
欢迎提交 PR!代码库刻意保持小巧和可读。🤗
|
谺「霑取署莠、 PR<50>∽サ」遐∝コ灘綾諢丈ソ晄戟蟆丞キァ蜥悟庄隸サ縲を洟?
|
||||||
|
|
||||||
路线图即将发布...
|
霍ッ郤ソ蝗セ蜊ウ蟆<EFBFBD>書蟶?..
|
||||||
|
|
||||||
开发者群组正在组建中,入群门槛:至少合并过 1 个 PR。
|
蠑蜿題<EFBFBD>セ、扈<EFBFBD>ュ」蝨ィ扈<EFBFBD>サコ荳ュ<EFBFBD>悟<EFBFBD>鄒、髣ィ讒幢シ夊<EFBFBD>蟆大粋蟷カ霑?1 荳?PR縲?
|
||||||
|
|
||||||
用户群组:
|
逕ィ謌キ鄒、扈<EFBFBD>シ?
|
||||||
|
|
||||||
Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
||||||
|
|
||||||
|
|
@ -708,12 +902,12 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
||||||
|
|
||||||
### 网络搜索提示 "API 配置问题"
|
### 网络搜索提示 "API 配置问题"
|
||||||
|
|
||||||
如果您尚未配置搜索 API Key,这是正常的。PicoClaw 会提供手动搜索的帮助链接。
|
螯よ棡謔ィ蟆壽悴驟咲スョ謳懃エ?API Key<65>瑚ソ呎弍豁」蟶ク逧<EFBDB8>1icoClaw 莨壽署萓帶焔蜉ィ謳懃エ「逧<EFBDA2>クョ蜉ゥ體セ謗・縲?
|
||||||
|
|
||||||
启用网络搜索:
|
蜷ッ逕ィ鄂醍サ懈頗邏「<EFBFBD>?
|
||||||
|
|
||||||
1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询)
|
1. 蝨?[https://brave.com/search/api](https://brave.com/search/api) 闔キ蜿門<E89CBF>雍ケ API Key (豈乗怦 2000 谺。蜈崎エケ譟・隸?
|
||||||
2. 添加到 `~/.picoclaw/config.json`:
|
2. 豺サ蜉<EFBFBD>蛻?`~/.picoclaw/config.json`:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
@ -732,11 +926,11 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
||||||
|
|
||||||
### 遇到内容过滤错误 (Content Filtering Errors)
|
### 遇到内容过滤错误 (Content Filtering Errors)
|
||||||
|
|
||||||
某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。
|
譟蝉コ帶署萓帛膚<EFBFBD>亥ヲよ匱隹ア<EFBFBD>画怏荳・譬シ逧<EFBFBD><EFBFBD>螳ケ霑<EFBFBD>サ、縲ょー晁ッ墓隼蜀呎お逧<EFBFBD>琉鬚俶<EFBFBD>菴ソ逕ィ蜈カ莉匁ィ。蝙九?
|
||||||
|
|
||||||
### Telegram bot 提示 "Conflict: terminated by other getUpdates"
|
### Telegram bot 提示 "Conflict: terminated by other getUpdates"
|
||||||
|
|
||||||
这表示有另一个机器人实例正在运行。请确保同一时间只有一个 `picoclaw gateway` 进程在运行。
|
霑呵。ィ遉コ譛牙嘗荳荳ェ譛コ蝎ィ莠コ螳樔セ区ュ」蝨ィ霑占。後りッキ遑ョ菫晏酔荳譌カ髣エ蜿ェ譛我ク荳?`picoclaw gateway` 霑帷ィ句惠霑占。後?
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -744,8 +938,8 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
||||||
|
|
||||||
| 服务 | 免费层级 | 适用场景 |
|
| 服务 | 免费层级 | 适用场景 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
| **OpenRouter** | 200K tokens/譛?| 螟壽ィ。蝙玖★蜷?(Claude, GPT-4 遲? |
|
||||||
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
|
| **譎コ隹ア (Zhipu)** | 200K tokens/譛?| 譛騾ょ粋荳ュ蝗ス逕ィ謌キ |
|
||||||
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
| **Brave Search** | 2000 谺。譟・隸?譛?| 鄂醍サ懈頗邏「蜉溯<E89C89> |
|
||||||
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
| **Groq** | 謠蝉セ帛<EFBFBD>雍ケ螻らコァ | 譫<>滓耳逅?(Llama, Mixtral) |
|
||||||
| **Cerebras** | 提供免费层级 | 极速推理 (Llama, Qwen 等) |
|
| **Cerebras** | 謠蝉セ帛<EFBFBD>雍ケ螻らコァ | 譫<>滓耳逅?(Llama, Qwen 遲? |
|
||||||
|
|
|
||||||
181
cmd/picoclaw/cmd_agent.go
Normal file
181
cmd/picoclaw/cmd_agent.go
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/chzyer/readline"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/agent"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func agentCmd() {
|
||||||
|
message := ""
|
||||||
|
sessionKey := "cli:default"
|
||||||
|
modelOverride := ""
|
||||||
|
|
||||||
|
args := os.Args[2:]
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--debug", "-d":
|
||||||
|
logger.SetLevel(logger.DEBUG)
|
||||||
|
fmt.Println("🔍 Debug mode enabled")
|
||||||
|
case "-m", "--message":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
message = args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case "-s", "--session":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
sessionKey = args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case "--model", "-model":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
modelOverride = args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error loading config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if modelOverride != "" {
|
||||||
|
cfg.Agents.Defaults.Model = modelOverride
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, modelID, err := providers.CreateProvider(cfg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error creating provider: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// Use the resolved model ID from provider creation
|
||||||
|
if modelID != "" {
|
||||||
|
cfg.Agents.Defaults.Model = modelID
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
|
// Print agent startup info (only for interactive mode)
|
||||||
|
startupInfo := agentLoop.GetStartupInfo()
|
||||||
|
logger.InfoCF("agent", "Agent initialized",
|
||||||
|
map[string]interface{}{
|
||||||
|
"tools_count": startupInfo["tools"].(map[string]interface{})["count"],
|
||||||
|
"skills_total": startupInfo["skills"].(map[string]interface{})["total"],
|
||||||
|
"skills_available": startupInfo["skills"].(map[string]interface{})["available"],
|
||||||
|
})
|
||||||
|
|
||||||
|
if message != "" {
|
||||||
|
ctx := context.Background()
|
||||||
|
response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n%s %s\n", logo, response)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo)
|
||||||
|
interactiveMode(agentLoop, sessionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||||
|
prompt := fmt.Sprintf("%s You: ", logo)
|
||||||
|
|
||||||
|
rl, err := readline.NewEx(&readline.Config{
|
||||||
|
Prompt: prompt,
|
||||||
|
HistoryFile: filepath.Join(os.TempDir(), ".picoclaw_history"),
|
||||||
|
HistoryLimit: 100,
|
||||||
|
InterruptPrompt: "^C",
|
||||||
|
EOFPrompt: "exit",
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error initializing readline: %v\n", err)
|
||||||
|
fmt.Println("Falling back to simple input mode...")
|
||||||
|
simpleInteractiveMode(agentLoop, sessionKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rl.Close()
|
||||||
|
|
||||||
|
for {
|
||||||
|
line, err := rl.Readline()
|
||||||
|
if err != nil {
|
||||||
|
if err == readline.ErrInterrupt || err == io.EOF {
|
||||||
|
fmt.Println("\nGoodbye!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("Error reading input: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
input := strings.TrimSpace(line)
|
||||||
|
if input == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if input == "exit" || input == "quit" {
|
||||||
|
fmt.Println("Goodbye!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n%s %s\n\n", logo, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
for {
|
||||||
|
fmt.Print(fmt.Sprintf("%s You: ", logo))
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
fmt.Println("\nGoodbye!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("Error reading input: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
input := strings.TrimSpace(line)
|
||||||
|
if input == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if input == "exit" || input == "quit" {
|
||||||
|
fmt.Println("Goodbye!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n%s %s\n\n", logo, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
512
cmd/picoclaw/cmd_auth.go
Normal file
512
cmd/picoclaw/cmd_auth.go
Normal file
|
|
@ -0,0 +1,512 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
const supportedProvidersMsg = "Supported providers: openai, anthropic, google-antigravity"
|
||||||
|
|
||||||
|
func authCmd() {
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
authHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch os.Args[2] {
|
||||||
|
case "login":
|
||||||
|
authLoginCmd()
|
||||||
|
case "logout":
|
||||||
|
authLogoutCmd()
|
||||||
|
case "status":
|
||||||
|
authStatusCmd()
|
||||||
|
case "models":
|
||||||
|
authModelsCmd()
|
||||||
|
default:
|
||||||
|
fmt.Printf("Unknown auth command: %s\n", os.Args[2])
|
||||||
|
authHelp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authHelp() {
|
||||||
|
fmt.Println("\nAuth commands:")
|
||||||
|
fmt.Println(" login Login via OAuth or paste token")
|
||||||
|
fmt.Println(" logout Remove stored credentials")
|
||||||
|
fmt.Println(" status Show current auth status")
|
||||||
|
fmt.Println(" models List available Antigravity models")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Login options:")
|
||||||
|
fmt.Println(" --provider <name> Provider to login with (openai, anthropic, google-antigravity)")
|
||||||
|
fmt.Println(" --device-code Use device code flow (for headless environments)")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Examples:")
|
||||||
|
fmt.Println(" picoclaw auth login --provider openai")
|
||||||
|
fmt.Println(" picoclaw auth login --provider openai --device-code")
|
||||||
|
fmt.Println(" picoclaw auth login --provider anthropic")
|
||||||
|
fmt.Println(" picoclaw auth login --provider google-antigravity")
|
||||||
|
fmt.Println(" picoclaw auth models")
|
||||||
|
fmt.Println(" picoclaw auth logout --provider openai")
|
||||||
|
fmt.Println(" picoclaw auth status")
|
||||||
|
}
|
||||||
|
|
||||||
|
func authLoginCmd() {
|
||||||
|
provider := ""
|
||||||
|
useDeviceCode := false
|
||||||
|
|
||||||
|
args := os.Args[3:]
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--provider", "-p":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
provider = args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case "--device-code":
|
||||||
|
useDeviceCode = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider == "" {
|
||||||
|
fmt.Println("Error: --provider is required")
|
||||||
|
fmt.Println(supportedProvidersMsg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch provider {
|
||||||
|
case "openai":
|
||||||
|
authLoginOpenAI(useDeviceCode)
|
||||||
|
case "anthropic":
|
||||||
|
authLoginPasteToken(provider)
|
||||||
|
case "google-antigravity", "antigravity":
|
||||||
|
authLoginGoogleAntigravity()
|
||||||
|
default:
|
||||||
|
fmt.Printf("Unsupported provider: %s\n", provider)
|
||||||
|
fmt.Println(supportedProvidersMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authLoginOpenAI(useDeviceCode bool) {
|
||||||
|
cfg := auth.OpenAIOAuthConfig()
|
||||||
|
|
||||||
|
var cred *auth.AuthCredential
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if useDeviceCode {
|
||||||
|
cred, err = auth.LoginDeviceCode(cfg)
|
||||||
|
} else {
|
||||||
|
cred, err = auth.LoginBrowser(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Login failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.SetCredential("openai", cred); err != nil {
|
||||||
|
fmt.Printf("Failed to save credentials: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
appCfg, err := loadConfig()
|
||||||
|
if err == nil {
|
||||||
|
// Update Providers (legacy format)
|
||||||
|
appCfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||||
|
|
||||||
|
// Update or add openai in ModelList
|
||||||
|
foundOpenAI := false
|
||||||
|
for i := range appCfg.ModelList {
|
||||||
|
if isOpenAIModel(appCfg.ModelList[i].Model) {
|
||||||
|
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||||
|
foundOpenAI = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no openai in ModelList, add it
|
||||||
|
if !foundOpenAI {
|
||||||
|
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||||
|
ModelName: "gpt-5.2",
|
||||||
|
Model: "openai/gpt-5.2",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update default model to use OpenAI
|
||||||
|
appCfg.Agents.Defaults.Model = "gpt-5.2"
|
||||||
|
|
||||||
|
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
||||||
|
fmt.Printf("Warning: could not update config: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Login successful!")
|
||||||
|
if cred.AccountID != "" {
|
||||||
|
fmt.Printf("Account: %s\n", cred.AccountID)
|
||||||
|
}
|
||||||
|
fmt.Println("Default model set to: gpt-5.2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func authLoginGoogleAntigravity() {
|
||||||
|
cfg := auth.GoogleAntigravityOAuthConfig()
|
||||||
|
|
||||||
|
cred, err := auth.LoginBrowser(cfg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Login failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
cred.Provider = "google-antigravity"
|
||||||
|
|
||||||
|
// Fetch user email from Google userinfo
|
||||||
|
email, err := fetchGoogleUserEmail(cred.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: could not fetch email: %v\n", err)
|
||||||
|
} else {
|
||||||
|
cred.Email = email
|
||||||
|
fmt.Printf("Email: %s\n", email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch Cloud Code Assist project ID
|
||||||
|
projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: could not fetch project ID: %v\n", err)
|
||||||
|
fmt.Println("You may need Google Cloud Code Assist enabled on your account.")
|
||||||
|
} else {
|
||||||
|
cred.ProjectID = projectID
|
||||||
|
fmt.Printf("Project: %s\n", projectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.SetCredential("google-antigravity", cred); err != nil {
|
||||||
|
fmt.Printf("Failed to save credentials: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
appCfg, err := loadConfig()
|
||||||
|
if err == nil {
|
||||||
|
// Update Providers (legacy format, for backward compatibility)
|
||||||
|
appCfg.Providers.Antigravity.AuthMethod = "oauth"
|
||||||
|
|
||||||
|
// Update or add antigravity in ModelList
|
||||||
|
foundAntigravity := false
|
||||||
|
for i := range appCfg.ModelList {
|
||||||
|
if isAntigravityModel(appCfg.ModelList[i].Model) {
|
||||||
|
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||||
|
foundAntigravity = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no antigravity in ModelList, add it
|
||||||
|
if !foundAntigravity {
|
||||||
|
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||||
|
ModelName: "gemini-flash",
|
||||||
|
Model: "antigravity/gemini-3-flash",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update default model
|
||||||
|
appCfg.Agents.Defaults.Model = "gemini-flash"
|
||||||
|
|
||||||
|
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
||||||
|
fmt.Printf("Warning: could not update config: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n✓ Google Antigravity login successful!")
|
||||||
|
fmt.Println("Default model set to: gemini-flash")
|
||||||
|
fmt.Println("Try it: picoclaw agent -m \"Hello world\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||||
|
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var userInfo struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &userInfo); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return userInfo.Email, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func authLoginPasteToken(provider string) {
|
||||||
|
cred, err := auth.LoginPasteToken(provider, os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Login failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.SetCredential(provider, cred); err != nil {
|
||||||
|
fmt.Printf("Failed to save credentials: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
appCfg, err := loadConfig()
|
||||||
|
if err == nil {
|
||||||
|
switch provider {
|
||||||
|
case "anthropic":
|
||||||
|
appCfg.Providers.Anthropic.AuthMethod = "token"
|
||||||
|
// Update ModelList
|
||||||
|
found := false
|
||||||
|
for i := range appCfg.ModelList {
|
||||||
|
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||||
|
appCfg.ModelList[i].AuthMethod = "token"
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||||
|
ModelName: "claude-sonnet-4.6",
|
||||||
|
Model: "anthropic/claude-sonnet-4.6",
|
||||||
|
AuthMethod: "token",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Update default model
|
||||||
|
appCfg.Agents.Defaults.Model = "claude-sonnet-4.6"
|
||||||
|
case "openai":
|
||||||
|
appCfg.Providers.OpenAI.AuthMethod = "token"
|
||||||
|
// Update ModelList
|
||||||
|
found := false
|
||||||
|
for i := range appCfg.ModelList {
|
||||||
|
if isOpenAIModel(appCfg.ModelList[i].Model) {
|
||||||
|
appCfg.ModelList[i].AuthMethod = "token"
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||||
|
ModelName: "gpt-5.2",
|
||||||
|
Model: "openai/gpt-5.2",
|
||||||
|
AuthMethod: "token",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Update default model
|
||||||
|
appCfg.Agents.Defaults.Model = "gpt-5.2"
|
||||||
|
}
|
||||||
|
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
||||||
|
fmt.Printf("Warning: could not update config: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Token saved for %s!\n", provider)
|
||||||
|
fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func authLogoutCmd() {
|
||||||
|
provider := ""
|
||||||
|
|
||||||
|
args := os.Args[3:]
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--provider", "-p":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
provider = args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider != "" {
|
||||||
|
if err := auth.DeleteCredential(provider); err != nil {
|
||||||
|
fmt.Printf("Failed to remove credentials: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
appCfg, err := loadConfig()
|
||||||
|
if err == nil {
|
||||||
|
// Clear AuthMethod in ModelList
|
||||||
|
for i := range appCfg.ModelList {
|
||||||
|
switch provider {
|
||||||
|
case "openai":
|
||||||
|
if isOpenAIModel(appCfg.ModelList[i].Model) {
|
||||||
|
appCfg.ModelList[i].AuthMethod = ""
|
||||||
|
}
|
||||||
|
case "anthropic":
|
||||||
|
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||||
|
appCfg.ModelList[i].AuthMethod = ""
|
||||||
|
}
|
||||||
|
case "google-antigravity", "antigravity":
|
||||||
|
if isAntigravityModel(appCfg.ModelList[i].Model) {
|
||||||
|
appCfg.ModelList[i].AuthMethod = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clear AuthMethod in Providers (legacy)
|
||||||
|
switch provider {
|
||||||
|
case "openai":
|
||||||
|
appCfg.Providers.OpenAI.AuthMethod = ""
|
||||||
|
case "anthropic":
|
||||||
|
appCfg.Providers.Anthropic.AuthMethod = ""
|
||||||
|
case "google-antigravity", "antigravity":
|
||||||
|
appCfg.Providers.Antigravity.AuthMethod = ""
|
||||||
|
}
|
||||||
|
config.SaveConfig(getConfigPath(), appCfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Logged out from %s\n", provider)
|
||||||
|
} else {
|
||||||
|
if err := auth.DeleteAllCredentials(); err != nil {
|
||||||
|
fmt.Printf("Failed to remove credentials: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
appCfg, err := loadConfig()
|
||||||
|
if err == nil {
|
||||||
|
// Clear all AuthMethods in ModelList
|
||||||
|
for i := range appCfg.ModelList {
|
||||||
|
appCfg.ModelList[i].AuthMethod = ""
|
||||||
|
}
|
||||||
|
// Clear all AuthMethods in Providers (legacy)
|
||||||
|
appCfg.Providers.OpenAI.AuthMethod = ""
|
||||||
|
appCfg.Providers.Anthropic.AuthMethod = ""
|
||||||
|
appCfg.Providers.Antigravity.AuthMethod = ""
|
||||||
|
config.SaveConfig(getConfigPath(), appCfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Logged out from all providers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authStatusCmd() {
|
||||||
|
store, err := auth.LoadStore()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error loading auth store: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(store.Credentials) == 0 {
|
||||||
|
fmt.Println("No authenticated providers.")
|
||||||
|
fmt.Println("Run: picoclaw auth login --provider <name>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\nAuthenticated Providers:")
|
||||||
|
fmt.Println("------------------------")
|
||||||
|
for provider, cred := range store.Credentials {
|
||||||
|
status := "active"
|
||||||
|
if cred.IsExpired() {
|
||||||
|
status = "expired"
|
||||||
|
} else if cred.NeedsRefresh() {
|
||||||
|
status = "needs refresh"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" %s:\n", provider)
|
||||||
|
fmt.Printf(" Method: %s\n", cred.AuthMethod)
|
||||||
|
fmt.Printf(" Status: %s\n", status)
|
||||||
|
if cred.AccountID != "" {
|
||||||
|
fmt.Printf(" Account: %s\n", cred.AccountID)
|
||||||
|
}
|
||||||
|
if cred.Email != "" {
|
||||||
|
fmt.Printf(" Email: %s\n", cred.Email)
|
||||||
|
}
|
||||||
|
if cred.ProjectID != "" {
|
||||||
|
fmt.Printf(" Project: %s\n", cred.ProjectID)
|
||||||
|
}
|
||||||
|
if !cred.ExpiresAt.IsZero() {
|
||||||
|
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authModelsCmd() {
|
||||||
|
cred, err := auth.GetCredential("google-antigravity")
|
||||||
|
if err != nil || cred == nil {
|
||||||
|
fmt.Println("Not logged in to Google Antigravity.")
|
||||||
|
fmt.Println("Run: picoclaw auth login --provider google-antigravity")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh token if needed
|
||||||
|
if cred.NeedsRefresh() && cred.RefreshToken != "" {
|
||||||
|
oauthCfg := auth.GoogleAntigravityOAuthConfig()
|
||||||
|
refreshed, refreshErr := auth.RefreshAccessToken(cred, oauthCfg)
|
||||||
|
if refreshErr == nil {
|
||||||
|
cred = refreshed
|
||||||
|
_ = auth.SetCredential("google-antigravity", cred)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID := cred.ProjectID
|
||||||
|
if projectID == "" {
|
||||||
|
fmt.Println("No project ID stored. Try logging in again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Fetching models for project: %s\n\n", projectID)
|
||||||
|
|
||||||
|
models, err := providers.FetchAntigravityModels(cred.AccessToken, projectID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error fetching models: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(models) == 0 {
|
||||||
|
fmt.Println("No models available.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Available Antigravity Models:")
|
||||||
|
fmt.Println("-----------------------------")
|
||||||
|
for _, m := range models {
|
||||||
|
status := "✓"
|
||||||
|
if m.IsExhausted {
|
||||||
|
status = "✗ (quota exhausted)"
|
||||||
|
}
|
||||||
|
name := m.ID
|
||||||
|
if m.DisplayName != "" {
|
||||||
|
name = fmt.Sprintf("%s (%s)", m.ID, m.DisplayName)
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s %s\n", status, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAntigravityModel checks if a model string belongs to antigravity provider
|
||||||
|
func isAntigravityModel(model string) bool {
|
||||||
|
return model == "antigravity" ||
|
||||||
|
model == "google-antigravity" ||
|
||||||
|
strings.HasPrefix(model, "antigravity/") ||
|
||||||
|
strings.HasPrefix(model, "google-antigravity/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isOpenAIModel checks if a model string belongs to openai provider
|
||||||
|
func isOpenAIModel(model string) bool {
|
||||||
|
return model == "openai" ||
|
||||||
|
strings.HasPrefix(model, "openai/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAnthropicModel checks if a model string belongs to anthropic provider
|
||||||
|
func isAnthropicModel(model string) bool {
|
||||||
|
return model == "anthropic" ||
|
||||||
|
strings.HasPrefix(model, "anthropic/")
|
||||||
|
}
|
||||||
227
cmd/picoclaw/cmd_cron.go
Normal file
227
cmd/picoclaw/cmd_cron.go
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/cron"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cronCmd() {
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
cronHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subcommand := os.Args[2]
|
||||||
|
|
||||||
|
// Load config to get workspace path
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error loading config: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cronStorePath := filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
|
||||||
|
|
||||||
|
switch subcommand {
|
||||||
|
case "list":
|
||||||
|
cronListCmd(cronStorePath)
|
||||||
|
case "add":
|
||||||
|
cronAddCmd(cronStorePath)
|
||||||
|
case "remove":
|
||||||
|
if len(os.Args) < 4 {
|
||||||
|
fmt.Println("Usage: picoclaw cron remove <job_id>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cronRemoveCmd(cronStorePath, os.Args[3])
|
||||||
|
case "enable":
|
||||||
|
cronEnableCmd(cronStorePath, false)
|
||||||
|
case "disable":
|
||||||
|
cronEnableCmd(cronStorePath, true)
|
||||||
|
default:
|
||||||
|
fmt.Printf("Unknown cron command: %s\n", subcommand)
|
||||||
|
cronHelp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cronHelp() {
|
||||||
|
fmt.Println("\nCron commands:")
|
||||||
|
fmt.Println(" list List all scheduled jobs")
|
||||||
|
fmt.Println(" add Add a new scheduled job")
|
||||||
|
fmt.Println(" remove <id> Remove a job by ID")
|
||||||
|
fmt.Println(" enable <id> Enable a job")
|
||||||
|
fmt.Println(" disable <id> Disable a job")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Add options:")
|
||||||
|
fmt.Println(" -n, --name Job name")
|
||||||
|
fmt.Println(" -m, --message Message for agent")
|
||||||
|
fmt.Println(" -e, --every Run every N seconds")
|
||||||
|
fmt.Println(" -c, --cron Cron expression (e.g. '0 9 * * *')")
|
||||||
|
fmt.Println(" -d, --deliver Deliver response to channel")
|
||||||
|
fmt.Println(" --to Recipient for delivery")
|
||||||
|
fmt.Println(" --channel Channel for delivery")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cronListCmd(storePath string) {
|
||||||
|
cs := cron.NewCronService(storePath, nil)
|
||||||
|
jobs := cs.ListJobs(true) // Show all jobs, including disabled
|
||||||
|
|
||||||
|
if len(jobs) == 0 {
|
||||||
|
fmt.Println("No scheduled jobs.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\nScheduled Jobs:")
|
||||||
|
fmt.Println("----------------")
|
||||||
|
for _, job := range jobs {
|
||||||
|
var schedule string
|
||||||
|
if job.Schedule.Kind == "every" && job.Schedule.EveryMS != nil {
|
||||||
|
schedule = fmt.Sprintf("every %ds", *job.Schedule.EveryMS/1000)
|
||||||
|
} else if job.Schedule.Kind == "cron" {
|
||||||
|
schedule = job.Schedule.Expr
|
||||||
|
} else {
|
||||||
|
schedule = "one-time"
|
||||||
|
}
|
||||||
|
|
||||||
|
nextRun := "scheduled"
|
||||||
|
if job.State.NextRunAtMS != nil {
|
||||||
|
nextTime := time.UnixMilli(*job.State.NextRunAtMS)
|
||||||
|
nextRun = nextTime.Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "enabled"
|
||||||
|
if !job.Enabled {
|
||||||
|
status = "disabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" %s (%s)\n", job.Name, job.ID)
|
||||||
|
fmt.Printf(" Schedule: %s\n", schedule)
|
||||||
|
fmt.Printf(" Status: %s\n", status)
|
||||||
|
fmt.Printf(" Next run: %s\n", nextRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cronAddCmd(storePath string) {
|
||||||
|
name := ""
|
||||||
|
message := ""
|
||||||
|
var everySec *int64
|
||||||
|
cronExpr := ""
|
||||||
|
deliver := false
|
||||||
|
channel := ""
|
||||||
|
to := ""
|
||||||
|
|
||||||
|
args := os.Args[3:]
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "-n", "--name":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
name = args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case "-m", "--message":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
message = args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case "-e", "--every":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
var sec int64
|
||||||
|
fmt.Sscanf(args[i+1], "%d", &sec)
|
||||||
|
everySec = &sec
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case "-c", "--cron":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
cronExpr = args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case "-d", "--deliver":
|
||||||
|
deliver = true
|
||||||
|
case "--to":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
to = args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case "--channel":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
channel = args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
fmt.Println("Error: --name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if message == "" {
|
||||||
|
fmt.Println("Error: --message is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if everySec == nil && cronExpr == "" {
|
||||||
|
fmt.Println("Error: Either --every or --cron must be specified")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var schedule cron.CronSchedule
|
||||||
|
if everySec != nil {
|
||||||
|
everyMS := *everySec * 1000
|
||||||
|
schedule = cron.CronSchedule{
|
||||||
|
Kind: "every",
|
||||||
|
EveryMS: &everyMS,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
schedule = cron.CronSchedule{
|
||||||
|
Kind: "cron",
|
||||||
|
Expr: cronExpr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cs := cron.NewCronService(storePath, nil)
|
||||||
|
job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error adding job: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cronRemoveCmd(storePath, jobID string) {
|
||||||
|
cs := cron.NewCronService(storePath, nil)
|
||||||
|
if cs.RemoveJob(jobID) {
|
||||||
|
fmt.Printf("✓ Removed job %s\n", jobID)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("✗ Job %s not found\n", jobID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cronEnableCmd(storePath string, disable bool) {
|
||||||
|
if len(os.Args) < 4 {
|
||||||
|
fmt.Println("Usage: picoclaw cron enable/disable <job_id>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobID := os.Args[3]
|
||||||
|
cs := cron.NewCronService(storePath, nil)
|
||||||
|
enabled := !disable
|
||||||
|
|
||||||
|
job := cs.EnableJob(jobID, enabled)
|
||||||
|
if job != nil {
|
||||||
|
status := "enabled"
|
||||||
|
if disable {
|
||||||
|
status = "disabled"
|
||||||
|
}
|
||||||
|
fmt.Printf("✓ Job '%s' %s\n", job.Name, status)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("✗ Job %s not found\n", jobID)
|
||||||
|
}
|
||||||
|
}
|
||||||
223
cmd/picoclaw/cmd_gateway.go
Normal file
223
cmd/picoclaw/cmd_gateway.go
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/agent"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/cron"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/devices"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/health"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/heartbeat"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
func gatewayCmd() {
|
||||||
|
// Check for --debug flag
|
||||||
|
args := os.Args[2:]
|
||||||
|
for _, arg := range args {
|
||||||
|
if arg == "--debug" || arg == "-d" {
|
||||||
|
logger.SetLevel(logger.DEBUG)
|
||||||
|
fmt.Println("🔍 Debug mode enabled")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error loading config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, modelID, err := providers.CreateProvider(cfg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error creating provider: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// Use the resolved model ID from provider creation
|
||||||
|
if modelID != "" {
|
||||||
|
cfg.Agents.Defaults.Model = modelID
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
|
// Print agent startup info
|
||||||
|
fmt.Println("\n📦 Agent Status:")
|
||||||
|
startupInfo := agentLoop.GetStartupInfo()
|
||||||
|
toolsInfo := startupInfo["tools"].(map[string]interface{})
|
||||||
|
skillsInfo := startupInfo["skills"].(map[string]interface{})
|
||||||
|
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
|
||||||
|
fmt.Printf(" • Skills: %d/%d available\n",
|
||||||
|
skillsInfo["available"],
|
||||||
|
skillsInfo["total"])
|
||||||
|
|
||||||
|
// Log to file as well
|
||||||
|
logger.InfoCF("agent", "Agent initialized",
|
||||||
|
map[string]interface{}{
|
||||||
|
"tools_count": toolsInfo["count"],
|
||||||
|
"skills_total": skillsInfo["total"],
|
||||||
|
"skills_available": skillsInfo["available"],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Setup cron tool and service
|
||||||
|
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
||||||
|
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout, cfg)
|
||||||
|
|
||||||
|
heartbeatService := heartbeat.NewHeartbeatService(
|
||||||
|
cfg.WorkspacePath(),
|
||||||
|
cfg.Heartbeat.Interval,
|
||||||
|
cfg.Heartbeat.Enabled,
|
||||||
|
)
|
||||||
|
heartbeatService.SetBus(msgBus)
|
||||||
|
heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||||
|
// Use cli:direct as fallback if no valid channel
|
||||||
|
if channel == "" || chatID == "" {
|
||||||
|
channel, chatID = "cli", "direct"
|
||||||
|
}
|
||||||
|
// Use ProcessHeartbeat - no session history, each heartbeat is independent
|
||||||
|
response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
|
||||||
|
if err != nil {
|
||||||
|
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
|
||||||
|
}
|
||||||
|
if response == "HEARTBEAT_OK" {
|
||||||
|
return tools.SilentResult("Heartbeat OK")
|
||||||
|
}
|
||||||
|
// For heartbeat, always return silent - the subagent result will be
|
||||||
|
// sent to user via processSystemMessage when the async task completes
|
||||||
|
return tools.SilentResult(response)
|
||||||
|
})
|
||||||
|
|
||||||
|
channelManager, err := channels.NewManager(cfg, msgBus)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error creating channel manager: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject channel manager into agent loop for command handling
|
||||||
|
agentLoop.SetChannelManager(channelManager)
|
||||||
|
|
||||||
|
var transcriber *voice.GroqTranscriber
|
||||||
|
if cfg.Providers.Groq.APIKey != "" {
|
||||||
|
transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey)
|
||||||
|
logger.InfoC("voice", "Groq voice transcription enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
if transcriber != nil {
|
||||||
|
if telegramChannel, ok := channelManager.GetChannel("telegram"); ok {
|
||||||
|
if tc, ok := telegramChannel.(*channels.TelegramChannel); ok {
|
||||||
|
tc.SetTranscriber(transcriber)
|
||||||
|
logger.InfoC("voice", "Groq transcription attached to Telegram channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if discordChannel, ok := channelManager.GetChannel("discord"); ok {
|
||||||
|
if dc, ok := discordChannel.(*channels.DiscordChannel); ok {
|
||||||
|
dc.SetTranscriber(transcriber)
|
||||||
|
logger.InfoC("voice", "Groq transcription attached to Discord channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if slackChannel, ok := channelManager.GetChannel("slack"); ok {
|
||||||
|
if sc, ok := slackChannel.(*channels.SlackChannel); ok {
|
||||||
|
sc.SetTranscriber(transcriber)
|
||||||
|
logger.InfoC("voice", "Groq transcription attached to Slack channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enabledChannels := channelManager.GetEnabledChannels()
|
||||||
|
if len(enabledChannels) > 0 {
|
||||||
|
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
||||||
|
} else {
|
||||||
|
fmt.Println("⚠ Warning: No channels enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
|
fmt.Println("Press Ctrl+C to stop")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := cronService.Start(); err != nil {
|
||||||
|
fmt.Printf("Error starting cron service: %v\n", err)
|
||||||
|
}
|
||||||
|
fmt.Println("✓ Cron service started")
|
||||||
|
|
||||||
|
if err := heartbeatService.Start(); err != nil {
|
||||||
|
fmt.Printf("Error starting heartbeat service: %v\n", err)
|
||||||
|
}
|
||||||
|
fmt.Println("✓ Heartbeat service started")
|
||||||
|
|
||||||
|
stateManager := state.NewManager(cfg.WorkspacePath())
|
||||||
|
deviceService := devices.NewService(devices.Config{
|
||||||
|
Enabled: cfg.Devices.Enabled,
|
||||||
|
MonitorUSB: cfg.Devices.MonitorUSB,
|
||||||
|
}, stateManager)
|
||||||
|
deviceService.SetBus(msgBus)
|
||||||
|
if err := deviceService.Start(ctx); err != nil {
|
||||||
|
fmt.Printf("Error starting device service: %v\n", err)
|
||||||
|
} else if cfg.Devices.Enabled {
|
||||||
|
fmt.Println("✓ Device event service started")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := channelManager.StartAll(ctx); err != nil {
|
||||||
|
fmt.Printf("Error starting channels: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
|
go func() {
|
||||||
|
if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.ErrorCF("health", "Health server error", map[string]interface{}{"error": err.Error()})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
|
|
||||||
|
go agentLoop.Run(ctx)
|
||||||
|
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, os.Interrupt)
|
||||||
|
<-sigChan
|
||||||
|
|
||||||
|
fmt.Println("\nShutting down...")
|
||||||
|
cancel()
|
||||||
|
healthServer.Stop(context.Background())
|
||||||
|
deviceService.Stop()
|
||||||
|
heartbeatService.Stop()
|
||||||
|
cronService.Stop()
|
||||||
|
agentLoop.Stop()
|
||||||
|
channelManager.StopAll(ctx)
|
||||||
|
fmt.Println("✓ Gateway stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, cfg *config.Config) *cron.CronService {
|
||||||
|
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
|
||||||
|
|
||||||
|
// Create cron service
|
||||||
|
cronService := cron.NewCronService(cronStorePath, nil)
|
||||||
|
|
||||||
|
// Create and register CronTool
|
||||||
|
cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
||||||
|
agentLoop.RegisterTool(cronTool)
|
||||||
|
|
||||||
|
// Set the onJob handler
|
||||||
|
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
||||||
|
result := cronTool.ExecuteJob(context.Background(), job)
|
||||||
|
return result, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return cronService
|
||||||
|
}
|
||||||
81
cmd/picoclaw/cmd_migrate.go
Normal file
81
cmd/picoclaw/cmd_migrate.go
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/migrate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func migrateCmd() {
|
||||||
|
if len(os.Args) > 2 && (os.Args[2] == "--help" || os.Args[2] == "-h") {
|
||||||
|
migrateHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := migrate.Options{}
|
||||||
|
|
||||||
|
args := os.Args[2:]
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--dry-run":
|
||||||
|
opts.DryRun = true
|
||||||
|
case "--config-only":
|
||||||
|
opts.ConfigOnly = true
|
||||||
|
case "--workspace-only":
|
||||||
|
opts.WorkspaceOnly = true
|
||||||
|
case "--force":
|
||||||
|
opts.Force = true
|
||||||
|
case "--refresh":
|
||||||
|
opts.Refresh = true
|
||||||
|
case "--openclaw-home":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
opts.OpenClawHome = args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case "--picoclaw-home":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
opts.PicoClawHome = args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
fmt.Printf("Unknown flag: %s\n", args[i])
|
||||||
|
migrateHelp()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := migrate.Run(opts)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.DryRun {
|
||||||
|
migrate.PrintSummary(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateHelp() {
|
||||||
|
fmt.Println("\nMigrate from OpenClaw to PicoClaw")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Usage: picoclaw migrate [options]")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Options:")
|
||||||
|
fmt.Println(" --dry-run Show what would be migrated without making changes")
|
||||||
|
fmt.Println(" --refresh Re-sync workspace files from OpenClaw (repeatable)")
|
||||||
|
fmt.Println(" --config-only Only migrate config, skip workspace files")
|
||||||
|
fmt.Println(" --workspace-only Only migrate workspace files, skip config")
|
||||||
|
fmt.Println(" --force Skip confirmation prompts")
|
||||||
|
fmt.Println(" --openclaw-home Override OpenClaw home directory (default: ~/.openclaw)")
|
||||||
|
fmt.Println(" --picoclaw-home Override PicoClaw home directory (default: ~/.picoclaw)")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Examples:")
|
||||||
|
fmt.Println(" picoclaw migrate Detect and migrate from OpenClaw")
|
||||||
|
fmt.Println(" picoclaw migrate --dry-run Show what would be migrated")
|
||||||
|
fmt.Println(" picoclaw migrate --refresh Re-sync workspace files")
|
||||||
|
fmt.Println(" picoclaw migrate --force Migrate without confirmation")
|
||||||
|
}
|
||||||
108
cmd/picoclaw/cmd_onboard.go
Normal file
108
cmd/picoclaw/cmd_onboard.go
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:generate cp -r ../../workspace .
|
||||||
|
//go:embed workspace
|
||||||
|
var embeddedFiles embed.FS
|
||||||
|
|
||||||
|
func onboard() {
|
||||||
|
configPath := getConfigPath()
|
||||||
|
|
||||||
|
if _, err := os.Stat(configPath); err == nil {
|
||||||
|
fmt.Printf("Config already exists at %s\n", configPath)
|
||||||
|
fmt.Print("Overwrite? (y/n): ")
|
||||||
|
var response string
|
||||||
|
fmt.Scanln(&response)
|
||||||
|
if response != "y" {
|
||||||
|
fmt.Println("Aborted.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
fmt.Printf("Error saving config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace := cfg.WorkspacePath()
|
||||||
|
createWorkspaceTemplates(workspace)
|
||||||
|
|
||||||
|
fmt.Printf("%s picoclaw is ready!\n", logo)
|
||||||
|
fmt.Println("\nNext steps:")
|
||||||
|
fmt.Println(" 1. Add your API key to", configPath)
|
||||||
|
fmt.Println("")
|
||||||
|
fmt.Println(" Recommended:")
|
||||||
|
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
|
||||||
|
fmt.Println(" - Ollama: https://ollama.com (local, free)")
|
||||||
|
fmt.Println("")
|
||||||
|
fmt.Println(" See README.md for 17+ supported providers.")
|
||||||
|
fmt.Println("")
|
||||||
|
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyEmbeddedToTarget(targetDir string) error {
|
||||||
|
// Ensure target directory exists
|
||||||
|
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("Failed to create target directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk through all files in embed.FS
|
||||||
|
err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip directories
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read embedded file
|
||||||
|
data, err := embeddedFiles.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to read embedded file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
new_path, err := filepath.Rel("workspace", path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build target file path
|
||||||
|
targetPath := filepath.Join(targetDir, new_path)
|
||||||
|
|
||||||
|
// Ensure target file's directory exists
|
||||||
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
||||||
|
return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write file
|
||||||
|
if err := os.WriteFile(targetPath, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func createWorkspaceTemplates(workspace string) {
|
||||||
|
err := copyEmbeddedToTarget(workspace)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error copying workspace templates: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
216
cmd/picoclaw/cmd_skills.go
Normal file
216
cmd/picoclaw/cmd_skills.go
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
func skillsHelp() {
|
||||||
|
fmt.Println("\nSkills commands:")
|
||||||
|
fmt.Println(" list List installed skills")
|
||||||
|
fmt.Println(" install <repo> Install skill from GitHub")
|
||||||
|
fmt.Println(" install-builtin Install all builtin skills to workspace")
|
||||||
|
fmt.Println(" list-builtin List available builtin skills")
|
||||||
|
fmt.Println(" remove <name> Remove installed skill")
|
||||||
|
fmt.Println(" search Search available skills")
|
||||||
|
fmt.Println(" show <name> Show skill details")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Examples:")
|
||||||
|
fmt.Println(" picoclaw skills list")
|
||||||
|
fmt.Println(" picoclaw skills install sipeed/picoclaw-skills/weather")
|
||||||
|
fmt.Println(" picoclaw skills install-builtin")
|
||||||
|
fmt.Println(" picoclaw skills list-builtin")
|
||||||
|
fmt.Println(" picoclaw skills remove weather")
|
||||||
|
}
|
||||||
|
|
||||||
|
func skillsListCmd(loader *skills.SkillsLoader) {
|
||||||
|
allSkills := loader.ListSkills()
|
||||||
|
|
||||||
|
if len(allSkills) == 0 {
|
||||||
|
fmt.Println("No skills installed.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\nInstalled Skills:")
|
||||||
|
fmt.Println("------------------")
|
||||||
|
for _, skill := range allSkills {
|
||||||
|
fmt.Printf(" ✓ %s (%s)\n", skill.Name, skill.Source)
|
||||||
|
if skill.Description != "" {
|
||||||
|
fmt.Printf(" %s\n", skill.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func skillsInstallCmd(installer *skills.SkillInstaller) {
|
||||||
|
if len(os.Args) < 4 {
|
||||||
|
fmt.Println("Usage: picoclaw skills install <github-repo>")
|
||||||
|
fmt.Println("Example: picoclaw skills install sipeed/picoclaw-skills/weather")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := os.Args[3]
|
||||||
|
fmt.Printf("Installing skill from %s...\n", repo)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := installer.InstallFromGitHub(ctx, repo); err != nil {
|
||||||
|
fmt.Printf("✗ Failed to install skill: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✓ Skill '%s' installed successfully!\n", filepath.Base(repo))
|
||||||
|
}
|
||||||
|
|
||||||
|
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
|
||||||
|
fmt.Printf("Removing skill '%s'...\n", skillName)
|
||||||
|
|
||||||
|
if err := installer.Uninstall(skillName); err != nil {
|
||||||
|
fmt.Printf("✗ Failed to remove skill: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func skillsInstallBuiltinCmd(workspace string) {
|
||||||
|
builtinSkillsDir := "./picoclaw/skills"
|
||||||
|
workspaceSkillsDir := filepath.Join(workspace, "skills")
|
||||||
|
|
||||||
|
fmt.Printf("Copying builtin skills to workspace...\n")
|
||||||
|
|
||||||
|
skillsToInstall := []string{
|
||||||
|
"weather",
|
||||||
|
"news",
|
||||||
|
"stock",
|
||||||
|
"calculator",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, skillName := range skillsToInstall {
|
||||||
|
builtinPath := filepath.Join(builtinSkillsDir, skillName)
|
||||||
|
workspacePath := filepath.Join(workspaceSkillsDir, skillName)
|
||||||
|
|
||||||
|
if _, err := os.Stat(builtinPath); err != nil {
|
||||||
|
fmt.Printf("⊘ Builtin skill '%s' not found: %v\n", skillName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(workspacePath, 0755); err != nil {
|
||||||
|
fmt.Printf("✗ Failed to create directory for %s: %v\n", skillName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := copyDirectory(builtinPath, workspacePath); err != nil {
|
||||||
|
fmt.Printf("✗ Failed to copy %s: %v\n", skillName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n✓ All builtin skills installed!")
|
||||||
|
fmt.Println("Now you can use them in your workspace.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func skillsListBuiltinCmd() {
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error loading config: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
builtinSkillsDir := filepath.Join(filepath.Dir(cfg.WorkspacePath()), "picoclaw", "skills")
|
||||||
|
|
||||||
|
fmt.Println("\nAvailable Builtin Skills:")
|
||||||
|
fmt.Println("-----------------------")
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(builtinSkillsDir)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error reading builtin skills: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entries) == 0 {
|
||||||
|
fmt.Println("No builtin skills available.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
skillName := entry.Name()
|
||||||
|
skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md")
|
||||||
|
|
||||||
|
description := "No description"
|
||||||
|
if _, err := os.Stat(skillFile); err == nil {
|
||||||
|
data, err := os.ReadFile(skillFile)
|
||||||
|
if err == nil {
|
||||||
|
content := string(data)
|
||||||
|
if idx := strings.Index(content, "\n"); idx > 0 {
|
||||||
|
firstLine := content[:idx]
|
||||||
|
if strings.Contains(firstLine, "description:") {
|
||||||
|
descLine := strings.Index(content[idx:], "\n")
|
||||||
|
if descLine > 0 {
|
||||||
|
description = strings.TrimSpace(content[idx+descLine : idx+descLine])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status := "✓"
|
||||||
|
fmt.Printf(" %s %s\n", status, entry.Name())
|
||||||
|
if description != "" {
|
||||||
|
fmt.Printf(" %s\n", description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func skillsSearchCmd(installer *skills.SkillInstaller) {
|
||||||
|
fmt.Println("Searching for available skills...")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
availableSkills, err := installer.ListAvailableSkills(ctx)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("✗ Failed to fetch skills list: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(availableSkills) == 0 {
|
||||||
|
fmt.Println("No skills available.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills))
|
||||||
|
fmt.Println("--------------------")
|
||||||
|
for _, skill := range availableSkills {
|
||||||
|
fmt.Printf(" 📦 %s\n", skill.Name)
|
||||||
|
fmt.Printf(" %s\n", skill.Description)
|
||||||
|
fmt.Printf(" Repo: %s\n", skill.Repository)
|
||||||
|
if skill.Author != "" {
|
||||||
|
fmt.Printf(" Author: %s\n", skill.Author)
|
||||||
|
}
|
||||||
|
if len(skill.Tags) > 0 {
|
||||||
|
fmt.Printf(" Tags: %v\n", skill.Tags)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
|
||||||
|
content, ok := loader.LoadSkill(skillName)
|
||||||
|
if !ok {
|
||||||
|
fmt.Printf("✗ Skill '%s' not found\n", skillName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n📦 Skill: %s\n", skillName)
|
||||||
|
fmt.Println("----------------------")
|
||||||
|
fmt.Println(content)
|
||||||
|
}
|
||||||
102
cmd/picoclaw/cmd_status.go
Normal file
102
cmd/picoclaw/cmd_status.go
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func statusCmd() {
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error loading config: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := getConfigPath()
|
||||||
|
|
||||||
|
fmt.Printf("%s picoclaw Status\n", logo)
|
||||||
|
fmt.Printf("Version: %s\n", formatVersion())
|
||||||
|
build, _ := formatBuildInfo()
|
||||||
|
if build != "" {
|
||||||
|
fmt.Printf("Build: %s\n", build)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if _, err := os.Stat(configPath); err == nil {
|
||||||
|
fmt.Println("Config:", configPath, "✓")
|
||||||
|
} else {
|
||||||
|
fmt.Println("Config:", configPath, "✗")
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace := cfg.WorkspacePath()
|
||||||
|
if _, err := os.Stat(workspace); err == nil {
|
||||||
|
fmt.Println("Workspace:", workspace, "✓")
|
||||||
|
} else {
|
||||||
|
fmt.Println("Workspace:", workspace, "✗")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(configPath); err == nil {
|
||||||
|
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model)
|
||||||
|
|
||||||
|
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
|
||||||
|
hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
|
||||||
|
hasOpenAI := cfg.Providers.OpenAI.APIKey != ""
|
||||||
|
hasGemini := cfg.Providers.Gemini.APIKey != ""
|
||||||
|
hasZhipu := cfg.Providers.Zhipu.APIKey != ""
|
||||||
|
hasQwen := cfg.Providers.Qwen.APIKey != ""
|
||||||
|
hasGroq := cfg.Providers.Groq.APIKey != ""
|
||||||
|
hasVLLM := cfg.Providers.VLLM.APIBase != ""
|
||||||
|
hasMoonshot := cfg.Providers.Moonshot.APIKey != ""
|
||||||
|
hasDeepSeek := cfg.Providers.DeepSeek.APIKey != ""
|
||||||
|
hasVolcEngine := cfg.Providers.VolcEngine.APIKey != ""
|
||||||
|
hasNvidia := cfg.Providers.Nvidia.APIKey != ""
|
||||||
|
hasOllama := cfg.Providers.Ollama.APIBase != ""
|
||||||
|
|
||||||
|
status := func(enabled bool) string {
|
||||||
|
if enabled {
|
||||||
|
return "✓"
|
||||||
|
}
|
||||||
|
return "not set"
|
||||||
|
}
|
||||||
|
fmt.Println("OpenRouter API:", status(hasOpenRouter))
|
||||||
|
fmt.Println("Anthropic API:", status(hasAnthropic))
|
||||||
|
fmt.Println("OpenAI API:", status(hasOpenAI))
|
||||||
|
fmt.Println("Gemini API:", status(hasGemini))
|
||||||
|
fmt.Println("Zhipu API:", status(hasZhipu))
|
||||||
|
fmt.Println("Qwen API:", status(hasQwen))
|
||||||
|
fmt.Println("Groq API:", status(hasGroq))
|
||||||
|
fmt.Println("Moonshot API:", status(hasMoonshot))
|
||||||
|
fmt.Println("DeepSeek API:", status(hasDeepSeek))
|
||||||
|
fmt.Println("VolcEngine API:", status(hasVolcEngine))
|
||||||
|
fmt.Println("Nvidia API:", status(hasNvidia))
|
||||||
|
if hasVLLM {
|
||||||
|
fmt.Printf("vLLM/Local: ✓ %s\n", cfg.Providers.VLLM.APIBase)
|
||||||
|
} else {
|
||||||
|
fmt.Println("vLLM/Local: not set")
|
||||||
|
}
|
||||||
|
if hasOllama {
|
||||||
|
fmt.Printf("Ollama: ✓ %s\n", cfg.Providers.Ollama.APIBase)
|
||||||
|
} else {
|
||||||
|
fmt.Println("Ollama: not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
store, _ := auth.LoadStore()
|
||||||
|
if store != nil && len(store.Credentials) > 0 {
|
||||||
|
fmt.Println("\nOAuth/Token Auth:")
|
||||||
|
for provider, cred := range store.Credentials {
|
||||||
|
status := "authenticated"
|
||||||
|
if cred.IsExpired() {
|
||||||
|
status = "expired"
|
||||||
|
} else if cred.NeedsRefresh() {
|
||||||
|
status = "needs refresh"
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1233
cmd/picoclaw/main.go
1233
cmd/picoclaw/main.go
File diff suppressed because it is too large
Load diff
|
|
@ -3,12 +3,48 @@
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.picoclaw/workspace",
|
"workspace": "~/.picoclaw/workspace",
|
||||||
"restrict_to_workspace": true,
|
"restrict_to_workspace": true,
|
||||||
"model": "glm-4.7",
|
"model": "gpt4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20
|
"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_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_name": "gemini",
|
||||||
|
"model": "antigravity/gemini-2.0-flash",
|
||||||
|
"auth_method": "oauth"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "deepseek",
|
||||||
|
"model": "deepseek/deepseek-chat",
|
||||||
|
"api_key": "sk-your-deepseek-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "loadbalanced-gpt4",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_key": "sk-key1",
|
||||||
|
"api_base": "https://api1.example.com/v1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "loadbalanced-gpt4",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_key": "sk-key2",
|
||||||
|
"api_base": "https://api2.example.com/v1"
|
||||||
|
}
|
||||||
|
],
|
||||||
"channels": {
|
"channels": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|
@ -73,6 +109,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
"_comment": "DEPRECATED: Use model_list instead. This will be removed in a future version",
|
||||||
"anthropic": {
|
"anthropic": {
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
"api_base": ""
|
"api_base": ""
|
||||||
|
|
@ -111,9 +148,21 @@
|
||||||
"api_key": "sk-xxx",
|
"api_key": "sk-xxx",
|
||||||
"api_base": ""
|
"api_base": ""
|
||||||
},
|
},
|
||||||
|
"qwen": {
|
||||||
|
"api_key": "sk-xxx",
|
||||||
|
"api_base": ""
|
||||||
|
},
|
||||||
"ollama": {
|
"ollama": {
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
"api_base": "http://localhost:11434/v1"
|
"api_base": "http://localhost:11434/v1"
|
||||||
|
},
|
||||||
|
"cerebras": {
|
||||||
|
"api_key": "",
|
||||||
|
"api_base": ""
|
||||||
|
},
|
||||||
|
"volcengine": {
|
||||||
|
"api_key": "",
|
||||||
|
"api_base": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
|
||||||
1002
docs/ANTIGRAVITY_AUTH.md
Normal file
1002
docs/ANTIGRAVITY_AUTH.md
Normal file
File diff suppressed because it is too large
Load diff
72
docs/ANTIGRAVITY_USAGE.md
Normal file
72
docs/ANTIGRAVITY_USAGE.md
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
# Using Antigravity Provider in PicoClaw
|
||||||
|
|
||||||
|
This guide explains how to set up and use the **Antigravity** (Google Cloud Code Assist) provider in PicoClaw.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. A Google account.
|
||||||
|
2. Google Cloud Code Assist enabled (usually available via the "Gemini for Google Cloud" onboarding).
|
||||||
|
|
||||||
|
## 1. Authentication
|
||||||
|
|
||||||
|
To authenticate with Antigravity, run the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw auth login --provider antigravity
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Authentication (Headless/VPS)
|
||||||
|
If you are running on a server (Coolify/Docker) and cannot reach `localhost`, follow these steps:
|
||||||
|
1. Run the command above.
|
||||||
|
2. Copy the URL provided and open it in your local browser.
|
||||||
|
3. Complete the login.
|
||||||
|
4. Your browser will redirect to a `localhost:51121` URL (which will fail to load).
|
||||||
|
5. **Copy that final URL** from your browser's address bar.
|
||||||
|
6. **Paste it back into the terminal** where PicoClaw is waiting.
|
||||||
|
|
||||||
|
PicoClaw will extract the authorization code and complete the process automatically.
|
||||||
|
|
||||||
|
## 2. Managing Models
|
||||||
|
|
||||||
|
### List Available Models
|
||||||
|
To see which models your project has access to and check their quotas:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw auth models
|
||||||
|
```
|
||||||
|
|
||||||
|
### Switch Models
|
||||||
|
You can change the default model in `~/.picoclaw/config.json` or override it via the CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Override for a single command
|
||||||
|
picoclaw agent -m "Hello" --model claude-opus-4-6-thinking
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Real-world Usage (Coolify/Docker)
|
||||||
|
|
||||||
|
If you are deploying via Coolify or Docker, follow these steps to test:
|
||||||
|
|
||||||
|
1. **Branch**: Use the `feat/antigravity-provider` branch.
|
||||||
|
2. **Environment Variables**:
|
||||||
|
* `PICOCLAW_AGENTS_DEFAULTS_PROVIDER=antigravity`
|
||||||
|
* `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-3-flash`
|
||||||
|
3. **Authentication persistence**:
|
||||||
|
If you've logged in locally, you can copy your credentials to the server:
|
||||||
|
```bash
|
||||||
|
scp ~/.picoclaw/auth-profiles.json user@your-server:~/.picoclaw/
|
||||||
|
```
|
||||||
|
*Alternatively*, run the `auth login` command once on the server if you have terminal access.
|
||||||
|
|
||||||
|
## 4. Troubleshooting
|
||||||
|
|
||||||
|
* **Empty Response**: If a model returns an empty reply, it may be restricted for your project. Try `gemini-3-flash` or `claude-opus-4-6-thinking`.
|
||||||
|
* **429 Rate Limit**: Antigravity has strict quotas. PicoClaw will display the "reset time" in the error message if you hit a limit.
|
||||||
|
* **404 Not Found**: Ensure you are using a model ID from the `picoclaw auth models` list. Use the short ID (e.g., `gemini-3-flash`) not the full path.
|
||||||
|
|
||||||
|
## 5. Summary of Working Models
|
||||||
|
|
||||||
|
Based on testing, the following models are most reliable:
|
||||||
|
* `gemini-3-flash` (Fast, highly available)
|
||||||
|
* `gemini-2.5-flash-lite` (Lightweight)
|
||||||
|
* `claude-opus-4-6-thinking` (Powerful, includes reasoning)
|
||||||
179
docs/design/provider-refactoring-tests.md
Normal file
179
docs/design/provider-refactoring-tests.md
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
# Provider Architecture Refactoring - Test Suite Summary
|
||||||
|
|
||||||
|
> PRD: `tasks/prd-provider-refactoring.md`
|
||||||
|
|
||||||
|
This document summarizes the complete test suite designed for the Provider architecture refactoring.
|
||||||
|
|
||||||
|
## Test File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
pkg/
|
||||||
|
├── config/
|
||||||
|
│ ├── model_config_test.go # US-001, US-002: ModelConfig struct and GetModelConfig tests
|
||||||
|
│ └── migration_test.go # US-003: Backward compatibility and migration tests
|
||||||
|
├── providers/
|
||||||
|
│ ├── registry_test.go # US-006: Load balancing tests
|
||||||
|
│ ├── integration_test.go # E2E integration tests
|
||||||
|
│ └── factory/
|
||||||
|
│ └── factory_test.go # US-004, US-005: Provider factory tests
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Case Checklist
|
||||||
|
|
||||||
|
### 1. `pkg/config/model_config_test.go` - Configuration Parsing Tests
|
||||||
|
|
||||||
|
| Test Name | Purpose | PRD Reference |
|
||||||
|
|-----------|---------|---------------|
|
||||||
|
| `TestModelConfig_Parsing` | Verify ModelConfig JSON parsing | US-001 |
|
||||||
|
| `TestModelConfig_ModelListInConfig` | Verify model_list parsing in Config | US-001 |
|
||||||
|
| `TestModelConfig_Validation` | Verify required field validation | US-001 |
|
||||||
|
| `TestConfig_GetModelConfig_Found` | Verify GetModelConfig finds model | US-002 |
|
||||||
|
| `TestConfig_GetModelConfig_NotFound` | Verify GetModelConfig returns error | US-002 |
|
||||||
|
| `TestConfig_GetModelConfig_EmptyModelList` | Verify empty model_list handling | US-002 |
|
||||||
|
| `TestConfig_BackwardCompatibility_ProvidersToModelList` | Verify old config conversion | US-003 |
|
||||||
|
| `TestConfig_DeprecationWarning` | Verify deprecation warning | US-003 |
|
||||||
|
| `TestModelConfig_ProtocolExtraction` | Verify protocol prefix extraction | US-004 |
|
||||||
|
| `TestConfig_ModelNameUniqueness` | Verify model_name uniqueness | US-001 |
|
||||||
|
|
||||||
|
### 2. `pkg/config/migration_test.go` - Migration Tests
|
||||||
|
|
||||||
|
| Test Name | Purpose | PRD Reference |
|
||||||
|
|-----------|---------|---------------|
|
||||||
|
| `TestConvertProvidersToModelList_OpenAI` | OpenAI config conversion | US-003 |
|
||||||
|
| `TestConvertProvidersToModelList_Anthropic` | Anthropic config conversion | US-003 |
|
||||||
|
| `TestConvertProvidersToModelList_MultipleProviders` | Multiple provider conversion | US-003 |
|
||||||
|
| `TestConvertProvidersToModelList_EmptyProviders` | Empty providers handling | US-003 |
|
||||||
|
| `TestConvertProvidersToModelList_GitHubCopilot` | GitHub Copilot conversion | US-003 |
|
||||||
|
| `TestConvertProvidersToModelList_Antigravity` | Antigravity conversion | US-003 |
|
||||||
|
| `TestGenerateModelName_*` | Model name generation | US-003 |
|
||||||
|
| `TestHasProvidersConfig_*` | Detect old config existence | US-003 |
|
||||||
|
| `TestValidateMigration_*` | Migration validation | US-003 |
|
||||||
|
| `TestMigrateConfig_DryRun` | Dry run migration | US-003 |
|
||||||
|
| `TestMigrateConfig_Actual` | Actual migration | US-003 |
|
||||||
|
|
||||||
|
### 3. `pkg/providers/registry_test.go` - Load Balancing Tests
|
||||||
|
|
||||||
|
| Test Name | Purpose | PRD Reference |
|
||||||
|
|-----------|---------|---------------|
|
||||||
|
| `TestModelRegistry_SingleConfig` | Single config returns same result | US-006 |
|
||||||
|
| `TestModelRegistry_RoundRobinSelection` | 3-config round-robin selection | US-006 |
|
||||||
|
| `TestModelRegistry_RoundRobinTwoConfigs` | 2-config round-robin selection | US-006 |
|
||||||
|
| `TestModelRegistry_ConcurrentAccess` | Concurrent access thread safety | US-006 |
|
||||||
|
| `TestModelRegistry_RaceDetection` | Data race detection | US-006 |
|
||||||
|
| `TestModelRegistry_ModelNotFound` | Model not found error | US-006 |
|
||||||
|
| `TestModelRegistry_EmptyRegistry` | Empty registry handling | US-006 |
|
||||||
|
| `TestModelRegistry_MultipleModels` | Multiple model registration | US-006 |
|
||||||
|
| `TestModelRegistry_MixedSingleAndMultiple` | Single/multiple config mix | US-006 |
|
||||||
|
| `TestModelRegistry_CaseSensitiveModelNames` | Case sensitivity | US-006 |
|
||||||
|
|
||||||
|
### 4. `pkg/providers/factory/factory_test.go` - Provider Factory Tests
|
||||||
|
|
||||||
|
| Test Name | Purpose | PRD Reference |
|
||||||
|
|-----------|---------|---------------|
|
||||||
|
| `TestCreateProviderFromConfig_OpenAI` | Create OpenAI provider | US-004 |
|
||||||
|
| `TestCreateProviderFromConfig_OpenAIDefault` | Default openai protocol | US-004 |
|
||||||
|
| `TestCreateProviderFromConfig_Anthropic` | Create Anthropic provider | US-004 |
|
||||||
|
| `TestCreateProviderFromConfig_Antigravity` | Create Antigravity provider | US-004 |
|
||||||
|
| `TestCreateProviderFromConfig_ClaudeCLI` | Create Claude CLI provider | US-004 |
|
||||||
|
| `TestCreateProviderFromConfig_CodexCLI` | Create Codex CLI provider | US-004 |
|
||||||
|
| `TestCreateProviderFromConfig_GitHubCopilot` | Create GitHub Copilot provider | US-004 |
|
||||||
|
| `TestCreateProviderFromConfig_UnknownProtocol` | Unknown protocol error handling | US-004 |
|
||||||
|
| `TestCreateProviderFromConfig_MissingAPIKey` | Missing API key error | US-004 |
|
||||||
|
| `TestExtractProtocol` | Protocol prefix extraction | US-004 |
|
||||||
|
| `TestCreateProvider_UsesModelList` | Create using model_list | US-005 |
|
||||||
|
| `TestCreateProvider_FallbackToProviders` | Fallback to providers | US-005 |
|
||||||
|
| `TestCreateProvider_PriorityModelListOverProviders` | model_list priority | US-005 |
|
||||||
|
|
||||||
|
### 5. `pkg/providers/integration_test.go` - E2E Integration Tests
|
||||||
|
|
||||||
|
| Test Name | Purpose | PRD Reference |
|
||||||
|
|-----------|---------|---------------|
|
||||||
|
| `TestE2E_OpenAICompatibleProvider_NoCodeChange` | Zero-code provider addition | Goal |
|
||||||
|
| `TestE2E_LoadBalancing_RoundRobin` | Load balancing actual effect | US-006 |
|
||||||
|
| `TestE2E_BackwardCompatibility_OldProvidersConfig` | Old config compatibility | US-003 |
|
||||||
|
| `TestE2E_ErrorHandling_ModelNotFound` | Model not found | FR-30 |
|
||||||
|
| `TestE2E_ErrorHandling_MissingAPIKey` | Missing API key | FR-31 |
|
||||||
|
| `TestE2E_ErrorHandling_InvalidAPIBase` | Invalid API base | FR-30 |
|
||||||
|
| `TestE2E_ToolCalls_OpenAICompatible` | Tool call support | - |
|
||||||
|
| `TestE2E_AntigravityProvider` | Antigravity provider | US-004 |
|
||||||
|
| `TestE2E_ClaudeCLIProvider` | Claude CLI provider | US-004 |
|
||||||
|
|
||||||
|
### 6. Performance Tests
|
||||||
|
|
||||||
|
| Test Name | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `BenchmarkCreateProviderFromConfig` | Provider creation performance |
|
||||||
|
| `BenchmarkGetModelConfig` | Model lookup performance |
|
||||||
|
| `BenchmarkGetModelConfigParallel` | Concurrent lookup performance |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
go test ./pkg/... -v
|
||||||
|
|
||||||
|
# Run with data race detection
|
||||||
|
go test ./pkg/... -race
|
||||||
|
|
||||||
|
# Run specific package tests
|
||||||
|
go test ./pkg/config -v
|
||||||
|
go test ./pkg/providers -v
|
||||||
|
go test ./pkg/providers/factory -v
|
||||||
|
|
||||||
|
# Run E2E tests
|
||||||
|
go test ./pkg/providers -run TestE2E -v
|
||||||
|
|
||||||
|
# Run performance tests
|
||||||
|
go test ./pkg/providers -bench=. -benchmem
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PRD Acceptance Criteria Mapping
|
||||||
|
|
||||||
|
| PRD Acceptance Criteria | Test Cases |
|
||||||
|
|------------------------|------------|
|
||||||
|
| US-001: Add ModelConfig struct | `TestModelConfig_Parsing`, `TestModelConfig_Validation` |
|
||||||
|
| US-001: model_name unique | `TestConfig_ModelNameUniqueness` |
|
||||||
|
| US-002: GetModelConfig method | `TestConfig_GetModelConfig_*` |
|
||||||
|
| US-003: Auto-convert providers | `TestConvertProvidersToModelList_*` |
|
||||||
|
| US-003: Deprecation warning | `TestConfig_DeprecationWarning` |
|
||||||
|
| US-003: Existing tests pass | (existing test files unchanged) |
|
||||||
|
| US-004: Protocol prefix factory | `TestExtractProtocol`, `TestCreateProviderFromConfig_*` |
|
||||||
|
| US-004: Default prefix openai | `TestCreateProviderFromConfig_OpenAIDefault` |
|
||||||
|
| US-005: CreateProvider uses factory | `TestCreateProvider_*` |
|
||||||
|
| US-006: Round-robin selection | `TestModelRegistry_RoundRobin*` |
|
||||||
|
| US-006: Thread-safe atomic | `TestModelRegistry_RaceDetection` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Implementation Order
|
||||||
|
|
||||||
|
1. **Phase 1: Configuration Structure** (US-001, US-002)
|
||||||
|
- Implement `ModelConfig` struct
|
||||||
|
- Implement `GetModelConfig` method
|
||||||
|
- Run `model_config_test.go`
|
||||||
|
|
||||||
|
2. **Phase 2: Protocol Factory** (US-004)
|
||||||
|
- Implement `CreateProviderFromConfig`
|
||||||
|
- Implement `ExtractProtocol`
|
||||||
|
- Run `factory_test.go`
|
||||||
|
|
||||||
|
3. **Phase 3: Load Balancing** (US-006)
|
||||||
|
- Implement `ModelRegistry`
|
||||||
|
- Implement round-robin selection
|
||||||
|
- Run `registry_test.go` (with `-race`)
|
||||||
|
|
||||||
|
4. **Phase 4: Backward Compatibility** (US-003, US-005)
|
||||||
|
- Implement `ConvertProvidersToModelList`
|
||||||
|
- Refactor `CreateProvider`
|
||||||
|
- Run `migration_test.go`
|
||||||
|
- Verify existing tests pass
|
||||||
|
|
||||||
|
5. **Phase 5: E2E Verification**
|
||||||
|
- Run `integration_test.go`
|
||||||
|
- Manual testing with `config.example.json`
|
||||||
334
docs/design/provider-refactoring.md
Normal file
334
docs/design/provider-refactoring.md
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
# Provider Architecture Refactoring Design
|
||||||
|
|
||||||
|
> Issue: #283
|
||||||
|
> Discussion: #122
|
||||||
|
> Branch: feat/refactor-provider-by-protocol
|
||||||
|
|
||||||
|
## 1. Current Problems
|
||||||
|
|
||||||
|
### 1.1 Configuration Structure Issues
|
||||||
|
|
||||||
|
**Current State**: Each Provider requires a predefined field in `ProvidersConfig`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ProvidersConfig struct {
|
||||||
|
Anthropic ProviderConfig `json:"anthropic"`
|
||||||
|
OpenAI ProviderConfig `json:"openai"`
|
||||||
|
DeepSeek ProviderConfig `json:"deepseek"`
|
||||||
|
Qwen ProviderConfig `json:"qwen"`
|
||||||
|
Cerebras ProviderConfig `json:"cerebras"`
|
||||||
|
VolcEngine ProviderConfig `json:"volcengine"`
|
||||||
|
// ... every new provider requires changes here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problems**:
|
||||||
|
- Adding a new Provider requires modifying Go code (struct definition)
|
||||||
|
- `CreateProvider` function in `http_provider.go` has 200+ lines of switch-case
|
||||||
|
- Most Providers are OpenAI-compatible, but code is duplicated
|
||||||
|
|
||||||
|
### 1.2 Code Bloat Trend
|
||||||
|
|
||||||
|
Recent PRs demonstrate this issue:
|
||||||
|
|
||||||
|
| PR | Provider | Code Changes |
|
||||||
|
|----|----------|--------------|
|
||||||
|
| #365 | Qwen | +17 lines to http_provider.go |
|
||||||
|
| #333 | Cerebras | +17 lines to http_provider.go |
|
||||||
|
| #368 | Volcengine | +18 lines to http_provider.go |
|
||||||
|
|
||||||
|
Each OpenAI-compatible Provider requires:
|
||||||
|
1. Modify `config.go` to add configuration field
|
||||||
|
2. Modify `http_provider.go` to add switch case
|
||||||
|
3. Update documentation
|
||||||
|
|
||||||
|
### 1.3 Agent-Provider Coupling
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "deepseek", // need to know provider name
|
||||||
|
"model": "deepseek-chat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Problem: Agent needs to know both `provider` and `model`, adding complexity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. New Approach: model_list
|
||||||
|
|
||||||
|
### 2.1 Core Principles
|
||||||
|
|
||||||
|
Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
|
||||||
|
|
||||||
|
1. **Model-centric**: Users care about models, not providers
|
||||||
|
2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.2`, `anthropic/claude-sonnet-4.6`
|
||||||
|
3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes
|
||||||
|
|
||||||
|
### 2.2 New Configuration Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "deepseek-chat",
|
||||||
|
"model": "openai/deepseek-chat",
|
||||||
|
"api_base": "https://api.deepseek.com/v1",
|
||||||
|
"api_key": "sk-xxx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_key": "sk-xxx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
|
"api_key": "sk-xxx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gemini-3-flash",
|
||||||
|
"model": "antigravity/gemini-3-flash",
|
||||||
|
"auth_method": "oauth"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "my-company-llm",
|
||||||
|
"model": "openai/company-model-v1",
|
||||||
|
"api_base": "https://llm.company.com/v1",
|
||||||
|
"api_key": "xxx"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "deepseek-chat",
|
||||||
|
"max_tokens": 8192,
|
||||||
|
"temperature": 0.7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Go Struct Definition
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Config struct {
|
||||||
|
ModelList []ModelConfig `json:"model_list"` // new
|
||||||
|
Providers ProvidersConfig `json:"providers"` // old, deprecated
|
||||||
|
|
||||||
|
Agents AgentsConfig `json:"agents"`
|
||||||
|
Channels ChannelsConfig `json:"channels"`
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelConfig struct {
|
||||||
|
// Required
|
||||||
|
ModelName string `json:"model_name"` // user-facing name (alias)
|
||||||
|
Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.2
|
||||||
|
|
||||||
|
// Common config
|
||||||
|
APIBase string `json:"api_base,omitempty"`
|
||||||
|
APIKey string `json:"api_key,omitempty"`
|
||||||
|
Proxy string `json:"proxy,omitempty"`
|
||||||
|
|
||||||
|
// Special provider config
|
||||||
|
AuthMethod string `json:"auth_method,omitempty"` // oauth, token
|
||||||
|
ConnectMode string `json:"connect_mode,omitempty"` // stdio, grpc
|
||||||
|
|
||||||
|
// Optional optimizations
|
||||||
|
RPM int `json:"rpm,omitempty"` // rate limit
|
||||||
|
MaxTokensField string `json:"max_tokens_field,omitempty"` // max_tokens or max_completion_tokens
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Protocol Recognition
|
||||||
|
|
||||||
|
Identify protocol via prefix in `model` field:
|
||||||
|
|
||||||
|
| Prefix | Protocol | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `openai/` | OpenAI-compatible | Most common, includes DeepSeek, Qwen, Groq, etc. |
|
||||||
|
| `anthropic/` | Anthropic | Claude series specific |
|
||||||
|
| `antigravity/` | Antigravity | Google Cloud Code Assist |
|
||||||
|
| `gemini/` | Gemini | Google Gemini native API (if needed) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Design Rationale
|
||||||
|
|
||||||
|
### 3.1 Problems Solved
|
||||||
|
|
||||||
|
| Problem | Old Approach | New Approach |
|
||||||
|
|---------|--------------|--------------|
|
||||||
|
| Add OpenAI-compatible Provider | Change 3 code locations | Add one config entry |
|
||||||
|
| Agent specifies model | Need provider + model | Only need model |
|
||||||
|
| Code duplication | Each Provider duplicates logic | Share protocol implementation |
|
||||||
|
| Multi-Agent support | Complex | Naturally compatible |
|
||||||
|
|
||||||
|
### 3.2 Multi-Agent Compatibility
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [...],
|
||||||
|
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "deepseek-chat"
|
||||||
|
},
|
||||||
|
"coder": {
|
||||||
|
"model": "gpt-5.2",
|
||||||
|
"system_prompt": "You are a coding assistant..."
|
||||||
|
},
|
||||||
|
"translator": {
|
||||||
|
"model": "claude-sonnet-4.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each Agent only needs to specify `model` (corresponds to `model_name` in `model_list`).
|
||||||
|
|
||||||
|
### 3.3 Industry Comparison
|
||||||
|
|
||||||
|
**LiteLLM** (most mature open-source LLM Proxy) uses similar design:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
- model_name: gpt-4o
|
||||||
|
litellm_params:
|
||||||
|
model: openai/gpt-5.2
|
||||||
|
api_key: xxx
|
||||||
|
- model_name: my-custom
|
||||||
|
litellm_params:
|
||||||
|
model: openai/custom-model
|
||||||
|
api_base: https://my-api.com/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Migration Plan
|
||||||
|
|
||||||
|
### 4.1 Phase 1: Compatibility Period (v1.x)
|
||||||
|
|
||||||
|
Support both `providers` and `model_list`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
|
||||||
|
// Prefer new config
|
||||||
|
if len(c.ModelList) > 0 {
|
||||||
|
return c.findModelByName(modelName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatibility with old config
|
||||||
|
if !c.Providers.IsEmpty() {
|
||||||
|
logger.Warn("'providers' config is deprecated, please migrate to 'model_list'")
|
||||||
|
return c.convertFromProviders(modelName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("model %s not found", modelName)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Phase 2: Warning Period (late v1.x)
|
||||||
|
|
||||||
|
- Print more prominent warnings at startup
|
||||||
|
- Provide automatic migration script
|
||||||
|
- Mark `providers` as deprecated in documentation
|
||||||
|
|
||||||
|
### 4.3 Phase 3: Removal Period (v2.0)
|
||||||
|
|
||||||
|
- Completely remove `providers` support
|
||||||
|
- Remove `agents.defaults.provider` field
|
||||||
|
- Only support `model_list`
|
||||||
|
|
||||||
|
### 4.4 Configuration Migration Example
|
||||||
|
|
||||||
|
**Old Config**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"deepseek": {
|
||||||
|
"api_key": "sk-xxx",
|
||||||
|
"api_base": "https://api.deepseek.com/v1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "deepseek",
|
||||||
|
"model": "deepseek-chat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**New Config**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "deepseek-chat",
|
||||||
|
"model": "openai/deepseek-chat",
|
||||||
|
"api_base": "https://api.deepseek.com/v1",
|
||||||
|
"api_key": "sk-xxx"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "deepseek-chat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Implementation Checklist
|
||||||
|
|
||||||
|
### 5.1 Configuration Layer
|
||||||
|
|
||||||
|
- [ ] Add `ModelConfig` struct
|
||||||
|
- [ ] Add `Config.ModelList` field
|
||||||
|
- [ ] Implement `GetModelConfig(modelName)` method
|
||||||
|
- [ ] Implement old config compatibility conversion
|
||||||
|
- [ ] Add `model_name` uniqueness validation
|
||||||
|
|
||||||
|
### 5.2 Provider Layer
|
||||||
|
|
||||||
|
- [ ] Create `pkg/providers/factory/` directory
|
||||||
|
- [ ] Implement `CreateProviderFromModelConfig()`
|
||||||
|
- [ ] Refactor `http_provider.go` to `openai/provider.go`
|
||||||
|
- [ ] Maintain backward compatibility for old `CreateProvider()`
|
||||||
|
|
||||||
|
### 5.3 Testing
|
||||||
|
|
||||||
|
- [ ] New config unit tests
|
||||||
|
- [ ] Old config compatibility tests
|
||||||
|
- [ ] Integration tests
|
||||||
|
|
||||||
|
### 5.4 Documentation
|
||||||
|
|
||||||
|
- [ ] Update README
|
||||||
|
- [ ] Update config.example.json
|
||||||
|
- [ ] Write migration guide
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Risks and Mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| Breaking existing configs | Compatibility period keeps old config working |
|
||||||
|
| User migration cost | Provide automatic migration script |
|
||||||
|
| Special Provider incompatibility | Keep `auth_method` and other extension fields |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. References
|
||||||
|
|
||||||
|
- [LiteLLM Config Documentation](https://docs.litellm.ai/docs/proxy/configs)
|
||||||
|
- [One-API GitHub](https://github.com/songquanpeng/one-api)
|
||||||
|
- Discussion #122: Refactor Provider Architecture
|
||||||
211
docs/migration/model-list-migration.md
Normal file
211
docs/migration/model-list-migration.md
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
# Migration Guide: From `providers` to `model_list`
|
||||||
|
|
||||||
|
This guide explains how to migrate from the legacy `providers` configuration to the new `model_list` format.
|
||||||
|
|
||||||
|
## Why Migrate?
|
||||||
|
|
||||||
|
The new `model_list` configuration offers several advantages:
|
||||||
|
|
||||||
|
- **Zero-code provider addition**: Add OpenAI-compatible providers with configuration only
|
||||||
|
- **Load balancing**: Configure multiple endpoints for the same model
|
||||||
|
- **Protocol-based routing**: Use prefixes like `openai/`, `anthropic/`, etc.
|
||||||
|
- **Cleaner configuration**: Model-centric instead of vendor-centric
|
||||||
|
|
||||||
|
## Timeline
|
||||||
|
|
||||||
|
| Version | Status |
|
||||||
|
|---------|--------|
|
||||||
|
| v1.x | `model_list` introduced, `providers` deprecated but functional |
|
||||||
|
| v1.x+1 | Prominent deprecation warnings, migration tool available |
|
||||||
|
| v2.0 | `providers` configuration removed |
|
||||||
|
|
||||||
|
## Before and After
|
||||||
|
|
||||||
|
### Before: Legacy `providers` Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"openai": {
|
||||||
|
"api_key": "sk-your-openai-key",
|
||||||
|
"api_base": "https://api.openai.com/v1"
|
||||||
|
},
|
||||||
|
"anthropic": {
|
||||||
|
"api_key": "sk-ant-your-key"
|
||||||
|
},
|
||||||
|
"deepseek": {
|
||||||
|
"api_key": "sk-your-deepseek-key"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "gpt-5.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### After: New `model_list` Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt4",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_key": "sk-your-openai-key",
|
||||||
|
"api_base": "https://api.openai.com/v1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
|
"api_key": "sk-ant-your-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "deepseek",
|
||||||
|
"model": "deepseek/deepseek-chat",
|
||||||
|
"api_key": "sk-your-deepseek-key"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "gpt4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Protocol Prefixes
|
||||||
|
|
||||||
|
The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
|
||||||
|
|
||||||
|
| Prefix | Description | Example |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `openai/` | OpenAI API (default) | `openai/gpt-5.2` |
|
||||||
|
| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |
|
||||||
|
| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |
|
||||||
|
| `claude-cli/` | Claude CLI (local) | `claude-cli/claude-sonnet-4.6` |
|
||||||
|
| `codex-cli/` | Codex CLI (local) | `codex-cli/codex-4` |
|
||||||
|
| `github-copilot/` | GitHub Copilot | `github-copilot/gpt-4o` |
|
||||||
|
| `openrouter/` | OpenRouter | `openrouter/anthropic/claude-sonnet-4.6` |
|
||||||
|
| `groq/` | Groq API | `groq/llama-3.1-70b` |
|
||||||
|
| `deepseek/` | DeepSeek API | `deepseek/deepseek-chat` |
|
||||||
|
| `cerebras/` | Cerebras API | `cerebras/llama-3.3-70b` |
|
||||||
|
| `qwen/` | Alibaba Qwen | `qwen/qwen-max` |
|
||||||
|
|
||||||
|
**Note**: If no prefix is specified, `openai/` is used as the default.
|
||||||
|
|
||||||
|
## ModelConfig Fields
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `model_name` | Yes | User-facing alias for the model |
|
||||||
|
| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.2`) |
|
||||||
|
| `api_base` | No | API endpoint URL |
|
||||||
|
| `api_key` | No* | API authentication key |
|
||||||
|
| `proxy` | No | HTTP proxy URL |
|
||||||
|
| `auth_method` | No | Authentication method: `oauth`, `token` |
|
||||||
|
| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |
|
||||||
|
| `rpm` | No | Requests per minute limit |
|
||||||
|
| `max_tokens_field` | No | Field name for max tokens |
|
||||||
|
|
||||||
|
*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server.
|
||||||
|
|
||||||
|
## Load Balancing
|
||||||
|
|
||||||
|
Configure multiple endpoints for the same model to distribute load:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt4",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_key": "sk-key1",
|
||||||
|
"api_base": "https://api1.example.com/v1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt4",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_key": "sk-key2",
|
||||||
|
"api_base": "https://api2.example.com/v1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt4",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"api_key": "sk-key3",
|
||||||
|
"api_base": "https://api3.example.com/v1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When you request model `gpt4`, requests will be distributed across all three endpoints using round-robin selection.
|
||||||
|
|
||||||
|
## Adding a New OpenAI-Compatible Provider
|
||||||
|
|
||||||
|
With `model_list`, adding a new provider requires zero code changes:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-llm",
|
||||||
|
"model": "openai/my-model-v1",
|
||||||
|
"api_key": "your-api-key",
|
||||||
|
"api_base": "https://api.your-provider.com/v1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Just specify `openai/` as the protocol (or omit it for the default), and provide your provider's API base URL.
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
During the migration period, your existing `providers` configuration will continue to work:
|
||||||
|
|
||||||
|
1. If `model_list` is empty and `providers` has data, the system auto-converts internally
|
||||||
|
2. A deprecation warning is logged: `"providers config is deprecated, please migrate to model_list"`
|
||||||
|
3. All existing functionality remains unchanged
|
||||||
|
|
||||||
|
## Migration Checklist
|
||||||
|
|
||||||
|
- [ ] Identify all providers you're currently using
|
||||||
|
- [ ] Create `model_list` entries for each provider
|
||||||
|
- [ ] Use appropriate protocol prefixes
|
||||||
|
- [ ] Update `agents.defaults.model` to reference the new `model_name`
|
||||||
|
- [ ] Test that all models work correctly
|
||||||
|
- [ ] Remove or comment out the old `providers` section
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Model not found error
|
||||||
|
|
||||||
|
```
|
||||||
|
model "xxx" not found in model_list or providers
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution**: Ensure the `model_name` in `model_list` matches the value in `agents.defaults.model`.
|
||||||
|
|
||||||
|
### Unknown protocol error
|
||||||
|
|
||||||
|
```
|
||||||
|
unknown protocol "xxx" in model "xxx/model-name"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution**: Use a supported protocol prefix. See the [Protocol Prefixes](#protocol-prefixes) table above.
|
||||||
|
|
||||||
|
### Missing API key error
|
||||||
|
|
||||||
|
```
|
||||||
|
api_key or api_base is required for HTTP-based protocol "xxx"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution**: Provide `api_key` and/or `api_base` for HTTP-based providers.
|
||||||
|
|
||||||
|
## Need Help?
|
||||||
|
|
||||||
|
- [GitHub Issues](https://github.com/sipeed/picoclaw/issues)
|
||||||
|
- [Discussion #122](https://github.com/sipeed/picoclaw/discussions/122): Original proposal
|
||||||
|
|
@ -189,16 +189,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
|
||||||
systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary
|
systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary
|
||||||
}
|
}
|
||||||
|
|
||||||
//This fix prevents the session memory from LLM failure due to elimination of toolu_IDs required from LLM
|
history = sanitizeHistoryForProvider(history)
|
||||||
// --- INICIO DEL FIX ---
|
|
||||||
//Diegox-17
|
|
||||||
for len(history) > 0 && (history[0].Role == "tool") {
|
|
||||||
logger.DebugCF("agent", "Removing orphaned tool message from history to prevent LLM error",
|
|
||||||
map[string]interface{}{"role": history[0].Role})
|
|
||||||
history = history[1:]
|
|
||||||
}
|
|
||||||
//Diegox-17
|
|
||||||
// --- FIN DEL FIX ---
|
|
||||||
|
|
||||||
messages = append(messages, providers.Message{
|
messages = append(messages, providers.Message{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
|
|
@ -207,14 +198,58 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
|
||||||
|
|
||||||
messages = append(messages, history...)
|
messages = append(messages, history...)
|
||||||
|
|
||||||
|
if strings.TrimSpace(currentMessage) != "" {
|
||||||
messages = append(messages, providers.Message{
|
messages = append(messages, providers.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: currentMessage,
|
Content: currentMessage,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sanitizeHistoryForProvider(history []providers.Message) []providers.Message {
|
||||||
|
if len(history) == 0 {
|
||||||
|
return history
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized := make([]providers.Message, 0, len(history))
|
||||||
|
for _, msg := range history {
|
||||||
|
switch msg.Role {
|
||||||
|
case "tool":
|
||||||
|
if len(sanitized) == 0 {
|
||||||
|
logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]interface{}{})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
last := sanitized[len(sanitized)-1]
|
||||||
|
if last.Role != "assistant" || len(last.ToolCalls) == 0 {
|
||||||
|
logger.DebugCF("agent", "Dropping orphaned tool message", map[string]interface{}{})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sanitized = append(sanitized, msg)
|
||||||
|
|
||||||
|
case "assistant":
|
||||||
|
if len(msg.ToolCalls) > 0 {
|
||||||
|
if len(sanitized) == 0 {
|
||||||
|
logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]interface{}{})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prev := sanitized[len(sanitized)-1]
|
||||||
|
if prev.Role != "user" && prev.Role != "tool" {
|
||||||
|
logger.DebugCF("agent", "Dropping assistant tool-call turn with invalid predecessor", map[string]interface{}{"prev_role": prev.Role})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sanitized = append(sanitized, msg)
|
||||||
|
|
||||||
|
default:
|
||||||
|
sanitized = append(sanitized, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID, toolName, result string) []providers.Message {
|
func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID, toolName, result string) []providers.Message {
|
||||||
messages = append(messages, providers.Message{
|
messages = append(messages, providers.Message{
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ type AgentInstance struct {
|
||||||
Fallbacks []string
|
Fallbacks []string
|
||||||
Workspace string
|
Workspace string
|
||||||
MaxIterations int
|
MaxIterations int
|
||||||
|
MaxTokens int
|
||||||
|
Temperature float64
|
||||||
ContextWindow int
|
ContextWindow int
|
||||||
Provider providers.LLMProvider
|
Provider providers.LLMProvider
|
||||||
Sessions *session.SessionManager
|
Sessions *session.SessionManager
|
||||||
|
|
@ -76,6 +78,16 @@ func NewAgentInstance(
|
||||||
maxIter = 20
|
maxIter = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maxTokens := defaults.MaxTokens
|
||||||
|
if maxTokens == 0 {
|
||||||
|
maxTokens = 8192
|
||||||
|
}
|
||||||
|
|
||||||
|
temperature := 0.7
|
||||||
|
if defaults.Temperature != nil {
|
||||||
|
temperature = *defaults.Temperature
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve fallback candidates
|
// Resolve fallback candidates
|
||||||
modelCfg := providers.ModelConfig{
|
modelCfg := providers.ModelConfig{
|
||||||
Primary: model,
|
Primary: model,
|
||||||
|
|
@ -90,7 +102,9 @@ func NewAgentInstance(
|
||||||
Fallbacks: fallbacks,
|
Fallbacks: fallbacks,
|
||||||
Workspace: workspace,
|
Workspace: workspace,
|
||||||
MaxIterations: maxIter,
|
MaxIterations: maxIter,
|
||||||
ContextWindow: defaults.MaxTokens,
|
MaxTokens: maxTokens,
|
||||||
|
Temperature: temperature,
|
||||||
|
ContextWindow: maxTokens,
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
Sessions: sessionsManager,
|
Sessions: sessionsManager,
|
||||||
ContextBuilder: contextBuilder,
|
ContextBuilder: contextBuilder,
|
||||||
|
|
|
||||||
95
pkg/agent/instance_test.go
Normal file
95
pkg/agent/instance_test.go
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 1234,
|
||||||
|
MaxToolIterations: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
configuredTemp := 1.0
|
||||||
|
cfg.Agents.Defaults.Temperature = &configuredTemp
|
||||||
|
|
||||||
|
provider := &mockProvider{}
|
||||||
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
|
||||||
|
if agent.MaxTokens != 1234 {
|
||||||
|
t.Fatalf("MaxTokens = %d, want %d", agent.MaxTokens, 1234)
|
||||||
|
}
|
||||||
|
if agent.Temperature != 1.0 {
|
||||||
|
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAgentInstance_DefaultsTemperatureWhenZero(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 1234,
|
||||||
|
MaxToolIterations: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
configuredTemp := 0.0
|
||||||
|
cfg.Agents.Defaults.Temperature = &configuredTemp
|
||||||
|
|
||||||
|
provider := &mockProvider{}
|
||||||
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
|
||||||
|
if agent.Temperature != 0.0 {
|
||||||
|
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 1234,
|
||||||
|
MaxToolIterations: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := &mockProvider{}
|
||||||
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
|
||||||
|
if agent.Temperature != 0.7 {
|
||||||
|
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -119,6 +119,7 @@ func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *A
|
||||||
|
|
||||||
// Spawn tool with allowlist checker
|
// Spawn tool with allowlist checker
|
||||||
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus)
|
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus)
|
||||||
|
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
||||||
spawnTool := tools.NewSpawnTool(subagentManager)
|
spawnTool := tools.NewSpawnTool(subagentManager)
|
||||||
currentAgentID := agentID
|
currentAgentID := agentID
|
||||||
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
||||||
|
|
@ -470,8 +471,8 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
"model": agent.Model,
|
"model": agent.Model,
|
||||||
"messages_count": len(messages),
|
"messages_count": len(messages),
|
||||||
"tools_count": len(providerToolDefs),
|
"tools_count": len(providerToolDefs),
|
||||||
"max_tokens": 8192,
|
"max_tokens": agent.MaxTokens,
|
||||||
"temperature": 0.7,
|
"temperature": agent.Temperature,
|
||||||
"system_prompt_len": len(messages[0].Content),
|
"system_prompt_len": len(messages[0].Content),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -492,8 +493,8 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates,
|
fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates,
|
||||||
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
|
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
|
||||||
return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]interface{}{
|
return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]interface{}{
|
||||||
"max_tokens": 8192,
|
"max_tokens": agent.MaxTokens,
|
||||||
"temperature": 0.7,
|
"temperature": agent.Temperature,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -508,8 +509,8 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
return fbResult.Response, nil
|
return fbResult.Response, nil
|
||||||
}
|
}
|
||||||
return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]interface{}{
|
return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]interface{}{
|
||||||
"max_tokens": 8192,
|
"max_tokens": agent.MaxTokens,
|
||||||
"temperature": 0.7,
|
"temperature": agent.Temperature,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -575,16 +576,21 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log tool calls
|
normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls))
|
||||||
toolNames := make([]string, 0, len(response.ToolCalls))
|
|
||||||
for _, tc := range response.ToolCalls {
|
for _, tc := range response.ToolCalls {
|
||||||
|
normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log tool calls
|
||||||
|
toolNames := make([]string, 0, len(normalizedToolCalls))
|
||||||
|
for _, tc := range normalizedToolCalls {
|
||||||
toolNames = append(toolNames, tc.Name)
|
toolNames = append(toolNames, tc.Name)
|
||||||
}
|
}
|
||||||
logger.InfoCF("agent", "LLM requested tool calls",
|
logger.InfoCF("agent", "LLM requested tool calls",
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
"tools": toolNames,
|
"tools": toolNames,
|
||||||
"count": len(response.ToolCalls),
|
"count": len(normalizedToolCalls),
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -593,16 +599,26 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: response.Content,
|
Content: response.Content,
|
||||||
}
|
}
|
||||||
for _, tc := range response.ToolCalls {
|
for _, tc := range normalizedToolCalls {
|
||||||
argumentsJSON, _ := json.Marshal(tc.Arguments)
|
argumentsJSON, _ := json.Marshal(tc.Arguments)
|
||||||
|
// Copy ExtraContent to ensure thought_signature is persisted for Gemini 3
|
||||||
|
extraContent := tc.ExtraContent
|
||||||
|
thoughtSignature := ""
|
||||||
|
if tc.Function != nil {
|
||||||
|
thoughtSignature = tc.Function.ThoughtSignature
|
||||||
|
}
|
||||||
|
|
||||||
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{
|
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{
|
||||||
ID: tc.ID,
|
ID: tc.ID,
|
||||||
Type: "function",
|
Type: "function",
|
||||||
|
Name: tc.Name,
|
||||||
Function: &providers.FunctionCall{
|
Function: &providers.FunctionCall{
|
||||||
Name: tc.Name,
|
Name: tc.Name,
|
||||||
Arguments: string(argumentsJSON),
|
Arguments: string(argumentsJSON),
|
||||||
|
ThoughtSignature: thoughtSignature,
|
||||||
},
|
},
|
||||||
Name: tc.Name,
|
ExtraContent: extraContent,
|
||||||
|
ThoughtSignature: thoughtSignature,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
messages = append(messages, assistantMsg)
|
messages = append(messages, assistantMsg)
|
||||||
|
|
@ -611,7 +627,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
|
agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
|
||||||
|
|
||||||
// Execute tool calls
|
// Execute tool calls
|
||||||
for _, tc := range response.ToolCalls {
|
for _, tc := range normalizedToolCalls {
|
||||||
argsJSON, _ := json.Marshal(tc.Arguments)
|
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||||
argsPreview := utils.Truncate(string(argsJSON), 200)
|
argsPreview := utils.Truncate(string(argsJSON), 200)
|
||||||
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
|
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
|
||||||
|
|
@ -738,31 +754,21 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
|
||||||
mid := len(conversation) / 2
|
mid := len(conversation) / 2
|
||||||
|
|
||||||
// New history structure:
|
// New history structure:
|
||||||
// 1. System Prompt
|
// 1. System Prompt (with compression note appended)
|
||||||
// 2. [Summary of dropped part] - synthesized
|
// 2. Second half of conversation
|
||||||
// 3. Second half of conversation
|
// 3. Last message
|
||||||
// 4. Last message
|
|
||||||
|
|
||||||
// Simplified approach for emergency: Drop first half of conversation
|
|
||||||
// and rely on existing summary if present, or create a placeholder.
|
|
||||||
|
|
||||||
droppedCount := mid
|
droppedCount := mid
|
||||||
keptConversation := conversation[mid:]
|
keptConversation := conversation[mid:]
|
||||||
|
|
||||||
newHistory := make([]providers.Message, 0)
|
newHistory := make([]providers.Message, 0)
|
||||||
newHistory = append(newHistory, history[0]) // System prompt
|
|
||||||
|
|
||||||
// Add a note about compression
|
// Append compression note to the original system prompt instead of adding a new system message
|
||||||
compressionNote := fmt.Sprintf("[System: Emergency compression dropped %d oldest messages due to context limit]", droppedCount)
|
// This avoids having two consecutive system messages which some APIs (like Zhipu) reject
|
||||||
// If there was an existing summary, we might lose it if it was in the dropped part (which is just messages).
|
compressionNote := fmt.Sprintf("\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]", droppedCount)
|
||||||
// The summary is stored separately in session.Summary, so it persists!
|
enhancedSystemPrompt := history[0]
|
||||||
// We just need to ensure the user knows there's a gap.
|
enhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote
|
||||||
|
newHistory = append(newHistory, enhancedSystemPrompt)
|
||||||
// We only modify the messages list here
|
|
||||||
newHistory = append(newHistory, providers.Message{
|
|
||||||
Role: "system",
|
|
||||||
Content: compressionNote,
|
|
||||||
})
|
|
||||||
|
|
||||||
newHistory = append(newHistory, keptConversation...)
|
newHistory = append(newHistory, keptConversation...)
|
||||||
newHistory = append(newHistory, history[len(history)-1]) // Last message
|
newHistory = append(newHistory, history[len(history)-1]) // Last message
|
||||||
|
|
|
||||||
|
|
@ -14,20 +14,6 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mockProvider is a simple mock LLM provider for testing
|
|
||||||
type mockProvider struct{}
|
|
||||||
|
|
||||||
func (m *mockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
|
|
||||||
return &providers.LLMResponse{
|
|
||||||
Content: "Mock response",
|
|
||||||
ToolCalls: []providers.ToolCall{},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockProvider) GetDefaultModel() string {
|
|
||||||
return "mock-model"
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecordLastChannel(t *testing.T) {
|
func TestRecordLastChannel(t *testing.T) {
|
||||||
// Create temp workspace
|
// Create temp workspace
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
|
@ -603,7 +589,6 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
||||||
// Call ProcessDirectWithChannel
|
// Call ProcessDirectWithChannel
|
||||||
// Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration
|
// Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration
|
||||||
response, err := al.ProcessDirectWithChannel(context.Background(), "Trigger message", sessionKey, "test", "test-chat")
|
response, err := al.ProcessDirectWithChannel(context.Background(), "Trigger message", sessionKey, "test", "test-chat")
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Expected success after retry, got error: %v", err)
|
t.Fatalf("Expected success after retry, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
pkg/agent/mock_provider_test.go
Normal file
20
pkg/agent/mock_provider_test.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockProvider struct{}
|
||||||
|
|
||||||
|
func (m *mockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
|
||||||
|
return &providers.LLMResponse{
|
||||||
|
Content: "Mock response",
|
||||||
|
ToolCalls: []providers.ToolCall{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockProvider) GetDefaultModel() string {
|
||||||
|
return "mock-model"
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
|
@ -11,6 +12,7 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
@ -21,6 +23,8 @@ import (
|
||||||
type OAuthProviderConfig struct {
|
type OAuthProviderConfig struct {
|
||||||
Issuer string
|
Issuer string
|
||||||
ClientID string
|
ClientID string
|
||||||
|
ClientSecret string // Required for Google OAuth (confidential client)
|
||||||
|
TokenURL string // Override token endpoint (Google uses a different URL than issuer)
|
||||||
Scopes string
|
Scopes string
|
||||||
Originator string
|
Originator string
|
||||||
Port int
|
Port int
|
||||||
|
|
@ -36,6 +40,30 @@ func OpenAIOAuthConfig() OAuthProviderConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GoogleAntigravityOAuthConfig returns the OAuth configuration for Google Cloud Code Assist (Antigravity).
|
||||||
|
// Client credentials are the same ones used by OpenCode/pi-ai for Cloud Code Assist access.
|
||||||
|
func GoogleAntigravityOAuthConfig() OAuthProviderConfig {
|
||||||
|
// These are the same client credentials used by the OpenCode antigravity plugin.
|
||||||
|
clientID := decodeBase64("MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==")
|
||||||
|
clientSecret := decodeBase64("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=")
|
||||||
|
return OAuthProviderConfig{
|
||||||
|
Issuer: "https://accounts.google.com/o/oauth2/v2",
|
||||||
|
TokenURL: "https://oauth2.googleapis.com/token",
|
||||||
|
ClientID: clientID,
|
||||||
|
ClientSecret: clientSecret,
|
||||||
|
Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/cclog https://www.googleapis.com/auth/experimentsandconfigs",
|
||||||
|
Port: 51121,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBase64(s string) string {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
func generateState() (string, error) {
|
func generateState() (string, error) {
|
||||||
buf := make([]byte, 32)
|
buf := make([]byte, 32)
|
||||||
if _, err := rand.Read(buf); err != nil {
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
|
@ -101,8 +129,17 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
|
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("If you're running in a headless environment, use: picoclaw auth login --provider openai --device-code")
|
fmt.Printf("Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n", cfg.Port)
|
||||||
fmt.Println("Waiting for authentication in browser...")
|
fmt.Println("please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.")
|
||||||
|
fmt.Println("Waiting for authentication (browser or manual paste)...")
|
||||||
|
|
||||||
|
// Start manual input in a goroutine
|
||||||
|
manualCh := make(chan string)
|
||||||
|
go func() {
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
input, _ := reader.ReadString('\n')
|
||||||
|
manualCh <- strings.TrimSpace(input)
|
||||||
|
}()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case result := <-resultCh:
|
case result := <-resultCh:
|
||||||
|
|
@ -110,6 +147,22 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
return nil, result.err
|
return nil, result.err
|
||||||
}
|
}
|
||||||
return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
||||||
|
case manualInput := <-manualCh:
|
||||||
|
if manualInput == "" {
|
||||||
|
return nil, fmt.Errorf("manual input cancelled")
|
||||||
|
}
|
||||||
|
// Extract code from URL if it's a full URL
|
||||||
|
code := manualInput
|
||||||
|
if strings.Contains(manualInput, "?") {
|
||||||
|
u, err := url.Parse(manualInput)
|
||||||
|
if err == nil {
|
||||||
|
code = u.Query().Get("code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if code == "" {
|
||||||
|
return nil, fmt.Errorf("could not find authorization code in input")
|
||||||
|
}
|
||||||
|
return exchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
|
||||||
case <-time.After(5 * time.Minute):
|
case <-time.After(5 * time.Minute):
|
||||||
return nil, fmt.Errorf("authentication timed out after 5 minutes")
|
return nil, fmt.Errorf("authentication timed out after 5 minutes")
|
||||||
}
|
}
|
||||||
|
|
@ -269,8 +322,16 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre
|
||||||
"refresh_token": {cred.RefreshToken},
|
"refresh_token": {cred.RefreshToken},
|
||||||
"scope": {"openid profile email"},
|
"scope": {"openid profile email"},
|
||||||
}
|
}
|
||||||
|
if cfg.ClientSecret != "" {
|
||||||
|
data.Set("client_secret", cfg.ClientSecret)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := http.PostForm(cfg.Issuer+"/oauth/token", data)
|
tokenURL := cfg.Issuer + "/oauth/token"
|
||||||
|
if cfg.TokenURL != "" {
|
||||||
|
tokenURL = cfg.TokenURL
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.PostForm(tokenURL, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("refreshing token: %w", err)
|
return nil, fmt.Errorf("refreshing token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -291,6 +352,12 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre
|
||||||
if refreshed.AccountID == "" {
|
if refreshed.AccountID == "" {
|
||||||
refreshed.AccountID = cred.AccountID
|
refreshed.AccountID = cred.AccountID
|
||||||
}
|
}
|
||||||
|
if cred.Email != "" && refreshed.Email == "" {
|
||||||
|
refreshed.Email = cred.Email
|
||||||
|
}
|
||||||
|
if cred.ProjectID != "" && refreshed.ProjectID == "" {
|
||||||
|
refreshed.ProjectID = cred.ProjectID
|
||||||
|
}
|
||||||
return refreshed, nil
|
return refreshed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,16 +373,30 @@ func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectU
|
||||||
"scope": {cfg.Scopes},
|
"scope": {cfg.Scopes},
|
||||||
"code_challenge": {pkce.CodeChallenge},
|
"code_challenge": {pkce.CodeChallenge},
|
||||||
"code_challenge_method": {"S256"},
|
"code_challenge_method": {"S256"},
|
||||||
"id_token_add_organizations": {"true"},
|
|
||||||
"codex_cli_simplified_flow": {"true"},
|
|
||||||
"state": {state},
|
"state": {state},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isGoogle := strings.Contains(strings.ToLower(cfg.Issuer), "accounts.google.com")
|
||||||
|
if isGoogle {
|
||||||
|
// Google OAuth requires these for refresh token support
|
||||||
|
params.Set("access_type", "offline")
|
||||||
|
params.Set("prompt", "consent")
|
||||||
|
} else {
|
||||||
|
// OpenAI-specific parameters
|
||||||
|
params.Set("id_token_add_organizations", "true")
|
||||||
|
params.Set("codex_cli_simplified_flow", "true")
|
||||||
if strings.Contains(strings.ToLower(cfg.Issuer), "auth.openai.com") {
|
if strings.Contains(strings.ToLower(cfg.Issuer), "auth.openai.com") {
|
||||||
params.Set("originator", "picoclaw")
|
params.Set("originator", "picoclaw")
|
||||||
}
|
}
|
||||||
if cfg.Originator != "" {
|
if cfg.Originator != "" {
|
||||||
params.Set("originator", cfg.Originator)
|
params.Set("originator", cfg.Originator)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google uses /auth path, OpenAI uses /oauth/authorize
|
||||||
|
if isGoogle {
|
||||||
|
return cfg.Issuer + "/auth?" + params.Encode()
|
||||||
|
}
|
||||||
return cfg.Issuer + "/oauth/authorize?" + params.Encode()
|
return cfg.Issuer + "/oauth/authorize?" + params.Encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -327,8 +408,22 @@ func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirect
|
||||||
"client_id": {cfg.ClientID},
|
"client_id": {cfg.ClientID},
|
||||||
"code_verifier": {codeVerifier},
|
"code_verifier": {codeVerifier},
|
||||||
}
|
}
|
||||||
|
if cfg.ClientSecret != "" {
|
||||||
|
data.Set("client_secret", cfg.ClientSecret)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := http.PostForm(cfg.Issuer+"/oauth/token", data)
|
tokenURL := cfg.Issuer + "/oauth/token"
|
||||||
|
if cfg.TokenURL != "" {
|
||||||
|
tokenURL = cfg.TokenURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine provider name from config
|
||||||
|
provider := "openai"
|
||||||
|
if cfg.TokenURL != "" && strings.Contains(cfg.TokenURL, "googleapis.com") {
|
||||||
|
provider = "google-antigravity"
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.PostForm(tokenURL, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("exchanging code for tokens: %w", err)
|
return nil, fmt.Errorf("exchanging code for tokens: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -339,7 +434,7 @@ func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirect
|
||||||
return nil, fmt.Errorf("token exchange failed: %s", string(body))
|
return nil, fmt.Errorf("token exchange failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseTokenResponse(body, "openai")
|
return parseTokenResponse(body, provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
|
func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ type AuthCredential struct {
|
||||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
AuthMethod string `json:"auth_method"`
|
AuthMethod string `json:"auth_method"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
ProjectID string `json:"project_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthStore struct {
|
type AuthStore struct {
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,14 @@ func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *ch
|
||||||
"session_webhook": data.SessionWebhook,
|
"session_webhook": data.SessionWebhook,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if data.ConversationType == "1" {
|
||||||
|
metadata["peer_kind"] = "direct"
|
||||||
|
metadata["peer_id"] = senderID
|
||||||
|
} else {
|
||||||
|
metadata["peer_kind"] = "group"
|
||||||
|
metadata["peer_id"] = data.ConversationId
|
||||||
|
}
|
||||||
|
|
||||||
logger.DebugCF("dingtalk", "Received message", map[string]interface{}{
|
logger.DebugCF("dingtalk", "Received message", map[string]interface{}{
|
||||||
"sender_nick": senderNick,
|
"sender_nick": senderNick,
|
||||||
"sender_id": senderID,
|
"sender_id": senderID,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
|
|
@ -25,6 +26,8 @@ type DiscordChannel struct {
|
||||||
config config.DiscordConfig
|
config config.DiscordConfig
|
||||||
transcriber *voice.GroqTranscriber
|
transcriber *voice.GroqTranscriber
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
typingMu sync.Mutex
|
||||||
|
typingStop map[string]chan struct{} // chatID → stop signal
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
|
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
|
||||||
|
|
@ -41,6 +44,7 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
|
||||||
config: cfg,
|
config: cfg,
|
||||||
transcriber: nil,
|
transcriber: nil,
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
|
typingStop: make(map[string]chan struct{}),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,6 +87,14 @@ func (c *DiscordChannel) Stop(ctx context.Context) error {
|
||||||
logger.InfoC("discord", "Stopping Discord bot")
|
logger.InfoC("discord", "Stopping Discord bot")
|
||||||
c.setRunning(false)
|
c.setRunning(false)
|
||||||
|
|
||||||
|
// Stop all typing goroutines before closing session
|
||||||
|
c.typingMu.Lock()
|
||||||
|
for chatID, stop := range c.typingStop {
|
||||||
|
close(stop)
|
||||||
|
delete(c.typingStop, chatID)
|
||||||
|
}
|
||||||
|
c.typingMu.Unlock()
|
||||||
|
|
||||||
if err := c.session.Close(); err != nil {
|
if err := c.session.Close(); err != nil {
|
||||||
return fmt.Errorf("failed to close discord session: %w", err)
|
return fmt.Errorf("failed to close discord session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -91,6 +103,8 @@ func (c *DiscordChannel) Stop(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
c.stopTyping(msg.ChatID)
|
||||||
|
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return fmt.Errorf("discord bot not running")
|
return fmt.Errorf("discord bot not running")
|
||||||
}
|
}
|
||||||
|
|
@ -155,12 +169,6 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.session.ChannelTyping(m.ChannelID); err != nil {
|
|
||||||
logger.ErrorCF("discord", "Failed to send typing indicator", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查白名单,避免为被拒绝的用户下载附件和转录
|
// 检查白名单,避免为被拒绝的用户下载附件和转录
|
||||||
if !c.IsAllowed(m.Author.ID) {
|
if !c.IsAllowed(m.Author.ID) {
|
||||||
logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{
|
logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{
|
||||||
|
|
@ -243,6 +251,9 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||||
content = "[media only]"
|
content = "[media only]"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start typing after all early returns — guaranteed to have a matching Send()
|
||||||
|
c.startTyping(m.ChannelID)
|
||||||
|
|
||||||
logger.DebugCF("discord", "Received message", map[string]any{
|
logger.DebugCF("discord", "Received message", map[string]any{
|
||||||
"sender_name": senderName,
|
"sender_name": senderName,
|
||||||
"sender_id": senderID,
|
"sender_id": senderID,
|
||||||
|
|
@ -271,6 +282,52 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||||
c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata)
|
c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startTyping starts a continuous typing indicator loop for the given chatID.
|
||||||
|
// It stops any existing typing loop for that chatID before starting a new one.
|
||||||
|
func (c *DiscordChannel) startTyping(chatID string) {
|
||||||
|
c.typingMu.Lock()
|
||||||
|
// Stop existing loop for this chatID if any
|
||||||
|
if stop, ok := c.typingStop[chatID]; ok {
|
||||||
|
close(stop)
|
||||||
|
}
|
||||||
|
stop := make(chan struct{})
|
||||||
|
c.typingStop[chatID] = stop
|
||||||
|
c.typingMu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := c.session.ChannelTyping(chatID); err != nil {
|
||||||
|
logger.DebugCF("discord", "ChannelTyping error", map[string]interface{}{"chatID": chatID, "err": err})
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(8 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
timeout := time.After(5 * time.Minute)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-timeout:
|
||||||
|
return
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := c.session.ChannelTyping(chatID); err != nil {
|
||||||
|
logger.DebugCF("discord", "ChannelTyping error", map[string]interface{}{"chatID": chatID, "err": err})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopTyping stops the typing indicator loop for the given chatID.
|
||||||
|
func (c *DiscordChannel) stopTyping(chatID string) {
|
||||||
|
c.typingMu.Lock()
|
||||||
|
defer c.typingMu.Unlock()
|
||||||
|
if stop, ok := c.typingStop[chatID]; ok {
|
||||||
|
close(stop)
|
||||||
|
delete(c.typingStop, chatID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *DiscordChannel) downloadAttachment(url, filename string) string {
|
func (c *DiscordChannel) downloadAttachment(url, filename string) string {
|
||||||
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||||
LoggerPrefix: "discord",
|
LoggerPrefix: "discord",
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,15 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2
|
||||||
metadata["tenant_key"] = *sender.TenantKey
|
metadata["tenant_key"] = *sender.TenantKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chatType := stringValue(message.ChatType)
|
||||||
|
if chatType == "p2p" {
|
||||||
|
metadata["peer_kind"] = "direct"
|
||||||
|
metadata["peer_id"] = senderID
|
||||||
|
} else {
|
||||||
|
metadata["peer_kind"] = "group"
|
||||||
|
metadata["peer_id"] = chatID
|
||||||
|
}
|
||||||
|
|
||||||
logger.InfoCF("feishu", "Feishu message received", map[string]interface{}{
|
logger.InfoCF("feishu", "Feishu message received", map[string]interface{}{
|
||||||
"sender_id": senderID,
|
"sender_id": senderID,
|
||||||
"chat_id": chatID,
|
"chat_id": chatID,
|
||||||
|
|
|
||||||
|
|
@ -366,6 +366,14 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
"message_id": msg.ID,
|
"message_id": msg.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isGroup {
|
||||||
|
metadata["peer_kind"] = "group"
|
||||||
|
metadata["peer_id"] = chatID
|
||||||
|
} else {
|
||||||
|
metadata["peer_kind"] = "direct"
|
||||||
|
metadata["peer_id"] = senderID
|
||||||
|
}
|
||||||
|
|
||||||
logger.DebugCF("line", "Received message", map[string]interface{}{
|
logger.DebugCF("line", "Received message", map[string]interface{}{
|
||||||
"sender_id": senderID,
|
"sender_id": senderID,
|
||||||
"chat_id": chatID,
|
"chat_id": chatID,
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,8 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
|
||||||
"y": fmt.Sprintf("%.0f", y),
|
"y": fmt.Sprintf("%.0f", y),
|
||||||
"w": fmt.Sprintf("%.0f", w),
|
"w": fmt.Sprintf("%.0f", w),
|
||||||
"h": fmt.Sprintf("%.0f", h),
|
"h": fmt.Sprintf("%.0f", h),
|
||||||
|
"peer_kind": "channel",
|
||||||
|
"peer_id": "default",
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HandleMessage(senderID, chatID, content, []string{}, metadata)
|
c.HandleMessage(senderID, chatID, content, []string{}, metadata)
|
||||||
|
|
|
||||||
|
|
@ -866,10 +866,14 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
||||||
switch raw.MessageType {
|
switch raw.MessageType {
|
||||||
case "private":
|
case "private":
|
||||||
chatID = "private:" + senderID
|
chatID = "private:" + senderID
|
||||||
|
metadata["peer_kind"] = "direct"
|
||||||
|
metadata["peer_id"] = senderID
|
||||||
|
|
||||||
case "group":
|
case "group":
|
||||||
groupIDStr := strconv.FormatInt(groupID, 10)
|
groupIDStr := strconv.FormatInt(groupID, 10)
|
||||||
chatID = "group:" + groupIDStr
|
chatID = "group:" + groupIDStr
|
||||||
|
metadata["peer_kind"] = "group"
|
||||||
|
metadata["peer_id"] = groupIDStr
|
||||||
metadata["group_id"] = groupIDStr
|
metadata["group_id"] = groupIDStr
|
||||||
|
|
||||||
senderUserID, _ := parseJSONInt64(sender.UserID)
|
senderUserID, _ := parseJSONInt64(sender.UserID)
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,8 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||||
// 转发到消息总线
|
// 转发到消息总线
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"message_id": data.ID,
|
"message_id": data.ID,
|
||||||
|
"peer_kind": "direct",
|
||||||
|
"peer_id": senderID,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HandleMessage(senderID, senderID, content, []string{}, metadata)
|
c.HandleMessage(senderID, senderID, content, []string{}, metadata)
|
||||||
|
|
@ -207,6 +209,8 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"message_id": data.ID,
|
"message_id": data.ID,
|
||||||
"group_id": data.GroupID,
|
"group_id": data.GroupID,
|
||||||
|
"peer_kind": "group",
|
||||||
|
"peer_id": data.GroupID,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HandleMessage(senderID, data.GroupID, content, []string{}, metadata)
|
c.HandleMessage(senderID, data.GroupID, content, []string{}, metadata)
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,13 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
|
||||||
Proxy: http.ProxyURL(proxyURL),
|
Proxy: http.ProxyURL(proxyURL),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
} else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" {
|
||||||
|
// Use environment proxy if configured
|
||||||
|
opts = append(opts, telego.WithHTTPClient(&http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
},
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
bot, err := telego.NewBot(telegramCfg.Token, opts...)
|
bot, err := telego.NewBot(telegramCfg.Token, opts...)
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,14 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) {
|
||||||
metadata["user_name"] = userName
|
metadata["user_name"] = userName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if chatID == senderID {
|
||||||
|
metadata["peer_kind"] = "direct"
|
||||||
|
metadata["peer_id"] = senderID
|
||||||
|
} else {
|
||||||
|
metadata["peer_kind"] = "group"
|
||||||
|
metadata["peer_id"] = chatID
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("WhatsApp message from %s: %s...", senderID, utils.Truncate(content, 50))
|
log.Printf("WhatsApp message from %s: %s...", senderID, utils.Truncate(content, 50))
|
||||||
|
|
||||||
c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
|
c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,16 @@ import (
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// rrCounter is a global counter for round-robin load balancing across models.
|
||||||
|
var rrCounter atomic.Uint64
|
||||||
|
|
||||||
|
var configFileNames = []string{
|
||||||
|
"config.json",
|
||||||
|
"config.yaml",
|
||||||
|
"config.yml",
|
||||||
|
"config.toml",
|
||||||
|
}
|
||||||
|
|
||||||
// FlexibleStringSlice is a []string that also accepts JSON numbers,
|
// FlexibleStringSlice is a []string that also accepts JSON numbers,
|
||||||
// so allow_from can contain both "123" and 123.
|
// so allow_from can contain both "123" and 123.
|
||||||
type FlexibleStringSlice []string
|
type FlexibleStringSlice []string
|
||||||
|
|
@ -51,21 +61,37 @@ type Config struct {
|
||||||
Bindings []AgentBinding `json:"bindings,omitempty"`
|
Bindings []AgentBinding `json:"bindings,omitempty"`
|
||||||
Session SessionConfig `json:"session,omitempty"`
|
Session SessionConfig `json:"session,omitempty"`
|
||||||
Channels ChannelsConfig `json:"channels"`
|
Channels ChannelsConfig `json:"channels"`
|
||||||
Providers ProvidersConfig `json:"providers"`
|
Providers ProvidersConfig `json:"providers,omitempty"`
|
||||||
|
ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration
|
||||||
Gateway GatewayConfig `json:"gateway"`
|
Gateway GatewayConfig `json:"gateway"`
|
||||||
Tools ToolsConfig `json:"tools"`
|
Tools ToolsConfig `json:"tools"`
|
||||||
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
||||||
Devices DevicesConfig `json:"devices"`
|
Devices DevicesConfig `json:"devices"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// rrCounter is reserved for round-robin model selection compatibility.
|
// MarshalJSON implements custom JSON marshaling for Config
|
||||||
var rrCounter atomic.Uint64
|
// to omit providers section when empty and session when empty
|
||||||
|
func (c Config) MarshalJSON() ([]byte, error) {
|
||||||
|
type Alias Config
|
||||||
|
aux := &struct {
|
||||||
|
Providers *ProvidersConfig `json:"providers,omitempty"`
|
||||||
|
Session *SessionConfig `json:"session,omitempty"`
|
||||||
|
*Alias
|
||||||
|
}{
|
||||||
|
Alias: (*Alias)(&c),
|
||||||
|
}
|
||||||
|
|
||||||
var configFileNames = []string{
|
// Only include providers if not empty
|
||||||
"config.json",
|
if !c.Providers.IsEmpty() {
|
||||||
"config.yaml",
|
aux.Providers = &c.Providers
|
||||||
"config.yml",
|
}
|
||||||
"config.toml",
|
|
||||||
|
// Only include session if not empty
|
||||||
|
if c.Session.DMScope != "" || len(c.Session.IdentityLinks) > 0 {
|
||||||
|
aux.Session = &c.Session
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(aux)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentsConfig struct {
|
type AgentsConfig struct {
|
||||||
|
|
@ -77,8 +103,8 @@ type AgentsConfig struct {
|
||||||
// String format: "gpt-4" (just primary, no fallbacks)
|
// String format: "gpt-4" (just primary, no fallbacks)
|
||||||
// Object format: {"primary": "gpt-4", "fallbacks": ["claude-haiku"]}
|
// Object format: {"primary": "gpt-4", "fallbacks": ["claude-haiku"]}
|
||||||
type AgentModelConfig struct {
|
type AgentModelConfig struct {
|
||||||
Primary string `json:"primary" mapstructure:"primary"`
|
Primary string `json:"primary,omitempty"`
|
||||||
Fallbacks []string `json:"fallbacks,omitempty" mapstructure:"fallbacks"`
|
Fallbacks []string `json:"fallbacks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *AgentModelConfig) UnmarshalJSON(data []byte) error {
|
func (m *AgentModelConfig) UnmarshalJSON(data []byte) error {
|
||||||
|
|
@ -88,20 +114,30 @@ func (m *AgentModelConfig) UnmarshalJSON(data []byte) error {
|
||||||
m.Fallbacks = nil
|
m.Fallbacks = nil
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
type raw struct {
|
||||||
type Alias struct {
|
|
||||||
Primary string `json:"primary"`
|
Primary string `json:"primary"`
|
||||||
Fallbacks []string `json:"fallbacks"`
|
Fallbacks []string `json:"fallbacks"`
|
||||||
}
|
}
|
||||||
aux := &Alias{}
|
var r raw
|
||||||
if err := json.Unmarshal(data, aux); err != nil {
|
if err := json.Unmarshal(data, &r); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.Primary = aux.Primary
|
m.Primary = r.Primary
|
||||||
m.Fallbacks = aux.Fallbacks
|
m.Fallbacks = r.Fallbacks
|
||||||
return nil
|
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})
|
||||||
|
}
|
||||||
|
|
||||||
type AgentConfig struct {
|
type AgentConfig struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Default bool `json:"default,omitempty"`
|
Default bool `json:"default,omitempty"`
|
||||||
|
|
@ -141,16 +177,16 @@ type SessionConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentDefaults struct {
|
type AgentDefaults struct {
|
||||||
Workspace string `json:"workspace" mapstructure:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
||||||
RestrictToWorkspace bool `json:"restrict_to_workspace" mapstructure:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
||||||
Provider string `json:"provider" mapstructure:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
||||||
Model string `json:"model" mapstructure:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
|
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
|
||||||
ModelFallbacks []string `json:"model_fallbacks,omitempty" mapstructure:"model_fallbacks,omitempty"`
|
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
||||||
ImageModel string `json:"image_model,omitempty" mapstructure:"image_model" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
||||||
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty" mapstructure:"image_model_fallbacks,omitempty"`
|
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
||||||
MaxTokens int `json:"max_tokens" mapstructure:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
||||||
Temperature float64 `json:"temperature" mapstructure:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
||||||
MaxToolIterations int `json:"max_tool_iterations" mapstructure:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChannelsConfig struct {
|
type ChannelsConfig struct {
|
||||||
|
|
@ -264,7 +300,43 @@ type ProvidersConfig struct {
|
||||||
Moonshot ProviderConfig `json:"moonshot"`
|
Moonshot ProviderConfig `json:"moonshot"`
|
||||||
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
|
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
|
||||||
DeepSeek ProviderConfig `json:"deepseek"`
|
DeepSeek ProviderConfig `json:"deepseek"`
|
||||||
|
Cerebras ProviderConfig `json:"cerebras"`
|
||||||
|
VolcEngine ProviderConfig `json:"volcengine"`
|
||||||
GitHubCopilot ProviderConfig `json:"github_copilot"`
|
GitHubCopilot ProviderConfig `json:"github_copilot"`
|
||||||
|
Antigravity ProviderConfig `json:"antigravity"`
|
||||||
|
Qwen ProviderConfig `json:"qwen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
||||||
|
// Note: WebSearch is an optimization option and doesn't count as "non-empty"
|
||||||
|
func (p ProvidersConfig) IsEmpty() bool {
|
||||||
|
return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" &&
|
||||||
|
p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" &&
|
||||||
|
p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" &&
|
||||||
|
p.Groq.APIKey == "" && p.Groq.APIBase == "" &&
|
||||||
|
p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" &&
|
||||||
|
p.VLLM.APIKey == "" && p.VLLM.APIBase == "" &&
|
||||||
|
p.Gemini.APIKey == "" && p.Gemini.APIBase == "" &&
|
||||||
|
p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" &&
|
||||||
|
p.Ollama.APIKey == "" && p.Ollama.APIBase == "" &&
|
||||||
|
p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" &&
|
||||||
|
p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" &&
|
||||||
|
p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" &&
|
||||||
|
p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" &&
|
||||||
|
p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" &&
|
||||||
|
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
|
||||||
|
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
||||||
|
p.Qwen.APIKey == "" && p.Qwen.APIBase == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
||||||
|
// to omit the entire section when empty
|
||||||
|
func (p ProvidersConfig) MarshalJSON() ([]byte, error) {
|
||||||
|
if p.IsEmpty() {
|
||||||
|
return []byte("null"), nil
|
||||||
|
}
|
||||||
|
type Alias ProvidersConfig
|
||||||
|
return json.Marshal((*Alias)(&p))
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderConfig struct {
|
type ProviderConfig struct {
|
||||||
|
|
@ -277,7 +349,43 @@ type ProviderConfig struct {
|
||||||
|
|
||||||
type OpenAIProviderConfig struct {
|
type OpenAIProviderConfig struct {
|
||||||
ProviderConfig
|
ProviderConfig
|
||||||
WebSearch bool `json:"web_search" mapstructure:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"`
|
WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelConfig represents a model-centric provider configuration.
|
||||||
|
// It allows adding new providers (especially OpenAI-compatible ones) via configuration only.
|
||||||
|
// The model field uses protocol prefix format: [protocol/]model-identifier
|
||||||
|
// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot
|
||||||
|
// 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")
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks if the ModelConfig has all required fields.
|
||||||
|
func (c *ModelConfig) Validate() error {
|
||||||
|
if c.ModelName == "" {
|
||||||
|
return fmt.Errorf("model_name is required")
|
||||||
|
}
|
||||||
|
if c.Model == "" {
|
||||||
|
return fmt.Errorf("model is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type GatewayConfig struct {
|
type GatewayConfig struct {
|
||||||
|
|
@ -323,136 +431,6 @@ type ToolsConfig struct {
|
||||||
Exec ExecConfig `json:"exec"`
|
Exec ExecConfig `json:"exec"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultConfig() *Config {
|
|
||||||
return &Config{
|
|
||||||
Agents: AgentsConfig{
|
|
||||||
Defaults: AgentDefaults{
|
|
||||||
Workspace: "~/.picoclaw/workspace",
|
|
||||||
RestrictToWorkspace: true,
|
|
||||||
Provider: "",
|
|
||||||
Model: "glm-4.7",
|
|
||||||
MaxTokens: 8192,
|
|
||||||
Temperature: 0.7,
|
|
||||||
MaxToolIterations: 20,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Channels: ChannelsConfig{
|
|
||||||
WhatsApp: WhatsAppConfig{
|
|
||||||
Enabled: false,
|
|
||||||
BridgeURL: "ws://localhost:3001",
|
|
||||||
AllowFrom: FlexibleStringSlice{},
|
|
||||||
},
|
|
||||||
Telegram: TelegramConfig{
|
|
||||||
Enabled: false,
|
|
||||||
Token: "",
|
|
||||||
AllowFrom: FlexibleStringSlice{},
|
|
||||||
},
|
|
||||||
Feishu: FeishuConfig{
|
|
||||||
Enabled: false,
|
|
||||||
AppID: "",
|
|
||||||
AppSecret: "",
|
|
||||||
EncryptKey: "",
|
|
||||||
VerificationToken: "",
|
|
||||||
AllowFrom: FlexibleStringSlice{},
|
|
||||||
},
|
|
||||||
Discord: DiscordConfig{
|
|
||||||
Enabled: false,
|
|
||||||
Token: "",
|
|
||||||
AllowFrom: FlexibleStringSlice{},
|
|
||||||
},
|
|
||||||
MaixCam: MaixCamConfig{
|
|
||||||
Enabled: false,
|
|
||||||
Host: "0.0.0.0",
|
|
||||||
Port: 18790,
|
|
||||||
AllowFrom: FlexibleStringSlice{},
|
|
||||||
},
|
|
||||||
QQ: QQConfig{
|
|
||||||
Enabled: false,
|
|
||||||
AppID: "",
|
|
||||||
AppSecret: "",
|
|
||||||
AllowFrom: FlexibleStringSlice{},
|
|
||||||
},
|
|
||||||
DingTalk: DingTalkConfig{
|
|
||||||
Enabled: false,
|
|
||||||
ClientID: "",
|
|
||||||
ClientSecret: "",
|
|
||||||
AllowFrom: FlexibleStringSlice{},
|
|
||||||
},
|
|
||||||
Slack: SlackConfig{
|
|
||||||
Enabled: false,
|
|
||||||
BotToken: "",
|
|
||||||
AppToken: "",
|
|
||||||
AllowFrom: FlexibleStringSlice{},
|
|
||||||
},
|
|
||||||
LINE: LINEConfig{
|
|
||||||
Enabled: false,
|
|
||||||
ChannelSecret: "",
|
|
||||||
ChannelAccessToken: "",
|
|
||||||
WebhookHost: "0.0.0.0",
|
|
||||||
WebhookPort: 18791,
|
|
||||||
WebhookPath: "/webhook/line",
|
|
||||||
AllowFrom: FlexibleStringSlice{},
|
|
||||||
},
|
|
||||||
OneBot: OneBotConfig{
|
|
||||||
Enabled: false,
|
|
||||||
WSUrl: "ws://127.0.0.1:3001",
|
|
||||||
AccessToken: "",
|
|
||||||
ReconnectInterval: 5,
|
|
||||||
GroupTriggerPrefix: []string{},
|
|
||||||
AllowFrom: FlexibleStringSlice{},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Providers: ProvidersConfig{
|
|
||||||
Anthropic: ProviderConfig{},
|
|
||||||
OpenAI: OpenAIProviderConfig{WebSearch: true},
|
|
||||||
OpenRouter: ProviderConfig{},
|
|
||||||
Groq: ProviderConfig{},
|
|
||||||
Zhipu: ProviderConfig{},
|
|
||||||
VLLM: ProviderConfig{},
|
|
||||||
Gemini: ProviderConfig{},
|
|
||||||
Nvidia: ProviderConfig{},
|
|
||||||
Moonshot: ProviderConfig{},
|
|
||||||
ShengSuanYun: ProviderConfig{},
|
|
||||||
},
|
|
||||||
Gateway: GatewayConfig{
|
|
||||||
Host: "0.0.0.0",
|
|
||||||
Port: 18790,
|
|
||||||
},
|
|
||||||
Tools: ToolsConfig{
|
|
||||||
Web: WebToolsConfig{
|
|
||||||
Brave: BraveConfig{
|
|
||||||
Enabled: false,
|
|
||||||
APIKey: "",
|
|
||||||
MaxResults: 5,
|
|
||||||
},
|
|
||||||
DuckDuckGo: DuckDuckGoConfig{
|
|
||||||
Enabled: true,
|
|
||||||
MaxResults: 5,
|
|
||||||
},
|
|
||||||
Perplexity: PerplexityConfig{
|
|
||||||
Enabled: false,
|
|
||||||
APIKey: "",
|
|
||||||
MaxResults: 5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Cron: CronToolsConfig{
|
|
||||||
ExecTimeoutMinutes: 5, // default 5 minutes for LLM operations
|
|
||||||
},
|
|
||||||
Exec: ExecConfig{
|
|
||||||
EnableDenyPatterns: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Heartbeat: HeartbeatConfig{
|
|
||||||
Enabled: true,
|
|
||||||
Interval: 30, // default 30 minutes
|
|
||||||
},
|
|
||||||
Devices: DevicesConfig{
|
|
||||||
Enabled: false,
|
|
||||||
MonitorUSB: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadConfig(path string) (*Config, error) {
|
func LoadConfig(path string) (*Config, error) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
|
@ -521,6 +499,16 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
return nil, fmt.Errorf("failed to parse env overrides: %w", err)
|
return nil, fmt.Errorf("failed to parse env overrides: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-migrate: if only legacy providers config exists, convert to model_list
|
||||||
|
if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
|
||||||
|
cfg.ModelList = ConvertProvidersToModelList(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate model_list for uniqueness and required fields
|
||||||
|
if err := cfg.ValidateModelList(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -535,7 +523,7 @@ func SaveConfig(path string, cfg *Config) error {
|
||||||
case ".json", "":
|
case ".json", "":
|
||||||
configType = "json"
|
configType = "json"
|
||||||
if ext == "" {
|
if ext == "" {
|
||||||
path = path + ".json"
|
path += ".json"
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
configType = "json"
|
configType = "json"
|
||||||
|
|
@ -545,17 +533,31 @@ func SaveConfig(path string, cfg *Config) error {
|
||||||
v.SetConfigType(configType)
|
v.SetConfigType(configType)
|
||||||
v.SetConfigFile(path)
|
v.SetConfigFile(path)
|
||||||
|
|
||||||
data, err := json.Marshal(cfg)
|
settings := map[string]interface{}{}
|
||||||
|
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
||||||
|
TagName: "json",
|
||||||
|
Result: &settings,
|
||||||
|
WeaklyTypedInput: true,
|
||||||
|
Squash: true,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to marshal config: %w", err)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
var dataMap map[string]interface{}
|
if cfg.Providers.IsEmpty() {
|
||||||
if err := json.Unmarshal(data, &dataMap); err != nil {
|
delete(settings, "providers")
|
||||||
return fmt.Errorf("failed to unmarshal config map: %w", err)
|
}
|
||||||
|
if cfg.Session.DMScope == "" && len(cfg.Session.IdentityLinks) == 0 {
|
||||||
|
delete(settings, "session")
|
||||||
|
}
|
||||||
|
if len(cfg.Bindings) == 0 {
|
||||||
|
delete(settings, "bindings")
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, val := range dataMap {
|
for key, val := range settings {
|
||||||
v.Set(key, val)
|
v.Set(key, val)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -614,6 +616,9 @@ func (c *Config) GetAPIKey() string {
|
||||||
if c.Providers.ShengSuanYun.APIKey != "" {
|
if c.Providers.ShengSuanYun.APIKey != "" {
|
||||||
return c.Providers.ShengSuanYun.APIKey
|
return c.Providers.ShengSuanYun.APIKey
|
||||||
}
|
}
|
||||||
|
if c.Providers.Cerebras.APIKey != "" {
|
||||||
|
return c.Providers.Cerebras.APIKey
|
||||||
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -633,28 +638,6 @@ func (c *Config) GetAPIBase() string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModelConfig holds primary model and fallback list.
|
|
||||||
type ModelConfig struct {
|
|
||||||
Primary string
|
|
||||||
Fallbacks []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetModelConfig returns the text model configuration with fallbacks.
|
|
||||||
func (c *Config) GetModelConfig() ModelConfig {
|
|
||||||
return ModelConfig{
|
|
||||||
Primary: c.Agents.Defaults.Model,
|
|
||||||
Fallbacks: c.Agents.Defaults.ModelFallbacks,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetImageModelConfig returns the image model configuration with fallbacks.
|
|
||||||
func (c *Config) GetImageModelConfig() ModelConfig {
|
|
||||||
return ModelConfig{
|
|
||||||
Primary: c.Agents.Defaults.ImageModel,
|
|
||||||
Fallbacks: c.Agents.Defaults.ImageModelFallbacks,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func expandHome(path string) string {
|
func expandHome(path string) string {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return path
|
return path
|
||||||
|
|
@ -668,3 +651,65 @@ func expandHome(path string) string {
|
||||||
}
|
}
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetModelConfig returns the ModelConfig for the given model name.
|
||||||
|
// If multiple configs exist with the same model_name, it uses round-robin
|
||||||
|
// selection for load balancing. Returns an error if the model is not found.
|
||||||
|
func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
|
||||||
|
matches := c.findMatches(modelName)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil, fmt.Errorf("model %q not found in model_list or providers", modelName)
|
||||||
|
}
|
||||||
|
if len(matches) == 1 {
|
||||||
|
return &matches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple configs - use round-robin for load balancing
|
||||||
|
idx := rrCounter.Add(1) % uint64(len(matches))
|
||||||
|
return &matches[idx], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMatches finds all ModelConfig entries with the given model_name.
|
||||||
|
func (c *Config) findMatches(modelName string) []ModelConfig {
|
||||||
|
var matches []ModelConfig
|
||||||
|
for i := range c.ModelList {
|
||||||
|
if c.ModelList[i].ModelName == modelName {
|
||||||
|
matches = append(matches, c.ModelList[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasProvidersConfig checks if any provider in the old providers config has configuration.
|
||||||
|
func (c *Config) HasProvidersConfig() bool {
|
||||||
|
v := c.Providers
|
||||||
|
return v.Anthropic.APIKey != "" || v.Anthropic.APIBase != "" ||
|
||||||
|
v.OpenAI.APIKey != "" || v.OpenAI.APIBase != "" ||
|
||||||
|
v.OpenRouter.APIKey != "" || v.OpenRouter.APIBase != "" ||
|
||||||
|
v.Groq.APIKey != "" || v.Groq.APIBase != "" ||
|
||||||
|
v.Zhipu.APIKey != "" || v.Zhipu.APIBase != "" ||
|
||||||
|
v.VLLM.APIKey != "" || v.VLLM.APIBase != "" ||
|
||||||
|
v.Gemini.APIKey != "" || v.Gemini.APIBase != "" ||
|
||||||
|
v.Nvidia.APIKey != "" || v.Nvidia.APIBase != "" ||
|
||||||
|
v.Ollama.APIKey != "" || v.Ollama.APIBase != "" ||
|
||||||
|
v.Moonshot.APIKey != "" || v.Moonshot.APIBase != "" ||
|
||||||
|
v.ShengSuanYun.APIKey != "" || v.ShengSuanYun.APIBase != "" ||
|
||||||
|
v.DeepSeek.APIKey != "" || v.DeepSeek.APIBase != "" ||
|
||||||
|
v.Cerebras.APIKey != "" || v.Cerebras.APIBase != "" ||
|
||||||
|
v.VolcEngine.APIKey != "" || v.VolcEngine.APIBase != "" ||
|
||||||
|
v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" ||
|
||||||
|
v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" ||
|
||||||
|
v.Qwen.APIKey != "" || v.Qwen.APIBase != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateModelList validates all ModelConfig entries in the model_list.
|
||||||
|
// It checks that each model config is valid.
|
||||||
|
// Note: Multiple entries with the same model_name are allowed for load balancing.
|
||||||
|
func (c *Config) ValidateModelList() error {
|
||||||
|
for i := range c.ModelList {
|
||||||
|
if err := c.ModelList[i].Validate(); err != nil {
|
||||||
|
return fmt.Errorf("model_list[%d]: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -213,8 +213,8 @@ func TestDefaultConfig_MaxToolIterations(t *testing.T) {
|
||||||
func TestDefaultConfig_Temperature(t *testing.T) {
|
func TestDefaultConfig_Temperature(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
if cfg.Agents.Defaults.Temperature == 0 {
|
if cfg.Agents.Defaults.Temperature != nil {
|
||||||
t.Error("Temperature should not be zero")
|
t.Error("Temperature should be nil when not provided")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -389,8 +389,8 @@ func TestConfig_Complete(t *testing.T) {
|
||||||
if cfg.Agents.Defaults.Model == "" {
|
if cfg.Agents.Defaults.Model == "" {
|
||||||
t.Error("Model should not be empty")
|
t.Error("Model should not be empty")
|
||||||
}
|
}
|
||||||
if cfg.Agents.Defaults.Temperature == 0 {
|
if cfg.Agents.Defaults.Temperature != nil {
|
||||||
t.Error("Temperature should have default value")
|
t.Error("Temperature should be nil when not provided")
|
||||||
}
|
}
|
||||||
if cfg.Agents.Defaults.MaxTokens == 0 {
|
if cfg.Agents.Defaults.MaxTokens == 0 {
|
||||||
t.Error("MaxTokens should not be zero")
|
t.Error("MaxTokens should not be zero")
|
||||||
|
|
|
||||||
275
pkg/config/defaults.go
Normal file
275
pkg/config/defaults.go
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
// DefaultConfig returns the default configuration for PicoClaw.
|
||||||
|
func DefaultConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
Agents: AgentsConfig{
|
||||||
|
Defaults: AgentDefaults{
|
||||||
|
Workspace: "~/.picoclaw/workspace",
|
||||||
|
RestrictToWorkspace: true,
|
||||||
|
Provider: "",
|
||||||
|
Model: "glm-4.7",
|
||||||
|
MaxTokens: 8192,
|
||||||
|
Temperature: nil, // nil means use provider default
|
||||||
|
MaxToolIterations: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Bindings: []AgentBinding{},
|
||||||
|
Session: SessionConfig{
|
||||||
|
DMScope: "main",
|
||||||
|
},
|
||||||
|
Channels: ChannelsConfig{
|
||||||
|
WhatsApp: WhatsAppConfig{
|
||||||
|
Enabled: false,
|
||||||
|
BridgeURL: "ws://localhost:3001",
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
},
|
||||||
|
Telegram: TelegramConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Token: "",
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
},
|
||||||
|
Feishu: FeishuConfig{
|
||||||
|
Enabled: false,
|
||||||
|
AppID: "",
|
||||||
|
AppSecret: "",
|
||||||
|
EncryptKey: "",
|
||||||
|
VerificationToken: "",
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
},
|
||||||
|
Discord: DiscordConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Token: "",
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
},
|
||||||
|
MaixCam: MaixCamConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Host: "0.0.0.0",
|
||||||
|
Port: 18790,
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
},
|
||||||
|
QQ: QQConfig{
|
||||||
|
Enabled: false,
|
||||||
|
AppID: "",
|
||||||
|
AppSecret: "",
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
},
|
||||||
|
DingTalk: DingTalkConfig{
|
||||||
|
Enabled: false,
|
||||||
|
ClientID: "",
|
||||||
|
ClientSecret: "",
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
},
|
||||||
|
Slack: SlackConfig{
|
||||||
|
Enabled: false,
|
||||||
|
BotToken: "",
|
||||||
|
AppToken: "",
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
},
|
||||||
|
LINE: LINEConfig{
|
||||||
|
Enabled: false,
|
||||||
|
ChannelSecret: "",
|
||||||
|
ChannelAccessToken: "",
|
||||||
|
WebhookHost: "0.0.0.0",
|
||||||
|
WebhookPort: 18791,
|
||||||
|
WebhookPath: "/webhook/line",
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
},
|
||||||
|
OneBot: OneBotConfig{
|
||||||
|
Enabled: false,
|
||||||
|
WSUrl: "ws://127.0.0.1:3001",
|
||||||
|
AccessToken: "",
|
||||||
|
ReconnectInterval: 5,
|
||||||
|
GroupTriggerPrefix: []string{},
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Providers: ProvidersConfig{
|
||||||
|
OpenAI: OpenAIProviderConfig{WebSearch: true},
|
||||||
|
},
|
||||||
|
ModelList: []ModelConfig{
|
||||||
|
// ============================================
|
||||||
|
// Add your API key to the model you want to use
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Zhipu AI (智谱) - https://open.bigmodel.cn/usercenter/apikeys
|
||||||
|
{
|
||||||
|
ModelName: "glm-4.7",
|
||||||
|
Model: "zhipu/glm-4.7",
|
||||||
|
APIBase: "https://open.bigmodel.cn/api/paas/v4",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
// OpenAI - https://platform.openai.com/api-keys
|
||||||
|
{
|
||||||
|
ModelName: "gpt-5.2",
|
||||||
|
Model: "openai/gpt-5.2",
|
||||||
|
APIBase: "https://api.openai.com/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Anthropic Claude - https://console.anthropic.com/settings/keys
|
||||||
|
{
|
||||||
|
ModelName: "claude-sonnet-4.6",
|
||||||
|
Model: "anthropic/claude-sonnet-4.6",
|
||||||
|
APIBase: "https://api.anthropic.com/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
// DeepSeek - https://platform.deepseek.com/
|
||||||
|
{
|
||||||
|
ModelName: "deepseek-chat",
|
||||||
|
Model: "deepseek/deepseek-chat",
|
||||||
|
APIBase: "https://api.deepseek.com/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Google Gemini - https://ai.google.dev/
|
||||||
|
{
|
||||||
|
ModelName: "gemini-2.0-flash",
|
||||||
|
Model: "gemini/gemini-2.0-flash-exp",
|
||||||
|
APIBase: "https://generativelanguage.googleapis.com/v1beta",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Qwen (通义千问) - https://dashscope.console.aliyun.com/apiKey
|
||||||
|
{
|
||||||
|
ModelName: "qwen-plus",
|
||||||
|
Model: "qwen/qwen-plus",
|
||||||
|
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Moonshot (月之暗面) - https://platform.moonshot.cn/console/api-keys
|
||||||
|
{
|
||||||
|
ModelName: "moonshot-v1-8k",
|
||||||
|
Model: "moonshot/moonshot-v1-8k",
|
||||||
|
APIBase: "https://api.moonshot.cn/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Groq - https://console.groq.com/keys
|
||||||
|
{
|
||||||
|
ModelName: "llama-3.3-70b",
|
||||||
|
Model: "groq/llama-3.3-70b-versatile",
|
||||||
|
APIBase: "https://api.groq.com/openai/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
// OpenRouter (100+ models) - https://openrouter.ai/keys
|
||||||
|
{
|
||||||
|
ModelName: "openrouter-auto",
|
||||||
|
Model: "openrouter/auto",
|
||||||
|
APIBase: "https://openrouter.ai/api/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "openrouter-gpt-5.2",
|
||||||
|
Model: "openrouter/openai/gpt-5.2",
|
||||||
|
APIBase: "https://openrouter.ai/api/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
// NVIDIA - https://build.nvidia.com/
|
||||||
|
{
|
||||||
|
ModelName: "nemotron-4-340b",
|
||||||
|
Model: "nvidia/nemotron-4-340b-instruct",
|
||||||
|
APIBase: "https://integrate.api.nvidia.com/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cerebras - https://inference.cerebras.ai/
|
||||||
|
{
|
||||||
|
ModelName: "cerebras-llama-3.3-70b",
|
||||||
|
Model: "cerebras/llama-3.3-70b",
|
||||||
|
APIBase: "https://api.cerebras.ai/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Volcengine (火山引擎) - https://console.volcengine.com/ark
|
||||||
|
{
|
||||||
|
ModelName: "doubao-pro",
|
||||||
|
Model: "volcengine/doubao-pro-32k",
|
||||||
|
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ShengsuanYun (神算云)
|
||||||
|
{
|
||||||
|
ModelName: "deepseek-v3",
|
||||||
|
Model: "shengsuanyun/deepseek-v3",
|
||||||
|
APIBase: "https://api.shengsuanyun.com/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Antigravity (Google Cloud Code Assist) - OAuth only
|
||||||
|
{
|
||||||
|
ModelName: "gemini-flash",
|
||||||
|
Model: "antigravity/gemini-3-flash",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
},
|
||||||
|
|
||||||
|
// GitHub Copilot - https://github.com/settings/tokens
|
||||||
|
{
|
||||||
|
ModelName: "copilot-gpt-5.2",
|
||||||
|
Model: "github-copilot/gpt-5.2",
|
||||||
|
APIBase: "http://localhost:4321",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Ollama (local) - https://ollama.com
|
||||||
|
{
|
||||||
|
ModelName: "llama3",
|
||||||
|
Model: "ollama/llama3",
|
||||||
|
APIBase: "http://localhost:11434/v1",
|
||||||
|
APIKey: "ollama",
|
||||||
|
},
|
||||||
|
|
||||||
|
// VLLM (local) - http://localhost:8000
|
||||||
|
{
|
||||||
|
ModelName: "local-model",
|
||||||
|
Model: "vllm/custom-model",
|
||||||
|
APIBase: "http://localhost:8000/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Gateway: GatewayConfig{
|
||||||
|
Host: "0.0.0.0",
|
||||||
|
Port: 18790,
|
||||||
|
},
|
||||||
|
Tools: ToolsConfig{
|
||||||
|
Web: WebToolsConfig{
|
||||||
|
Brave: BraveConfig{
|
||||||
|
Enabled: false,
|
||||||
|
APIKey: "",
|
||||||
|
MaxResults: 5,
|
||||||
|
},
|
||||||
|
DuckDuckGo: DuckDuckGoConfig{
|
||||||
|
Enabled: true,
|
||||||
|
MaxResults: 5,
|
||||||
|
},
|
||||||
|
Perplexity: PerplexityConfig{
|
||||||
|
Enabled: false,
|
||||||
|
APIKey: "",
|
||||||
|
MaxResults: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Cron: CronToolsConfig{
|
||||||
|
ExecTimeoutMinutes: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Heartbeat: HeartbeatConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Interval: 30,
|
||||||
|
},
|
||||||
|
Devices: DevicesConfig{
|
||||||
|
Enabled: false,
|
||||||
|
MonitorUSB: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
353
pkg/config/migration.go
Normal file
353
pkg/config/migration.go
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildModelWithProtocol constructs a model string with protocol prefix.
|
||||||
|
// If the model already contains a "/" (indicating it has a protocol prefix), it is returned as-is.
|
||||||
|
// Otherwise, the protocol prefix is added.
|
||||||
|
func buildModelWithProtocol(protocol, model string) string {
|
||||||
|
if strings.Contains(model, "/") {
|
||||||
|
// Model already has a protocol prefix, return as-is
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
return protocol + "/" + model
|
||||||
|
}
|
||||||
|
|
||||||
|
// providerMigrationConfig defines how to migrate a provider from old config to new format.
|
||||||
|
type providerMigrationConfig struct {
|
||||||
|
// providerNames are the possible names used in agents.defaults.provider
|
||||||
|
providerNames []string
|
||||||
|
// protocol is the protocol prefix for the model field
|
||||||
|
protocol string
|
||||||
|
// buildConfig creates the ModelConfig from ProviderConfig
|
||||||
|
buildConfig func(p ProvidersConfig) (ModelConfig, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertProvidersToModelList converts the old ProvidersConfig to a slice of ModelConfig.
|
||||||
|
// This enables backward compatibility with existing configurations.
|
||||||
|
// It preserves the user's configured model from agents.defaults.model when possible.
|
||||||
|
func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's configured provider and model
|
||||||
|
userProvider := strings.ToLower(cfg.Agents.Defaults.Provider)
|
||||||
|
userModel := cfg.Agents.Defaults.Model
|
||||||
|
|
||||||
|
p := cfg.Providers
|
||||||
|
|
||||||
|
var result []ModelConfig
|
||||||
|
|
||||||
|
// Track if we've applied the legacy model name fix (only for first provider)
|
||||||
|
legacyModelNameApplied := false
|
||||||
|
|
||||||
|
// Define migration rules for each provider
|
||||||
|
migrations := []providerMigrationConfig{
|
||||||
|
{
|
||||||
|
providerNames: []string{"openai", "gpt"},
|
||||||
|
protocol: "openai",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "openai",
|
||||||
|
Model: "openai/gpt-5.2",
|
||||||
|
APIKey: p.OpenAI.APIKey,
|
||||||
|
APIBase: p.OpenAI.APIBase,
|
||||||
|
Proxy: p.OpenAI.Proxy,
|
||||||
|
AuthMethod: p.OpenAI.AuthMethod,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"anthropic", "claude"},
|
||||||
|
protocol: "anthropic",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "anthropic",
|
||||||
|
Model: "anthropic/claude-sonnet-4.6",
|
||||||
|
APIKey: p.Anthropic.APIKey,
|
||||||
|
APIBase: p.Anthropic.APIBase,
|
||||||
|
Proxy: p.Anthropic.Proxy,
|
||||||
|
AuthMethod: p.Anthropic.AuthMethod,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"openrouter"},
|
||||||
|
protocol: "openrouter",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "openrouter",
|
||||||
|
Model: "openrouter/auto",
|
||||||
|
APIKey: p.OpenRouter.APIKey,
|
||||||
|
APIBase: p.OpenRouter.APIBase,
|
||||||
|
Proxy: p.OpenRouter.Proxy,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"groq"},
|
||||||
|
protocol: "groq",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.Groq.APIKey == "" && p.Groq.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "groq",
|
||||||
|
Model: "groq/llama-3.1-70b-versatile",
|
||||||
|
APIKey: p.Groq.APIKey,
|
||||||
|
APIBase: p.Groq.APIBase,
|
||||||
|
Proxy: p.Groq.Proxy,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"zhipu", "glm"},
|
||||||
|
protocol: "zhipu",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "zhipu",
|
||||||
|
Model: "zhipu/glm-4",
|
||||||
|
APIKey: p.Zhipu.APIKey,
|
||||||
|
APIBase: p.Zhipu.APIBase,
|
||||||
|
Proxy: p.Zhipu.Proxy,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"vllm"},
|
||||||
|
protocol: "vllm",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.VLLM.APIKey == "" && p.VLLM.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "vllm",
|
||||||
|
Model: "vllm/auto",
|
||||||
|
APIKey: p.VLLM.APIKey,
|
||||||
|
APIBase: p.VLLM.APIBase,
|
||||||
|
Proxy: p.VLLM.Proxy,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"gemini", "google"},
|
||||||
|
protocol: "gemini",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.Gemini.APIKey == "" && p.Gemini.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "gemini",
|
||||||
|
Model: "gemini/gemini-pro",
|
||||||
|
APIKey: p.Gemini.APIKey,
|
||||||
|
APIBase: p.Gemini.APIBase,
|
||||||
|
Proxy: p.Gemini.Proxy,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"nvidia"},
|
||||||
|
protocol: "nvidia",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "nvidia",
|
||||||
|
Model: "nvidia/meta/llama-3.1-8b-instruct",
|
||||||
|
APIKey: p.Nvidia.APIKey,
|
||||||
|
APIBase: p.Nvidia.APIBase,
|
||||||
|
Proxy: p.Nvidia.Proxy,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"ollama"},
|
||||||
|
protocol: "ollama",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.Ollama.APIKey == "" && p.Ollama.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "ollama",
|
||||||
|
Model: "ollama/llama3",
|
||||||
|
APIKey: p.Ollama.APIKey,
|
||||||
|
APIBase: p.Ollama.APIBase,
|
||||||
|
Proxy: p.Ollama.Proxy,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"moonshot", "kimi"},
|
||||||
|
protocol: "moonshot",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "moonshot",
|
||||||
|
Model: "moonshot/kimi",
|
||||||
|
APIKey: p.Moonshot.APIKey,
|
||||||
|
APIBase: p.Moonshot.APIBase,
|
||||||
|
Proxy: p.Moonshot.Proxy,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"shengsuanyun"},
|
||||||
|
protocol: "shengsuanyun",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "shengsuanyun",
|
||||||
|
Model: "shengsuanyun/auto",
|
||||||
|
APIKey: p.ShengSuanYun.APIKey,
|
||||||
|
APIBase: p.ShengSuanYun.APIBase,
|
||||||
|
Proxy: p.ShengSuanYun.Proxy,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"deepseek"},
|
||||||
|
protocol: "deepseek",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "deepseek",
|
||||||
|
Model: "deepseek/deepseek-chat",
|
||||||
|
APIKey: p.DeepSeek.APIKey,
|
||||||
|
APIBase: p.DeepSeek.APIBase,
|
||||||
|
Proxy: p.DeepSeek.Proxy,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"cerebras"},
|
||||||
|
protocol: "cerebras",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "cerebras",
|
||||||
|
Model: "cerebras/llama-3.3-70b",
|
||||||
|
APIKey: p.Cerebras.APIKey,
|
||||||
|
APIBase: p.Cerebras.APIBase,
|
||||||
|
Proxy: p.Cerebras.Proxy,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"volcengine", "doubao"},
|
||||||
|
protocol: "volcengine",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "volcengine",
|
||||||
|
Model: "volcengine/doubao-pro",
|
||||||
|
APIKey: p.VolcEngine.APIKey,
|
||||||
|
APIBase: p.VolcEngine.APIBase,
|
||||||
|
Proxy: p.VolcEngine.Proxy,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"github_copilot", "copilot"},
|
||||||
|
protocol: "github-copilot",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && p.GitHubCopilot.ConnectMode == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "github-copilot",
|
||||||
|
Model: "github-copilot/gpt-5.2",
|
||||||
|
APIBase: p.GitHubCopilot.APIBase,
|
||||||
|
ConnectMode: p.GitHubCopilot.ConnectMode,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"antigravity"},
|
||||||
|
protocol: "antigravity",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.Antigravity.APIKey == "" && p.Antigravity.AuthMethod == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "antigravity",
|
||||||
|
Model: "antigravity/gemini-2.0-flash",
|
||||||
|
APIKey: p.Antigravity.APIKey,
|
||||||
|
AuthMethod: p.Antigravity.AuthMethod,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"qwen", "tongyi"},
|
||||||
|
protocol: "qwen",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.Qwen.APIKey == "" && p.Qwen.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "qwen",
|
||||||
|
Model: "qwen/qwen-max",
|
||||||
|
APIKey: p.Qwen.APIKey,
|
||||||
|
APIBase: p.Qwen.APIBase,
|
||||||
|
Proxy: p.Qwen.Proxy,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each provider migration
|
||||||
|
for _, m := range migrations {
|
||||||
|
mc, ok := m.buildConfig(p)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is the user's configured provider
|
||||||
|
if slices.Contains(m.providerNames, userProvider) && userModel != "" {
|
||||||
|
// Use the user's configured model instead of default
|
||||||
|
mc.Model = buildModelWithProtocol(m.protocol, userModel)
|
||||||
|
} else if userProvider == "" && userModel != "" && !legacyModelNameApplied {
|
||||||
|
// Legacy config: no explicit provider field but model is specified
|
||||||
|
// Use userModel as ModelName for the FIRST provider so GetModelConfig(model) can find it
|
||||||
|
// This maintains backward compatibility with old configs that relied on implicit provider selection
|
||||||
|
mc.ModelName = userModel
|
||||||
|
mc.Model = buildModelWithProtocol(m.protocol, userModel)
|
||||||
|
legacyModelNameApplied = true
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, mc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
551
pkg/config/migration_test.go
Normal file
551
pkg/config/migration_test.go
Normal file
|
|
@ -0,0 +1,551 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Providers: ProvidersConfig{
|
||||||
|
OpenAI: OpenAIProviderConfig{
|
||||||
|
ProviderConfig: ProviderConfig{
|
||||||
|
APIKey: "sk-test-key",
|
||||||
|
APIBase: "https://custom.api.com/v1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[0].ModelName != "openai" {
|
||||||
|
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai")
|
||||||
|
}
|
||||||
|
if result[0].Model != "openai/gpt-5.2" {
|
||||||
|
t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.2")
|
||||||
|
}
|
||||||
|
if result[0].APIKey != "sk-test-key" {
|
||||||
|
t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertProvidersToModelList_Anthropic(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Providers: ProvidersConfig{
|
||||||
|
Anthropic: ProviderConfig{
|
||||||
|
APIKey: "ant-key",
|
||||||
|
APIBase: "https://custom.anthropic.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[0].ModelName != "anthropic" {
|
||||||
|
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "anthropic")
|
||||||
|
}
|
||||||
|
if result[0].Model != "anthropic/claude-sonnet-4.6" {
|
||||||
|
t.Errorf("Model = %q, want %q", result[0].Model, "anthropic/claude-sonnet-4.6")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertProvidersToModelList_Multiple(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Providers: ProvidersConfig{
|
||||||
|
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "openai-key"}},
|
||||||
|
Groq: ProviderConfig{APIKey: "groq-key"},
|
||||||
|
Zhipu: ProviderConfig{APIKey: "zhipu-key"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
|
if len(result) != 3 {
|
||||||
|
t.Fatalf("len(result) = %d, want 3", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that all providers are present
|
||||||
|
found := make(map[string]bool)
|
||||||
|
for _, mc := range result {
|
||||||
|
found[mc.ModelName] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{"openai", "groq", "zhipu"} {
|
||||||
|
if !found[name] {
|
||||||
|
t.Errorf("Missing provider %q in result", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertProvidersToModelList_Empty(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Providers: ProvidersConfig{},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
|
if len(result) != 0 {
|
||||||
|
t.Errorf("len(result) = %d, want 0", len(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertProvidersToModelList_Nil(t *testing.T) {
|
||||||
|
result := ConvertProvidersToModelList(nil)
|
||||||
|
|
||||||
|
if result != nil {
|
||||||
|
t.Errorf("result = %v, want nil", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Providers: ProvidersConfig{
|
||||||
|
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "key1"}},
|
||||||
|
Anthropic: ProviderConfig{APIKey: "key2"},
|
||||||
|
OpenRouter: ProviderConfig{APIKey: "key3"},
|
||||||
|
Groq: ProviderConfig{APIKey: "key4"},
|
||||||
|
Zhipu: ProviderConfig{APIKey: "key5"},
|
||||||
|
VLLM: ProviderConfig{APIKey: "key6"},
|
||||||
|
Gemini: ProviderConfig{APIKey: "key7"},
|
||||||
|
Nvidia: ProviderConfig{APIKey: "key8"},
|
||||||
|
Ollama: ProviderConfig{APIKey: "key9"},
|
||||||
|
Moonshot: ProviderConfig{APIKey: "key10"},
|
||||||
|
ShengSuanYun: ProviderConfig{APIKey: "key11"},
|
||||||
|
DeepSeek: ProviderConfig{APIKey: "key12"},
|
||||||
|
Cerebras: ProviderConfig{APIKey: "key13"},
|
||||||
|
VolcEngine: ProviderConfig{APIKey: "key14"},
|
||||||
|
GitHubCopilot: ProviderConfig{ConnectMode: "grpc"},
|
||||||
|
Antigravity: ProviderConfig{AuthMethod: "oauth"},
|
||||||
|
Qwen: ProviderConfig{APIKey: "key17"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
|
// All 17 providers should be converted
|
||||||
|
if len(result) != 17 {
|
||||||
|
t.Errorf("len(result) = %d, want 17", len(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertProvidersToModelList_Proxy(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Providers: ProvidersConfig{
|
||||||
|
OpenAI: OpenAIProviderConfig{
|
||||||
|
ProviderConfig: ProviderConfig{
|
||||||
|
APIKey: "key",
|
||||||
|
Proxy: "http://proxy:8080",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[0].Proxy != "http://proxy:8080" {
|
||||||
|
t.Errorf("Proxy = %q, want %q", result[0].Proxy, "http://proxy:8080")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertProvidersToModelList_AuthMethod(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Providers: ProvidersConfig{
|
||||||
|
OpenAI: OpenAIProviderConfig{
|
||||||
|
ProviderConfig: ProviderConfig{
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
|
if len(result) != 0 {
|
||||||
|
t.Errorf("len(result) = %d, want 0 (AuthMethod alone should not create entry)", len(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests for preserving user's configured model during migration
|
||||||
|
|
||||||
|
func TestConvertProvidersToModelList_PreservesUserModel_DeepSeek(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Agents: AgentsConfig{
|
||||||
|
Defaults: AgentDefaults{
|
||||||
|
Provider: "deepseek",
|
||||||
|
Model: "deepseek-reasoner",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Providers: ProvidersConfig{
|
||||||
|
DeepSeek: ProviderConfig{APIKey: "sk-deepseek"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should use user's model, not default
|
||||||
|
if result[0].Model != "deepseek/deepseek-reasoner" {
|
||||||
|
t.Errorf("Model = %q, want %q (user's configured model)", result[0].Model, "deepseek/deepseek-reasoner")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertProvidersToModelList_PreservesUserModel_OpenAI(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Agents: AgentsConfig{
|
||||||
|
Defaults: AgentDefaults{
|
||||||
|
Provider: "openai",
|
||||||
|
Model: "gpt-4-turbo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Providers: ProvidersConfig{
|
||||||
|
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "sk-openai"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[0].Model != "openai/gpt-4-turbo" {
|
||||||
|
t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-4-turbo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertProvidersToModelList_PreservesUserModel_Anthropic(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Agents: AgentsConfig{
|
||||||
|
Defaults: AgentDefaults{
|
||||||
|
Provider: "claude", // alternative name
|
||||||
|
Model: "claude-opus-4-20250514",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Providers: ProvidersConfig{
|
||||||
|
Anthropic: ProviderConfig{APIKey: "sk-ant"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[0].Model != "anthropic/claude-opus-4-20250514" {
|
||||||
|
t.Errorf("Model = %q, want %q", result[0].Model, "anthropic/claude-opus-4-20250514")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertProvidersToModelList_PreservesUserModel_Qwen(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Agents: AgentsConfig{
|
||||||
|
Defaults: AgentDefaults{
|
||||||
|
Provider: "qwen",
|
||||||
|
Model: "qwen-plus",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Providers: ProvidersConfig{
|
||||||
|
Qwen: ProviderConfig{APIKey: "sk-qwen"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[0].Model != "qwen/qwen-plus" {
|
||||||
|
t.Errorf("Model = %q, want %q", result[0].Model, "qwen/qwen-plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertProvidersToModelList_UsesDefaultWhenNoUserModel(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Agents: AgentsConfig{
|
||||||
|
Defaults: AgentDefaults{
|
||||||
|
Provider: "deepseek",
|
||||||
|
Model: "", // no model specified
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Providers: ProvidersConfig{
|
||||||
|
DeepSeek: ProviderConfig{APIKey: "sk-deepseek"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should use default model
|
||||||
|
if result[0].Model != "deepseek/deepseek-chat" {
|
||||||
|
t.Errorf("Model = %q, want %q (default)", result[0].Model, "deepseek/deepseek-chat")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Agents: AgentsConfig{
|
||||||
|
Defaults: AgentDefaults{
|
||||||
|
Provider: "deepseek",
|
||||||
|
Model: "deepseek-reasoner",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Providers: ProvidersConfig{
|
||||||
|
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "sk-openai"}},
|
||||||
|
DeepSeek: ProviderConfig{APIKey: "sk-deepseek"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
|
if len(result) != 2 {
|
||||||
|
t.Fatalf("len(result) = %d, want 2", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find each provider and verify model
|
||||||
|
for _, mc := range result {
|
||||||
|
switch mc.ModelName {
|
||||||
|
case "openai":
|
||||||
|
if mc.Model != "openai/gpt-5.2" {
|
||||||
|
t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.2")
|
||||||
|
}
|
||||||
|
case "deepseek":
|
||||||
|
if mc.Model != "deepseek/deepseek-reasoner" {
|
||||||
|
t.Errorf("DeepSeek Model = %q, want %q (user's)", mc.Model, "deepseek/deepseek-reasoner")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
providerAlias string
|
||||||
|
expectedModel string
|
||||||
|
provider ProviderConfig
|
||||||
|
}{
|
||||||
|
{"gpt", "openai/gpt-4-custom", ProviderConfig{APIKey: "key"}},
|
||||||
|
{"claude", "anthropic/claude-custom", ProviderConfig{APIKey: "key"}},
|
||||||
|
{"doubao", "volcengine/doubao-custom", ProviderConfig{APIKey: "key"}},
|
||||||
|
{"tongyi", "qwen/qwen-custom", ProviderConfig{APIKey: "key"}},
|
||||||
|
{"kimi", "moonshot/kimi-custom", ProviderConfig{APIKey: "key"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.providerAlias, func(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Agents: AgentsConfig{
|
||||||
|
Defaults: AgentDefaults{
|
||||||
|
Provider: tt.providerAlias,
|
||||||
|
Model: strings.TrimPrefix(tt.expectedModel, tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Providers: ProvidersConfig{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the appropriate provider config
|
||||||
|
switch tt.providerAlias {
|
||||||
|
case "gpt":
|
||||||
|
cfg.Providers.OpenAI = OpenAIProviderConfig{ProviderConfig: tt.provider}
|
||||||
|
case "claude":
|
||||||
|
cfg.Providers.Anthropic = tt.provider
|
||||||
|
case "doubao":
|
||||||
|
cfg.Providers.VolcEngine = tt.provider
|
||||||
|
case "tongyi":
|
||||||
|
cfg.Providers.Qwen = tt.provider
|
||||||
|
case "kimi":
|
||||||
|
cfg.Providers.Moonshot = tt.provider
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need to fix the model name in config
|
||||||
|
cfg.Agents.Defaults.Model = strings.TrimPrefix(tt.expectedModel, tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1])
|
||||||
|
|
||||||
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract just the model ID part (after the first /)
|
||||||
|
expectedModelID := tt.expectedModel
|
||||||
|
if result[0].Model != expectedModelID {
|
||||||
|
t.Errorf("Model = %q, want %q", result[0].Model, expectedModelID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test for backward compatibility: single provider without explicit provider field
|
||||||
|
// This matches the legacy config pattern where users only set model, not provider
|
||||||
|
|
||||||
|
func TestConvertProvidersToModelList_NoProviderField_SingleProvider(t *testing.T) {
|
||||||
|
// This matches the user's actual config:
|
||||||
|
// - No provider field set
|
||||||
|
// - model = "glm-4.7"
|
||||||
|
// - Only zhipu has API key configured
|
||||||
|
cfg := &Config{
|
||||||
|
Agents: AgentsConfig{
|
||||||
|
Defaults: AgentDefaults{
|
||||||
|
Provider: "", // Not set
|
||||||
|
Model: "glm-4.7",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Providers: ProvidersConfig{
|
||||||
|
Zhipu: ProviderConfig{APIKey: "test-zhipu-key"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelName should be the user's model value for backward compatibility
|
||||||
|
if result[0].ModelName != "glm-4.7" {
|
||||||
|
t.Errorf("ModelName = %q, want %q (user's model for backward compatibility)", result[0].ModelName, "glm-4.7")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model should use the user's model with protocol prefix
|
||||||
|
if result[0].Model != "zhipu/glm-4.7" {
|
||||||
|
t.Errorf("Model = %q, want %q", result[0].Model, "zhipu/glm-4.7")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertProvidersToModelList_NoProviderField_MultipleProviders(t *testing.T) {
|
||||||
|
// When multiple providers are configured but no provider field is set,
|
||||||
|
// the FIRST provider (in migration order) will use userModel as ModelName
|
||||||
|
// for backward compatibility with legacy implicit provider selection
|
||||||
|
cfg := &Config{
|
||||||
|
Agents: AgentsConfig{
|
||||||
|
Defaults: AgentDefaults{
|
||||||
|
Provider: "", // Not set
|
||||||
|
Model: "some-model",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Providers: ProvidersConfig{
|
||||||
|
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "openai-key"}},
|
||||||
|
Zhipu: ProviderConfig{APIKey: "zhipu-key"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
|
if len(result) != 2 {
|
||||||
|
t.Fatalf("len(result) = %d, want 2", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first provider (OpenAI in migration order) should use userModel as ModelName
|
||||||
|
// This ensures GetModelConfig("some-model") will find it
|
||||||
|
if result[0].ModelName != "some-model" {
|
||||||
|
t.Errorf("First provider ModelName = %q, want %q", result[0].ModelName, "some-model")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other providers should use provider name as ModelName
|
||||||
|
if result[1].ModelName != "zhipu" {
|
||||||
|
t.Errorf("Second provider ModelName = %q, want %q", result[1].ModelName, "zhipu")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) {
|
||||||
|
// Edge case: no provider, no model
|
||||||
|
cfg := &Config{
|
||||||
|
Agents: AgentsConfig{
|
||||||
|
Defaults: AgentDefaults{
|
||||||
|
Provider: "",
|
||||||
|
Model: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Providers: ProvidersConfig{
|
||||||
|
Zhipu: ProviderConfig{APIKey: "zhipu-key"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should use default provider name since no model is specified
|
||||||
|
if result[0].ModelName != "zhipu" {
|
||||||
|
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "zhipu")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests for buildModelWithProtocol helper function
|
||||||
|
|
||||||
|
func TestBuildModelWithProtocol_NoPrefix(t *testing.T) {
|
||||||
|
result := buildModelWithProtocol("openai", "gpt-5.2")
|
||||||
|
if result != "openai/gpt-5.2" {
|
||||||
|
t.Errorf("buildModelWithProtocol(openai, gpt-5.2) = %q, want %q", result, "openai/gpt-5.2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildModelWithProtocol_AlreadyHasPrefix(t *testing.T) {
|
||||||
|
result := buildModelWithProtocol("openrouter", "openrouter/auto")
|
||||||
|
if result != "openrouter/auto" {
|
||||||
|
t.Errorf("buildModelWithProtocol(openrouter, openrouter/auto) = %q, want %q", result, "openrouter/auto")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildModelWithProtocol_DifferentPrefix(t *testing.T) {
|
||||||
|
result := buildModelWithProtocol("anthropic", "openrouter/claude-sonnet-4.6")
|
||||||
|
if result != "openrouter/claude-sonnet-4.6" {
|
||||||
|
t.Errorf("buildModelWithProtocol(anthropic, openrouter/claude-sonnet-4.6) = %q, want %q", result, "openrouter/claude-sonnet-4.6")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test for legacy config with protocol prefix in model name
|
||||||
|
func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Agents: AgentsConfig{
|
||||||
|
Defaults: AgentDefaults{
|
||||||
|
Provider: "", // No explicit provider
|
||||||
|
Model: "openrouter/auto", // Model already has protocol prefix
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Providers: ProvidersConfig{
|
||||||
|
OpenRouter: ProviderConfig{APIKey: "sk-or-test"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
|
if len(result) < 1 {
|
||||||
|
t.Fatalf("len(result) = %d, want at least 1", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// First provider should use userModel as ModelName for backward compatibility
|
||||||
|
if result[0].ModelName != "openrouter/auto" {
|
||||||
|
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openrouter/auto")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model should NOT have duplicated prefix
|
||||||
|
if result[0].Model != "openrouter/auto" {
|
||||||
|
t.Errorf("Model = %q, want %q (should not duplicate prefix)", result[0].Model, "openrouter/auto")
|
||||||
|
}
|
||||||
|
}
|
||||||
235
pkg/config/model_config_test.go
Normal file
235
pkg/config/model_config_test.go
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetModelConfig_Found(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
ModelList: []ModelConfig{
|
||||||
|
{ModelName: "test-model", Model: "openai/gpt-4o", APIKey: "key1"},
|
||||||
|
{ModelName: "other-model", Model: "anthropic/claude", APIKey: "key2"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := cfg.GetModelConfig("test-model")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetModelConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Model != "openai/gpt-4o" {
|
||||||
|
t.Errorf("Model = %q, want %q", result.Model, "openai/gpt-4o")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetModelConfig_NotFound(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
ModelList: []ModelConfig{
|
||||||
|
{ModelName: "test-model", Model: "openai/gpt-4o", APIKey: "key1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := cfg.GetModelConfig("nonexistent")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("GetModelConfig() expected error for nonexistent model")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetModelConfig_EmptyList(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
ModelList: []ModelConfig{},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := cfg.GetModelConfig("any-model")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("GetModelConfig() expected error for empty model list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetModelConfig_RoundRobin(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
ModelList: []ModelConfig{
|
||||||
|
{ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKey: "key1"},
|
||||||
|
{ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKey: "key2"},
|
||||||
|
{ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKey: "key3"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test round-robin distribution
|
||||||
|
results := make(map[string]int)
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
result, err := cfg.GetModelConfig("lb-model")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetModelConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
results[result.Model]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each model should appear roughly 10 times (30 calls / 3 models)
|
||||||
|
for model, count := range results {
|
||||||
|
if count < 5 || count > 15 {
|
||||||
|
t.Errorf("Model %s appeared %d times, expected ~10", model, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetModelConfig_Concurrent(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
ModelList: []ModelConfig{
|
||||||
|
{ModelName: "concurrent-model", Model: "openai/gpt-4o-1", APIKey: "key1"},
|
||||||
|
{ModelName: "concurrent-model", Model: "openai/gpt-4o-2", APIKey: "key2"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const goroutines = 100
|
||||||
|
const iterations = 10
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errors := make(chan error, goroutines*iterations)
|
||||||
|
|
||||||
|
for i := 0; i < goroutines; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < iterations; j++ {
|
||||||
|
_, err := cfg.GetModelConfig("concurrent-model")
|
||||||
|
if err != nil {
|
||||||
|
errors <- err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(errors)
|
||||||
|
|
||||||
|
for err := range errors {
|
||||||
|
t.Errorf("Concurrent GetModelConfig() error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModelConfig_Validate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config ModelConfig
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid config",
|
||||||
|
config: ModelConfig{
|
||||||
|
ModelName: "test",
|
||||||
|
Model: "openai/gpt-4o",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing model_name",
|
||||||
|
config: ModelConfig{
|
||||||
|
Model: "openai/gpt-4o",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing model",
|
||||||
|
config: ModelConfig{
|
||||||
|
ModelName: "test",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty config",
|
||||||
|
config: ModelConfig{},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.config.Validate()
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_ValidateModelList(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config *Config
|
||||||
|
wantErr bool
|
||||||
|
errMsg string // partial error message to check
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid list",
|
||||||
|
config: &Config{
|
||||||
|
ModelList: []ModelConfig{
|
||||||
|
{ModelName: "test1", Model: "openai/gpt-4o"},
|
||||||
|
{ModelName: "test2", Model: "anthropic/claude"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid entry",
|
||||||
|
config: &Config{
|
||||||
|
ModelList: []ModelConfig{
|
||||||
|
{ModelName: "test1", Model: "openai/gpt-4o"},
|
||||||
|
{ModelName: "", Model: "anthropic/claude"}, // missing model_name
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "model_name is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty list",
|
||||||
|
config: &Config{
|
||||||
|
ModelList: []ModelConfig{},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Load balancing: multiple entries with same model_name are allowed
|
||||||
|
name: "duplicate model_name for load balancing",
|
||||||
|
config: &Config{
|
||||||
|
ModelList: []ModelConfig{
|
||||||
|
{ModelName: "gpt-4", Model: "openai/gpt-4o", APIKey: "key1"},
|
||||||
|
{ModelName: "gpt-4", Model: "openai/gpt-4-turbo", APIKey: "key2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false, // Changed: duplicates are allowed for load balancing
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Load balancing: non-adjacent entries with same model_name are also allowed
|
||||||
|
name: "duplicate model_name non-adjacent for load balancing",
|
||||||
|
config: &Config{
|
||||||
|
ModelList: []ModelConfig{
|
||||||
|
{ModelName: "model-a", Model: "openai/gpt-4o"},
|
||||||
|
{ModelName: "model-b", Model: "anthropic/claude"},
|
||||||
|
{ModelName: "model-a", Model: "openai/gpt-4-turbo"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false, // Changed: duplicates are allowed for load balancing
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.config.ValidateModelList()
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ValidateModelList() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if err != nil && tt.errMsg != "" {
|
||||||
|
if !strings.Contains(err.Error(), tt.errMsg) {
|
||||||
|
t.Errorf("ValidateModelList() error = %v, want error containing %q", err, tt.errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,9 @@ var supportedProviders = map[string]bool{
|
||||||
"zhipu": true,
|
"zhipu": true,
|
||||||
"vllm": true,
|
"vllm": true,
|
||||||
"gemini": true,
|
"gemini": true,
|
||||||
|
"qwen": true,
|
||||||
|
"deepseek": true,
|
||||||
|
"github_copilot": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
var supportedChannels = map[string]bool{
|
var supportedChannels = map[string]bool{
|
||||||
|
|
@ -76,7 +79,7 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
|
||||||
cfg.Agents.Defaults.MaxTokens = int(v)
|
cfg.Agents.Defaults.MaxTokens = int(v)
|
||||||
}
|
}
|
||||||
if v, ok := getFloat(defaults, "temperature"); ok {
|
if v, ok := getFloat(defaults, "temperature"); ok {
|
||||||
cfg.Agents.Defaults.Temperature = v
|
cfg.Agents.Defaults.Temperature = &v
|
||||||
}
|
}
|
||||||
if v, ok := getFloat(defaults, "max_tool_iterations"); ok {
|
if v, ok := getFloat(defaults, "max_tool_iterations"); ok {
|
||||||
cfg.Agents.Defaults.MaxToolIterations = int(v)
|
cfg.Agents.Defaults.MaxToolIterations = int(v)
|
||||||
|
|
@ -256,6 +259,15 @@ func MergeConfig(existing, incoming *config.Config) *config.Config {
|
||||||
if existing.Providers.Gemini.APIKey == "" {
|
if existing.Providers.Gemini.APIKey == "" {
|
||||||
existing.Providers.Gemini = incoming.Providers.Gemini
|
existing.Providers.Gemini = incoming.Providers.Gemini
|
||||||
}
|
}
|
||||||
|
if existing.Providers.DeepSeek.APIKey == "" {
|
||||||
|
existing.Providers.DeepSeek = incoming.Providers.DeepSeek
|
||||||
|
}
|
||||||
|
if existing.Providers.GitHubCopilot.APIBase == "" {
|
||||||
|
existing.Providers.GitHubCopilot = incoming.Providers.GitHubCopilot
|
||||||
|
}
|
||||||
|
if existing.Providers.Qwen.APIKey == "" {
|
||||||
|
existing.Providers.Qwen = incoming.Providers.Qwen
|
||||||
|
}
|
||||||
|
|
||||||
if !existing.Channels.Telegram.Enabled && incoming.Channels.Telegram.Enabled {
|
if !existing.Channels.Telegram.Enabled && incoming.Channels.Telegram.Enabled {
|
||||||
existing.Channels.Telegram = incoming.Channels.Telegram
|
existing.Channels.Telegram = incoming.Channels.Telegram
|
||||||
|
|
|
||||||
|
|
@ -180,8 +180,8 @@ func TestConvertConfig(t *testing.T) {
|
||||||
t.Run("unsupported provider warning", func(t *testing.T) {
|
t.Run("unsupported provider warning", func(t *testing.T) {
|
||||||
data := map[string]interface{}{
|
data := map[string]interface{}{
|
||||||
"providers": map[string]interface{}{
|
"providers": map[string]interface{}{
|
||||||
"deepseek": map[string]interface{}{
|
"unknown_provider": map[string]interface{}{
|
||||||
"api_key": "sk-deep-test",
|
"api_key": "sk-test",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +193,7 @@ func TestConvertConfig(t *testing.T) {
|
||||||
if len(warnings) != 1 {
|
if len(warnings) != 1 {
|
||||||
t.Fatalf("expected 1 warning, got %d", len(warnings))
|
t.Fatalf("expected 1 warning, got %d", len(warnings))
|
||||||
}
|
}
|
||||||
if warnings[0] != "Provider 'deepseek' not supported in PicoClaw, skipping" {
|
if warnings[0] != "Provider 'unknown_provider' not supported in PicoClaw, skipping" {
|
||||||
t.Errorf("unexpected warning: %s", warnings[0])
|
t.Errorf("unexpected warning: %s", warnings[0])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -275,8 +275,11 @@ func TestConvertConfig(t *testing.T) {
|
||||||
if cfg.Agents.Defaults.MaxTokens != 4096 {
|
if cfg.Agents.Defaults.MaxTokens != 4096 {
|
||||||
t.Errorf("MaxTokens = %d, want %d", cfg.Agents.Defaults.MaxTokens, 4096)
|
t.Errorf("MaxTokens = %d, want %d", cfg.Agents.Defaults.MaxTokens, 4096)
|
||||||
}
|
}
|
||||||
if cfg.Agents.Defaults.Temperature != 0.5 {
|
if cfg.Agents.Defaults.Temperature == nil {
|
||||||
t.Errorf("Temperature = %f, want %f", cfg.Agents.Defaults.Temperature, 0.5)
|
t.Fatalf("Temperature is nil, want %f", 0.5)
|
||||||
|
}
|
||||||
|
if *cfg.Agents.Defaults.Temperature != 0.5 {
|
||||||
|
t.Errorf("Temperature = %f, want %f", *cfg.Agents.Defaults.Temperature, 0.5)
|
||||||
}
|
}
|
||||||
if cfg.Agents.Defaults.Workspace != "~/.picoclaw/workspace" {
|
if cfg.Agents.Defaults.Workspace != "~/.picoclaw/workspace" {
|
||||||
t.Errorf("Workspace = %q, want %q", cfg.Agents.Defaults.Workspace, "~/.picoclaw/workspace")
|
t.Errorf("Workspace = %q, want %q", cfg.Agents.Defaults.Workspace, "~/.picoclaw/workspace")
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDef
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Provider) GetDefaultModel() string {
|
func (p *Provider) GetDefaultModel() string {
|
||||||
return "claude-sonnet-4-5-20250929"
|
return "claude-sonnet-4.6"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Provider) BaseURL() string {
|
func (p *Provider) BaseURL() string {
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,14 @@ func TestBuildParams_BasicMessage(t *testing.T) {
|
||||||
messages := []Message{
|
messages := []Message{
|
||||||
{Role: "user", Content: "Hello"},
|
{Role: "user", Content: "Hello"},
|
||||||
}
|
}
|
||||||
params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{
|
params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]interface{}{
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("buildParams() error: %v", err)
|
t.Fatalf("buildParams() error: %v", err)
|
||||||
}
|
}
|
||||||
if string(params.Model) != "claude-sonnet-4-5-20250929" {
|
if string(params.Model) != "claude-sonnet-4.6" {
|
||||||
t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-5-20250929")
|
t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4.6")
|
||||||
}
|
}
|
||||||
if params.MaxTokens != 1024 {
|
if params.MaxTokens != 1024 {
|
||||||
t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens)
|
t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens)
|
||||||
|
|
@ -37,7 +37,7 @@ func TestBuildParams_SystemMessage(t *testing.T) {
|
||||||
{Role: "system", Content: "You are helpful"},
|
{Role: "system", Content: "You are helpful"},
|
||||||
{Role: "user", Content: "Hi"},
|
{Role: "user", Content: "Hi"},
|
||||||
}
|
}
|
||||||
params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
|
params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]interface{}{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("buildParams() error: %v", err)
|
t.Fatalf("buildParams() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -68,7 +68,7 @@ func TestBuildParams_ToolCallMessage(t *testing.T) {
|
||||||
},
|
},
|
||||||
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
|
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
|
||||||
}
|
}
|
||||||
params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
|
params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]interface{}{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("buildParams() error: %v", err)
|
t.Fatalf("buildParams() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +94,7 @@ func TestBuildParams_WithTools(t *testing.T) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
params, err := buildParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4-5-20250929", map[string]interface{}{})
|
params, err := buildParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4.6", map[string]interface{}{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("buildParams() error: %v", err)
|
t.Fatalf("buildParams() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -178,7 +178,7 @@ func TestProvider_ChatRoundTrip(t *testing.T) {
|
||||||
|
|
||||||
provider := NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token"))
|
provider := NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token"))
|
||||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
messages := []Message{{Role: "user", Content: "Hello"}}
|
||||||
resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{"max_tokens": 1024})
|
resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]interface{}{"max_tokens": 1024})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error: %v", err)
|
t.Fatalf("Chat() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -195,8 +195,8 @@ func TestProvider_ChatRoundTrip(t *testing.T) {
|
||||||
|
|
||||||
func TestProvider_GetDefaultModel(t *testing.T) {
|
func TestProvider_GetDefaultModel(t *testing.T) {
|
||||||
p := NewProvider("test-token")
|
p := NewProvider("test-token")
|
||||||
if got := p.GetDefaultModel(); got != "claude-sonnet-4-5-20250929" {
|
if got := p.GetDefaultModel(); got != "claude-sonnet-4.6" {
|
||||||
t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4-5-20250929")
|
t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4.6")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -247,7 +247,7 @@ func TestProvider_ChatUsesTokenSource(t *testing.T) {
|
||||||
return "refreshed-token", nil
|
return "refreshed-token", nil
|
||||||
}, server.URL)
|
}, server.URL)
|
||||||
|
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hello"}}, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hello"}}, nil, "claude-sonnet-4.6", map[string]interface{}{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error: %v", err)
|
t.Fatalf("Chat() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
827
pkg/providers/antigravity_provider.go
Normal file
827
pkg/providers/antigravity_provider.go
Normal file
|
|
@ -0,0 +1,827 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
antigravityBaseURL = "https://cloudcode-pa.googleapis.com"
|
||||||
|
antigravityDefaultModel = "gemini-3-flash"
|
||||||
|
antigravityUserAgent = "antigravity"
|
||||||
|
antigravityXGoogClient = "google-cloud-sdk vscode_cloudshelleditor/0.1"
|
||||||
|
antigravityVersion = "1.15.8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AntigravityProvider implements LLMProvider using Google's Cloud Code Assist (Antigravity) API.
|
||||||
|
// This provider authenticates via Google OAuth and provides access to models like Claude and Gemini
|
||||||
|
// through Google's infrastructure.
|
||||||
|
type AntigravityProvider struct {
|
||||||
|
tokenSource func() (string, string, error) // Returns (accessToken, projectID, error)
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAntigravityProvider creates a new Antigravity provider using stored auth credentials.
|
||||||
|
func NewAntigravityProvider() *AntigravityProvider {
|
||||||
|
return &AntigravityProvider{
|
||||||
|
tokenSource: createAntigravityTokenSource(),
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 120 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat implements LLMProvider.Chat using the Cloud Code Assist v1internal API.
|
||||||
|
// The v1internal endpoint wraps the standard Gemini request in an envelope with
|
||||||
|
// project, model, request, requestType, userAgent, and requestId fields.
|
||||||
|
func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||||
|
accessToken, projectID, err := p.tokenSource()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("antigravity auth: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if model == "" || model == "antigravity" || model == "google-antigravity" {
|
||||||
|
model = antigravityDefaultModel
|
||||||
|
}
|
||||||
|
// Strip provider prefixes if present
|
||||||
|
model = strings.TrimPrefix(model, "google-antigravity/")
|
||||||
|
model = strings.TrimPrefix(model, "antigravity/")
|
||||||
|
|
||||||
|
logger.DebugCF("provider.antigravity", "Starting chat", map[string]interface{}{
|
||||||
|
"model": model,
|
||||||
|
"project": projectID,
|
||||||
|
"requestId": fmt.Sprintf("agent-%d-%s", time.Now().UnixMilli(), randomString(9)),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build the inner Gemini-format request
|
||||||
|
innerRequest := p.buildRequest(messages, tools, model, options)
|
||||||
|
|
||||||
|
// Wrap in v1internal envelope (matches pi-ai SDK format)
|
||||||
|
envelope := map[string]interface{}{
|
||||||
|
"project": projectID,
|
||||||
|
"model": model,
|
||||||
|
"request": innerRequest,
|
||||||
|
"requestType": "agent",
|
||||||
|
"userAgent": antigravityUserAgent,
|
||||||
|
"requestId": fmt.Sprintf("agent-%d-%s", time.Now().UnixMilli(), randomString(9)),
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(envelope)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshaling request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build API URL — uses Cloud Code Assist v1internal streaming endpoint
|
||||||
|
apiURL := fmt.Sprintf("%s/v1internal:streamGenerateContent?alt=sse", antigravityBaseURL)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers matching the pi-ai SDK antigravity format
|
||||||
|
clientMetadata, _ := json.Marshal(map[string]string{
|
||||||
|
"ideType": "IDE_UNSPECIFIED",
|
||||||
|
"platform": "PLATFORM_UNSPECIFIED",
|
||||||
|
"pluginType": "GEMINI",
|
||||||
|
})
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "text/event-stream")
|
||||||
|
req.Header.Set("User-Agent", fmt.Sprintf("antigravity/%s linux/amd64", antigravityVersion))
|
||||||
|
req.Header.Set("X-Goog-Api-Client", antigravityXGoogClient)
|
||||||
|
req.Header.Set("Client-Metadata", string(clientMetadata))
|
||||||
|
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("antigravity API call: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
logger.ErrorCF("provider.antigravity", "API call failed", map[string]interface{}{
|
||||||
|
"status_code": resp.StatusCode,
|
||||||
|
"response": string(respBody),
|
||||||
|
"model": model,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil, p.parseAntigravityError(resp.StatusCode, respBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response is always SSE from streamGenerateContent — each line is "data: {...}"
|
||||||
|
// with a "response" wrapper containing the standard Gemini response
|
||||||
|
llmResp, err := p.parseSSEResponse(string(respBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for empty response (some models might return valid success but empty text)
|
||||||
|
if llmResp.Content == "" && len(llmResp.ToolCalls) == 0 {
|
||||||
|
return nil, fmt.Errorf("antigravity: model returned an empty response (this model might be invalid or restricted)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return llmResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultModel returns the default model identifier.
|
||||||
|
func (p *AntigravityProvider) GetDefaultModel() string {
|
||||||
|
return antigravityDefaultModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Request building ---
|
||||||
|
|
||||||
|
type antigravityRequest struct {
|
||||||
|
Contents []antigravityContent `json:"contents"`
|
||||||
|
Tools []antigravityTool `json:"tools,omitempty"`
|
||||||
|
SystemPrompt *antigravitySystemPrompt `json:"systemInstruction,omitempty"`
|
||||||
|
Config *antigravityGenConfig `json:"generationConfig,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type antigravityContent struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Parts []antigravityPart `json:"parts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type antigravityPart struct {
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ThoughtSignature string `json:"thoughtSignature,omitempty"`
|
||||||
|
ThoughtSignatureSnake string `json:"thought_signature,omitempty"`
|
||||||
|
FunctionCall *antigravityFunctionCall `json:"functionCall,omitempty"`
|
||||||
|
FunctionResponse *antigravityFunctionResponse `json:"functionResponse,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type antigravityFunctionCall struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Args map[string]interface{} `json:"args"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type antigravityFunctionResponse struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Response map[string]interface{} `json:"response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type antigravityTool struct {
|
||||||
|
FunctionDeclarations []antigravityFuncDecl `json:"functionDeclarations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type antigravityFuncDecl struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Parameters interface{} `json:"parameters,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type antigravitySystemPrompt struct {
|
||||||
|
Parts []antigravityPart `json:"parts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type antigravityGenConfig struct {
|
||||||
|
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
|
||||||
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) antigravityRequest {
|
||||||
|
req := antigravityRequest{}
|
||||||
|
toolCallNames := make(map[string]string)
|
||||||
|
|
||||||
|
// Build contents from messages
|
||||||
|
for _, msg := range messages {
|
||||||
|
switch msg.Role {
|
||||||
|
case "system":
|
||||||
|
req.SystemPrompt = &antigravitySystemPrompt{
|
||||||
|
Parts: []antigravityPart{{Text: msg.Content}},
|
||||||
|
}
|
||||||
|
case "user":
|
||||||
|
if msg.ToolCallID != "" {
|
||||||
|
toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames)
|
||||||
|
// Tool result
|
||||||
|
req.Contents = append(req.Contents, antigravityContent{
|
||||||
|
Role: "user",
|
||||||
|
Parts: []antigravityPart{{
|
||||||
|
FunctionResponse: &antigravityFunctionResponse{
|
||||||
|
Name: toolName,
|
||||||
|
Response: map[string]interface{}{
|
||||||
|
"result": msg.Content,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
req.Contents = append(req.Contents, antigravityContent{
|
||||||
|
Role: "user",
|
||||||
|
Parts: []antigravityPart{{Text: msg.Content}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case "assistant":
|
||||||
|
content := antigravityContent{
|
||||||
|
Role: "model",
|
||||||
|
}
|
||||||
|
if msg.Content != "" {
|
||||||
|
content.Parts = append(content.Parts, antigravityPart{Text: msg.Content})
|
||||||
|
}
|
||||||
|
for _, tc := range msg.ToolCalls {
|
||||||
|
toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc)
|
||||||
|
if toolName == "" {
|
||||||
|
logger.WarnCF("provider.antigravity", "Skipping tool call with empty name in history", map[string]interface{}{
|
||||||
|
"tool_call_id": tc.ID,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tc.ID != "" {
|
||||||
|
toolCallNames[tc.ID] = toolName
|
||||||
|
}
|
||||||
|
content.Parts = append(content.Parts, antigravityPart{
|
||||||
|
ThoughtSignature: thoughtSignature,
|
||||||
|
ThoughtSignatureSnake: thoughtSignature,
|
||||||
|
FunctionCall: &antigravityFunctionCall{
|
||||||
|
Name: toolName,
|
||||||
|
Args: toolArgs,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(content.Parts) > 0 {
|
||||||
|
req.Contents = append(req.Contents, content)
|
||||||
|
}
|
||||||
|
case "tool":
|
||||||
|
toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames)
|
||||||
|
req.Contents = append(req.Contents, antigravityContent{
|
||||||
|
Role: "user",
|
||||||
|
Parts: []antigravityPart{{
|
||||||
|
FunctionResponse: &antigravityFunctionResponse{
|
||||||
|
Name: toolName,
|
||||||
|
Response: map[string]interface{}{
|
||||||
|
"result": msg.Content,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build tools (sanitize schemas for Gemini compatibility)
|
||||||
|
if len(tools) > 0 {
|
||||||
|
var funcDecls []antigravityFuncDecl
|
||||||
|
for _, t := range tools {
|
||||||
|
if t.Type != "function" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
params := sanitizeSchemaForGemini(t.Function.Parameters)
|
||||||
|
funcDecls = append(funcDecls, antigravityFuncDecl{
|
||||||
|
Name: t.Function.Name,
|
||||||
|
Description: t.Function.Description,
|
||||||
|
Parameters: params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(funcDecls) > 0 {
|
||||||
|
req.Tools = []antigravityTool{{FunctionDeclarations: funcDecls}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generation config
|
||||||
|
config := &antigravityGenConfig{}
|
||||||
|
if val, ok := options["max_tokens"]; ok {
|
||||||
|
if maxTokens, ok := val.(int); ok && maxTokens > 0 {
|
||||||
|
config.MaxOutputTokens = maxTokens
|
||||||
|
} else if maxTokens, ok := val.(float64); ok && maxTokens > 0 {
|
||||||
|
config.MaxOutputTokens = int(maxTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if temp, ok := options["temperature"].(float64); ok {
|
||||||
|
config.Temperature = temp
|
||||||
|
}
|
||||||
|
if config.MaxOutputTokens > 0 || config.Temperature > 0 {
|
||||||
|
req.Config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStoredToolCall(tc ToolCall) (string, map[string]interface{}, string) {
|
||||||
|
name := tc.Name
|
||||||
|
args := tc.Arguments
|
||||||
|
thoughtSignature := ""
|
||||||
|
|
||||||
|
if name == "" && tc.Function != nil {
|
||||||
|
name = tc.Function.Name
|
||||||
|
thoughtSignature = tc.Function.ThoughtSignature
|
||||||
|
} else if tc.Function != nil {
|
||||||
|
thoughtSignature = tc.Function.ThoughtSignature
|
||||||
|
}
|
||||||
|
|
||||||
|
if args == nil {
|
||||||
|
args = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" {
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil {
|
||||||
|
args = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return name, args, thoughtSignature
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveToolResponseName(toolCallID string, toolCallNames map[string]string) string {
|
||||||
|
if toolCallID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if name, ok := toolCallNames[toolCallID]; ok && name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
return inferToolNameFromCallID(toolCallID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferToolNameFromCallID(toolCallID string) string {
|
||||||
|
if !strings.HasPrefix(toolCallID, "call_") {
|
||||||
|
return toolCallID
|
||||||
|
}
|
||||||
|
|
||||||
|
rest := strings.TrimPrefix(toolCallID, "call_")
|
||||||
|
if idx := strings.LastIndex(rest, "_"); idx > 0 {
|
||||||
|
candidate := rest[:idx]
|
||||||
|
if candidate != "" {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolCallID
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Response parsing ---
|
||||||
|
|
||||||
|
type antigravityJSONResponse struct {
|
||||||
|
Candidates []struct {
|
||||||
|
Content struct {
|
||||||
|
Parts []struct {
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ThoughtSignature string `json:"thoughtSignature,omitempty"`
|
||||||
|
ThoughtSignatureSnake string `json:"thought_signature,omitempty"`
|
||||||
|
FunctionCall *antigravityFunctionCall `json:"functionCall,omitempty"`
|
||||||
|
} `json:"parts"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
} `json:"content"`
|
||||||
|
FinishReason string `json:"finishReason"`
|
||||||
|
} `json:"candidates"`
|
||||||
|
UsageMetadata struct {
|
||||||
|
PromptTokenCount int `json:"promptTokenCount"`
|
||||||
|
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||||
|
TotalTokenCount int `json:"totalTokenCount"`
|
||||||
|
} `json:"usageMetadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AntigravityProvider) parseJSONResponse(body []byte) (*LLMResponse, error) {
|
||||||
|
var resp antigravityJSONResponse
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing antigravity response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Candidates) == 0 {
|
||||||
|
return nil, fmt.Errorf("antigravity: no candidates in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate := resp.Candidates[0]
|
||||||
|
var contentParts []string
|
||||||
|
var toolCalls []ToolCall
|
||||||
|
|
||||||
|
for _, part := range candidate.Content.Parts {
|
||||||
|
if part.Text != "" {
|
||||||
|
contentParts = append(contentParts, part.Text)
|
||||||
|
}
|
||||||
|
if part.FunctionCall != nil {
|
||||||
|
argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
|
||||||
|
toolCalls = append(toolCalls, ToolCall{
|
||||||
|
ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()),
|
||||||
|
Name: part.FunctionCall.Name,
|
||||||
|
Arguments: part.FunctionCall.Args,
|
||||||
|
Function: &FunctionCall{
|
||||||
|
Name: part.FunctionCall.Name,
|
||||||
|
Arguments: string(argumentsJSON),
|
||||||
|
ThoughtSignature: extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finishReason := "stop"
|
||||||
|
if len(toolCalls) > 0 {
|
||||||
|
finishReason = "tool_calls"
|
||||||
|
}
|
||||||
|
if candidate.FinishReason == "MAX_TOKENS" {
|
||||||
|
finishReason = "length"
|
||||||
|
}
|
||||||
|
|
||||||
|
var usage *UsageInfo
|
||||||
|
if resp.UsageMetadata.TotalTokenCount > 0 {
|
||||||
|
usage = &UsageInfo{
|
||||||
|
PromptTokens: resp.UsageMetadata.PromptTokenCount,
|
||||||
|
CompletionTokens: resp.UsageMetadata.CandidatesTokenCount,
|
||||||
|
TotalTokens: resp.UsageMetadata.TotalTokenCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LLMResponse{
|
||||||
|
Content: strings.Join(contentParts, ""),
|
||||||
|
ToolCalls: toolCalls,
|
||||||
|
FinishReason: finishReason,
|
||||||
|
Usage: usage,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error) {
|
||||||
|
var contentParts []string
|
||||||
|
var toolCalls []ToolCall
|
||||||
|
var usage *UsageInfo
|
||||||
|
var finishReason string
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(body))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if !strings.HasPrefix(line, "data: ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data := strings.TrimPrefix(line, "data: ")
|
||||||
|
if data == "[DONE]" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1internal SSE wraps the Gemini response in a "response" field
|
||||||
|
var sseChunk struct {
|
||||||
|
Response antigravityJSONResponse `json:"response"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(data), &sseChunk); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp := sseChunk.Response
|
||||||
|
|
||||||
|
for _, candidate := range resp.Candidates {
|
||||||
|
for _, part := range candidate.Content.Parts {
|
||||||
|
if part.Text != "" {
|
||||||
|
contentParts = append(contentParts, part.Text)
|
||||||
|
}
|
||||||
|
if part.FunctionCall != nil {
|
||||||
|
argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
|
||||||
|
toolCalls = append(toolCalls, ToolCall{
|
||||||
|
ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()),
|
||||||
|
Name: part.FunctionCall.Name,
|
||||||
|
Arguments: part.FunctionCall.Args,
|
||||||
|
Function: &FunctionCall{
|
||||||
|
Name: part.FunctionCall.Name,
|
||||||
|
Arguments: string(argumentsJSON),
|
||||||
|
ThoughtSignature: extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if candidate.FinishReason != "" {
|
||||||
|
finishReason = candidate.FinishReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.UsageMetadata.TotalTokenCount > 0 {
|
||||||
|
usage = &UsageInfo{
|
||||||
|
PromptTokens: resp.UsageMetadata.PromptTokenCount,
|
||||||
|
CompletionTokens: resp.UsageMetadata.CandidatesTokenCount,
|
||||||
|
TotalTokens: resp.UsageMetadata.TotalTokenCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mappedFinish := "stop"
|
||||||
|
if len(toolCalls) > 0 {
|
||||||
|
mappedFinish = "tool_calls"
|
||||||
|
}
|
||||||
|
if finishReason == "MAX_TOKENS" {
|
||||||
|
mappedFinish = "length"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LLMResponse{
|
||||||
|
Content: strings.Join(contentParts, ""),
|
||||||
|
ToolCalls: toolCalls,
|
||||||
|
FinishReason: mappedFinish,
|
||||||
|
Usage: usage,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractPartThoughtSignature(thoughtSignature string, thoughtSignatureSnake string) string {
|
||||||
|
if thoughtSignature != "" {
|
||||||
|
return thoughtSignature
|
||||||
|
}
|
||||||
|
if thoughtSignatureSnake != "" {
|
||||||
|
return thoughtSignatureSnake
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Schema sanitization ---
|
||||||
|
|
||||||
|
// Google/Gemini doesn't support many JSON Schema keywords that other providers accept.
|
||||||
|
var geminiUnsupportedKeywords = map[string]bool{
|
||||||
|
"patternProperties": true,
|
||||||
|
"additionalProperties": true,
|
||||||
|
"$schema": true,
|
||||||
|
"$id": true,
|
||||||
|
"$ref": true,
|
||||||
|
"$defs": true,
|
||||||
|
"definitions": true,
|
||||||
|
"examples": true,
|
||||||
|
"minLength": true,
|
||||||
|
"maxLength": true,
|
||||||
|
"minimum": true,
|
||||||
|
"maximum": true,
|
||||||
|
"multipleOf": true,
|
||||||
|
"pattern": true,
|
||||||
|
"format": true,
|
||||||
|
"minItems": true,
|
||||||
|
"maxItems": true,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"minProperties": true,
|
||||||
|
"maxProperties": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeSchemaForGemini(schema map[string]interface{}) map[string]interface{} {
|
||||||
|
if schema == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]interface{})
|
||||||
|
for k, v := range schema {
|
||||||
|
if geminiUnsupportedKeywords[k] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Recursively sanitize nested objects
|
||||||
|
switch val := v.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
result[k] = sanitizeSchemaForGemini(val)
|
||||||
|
case []interface{}:
|
||||||
|
sanitized := make([]interface{}, len(val))
|
||||||
|
for i, item := range val {
|
||||||
|
if m, ok := item.(map[string]interface{}); ok {
|
||||||
|
sanitized[i] = sanitizeSchemaForGemini(m)
|
||||||
|
} else {
|
||||||
|
sanitized[i] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[k] = sanitized
|
||||||
|
default:
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure top-level has type: "object" if properties are present
|
||||||
|
if _, hasProps := result["properties"]; hasProps {
|
||||||
|
if _, hasType := result["type"]; !hasType {
|
||||||
|
result["type"] = "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Token source ---
|
||||||
|
|
||||||
|
func createAntigravityTokenSource() func() (string, string, error) {
|
||||||
|
return func() (string, string, error) {
|
||||||
|
cred, err := auth.GetCredential("google-antigravity")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("loading auth credentials: %w", err)
|
||||||
|
}
|
||||||
|
if cred == nil {
|
||||||
|
return "", "", fmt.Errorf("no credentials for google-antigravity. Run: picoclaw auth login --provider google-antigravity")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh if needed
|
||||||
|
if cred.NeedsRefresh() && cred.RefreshToken != "" {
|
||||||
|
oauthCfg := auth.GoogleAntigravityOAuthConfig()
|
||||||
|
refreshed, err := auth.RefreshAccessToken(cred, oauthCfg)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("refreshing token: %w", err)
|
||||||
|
}
|
||||||
|
refreshed.Email = cred.Email
|
||||||
|
if refreshed.ProjectID == "" {
|
||||||
|
refreshed.ProjectID = cred.ProjectID
|
||||||
|
}
|
||||||
|
if err := auth.SetCredential("google-antigravity", refreshed); err != nil {
|
||||||
|
return "", "", fmt.Errorf("saving refreshed token: %w", err)
|
||||||
|
}
|
||||||
|
cred = refreshed
|
||||||
|
}
|
||||||
|
|
||||||
|
if cred.IsExpired() {
|
||||||
|
return "", "", fmt.Errorf("antigravity credentials expired. Run: picoclaw auth login --provider google-antigravity")
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID := cred.ProjectID
|
||||||
|
if projectID == "" {
|
||||||
|
// Try to fetch project ID from API
|
||||||
|
fetchedID, err := FetchAntigravityProjectID(cred.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("provider.antigravity", "Could not fetch project ID, using fallback", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
projectID = "rising-fact-p41fc" // Default fallback (same as OpenCode)
|
||||||
|
} else {
|
||||||
|
projectID = fetchedID
|
||||||
|
cred.ProjectID = projectID
|
||||||
|
_ = auth.SetCredential("google-antigravity", cred)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cred.AccessToken, projectID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchAntigravityProjectID retrieves the Google Cloud project ID from the loadCodeAssist endpoint.
|
||||||
|
func FetchAntigravityProjectID(accessToken string) (string, error) {
|
||||||
|
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"metadata": map[string]interface{}{
|
||||||
|
"ideType": "IDE_UNSPECIFIED",
|
||||||
|
"platform": "PLATFORM_UNSPECIFIED",
|
||||||
|
"pluginType": "GEMINI",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", antigravityBaseURL+"/v1internal:loadCodeAssist", bytes.NewReader(reqBody))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", antigravityUserAgent)
|
||||||
|
req.Header.Set("X-Goog-Api-Client", antigravityXGoogClient)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("loadCodeAssist failed: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
CloudAICompanionProject string `json:"cloudaicompanionProject"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.CloudAICompanionProject == "" {
|
||||||
|
return "", fmt.Errorf("no project ID in loadCodeAssist response")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.CloudAICompanionProject, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchAntigravityModels fetches available models from the Cloud Code Assist API.
|
||||||
|
func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelInfo, error) {
|
||||||
|
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"project": projectID,
|
||||||
|
})
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", antigravityBaseURL+"/v1internal:fetchAvailableModels", bytes.NewReader(reqBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", antigravityUserAgent)
|
||||||
|
req.Header.Set("X-Goog-Api-Client", antigravityXGoogClient)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("fetchAvailableModels failed (HTTP %d): %s", resp.StatusCode, truncateString(string(body), 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Models map[string]struct {
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
QuotaInfo struct {
|
||||||
|
RemainingFraction interface{} `json:"remainingFraction"`
|
||||||
|
ResetTime string `json:"resetTime"`
|
||||||
|
IsExhausted bool `json:"isExhausted"`
|
||||||
|
} `json:"quotaInfo"`
|
||||||
|
} `json:"models"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing models response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var models []AntigravityModelInfo
|
||||||
|
for id, info := range result.Models {
|
||||||
|
models = append(models, AntigravityModelInfo{
|
||||||
|
ID: id,
|
||||||
|
DisplayName: info.DisplayName,
|
||||||
|
IsExhausted: info.QuotaInfo.IsExhausted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure gemini-3-flash-preview and gemini-3-flash are in the list if they aren't already
|
||||||
|
hasFlashPreview := false
|
||||||
|
hasFlash := false
|
||||||
|
for _, m := range models {
|
||||||
|
if m.ID == "gemini-3-flash-preview" {
|
||||||
|
hasFlashPreview = true
|
||||||
|
}
|
||||||
|
if m.ID == "gemini-3-flash" {
|
||||||
|
hasFlash = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasFlashPreview {
|
||||||
|
models = append(models, AntigravityModelInfo{
|
||||||
|
ID: "gemini-3-flash-preview",
|
||||||
|
DisplayName: "Gemini 3 Flash (Preview)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !hasFlash {
|
||||||
|
models = append(models, AntigravityModelInfo{
|
||||||
|
ID: "gemini-3-flash",
|
||||||
|
DisplayName: "Gemini 3 Flash",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return models, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type AntigravityModelInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
IsExhausted bool `json:"is_exhausted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
func truncateString(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomString(n int) string {
|
||||||
|
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
b := make([]byte, n)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = letters[rand.Intn(len(letters))]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AntigravityProvider) parseAntigravityError(statusCode int, body []byte) error {
|
||||||
|
var errResp struct {
|
||||||
|
Error struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Details []map[string]interface{} `json:"details"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &errResp); err != nil {
|
||||||
|
return fmt.Errorf("antigravity API error (HTTP %d): %s", statusCode, truncateString(string(body), 500))
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := errResp.Error.Message
|
||||||
|
if statusCode == 429 {
|
||||||
|
// Try to extract quota reset info
|
||||||
|
for _, detail := range errResp.Error.Details {
|
||||||
|
if typeVal, ok := detail["@type"].(string); ok && strings.HasSuffix(typeVal, "ErrorInfo") {
|
||||||
|
if metadata, ok := detail["metadata"].(map[string]interface{}); ok {
|
||||||
|
if delay, ok := metadata["quotaResetDelay"].(string); ok {
|
||||||
|
return fmt.Errorf("antigravity rate limit exceeded: %s (reset in %s)", msg, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("antigravity rate limit exceeded: %s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("antigravity API error (%s): %s", errResp.Error.Status, msg)
|
||||||
|
}
|
||||||
56
pkg/providers/antigravity_provider_test.go
Normal file
56
pkg/providers/antigravity_provider_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBuildRequestUsesFunctionFieldsWhenToolCallNameMissing(t *testing.T) {
|
||||||
|
p := &AntigravityProvider{}
|
||||||
|
|
||||||
|
messages := []Message{
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
ToolCalls: []ToolCall{{
|
||||||
|
ID: "call_read_file_123",
|
||||||
|
Function: &FunctionCall{
|
||||||
|
Name: "read_file",
|
||||||
|
Arguments: `{"path":"README.md"}`,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "tool",
|
||||||
|
ToolCallID: "call_read_file_123",
|
||||||
|
Content: "ok",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := p.buildRequest(messages, nil, "", nil)
|
||||||
|
if len(req.Contents) != 2 {
|
||||||
|
t.Fatalf("expected 2 contents, got %d", len(req.Contents))
|
||||||
|
}
|
||||||
|
|
||||||
|
modelPart := req.Contents[0].Parts[0]
|
||||||
|
if modelPart.FunctionCall == nil {
|
||||||
|
t.Fatal("expected functionCall in assistant message")
|
||||||
|
}
|
||||||
|
if modelPart.FunctionCall.Name != "read_file" {
|
||||||
|
t.Fatalf("expected functionCall name read_file, got %q", modelPart.FunctionCall.Name)
|
||||||
|
}
|
||||||
|
if got := modelPart.FunctionCall.Args["path"]; got != "README.md" {
|
||||||
|
t.Fatalf("expected functionCall args[path] to be README.md, got %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
toolPart := req.Contents[1].Parts[0]
|
||||||
|
if toolPart.FunctionResponse == nil {
|
||||||
|
t.Fatal("expected functionResponse in tool message")
|
||||||
|
}
|
||||||
|
if toolPart.FunctionResponse.Name != "read_file" {
|
||||||
|
t.Fatalf("expected functionResponse name read_file, got %q", toolPart.FunctionResponse.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveToolResponseNameInfersNameFromGeneratedCallID(t *testing.T) {
|
||||||
|
got := resolveToolResponseName("call_search_docs_999", map[string]string{})
|
||||||
|
if got != "search_docs" {
|
||||||
|
t.Fatalf("expected inferred tool name search_docs, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -336,7 +336,7 @@ func TestChat_PassesModelFlag(t *testing.T) {
|
||||||
|
|
||||||
_, err := p.Chat(context.Background(), []Message{
|
_, err := p.Chat(context.Background(), []Message{
|
||||||
{Role: "user", Content: "Hi"},
|
{Role: "user", Content: "Hi"},
|
||||||
}, nil, "claude-sonnet-4-5-20250929", nil)
|
}, nil, "claude-sonnet-4.6", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -346,7 +346,7 @@ func TestChat_PassesModelFlag(t *testing.T) {
|
||||||
if !strings.Contains(args, "--model") {
|
if !strings.Contains(args, "--model") {
|
||||||
t.Errorf("CLI args missing --model, got: %s", args)
|
t.Errorf("CLI args missing --model, got: %s", args)
|
||||||
}
|
}
|
||||||
if !strings.Contains(args, "claude-sonnet-4-5-20250929") {
|
if !strings.Contains(args, "claude-sonnet-4.6") {
|
||||||
t.Errorf("CLI args missing model name, got: %s", args)
|
t.Errorf("CLI args missing model name, got: %s", args)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -416,10 +416,12 @@ func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) {
|
||||||
|
|
||||||
func TestCreateProvider_ClaudeCli(t *testing.T) {
|
func TestCreateProvider_ClaudeCli(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.Provider = "claude-cli"
|
cfg.ModelList = []config.ModelConfig{
|
||||||
cfg.Agents.Defaults.Workspace = "/test/ws"
|
{ModelName: "claude-sonnet-4.6", Model: "claude-cli/claude-sonnet-4.6", Workspace: "/test/ws"},
|
||||||
|
}
|
||||||
|
cfg.Agents.Defaults.Model = "claude-sonnet-4.6"
|
||||||
|
|
||||||
provider, err := CreateProvider(cfg)
|
provider, _, err := CreateProvider(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateProvider(claude-cli) error = %v", err)
|
t.Fatalf("CreateProvider(claude-cli) error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -435,9 +437,12 @@ func TestCreateProvider_ClaudeCli(t *testing.T) {
|
||||||
|
|
||||||
func TestCreateProvider_ClaudeCode(t *testing.T) {
|
func TestCreateProvider_ClaudeCode(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.Provider = "claude-code"
|
cfg.ModelList = []config.ModelConfig{
|
||||||
|
{ModelName: "claude-code", Model: "claude-cli/claude-code"},
|
||||||
|
}
|
||||||
|
cfg.Agents.Defaults.Model = "claude-code"
|
||||||
|
|
||||||
provider, err := CreateProvider(cfg)
|
provider, _, err := CreateProvider(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateProvider(claude-code) error = %v", err)
|
t.Fatalf("CreateProvider(claude-code) error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -448,9 +453,12 @@ func TestCreateProvider_ClaudeCode(t *testing.T) {
|
||||||
|
|
||||||
func TestCreateProvider_ClaudeCodec(t *testing.T) {
|
func TestCreateProvider_ClaudeCodec(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.Provider = "claudecode"
|
cfg.ModelList = []config.ModelConfig{
|
||||||
|
{ModelName: "claudecode", Model: "claude-cli/claudecode"},
|
||||||
|
}
|
||||||
|
cfg.Agents.Defaults.Model = "claudecode"
|
||||||
|
|
||||||
provider, err := CreateProvider(cfg)
|
provider, _, err := CreateProvider(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateProvider(claudecode) error = %v", err)
|
t.Fatalf("CreateProvider(claudecode) error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -461,10 +469,13 @@ func TestCreateProvider_ClaudeCodec(t *testing.T) {
|
||||||
|
|
||||||
func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) {
|
func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.Provider = "claude-cli"
|
cfg.ModelList = []config.ModelConfig{
|
||||||
|
{ModelName: "claude-cli", Model: "claude-cli/claude-sonnet"},
|
||||||
|
}
|
||||||
|
cfg.Agents.Defaults.Model = "claude-cli"
|
||||||
cfg.Agents.Defaults.Workspace = ""
|
cfg.Agents.Defaults.Workspace = ""
|
||||||
|
|
||||||
provider, err := CreateProvider(cfg)
|
provider, _, err := CreateProvider(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateProvider error = %v", err)
|
t.Fatalf("CreateProvider error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
|
||||||
provider := newClaudeProviderWithDelegate(delegate)
|
provider := newClaudeProviderWithDelegate(delegate)
|
||||||
|
|
||||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
messages := []Message{{Role: "user", Content: "Hello"}}
|
||||||
resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{"max_tokens": 1024})
|
resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]interface{}{"max_tokens": 1024})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error: %v", err)
|
t.Fatalf("Chat() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -65,8 +65,8 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
|
||||||
|
|
||||||
func TestClaudeProvider_GetDefaultModel(t *testing.T) {
|
func TestClaudeProvider_GetDefaultModel(t *testing.T) {
|
||||||
p := NewClaudeProvider("test-token")
|
p := NewClaudeProvider("test-token")
|
||||||
if got := p.GetDefaultModel(); got != "claude-sonnet-4-5-20250929" {
|
if got := p.GetDefaultModel(); got != "claude-sonnet-4.6" {
|
||||||
t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4-5-20250929")
|
t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4.6")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,33 +35,6 @@ type providerSelection struct {
|
||||||
enableWebSearch bool
|
enableWebSearch bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func createClaudeAuthProvider(apiBase string) (LLMProvider, error) {
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = defaultAnthropicAPIBase
|
|
||||||
}
|
|
||||||
cred, err := getCredential("anthropic")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("loading auth credentials: %w", err)
|
|
||||||
}
|
|
||||||
if cred == nil {
|
|
||||||
return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic")
|
|
||||||
}
|
|
||||||
return NewClaudeProviderWithTokenSourceAndBaseURL(cred.AccessToken, createClaudeTokenSource(), apiBase), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func createCodexAuthProvider(enableWebSearch bool) (LLMProvider, error) {
|
|
||||||
cred, err := getCredential("openai")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("loading auth credentials: %w", err)
|
|
||||||
}
|
|
||||||
if cred == nil {
|
|
||||||
return nil, fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai")
|
|
||||||
}
|
|
||||||
p := NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource())
|
|
||||||
p.enableWebSearch = enableWebSearch
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||||
model := cfg.Agents.Defaults.Model
|
model := cfg.Agents.Defaults.Model
|
||||||
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
|
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
|
||||||
|
|
@ -332,29 +305,3 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||||
|
|
||||||
return sel, nil
|
return sel, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateProvider(cfg *config.Config) (LLMProvider, error) {
|
|
||||||
sel, err := resolveProviderSelection(cfg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch sel.providerType {
|
|
||||||
case providerTypeClaudeAuth:
|
|
||||||
return createClaudeAuthProvider(sel.apiBase)
|
|
||||||
case providerTypeCodexAuth:
|
|
||||||
return createCodexAuthProvider(sel.enableWebSearch)
|
|
||||||
case providerTypeCodexCLIToken:
|
|
||||||
c := NewCodexProviderWithTokenSource("", "", CreateCodexCliTokenSource())
|
|
||||||
c.enableWebSearch = sel.enableWebSearch
|
|
||||||
return c, nil
|
|
||||||
case providerTypeClaudeCLI:
|
|
||||||
return NewClaudeCliProvider(sel.workspace), nil
|
|
||||||
case providerTypeCodexCLI:
|
|
||||||
return NewCodexCliProvider(sel.workspace), nil
|
|
||||||
case providerTypeGitHubCopilot:
|
|
||||||
return NewGitHubCopilotProvider(sel.apiBase, sel.connectMode, sel.model)
|
|
||||||
default:
|
|
||||||
return NewHTTPProvider(sel.apiKey, sel.apiBase, sel.proxy), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
192
pkg/providers/factory_provider.go
Normal file
192
pkg/providers/factory_provider.go
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
|
||||||
|
func createClaudeAuthProvider() (LLMProvider, error) {
|
||||||
|
cred, err := getCredential("anthropic")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("loading auth credentials: %w", err)
|
||||||
|
}
|
||||||
|
if cred == nil {
|
||||||
|
return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic")
|
||||||
|
}
|
||||||
|
return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createCodexAuthProvider creates a Codex provider using OAuth credentials from auth store.
|
||||||
|
func createCodexAuthProvider() (LLMProvider, error) {
|
||||||
|
cred, err := getCredential("openai")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("loading auth credentials: %w", err)
|
||||||
|
}
|
||||||
|
if cred == nil {
|
||||||
|
return nil, fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai")
|
||||||
|
}
|
||||||
|
return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractProtocol extracts the protocol prefix and model identifier from a model string.
|
||||||
|
// If no prefix is specified, it defaults to "openai".
|
||||||
|
// Examples:
|
||||||
|
// - "openai/gpt-4o" -> ("openai", "gpt-4o")
|
||||||
|
// - "anthropic/claude-sonnet-4.6" -> ("anthropic", "claude-sonnet-4.6")
|
||||||
|
// - "gpt-4o" -> ("openai", "gpt-4o") // default protocol
|
||||||
|
func ExtractProtocol(model string) (protocol, modelID string) {
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
protocol, modelID, found := strings.Cut(model, "/")
|
||||||
|
if !found {
|
||||||
|
return "openai", model
|
||||||
|
}
|
||||||
|
return protocol, modelID
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProviderFromConfig creates a provider based on the ModelConfig.
|
||||||
|
// It uses the protocol prefix in the Model field to determine which provider to create.
|
||||||
|
// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot
|
||||||
|
// Returns the provider, the model ID (without protocol prefix), and any error.
|
||||||
|
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil, "", fmt.Errorf("config is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Model == "" {
|
||||||
|
return nil, "", fmt.Errorf("model is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol, modelID := ExtractProtocol(cfg.Model)
|
||||||
|
|
||||||
|
switch protocol {
|
||||||
|
case "openai":
|
||||||
|
// OpenAI with OAuth/token auth (Codex-style)
|
||||||
|
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
|
||||||
|
provider, err := createCodexAuthProvider()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return provider, modelID, nil
|
||||||
|
}
|
||||||
|
// OpenAI with API key
|
||||||
|
if cfg.APIKey == "" && cfg.APIBase == "" {
|
||||||
|
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
||||||
|
}
|
||||||
|
apiBase := cfg.APIBase
|
||||||
|
if apiBase == "" {
|
||||||
|
apiBase = getDefaultAPIBase(protocol)
|
||||||
|
}
|
||||||
|
return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
|
||||||
|
|
||||||
|
case "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||||
|
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||||
|
"volcengine", "vllm", "qwen":
|
||||||
|
// All other OpenAI-compatible HTTP providers
|
||||||
|
if cfg.APIKey == "" && cfg.APIBase == "" {
|
||||||
|
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
||||||
|
}
|
||||||
|
apiBase := cfg.APIBase
|
||||||
|
if apiBase == "" {
|
||||||
|
apiBase = getDefaultAPIBase(protocol)
|
||||||
|
}
|
||||||
|
return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
|
||||||
|
|
||||||
|
case "anthropic":
|
||||||
|
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
|
||||||
|
// Use OAuth credentials from auth store
|
||||||
|
provider, err := createClaudeAuthProvider()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return provider, modelID, nil
|
||||||
|
}
|
||||||
|
// Use API key with HTTP API
|
||||||
|
apiBase := cfg.APIBase
|
||||||
|
if apiBase == "" {
|
||||||
|
apiBase = "https://api.anthropic.com/v1"
|
||||||
|
}
|
||||||
|
if cfg.APIKey == "" {
|
||||||
|
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
|
||||||
|
}
|
||||||
|
return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
|
||||||
|
|
||||||
|
case "antigravity":
|
||||||
|
return NewAntigravityProvider(), modelID, nil
|
||||||
|
|
||||||
|
case "claude-cli", "claudecli":
|
||||||
|
workspace := cfg.Workspace
|
||||||
|
if workspace == "" {
|
||||||
|
workspace = "."
|
||||||
|
}
|
||||||
|
return NewClaudeCliProvider(workspace), modelID, nil
|
||||||
|
|
||||||
|
case "codex-cli", "codexcli":
|
||||||
|
workspace := cfg.Workspace
|
||||||
|
if workspace == "" {
|
||||||
|
workspace = "."
|
||||||
|
}
|
||||||
|
return NewCodexCliProvider(workspace), modelID, nil
|
||||||
|
|
||||||
|
case "github-copilot", "copilot":
|
||||||
|
apiBase := cfg.APIBase
|
||||||
|
if apiBase == "" {
|
||||||
|
apiBase = "localhost:4321"
|
||||||
|
}
|
||||||
|
connectMode := cfg.ConnectMode
|
||||||
|
if connectMode == "" {
|
||||||
|
connectMode = "grpc"
|
||||||
|
}
|
||||||
|
provider, err := NewGitHubCopilotProvider(apiBase, connectMode, modelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return provider, modelID, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, "", fmt.Errorf("unknown protocol %q in model %q", protocol, cfg.Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDefaultAPIBase returns the default API base URL for a given protocol.
|
||||||
|
func getDefaultAPIBase(protocol string) string {
|
||||||
|
switch protocol {
|
||||||
|
case "openai":
|
||||||
|
return "https://api.openai.com/v1"
|
||||||
|
case "openrouter":
|
||||||
|
return "https://openrouter.ai/api/v1"
|
||||||
|
case "groq":
|
||||||
|
return "https://api.groq.com/openai/v1"
|
||||||
|
case "zhipu":
|
||||||
|
return "https://open.bigmodel.cn/api/paas/v4"
|
||||||
|
case "gemini":
|
||||||
|
return "https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
case "nvidia":
|
||||||
|
return "https://integrate.api.nvidia.com/v1"
|
||||||
|
case "ollama":
|
||||||
|
return "http://localhost:11434/v1"
|
||||||
|
case "moonshot":
|
||||||
|
return "https://api.moonshot.cn/v1"
|
||||||
|
case "shengsuanyun":
|
||||||
|
return "https://router.shengsuanyun.com/api/v1"
|
||||||
|
case "deepseek":
|
||||||
|
return "https://api.deepseek.com/v1"
|
||||||
|
case "cerebras":
|
||||||
|
return "https://api.cerebras.ai/v1"
|
||||||
|
case "volcengine":
|
||||||
|
return "https://ark.cn-beijing.volces.com/api/v3"
|
||||||
|
case "qwen":
|
||||||
|
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||||
|
case "vllm":
|
||||||
|
return "http://localhost:8000/v1"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
249
pkg/providers/factory_provider_test.go
Normal file
249
pkg/providers/factory_provider_test.go
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractProtocol(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
model string
|
||||||
|
wantProtocol string
|
||||||
|
wantModelID string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "openai with prefix",
|
||||||
|
model: "openai/gpt-4o",
|
||||||
|
wantProtocol: "openai",
|
||||||
|
wantModelID: "gpt-4o",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "anthropic with prefix",
|
||||||
|
model: "anthropic/claude-sonnet-4.6",
|
||||||
|
wantProtocol: "anthropic",
|
||||||
|
wantModelID: "claude-sonnet-4.6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no prefix - defaults to openai",
|
||||||
|
model: "gpt-4o",
|
||||||
|
wantProtocol: "openai",
|
||||||
|
wantModelID: "gpt-4o",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "groq with prefix",
|
||||||
|
model: "groq/llama-3.1-70b",
|
||||||
|
wantProtocol: "groq",
|
||||||
|
wantModelID: "llama-3.1-70b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
model: "",
|
||||||
|
wantProtocol: "openai",
|
||||||
|
wantModelID: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with whitespace",
|
||||||
|
model: " openai/gpt-4 ",
|
||||||
|
wantProtocol: "openai",
|
||||||
|
wantModelID: "gpt-4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple slashes",
|
||||||
|
model: "nvidia/meta/llama-3.1-8b",
|
||||||
|
wantProtocol: "nvidia",
|
||||||
|
wantModelID: "meta/llama-3.1-8b",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
protocol, modelID := ExtractProtocol(tt.model)
|
||||||
|
if protocol != tt.wantProtocol {
|
||||||
|
t.Errorf("ExtractProtocol(%q) protocol = %q, want %q", tt.model, protocol, tt.wantProtocol)
|
||||||
|
}
|
||||||
|
if modelID != tt.wantModelID {
|
||||||
|
t.Errorf("ExtractProtocol(%q) modelID = %q, want %q", tt.model, modelID, tt.wantModelID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_OpenAI(t *testing.T) {
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: "test-openai",
|
||||||
|
Model: "openai/gpt-4o",
|
||||||
|
APIKey: "test-key",
|
||||||
|
APIBase: "https://api.example.com/v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if provider == nil {
|
||||||
|
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||||
|
}
|
||||||
|
if modelID != "gpt-4o" {
|
||||||
|
t.Errorf("modelID = %q, want %q", modelID, "gpt-4o")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
protocol string
|
||||||
|
}{
|
||||||
|
{"openai", "openai"},
|
||||||
|
{"groq", "groq"},
|
||||||
|
{"openrouter", "openrouter"},
|
||||||
|
{"cerebras", "cerebras"},
|
||||||
|
{"qwen", "qwen"},
|
||||||
|
{"vllm", "vllm"},
|
||||||
|
{"deepseek", "deepseek"},
|
||||||
|
{"ollama", "ollama"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: "test-" + tt.protocol,
|
||||||
|
Model: tt.protocol + "/test-model",
|
||||||
|
APIKey: "test-key",
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, _, err := CreateProviderFromConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we got an HTTPProvider for all these protocols
|
||||||
|
if _, ok := provider.(*HTTPProvider); !ok {
|
||||||
|
t.Fatalf("expected *HTTPProvider, got %T", provider)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_Anthropic(t *testing.T) {
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: "test-anthropic",
|
||||||
|
Model: "anthropic/claude-sonnet-4.6",
|
||||||
|
APIKey: "test-key",
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if provider == nil {
|
||||||
|
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||||
|
}
|
||||||
|
if modelID != "claude-sonnet-4.6" {
|
||||||
|
t.Errorf("modelID = %q, want %q", modelID, "claude-sonnet-4.6")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_Antigravity(t *testing.T) {
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: "test-antigravity",
|
||||||
|
Model: "antigravity/gemini-2.0-flash",
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if provider == nil {
|
||||||
|
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||||
|
}
|
||||||
|
if modelID != "gemini-2.0-flash" {
|
||||||
|
t.Errorf("modelID = %q, want %q", modelID, "gemini-2.0-flash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_ClaudeCLI(t *testing.T) {
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: "test-claude-cli",
|
||||||
|
Model: "claude-cli/claude-sonnet-4.6",
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if provider == nil {
|
||||||
|
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||||
|
}
|
||||||
|
if modelID != "claude-sonnet-4.6" {
|
||||||
|
t.Errorf("modelID = %q, want %q", modelID, "claude-sonnet-4.6")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_CodexCLI(t *testing.T) {
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: "test-codex-cli",
|
||||||
|
Model: "codex-cli/codex",
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if provider == nil {
|
||||||
|
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||||
|
}
|
||||||
|
if modelID != "codex" {
|
||||||
|
t.Errorf("modelID = %q, want %q", modelID, "codex")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) {
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: "test-no-key",
|
||||||
|
Model: "openai/gpt-4o",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err := CreateProviderFromConfig(cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("CreateProviderFromConfig() expected error for missing API key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) {
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: "test-unknown",
|
||||||
|
Model: "unknown-protocol/model",
|
||||||
|
APIKey: "test-key",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err := CreateProviderFromConfig(cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("CreateProviderFromConfig() expected error for unknown protocol")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_NilConfig(t *testing.T) {
|
||||||
|
_, _, err := CreateProviderFromConfig(nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("CreateProviderFromConfig(nil) expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_EmptyModel(t *testing.T) {
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: "test-empty",
|
||||||
|
Model: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err := CreateProviderFromConfig(cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("CreateProviderFromConfig() expected error for empty model")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -79,7 +79,7 @@ func TestResolveProviderSelection(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "anthropic oauth routes to claude auth provider",
|
name: "anthropic oauth routes to claude auth provider",
|
||||||
setup: func(cfg *config.Config) {
|
setup: func(cfg *config.Config) {
|
||||||
cfg.Agents.Defaults.Model = "claude-sonnet-4-5-20250929"
|
cfg.Agents.Defaults.Model = "claude-sonnet-4.6"
|
||||||
cfg.Providers.Anthropic.AuthMethod = "oauth"
|
cfg.Providers.Anthropic.AuthMethod = "oauth"
|
||||||
},
|
},
|
||||||
wantType: providerTypeClaudeAuth,
|
wantType: providerTypeClaudeAuth,
|
||||||
|
|
@ -196,10 +196,17 @@ func TestResolveProviderSelection(t *testing.T) {
|
||||||
|
|
||||||
func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) {
|
func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.Model = "openrouter/auto"
|
cfg.Agents.Defaults.Model = "test-openrouter"
|
||||||
cfg.Providers.OpenRouter.APIKey = "sk-or-test"
|
cfg.ModelList = []config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: "test-openrouter",
|
||||||
|
Model: "openrouter/auto",
|
||||||
|
APIKey: "sk-or-test",
|
||||||
|
APIBase: "https://openrouter.ai/api/v1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
provider, err := CreateProvider(cfg)
|
provider, _, err := CreateProvider(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateProvider() error = %v", err)
|
t.Fatalf("CreateProvider() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -211,9 +218,16 @@ func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) {
|
||||||
|
|
||||||
func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) {
|
func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.Provider = "codex-code"
|
cfg.Agents.Defaults.Model = "test-codex"
|
||||||
|
cfg.ModelList = []config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: "test-codex",
|
||||||
|
Model: "codex-cli/codex-model",
|
||||||
|
Workspace: "/tmp/workspace",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
provider, err := CreateProvider(cfg)
|
provider, _, err := CreateProvider(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateProvider() error = %v", err)
|
t.Fatalf("CreateProvider() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -223,18 +237,24 @@ func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateProviderReturnsCodexProviderForCodexCliAuthMethod(t *testing.T) {
|
func TestCreateProviderReturnsClaudeCliProviderForClaudeCli(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.Provider = "openai"
|
cfg.Agents.Defaults.Model = "test-claude-cli"
|
||||||
cfg.Providers.OpenAI.AuthMethod = "codex-cli"
|
cfg.ModelList = []config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: "test-claude-cli",
|
||||||
|
Model: "claude-cli/claude-sonnet",
|
||||||
|
Workspace: "/tmp/workspace",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
provider, err := CreateProvider(cfg)
|
provider, _, err := CreateProvider(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateProvider() error = %v", err)
|
t.Fatalf("CreateProvider() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := provider.(*CodexProvider); !ok {
|
if _, ok := provider.(*ClaudeCliProvider); !ok {
|
||||||
t.Fatalf("provider type = %T, want *CodexProvider", provider)
|
t.Fatalf("provider type = %T, want *ClaudeCliProvider", provider)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -252,48 +272,28 @@ func TestCreateProviderReturnsClaudeProviderForAnthropicOAuth(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.Provider = "anthropic"
|
cfg.Agents.Defaults.Model = "test-claude-oauth"
|
||||||
cfg.Providers.Anthropic.AuthMethod = "oauth"
|
cfg.ModelList = []config.ModelConfig{
|
||||||
cfg.Providers.Anthropic.APIBase = "https://proxy.example.com/v1"
|
{
|
||||||
|
ModelName: "test-claude-oauth",
|
||||||
|
Model: "anthropic/claude-sonnet-4.6",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
provider, err := CreateProvider(cfg)
|
provider, _, err := CreateProvider(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateProvider() error = %v", err)
|
t.Fatalf("CreateProvider() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
claudeProvider, ok := provider.(*ClaudeProvider)
|
if _, ok := provider.(*ClaudeProvider); !ok {
|
||||||
if !ok {
|
|
||||||
t.Fatalf("provider type = %T, want *ClaudeProvider", provider)
|
t.Fatalf("provider type = %T, want *ClaudeProvider", provider)
|
||||||
}
|
}
|
||||||
if got := claudeProvider.delegate.BaseURL(); got != "https://proxy.example.com" {
|
// TODO: Test custom APIBase when createClaudeAuthProvider supports it
|
||||||
t.Fatalf("anthropic baseURL = %q, want %q", got, "https://proxy.example.com")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateProviderReturnsCodexProviderForOpenAIOAuth(t *testing.T) {
|
func TestCreateProviderReturnsCodexProviderForOpenAIOAuth(t *testing.T) {
|
||||||
originalGetCredential := getCredential
|
// TODO: This test requires openai protocol to support auth_method: "oauth"
|
||||||
t.Cleanup(func() { getCredential = originalGetCredential })
|
// which is not yet implemented in the new factory_provider.go
|
||||||
|
t.Skip("OpenAI OAuth via model_list not yet implemented")
|
||||||
getCredential = func(provider string) (*auth.AuthCredential, error) {
|
|
||||||
if provider != "openai" {
|
|
||||||
t.Fatalf("provider = %q, want openai", provider)
|
|
||||||
}
|
|
||||||
return &auth.AuthCredential{
|
|
||||||
AccessToken: "openai-token",
|
|
||||||
AccountID: "acct_123",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := config.DefaultConfig()
|
|
||||||
cfg.Agents.Defaults.Provider = "openai"
|
|
||||||
cfg.Providers.OpenAI.AuthMethod = "oauth"
|
|
||||||
|
|
||||||
provider, err := CreateProvider(cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateProvider() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := provider.(*CodexProvider); !ok {
|
|
||||||
t.Fatalf("provider type = %T, want *CodexProvider", provider)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider {
|
||||||
|
return &HTTPProvider{
|
||||||
|
delegate: openai_compat.NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||||
return p.delegate.Chat(ctx, messages, tools, model, options)
|
return p.delegate.Chat(ctx, messages, tools, model, options)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
49
pkg/providers/legacy_provider.go
Normal file
49
pkg/providers/legacy_provider.go
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateProvider creates a provider based on the configuration.
|
||||||
|
// It uses the model_list configuration (new format) to create providers.
|
||||||
|
// The old providers config is automatically converted to model_list during config loading.
|
||||||
|
// Returns the provider, the model ID to use, and any error.
|
||||||
|
func CreateProvider(cfg *config.Config) (LLMProvider, string, error) {
|
||||||
|
model := cfg.Agents.Defaults.Model
|
||||||
|
|
||||||
|
// Ensure model_list is populated (should be done by LoadConfig, but handle edge cases)
|
||||||
|
if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
|
||||||
|
cfg.ModelList = config.ConvertProvidersToModelList(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have model_list at this point
|
||||||
|
if len(cfg.ModelList) == 0 {
|
||||||
|
return nil, "", fmt.Errorf("no providers configured. Please add entries to model_list in your config")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get model config from model_list
|
||||||
|
modelCfg, err := cfg.GetModelConfig(model)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("model %q not found in model_list: %w", model, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject global workspace if not set in model config
|
||||||
|
if modelCfg.Workspace == "" {
|
||||||
|
modelCfg.Workspace = cfg.WorkspacePath()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use factory to create provider
|
||||||
|
provider, modelID, err := CreateProviderFromConfig(modelCfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("failed to create provider for model %q: %w", model, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider, modelID, nil
|
||||||
|
}
|
||||||
|
|
@ -22,14 +22,21 @@ type UsageInfo = protocoltypes.UsageInfo
|
||||||
type Message = protocoltypes.Message
|
type Message = protocoltypes.Message
|
||||||
type ToolDefinition = protocoltypes.ToolDefinition
|
type ToolDefinition = protocoltypes.ToolDefinition
|
||||||
type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
||||||
|
type ExtraContent = protocoltypes.ExtraContent
|
||||||
|
type GoogleExtra = protocoltypes.GoogleExtra
|
||||||
|
|
||||||
type Provider struct {
|
type Provider struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
apiBase string
|
apiBase string
|
||||||
|
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProvider(apiKey, apiBase, proxy string) *Provider {
|
func NewProvider(apiKey, apiBase, proxy string) *Provider {
|
||||||
|
return NewProviderWithMaxTokensField(apiKey, apiBase, proxy, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *Provider {
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 120 * time.Second,
|
Timeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
@ -48,6 +55,7 @@ func NewProvider(apiKey, apiBase, proxy string) *Provider {
|
||||||
return &Provider{
|
return &Provider{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
apiBase: strings.TrimRight(apiBase, "/"),
|
apiBase: strings.TrimRight(apiBase, "/"),
|
||||||
|
maxTokensField: maxTokensField,
|
||||||
httpClient: client,
|
httpClient: client,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -70,13 +78,19 @@ func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDef
|
||||||
}
|
}
|
||||||
|
|
||||||
if maxTokens, ok := asInt(options["max_tokens"]); ok {
|
if maxTokens, ok := asInt(options["max_tokens"]); ok {
|
||||||
|
// Use configured maxTokensField if specified, otherwise fallback to model-based detection
|
||||||
|
fieldName := p.maxTokensField
|
||||||
|
if fieldName == "" {
|
||||||
|
// Fallback: detect from model name for backward compatibility
|
||||||
lowerModel := strings.ToLower(model)
|
lowerModel := strings.ToLower(model)
|
||||||
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") || strings.Contains(lowerModel, "gpt-5") {
|
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") || strings.Contains(lowerModel, "gpt-5") {
|
||||||
requestBody["max_completion_tokens"] = maxTokens
|
fieldName = "max_completion_tokens"
|
||||||
} else {
|
} else {
|
||||||
requestBody["max_tokens"] = maxTokens
|
fieldName = "max_tokens"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
requestBody[fieldName] = maxTokens
|
||||||
|
}
|
||||||
|
|
||||||
if temperature, ok := asFloat(options["temperature"]); ok {
|
if temperature, ok := asFloat(options["temperature"]); ok {
|
||||||
lowerModel := strings.ToLower(model)
|
lowerModel := strings.ToLower(model)
|
||||||
|
|
@ -133,6 +147,11 @@ func parseResponse(body []byte) (*LLMResponse, error) {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Arguments string `json:"arguments"`
|
Arguments string `json:"arguments"`
|
||||||
} `json:"function"`
|
} `json:"function"`
|
||||||
|
ExtraContent *struct {
|
||||||
|
Google *struct {
|
||||||
|
ThoughtSignature string `json:"thought_signature"`
|
||||||
|
} `json:"google"`
|
||||||
|
} `json:"extra_content"`
|
||||||
} `json:"tool_calls"`
|
} `json:"tool_calls"`
|
||||||
} `json:"message"`
|
} `json:"message"`
|
||||||
FinishReason string `json:"finish_reason"`
|
FinishReason string `json:"finish_reason"`
|
||||||
|
|
@ -157,6 +176,12 @@ func parseResponse(body []byte) (*LLMResponse, error) {
|
||||||
arguments := make(map[string]interface{})
|
arguments := make(map[string]interface{})
|
||||||
name := ""
|
name := ""
|
||||||
|
|
||||||
|
// Extract thought_signature from Gemini/Google-specific extra content
|
||||||
|
thoughtSignature := ""
|
||||||
|
if tc.ExtraContent != nil && tc.ExtraContent.Google != nil {
|
||||||
|
thoughtSignature = tc.ExtraContent.Google.ThoughtSignature
|
||||||
|
}
|
||||||
|
|
||||||
if tc.Function != nil {
|
if tc.Function != nil {
|
||||||
name = tc.Function.Name
|
name = tc.Function.Name
|
||||||
if tc.Function.Arguments != "" {
|
if tc.Function.Arguments != "" {
|
||||||
|
|
@ -167,11 +192,23 @@ func parseResponse(body []byte) (*LLMResponse, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toolCalls = append(toolCalls, ToolCall{
|
// Build ToolCall with ExtraContent for Gemini 3 thought_signature persistence
|
||||||
|
toolCall := ToolCall{
|
||||||
ID: tc.ID,
|
ID: tc.ID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Arguments: arguments,
|
Arguments: arguments,
|
||||||
})
|
ThoughtSignature: thoughtSignature,
|
||||||
|
}
|
||||||
|
|
||||||
|
if thoughtSignature != "" {
|
||||||
|
toolCall.ExtraContent = &ExtraContent{
|
||||||
|
Google: &GoogleExtra{
|
||||||
|
ThoughtSignature: thoughtSignature,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCalls = append(toolCalls, toolCall)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &LLMResponse{
|
return &LLMResponse{
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,22 @@ type ToolCall struct {
|
||||||
Function *FunctionCall `json:"function,omitempty"`
|
Function *FunctionCall `json:"function,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Arguments map[string]interface{} `json:"arguments,omitempty"`
|
Arguments map[string]interface{} `json:"arguments,omitempty"`
|
||||||
|
ThoughtSignature string `json:"-"` // Internal use only
|
||||||
|
ExtraContent *ExtraContent `json:"extra_content,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtraContent struct {
|
||||||
|
Google *GoogleExtra `json:"google,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GoogleExtra struct {
|
||||||
|
ThoughtSignature string `json:"thought_signature,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FunctionCall struct {
|
type FunctionCall struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Arguments string `json:"arguments"`
|
Arguments string `json:"arguments"`
|
||||||
|
ThoughtSignature string `json:"thought_signature,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LLMResponse struct {
|
type LLMResponse struct {
|
||||||
|
|
|
||||||
54
pkg/providers/toolcall_utils.go
Normal file
54
pkg/providers/toolcall_utils.go
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// NormalizeToolCall normalizes a ToolCall to ensure all fields are properly populated.
|
||||||
|
// It handles cases where Name/Arguments might be in different locations (top-level vs Function)
|
||||||
|
// and ensures both are populated consistently.
|
||||||
|
func NormalizeToolCall(tc ToolCall) ToolCall {
|
||||||
|
normalized := tc
|
||||||
|
|
||||||
|
// Ensure Name is populated from Function if not set
|
||||||
|
if normalized.Name == "" && normalized.Function != nil {
|
||||||
|
normalized.Name = normalized.Function.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Arguments is not nil
|
||||||
|
if normalized.Arguments == nil {
|
||||||
|
normalized.Arguments = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse Arguments from Function.Arguments if not already set
|
||||||
|
if len(normalized.Arguments) == 0 && normalized.Function != nil && normalized.Function.Arguments != "" {
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(normalized.Function.Arguments), &parsed); err == nil && parsed != nil {
|
||||||
|
normalized.Arguments = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Function is populated with consistent values
|
||||||
|
argsJSON, _ := json.Marshal(normalized.Arguments)
|
||||||
|
if normalized.Function == nil {
|
||||||
|
normalized.Function = &FunctionCall{
|
||||||
|
Name: normalized.Name,
|
||||||
|
Arguments: string(argsJSON),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if normalized.Function.Name == "" {
|
||||||
|
normalized.Function.Name = normalized.Name
|
||||||
|
}
|
||||||
|
if normalized.Name == "" {
|
||||||
|
normalized.Name = normalized.Function.Name
|
||||||
|
}
|
||||||
|
if normalized.Function.Arguments == "" {
|
||||||
|
normalized.Function.Arguments = string(argsJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,8 @@ type UsageInfo = protocoltypes.UsageInfo
|
||||||
type Message = protocoltypes.Message
|
type Message = protocoltypes.Message
|
||||||
type ToolDefinition = protocoltypes.ToolDefinition
|
type ToolDefinition = protocoltypes.ToolDefinition
|
||||||
type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
||||||
|
type ExtraContent = protocoltypes.ExtraContent
|
||||||
|
type GoogleExtra = protocoltypes.GoogleExtra
|
||||||
|
|
||||||
type LLMProvider interface {
|
type LLMProvider interface {
|
||||||
Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error)
|
Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ type SubagentManager struct {
|
||||||
workspace string
|
workspace string
|
||||||
tools *ToolRegistry
|
tools *ToolRegistry
|
||||||
maxIterations int
|
maxIterations int
|
||||||
|
maxTokens int
|
||||||
|
temperature float64
|
||||||
|
hasMaxTokens bool
|
||||||
|
hasTemperature bool
|
||||||
nextID int
|
nextID int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,6 +51,16 @@ func NewSubagentManager(provider providers.LLMProvider, defaultModel, workspace
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLLMOptions sets max tokens and temperature for subagent LLM calls.
|
||||||
|
func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
sm.maxTokens = maxTokens
|
||||||
|
sm.hasMaxTokens = true
|
||||||
|
sm.temperature = temperature
|
||||||
|
sm.hasTemperature = true
|
||||||
|
}
|
||||||
|
|
||||||
// SetTools sets the tool registry for subagent execution.
|
// SetTools sets the tool registry for subagent execution.
|
||||||
// If not set, subagent will have access to the provided tools.
|
// If not set, subagent will have access to the provided tools.
|
||||||
func (sm *SubagentManager) SetTools(tools *ToolRegistry) {
|
func (sm *SubagentManager) SetTools(tools *ToolRegistry) {
|
||||||
|
|
@ -125,17 +139,29 @@ After completing the task, provide a clear summary of what was done.`
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
tools := sm.tools
|
tools := sm.tools
|
||||||
maxIter := sm.maxIterations
|
maxIter := sm.maxIterations
|
||||||
|
maxTokens := sm.maxTokens
|
||||||
|
temperature := sm.temperature
|
||||||
|
hasMaxTokens := sm.hasMaxTokens
|
||||||
|
hasTemperature := sm.hasTemperature
|
||||||
sm.mu.RUnlock()
|
sm.mu.RUnlock()
|
||||||
|
|
||||||
|
var llmOptions map[string]any
|
||||||
|
if hasMaxTokens || hasTemperature {
|
||||||
|
llmOptions = map[string]any{}
|
||||||
|
if hasMaxTokens {
|
||||||
|
llmOptions["max_tokens"] = maxTokens
|
||||||
|
}
|
||||||
|
if hasTemperature {
|
||||||
|
llmOptions["temperature"] = temperature
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
|
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
|
||||||
Provider: sm.provider,
|
Provider: sm.provider,
|
||||||
Model: sm.defaultModel,
|
Model: sm.defaultModel,
|
||||||
Tools: tools,
|
Tools: tools,
|
||||||
MaxIterations: maxIter,
|
MaxIterations: maxIter,
|
||||||
LLMOptions: map[string]any{
|
LLMOptions: llmOptions,
|
||||||
"max_tokens": 4096,
|
|
||||||
"temperature": 0.7,
|
|
||||||
},
|
|
||||||
}, messages, task.OriginChannel, task.OriginChatID)
|
}, messages, task.OriginChannel, task.OriginChatID)
|
||||||
|
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
|
|
@ -283,19 +309,30 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{})
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
tools := sm.tools
|
tools := sm.tools
|
||||||
maxIter := sm.maxIterations
|
maxIter := sm.maxIterations
|
||||||
|
maxTokens := sm.maxTokens
|
||||||
|
temperature := sm.temperature
|
||||||
|
hasMaxTokens := sm.hasMaxTokens
|
||||||
|
hasTemperature := sm.hasTemperature
|
||||||
sm.mu.RUnlock()
|
sm.mu.RUnlock()
|
||||||
|
|
||||||
|
var llmOptions map[string]any
|
||||||
|
if hasMaxTokens || hasTemperature {
|
||||||
|
llmOptions = map[string]any{}
|
||||||
|
if hasMaxTokens {
|
||||||
|
llmOptions["max_tokens"] = maxTokens
|
||||||
|
}
|
||||||
|
if hasTemperature {
|
||||||
|
llmOptions["temperature"] = temperature
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
|
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
|
||||||
Provider: sm.provider,
|
Provider: sm.provider,
|
||||||
Model: sm.defaultModel,
|
Model: sm.defaultModel,
|
||||||
Tools: tools,
|
Tools: tools,
|
||||||
MaxIterations: maxIter,
|
MaxIterations: maxIter,
|
||||||
LLMOptions: map[string]any{
|
LLMOptions: llmOptions,
|
||||||
"max_tokens": 4096,
|
|
||||||
"temperature": 0.7,
|
|
||||||
},
|
|
||||||
}, messages, t.originChannel, t.originChatID)
|
}, messages, t.originChannel, t.originChatID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err)
|
return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,12 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockLLMProvider is a test implementation of LLMProvider
|
// MockLLMProvider is a test implementation of LLMProvider
|
||||||
type MockLLMProvider struct{}
|
type MockLLMProvider struct {
|
||||||
|
lastOptions map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockLLMProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) {
|
func (m *MockLLMProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) {
|
||||||
|
m.lastOptions = options
|
||||||
// Find the last user message to generate a response
|
// Find the last user message to generate a response
|
||||||
for i := len(messages) - 1; i >= 0; i-- {
|
for i := len(messages) - 1; i >= 0; i-- {
|
||||||
if messages[i].Role == "user" {
|
if messages[i].Role == "user" {
|
||||||
|
|
@ -36,6 +39,32 @@ func (m *MockLLMProvider) GetContextWindow() int {
|
||||||
return 4096
|
return 4096
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
|
||||||
|
manager.SetLLMOptions(2048, 0.6)
|
||||||
|
tool := NewSubagentTool(manager)
|
||||||
|
tool.SetContext("cli", "direct")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]interface{}{"task": "Do something"}
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
if result == nil || result.IsError {
|
||||||
|
t.Fatalf("Expected successful result, got: %+v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider.lastOptions == nil {
|
||||||
|
t.Fatal("Expected LLM options to be passed, got nil")
|
||||||
|
}
|
||||||
|
if provider.lastOptions["max_tokens"] != 2048 {
|
||||||
|
t.Fatalf("max_tokens = %v, want %d", provider.lastOptions["max_tokens"], 2048)
|
||||||
|
}
|
||||||
|
if provider.lastOptions["temperature"] != 0.6 {
|
||||||
|
t.Fatalf("temperature = %v, want %v", provider.lastOptions["temperature"], 0.6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestSubagentTool_Name verifies tool name
|
// TestSubagentTool_Name verifies tool name
|
||||||
func TestSubagentTool_Name(t *testing.T) {
|
func TestSubagentTool_Name(t *testing.T) {
|
||||||
provider := &MockLLMProvider{}
|
provider := &MockLLMProvider{}
|
||||||
|
|
|
||||||
|
|
@ -55,12 +55,8 @@ func RunToolLoop(ctx context.Context, config ToolLoopConfig, messages []provider
|
||||||
// 2. Set default LLM options
|
// 2. Set default LLM options
|
||||||
llmOpts := config.LLMOptions
|
llmOpts := config.LLMOptions
|
||||||
if llmOpts == nil {
|
if llmOpts == nil {
|
||||||
llmOpts = map[string]any{
|
llmOpts = map[string]any{}
|
||||||
"max_tokens": 4096,
|
|
||||||
"temperature": 0.7,
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Call LLM
|
// 3. Call LLM
|
||||||
response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts)
|
response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -83,15 +79,20 @@ func RunToolLoop(ctx context.Context, config ToolLoopConfig, messages []provider
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Log tool calls
|
normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls))
|
||||||
toolNames := make([]string, 0, len(response.ToolCalls))
|
|
||||||
for _, tc := range response.ToolCalls {
|
for _, tc := range response.ToolCalls {
|
||||||
|
normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Log tool calls
|
||||||
|
toolNames := make([]string, 0, len(normalizedToolCalls))
|
||||||
|
for _, tc := range normalizedToolCalls {
|
||||||
toolNames = append(toolNames, tc.Name)
|
toolNames = append(toolNames, tc.Name)
|
||||||
}
|
}
|
||||||
logger.InfoCF("toolloop", "LLM requested tool calls",
|
logger.InfoCF("toolloop", "LLM requested tool calls",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"tools": toolNames,
|
"tools": toolNames,
|
||||||
"count": len(response.ToolCalls),
|
"count": len(normalizedToolCalls),
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -100,22 +101,23 @@ func RunToolLoop(ctx context.Context, config ToolLoopConfig, messages []provider
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: response.Content,
|
Content: response.Content,
|
||||||
}
|
}
|
||||||
for _, tc := range response.ToolCalls {
|
for _, tc := range normalizedToolCalls {
|
||||||
argumentsJSON, _ := json.Marshal(tc.Arguments)
|
argumentsJSON, _ := json.Marshal(tc.Arguments)
|
||||||
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{
|
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{
|
||||||
ID: tc.ID,
|
ID: tc.ID,
|
||||||
Type: "function",
|
Type: "function",
|
||||||
|
Name: tc.Name,
|
||||||
|
Arguments: tc.Arguments,
|
||||||
Function: &providers.FunctionCall{
|
Function: &providers.FunctionCall{
|
||||||
Name: tc.Name,
|
Name: tc.Name,
|
||||||
Arguments: string(argumentsJSON),
|
Arguments: string(argumentsJSON),
|
||||||
},
|
},
|
||||||
Name: tc.Name,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
messages = append(messages, assistantMsg)
|
messages = append(messages, assistantMsg)
|
||||||
|
|
||||||
// 7. Execute tool calls
|
// 7. Execute tool calls
|
||||||
for _, tc := range response.ToolCalls {
|
for _, tc := range normalizedToolCalls {
|
||||||
argsJSON, _ := json.Marshal(tc.Arguments)
|
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||||
argsPreview := utils.Truncate(string(argsJSON), 200)
|
argsPreview := utils.Truncate(string(argsJSON), 200)
|
||||||
logger.InfoCF("toolloop", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
|
logger.InfoCF("toolloop", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue