- openai_compat: extract reasoning_content from streaming SSE deltas, benefiting providers like DeepSeek, Mimo, and Kimi that return chain-of-thought content in streamed chunks Media handling refactor (addresses review feedback): - agent: centralize video/audio inline decisions in resolveMediaRefs with provider capability gating via VideoCapable/AudioCapable interfaces - agent: add mediaInlinePolicy + mediaInlinePolicyFromProvider() to probe provider capabilities before encoding media as data URLs - agent: add size guard via encodeMediaToDataURL (respects MaxMediaSize) - providers: add VideoCapable and AudioCapable interfaces (same pattern as ThinkingCapable/NativeSearchCapable) - openai_compat: implement SupportsVideo/SupportsAudio for Mimo, Qwen, OpenAI (host and providerName detection) - httpapi: delegate SupportsVideo/SupportsAudio through HTTPProvider - httpapi: GeminiProvider declares video + audio support - common: add video_url serialization in SerializeMessages (now reachable via the centralized pipeline); fix missing continue after audio branch - Tests for capability detection, inline policy, oversized media skip, and provider-unsupported media fallback to path tags
579 lines
16 KiB
Go
579 lines
16 KiB
Go
// PicoClaw - Ultra-lightweight personal AI agent
|
|
// License: MIT
|
|
//
|
|
// Copyright (c) 2026 PicoClaw contributors
|
|
|
|
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
)
|
|
|
|
// buildModelWithProtocol constructs a model string with protocol prefix.
|
|
// If the model already contains a "/" (indicating it has a protocol prefix), it is returned as-is.
|
|
// Otherwise, the protocol prefix is added.
|
|
func buildModelWithProtocol(protocol, model string) string {
|
|
if strings.Contains(model, "/") {
|
|
// Model already has a protocol prefix, return as-is
|
|
return model
|
|
}
|
|
return protocol + "/" + model
|
|
}
|
|
|
|
type legacyDiagnosticConfig struct {
|
|
Version int `json:"version"`
|
|
Isolation IsolationConfig `json:"isolation,omitempty"`
|
|
Agents legacyDiagnosticAgents `json:"agents,omitempty"`
|
|
Session SessionConfig `json:"session,omitempty"`
|
|
Channels map[string]any `json:"channels,omitempty"`
|
|
ChannelList ChannelsConfig `json:"channel_list,omitempty"`
|
|
ModelList []map[string]any `json:"model_list,omitempty"`
|
|
Gateway GatewayConfig `json:"gateway,omitempty"`
|
|
Hooks HooksConfig `json:"hooks,omitempty"`
|
|
Tools ToolsConfig `json:"tools,omitempty"`
|
|
Heartbeat HeartbeatConfig `json:"heartbeat,omitempty"`
|
|
Devices DevicesConfig `json:"devices,omitempty"`
|
|
Voice VoiceConfig `json:"voice,omitempty"`
|
|
Bindings json.RawMessage `json:"bindings,omitempty"`
|
|
Providers json.RawMessage `json:"providers,omitempty"`
|
|
}
|
|
|
|
type legacyDiagnosticAgents struct {
|
|
Defaults legacyDiagnosticAgentDefaults `json:"defaults,omitempty"`
|
|
List []AgentConfig `json:"list,omitempty"`
|
|
Dispatch *DispatchConfig `json:"dispatch,omitempty"`
|
|
}
|
|
|
|
type legacyDiagnosticAgentDefaults struct {
|
|
AgentDefaults
|
|
LegacyModel string `json:"model,omitempty"`
|
|
}
|
|
|
|
func validateLegacyConfigDiagnostics(data []byte) error {
|
|
var cfg legacyDiagnosticConfig
|
|
return decodeJSONWithDiagnostics(data, &cfg, "config.json")
|
|
}
|
|
|
|
func migrateLegacyAgentDefaultsModel(m map[string]any) {
|
|
agents, ok := m["agents"].(map[string]any)
|
|
if !ok {
|
|
return
|
|
}
|
|
defaults, ok := agents["defaults"].(map[string]any)
|
|
if !ok {
|
|
return
|
|
}
|
|
model, hasModel := defaults["model"]
|
|
if !hasModel {
|
|
return
|
|
}
|
|
if _, hasModelName := defaults["model_name"]; !hasModelName {
|
|
defaults["model_name"] = model
|
|
}
|
|
delete(defaults, "model")
|
|
}
|
|
|
|
// loadConfigV1 loads a version 1 config (current schema)
|
|
func loadConfig(data []byte) (*Config, error) {
|
|
cfg := DefaultConfig()
|
|
|
|
// Sanitize deprecated fields before strict unknown-field validation.
|
|
// This handles configs written by older versions or frontends that still
|
|
// use removed fields (e.g. session.dm_scope → session.dimensions).
|
|
data, _ = sanitizeDeprecatedFields(data)
|
|
|
|
// Pre-scan the JSON to check how many model_list entries the user provided.
|
|
// Go's JSON decoder reuses existing slice backing-array elements rather than
|
|
// zero-initializing them, so fields absent from the user's JSON (e.g. api_base)
|
|
// would silently inherit values from the DefaultConfig template at the same
|
|
// index position. We only reset cfg.ModelList when the user actually provides
|
|
// entries; when count is 0 we keep DefaultConfig's built-in list as fallback.
|
|
var tmp Config
|
|
if err := decodeJSONWithDiagnostics(data, &tmp, "config.json"); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(tmp.ModelList) > 0 {
|
|
cfg.ModelList = nil
|
|
}
|
|
|
|
if err := decodeJSONWithDiagnostics(data, cfg, "config.json"); err != nil {
|
|
return nil, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func mergeAPIKeys(apiKey string, apiKeys []string) []string {
|
|
seen := make(map[string]struct{})
|
|
var all []string
|
|
|
|
if k := strings.TrimSpace(apiKey); k != "" {
|
|
if _, exists := seen[k]; !exists {
|
|
seen[k] = struct{}{}
|
|
all = append(all, k)
|
|
}
|
|
}
|
|
|
|
for _, k := range apiKeys {
|
|
if trimmed := strings.TrimSpace(k); trimmed != "" {
|
|
if _, exists := seen[trimmed]; !exists {
|
|
seen[trimmed] = struct{}{}
|
|
all = append(all, trimmed)
|
|
}
|
|
}
|
|
}
|
|
|
|
return all
|
|
}
|
|
|
|
func compareInt(v any, expected int) bool {
|
|
switch val := v.(type) {
|
|
case int:
|
|
return val == expected
|
|
case float64:
|
|
return val == float64(expected)
|
|
case nil:
|
|
return expected == 0
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// migrateV0ToV1 converts a V0 (legacy, no version field) config JSON to V1 format:
|
|
// 1. Migrates legacy providers to model_list
|
|
// 2. Migrates agents.defaults.model → agents.defaults.model_name
|
|
// 3. Sets version to 1
|
|
func migrateV0ToV1(m map[string]any) error {
|
|
if !compareInt(m["version"], 0) {
|
|
return fmt.Errorf("migrateV0ToV1: expected version 0, got %v", m["version"])
|
|
}
|
|
|
|
migrateLegacyAgentDefaultsModel(m)
|
|
|
|
// Migrate legacy providers to model_list if no model_list exists
|
|
if _, hasModelList := m["model_list"]; !hasModelList {
|
|
if providers, hasProviders := m["providers"]; hasProviders {
|
|
if provMap, ok := providers.(map[string]any); ok && !isProvidersMapEmpty(provMap) {
|
|
// Extract user's provider and model from agents.defaults
|
|
userProvider := ""
|
|
userModel := ""
|
|
if agents, ok := m["agents"].(map[string]any); ok {
|
|
if defaults, ok := agents["defaults"].(map[string]any); ok {
|
|
if v, ok := defaults["provider"].(string); ok {
|
|
userProvider = v
|
|
}
|
|
// Check both model_name (new) and model (old) fields
|
|
if v, ok := defaults["model_name"].(string); ok && v != "" {
|
|
userModel = v
|
|
} else if v, ok := defaults["model"].(string); ok && v != "" {
|
|
userModel = v
|
|
}
|
|
}
|
|
}
|
|
|
|
modelListRaw := v0ProvidersMapToModelList(provMap, userProvider, userModel)
|
|
if len(modelListRaw) > 0 {
|
|
m["model_list"] = modelListRaw
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert model_list api_key → api_keys
|
|
if modelList, ok := m["model_list"].([]any); ok {
|
|
for _, model := range modelList {
|
|
if mVal, ok := model.(map[string]any); ok {
|
|
if ss := toUniqueStrings(mVal["api_key"], mVal["api_keys"]); len(ss) > 0 {
|
|
mVal["api_keys"] = ss
|
|
delete(mVal, "api_key")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
m["version"] = 1
|
|
|
|
return nil
|
|
}
|
|
|
|
func toUniqueStrings(s any, ss any) []string {
|
|
set := make(map[string]struct{})
|
|
|
|
// process s
|
|
if str, ok := s.(string); ok && str != "" {
|
|
set[str] = struct{}{}
|
|
}
|
|
|
|
// process ss as []any (JSON arrays)
|
|
if slice, ok := ss.([]any); ok {
|
|
for _, item := range slice {
|
|
if str, ok := item.(string); ok && str != "" {
|
|
set[str] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
// process ss as []string
|
|
if slice, ok := ss.([]string); ok {
|
|
for _, item := range slice {
|
|
if item != "" {
|
|
set[item] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
// map to slice
|
|
result := make([]string, 0, len(set))
|
|
for k := range set {
|
|
result = append(result, k)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// migrateV1ToV2 converts a V1 config JSON to V2 format:
|
|
// 1. Migrates legacy "mention_only" to "group_trigger.mention_only"
|
|
// 2. Infers "enabled" field for models
|
|
// 3. Sets version to 2
|
|
func migrateV1ToV2(m map[string]any) error {
|
|
if !compareInt(m["version"], 1) {
|
|
return fmt.Errorf("migrateV1ToV2: expected version 1, got %#v", m["version"])
|
|
}
|
|
|
|
// Migrate channels: move "mention_only" to "group_trigger.mention_only"
|
|
if channels, ok := m["channels"]; ok {
|
|
if chMap, ok := channels.(map[string]any); ok {
|
|
for _, ch := range chMap {
|
|
if chVal, ok := ch.(map[string]any); ok {
|
|
if mentionOnly, hasMention := chVal["mention_only"]; hasMention {
|
|
delete(chVal, "mention_only")
|
|
if gt, hasGT := chVal["group_trigger"].(map[string]any); hasGT {
|
|
gt["mention_only"] = mentionOnly
|
|
} else {
|
|
chVal["group_trigger"] = map[string]any{"mention_only": mentionOnly}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Infer "enabled" field for models matching configV1.migrateModelEnabled behavior
|
|
if modelList, ok := m["model_list"].([]any); ok {
|
|
// Convert api_key → api_keys for each model
|
|
for _, model := range modelList {
|
|
if mVal, ok := model.(map[string]any); ok {
|
|
if ss := toUniqueStrings(mVal["api_key"], mVal["api_keys"]); len(ss) > 0 {
|
|
mVal["api_keys"] = ss
|
|
delete(mVal, "api_key")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Infer enabled status
|
|
for _, model := range modelList {
|
|
if mVal, ok := model.(map[string]any); ok {
|
|
// Skip if explicitly set
|
|
if _, hasEnabled := mVal["enabled"]; hasEnabled {
|
|
continue
|
|
}
|
|
// Models with API keys are considered enabled
|
|
if apiKeys, hasAPIKeys := mVal["api_keys"]; hasAPIKeys {
|
|
// Check for []any or []string
|
|
hasKeys := false
|
|
if keys, ok := apiKeys.([]any); ok {
|
|
hasKeys = len(keys) > 0
|
|
} else if keys, ok := apiKeys.([]string); ok {
|
|
hasKeys = len(keys) > 0
|
|
}
|
|
if hasKeys {
|
|
mVal["enabled"] = true
|
|
continue
|
|
}
|
|
}
|
|
// The reserved "local-model" entry is considered enabled
|
|
if mVal["model_name"] == "local-model" {
|
|
mVal["enabled"] = true
|
|
}
|
|
logger.Infof("model: %v", mVal)
|
|
}
|
|
}
|
|
} else {
|
|
logger.Warnf("model_list is not a slice: %#v", m["model_list"])
|
|
}
|
|
|
|
m["version"] = 2
|
|
|
|
return nil
|
|
}
|
|
|
|
// migrateV2ToV3 converts a V2 config JSON to V3 format:
|
|
// 1. Renames "channels" key to "channel_list"
|
|
// 2. Converts flat-format channel entries to nested format (wrapping
|
|
// channel-specific fields in "settings")
|
|
// 3. Sets version to 3
|
|
func migrateV2ToV3(m map[string]any) error {
|
|
if !compareInt(m["version"], 2) {
|
|
return fmt.Errorf("migrateV2ToV3: expected version 2, got %v", m["version"])
|
|
}
|
|
|
|
migrateLegacyAgentDefaultsModel(m)
|
|
delete(m, "bindings")
|
|
|
|
// Rename channels → channel_list
|
|
if channels, ok := m["channels"]; ok {
|
|
delete(m, "channels")
|
|
|
|
// Convert each channel from flat to nested format
|
|
if chMap, ok := channels.(map[string]any); ok {
|
|
for k, ch := range chMap {
|
|
if chVal, ok := ch.(map[string]any); ok {
|
|
chVal["type"] = k
|
|
// If already has "settings" key, leave as-is
|
|
if _, hasSettings := chVal["settings"]; hasSettings {
|
|
continue
|
|
}
|
|
|
|
// Migrate Onebot "group_trigger_prefix" → "group_trigger.prefixes"
|
|
if gtp, hasGTP := chVal["group_trigger_prefix"]; hasGTP {
|
|
if gt, hasGT := chVal["group_trigger"].(map[string]any); hasGT {
|
|
if _, hasPrefixes := gt["prefixes"]; !hasPrefixes {
|
|
gt["prefixes"] = gtp
|
|
}
|
|
} else {
|
|
chVal["group_trigger"] = map[string]any{"prefixes": gtp}
|
|
}
|
|
delete(chVal, "group_trigger_prefix")
|
|
}
|
|
|
|
// Separate channel-specific fields into "settings"
|
|
settings := make(map[string]any)
|
|
for fieldKey, v := range chVal {
|
|
if _, exists := BaseFieldNames[fieldKey]; !exists {
|
|
settings[fieldKey] = v
|
|
delete(chVal, fieldKey)
|
|
}
|
|
}
|
|
if len(settings) > 0 {
|
|
chVal["settings"] = settings
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
m["channel_list"] = channels
|
|
}
|
|
|
|
m["version"] = CurrentVersion
|
|
|
|
return nil
|
|
}
|
|
|
|
func loadConfigMap(path string) (map[string]any, error) {
|
|
var m1, m2 map[string]any
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return m1, nil
|
|
}
|
|
return nil, fmt.Errorf("failed to read config: %w", err)
|
|
}
|
|
if err = json.Unmarshal(data, &m1); err != nil {
|
|
return nil, wrapJSONError(data, err, "config.json")
|
|
}
|
|
secPath := securityPath(path)
|
|
data, err = os.ReadFile(secPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return m1, nil
|
|
}
|
|
return nil, fmt.Errorf("failed to read security config: %w", err)
|
|
}
|
|
if err = yaml.Unmarshal(data, &m2); err != nil {
|
|
return nil, fmt.Errorf("failed to parse security config: %w", err)
|
|
}
|
|
if m2["web"] != nil || m2["skills"] != nil {
|
|
m3 := make(map[string]any)
|
|
if m2["web"] != nil {
|
|
m3["web"] = m2["web"]
|
|
delete(m2, "web")
|
|
}
|
|
if m2["skills"] != nil {
|
|
m3["skills"] = m2["skills"]
|
|
delete(m2, "skills")
|
|
if m, ok := m3["skills"].(map[string]any); ok {
|
|
if m["clawhub"] != nil {
|
|
m["registries"] = map[string]any{"clawhub": m["clawhub"]}
|
|
delete(m, "clawhub")
|
|
}
|
|
if gh, ok := m["github"].(map[string]any); ok {
|
|
registries, _ := m["registries"].(map[string]any)
|
|
if registries == nil {
|
|
registries = map[string]any{}
|
|
}
|
|
githubRegistry := map[string]any{}
|
|
for k, v := range gh {
|
|
githubRegistry[k] = v
|
|
}
|
|
if token, ok := githubRegistry["token"]; ok {
|
|
githubRegistry["auth_token"] = token
|
|
}
|
|
registries["github"] = githubRegistry
|
|
m["registries"] = registries
|
|
}
|
|
}
|
|
}
|
|
m2["tools"] = m3
|
|
}
|
|
|
|
// Handle model_list merging specially: m1 has array format, m2 has map format
|
|
if mainML, hasMainML := m1["model_list"]; hasMainML {
|
|
if secML, hasSecML := m2["model_list"]; hasSecML {
|
|
if secMap, ok := secML.(map[string]any); ok {
|
|
// JSON unmarshals arrays as []any, convert to []map[string]any
|
|
var mainArr []any
|
|
if rawArr, ok := mainML.([]any); ok {
|
|
mainArr = make([]any, 0, len(rawArr))
|
|
for _, item := range rawArr {
|
|
if mVal, ok := item.(map[string]any); ok {
|
|
mainArr = append(mainArr, mVal)
|
|
}
|
|
}
|
|
}
|
|
if len(mainArr) > 0 {
|
|
// Merge array-style with map-style in-place
|
|
err = mergeModelListsWithMap(mainArr, secMap)
|
|
if err != nil {
|
|
logger.Errorf("mergeModelListsWithMap error: %v", err)
|
|
return nil, err
|
|
}
|
|
m1["model_list"] = mainArr
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Remove model_list from m2 so mergeMap doesn't override the array with map
|
|
delete(m2, "model_list")
|
|
|
|
m := mergeMap(m1, m2)
|
|
return m, nil
|
|
}
|
|
|
|
// mergeModelListsWithMap merges array-style model_list with map-style security model_list.
|
|
// It generates indexed keys from model_name (like toNameIndex) and uses them
|
|
// to look up security entries, falling back to ModelName if the indexed key doesn't exist.
|
|
func mergeModelListsWithMap(mainML []any, secML map[string]any) error {
|
|
// Build indexed keys like toNameIndex does
|
|
indexedKeys := make(map[string]int)
|
|
countMap := make(map[string]int)
|
|
for i, m := range mainML {
|
|
if mVal, ok := m.(map[string]any); ok {
|
|
if name, hasName := mVal["model_name"]; hasName {
|
|
nameStr := name.(string)
|
|
index := countMap[nameStr]
|
|
indexedKeys[fmt.Sprintf("%s:%d", nameStr, index)] = i
|
|
if _, ok := indexedKeys[nameStr]; !ok {
|
|
indexedKeys[nameStr] = i
|
|
}
|
|
countMap[nameStr]++
|
|
} else {
|
|
return fmt.Errorf("model_name is required: %#v", mVal)
|
|
}
|
|
}
|
|
}
|
|
|
|
for k, v := range secML {
|
|
if i, ok := indexedKeys[k]; ok {
|
|
if vv, ok := v.(map[string]any); ok {
|
|
if mVal, ok := mainML[i].(map[string]any); ok {
|
|
mVal["api_keys"] = vv["api_keys"]
|
|
}
|
|
}
|
|
} else {
|
|
logger.Warnf("model_name not found in main config: %s", k)
|
|
}
|
|
delete(secML, k)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// sanitizeDeprecatedFields removes known deprecated fields from raw config
|
|
// JSON so that the strict unknown-field validator does not reject them.
|
|
// When possible it migrates deprecated values into their replacements.
|
|
//
|
|
// Known deprecated fields:
|
|
// - session.dm_scope → session.dimensions (removed in ca9652e1)
|
|
// - channels → channel_list (renamed in V2→V3 migration)
|
|
// - bindings (removed in V2→V3 migration)
|
|
// - providers (removed in V0→V1 migration, replaced by model_list)
|
|
func sanitizeDeprecatedFields(data []byte) ([]byte, error) {
|
|
var m map[string]any
|
|
if err := json.Unmarshal(data, &m); err != nil {
|
|
return data, err
|
|
}
|
|
|
|
changed := false
|
|
|
|
// session.dm_scope → session.dimensions
|
|
if session, ok := m["session"].(map[string]any); ok {
|
|
if dmScope, hasDM := session["dm_scope"]; hasDM {
|
|
if _, hasDims := session["dimensions"]; !hasDims {
|
|
if scope, ok := dmScope.(string); ok {
|
|
session["dimensions"] = dmScopeToDimensions(scope)
|
|
}
|
|
}
|
|
delete(session, "dm_scope")
|
|
changed = true
|
|
}
|
|
}
|
|
|
|
// channels → channel_list (V2 legacy)
|
|
if channels, hasChannels := m["channels"]; hasChannels {
|
|
if _, hasChannelList := m["channel_list"]; !hasChannelList {
|
|
m["channel_list"] = channels
|
|
}
|
|
delete(m, "channels")
|
|
changed = true
|
|
}
|
|
|
|
// bindings (removed in V2→V3, handled by applyLegacyBindingsMigration)
|
|
if _, hasBindings := m["bindings"]; hasBindings {
|
|
delete(m, "bindings")
|
|
changed = true
|
|
}
|
|
|
|
// providers (V0 legacy, replaced by model_list)
|
|
if _, hasProviders := m["providers"]; hasProviders {
|
|
delete(m, "providers")
|
|
changed = true
|
|
}
|
|
|
|
if !changed {
|
|
return data, nil
|
|
}
|
|
return json.Marshal(m)
|
|
}
|
|
|
|
// dmScopeToDimensions converts a legacy dm_scope value to the new
|
|
// session dimensions slice.
|
|
func dmScopeToDimensions(scope string) []string {
|
|
switch scope {
|
|
case "per-channel-peer":
|
|
return []string{"chat", "sender"}
|
|
case "per-channel":
|
|
return []string{"chat"}
|
|
case "per-peer":
|
|
return []string{"sender"}
|
|
case "global":
|
|
return []string{}
|
|
default:
|
|
return []string{"chat"}
|
|
}
|
|
}
|