Merge remote-tracking branch 'origin/main' into refactor/cmd

This commit is contained in:
Ruslan Semagin 2026-02-24 14:19:17 +03:00
commit 209054f5bb
24 changed files with 240 additions and 40 deletions

View file

@ -226,7 +226,7 @@ picoclaw onboard
],
"agents": {
"defaults": {
"model": "gpt4"
"model_name": "gpt4"
}
},
"channels": {

View file

@ -188,7 +188,7 @@ picoclaw onboard
],
"agents": {
"defaults": {
"model": "gpt4"
"model_name": "gpt4"
}
},
"channels": {

View file

@ -12,6 +12,9 @@
<br>
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></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>
<br>
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](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**
@ -219,7 +222,7 @@ picoclaw onboard
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "gpt4",
"model_name": "gpt4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20

View file

@ -227,7 +227,7 @@ picoclaw onboard
],
"agents": {
"defaults": {
"model": "gpt4"
"model_name": "gpt4"
}
},
"tools": {

View file

@ -207,7 +207,7 @@ picoclaw onboard
],
"agents": {
"defaults": {
"model": "gpt4"
"model_name": "gpt4"
}
},
"channels": {

View file

@ -224,7 +224,7 @@ picoclaw onboard
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "gpt4",
"model_name": "gpt4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 147 KiB

View file

@ -34,7 +34,7 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
}
if model != "" {
cfg.Agents.Defaults.Model = model
cfg.Agents.Defaults.ModelName = model
}
provider, modelID, err := providers.CreateProvider(cfg)
@ -44,7 +44,7 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
// Use the resolved model ID from provider creation
if modelID != "" {
cfg.Agents.Defaults.Model = modelID
cfg.Agents.Defaults.ModelName = modelID
}
msgBus := bus.NewMessageBus()

View file

@ -75,7 +75,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
}
// Update default model to use OpenAI
appCfg.Agents.Defaults.Model = "gpt-5.2"
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
return fmt.Errorf("could not update config: %w", err)
@ -149,7 +149,7 @@ func authLoginGoogleAntigravity() error {
}
// Update default model
appCfg.Agents.Defaults.Model = "gemini-flash"
appCfg.Agents.Defaults.ModelName = "gemini-flash"
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
fmt.Printf("Warning: could not update config: %v\n", err)
@ -223,7 +223,7 @@ func authLoginPasteToken(provider string) error {
})
}
// Update default model
appCfg.Agents.Defaults.Model = "claude-sonnet-4.6"
appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
case "openai":
appCfg.Providers.OpenAI.AuthMethod = "token"
// Update ModelList
@ -243,7 +243,7 @@ func authLoginPasteToken(provider string) error {
})
}
// Update default model
appCfg.Agents.Defaults.Model = "gpt-5.2"
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
}
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
return fmt.Errorf("could not update config: %w", err)
@ -251,7 +251,10 @@ func authLoginPasteToken(provider string) error {
}
fmt.Printf("Token saved for %s!\n", provider)
fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.Model)
if appCfg != nil {
fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.GetModelName())
}
return nil
}

View file

@ -45,7 +45,7 @@ func gatewayCmd(debug bool) error {
// Use the resolved model ID from provider creation
if modelID != "" {
cfg.Agents.Defaults.Model = modelID
cfg.Agents.Defaults.ModelName = modelID
}
msgBus := bus.NewMessageBus()

View file

@ -39,7 +39,7 @@ func statusCmd() {
}
if _, err := os.Stat(configPath); err == nil {
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model)
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
hasAnthropic := cfg.Providers.Anthropic.APIKey != ""

View file

@ -3,7 +3,7 @@
"defaults": {
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true,
"model": "gpt4",
"model_name": "gpt4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20

View file

@ -133,7 +133,7 @@ func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefau
if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" {
return strings.TrimSpace(agentCfg.Model.Primary)
}
return defaults.Model
return defaults.GetModelName()
}
// resolveAgentFallbacks resolves the fallback models for an agent.

View file

@ -628,6 +628,7 @@ func (al *AgentLoop) runLLMIteration(
assistantMsg := providers.Message{
Role: "assistant",
Content: response.Content,
ReasoningContent: response.ReasoningContent,
}
for _, tc := range normalizedToolCalls {
argumentsJSON, _ := json.Marshal(tc.Arguments)

View file

@ -81,7 +81,7 @@ func (c *cmd) Show(ctx context.Context, message telego.Message) error {
switch args {
case "model":
response = fmt.Sprintf("Current Model: %s (Provider: %s)",
c.config.Agents.Defaults.Model,
c.config.Agents.Defaults.GetModelName(),
c.config.Agents.Defaults.Provider)
case "channel":
response = "Current Channel: telegram"
@ -120,7 +120,7 @@ func (c *cmd) List(ctx context.Context, message telego.Message) error {
provider = "configured default"
}
response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml",
c.config.Agents.Defaults.Model, provider)
c.config.Agents.Defaults.GetModelName(), provider)
case "channels":
var enabled []string

View file

@ -170,7 +170,8 @@ type AgentDefaults struct {
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
@ -179,6 +180,15 @@ type AgentDefaults struct {
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
}
// GetModelName returns the effective model name for the agent defaults.
// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
func (d *AgentDefaults) GetModelName() string {
if d.ModelName != "" {
return d.ModelName
}
return d.Model
}
type ChannelsConfig struct {
WhatsApp WhatsAppConfig `json:"whatsapp"`
Telegram TelegramConfig `json:"telegram"`

View file

@ -41,7 +41,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
// Get user's configured provider and model
userProvider := strings.ToLower(cfg.Agents.Defaults.Provider)
userModel := cfg.Agents.Defaults.Model
userModel := cfg.Agents.Defaults.GetModelName()
p := cfg.Providers

View file

@ -6,6 +6,7 @@
package config
import (
"encoding/json"
"strings"
"sync"
"testing"
@ -114,6 +115,137 @@ func TestGetModelConfig_Concurrent(t *testing.T) {
}
}
func TestAgentDefaults_GetModelName_BackwardCompat(t *testing.T) {
tests := []struct {
name string
defaults AgentDefaults
wantName string
}{
{
name: "new model_name field only",
defaults: AgentDefaults{ModelName: "new-model"},
wantName: "new-model",
},
{
name: "old model field only",
defaults: AgentDefaults{Model: "legacy-model"},
wantName: "legacy-model",
},
{
name: "both fields - model_name takes precedence",
defaults: AgentDefaults{ModelName: "new-model", Model: "old-model"},
wantName: "new-model",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.defaults.GetModelName(); got != tt.wantName {
t.Errorf("GetModelName() = %q, want %q", got, tt.wantName)
}
})
}
}
func TestAgentDefaults_JSON_BackwardCompat(t *testing.T) {
tests := []struct {
name string
json string
wantName string
}{
{
name: "new model_name field",
json: `{"model_name": "gpt4"}`,
wantName: "gpt4",
},
{
name: "old model field",
json: `{"model": "gpt4"}`,
wantName: "gpt4",
},
{
name: "both fields - model_name wins",
json: `{"model_name": "new", "model": "old"}`,
wantName: "new",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var defaults AgentDefaults
if err := json.Unmarshal([]byte(tt.json), &defaults); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}
if got := defaults.GetModelName(); got != tt.wantName {
t.Errorf("GetModelName() = %q, want %q", got, tt.wantName)
}
})
}
}
func TestFullConfig_JSON_BackwardCompat(t *testing.T) {
// Test complete config with both old and new formats
oldFormat := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "gpt4",
"max_tokens": 4096
}
},
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-4o",
"api_key": "test-key"
}
]
}`
newFormat := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model_name": "gpt4",
"max_tokens": 4096
}
},
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-4o",
"api_key": "test-key"
}
]
}`
for name, jsonStr := range map[string]string{
"old format (model)": oldFormat,
"new format (model_name)": newFormat,
} {
t.Run(name, func(t *testing.T) {
cfg := &Config{}
if err := json.Unmarshal([]byte(jsonStr), cfg); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}
// Check that GetModelName returns correct value
if got := cfg.Agents.Defaults.GetModelName(); got != "gpt4" {
t.Errorf("GetModelName() = %q, want %q", got, "gpt4")
}
// Check that GetModelConfig works
modelCfg, err := cfg.GetModelConfig("gpt4")
if err != nil {
t.Fatalf("GetModelConfig error: %v", err)
}
if modelCfg.Model != "openai/gpt-4o" {
t.Errorf("Model = %q, want %q", modelCfg.Model, "openai/gpt-4o")
}
})
}
}
func TestModelConfig_Validate(t *testing.T) {
tests := []struct {
name string

View file

@ -73,7 +73,10 @@ func ConvertConfig(data map[string]any) (*config.Config, []string, error) {
if agents, ok := getMap(data, "agents"); ok {
if defaults, ok := getMap(agents, "defaults"); ok {
if v, ok := getString(defaults, "model"); ok {
// Prefer model_name, fallback to model for backward compatibility
if v, ok := getString(defaults, "model_name"); ok {
cfg.Agents.Defaults.ModelName = v
} else if v, ok := getString(defaults, "model"); ok {
cfg.Agents.Defaults.Model = v
}
if v, ok := getFloat(defaults, "max_tokens"); ok {

View file

@ -36,7 +36,7 @@ type providerSelection struct {
}
func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
model := cfg.Agents.Defaults.Model
model := cfg.Agents.Defaults.GetModelName()
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
lowerModel := strings.ToLower(model)

View file

@ -16,7 +16,7 @@ import (
// 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
model := cfg.Agents.Defaults.GetModelName()
// Ensure model_list is populated (should be done by LoadConfig, but handle edge cases)
if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {

View file

@ -149,6 +149,7 @@ func parseResponse(body []byte) (*LLMResponse, error) {
Choices []struct {
Message struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
ToolCalls []struct {
ID string `json:"id"`
Type string `json:"type"`
@ -222,6 +223,7 @@ func parseResponse(body []byte) (*LLMResponse, error) {
return &LLMResponse{
Content: choice.Message.Content,
ReasoningContent: choice.Message.ReasoningContent,
ToolCalls: toolCalls,
FinishReason: choice.FinishReason,
Usage: apiResponse.Usage,

View file

@ -101,6 +101,50 @@ func TestProviderChat_ParsesToolCalls(t *testing.T) {
}
}
func TestProviderChat_ParsesReasoningContent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{
"choices": []map[string]any{
{
"message": map[string]any{
"content": "The answer is 2",
"reasoning_content": "Let me think step by step... 1+1=2",
"tool_calls": []map[string]any{
{
"id": "call_1",
"type": "function",
"function": map[string]any{
"name": "calculator",
"arguments": "{\"expr\":\"1+1\"}",
},
},
},
},
"finish_reason": "tool_calls",
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
p := NewProvider("key", server.URL, "")
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "1+1=?"}}, nil, "kimi-k2.5", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if out.ReasoningContent != "Let me think step by step... 1+1=2" {
t.Fatalf("ReasoningContent = %q, want %q", out.ReasoningContent, "Let me think step by step... 1+1=2")
}
if out.Content != "The answer is 2" {
t.Fatalf("Content = %q, want %q", out.Content, "The answer is 2")
}
if len(out.ToolCalls) != 1 {
t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
}
}
func TestProviderChat_HTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad request", http.StatusBadRequest)

View file

@ -26,6 +26,7 @@ type FunctionCall struct {
type LLMResponse struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
FinishReason string `json:"finish_reason"`
Usage *UsageInfo `json:"usage,omitempty"`
@ -40,6 +41,7 @@ type UsageInfo struct {
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}