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,
// and the provider's built-in search is used instead. Falls back to client-side
// 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).
// 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"`
FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
Format string `json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"`
PrivateHostWhitelist FlexibleStringSlice `json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"`
Proxy string ` json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
FetchLimitBytes int64 ` json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
Format string ` json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"`
PrivateHostWhitelist FlexibleStringSlice ` json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"`
}
type CronToolsConfig struct {
@ -1271,7 +1271,7 @@ type MCPConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"`
Discovery ToolDiscoveryConfig ` json:"discovery"`
// 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) {

View file

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

View file

@ -8,10 +8,10 @@ import (
"net/url"
"testing"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
func TestProvider_buildURL(t *testing.T) {
@ -59,16 +59,21 @@ func TestProvider_buildURL(t *testing.T) {
}
}
func TestProvider_buildRequestBody(t *testing.T) {
p := NewProvider("key", "", "", "proj", "us-central1")
messages := []protocoltypes.Message{
{Role: "system", Content: "You are a helpful assistant."},
{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: "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"},
}
@ -128,7 +133,6 @@ func TestProvider_buildRequestBody(t *testing.T) {
assert.Equal(t, "model", contents[3]["role"])
}
func TestProvider_Chat(t *testing.T) {
// Create a mock server
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")
// Write mock chunks
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()

View file

@ -4,11 +4,6 @@ import (
"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.
type RoutingTier struct {
Model string

View file

@ -241,9 +241,15 @@ func TestRuleClassifier_ScoreDoesNotExceedOne(t *testing.T) {
// ── Router ───────────────────────────────────────────────────────────────────
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"
model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
if !usedLight {
@ -255,7 +261,14 @@ func TestRouter_SelectModel_SimpleMessageUsesLight(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```"
model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
if usedLight {
@ -267,7 +280,14 @@ func TestRouter_SelectModel_CodeBlockUsesPrimary(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"
model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
if usedLight {
@ -279,7 +299,14 @@ func TestRouter_SelectModel_AttachmentUsesPrimary(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
msg := strings.Repeat("word ", 210)
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) {
// 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.
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{
{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}},
{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) {
// 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{
{Role: "assistant", ToolCalls: []providers.ToolCall{
{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) {
// 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
_, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
if usedLight {
@ -334,7 +382,14 @@ func TestRouter_SelectModel_CustomThreshold(t *testing.T) {
func TestRouter_SelectModel_HighThreshold(t *testing.T) {
// 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```"
_, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
if !usedLight {
@ -343,7 +398,11 @@ func TestRouter_SelectModel_HighThreshold(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" {
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))
}
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.
// We also populate the ForUser field to notify the user.
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
}

View file

@ -95,14 +95,16 @@ function buildSavePayload(
if (key.startsWith("_")) continue
if (key === "enabled") continue
if (key in SECRET_FIELD_MAP) {
const editKey = SECRET_FIELD_MAP[key]
const incoming = asString(editConfig[editKey])
payload[key] = incoming !== "" ? incoming : value
continue
payload[key] = value
}
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") {