Merge pull request #12 from TanLuong/fix-channel-secrets-validation-10727111259372352716

Fix channel secret fields validation error on initial setup
This commit is contained in:
Nhat Tan 2026-03-26 16:56:10 +07:00 committed by GitHub
commit f63ddcfae2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 156 additions and 86 deletions

View file

@ -1101,13 +1101,13 @@ type WebToolsConfig struct {
// the client-side web_search tool is hidden to avoid duplicate search surfaces, // the client-side web_search tool is hidden to avoid duplicate search surfaces,
// and the provider's built-in search is used instead. Falls back to client-side // and the provider's built-in search is used instead. Falls back to client-side
// search when the provider does not support native search. // search when the provider does not support native search.
PreferNative bool `json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"` PreferNative bool ` json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"`
// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h). // Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config. // For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"` Proxy string ` json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` FetchLimitBytes int64 ` json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
Format string `json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"` Format string ` json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"`
PrivateHostWhitelist FlexibleStringSlice `json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"` PrivateHostWhitelist FlexibleStringSlice ` json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"`
} }
type CronToolsConfig struct { type CronToolsConfig struct {
@ -1271,7 +1271,7 @@ type MCPConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"` ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"`
Discovery ToolDiscoveryConfig ` json:"discovery"` Discovery ToolDiscoveryConfig ` json:"discovery"`
// Servers is a map of server name to server configuration // Servers is a map of server name to server configuration
Servers map[string]MCPServerConfig `json:"servers,omitempty"` Servers map[string]MCPServerConfig ` json:"servers,omitempty"`
} }
func LoadConfig(path string) (*Config, error) { func LoadConfig(path string) (*Config, error) {

View file

@ -28,10 +28,6 @@ type (
FunctionCall = protocoltypes.FunctionCall FunctionCall = protocoltypes.FunctionCall
) )
const (
defaultRequestTimeout = common.DefaultRequestTimeout
)
// Provider implements the LLM provider interface for Google Vertex AI. // Provider implements the LLM provider interface for Google Vertex AI.
// It uses the standard Vertex AI REST API for Gemini models. // It uses the standard Vertex AI REST API for Gemini models.
type Provider struct { type Provider struct {
@ -114,7 +110,6 @@ func (p *Provider) buildURL(model string, action string) string {
return baseURL return baseURL
} }
// parseMediaData converts base64 media data into the Vertex AI inlineData format. // parseMediaData converts base64 media data into the Vertex AI inlineData format.
// It tries to detect mime type from the data URI scheme if present. // It tries to detect mime type from the data URI scheme if present.
func parseMediaData(mediaData string) map[string]any { func parseMediaData(mediaData string) map[string]any {
@ -138,7 +133,11 @@ func parseMediaData(mediaData string) map[string]any {
} }
// buildRequestBody formats the standard messages and tools into the Vertex AI (Gemini) REST payload format. // buildRequestBody formats the standard messages and tools into the Vertex AI (Gemini) REST payload format.
func (p *Provider) buildRequestBody(messages []Message, tools []ToolDefinition, options map[string]any) (map[string]any, error) { func (p *Provider) buildRequestBody(
messages []Message,
tools []ToolDefinition,
options map[string]any,
) (map[string]any, error) {
req := make(map[string]any) req := make(map[string]any)
var contents []map[string]any var contents []map[string]any
@ -291,7 +290,6 @@ func (p *Provider) buildRequestBody(messages []Message, tools []ToolDefinition,
return req, nil return req, nil
} }
func (p *Provider) Chat( func (p *Provider) Chat(
ctx context.Context, ctx context.Context,
messages []Message, messages []Message,

View file

@ -8,10 +8,10 @@ import (
"net/url" "net/url"
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
) )
func TestProvider_buildURL(t *testing.T) { func TestProvider_buildURL(t *testing.T) {
@ -59,16 +59,21 @@ func TestProvider_buildURL(t *testing.T) {
} }
} }
func TestProvider_buildRequestBody(t *testing.T) { func TestProvider_buildRequestBody(t *testing.T) {
p := NewProvider("key", "", "", "proj", "us-central1") p := NewProvider("key", "", "", "proj", "us-central1")
messages := []protocoltypes.Message{ messages := []protocoltypes.Message{
{Role: "system", Content: "You are a helpful assistant."}, {Role: "system", Content: "You are a helpful assistant."},
{Role: "user", Content: "Hello!", Media: []string{"data:image/png;base64,iVBORw0KGgo"}}, {Role: "user", Content: "Hello!", Media: []string{"data:image/png;base64,iVBORw0KGgo"}},
{Role: "assistant", ToolCalls: []protocoltypes.ToolCall{{Name: "get_weather", Arguments: map[string]any{"location": "Tokyo"}}}}, {
Role: "assistant",
ToolCalls: []protocoltypes.ToolCall{{Name: "get_weather", Arguments: map[string]any{"location": "Tokyo"}}},
},
{Role: "tool", ToolCallID: "get_weather", Content: "Sunny"}, {Role: "tool", ToolCallID: "get_weather", Content: "Sunny"},
{Role: "assistant", ToolCalls: []protocoltypes.ToolCall{{Name: "get_time", Arguments: map[string]any{"location": "Tokyo"}}}}, {
Role: "assistant",
ToolCalls: []protocoltypes.ToolCall{{Name: "get_time", Arguments: map[string]any{"location": "Tokyo"}}},
},
{Role: "tool", ToolCallID: "get_time", Content: "12:00 PM"}, {Role: "tool", ToolCallID: "get_time", Content: "12:00 PM"},
} }
@ -128,7 +133,6 @@ func TestProvider_buildRequestBody(t *testing.T) {
assert.Equal(t, "model", contents[3]["role"]) assert.Equal(t, "model", contents[3]["role"])
} }
func TestProvider_Chat(t *testing.T) { func TestProvider_Chat(t *testing.T) {
// Create a mock server // Create a mock server
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -194,7 +198,11 @@ func TestProvider_ChatStream(t *testing.T) {
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
// Write mock chunks // Write mock chunks
w.Write([]byte(`data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]}}]}` + "\n\n")) w.Write([]byte(`data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]}}]}` + "\n\n"))
w.Write([]byte(`data: {"candidates":[{"content":{"parts":[{"text":", world!"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}` + "\n\n")) w.Write(
[]byte(
`data: {"candidates":[{"content":{"parts":[{"text":", world!"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}` + "\n\n",
),
)
})) }))
defer ts.Close() defer ts.Close()

View file

@ -4,11 +4,6 @@ import (
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
) )
// defaultThreshold is used when the config threshold is zero or negative.
// At 0.35 a message needs at least one strong signal (code block, long text,
// or an attachment) before the heavy model is chosen.
const defaultThreshold = 0.35
// RoutingTier defines a single tier for model routing. // RoutingTier defines a single tier for model routing.
type RoutingTier struct { type RoutingTier struct {
Model string Model string

View file

@ -241,9 +241,15 @@ func TestRuleClassifier_ScoreDoesNotExceedOne(t *testing.T) {
// ── Router ─────────────────────────────────────────────────────────────────── // ── Router ───────────────────────────────────────────────────────────────────
func TestRouter_SelectModel_SimpleMessageUsesLight(t *testing.T) { func TestRouter_SelectModel_SimpleMessageUsesLight(t *testing.T) {
r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.35}}}) r := New(
RouterConfig{
Tiers: []RoutingTier{
{Model: "gemini-flash", Threshold: 0.0},
{Model: "claude-sonnet-4-6", Threshold: 0.35},
},
},
)
msg := "hi" msg := "hi"
model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
if !usedLight { if !usedLight {
@ -255,7 +261,14 @@ func TestRouter_SelectModel_SimpleMessageUsesLight(t *testing.T) {
} }
func TestRouter_SelectModel_CodeBlockUsesPrimary(t *testing.T) { func TestRouter_SelectModel_CodeBlockUsesPrimary(t *testing.T) {
r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.35}}}) r := New(
RouterConfig{
Tiers: []RoutingTier{
{Model: "gemini-flash", Threshold: 0.0},
{Model: "claude-sonnet-4-6", Threshold: 0.35},
},
},
)
msg := "```go\nfmt.Println(\"hello\")\n```" msg := "```go\nfmt.Println(\"hello\")\n```"
model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
if usedLight { if usedLight {
@ -267,7 +280,14 @@ func TestRouter_SelectModel_CodeBlockUsesPrimary(t *testing.T) {
} }
func TestRouter_SelectModel_AttachmentUsesPrimary(t *testing.T) { func TestRouter_SelectModel_AttachmentUsesPrimary(t *testing.T) {
r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.35}}}) r := New(
RouterConfig{
Tiers: []RoutingTier{
{Model: "gemini-flash", Threshold: 0.0},
{Model: "claude-sonnet-4-6", Threshold: 0.35},
},
},
)
msg := "can you analyze this? data:image/png;base64,abc123" msg := "can you analyze this? data:image/png;base64,abc123"
model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
if usedLight { if usedLight {
@ -279,7 +299,14 @@ func TestRouter_SelectModel_AttachmentUsesPrimary(t *testing.T) {
} }
func TestRouter_SelectModel_LongMessageUsesPrimary(t *testing.T) { func TestRouter_SelectModel_LongMessageUsesPrimary(t *testing.T) {
r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.35}}}) r := New(
RouterConfig{
Tiers: []RoutingTier{
{Model: "gemini-flash", Threshold: 0.0},
{Model: "claude-sonnet-4-6", Threshold: 0.35},
},
},
)
// >200 token estimate: 210 * 3 = 630 chars // >200 token estimate: 210 * 3 = 630 chars
msg := strings.Repeat("word ", 210) msg := strings.Repeat("word ", 210)
model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
@ -294,7 +321,14 @@ func TestRouter_SelectModel_LongMessageUsesPrimary(t *testing.T) {
func TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) { func TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) {
// Tool calls alone (0.25) don't cross the 0.35 threshold — acceptable behavior. // Tool calls alone (0.25) don't cross the 0.35 threshold — acceptable behavior.
// Routing is conservative: only promote to heavy when the signal is unambiguous. // Routing is conservative: only promote to heavy when the signal is unambiguous.
r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.35}}}) r := New(
RouterConfig{
Tiers: []RoutingTier{
{Model: "gemini-flash", Threshold: 0.0},
{Model: "claude-sonnet-4-6", Threshold: 0.35},
},
},
)
history := []providers.Message{ history := []providers.Message{
{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}}, {Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}},
{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "exec"}, {Name: "search"}}}, {Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "exec"}, {Name: "search"}}},
@ -308,7 +342,14 @@ func TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) {
func TestRouter_SelectModel_ToolChainPlusMediumUsesHeavy(t *testing.T) { func TestRouter_SelectModel_ToolChainPlusMediumUsesHeavy(t *testing.T) {
// Tool calls (0.25) + medium message (0.15) = 0.40 >= 0.35 → heavy // Tool calls (0.25) + medium message (0.15) = 0.40 >= 0.35 → heavy
r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.35}}}) r := New(
RouterConfig{
Tiers: []RoutingTier{
{Model: "gemini-flash", Threshold: 0.0},
{Model: "claude-sonnet-4-6", Threshold: 0.35},
},
},
)
history := []providers.Message{ history := []providers.Message{
{Role: "assistant", ToolCalls: []providers.ToolCall{ {Role: "assistant", ToolCalls: []providers.ToolCall{
{Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"}, {Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"},
@ -324,7 +365,14 @@ func TestRouter_SelectModel_ToolChainPlusMediumUsesHeavy(t *testing.T) {
func TestRouter_SelectModel_CustomThreshold(t *testing.T) { func TestRouter_SelectModel_CustomThreshold(t *testing.T) {
// Very low threshold: even a short message triggers heavy model // Very low threshold: even a short message triggers heavy model
r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.05}}}) r := New(
RouterConfig{
Tiers: []RoutingTier{
{Model: "gemini-flash", Threshold: 0.0},
{Model: "claude-sonnet-4-6", Threshold: 0.05},
},
},
)
msg := strings.Repeat("word ", 55) // medium message → 0.15 >= 0.05 msg := strings.Repeat("word ", 55) // medium message → 0.15 >= 0.05
_, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") _, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
if usedLight { if usedLight {
@ -334,7 +382,14 @@ func TestRouter_SelectModel_CustomThreshold(t *testing.T) {
func TestRouter_SelectModel_HighThreshold(t *testing.T) { func TestRouter_SelectModel_HighThreshold(t *testing.T) {
// Very high threshold: even code blocks route to light // Very high threshold: even code blocks route to light
r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.99}}}) r := New(
RouterConfig{
Tiers: []RoutingTier{
{Model: "gemini-flash", Threshold: 0.0},
{Model: "claude-sonnet-4-6", Threshold: 0.99},
},
},
)
msg := "```go\nfmt.Println()\n```" msg := "```go\nfmt.Println()\n```"
_, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") _, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
if !usedLight { if !usedLight {
@ -343,7 +398,11 @@ func TestRouter_SelectModel_HighThreshold(t *testing.T) {
} }
func TestRouter_Tiers(t *testing.T) { func TestRouter_Tiers(t *testing.T) {
r := New(RouterConfig{Tiers: []RoutingTier{{Model: "my-fast-model", Threshold: 0.0}, {Model: "heavy-model", Threshold: 0.35}}}) r := New(
RouterConfig{
Tiers: []RoutingTier{{Model: "my-fast-model", Threshold: 0.0}, {Model: "heavy-model", Threshold: 0.35}},
},
)
if r.Tiers()[0].Model != "my-fast-model" { if r.Tiers()[0].Model != "my-fast-model" {
t.Errorf("LightModel: got %q, want %q", "my-fast-model", "my-fast-model") t.Errorf("LightModel: got %q, want %q", "my-fast-model", "my-fast-model")
} }

View file

@ -92,11 +92,19 @@ func (t *UpdateSkillTool) Execute(ctx context.Context, args map[string]any) *Too
return ErrorResult(fmt.Sprintf("failed to append to SKILL.md: %v", err)) return ErrorResult(fmt.Sprintf("failed to append to SKILL.md: %v", err))
} }
output := fmt.Sprintf("Successfully learned and updated SKILL.md.\n\nAnalysis: %s\nSkills Improved: %s\n", analysis, skillsToImprove) output := fmt.Sprintf(
"Successfully learned and updated SKILL.md.\n\nAnalysis: %s\nSkills Improved: %s\n",
analysis,
skillsToImprove,
)
// The response is passed back to the LLM. // The response is passed back to the LLM.
// We also populate the ForUser field to notify the user. // We also populate the ForUser field to notify the user.
res := SilentResult(output) res := SilentResult(output)
res.ForUser = fmt.Sprintf("I have analyzed our conversation and improved my skills.\n\n**My Analysis**:\n%s\n\n**Skills I've Improved/Added**:\n%s\n\nI have saved these learnings to `SKILL.md`.", analysis, skillsToImprove) res.ForUser = fmt.Sprintf(
"I have analyzed our conversation and improved my skills.\n\n**My Analysis**:\n%s\n\n**Skills I've Improved/Added**:\n%s\n\nI have saved these learnings to `SKILL.md`.",
analysis,
skillsToImprove,
)
return res return res
} }

View file

@ -95,14 +95,16 @@ function buildSavePayload(
if (key.startsWith("_")) continue if (key.startsWith("_")) continue
if (key === "enabled") continue if (key === "enabled") continue
if (key in SECRET_FIELD_MAP) { payload[key] = value
const editKey = SECRET_FIELD_MAP[key]
const incoming = asString(editConfig[editKey])
payload[key] = incoming !== "" ? incoming : value
continue
} }
payload[key] = value for (const [key, editKey] of Object.entries(SECRET_FIELD_MAP)) {
if (editKey in editConfig) {
const incoming = asString(editConfig[editKey])
if (incoming !== "") {
payload[key] = incoming
}
}
} }
if (channel.name === "whatsapp_native") { if (channel.name === "whatsapp_native") {