From 844a4eefc7ae1c26bb12490495c736d5bf3b8550 Mon Sep 17 00:00:00 2001 From: SakoroYou <165740095+Sakurapainting@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:11:36 +0800 Subject: [PATCH 1/6] fix(agent): avoid process exit on exec init failure and add regression test (#1784) * fix(agent): make exec tool init failure non-fatal * test(agent): add regression test for invalid exec config fallback --- pkg/agent/instance.go | 20 ++++++++++++-------- pkg/agent/instance_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 1c3635322..d2a4f81a4 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -3,13 +3,13 @@ package agent import ( "context" "fmt" - "log" "os" "path/filepath" "regexp" "strings" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/memory" "github.com/sipeed/picoclaw/pkg/providers" @@ -85,9 +85,11 @@ func NewAgentInstance( if cfg.Tools.IsToolEnabled("exec") { execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg, allowReadPaths) if err != nil { - log.Fatalf("Critical error: unable to initialize exec tool: %v", err) + logger.ErrorCF("agent", "Failed to initialize exec tool; continuing without exec", + map[string]any{"error": err.Error()}) + } else { + toolsRegistry.Register(execTool) } - toolsRegistry.Register(execTool) } if cfg.Tools.IsToolEnabled("edit_file") { @@ -210,8 +212,8 @@ func NewAgentInstance( }) lightCandidates = resolved } else { - log.Printf("routing: light_model %q not found in model_list — routing disabled for agent %q", - rc.LightModel, agentID) + logger.WarnCF("agent", "Routing light model not found; routing disabled", + map[string]any{"light_model": rc.LightModel, "agent_id": agentID}) } } @@ -320,7 +322,8 @@ func (a *AgentInstance) Close() error { func initSessionStore(dir string) session.SessionStore { store, err := memory.NewJSONLStore(dir) if err != nil { - log.Printf("memory: init store: %v; using json sessions", err) + logger.WarnCF("agent", "Memory JSONL store init failed; falling back to json sessions", + map[string]any{"error": err.Error()}) return session.NewSessionManager(dir) } @@ -328,11 +331,12 @@ func initSessionStore(dir string) session.SessionStore { // Migration failure means the store could not write data. // Fall back to SessionManager to avoid a split state where // some sessions are in JSONL and others remain in JSON. - log.Printf("memory: migration failed: %v; falling back to json sessions", merr) + logger.WarnCF("agent", "Memory migration failed; falling back to json sessions", + map[string]any{"error": merr.Error()}) store.Close() return session.NewSessionManager(dir) } else if n > 0 { - log.Printf("memory: migrated %d session(s) to jsonl", n) + logger.InfoCF("agent", "Memory migrated to JSONL", map[string]any{"sessions_migrated": n}) } return session.NewJSONLBackend(store) diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index 5a13c8f1b..b3318ad1f 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -246,3 +246,37 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) { t.Fatalf("exec output missing media content: %s", execResult.ForLLM) } } + +func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) { + workspace := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "test-model", + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{Enabled: true}, + Exec: config.ExecConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + EnableDenyPatterns: true, + CustomDenyPatterns: []string{"[invalid-regex"}, + }, + }, + } + + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{}) + if agent == nil { + t.Fatal("expected agent instance, got nil") + } + + if _, ok := agent.Tools.Get("exec"); ok { + t.Fatal("exec tool should not be registered when exec config is invalid") + } + + if _, ok := agent.Tools.Get("read_file"); !ok { + t.Fatal("read_file tool should still be registered") + } +} From 38e1fe435a1a0431bd44452c50c22bd3f85b1c09 Mon Sep 17 00:00:00 2001 From: Bijin <38134380+sliverp@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:24:46 +0800 Subject: [PATCH 2/6] fix(config): model_list inherits api_key/api_base from providers (#1786) When both providers and model_list are configured, model_list entries with empty api_key or api_base now automatically inherit from the matching provider (matched by protocol prefix in the Model field). Example: a model_list entry with model='deepseek/deepseek-chat' and no api_key will inherit from providers.deepseek.api_key. Explicit model_list values always take precedence. Changes: - Add InheritProviderCredentials() in migration.go - Call it in LoadConfig() after provider-to-model-list conversion - Add protocolProviderMapping for all 25 supported protocols - 6 new tests covering inheritance, precedence, and edge cases Closes #1635 --- pkg/config/config.go | 9 +++ pkg/config/migration.go | 81 ++++++++++++++++++++ pkg/config/migration_test.go | 140 +++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+) diff --git a/pkg/config/config.go b/pkg/config/config.go index d226bba51..4f8026d27 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -916,6 +916,15 @@ func LoadConfig(path string) (*Config, error) { cfg.ModelList = ConvertProvidersToModelList(cfg) } + // Inherit credentials from providers to model_list entries (#1635). + // When both providers and model_list are present, model_list entries + // whose api_key/api_base are empty will inherit from the matching + // provider (matched by protocol prefix). Explicit model_list values + // always take precedence. + if cfg.HasProvidersConfig() { + InheritProviderCredentials(cfg.ModelList, cfg.Providers) + } + // Validate model_list for uniqueness and required fields if err := cfg.ValidateModelList(); err != nil { return nil, err diff --git a/pkg/config/migration.go b/pkg/config/migration.go index c7fc214d5..832d8bf17 100644 --- a/pkg/config/migration.go +++ b/pkg/config/migration.go @@ -468,3 +468,84 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { return result } + +// protocolProviderMapping maps a model protocol prefix (the part before "/" in +// the Model field) to a function that extracts the corresponding ProviderConfig +// from the legacy ProvidersConfig. Used by InheritProviderCredentials. +var protocolProviderMapping = map[string]func(p ProvidersConfig) ProviderConfig{ + "openai": func(p ProvidersConfig) ProviderConfig { return p.OpenAI.ProviderConfig }, + "anthropic": func(p ProvidersConfig) ProviderConfig { return p.Anthropic }, + "litellm": func(p ProvidersConfig) ProviderConfig { return p.LiteLLM }, + "openrouter": func(p ProvidersConfig) ProviderConfig { return p.OpenRouter }, + "groq": func(p ProvidersConfig) ProviderConfig { return p.Groq }, + "zhipu": func(p ProvidersConfig) ProviderConfig { return p.Zhipu }, + "vllm": func(p ProvidersConfig) ProviderConfig { return p.VLLM }, + "gemini": func(p ProvidersConfig) ProviderConfig { return p.Gemini }, + "nvidia": func(p ProvidersConfig) ProviderConfig { return p.Nvidia }, + "ollama": func(p ProvidersConfig) ProviderConfig { return p.Ollama }, + "moonshot": func(p ProvidersConfig) ProviderConfig { return p.Moonshot }, + "shengsuanyun": func(p ProvidersConfig) ProviderConfig { return p.ShengSuanYun }, + "deepseek": func(p ProvidersConfig) ProviderConfig { return p.DeepSeek }, + "cerebras": func(p ProvidersConfig) ProviderConfig { return p.Cerebras }, + "vivgrid": func(p ProvidersConfig) ProviderConfig { return p.Vivgrid }, + "volcengine": func(p ProvidersConfig) ProviderConfig { return p.VolcEngine }, + "github-copilot": func(p ProvidersConfig) ProviderConfig { return p.GitHubCopilot }, + "antigravity": func(p ProvidersConfig) ProviderConfig { return p.Antigravity }, + "qwen": func(p ProvidersConfig) ProviderConfig { return p.Qwen }, + "mistral": func(p ProvidersConfig) ProviderConfig { return p.Mistral }, + "avian": func(p ProvidersConfig) ProviderConfig { return p.Avian }, + "minimax": func(p ProvidersConfig) ProviderConfig { return p.Minimax }, + "longcat": func(p ProvidersConfig) ProviderConfig { return p.LongCat }, + "modelscope": func(p ProvidersConfig) ProviderConfig { return p.ModelScope }, + "novita": func(p ProvidersConfig) ProviderConfig { return p.Novita }, +} + +// InheritProviderCredentials fills in missing api_key, api_base, proxy, and +// request_timeout on model_list entries from the matching legacy providers +// configuration. The match is determined by the protocol prefix in the Model +// field (e.g. "deepseek/deepseek-chat" matches providers.deepseek). +// +// Only empty fields are filled — any value explicitly set on a model_list entry +// takes precedence. This function modifies the slice in place. +// +// This bridges the gap described in issue #1635: users who configure +// credentials once in the providers section expect model_list entries using +// the same protocol to "just work" without duplicating credentials. +func InheritProviderCredentials(models []ModelConfig, providers ProvidersConfig) { + if providers.IsEmpty() { + return + } + + for i := range models { + m := &models[i] + + // Extract protocol prefix from Model field + protocol := "" + if idx := strings.Index(m.Model, "/"); idx > 0 { + protocol = strings.ToLower(m.Model[:idx]) + } + if protocol == "" { + continue + } + + getProvider, ok := protocolProviderMapping[protocol] + if !ok { + continue + } + pc := getProvider(providers) + + // Only fill empty fields — explicit model_list values win + if m.APIKey == "" && pc.APIKey != "" { + m.APIKey = pc.APIKey + } + if m.APIBase == "" && pc.APIBase != "" { + m.APIBase = pc.APIBase + } + if m.Proxy == "" && pc.Proxy != "" { + m.Proxy = pc.Proxy + } + if m.RequestTimeout == 0 && pc.RequestTimeout != 0 { + m.RequestTimeout = pc.RequestTimeout + } + } +} diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go index 1b6e5b032..bea5b9034 100644 --- a/pkg/config/migration_test.go +++ b/pkg/config/migration_test.go @@ -613,3 +613,143 @@ func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T) t.Errorf("Model = %q, want %q (should not duplicate prefix)", result[0].Model, "openrouter/auto") } } + +// ---------- InheritProviderCredentials tests ---------- + +func TestInheritProviderCredentials_FillsMissingAPIKey(t *testing.T) { + models := []ModelConfig{ + {ModelName: "my-deepseek", Model: "deepseek/deepseek-chat"}, + } + providers := ProvidersConfig{ + DeepSeek: ProviderConfig{ + APIKey: "sk-deepseek-from-providers", + APIBase: "https://api.deepseek.com/v1", + }, + } + + InheritProviderCredentials(models, providers) + + if models[0].APIKey != "sk-deepseek-from-providers" { + t.Errorf("APIKey = %q, want %q", models[0].APIKey, "sk-deepseek-from-providers") + } + if models[0].APIBase != "https://api.deepseek.com/v1" { + t.Errorf("APIBase = %q, want %q", models[0].APIBase, "https://api.deepseek.com/v1") + } +} + +func TestInheritProviderCredentials_ExplicitValuesTakePrecedence(t *testing.T) { + models := []ModelConfig{ + { + ModelName: "my-openai", + Model: "openai/gpt-5.4", + APIKey: "sk-explicit-model-key", + APIBase: "https://my-custom-endpoint.com/v1", + }, + } + providers := ProvidersConfig{ + OpenAI: OpenAIProviderConfig{ + ProviderConfig: ProviderConfig{ + APIKey: "sk-provider-key", + APIBase: "https://api.openai.com/v1", + }, + }, + } + + InheritProviderCredentials(models, providers) + + if models[0].APIKey != "sk-explicit-model-key" { + t.Errorf("APIKey = %q, want %q (explicit should win)", models[0].APIKey, "sk-explicit-model-key") + } + if models[0].APIBase != "https://my-custom-endpoint.com/v1" { + t.Errorf("APIBase = %q, want %q (explicit should win)", models[0].APIBase, "https://my-custom-endpoint.com/v1") + } +} + +func TestInheritProviderCredentials_MultipleModels(t *testing.T) { + models := []ModelConfig{ + {ModelName: "groq-llama", Model: "groq/llama-3.1-70b"}, + {ModelName: "zhipu-glm", Model: "zhipu/glm-4"}, + {ModelName: "custom-openai", Model: "openai/gpt-5.4", APIKey: "sk-already-set"}, + } + providers := ProvidersConfig{ + Groq: ProviderConfig{APIKey: "gsk-groq-key", Proxy: "http://proxy:8080"}, + Zhipu: ProviderConfig{APIKey: "zhipu-key-123", APIBase: "https://zhipu.example.com"}, + OpenAI: OpenAIProviderConfig{ + ProviderConfig: ProviderConfig{APIKey: "sk-should-not-override"}, + }, + } + + InheritProviderCredentials(models, providers) + + // groq model should inherit + if models[0].APIKey != "gsk-groq-key" { + t.Errorf("groq APIKey = %q, want %q", models[0].APIKey, "gsk-groq-key") + } + if models[0].Proxy != "http://proxy:8080" { + t.Errorf("groq Proxy = %q, want %q", models[0].Proxy, "http://proxy:8080") + } + + // zhipu model should inherit + if models[1].APIKey != "zhipu-key-123" { + t.Errorf("zhipu APIKey = %q, want %q", models[1].APIKey, "zhipu-key-123") + } + if models[1].APIBase != "https://zhipu.example.com" { + t.Errorf("zhipu APIBase = %q, want %q", models[1].APIBase, "https://zhipu.example.com") + } + + // openai model already has key — should NOT be overridden + if models[2].APIKey != "sk-already-set" { + t.Errorf("openai APIKey = %q, want %q (should not be overridden)", models[2].APIKey, "sk-already-set") + } +} + +func TestInheritProviderCredentials_NoMatchingProvider(t *testing.T) { + models := []ModelConfig{ + {ModelName: "my-model", Model: "novelai/some-model"}, + } + providers := ProvidersConfig{ + DeepSeek: ProviderConfig{APIKey: "sk-deepseek"}, + } + + InheritProviderCredentials(models, providers) + + // No matching provider for "novelai" protocol — should stay empty + if models[0].APIKey != "" { + t.Errorf("APIKey = %q, want empty (no matching provider)", models[0].APIKey) + } +} + +func TestInheritProviderCredentials_EmptyProviders(t *testing.T) { + models := []ModelConfig{ + {ModelName: "my-model", Model: "openai/gpt-5.4"}, + } + providers := ProvidersConfig{} // all empty + + InheritProviderCredentials(models, providers) + + // Empty providers — nothing to inherit + if models[0].APIKey != "" { + t.Errorf("APIKey = %q, want empty", models[0].APIKey) + } +} + +func TestInheritProviderCredentials_InheritsRequestTimeout(t *testing.T) { + models := []ModelConfig{ + {ModelName: "my-ollama", Model: "ollama/llama3.2:3b"}, + } + providers := ProvidersConfig{ + Ollama: ProviderConfig{ + APIBase: "http://localhost:11434", + RequestTimeout: 120, + }, + } + + InheritProviderCredentials(models, providers) + + if models[0].APIBase != "http://localhost:11434" { + t.Errorf("APIBase = %q, want %q", models[0].APIBase, "http://localhost:11434") + } + if models[0].RequestTimeout != 120 { + t.Errorf("RequestTimeout = %d, want 120", models[0].RequestTimeout) + } +} From bb59518958bf519120c66c1799acb79bdd1de10c Mon Sep 17 00:00:00 2001 From: I Putu Eddy Irawan <40250580+putueddy@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:28:35 +0700 Subject: [PATCH 3/6] docs: add Indonesian (Bahasa Indonesia) README translation (#1777) - Rewrite README.id.md to match current upstream structure (~250 lines) - Detailed docs moved to docs/*.md, README is quick-start only - Sync badges (Go 1.25+, LoongArch), news (v0.2.3), Termux instructions - Add Bahasa Indonesia + Italiano to language selectors in all 8 READMEs --- README.fr.md | 2 +- README.id.md | 249 ++++++++++++++++++++++++++++++++++++++++++++++++ README.it.md | 2 +- README.ja.md | 2 +- README.md | 2 +- README.pt-br.md | 2 +- README.vi.md | 2 +- README.zh.md | 2 +- 8 files changed, 256 insertions(+), 7 deletions(-) create mode 100644 README.id.md diff --git a/README.fr.md b/README.fr.md index 325c6c096..bf49ed90a 100644 --- a/README.fr.md +++ b/README.fr.md @@ -18,7 +18,7 @@ Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md) | **Français** +[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md) diff --git a/README.id.md b/README.id.md new file mode 100644 index 000000000..3f462981c --- /dev/null +++ b/README.id.md @@ -0,0 +1,249 @@ +
+ PicoClaw + +

PicoClaw: Asisten AI Super Ringan berbasis Go

+ +

Perangkat Keras $10 · RAM <10MB · Boot <1 Detik · Ayo, Berangkat!

+

+ Go + Hardware + License +
+ Website + Docs + Wiki +
+ Twitter + + Discord +

+ +[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [English](README.md) | **Bahasa Indonesia** + +
+ +--- + +> **PicoClaw** adalah proyek open-source independen yang diinisiasi oleh [Sipeed](https://sipeed.com). Ditulis sepenuhnya dalam **Go** — bukan fork dari OpenClaw, NanoBot, atau proyek lainnya. + +🦐 PicoClaw adalah asisten AI pribadi yang super ringan, terinspirasi dari [NanoBot](https://github.com/HKUDS/nanobot), ditulis ulang sepenuhnya dalam Go melalui proses "self-bootstrapping" — di mana AI Agent itu sendiri yang memandu seluruh migrasi arsitektur dan optimasi kode. + +⚡️ Berjalan di perangkat keras $10 dengan RAM <10MB: Hemat 99% memori dibanding OpenClaw dan 98% lebih murah dibanding Mac mini! + + + + + + +
+

+ +

+
+

+ +

+
+ +> [!CAUTION] +> **🚨 KEAMANAN & SALURAN RESMI** +> +> * **TANPA KRIPTO:** PicoClaw **TIDAK** memiliki token/koin resmi. Semua klaim di `pump.fun` atau platform trading lainnya adalah **PENIPUAN**. +> +> * **DOMAIN RESMI:** Satu-satunya website resmi adalah **[picoclaw.io](https://picoclaw.io)**, dan website perusahaan adalah **[sipeed.com](https://sipeed.com)** +> * **Peringatan:** Banyak domain `.ai/.org/.com/.net/...` yang didaftarkan oleh pihak ketiga. +> * **Peringatan:** PicoClaw masih dalam tahap pengembangan awal dan mungkin memiliki masalah keamanan jaringan yang belum teratasi. Jangan deploy ke lingkungan produksi sebelum rilis v1.0. +> * **Catatan:** PicoClaw baru-baru ini menggabungkan banyak PR, yang mungkin mengakibatkan penggunaan memori lebih besar (10–20MB) pada versi terbaru. Kami berencana untuk memprioritaskan optimasi sumber daya segera setelah fitur saat ini mencapai kondisi stabil. + +## 📢 Berita + +2026-03-17 🚀 **v0.2.3 Dirilis!** UI system tray (Windows & Linux), pelacakan status sub-agent (`spawn_status`), eksperimental gateway hot-reload, gerbang keamanan cron, dan 2 perbaikan keamanan. PicoClaw kini di **25K ⭐**! + +2026-03-09 🎉 **v0.2.1 — Update terbesar!** Dukungan protokol MCP, 4 channel baru (Matrix/IRC/WeCom/Discord Proxy), 3 provider baru (Kimi/Minimax/Avian), pipeline vision, penyimpanan memori JSONL, dan routing model. + +2026-02-28 📦 **v0.2.0** dirilis dengan dukungan Docker Compose dan launcher Web UI. + +2026-02-26 🎉 PicoClaw mencapai **20K bintang** hanya dalam 17 hari! Orkestrasi channel otomatis dan antarmuka kapabilitas diluncurkan. + +
+Berita lama... + +2026-02-16 🎉 PicoClaw mencapai 12K bintang dalam satu minggu! Peran maintainer komunitas dan [roadmap](ROADMAP.md) resmi diposting. + +2026-02-13 🎉 PicoClaw mencapai 5000 bintang dalam 4 hari! Roadmap Proyek dan pengaturan Grup Pengembang sedang berjalan. + +2026-02-09 🎉 **PicoClaw Diluncurkan!** Dibangun dalam 1 hari untuk menghadirkan AI Agent ke perangkat keras $10 dengan RAM <10MB. 🦐 PicoClaw, Ayo Berangkat! + +
+ +## ✨ Fitur + +🪶 **Super Ringan**: Penggunaan memori <10MB — 99% lebih kecil dari fungsionalitas inti OpenClaw.* + +💰 **Biaya Minimal**: Cukup efisien untuk berjalan di perangkat keras $10 — 98% lebih murah dari Mac mini. + +⚡️ **Secepat Kilat**: Waktu startup 400X lebih cepat, boot dalam <1 detik bahkan di prosesor single core 0,6GHz. + +🌍 **Portabilitas Sejati**: Satu binary mandiri untuk RISC-V, ARM, MIPS, dan x86, Satu Klik Langsung Jalan! + +🤖 **AI-Bootstrapped**: Implementasi Go-native secara otonom — 95% kode inti dihasilkan oleh Agent dengan penyempurnaan human-in-the-loop. + +🔌 **Dukungan MCP**: Integrasi [Model Context Protocol](https://modelcontextprotocol.io/) native — hubungkan server MCP mana pun untuk memperluas kapabilitas agent. + +👁️ **Pipeline Vision**: Kirim gambar dan file langsung ke agent — encoding base64 otomatis untuk LLM multimodal. + +🧠 **Routing Cerdas**: Routing model berbasis aturan — kueri sederhana diarahkan ke model ringan, menghemat biaya API. + +_*Versi terbaru mungkin menggunakan 10–20MB karena penggabungan fitur yang cepat. Optimasi sumber daya direncanakan. Perbandingan startup berdasarkan benchmark prosesor single-core 0,8GHz (lihat tabel di bawah)._ + +| | OpenClaw | NanoBot | **PicoClaw** | +| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | +| **Bahasa** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB*** | +| **Startup**
(0,8GHz core) | >500d | >30d | **<1d** | +| **Biaya** | Mac Mini $599 | Kebanyakan Linux SBC
~$50 | **Semua Board Linux**
**Mulai dari $10** | + +PicoClaw + +## 🦾 Demonstrasi + +### 🛠️ Alur Kerja Asisten Standar + + + + + + + + + + + + + + + + + +

🧩 Full-Stack Engineer

🗂️ Pencatatan & Manajemen Perencanaan

🔎 Pencarian Web & Pembelajaran

Develop • Deploy • ScaleJadwal • Otomasi • MemoriPenemuan • Wawasan • Tren
+ +### 📱 Jalankan di HP Android Lama + +Berikan kehidupan kedua untuk HP lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw. Panduan Cepat: + +1. **Instal [Termux](https://github.com/termux/termux-app)** (Unduh dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play). +2. **Jalankan perintah** + +```bash +# Unduh rilis terbaru dari https://github.com/sipeed/picoclaw/releases +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz +pkg install proot +termux-chroot ./picoclaw onboard +``` + +Kemudian ikuti instruksi di bagian "Panduan Cepat" untuk menyelesaikan konfigurasi! + +PicoClaw + +### 🐜 Deploy Inovatif dengan Footprint Rendah + +PicoClaw dapat di-deploy di hampir semua perangkat Linux! + +- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versi E(Ethernet) atau W(WiFi6), untuk Home Assistant Minimal +- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), atau $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) untuk Pemeliharaan Server Otomatis +- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) atau $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) untuk Pemantauan Cerdas + + + +🌟 Lebih Banyak Kasus Deploy Menanti! + +## 📦 Instalasi + +### Instal dengan binary yang sudah dikompilasi + +Unduh binary untuk platform Anda dari halaman [Releases](https://github.com/sipeed/picoclaw/releases). + +### Instal dari source (fitur terbaru, disarankan untuk pengembangan) + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# Build, tidak perlu instal +make build + +# Build untuk berbagai platform +make build-all + +# Build untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) +make build-pi-zero + +# Build dan Instal +make install +``` + +**Raspberry Pi Zero 2 W:** Gunakan binary yang sesuai dengan OS Anda: Raspberry Pi OS 32-bit → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Atau jalankan `make build-pi-zero` untuk build keduanya. + +## 📚 Dokumentasi + +Untuk panduan lengkap, lihat dokumen di bawah. README ini hanya berisi panduan cepat. + +| Topik | Deskripsi | +|-------|-----------| +| 🐳 [Docker & Panduan Cepat](docs/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent, konfigurasi Panduan Cepat | +| 💬 [Aplikasi Chat](docs/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom, dan lainnya | +| ⚙️ [Konfigurasi](docs/configuration.md) | Variabel environment, tata letak workspace, sumber skill, sandbox keamanan, heartbeat | +| 🔌 [Provider & Model](docs/providers.md) | 20+ provider LLM, routing model, konfigurasi model_list, arsitektur provider | +| 🔄 [Spawn & Tugas Async](docs/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async | +| 🐛 [Pemecahan Masalah](docs/troubleshooting.md) | Masalah umum dan solusinya | +| 🔧 [Konfigurasi Tools](docs/tools_configuration.md) | Aktifkan/nonaktifkan tool, kebijakan exec | + +## ClawdChat Bergabung dengan Jaringan Sosial Agent + +Hubungkan Picoclaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan melalui CLI atau Aplikasi Chat terintegrasi. + +**Baca `https://clawdchat.ai/skill.md` dan ikuti instruksi untuk bergabung dengan [ClawdChat.ai](https://clawdchat.ai)** + +## 🖥️ Referensi CLI + +| Perintah | Deskripsi | +| ------------------------- | -------------------------------- | +| `picoclaw onboard` | Inisialisasi konfigurasi & workspace | +| `picoclaw agent -m "..."` | Chat dengan agent | +| `picoclaw agent` | Mode chat interaktif | +| `picoclaw gateway` | Mulai gateway | +| `picoclaw status` | Tampilkan status | +| `picoclaw version` | Tampilkan info versi | +| `picoclaw cron list` | Daftar semua tugas terjadwal | +| `picoclaw cron add ...` | Tambah tugas terjadwal | +| `picoclaw cron disable` | Nonaktifkan tugas terjadwal | +| `picoclaw cron remove` | Hapus tugas terjadwal | +| `picoclaw skills list` | Daftar skill yang terinstal | +| `picoclaw skills install` | Instal skill | +| `picoclaw migrate` | Migrasi data dari versi lama | +| `picoclaw auth login` | Autentikasi dengan provider | + +### Tugas Terjadwal / Pengingat + +PicoClaw mendukung pengingat terjadwal dan tugas berulang melalui tool `cron`: + +* **Pengingat satu kali**: "Ingatkan saya dalam 10 menit" → terpicu sekali setelah 10 menit +* **Tugas berulang**: "Ingatkan saya setiap 2 jam" → terpicu setiap 2 jam +* **Ekspresi cron**: "Ingatkan saya jam 9 pagi setiap hari" → menggunakan ekspresi cron + +## 🤝 Kontribusi & Roadmap + +PR sangat diterima! Codebase sengaja dibuat kecil dan mudah dibaca. 🤗 + +Lihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) lengkap kami. + +Grup pengembang sedang dibangun, bergabunglah setelah PR pertama Anda di-merge! + +Grup Pengguna: + +discord: + +PicoClaw diff --git a/README.it.md b/README.it.md index 1f5acadcf..27027d95f 100644 --- a/README.it.md +++ b/README.it.md @@ -18,7 +18,7 @@ Discord

-[中文](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](README.md) | **Italiano** +[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [English](README.md) diff --git a/README.ja.md b/README.ja.md index 5cfd6359a..3c017aacd 100644 --- a/README.ja.md +++ b/README.ja.md @@ -18,7 +18,7 @@ Discord

-[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.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) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md) diff --git a/README.md b/README.md index 2aa3b631f..d9785f200 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.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) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **English** diff --git a/README.pt-br.md b/README.pt-br.md index 04f7dae26..928e4778c 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -18,7 +18,7 @@ Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md) diff --git a/README.vi.md b/README.vi.md index 3832890ed..c7ad6b4be 100644 --- a/README.vi.md +++ b/README.vi.md @@ -18,7 +18,7 @@ Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.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) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md) diff --git a/README.zh.md b/README.zh.md index bbb8e8e4d..7bf936709 100644 --- a/README.zh.md +++ b/README.zh.md @@ -18,7 +18,7 @@ Discord

-**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) +**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md) From 05c65d2fe70c16c9671194606d939c5fdd621519 Mon Sep 17 00:00:00 2001 From: Alix-007 Date: Thu, 19 Mar 2026 21:35:17 +0800 Subject: [PATCH 4/6] fix(provider): skip empty anthropic tool names (#1772) Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com> --- pkg/providers/anthropic_messages/provider.go | 4 ++ .../anthropic_messages/provider_test.go | 45 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/pkg/providers/anthropic_messages/provider.go b/pkg/providers/anthropic_messages/provider.go index c201dfe00..2b19e941a 100644 --- a/pkg/providers/anthropic_messages/provider.go +++ b/pkg/providers/anthropic_messages/provider.go @@ -221,6 +221,10 @@ func buildRequestBody( // Add tool_use blocks for _, tc := range msg.ToolCalls { + if strings.TrimSpace(tc.Name) == "" { + continue + } + // Handle nil Arguments (GLM-4 may return null input) input := tc.Arguments if input == nil { diff --git a/pkg/providers/anthropic_messages/provider_test.go b/pkg/providers/anthropic_messages/provider_test.go index da4213e92..8eabc15fa 100644 --- a/pkg/providers/anthropic_messages/provider_test.go +++ b/pkg/providers/anthropic_messages/provider_test.go @@ -492,6 +492,20 @@ func TestBuildRequestBodyEdgeCases(t *testing.T) { }, wantErr: false, }, + { + name: "skip tool calls with empty names", + messages: []Message{ + {Role: "assistant", Content: "Calling tool", ToolCalls: []ToolCall{ + {ID: "tool-empty", Name: "", Arguments: map[string]any{"ignored": true}}, + {ID: "tool-valid", Name: "test_tool", Arguments: map[string]any{"arg": "value"}}, + }}, + }, + model: "test-model", + options: map[string]any{ + "max_tokens": 8192, + }, + wantErr: false, + }, } for _, tt := range tests { @@ -513,6 +527,37 @@ func TestBuildRequestBodyEdgeCases(t *testing.T) { if got["model"] != tt.model { t.Errorf("model = %v, want %v", got["model"], tt.model) } + + if tt.name == "skip tool calls with empty names" { + messages, ok := got["messages"].([]any) + if !ok || len(messages) != 1 { + t.Fatalf("messages = %#v, want single assistant message", got["messages"]) + } + + assistantMsg, ok := messages[0].(map[string]any) + if !ok { + t.Fatalf("assistant message = %#v, want map", messages[0]) + } + + content, ok := assistantMsg["content"].([]any) + if !ok { + t.Fatalf("assistant content = %#v, want []any", assistantMsg["content"]) + } + if len(content) != 2 { + t.Fatalf("assistant content length = %d, want 2", len(content)) + } + + toolUse, ok := content[1].(map[string]any) + if !ok { + t.Fatalf("tool_use block = %#v, want map", content[1]) + } + if gotName := toolUse["name"]; gotName != "test_tool" { + t.Fatalf("tool_use name = %v, want %q", gotName, "test_tool") + } + if gotID := toolUse["id"]; gotID != "tool-valid" { + t.Fatalf("tool_use id = %v, want %q", gotID, "tool-valid") + } + } }) } } From 276a0cb92cfaa886ac0332b533659365262472ae Mon Sep 17 00:00:00 2001 From: Alix-007 Date: Thu, 19 Mar 2026 21:44:01 +0800 Subject: [PATCH 5/6] fix(agent): rebind provider after /switch model to (#1769) * fix(agent): rebind provider after model switch * test(agent): deduplicate switch model mock servers --------- Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com> --- pkg/agent/instance.go | 49 +------ pkg/agent/loop.go | 34 ++++- pkg/agent/loop_test.go | 246 +++++++++++++++++++++++++++++++++- pkg/agent/model_resolution.go | 97 ++++++++++++++ 4 files changed, 371 insertions(+), 55 deletions(-) create mode 100644 pkg/agent/model_resolution.go diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index d2a4f81a4..355e78a33 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -152,59 +152,14 @@ func NewAgentInstance( } // Resolve fallback candidates - modelCfg := providers.ModelConfig{ - Primary: model, - Fallbacks: fallbacks, - } - resolveFromModelList := func(raw string) (string, bool) { - ensureProtocol := func(model string) string { - model = strings.TrimSpace(model) - if model == "" { - return "" - } - if strings.Contains(model, "/") { - return model - } - return "openai/" + model - } - - raw = strings.TrimSpace(raw) - if raw == "" { - return "", false - } - - if cfg != nil { - if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" { - return ensureProtocol(mc.Model), true - } - - for i := range cfg.ModelList { - fullModel := strings.TrimSpace(cfg.ModelList[i].Model) - if fullModel == "" { - continue - } - if fullModel == raw { - return ensureProtocol(fullModel), true - } - _, modelID := providers.ExtractProtocol(fullModel) - if modelID == raw { - return ensureProtocol(fullModel), true - } - } - } - - return "", false - } - - candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList) + candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks) // Model routing setup: pre-resolve light model candidates at creation time // to avoid repeated model_list lookups on every incoming message. var router *routing.Router var lightCandidates []providers.FallbackCandidate if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" { - lightModelCfg := providers.ModelConfig{Primary: rc.LightModel} - resolved := providers.ResolveCandidatesWithLookup(lightModelCfg, defaults.Provider, resolveFromModelList) + resolved := resolveModelCandidates(cfg, defaults.Provider, rc.LightModel, nil) if len(resolved) > 0 { router = routing.New(routing.RouterConfig{ LightModel: rc.LightModel, diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index edb0994c2..aade18014 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1477,7 +1477,7 @@ func (al *AgentLoop) selectCandidates( history []providers.Message, ) (candidates []providers.FallbackCandidate, model string) { if agent.Router == nil || len(agent.LightCandidates) == 0 { - return agent.Candidates, agent.Model + return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model) } _, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model) @@ -1488,7 +1488,7 @@ func (al *AgentLoop) selectCandidates( "score": score, "threshold": agent.Router.Threshold(), }) - return agent.Candidates, agent.Model + return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model) } logger.InfoCF("agent", "Model routing: light model selected", @@ -1498,7 +1498,7 @@ func (al *AgentLoop) selectCandidates( "score": score, "threshold": agent.Router.Threshold(), }) - return agent.LightCandidates, agent.Router.LightModel() + return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()) } // maybeSummarize triggers summarization if the session history exceeds thresholds. @@ -1961,11 +1961,37 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt } if agent != nil { rt.GetModelInfo = func() (string, string) { - return agent.Model, cfg.Agents.Defaults.Provider + return agent.Model, resolvedCandidateProvider(agent.Candidates, cfg.Agents.Defaults.Provider) } rt.SwitchModel = func(value string) (string, error) { + value = strings.TrimSpace(value) + modelCfg, err := resolvedModelConfig(cfg, value, agent.Workspace) + if err != nil { + return "", err + } + + nextProvider, _, err := providers.CreateProviderFromConfig(modelCfg) + if err != nil { + return "", fmt.Errorf("failed to initialize model %q: %w", value, err) + } + + nextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, modelCfg.Model, agent.Fallbacks) + if len(nextCandidates) == 0 { + return "", fmt.Errorf("model %q did not resolve to any provider candidates", value) + } + oldModel := agent.Model + oldProvider := agent.Provider agent.Model = value + agent.Provider = nextProvider + agent.Candidates = nextCandidates + agent.ThinkingLevel = parseThinkingLevel(modelCfg.ThinkingLevel) + + if oldProvider != nil && oldProvider != nextProvider { + if stateful, ok := oldProvider.(providers.StatefulProvider); ok { + stateful.Close() + } + } return oldModel, nil } diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 8432ccac4..b6b6c2c6c 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -2,7 +2,10 @@ package agent import ( "context" + "encoding/json" "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "slices" @@ -444,6 +447,46 @@ type testHelper struct { al *AgentLoop } +func newChatCompletionTestServer( + t *testing.T, + label string, + response string, + calls *int, + model *string, +) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Fatalf("%s server path = %q, want /chat/completions", label, r.URL.Path) + } + *calls = *calls + 1 + defer r.Body.Close() + + var req struct { + Model string `json:"model"` + } + decodeErr := json.NewDecoder(r.Body).Decode(&req) + if decodeErr != nil { + t.Fatalf("decode %s request: %v", label, decodeErr) + } + *model = req.Model + + w.Header().Set("Content-Type", "application/json") + encodeErr := json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": response}, + "finish_reason": "stop", + }, + }, + }) + if encodeErr != nil { + t.Fatalf("encode %s response: %v", label, encodeErr) + } + })) +} + func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, msg bus.InboundMessage) string { // Use a short timeout to avoid hanging timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout) @@ -605,11 +648,25 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { Defaults: config.AgentDefaults{ Workspace: tmpDir, Provider: "openai", - Model: "before-switch", + Model: "local", MaxTokens: 4096, MaxToolIterations: 10, }, }, + ModelList: []config.ModelConfig{ + { + ModelName: "local", + Model: "openai/local-model", + APIKey: "test-key", + APIBase: "https://local.example.invalid/v1", + }, + { + ModelName: "deepseek", + Model: "openrouter/deepseek/deepseek-v3.2", + APIKey: "test-key", + APIBase: "https://openrouter.ai/api/v1", + }, + }, } msgBus := bus.NewMessageBus() @@ -621,13 +678,13 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { Channel: "telegram", SenderID: "user1", ChatID: "chat1", - Content: "/switch model to after-switch", + Content: "/switch model to deepseek", Peer: bus.Peer{ Kind: "direct", ID: "user1", }, }) - if !strings.Contains(switchResp, "Switched model from before-switch to after-switch") { + if !strings.Contains(switchResp, "Switched model from local to deepseek") { t.Fatalf("unexpected /switch reply: %q", switchResp) } @@ -641,7 +698,7 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { ID: "user1", }, }) - if !strings.Contains(showResp, "Current Model: after-switch (Provider: openai)") { + if !strings.Contains(showResp, "Current Model: deepseek (Provider: openrouter)") { t.Fatalf("unexpected /show model reply after switch: %q", showResp) } @@ -650,6 +707,187 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { } } +func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-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, + Provider: "openai", + Model: "local", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + ModelList: []config.ModelConfig{ + { + ModelName: "local", + Model: "openai/local-model", + APIKey: "test-key", + APIBase: "https://local.example.invalid/v1", + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &countingMockProvider{response: "LLM reply"} + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "/switch model to missing", + Peer: bus.Peer{ + Kind: "direct", + ID: "user1", + }, + }) + if switchResp != `model "missing" not found in model_list or providers` { + t.Fatalf("unexpected /switch error reply: %q", switchResp) + } + + showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "/show model", + Peer: bus.Peer{ + Kind: "direct", + ID: "user1", + }, + }) + if !strings.Contains(showResp, "Current Model: local (Provider: openai)") { + t.Fatalf("unexpected /show model reply after rejected switch: %q", showResp) + } + + if provider.calls != 0 { + t.Fatalf("LLM should not be called for rejected /switch and /show, calls=%d", provider.calls) + } +} + +func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + localCalls := 0 + localModel := "" + localServer := newChatCompletionTestServer(t, "local", "local reply", &localCalls, &localModel) + defer localServer.Close() + + remoteCalls := 0 + remoteModel := "" + remoteServer := newChatCompletionTestServer(t, "remote", "remote reply", &remoteCalls, &remoteModel) + defer remoteServer.Close() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Provider: "openai", + Model: "local", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + ModelList: []config.ModelConfig{ + { + ModelName: "local", + Model: "openai/Qwen3.5-35B-A3B", + APIKey: "local-key", + APIBase: localServer.URL, + }, + { + ModelName: "deepseek", + Model: "openrouter/deepseek/deepseek-v3.2", + APIKey: "remote-key", + APIBase: remoteServer.URL, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider, _, err := providers.CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + firstResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "hello before switch", + Peer: bus.Peer{ + Kind: "direct", + ID: "user1", + }, + }) + if firstResp != "local reply" { + t.Fatalf("unexpected response before switch: %q", firstResp) + } + if localCalls != 1 { + t.Fatalf("local calls before switch = %d, want 1", localCalls) + } + if remoteCalls != 0 { + t.Fatalf("remote calls before switch = %d, want 0", remoteCalls) + } + if localModel != "Qwen3.5-35B-A3B" { + t.Fatalf("local model before switch = %q, want %q", localModel, "Qwen3.5-35B-A3B") + } + + switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "/switch model to deepseek", + Peer: bus.Peer{ + Kind: "direct", + ID: "user1", + }, + }) + if !strings.Contains(switchResp, "Switched model from local to deepseek") { + t.Fatalf("unexpected /switch reply: %q", switchResp) + } + + secondResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "hello after switch", + Peer: bus.Peer{ + Kind: "direct", + ID: "user1", + }, + }) + if secondResp != "remote reply" { + t.Fatalf("unexpected response after switch: %q", secondResp) + } + if localCalls != 1 { + t.Fatalf("local calls after switch = %d, want 1", localCalls) + } + if remoteCalls != 1 { + t.Fatalf("remote calls after switch = %d, want 1", remoteCalls) + } + if remoteModel != "deepseek-v3.2" { + t.Fatalf( + "remote model after switch = %q, want %q", + remoteModel, + "deepseek-v3.2", + ) + } +} + // TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") diff --git a/pkg/agent/model_resolution.go b/pkg/agent/model_resolution.go new file mode 100644 index 000000000..140cff718 --- /dev/null +++ b/pkg/agent/model_resolution.go @@ -0,0 +1,97 @@ +package agent + +import ( + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +func buildModelListResolver(cfg *config.Config) func(raw string) (string, bool) { + ensureProtocol := func(model string) string { + model = strings.TrimSpace(model) + if model == "" { + return "" + } + if strings.Contains(model, "/") { + return model + } + return "openai/" + model + } + + return func(raw string) (string, bool) { + raw = strings.TrimSpace(raw) + if raw == "" || cfg == nil { + return "", false + } + + if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" { + return ensureProtocol(mc.Model), true + } + + for i := range cfg.ModelList { + fullModel := strings.TrimSpace(cfg.ModelList[i].Model) + if fullModel == "" { + continue + } + if fullModel == raw { + return ensureProtocol(fullModel), true + } + _, modelID := providers.ExtractProtocol(fullModel) + if modelID == raw { + return ensureProtocol(fullModel), true + } + } + + return "", false + } +} + +func resolveModelCandidates( + cfg *config.Config, + defaultProvider string, + primary string, + fallbacks []string, +) []providers.FallbackCandidate { + return providers.ResolveCandidatesWithLookup( + providers.ModelConfig{ + Primary: primary, + Fallbacks: fallbacks, + }, + defaultProvider, + buildModelListResolver(cfg), + ) +} + +func resolvedCandidateModel(candidates []providers.FallbackCandidate, fallback string) string { + if len(candidates) > 0 && strings.TrimSpace(candidates[0].Model) != "" { + return candidates[0].Model + } + return fallback +} + +func resolvedCandidateProvider(candidates []providers.FallbackCandidate, fallback string) string { + if len(candidates) > 0 && strings.TrimSpace(candidates[0].Provider) != "" { + return candidates[0].Provider + } + return fallback +} + +func resolvedModelConfig(cfg *config.Config, modelName, workspace string) (*config.ModelConfig, error) { + if cfg == nil { + return nil, fmt.Errorf("config is nil") + } + + modelCfg, err := cfg.GetModelConfig(strings.TrimSpace(modelName)) + if err != nil { + return nil, err + } + + clone := *modelCfg + if clone.Workspace == "" { + clone.Workspace = workspace + } + + return &clone, nil +} From 9a3ca8e54d4a224a2a782c6d4855c42bfe90a353 Mon Sep 17 00:00:00 2001 From: Adi Susilayasa <71677862+adisusilayasa@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:07:30 +0800 Subject: [PATCH 6/6] feat(provider): add Alibaba Coding Plan and regional Qwen endpoints (#1748) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(provider): add Alibaba Coding Plan and regional Qwen endpoints - Add Alibaba Coding Plan provider with OpenAI-compatible endpoint (https://coding-intl.dashscope.aliyuncs.com/v1) - Add Coding Plan Anthropic-compatible endpoint (https://coding-intl.dashscope.aliyuncs.com/apps/anthropic) - Add regional Qwen endpoints (qwen-intl, qwen-us) - Add provider aliases: coding-plan, alibaba-coding, qwen-coding - Normalize provider names for coding-plan variants 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix(provider): add reviewer-requested fixes for Alibaba Coding Plan - Add qwen-international, dashscope-intl, dashscope-us aliases to switch case - Add coding-plan-anthropic case with anthropicmessages.NewProviderWithTimeout - Add alibaba-coding-anthropic -> coding-plan-anthropic normalization - Add qwen-international -> qwen-intl and dashscope-us -> qwen-us normalization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * test(provider): add tests for Alibaba Coding Plan protocol aliases - Add tests for qwen-international, dashscope-intl, dashscope-us aliases - Add tests for coding-plan-anthropic and alibaba-coding-anthropic - Add getDefaultAPIBase tests for all new aliases - Add normalization tests for new provider aliases 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- pkg/providers/factory_provider.go | 28 +++++- pkg/providers/factory_provider_test.go | 131 +++++++++++++++++++++++++ pkg/providers/model_ref.go | 8 ++ pkg/providers/model_ref_test.go | 8 ++ 4 files changed, 173 insertions(+), 2 deletions(-) diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index dbb5db5cb..a7fef8f5b 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -115,8 +115,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", - "vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian", - "minimax", "longcat", "modelscope", "novita": + "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", + "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", + "coding-plan", "alibaba-coding", "qwen-coding": // 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) @@ -173,6 +174,21 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.RequestTimeout, ), modelID, nil + case "coding-plan-anthropic", "alibaba-coding-anthropic": + // Alibaba Coding Plan with Anthropic-compatible API + apiBase := cfg.APIBase + if apiBase == "" { + apiBase = getDefaultAPIBase(protocol) + } + if cfg.APIKey == "" { + return nil, "", fmt.Errorf("api_key is required for %q protocol (model: %s)", protocol, cfg.Model) + } + return anthropicmessages.NewProviderWithTimeout( + cfg.APIKey, + apiBase, + cfg.RequestTimeout, + ), modelID, nil + case "antigravity": return NewAntigravityProvider(), modelID, nil @@ -245,6 +261,14 @@ func getDefaultAPIBase(protocol string) string { return "https://ark.cn-beijing.volces.com/api/v3" case "qwen": return "https://dashscope.aliyuncs.com/compatible-mode/v1" + case "qwen-intl", "qwen-international", "dashscope-intl": + return "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" + case "qwen-us", "dashscope-us": + return "https://dashscope-us.aliyuncs.com/compatible-mode/v1" + case "coding-plan", "alibaba-coding", "qwen-coding": + return "https://coding-intl.dashscope.aliyuncs.com/v1" + case "coding-plan-anthropic", "alibaba-coding-anthropic": + return "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" case "vllm": return "http://localhost:8000/v1" case "mistral": diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index c7629ad9d..8b9ddeecd 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -472,3 +472,134 @@ func TestCreateProviderFromConfig_AzureMissingAPIBase(t *testing.T) { t.Fatal("CreateProviderFromConfig() expected error for missing API base") } } + +func TestCreateProviderFromConfig_QwenInternationalAlias(t *testing.T) { + tests := []struct { + name string + protocol string + }{ + {"qwen-international", "qwen-international"}, + {"dashscope-intl", "dashscope-intl"}, + {"qwen-intl", "qwen-intl"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-" + tt.protocol, + Model: tt.protocol + "/qwen-max", + 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 != "qwen-max" { + t.Errorf("modelID = %q, want %q", modelID, "qwen-max") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } + }) + } +} + +func TestCreateProviderFromConfig_QwenUSAlias(t *testing.T) { + tests := []struct { + name string + protocol string + }{ + {"qwen-us", "qwen-us"}, + {"dashscope-us", "dashscope-us"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-" + tt.protocol, + Model: tt.protocol + "/qwen-max", + 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 != "qwen-max" { + t.Errorf("modelID = %q, want %q", modelID, "qwen-max") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } + }) + } +} + +func TestCreateProviderFromConfig_CodingPlanAnthropic(t *testing.T) { + tests := []struct { + name string + protocol string + }{ + {"coding-plan-anthropic", "coding-plan-anthropic"}, + {"alibaba-coding-anthropic", "alibaba-coding-anthropic"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-" + tt.protocol, + Model: tt.protocol + "/claude-sonnet-4-20250514", + 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-20250514" { + t.Errorf("modelID = %q, want %q", modelID, "claude-sonnet-4-20250514") + } + // coding-plan-anthropic uses Anthropic Messages provider + // Verify it's the anthropic messages provider by checking interface + var _ LLMProvider = provider + }) + } +} + +func TestGetDefaultAPIBase_CodingPlanAnthropic(t *testing.T) { + expectedURL := "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" + if got := getDefaultAPIBase("coding-plan-anthropic"); got != expectedURL { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "coding-plan-anthropic", got, expectedURL) + } + if got := getDefaultAPIBase("alibaba-coding-anthropic"); got != expectedURL { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "alibaba-coding-anthropic", got, expectedURL) + } +} + +func TestGetDefaultAPIBase_QwenIntlAliases(t *testing.T) { + expectedURL := "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" + for _, protocol := range []string{"qwen-intl", "qwen-international", "dashscope-intl"} { + if got := getDefaultAPIBase(protocol); got != expectedURL { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", protocol, got, expectedURL) + } + } +} + +func TestGetDefaultAPIBase_QwenUSAliases(t *testing.T) { + expectedURL := "https://dashscope-us.aliyuncs.com/compatible-mode/v1" + for _, protocol := range []string{"qwen-us", "dashscope-us"} { + if got := getDefaultAPIBase(protocol); got != expectedURL { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", protocol, got, expectedURL) + } + } +} diff --git a/pkg/providers/model_ref.go b/pkg/providers/model_ref.go index 0d1b02d16..be9f63bc6 100644 --- a/pkg/providers/model_ref.go +++ b/pkg/providers/model_ref.go @@ -53,6 +53,14 @@ func NormalizeProvider(provider string) string { return "zhipu" case "google": return "gemini" + case "alibaba-coding", "qwen-coding": + return "coding-plan" + case "alibaba-coding-anthropic": + return "coding-plan-anthropic" + case "qwen-international", "dashscope-intl": + return "qwen-intl" + case "dashscope-us": + return "qwen-us" } return p diff --git a/pkg/providers/model_ref_test.go b/pkg/providers/model_ref_test.go index 6dd25167f..040c511ba 100644 --- a/pkg/providers/model_ref_test.go +++ b/pkg/providers/model_ref_test.go @@ -73,6 +73,14 @@ func TestNormalizeProvider(t *testing.T) { {"glm", "zhipu"}, {"google", "gemini"}, {"groq", "groq"}, + // Alibaba Coding Plan aliases + {"alibaba-coding", "coding-plan"}, + {"qwen-coding", "coding-plan"}, + {"alibaba-coding-anthropic", "coding-plan-anthropic"}, + // Qwen international aliases + {"qwen-international", "qwen-intl"}, + {"dashscope-intl", "qwen-intl"}, + {"dashscope-us", "qwen-us"}, {"", ""}, }