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:
commit
f63ddcfae2
10 changed files with 156 additions and 86 deletions
|
|
@ -2762,8 +2762,8 @@ func (al *AgentLoop) selectCandidates(
|
||||||
if targetModelName == agent.Model {
|
if targetModelName == agent.Model {
|
||||||
logger.DebugCF("agent", "Model routing: primary model selected",
|
logger.DebugCF("agent", "Model routing: primary model selected",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
"score": score,
|
"score": score,
|
||||||
})
|
})
|
||||||
return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model)
|
return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -870,9 +870,9 @@ type ModelConfig struct {
|
||||||
Region string `json:"region,omitempty"` // Region (e.g. for Google Vertex AI)
|
Region string `json:"region,omitempty"` // Region (e.g. for Google Vertex AI)
|
||||||
|
|
||||||
// Optional optimizations
|
// Optional optimizations
|
||||||
RPM int `json:"rpm,omitempty"` // Requests per minute limit
|
RPM int `json:"rpm,omitempty"` // Requests per minute limit
|
||||||
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
|
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
|
||||||
RequestTimeout int `json:"request_timeout,omitempty"`
|
RequestTimeout int `json:"request_timeout,omitempty"`
|
||||||
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
|
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
|
||||||
ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body
|
ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body
|
||||||
ExtraHeaders map[string]string `json:"extra_headers,omitempty"` // Additional headers to inject into request
|
ExtraHeaders map[string]string `json:"extra_headers,omitempty"` // Additional headers to inject into request
|
||||||
|
|
@ -1048,7 +1048,7 @@ type SearXNGConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type GLMSearchConfig struct {
|
type GLMSearchConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"`
|
||||||
apiKey string
|
apiKey string
|
||||||
secDirty bool
|
secDirty bool
|
||||||
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"`
|
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"`
|
||||||
|
|
@ -1088,7 +1088,7 @@ func (c *BaiduSearchConfig) SetAPIKey(key string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebToolsConfig struct {
|
type WebToolsConfig struct {
|
||||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
|
||||||
Brave BraveConfig ` json:"brave"`
|
Brave BraveConfig ` json:"brave"`
|
||||||
Tavily TavilyConfig ` json:"tavily"`
|
Tavily TavilyConfig ` json:"tavily"`
|
||||||
DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"`
|
DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"`
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -1145,8 +1145,8 @@ type ReadFileToolConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolsConfig struct {
|
type ToolsConfig struct {
|
||||||
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
|
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
|
||||||
AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
|
AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
|
||||||
// FilterSensitiveData controls whether to filter sensitive values (API keys,
|
// FilterSensitiveData controls whether to filter sensitive values (API keys,
|
||||||
// tokens, secrets) from tool results before sending to the LLM.
|
// tokens, secrets) from tool results before sending to the LLM.
|
||||||
// Default: true (enabled)
|
// Default: true (enabled)
|
||||||
|
|
@ -1154,29 +1154,29 @@ type ToolsConfig struct {
|
||||||
// FilterMinLength is the minimum content length required for filtering.
|
// FilterMinLength is the minimum content length required for filtering.
|
||||||
// Content shorter than this will be returned unchanged for performance.
|
// Content shorter than this will be returned unchanged for performance.
|
||||||
// Default: 8
|
// Default: 8
|
||||||
FilterMinLength int `json:"filter_min_length" env:"PICOCLAW_TOOLS_FILTER_MIN_LENGTH"`
|
FilterMinLength int `json:"filter_min_length" env:"PICOCLAW_TOOLS_FILTER_MIN_LENGTH"`
|
||||||
Web WebToolsConfig `json:"web"`
|
Web WebToolsConfig `json:"web"`
|
||||||
Cron CronToolsConfig `json:"cron"`
|
Cron CronToolsConfig `json:"cron"`
|
||||||
Exec ExecConfig `json:"exec"`
|
Exec ExecConfig `json:"exec"`
|
||||||
Skills SkillsToolsConfig `json:"skills"`
|
Skills SkillsToolsConfig `json:"skills"`
|
||||||
MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
|
MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
|
||||||
MCP MCPConfig `json:"mcp"`
|
MCP MCPConfig `json:"mcp"`
|
||||||
AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"`
|
AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"`
|
||||||
EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
|
EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
|
||||||
FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
|
FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
|
||||||
I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"`
|
I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"`
|
||||||
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
|
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
|
||||||
UpdateSkill ToolConfig `json:"update_skill" envPrefix:"PICOCLAW_TOOLS_UPDATE_SKILL_"`
|
UpdateSkill ToolConfig `json:"update_skill" envPrefix:"PICOCLAW_TOOLS_UPDATE_SKILL_"`
|
||||||
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
|
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
|
||||||
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
|
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
|
||||||
ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
|
ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
|
||||||
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
|
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
|
||||||
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
|
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
|
||||||
SpawnStatus ToolConfig `json:"spawn_status" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"`
|
SpawnStatus ToolConfig `json:"spawn_status" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"`
|
||||||
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
|
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
|
||||||
Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
|
Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
|
||||||
WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
|
WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
|
||||||
WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
|
WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsFilterSensitiveDataEnabled returns true if sensitive data filtering is enabled
|
// IsFilterSensitiveDataEnabled returns true if sensitive data filtering is enabled
|
||||||
|
|
@ -1268,10 +1268,10 @@ type MCPServerConfig struct {
|
||||||
|
|
||||||
// MCPConfig defines configuration for all MCP servers
|
// MCPConfig defines configuration for all MCP servers
|
||||||
type MCPConfig struct {
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -234,7 +233,7 @@ func (p *Provider) buildRequestBody(messages []Message, tools []ToolDefinition,
|
||||||
|
|
||||||
parts = append(parts, map[string]any{
|
parts = append(parts, map[string]any{
|
||||||
"functionResponse": map[string]any{
|
"functionResponse": map[string]any{
|
||||||
"name": msg.ToolCallID,
|
"name": msg.ToolCallID,
|
||||||
"response": responseObj,
|
"response": responseObj,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
@ -37,16 +37,16 @@ func TestProvider_buildURL(t *testing.T) {
|
||||||
expected: "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-1.5-flash:generateContent",
|
expected: "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-1.5-flash:generateContent",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Override with base URL without method",
|
name: "Override with base URL without method",
|
||||||
apiBase: "http://localhost:8080/v1/models",
|
apiBase: "http://localhost:8080/v1/models",
|
||||||
model: "gemini-1.0-pro",
|
model: "gemini-1.0-pro",
|
||||||
expected: "http://localhost:8080/v1/models/gemini-1.0-pro:generateContent?key=key",
|
expected: "http://localhost:8080/v1/models/gemini-1.0-pro:generateContent?key=key",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Override with full endpoint URL",
|
name: "Override with full endpoint URL",
|
||||||
apiBase: "https://my-custom-proxy.com/my-endpoint:generateContent",
|
apiBase: "https://my-custom-proxy.com/my-endpoint:generateContent",
|
||||||
model: "gemini-1.5-pro",
|
model: "gemini-1.5-pro",
|
||||||
expected: "https://my-custom-proxy.com/my-endpoint:generateContent?key=key",
|
expected: "https://my-custom-proxy.com/my-endpoint:generateContent?key=key",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,5 +65,5 @@ func TestUpdateSkillTool_Execute(t *testing.T) {
|
||||||
|
|
||||||
contentStr2 := string(content2)
|
contentStr2 := string(content2)
|
||||||
assert.Contains(t, contentStr2, "I was slow to find the file.") // Old content still there
|
assert.Contains(t, contentStr2, "I was slow to find the file.") // Old content still there
|
||||||
assert.Contains(t, contentStr2, "Another analysis.") // New content added
|
assert.Contains(t, contentStr2, "Another analysis.") // New content added
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,11 @@ type modelResponse struct {
|
||||||
Proxy string `json:"proxy,omitempty"`
|
Proxy string `json:"proxy,omitempty"`
|
||||||
AuthMethod string `json:"auth_method,omitempty"`
|
AuthMethod string `json:"auth_method,omitempty"`
|
||||||
// Advanced fields
|
// Advanced fields
|
||||||
ConnectMode string `json:"connect_mode,omitempty"`
|
ConnectMode string `json:"connect_mode,omitempty"`
|
||||||
Workspace string `json:"workspace,omitempty"`
|
Workspace string `json:"workspace,omitempty"`
|
||||||
RPM int `json:"rpm,omitempty"`
|
RPM int `json:"rpm,omitempty"`
|
||||||
MaxTokensField string `json:"max_tokens_field,omitempty"`
|
MaxTokensField string `json:"max_tokens_field,omitempty"`
|
||||||
RequestTimeout int `json:"request_timeout,omitempty"`
|
RequestTimeout int `json:"request_timeout,omitempty"`
|
||||||
ThinkingLevel string `json:"thinking_level,omitempty"`
|
ThinkingLevel string `json:"thinking_level,omitempty"`
|
||||||
ExtraBody map[string]any `json:"extra_body,omitempty"`
|
ExtraBody map[string]any `json:"extra_body,omitempty"`
|
||||||
ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
|
ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -95,16 +95,18 @@ function buildSavePayload(
|
||||||
if (key.startsWith("_")) continue
|
if (key.startsWith("_")) continue
|
||||||
if (key === "enabled") 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") {
|
if (channel.name === "whatsapp_native") {
|
||||||
payload.use_native = true
|
payload.use_native = true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue