refactor: seperate security.yml for store keys
This commit is contained in:
parent
94fcb25039
commit
e455eb5e67
68 changed files with 5313 additions and 1185 deletions
|
|
@ -430,7 +430,7 @@ func (s *appState) isActiveModelValid() bool {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
hasKey := strings.TrimSpace(cfg.APIKey) != "" || strings.TrimSpace(cfg.AuthMethod) == "oauth"
|
hasKey := strings.TrimSpace(cfg.APIKey()) != "" || strings.TrimSpace(cfg.AuthMethod) == "oauth"
|
||||||
hasModel := strings.TrimSpace(cfg.Model) != ""
|
hasModel := strings.TrimSpace(cfg.Model) != ""
|
||||||
return hasKey && hasModel
|
return hasKey && hasModel
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -112,8 +112,8 @@ func refreshChannelMenuFromState(menu *Menu, s *appState) {
|
||||||
func (s *appState) telegramForm() tview.Primitive {
|
func (s *appState) telegramForm() tview.Primitive {
|
||||||
cfg := &s.config.Channels.Telegram
|
cfg := &s.config.Channels.Telegram
|
||||||
form := baseChannelForm("Telegram", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
form := baseChannelForm("Telegram", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
form.AddInputField("Token", cfg.Token(), 128, nil, func(text string) {
|
||||||
cfg.Token = strings.TrimSpace(text)
|
cfg.SetToken(strings.TrimSpace(text))
|
||||||
})
|
})
|
||||||
form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) {
|
form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) {
|
||||||
cfg.Proxy = strings.TrimSpace(text)
|
cfg.Proxy = strings.TrimSpace(text)
|
||||||
|
|
@ -125,8 +125,8 @@ func (s *appState) telegramForm() tview.Primitive {
|
||||||
func (s *appState) discordForm() tview.Primitive {
|
func (s *appState) discordForm() tview.Primitive {
|
||||||
cfg := &s.config.Channels.Discord
|
cfg := &s.config.Channels.Discord
|
||||||
form := baseChannelForm("Discord", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
form := baseChannelForm("Discord", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
form.AddInputField("Token", cfg.Token(), 128, nil, func(text string) {
|
||||||
cfg.Token = strings.TrimSpace(text)
|
cfg.SetToken(strings.TrimSpace(text))
|
||||||
})
|
})
|
||||||
form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) {
|
form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) {
|
||||||
cfg.MentionOnly = checked
|
cfg.MentionOnly = checked
|
||||||
|
|
@ -141,8 +141,8 @@ func (s *appState) qqForm() tview.Primitive {
|
||||||
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
|
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
|
||||||
cfg.AppID = strings.TrimSpace(text)
|
cfg.AppID = strings.TrimSpace(text)
|
||||||
})
|
})
|
||||||
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
|
form.AddInputField("App Secret", cfg.AppSecret(), 128, nil, func(text string) {
|
||||||
cfg.AppSecret = strings.TrimSpace(text)
|
cfg.SetAppSecret(strings.TrimSpace(text))
|
||||||
})
|
})
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
return wrapWithBack(form, s)
|
return wrapWithBack(form, s)
|
||||||
|
|
@ -175,14 +175,14 @@ func (s *appState) feishuForm() tview.Primitive {
|
||||||
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
|
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
|
||||||
cfg.AppID = strings.TrimSpace(text)
|
cfg.AppID = strings.TrimSpace(text)
|
||||||
})
|
})
|
||||||
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
|
form.AddInputField("App Secret", cfg.AppSecret(), 128, nil, func(text string) {
|
||||||
cfg.AppSecret = strings.TrimSpace(text)
|
cfg.SetAppSecret(strings.TrimSpace(text))
|
||||||
})
|
})
|
||||||
form.AddInputField("Encrypt Key", cfg.EncryptKey, 128, nil, func(text string) {
|
form.AddInputField("Encrypt Key", cfg.EncryptKey(), 128, nil, func(text string) {
|
||||||
cfg.EncryptKey = strings.TrimSpace(text)
|
cfg.SetEncryptKey(strings.TrimSpace(text))
|
||||||
})
|
})
|
||||||
form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) {
|
form.AddInputField("Verification Token", cfg.VerificationToken(), 128, nil, func(text string) {
|
||||||
cfg.VerificationToken = strings.TrimSpace(text)
|
cfg.SetVerificationToken(strings.TrimSpace(text))
|
||||||
})
|
})
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
return wrapWithBack(form, s)
|
return wrapWithBack(form, s)
|
||||||
|
|
@ -194,8 +194,8 @@ func (s *appState) dingtalkForm() tview.Primitive {
|
||||||
form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) {
|
form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) {
|
||||||
cfg.ClientID = strings.TrimSpace(text)
|
cfg.ClientID = strings.TrimSpace(text)
|
||||||
})
|
})
|
||||||
form.AddInputField("Client Secret", cfg.ClientSecret, 128, nil, func(text string) {
|
form.AddInputField("Client Secret", cfg.ClientSecret(), 128, nil, func(text string) {
|
||||||
cfg.ClientSecret = strings.TrimSpace(text)
|
cfg.SetClientSecret(strings.TrimSpace(text))
|
||||||
})
|
})
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
return wrapWithBack(form, s)
|
return wrapWithBack(form, s)
|
||||||
|
|
@ -204,11 +204,11 @@ func (s *appState) dingtalkForm() tview.Primitive {
|
||||||
func (s *appState) slackForm() tview.Primitive {
|
func (s *appState) slackForm() tview.Primitive {
|
||||||
cfg := &s.config.Channels.Slack
|
cfg := &s.config.Channels.Slack
|
||||||
form := baseChannelForm("Slack", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
form := baseChannelForm("Slack", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
form.AddInputField("Bot Token", cfg.BotToken, 128, nil, func(text string) {
|
form.AddInputField("Bot Token", cfg.BotToken(), 128, nil, func(text string) {
|
||||||
cfg.BotToken = strings.TrimSpace(text)
|
cfg.SetBotToken(strings.TrimSpace(text))
|
||||||
})
|
})
|
||||||
form.AddInputField("App Token", cfg.AppToken, 128, nil, func(text string) {
|
form.AddInputField("App Token", cfg.AppToken(), 128, nil, func(text string) {
|
||||||
cfg.AppToken = strings.TrimSpace(text)
|
cfg.SetAppToken(strings.TrimSpace(text))
|
||||||
})
|
})
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
return wrapWithBack(form, s)
|
return wrapWithBack(form, s)
|
||||||
|
|
@ -217,11 +217,11 @@ func (s *appState) slackForm() tview.Primitive {
|
||||||
func (s *appState) lineForm() tview.Primitive {
|
func (s *appState) lineForm() tview.Primitive {
|
||||||
cfg := &s.config.Channels.LINE
|
cfg := &s.config.Channels.LINE
|
||||||
form := baseChannelForm("LINE", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
form := baseChannelForm("LINE", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
form.AddInputField("Channel Secret", cfg.ChannelSecret, 128, nil, func(text string) {
|
form.AddInputField("Channel Secret", cfg.ChannelSecret(), 128, nil, func(text string) {
|
||||||
cfg.ChannelSecret = strings.TrimSpace(text)
|
cfg.SetChannelSecret(strings.TrimSpace(text))
|
||||||
})
|
})
|
||||||
form.AddInputField("Channel Access Token", cfg.ChannelAccessToken, 128, nil, func(text string) {
|
form.AddInputField("Channel Access Token", cfg.ChannelAccessToken(), 128, nil, func(text string) {
|
||||||
cfg.ChannelAccessToken = strings.TrimSpace(text)
|
cfg.SetChannelAccessToken(strings.TrimSpace(text))
|
||||||
})
|
})
|
||||||
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
||||||
cfg.WebhookHost = strings.TrimSpace(text)
|
cfg.WebhookHost = strings.TrimSpace(text)
|
||||||
|
|
@ -243,8 +243,8 @@ func (s *appState) matrixForm() tview.Primitive {
|
||||||
form.AddInputField("User ID", cfg.UserID, 128, nil, func(text string) {
|
form.AddInputField("User ID", cfg.UserID, 128, nil, func(text string) {
|
||||||
cfg.UserID = strings.TrimSpace(text)
|
cfg.UserID = strings.TrimSpace(text)
|
||||||
})
|
})
|
||||||
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
|
form.AddInputField("Access Token", cfg.AccessToken(), 128, nil, func(text string) {
|
||||||
cfg.AccessToken = strings.TrimSpace(text)
|
cfg.SetAccessToken(strings.TrimSpace(text))
|
||||||
})
|
})
|
||||||
form.AddInputField("Device ID", cfg.DeviceID, 128, nil, func(text string) {
|
form.AddInputField("Device ID", cfg.DeviceID, 128, nil, func(text string) {
|
||||||
cfg.DeviceID = strings.TrimSpace(text)
|
cfg.DeviceID = strings.TrimSpace(text)
|
||||||
|
|
@ -262,8 +262,8 @@ func (s *appState) onebotForm() tview.Primitive {
|
||||||
form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) {
|
form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) {
|
||||||
cfg.WSUrl = strings.TrimSpace(text)
|
cfg.WSUrl = strings.TrimSpace(text)
|
||||||
})
|
})
|
||||||
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
|
form.AddInputField("Access Token", cfg.AccessToken(), 128, nil, func(text string) {
|
||||||
cfg.AccessToken = strings.TrimSpace(text)
|
cfg.SetAccessToken(strings.TrimSpace(text))
|
||||||
})
|
})
|
||||||
addIntField(
|
addIntField(
|
||||||
form,
|
form,
|
||||||
|
|
@ -287,11 +287,11 @@ func (s *appState) onebotForm() tview.Primitive {
|
||||||
func (s *appState) wecomForm() tview.Primitive {
|
func (s *appState) wecomForm() tview.Primitive {
|
||||||
cfg := &s.config.Channels.WeCom
|
cfg := &s.config.Channels.WeCom
|
||||||
form := baseChannelForm("WeCom", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
form := baseChannelForm("WeCom", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
form.AddInputField("Token", cfg.Token(), 128, nil, func(text string) {
|
||||||
cfg.Token = strings.TrimSpace(text)
|
cfg.SetToken(strings.TrimSpace(text))
|
||||||
})
|
})
|
||||||
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
|
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey(), 128, nil, func(text string) {
|
||||||
cfg.EncodingAESKey = strings.TrimSpace(text)
|
cfg.SetEncodingAESKey(strings.TrimSpace(text))
|
||||||
})
|
})
|
||||||
form.AddInputField("Webhook URL", cfg.WebhookURL, 128, nil, func(text string) {
|
form.AddInputField("Webhook URL", cfg.WebhookURL, 128, nil, func(text string) {
|
||||||
cfg.WebhookURL = strings.TrimSpace(text)
|
cfg.WebhookURL = strings.TrimSpace(text)
|
||||||
|
|
@ -319,15 +319,15 @@ func (s *appState) wecomAppForm() tview.Primitive {
|
||||||
form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) {
|
form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) {
|
||||||
cfg.CorpID = strings.TrimSpace(text)
|
cfg.CorpID = strings.TrimSpace(text)
|
||||||
})
|
})
|
||||||
form.AddInputField("Corp Secret", cfg.CorpSecret, 128, nil, func(text string) {
|
form.AddInputField("Corp Secret", cfg.CorpSecret(), 128, nil, func(text string) {
|
||||||
cfg.CorpSecret = strings.TrimSpace(text)
|
cfg.SetCorpSecret(strings.TrimSpace(text))
|
||||||
})
|
})
|
||||||
addInt64Field(form, "Agent ID", cfg.AgentID, func(value int64) { cfg.AgentID = value })
|
addInt64Field(form, "Agent ID", cfg.AgentID, func(value int64) { cfg.AgentID = value })
|
||||||
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
form.AddInputField("Token", cfg.Token(), 128, nil, func(text string) {
|
||||||
cfg.Token = strings.TrimSpace(text)
|
cfg.SetToken(strings.TrimSpace(text))
|
||||||
})
|
})
|
||||||
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
|
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey(), 128, nil, func(text string) {
|
||||||
cfg.EncodingAESKey = strings.TrimSpace(text)
|
cfg.SetEncodingAESKey(strings.TrimSpace(text))
|
||||||
})
|
})
|
||||||
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
||||||
cfg.WebhookHost = strings.TrimSpace(text)
|
cfg.WebhookHost = strings.TrimSpace(text)
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ func (s *appState) modelMenu() tview.Primitive {
|
||||||
Action: func() {
|
Action: func() {
|
||||||
newName := s.nextAvailableModelName("new-model")
|
newName := s.nextAvailableModelName("new-model")
|
||||||
s.addModel(
|
s.addModel(
|
||||||
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
|
&picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
|
||||||
)
|
)
|
||||||
s.push(
|
s.push(
|
||||||
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||||
|
|
@ -90,7 +90,7 @@ func (s *appState) modelMenu() tview.Primitive {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *appState) modelForm(index int) tview.Primitive {
|
func (s *appState) modelForm(index int) tview.Primitive {
|
||||||
model := &s.config.ModelList[index]
|
model := s.config.ModelList[index]
|
||||||
form := tview.NewForm()
|
form := tview.NewForm()
|
||||||
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
||||||
|
|
||||||
|
|
@ -131,8 +131,8 @@ func (s *appState) modelForm(index int) tview.Primitive {
|
||||||
refreshModelMenuFromState(menu, s)
|
refreshModelMenuFromState(menu, s)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
addInput(form, "API Key", model.APIKey, func(value string) {
|
addInput(form, "API Key", model.APIKey(), func(value string) {
|
||||||
model.APIKey = value
|
model.SetAPIKey(value)
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
refreshMainMenuIfPresent(s)
|
refreshMainMenuIfPresent(s)
|
||||||
if menu, ok := s.menus["model"]; ok {
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
|
@ -215,7 +215,7 @@ func addIntInput(form *tview.Form, label string, value int, onChange func(int))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *appState) addModel(model picoclawconfig.ModelConfig) {
|
func (s *appState) addModel(model *picoclawconfig.ModelConfig) {
|
||||||
s.config.ModelList = append(s.config.ModelList, model)
|
s.config.ModelList = append(s.config.ModelList, model)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,7 +236,7 @@ func modelStatusColor(valid bool, selected bool) *tcell.Color {
|
||||||
return &color
|
return &color
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
|
func refreshModelMenu(menu *Menu, currentModel string, models []*picoclawconfig.ModelConfig) {
|
||||||
for i, model := range models {
|
for i, model := range models {
|
||||||
row := i
|
row := i
|
||||||
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||||
|
|
@ -291,7 +291,7 @@ func refreshModelMenuFromState(menu *Menu, s *appState) {
|
||||||
Action: func() {
|
Action: func() {
|
||||||
newName := s.nextAvailableModelName("new-model")
|
newName := s.nextAvailableModelName("new-model")
|
||||||
s.addModel(
|
s.addModel(
|
||||||
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
|
&picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
|
||||||
)
|
)
|
||||||
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
|
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
|
||||||
},
|
},
|
||||||
|
|
@ -300,8 +300,8 @@ func refreshModelMenuFromState(menu *Menu, s *appState) {
|
||||||
menu.applyItems(items)
|
menu.applyItems(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isModelValid(model picoclawconfig.ModelConfig) bool {
|
func isModelValid(model *picoclawconfig.ModelConfig) bool {
|
||||||
hasKey := strings.TrimSpace(model.APIKey) != "" ||
|
hasKey := strings.TrimSpace(model.APIKey()) != "" ||
|
||||||
strings.TrimSpace(model.AuthMethod) == "oauth"
|
strings.TrimSpace(model.AuthMethod) == "oauth"
|
||||||
hasModel := strings.TrimSpace(model.Model) != ""
|
hasModel := strings.TrimSpace(model.Model) != ""
|
||||||
return hasKey && hasModel
|
return hasKey && hasModel
|
||||||
|
|
@ -343,7 +343,7 @@ func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
|
||||||
if model == nil {
|
if model == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(model.APIKey) == "" {
|
if strings.TrimSpace(model.APIKey()) == "" {
|
||||||
s.showMessage("Missing API Key", "Set api_key before testing")
|
s.showMessage("Missing API Key", "Set api_key before testing")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -375,7 +375,7 @@ func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(model.APIKey))
|
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(model.APIKey()))
|
||||||
|
|
||||||
resp, err := client.Do(request)
|
resp, err := client.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
||||||
|
|
||||||
// If no openai in ModelList, add it
|
// If no openai in ModelList, add it
|
||||||
if !foundOpenAI {
|
if !foundOpenAI {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||||
ModelName: "gpt-5.4",
|
ModelName: "gpt-5.4",
|
||||||
Model: "openai/gpt-5.4",
|
Model: "openai/gpt-5.4",
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
|
|
@ -139,7 +139,7 @@ func authLoginGoogleAntigravity() error {
|
||||||
|
|
||||||
// If no antigravity in ModelList, add it
|
// If no antigravity in ModelList, add it
|
||||||
if !foundAntigravity {
|
if !foundAntigravity {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||||
ModelName: "gemini-flash",
|
ModelName: "gemini-flash",
|
||||||
Model: "antigravity/gemini-3-flash",
|
Model: "antigravity/gemini-3-flash",
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
|
|
@ -213,7 +213,7 @@ func authLoginAnthropicSetupToken() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||||
ModelName: defaultAnthropicModel,
|
ModelName: defaultAnthropicModel,
|
||||||
Model: "anthropic/" + defaultAnthropicModel,
|
Model: "anthropic/" + defaultAnthropicModel,
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
|
|
@ -289,7 +289,7 @@ func authLoginPasteToken(provider string) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||||
ModelName: defaultAnthropicModel,
|
ModelName: defaultAnthropicModel,
|
||||||
Model: "anthropic/" + defaultAnthropicModel,
|
Model: "anthropic/" + defaultAnthropicModel,
|
||||||
AuthMethod: "token",
|
AuthMethod: "token",
|
||||||
|
|
@ -307,7 +307,7 @@ func authLoginPasteToken(provider string) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||||
ModelName: "gpt-5.4",
|
ModelName: "gpt-5.4",
|
||||||
Model: "openai/gpt-5.4",
|
Model: "openai/gpt-5.4",
|
||||||
AuthMethod: "token",
|
AuthMethod: "token",
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ func listAvailableModels(cfg *config.Config) {
|
||||||
if model.ModelName == defaultModel {
|
if model.ModelName == defaultModel {
|
||||||
marker = "> "
|
marker = "> "
|
||||||
}
|
}
|
||||||
if model.APIKey == "" {
|
if model.APIKey() == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
|
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
|
||||||
|
|
@ -92,7 +92,7 @@ func setDefaultModel(configPath string, cfg *config.Config, modelName string) er
|
||||||
// Validate that the model exists in model_list
|
// Validate that the model exists in model_list
|
||||||
modelFound := false
|
modelFound := false
|
||||||
for _, model := range cfg.ModelList {
|
for _, model := range cfg.ModelList {
|
||||||
if model.APIKey != "" && model.ModelName == modelName {
|
if model.APIKey() != "" && model.ModelName == modelName {
|
||||||
modelFound = true
|
modelFound = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,17 +58,24 @@ func TestNewModelCommand(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
|
func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
|
||||||
cfg := &config.Config{
|
cfg := (&config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "gpt-4",
|
ModelName: "gpt-4",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
|
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||||
{ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"},
|
{ModelName: "claude-3", Model: "anthropic/claude-3"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"gpt-4": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
"claude-3": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
|
||||||
output := captureStdout(func() {
|
output := captureStdout(func() {
|
||||||
showCurrentModel(cfg)
|
showCurrentModel(cfg)
|
||||||
|
|
@ -81,16 +88,20 @@ func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
|
func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
|
||||||
cfg := &config.Config{
|
cfg := (&config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "",
|
ModelName: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
|
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"gpt-4": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
|
||||||
output := captureStdout(func() {
|
output := captureStdout(func() {
|
||||||
showCurrentModel(cfg)
|
showCurrentModel(cfg)
|
||||||
|
|
@ -102,7 +113,7 @@ func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
|
||||||
|
|
||||||
func TestListAvailableModels_Empty(t *testing.T) {
|
func TestListAvailableModels_Empty(t *testing.T) {
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
ModelList: []config.ModelConfig{},
|
ModelList: []*config.ModelConfig{},
|
||||||
}
|
}
|
||||||
|
|
||||||
output := captureStdout(func() {
|
output := captureStdout(func() {
|
||||||
|
|
@ -113,18 +124,25 @@ func TestListAvailableModels_Empty(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListAvailableModels_WithModels(t *testing.T) {
|
func TestListAvailableModels_WithModels(t *testing.T) {
|
||||||
cfg := &config.Config{
|
cfg := (&config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "gpt-4",
|
ModelName: "gpt-4",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
|
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||||
{ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"},
|
{ModelName: "claude-3", Model: "anthropic/claude-3"},
|
||||||
{ModelName: "no-key-model", Model: "openai/test", APIKey: ""},
|
{ModelName: "no-key-model", Model: "openai/test"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"gpt-4": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
"claude-3": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
|
||||||
output := captureStdout(func() {
|
output := captureStdout(func() {
|
||||||
listAvailableModels(cfg)
|
listAvailableModels(cfg)
|
||||||
|
|
@ -139,17 +157,24 @@ func TestListAvailableModels_WithModels(t *testing.T) {
|
||||||
func TestSetDefaultModel_ValidModel(t *testing.T) {
|
func TestSetDefaultModel_ValidModel(t *testing.T) {
|
||||||
initTest(t)
|
initTest(t)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := (&config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "old-model",
|
ModelName: "old-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
|
{ModelName: "new-model", Model: "openai/new-model"},
|
||||||
{ModelName: "old-model", Model: "openai/old-model", APIKey: "test"},
|
{ModelName: "old-model", Model: "openai/old-model"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"new-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
"old-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
|
||||||
output := captureStdout(func() {
|
output := captureStdout(func() {
|
||||||
err := setDefaultModel(configPath, cfg, "new-model")
|
err := setDefaultModel(configPath, cfg, "new-model")
|
||||||
|
|
@ -167,16 +192,20 @@ func TestSetDefaultModel_ValidModel(t *testing.T) {
|
||||||
func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
||||||
initTest(t)
|
initTest(t)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := (&config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "existing-model",
|
ModelName: "existing-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "existing-model", Model: "openai/existing", APIKey: "test"},
|
{ModelName: "existing-model", Model: "openai/existing"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"existing-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
|
||||||
assert.Error(t, setDefaultModel(configPath, cfg, "nonexistent-model"))
|
assert.Error(t, setDefaultModel(configPath, cfg, "nonexistent-model"))
|
||||||
}
|
}
|
||||||
|
|
@ -184,17 +213,24 @@ func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
||||||
func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
|
func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
|
||||||
initTest(t)
|
initTest(t)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := (&config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "existing-model",
|
ModelName: "existing-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "existing-model", Model: "openai/existing", APIKey: "test"},
|
{ModelName: "existing-model", Model: "openai/existing"},
|
||||||
{ModelName: "no-key-model", Model: "openai/nokey", APIKey: ""},
|
{ModelName: "no-key-model", Model: "openai/nokey"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"existing-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
"no-key-model": {
|
||||||
|
APIKeys: []string{""},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
|
||||||
assert.Error(t, setDefaultModel(configPath, cfg, "no-key-model"))
|
assert.Error(t, setDefaultModel(configPath, cfg, "no-key-model"))
|
||||||
}
|
}
|
||||||
|
|
@ -203,16 +239,20 @@ func TestSetDefaultModel_SaveConfigError(t *testing.T) {
|
||||||
// Use an invalid path to trigger save error
|
// Use an invalid path to trigger save error
|
||||||
invalidPath := "/nonexistent/directory/config.json"
|
invalidPath := "/nonexistent/directory/config.json"
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := (&config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "old-model",
|
ModelName: "old-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
|
{ModelName: "new-model", Model: "openai/new-model"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"new-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
|
||||||
err := setDefaultModel(invalidPath, cfg, "new-model")
|
err := setDefaultModel(invalidPath, cfg, "new-model")
|
||||||
|
|
||||||
|
|
@ -244,16 +284,20 @@ func TestModelCommandExecution_Show(t *testing.T) {
|
||||||
initTest(t)
|
initTest(t)
|
||||||
|
|
||||||
// Create a test config
|
// Create a test config
|
||||||
cfg := &config.Config{
|
cfg := (&config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "test-model",
|
ModelName: "test-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "test-model", Model: "openai/test", APIKey: "test"},
|
{ModelName: "test-model", Model: "openai/test"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"test-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
|
||||||
err := config.SaveConfig(configPath, cfg)
|
err := config.SaveConfig(configPath, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -271,17 +315,25 @@ func TestModelCommandExecution_Show(t *testing.T) {
|
||||||
func TestModelCommandExecution_Set(t *testing.T) {
|
func TestModelCommandExecution_Set(t *testing.T) {
|
||||||
initTest(t)
|
initTest(t)
|
||||||
|
|
||||||
cfg := &config.Config{
|
sec := &config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"old-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
"new-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
cfg := (&config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "old-model",
|
ModelName: "old-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "old-model", Model: "openai/old", APIKey: "test"},
|
{ModelName: "old-model", Model: "openai/old"},
|
||||||
{ModelName: "new-model", Model: "openai/new", APIKey: "test"},
|
{ModelName: "new-model", Model: "openai/new"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(sec)
|
||||||
|
|
||||||
err := config.SaveConfig(configPath, cfg)
|
err := config.SaveConfig(configPath, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -305,18 +357,28 @@ func TestModelCommandExecution_TooManyArgs(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListAvailableModels_MarkerLogic(t *testing.T) {
|
func TestListAvailableModels_MarkerLogic(t *testing.T) {
|
||||||
cfg := &config.Config{
|
cfg := (&config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "middle-model",
|
ModelName: "middle-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "first-model", Model: "openai/first", APIKey: "test"},
|
{ModelName: "first-model", Model: "openai/first"},
|
||||||
{ModelName: "middle-model", Model: "openai/middle", APIKey: "test"},
|
{ModelName: "middle-model", Model: "openai/middle"},
|
||||||
{ModelName: "last-model", Model: "openai/last", APIKey: "test"},
|
{ModelName: "last-model", Model: "openai/last"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"first-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
"middle-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
"last-model": {
|
||||||
|
APIKeys: []string{"test"},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
|
||||||
output := captureStdout(func() {
|
output := captureStdout(func() {
|
||||||
listAvailableModels(cfg)
|
listAvailableModels(cfg)
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ func NewSkillsCommand() *cobra.Command {
|
||||||
d.workspace = cfg.WorkspacePath()
|
d.workspace = cfg.WorkspacePath()
|
||||||
installer, err := skills.NewSkillInstaller(
|
installer, err := skills.NewSkillInstaller(
|
||||||
d.workspace,
|
d.workspace,
|
||||||
cfg.Tools.Skills.Github.Token,
|
cfg.Tools.Skills.Github.Token(),
|
||||||
cfg.Tools.Skills.Github.Proxy,
|
cfg.Tools.Skills.Github.Proxy,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -64,9 +64,20 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
|
||||||
|
|
||||||
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
|
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
|
||||||
|
|
||||||
|
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
|
||||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
ClawHub: skills.ClawHubConfig{
|
||||||
|
Enabled: clawHubConfig.Enabled,
|
||||||
|
BaseURL: clawHubConfig.BaseURL,
|
||||||
|
AuthToken: clawHubConfig.AuthToken(),
|
||||||
|
SearchPath: clawHubConfig.SearchPath,
|
||||||
|
SkillsPath: clawHubConfig.SkillsPath,
|
||||||
|
DownloadPath: clawHubConfig.DownloadPath,
|
||||||
|
Timeout: clawHubConfig.Timeout,
|
||||||
|
MaxZipSize: clawHubConfig.MaxZipSize,
|
||||||
|
MaxResponseSize: clawHubConfig.MaxResponseSize,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
registry := registryMgr.GetRegistry(registryName)
|
registry := registryMgr.GetRegistry(registryName)
|
||||||
|
|
@ -226,9 +237,20 @@ func skillsSearchCmd(query string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
|
||||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
ClawHub: skills.ClawHubConfig{
|
||||||
|
Enabled: clawHubConfig.Enabled,
|
||||||
|
BaseURL: clawHubConfig.BaseURL,
|
||||||
|
AuthToken: clawHubConfig.AuthToken(),
|
||||||
|
SearchPath: clawHubConfig.SearchPath,
|
||||||
|
SkillsPath: clawHubConfig.SkillsPath,
|
||||||
|
DownloadPath: clawHubConfig.DownloadPath,
|
||||||
|
Timeout: clawHubConfig.Timeout,
|
||||||
|
MaxZipSize: clawHubConfig.MaxZipSize,
|
||||||
|
MaxResponseSize: clawHubConfig.MaxResponseSize,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -93,7 +93,7 @@ require (
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
golang.org/x/arch v0.24.0 // indirect
|
golang.org/x/arch v0.24.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.51.0
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
|
||||||
ModelName: tt.aliasName,
|
ModelName: tt.aliasName,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{
|
{
|
||||||
ModelName: tt.aliasName,
|
ModelName: tt.aliasName,
|
||||||
Model: tt.modelName,
|
Model: tt.modelName,
|
||||||
|
|
|
||||||
|
|
@ -130,25 +130,28 @@ func registerSharedTools(
|
||||||
|
|
||||||
if cfg.Tools.IsToolEnabled("web") {
|
if cfg.Tools.IsToolEnabled("web") {
|
||||||
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
||||||
BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey, cfg.Tools.Web.Brave.APIKeys),
|
BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey(), cfg.Tools.Web.Brave.APIKeys()),
|
||||||
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||||
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
||||||
TavilyAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Tavily.APIKey, cfg.Tools.Web.Tavily.APIKeys),
|
TavilyAPIKeys: config.MergeAPIKeys(
|
||||||
|
cfg.Tools.Web.Tavily.APIKey(),
|
||||||
|
cfg.Tools.Web.Tavily.APIKeys(),
|
||||||
|
),
|
||||||
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
||||||
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
||||||
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
||||||
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||||
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||||
PerplexityAPIKeys: config.MergeAPIKeys(
|
PerplexityAPIKeys: config.MergeAPIKeys(
|
||||||
cfg.Tools.Web.Perplexity.APIKey,
|
cfg.Tools.Web.Perplexity.APIKey(),
|
||||||
cfg.Tools.Web.Perplexity.APIKeys,
|
cfg.Tools.Web.Perplexity.APIKeys(),
|
||||||
),
|
),
|
||||||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||||
SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
|
SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
|
||||||
SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults,
|
SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults,
|
||||||
SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled,
|
SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled,
|
||||||
GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey,
|
GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey(),
|
||||||
GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
|
GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
|
||||||
GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine,
|
GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine,
|
||||||
GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
|
GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
|
||||||
|
|
@ -215,9 +218,20 @@ func registerSharedTools(
|
||||||
find_skills_enable := cfg.Tools.IsToolEnabled("find_skills")
|
find_skills_enable := cfg.Tools.IsToolEnabled("find_skills")
|
||||||
install_skills_enable := cfg.Tools.IsToolEnabled("install_skill")
|
install_skills_enable := cfg.Tools.IsToolEnabled("install_skill")
|
||||||
if skills_enabled && (find_skills_enable || install_skills_enable) {
|
if skills_enabled && (find_skills_enable || install_skills_enable) {
|
||||||
|
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
|
||||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
ClawHub: skills.ClawHubConfig{
|
||||||
|
Enabled: clawHubConfig.Enabled,
|
||||||
|
BaseURL: clawHubConfig.BaseURL,
|
||||||
|
AuthToken: clawHubConfig.AuthToken(),
|
||||||
|
SearchPath: clawHubConfig.SearchPath,
|
||||||
|
SkillsPath: clawHubConfig.SkillsPath,
|
||||||
|
DownloadPath: clawHubConfig.DownloadPath,
|
||||||
|
Timeout: clawHubConfig.Timeout,
|
||||||
|
MaxZipSize: clawHubConfig.MaxZipSize,
|
||||||
|
MaxResponseSize: clawHubConfig.MaxResponseSize,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if find_skills_enable {
|
if find_skills_enable {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ type DingTalkChannel struct {
|
||||||
|
|
||||||
// NewDingTalkChannel creates a new DingTalk channel instance
|
// NewDingTalkChannel creates a new DingTalk channel instance
|
||||||
func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) {
|
func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) {
|
||||||
if cfg.ClientID == "" || cfg.ClientSecret == "" {
|
if cfg.ClientID == "" || cfg.ClientSecret() == "" {
|
||||||
return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
|
return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,7 +53,7 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
clientID: cfg.ClientID,
|
clientID: cfg.ClientID,
|
||||||
clientSecret: cfg.ClientSecret,
|
clientSecret: cfg.ClientSecret(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
|
||||||
discordgo.LogDebug: logger.DEBUG,
|
discordgo.LogDebug: logger.DEBUG,
|
||||||
}).Log
|
}).Log
|
||||||
|
|
||||||
session, err := discordgo.New("Bot " + cfg.Token)
|
session, err := discordgo.New("Bot " + cfg.Token())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create discord session: %w", err)
|
return nil, fmt.Errorf("failed to create discord session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,14 +62,14 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
tokenCache: tc,
|
tokenCache: tc,
|
||||||
client: lark.NewClient(cfg.AppID, cfg.AppSecret, opts...),
|
client: lark.NewClient(cfg.AppID, cfg.AppSecret(), opts...),
|
||||||
}
|
}
|
||||||
ch.SetOwner(ch)
|
ch.SetOwner(ch)
|
||||||
return ch, nil
|
return ch, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *FeishuChannel) Start(ctx context.Context) error {
|
func (c *FeishuChannel) Start(ctx context.Context) error {
|
||||||
if c.config.AppID == "" || c.config.AppSecret == "" {
|
if c.config.AppID == "" || c.config.AppSecret() == "" {
|
||||||
return fmt.Errorf("feishu app_id or app_secret is empty")
|
return fmt.Errorf("feishu app_id or app_secret is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,7 +80,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken, c.config.EncryptKey).
|
dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken(), c.config.EncryptKey()).
|
||||||
OnP2MessageReceiveV1(c.handleMessageReceive)
|
OnP2MessageReceiveV1(c.handleMessageReceive)
|
||||||
|
|
||||||
runCtx, cancel := context.WithCancel(ctx)
|
runCtx, cancel := context.WithCancel(ctx)
|
||||||
|
|
@ -93,7 +93,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
c.wsClient = larkws.NewClient(
|
c.wsClient = larkws.NewClient(
|
||||||
c.config.AppID,
|
c.config.AppID,
|
||||||
c.config.AppSecret,
|
c.config.AppSecret(),
|
||||||
larkws.WithEventHandler(dispatcher),
|
larkws.WithEventHandler(dispatcher),
|
||||||
larkws.WithDomain(domain),
|
larkws.WithDomain(domain),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ import (
|
||||||
// onConnect is called after a successful connection (and on reconnect).
|
// onConnect is called after a successful connection (and on reconnect).
|
||||||
func (c *IRCChannel) onConnect(conn *ircevent.Connection) {
|
func (c *IRCChannel) onConnect(conn *ircevent.Connection) {
|
||||||
// NickServ auth (only if SASL is not configured)
|
// NickServ auth (only if SASL is not configured)
|
||||||
if c.config.NickServPassword != "" && c.config.SASLUser == "" {
|
if c.config.NickServPassword() != "" && c.config.SASLUser == "" {
|
||||||
conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword)
|
conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Join configured channels
|
// Join configured channels
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ func (c *IRCChannel) Start(ctx context.Context) error {
|
||||||
Nick: c.config.Nick,
|
Nick: c.config.Nick,
|
||||||
User: user,
|
User: user,
|
||||||
RealName: realName,
|
RealName: realName,
|
||||||
Password: c.config.Password,
|
Password: c.config.Password(),
|
||||||
UseTLS: c.config.TLS,
|
UseTLS: c.config.TLS,
|
||||||
RequestCaps: caps,
|
RequestCaps: caps,
|
||||||
QuitMessage: "Goodbye",
|
QuitMessage: "Goodbye",
|
||||||
|
|
@ -83,9 +83,9 @@ func (c *IRCChannel) Start(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SASL auth (takes priority over NickServ)
|
// SASL auth (takes priority over NickServ)
|
||||||
if c.config.SASLUser != "" && c.config.SASLPassword != "" {
|
if c.config.SASLUser != "" && c.config.SASLPassword() != "" {
|
||||||
conn.SASLLogin = c.config.SASLUser
|
conn.SASLLogin = c.config.SASLUser
|
||||||
conn.SASLPassword = c.config.SASLPassword
|
conn.SASLPassword = c.config.SASLPassword()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register event handlers
|
// Register event handlers
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ type LINEChannel struct {
|
||||||
|
|
||||||
// NewLINEChannel creates a new LINE channel instance.
|
// NewLINEChannel creates a new LINE channel instance.
|
||||||
func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) {
|
func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) {
|
||||||
if cfg.ChannelSecret == "" || cfg.ChannelAccessToken == "" {
|
if cfg.ChannelSecret() == "" || cfg.ChannelAccessToken() == "" {
|
||||||
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
|
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,7 +110,7 @@ func (c *LINEChannel) fetchBotInfo() error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
|
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken())
|
||||||
|
|
||||||
resp, err := c.infoClient.Do(req)
|
resp, err := c.infoClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -216,7 +216,7 @@ func (c *LINEChannel) verifySignature(body []byte, signature string) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret))
|
mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret()))
|
||||||
mac.Write(body)
|
mac.Write(body)
|
||||||
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
|
||||||
|
|
@ -654,7 +654,7 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
|
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken())
|
||||||
|
|
||||||
resp, err := c.apiClient.Do(req)
|
resp, err := c.apiClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -679,7 +679,7 @@ func (c *LINEChannel) downloadContent(messageID, filename string) string {
|
||||||
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||||
LoggerPrefix: "line",
|
LoggerPrefix: "line",
|
||||||
ExtraHeaders: map[string]string{
|
ExtraHeaders: map[string]string{
|
||||||
"Authorization": "Bearer " + c.config.ChannelAccessToken,
|
"Authorization": "Bearer " + c.config.ChannelAccessToken(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,7 @@ func (m *Manager) initChannel(name, displayName string) {
|
||||||
func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
||||||
logger.InfoC("channels", "Initializing channel manager")
|
logger.InfoC("channels", "Initializing channel manager")
|
||||||
|
|
||||||
if channels.Telegram.Enabled && channels.Telegram.Token != "" {
|
if channels.Telegram.Enabled && channels.Telegram.Token() != "" {
|
||||||
m.initChannel("telegram", "Telegram")
|
m.initChannel("telegram", "Telegram")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -257,7 +257,7 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
||||||
m.initChannel("feishu", "Feishu")
|
m.initChannel("feishu", "Feishu")
|
||||||
}
|
}
|
||||||
|
|
||||||
if channels.Discord.Enabled && channels.Discord.Token != "" {
|
if channels.Discord.Enabled && channels.Discord.Token() != "" {
|
||||||
m.initChannel("discord", "Discord")
|
m.initChannel("discord", "Discord")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -273,18 +273,18 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
||||||
m.initChannel("dingtalk", "DingTalk")
|
m.initChannel("dingtalk", "DingTalk")
|
||||||
}
|
}
|
||||||
|
|
||||||
if channels.Slack.Enabled && channels.Slack.BotToken != "" {
|
if channels.Slack.Enabled && channels.Slack.BotToken() != "" {
|
||||||
m.initChannel("slack", "Slack")
|
m.initChannel("slack", "Slack")
|
||||||
}
|
}
|
||||||
|
|
||||||
if channels.Matrix.Enabled &&
|
if channels.Matrix.Enabled &&
|
||||||
m.config.Channels.Matrix.Homeserver != "" &&
|
m.config.Channels.Matrix.Homeserver != "" &&
|
||||||
m.config.Channels.Matrix.UserID != "" &&
|
m.config.Channels.Matrix.UserID != "" &&
|
||||||
m.config.Channels.Matrix.AccessToken != "" {
|
m.config.Channels.Matrix.AccessToken() != "" {
|
||||||
m.initChannel("matrix", "Matrix")
|
m.initChannel("matrix", "Matrix")
|
||||||
}
|
}
|
||||||
|
|
||||||
if channels.LINE.Enabled && channels.LINE.ChannelAccessToken != "" {
|
if channels.LINE.Enabled && channels.LINE.ChannelAccessToken() != "" {
|
||||||
m.initChannel("line", "LINE")
|
m.initChannel("line", "LINE")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -292,11 +292,11 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
||||||
m.initChannel("onebot", "OneBot")
|
m.initChannel("onebot", "OneBot")
|
||||||
}
|
}
|
||||||
|
|
||||||
if channels.WeCom.Enabled && channels.WeCom.Token != "" {
|
if channels.WeCom.Enabled && channels.WeCom.Token() != "" {
|
||||||
m.initChannel("wecom", "WeCom")
|
m.initChannel("wecom", "WeCom")
|
||||||
}
|
}
|
||||||
|
|
||||||
if channels.WeComAIBot.Enabled && channels.WeComAIBot.Token != "" {
|
if channels.WeComAIBot.Enabled && channels.WeComAIBot.Token() != "" {
|
||||||
m.initChannel("wecom_aibot", "WeCom AI Bot")
|
m.initChannel("wecom_aibot", "WeCom AI Bot")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -304,7 +304,7 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
||||||
m.initChannel("wecom_app", "WeCom App")
|
m.initChannel("wecom_app", "WeCom App")
|
||||||
}
|
}
|
||||||
|
|
||||||
if channels.Pico.Enabled && channels.Pico.Token != "" {
|
if channels.Pico.Enabled && channels.Pico.Token() != "" {
|
||||||
m.initChannel("pico", "Pico")
|
m.initChannel("pico", "Pico")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ func toChannelHashes(cfg *config.Config) map[string]string {
|
||||||
if !value["enabled"].(bool) {
|
if !value["enabled"].(bool) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
hiddenValues(key, value, ch)
|
||||||
valueBytes, _ := json.Marshal(value)
|
valueBytes, _ := json.Marshal(value)
|
||||||
hash := md5.Sum(valueBytes)
|
hash := md5.Sum(valueBytes)
|
||||||
result[key] = hex.EncodeToString(hash[:])
|
result[key] = hex.EncodeToString(hash[:])
|
||||||
|
|
@ -29,6 +30,48 @@ func toChannelHashes(cfg *config.Config) map[string]string {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hiddenValues(key string, value map[string]any, ch config.ChannelsConfig) {
|
||||||
|
switch key {
|
||||||
|
case "pico":
|
||||||
|
value["token"] = ch.Pico.Token()
|
||||||
|
case "telegram":
|
||||||
|
value["token"] = ch.Telegram.Token()
|
||||||
|
case "discord":
|
||||||
|
value["token"] = ch.Discord.Token()
|
||||||
|
case "slack":
|
||||||
|
value["bot_token"] = ch.Slack.BotToken()
|
||||||
|
value["app_token"] = ch.Slack.AppToken()
|
||||||
|
case "matrix":
|
||||||
|
value["token"] = ch.Matrix.AccessToken()
|
||||||
|
case "onebot":
|
||||||
|
value["token"] = ch.OneBot.AccessToken()
|
||||||
|
case "line":
|
||||||
|
value["token"] = ch.LINE.ChannelAccessToken()
|
||||||
|
value["secret"] = ch.LINE.ChannelSecret()
|
||||||
|
case "wecom":
|
||||||
|
value["token"] = ch.WeCom.Token()
|
||||||
|
value["key"] = ch.WeCom.EncodingAESKey()
|
||||||
|
case "wecom_app":
|
||||||
|
value["token"] = ch.WeComApp.Token()
|
||||||
|
value["secret"] = ch.WeComApp.CorpSecret()
|
||||||
|
case "wecom_aibot":
|
||||||
|
value["token"] = ch.WeComAIBot.Token()
|
||||||
|
value["key"] = ch.WeComAIBot.EncodingAESKey()
|
||||||
|
case "dingtalk":
|
||||||
|
value["secret"] = ch.QQ.AppSecret()
|
||||||
|
case "qq":
|
||||||
|
value["secret"] = ch.DingTalk.ClientSecret()
|
||||||
|
case "irc":
|
||||||
|
value["password"] = ch.IRC.Password()
|
||||||
|
value["serv_password"] = ch.IRC.NickServPassword()
|
||||||
|
value["sasl_password"] = ch.IRC.SASLPassword()
|
||||||
|
case "feishu":
|
||||||
|
value["app_secret"] = ch.Feishu.AppSecret()
|
||||||
|
value["encrypt_key"] = ch.Feishu.EncryptKey()
|
||||||
|
value["verification_token"] = ch.Feishu.VerificationToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func compareChannels(old, news map[string]string) (added, removed []string) {
|
func compareChannels(old, news map[string]string) (added, removed []string) {
|
||||||
for key, newHash := range news {
|
for key, newHash := range news {
|
||||||
if oldHash, ok := old[key]; ok {
|
if oldHash, ok := old[key]; ok {
|
||||||
|
|
@ -82,5 +125,61 @@ func toChannelConfig(cfg *config.Config, list []string) (*config.ChannelsConfig,
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateKeys(result, &ch)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateKeys(newcfg, old *config.ChannelsConfig) {
|
||||||
|
if newcfg.Pico.Enabled {
|
||||||
|
newcfg.Pico.SetToken(old.Pico.Token())
|
||||||
|
}
|
||||||
|
if newcfg.Telegram.Enabled {
|
||||||
|
newcfg.Telegram.SetToken(old.Telegram.Token())
|
||||||
|
}
|
||||||
|
if newcfg.Discord.Enabled {
|
||||||
|
newcfg.Discord.SetToken(old.Discord.Token())
|
||||||
|
}
|
||||||
|
if newcfg.Slack.Enabled {
|
||||||
|
newcfg.Slack.SetBotToken(old.Slack.BotToken())
|
||||||
|
newcfg.Slack.SetAppToken(old.Slack.AppToken())
|
||||||
|
}
|
||||||
|
if newcfg.Matrix.Enabled {
|
||||||
|
newcfg.Matrix.SetAccessToken(old.Matrix.AccessToken())
|
||||||
|
}
|
||||||
|
if newcfg.OneBot.Enabled {
|
||||||
|
newcfg.OneBot.SetAccessToken(old.OneBot.AccessToken())
|
||||||
|
}
|
||||||
|
if newcfg.LINE.Enabled {
|
||||||
|
newcfg.LINE.SetChannelAccessToken(old.LINE.ChannelAccessToken())
|
||||||
|
newcfg.LINE.SetChannelSecret(old.LINE.ChannelSecret())
|
||||||
|
}
|
||||||
|
if newcfg.WeCom.Enabled {
|
||||||
|
newcfg.WeCom.SetToken(old.WeCom.Token())
|
||||||
|
newcfg.WeCom.SetEncodingAESKey(old.WeCom.EncodingAESKey())
|
||||||
|
}
|
||||||
|
if newcfg.WeComApp.Enabled {
|
||||||
|
newcfg.WeComApp.SetToken(old.WeComApp.Token())
|
||||||
|
newcfg.WeComApp.SetCorpSecret(old.WeComApp.CorpSecret())
|
||||||
|
}
|
||||||
|
if newcfg.WeComAIBot.Enabled {
|
||||||
|
newcfg.WeComAIBot.SetToken(old.WeComAIBot.Token())
|
||||||
|
newcfg.WeComAIBot.SetEncodingAESKey(old.WeComAIBot.EncodingAESKey())
|
||||||
|
}
|
||||||
|
if newcfg.DingTalk.Enabled {
|
||||||
|
newcfg.DingTalk.SetClientSecret(old.DingTalk.ClientSecret())
|
||||||
|
}
|
||||||
|
if newcfg.QQ.Enabled {
|
||||||
|
newcfg.QQ.SetAppSecret(old.QQ.AppSecret())
|
||||||
|
}
|
||||||
|
if newcfg.IRC.Enabled {
|
||||||
|
newcfg.IRC.SetPassword(old.IRC.Password())
|
||||||
|
newcfg.IRC.SetNickServPassword(old.IRC.NickServPassword())
|
||||||
|
newcfg.IRC.SetSASLPassword(old.IRC.SASLPassword())
|
||||||
|
}
|
||||||
|
if newcfg.Feishu.Enabled {
|
||||||
|
newcfg.Feishu.SetAppSecret(old.Feishu.AppSecret())
|
||||||
|
newcfg.Feishu.SetEncryptKey(old.Feishu.EncryptKey())
|
||||||
|
newcfg.Feishu.SetVerificationToken(old.Feishu.VerificationToken())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ func TestToChannelHashes(t *testing.T) {
|
||||||
added, removed = compareChannels(results2, results3)
|
added, removed = compareChannels(results2, results3)
|
||||||
assert.EqualValues(t, []string{"dingtalk"}, removed)
|
assert.EqualValues(t, []string{"dingtalk"}, removed)
|
||||||
assert.EqualValues(t, []string{"telegram"}, added)
|
assert.EqualValues(t, []string{"telegram"}, added)
|
||||||
cfg3.Channels.Telegram.Token = "114314"
|
cfg3.Channels.Telegram.SetToken("114314")
|
||||||
results4 := toChannelHashes(cfg3)
|
results4 := toChannelHashes(cfg3)
|
||||||
assert.Equal(t, 1, len(results4))
|
assert.Equal(t, 1, len(results4))
|
||||||
logger.Debugf("results4: %v", results4)
|
logger.Debugf("results4: %v", results4)
|
||||||
|
|
@ -41,11 +41,11 @@ func TestToChannelHashes(t *testing.T) {
|
||||||
cc, err := toChannelConfig(cfg3, added)
|
cc, err := toChannelConfig(cfg3, added)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
logger.Debugf("cc: %#v", cc.Telegram)
|
logger.Debugf("cc: %#v", cc.Telegram)
|
||||||
assert.Equal(t, "114314", cc.Telegram.Token)
|
assert.Equal(t, "114314", cc.Telegram.Token())
|
||||||
assert.Equal(t, true, cc.Telegram.Enabled)
|
assert.Equal(t, true, cc.Telegram.Enabled)
|
||||||
cc, err = toChannelConfig(cfg2, added)
|
cc, err = toChannelConfig(cfg2, added)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
logger.Debugf("cc: %#v", cc.Telegram)
|
logger.Debugf("cc: %#v", cc.Telegram)
|
||||||
assert.Equal(t, "", cc.Telegram.Token)
|
assert.Equal(t, "", cc.Telegram.Token())
|
||||||
assert.Equal(t, false, cc.Telegram.Enabled)
|
assert.Equal(t, false, cc.Telegram.Enabled)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ type MatrixChannel struct {
|
||||||
func NewMatrixChannel(cfg config.MatrixConfig, messageBus *bus.MessageBus) (*MatrixChannel, error) {
|
func NewMatrixChannel(cfg config.MatrixConfig, messageBus *bus.MessageBus) (*MatrixChannel, error) {
|
||||||
homeserver := strings.TrimSpace(cfg.Homeserver)
|
homeserver := strings.TrimSpace(cfg.Homeserver)
|
||||||
userID := strings.TrimSpace(cfg.UserID)
|
userID := strings.TrimSpace(cfg.UserID)
|
||||||
accessToken := strings.TrimSpace(cfg.AccessToken)
|
accessToken := strings.TrimSpace(cfg.AccessToken())
|
||||||
if homeserver == "" {
|
if homeserver == "" {
|
||||||
return nil, fmt.Errorf("matrix homeserver is required")
|
return nil, fmt.Errorf("matrix homeserver is required")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -184,8 +184,8 @@ func (c *OneBotChannel) connect() error {
|
||||||
dialer.HandshakeTimeout = 10 * time.Second
|
dialer.HandshakeTimeout = 10 * time.Second
|
||||||
|
|
||||||
header := make(map[string][]string)
|
header := make(map[string][]string)
|
||||||
if c.config.AccessToken != "" {
|
if c.config.AccessToken() != "" {
|
||||||
header["Authorization"] = []string{"Bearer " + c.config.AccessToken}
|
header["Authorization"] = []string{"Bearer " + c.config.AccessToken()}
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, resp, err := dialer.Dial(c.config.WSUrl, header)
|
conn, resp, err := dialer.Dial(c.config.WSUrl, header)
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ type PicoChannel struct {
|
||||||
|
|
||||||
// NewPicoChannel creates a new Pico Protocol channel.
|
// NewPicoChannel creates a new Pico Protocol channel.
|
||||||
func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoChannel, error) {
|
func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoChannel, error) {
|
||||||
if cfg.Token == "" {
|
if cfg.Token() == "" {
|
||||||
return nil, fmt.Errorf("pico token is required")
|
return nil, fmt.Errorf("pico token is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,7 +293,7 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
// 2. Sec-WebSocket-Protocol "token.<value>" (for browsers that can't set headers)
|
// 2. Sec-WebSocket-Protocol "token.<value>" (for browsers that can't set headers)
|
||||||
// 3. Query parameter "token" (only when AllowTokenQuery is on)
|
// 3. Query parameter "token" (only when AllowTokenQuery is on)
|
||||||
func (c *PicoChannel) authenticate(r *http.Request) bool {
|
func (c *PicoChannel) authenticate(r *http.Request) bool {
|
||||||
token := c.config.Token
|
token := c.config.Token()
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -324,7 +324,7 @@ func (c *PicoChannel) authenticate(r *http.Request) bool {
|
||||||
// matchedSubprotocol returns the "token.<value>" subprotocol that matches
|
// matchedSubprotocol returns the "token.<value>" subprotocol that matches
|
||||||
// the configured token, or "" if none do.
|
// the configured token, or "" if none do.
|
||||||
func (c *PicoChannel) matchedSubprotocol(r *http.Request) string {
|
func (c *PicoChannel) matchedSubprotocol(r *http.Request) string {
|
||||||
token := c.config.Token
|
token := c.config.Token()
|
||||||
for _, proto := range websocket.Subprotocols(r) {
|
for _, proto := range websocket.Subprotocols(r) {
|
||||||
if after, ok := strings.CutPrefix(proto, "token."); ok && after == token {
|
if after, ok := strings.CutPrefix(proto, "token."); ok && after == token {
|
||||||
return proto
|
return proto
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *QQChannel) Start(ctx context.Context) error {
|
func (c *QQChannel) Start(ctx context.Context) error {
|
||||||
if c.config.AppID == "" || c.config.AppSecret == "" {
|
if c.config.AppID == "" || c.config.AppSecret() == "" {
|
||||||
return fmt.Errorf("QQ app_id and app_secret not configured")
|
return fmt.Errorf("QQ app_id and app_secret not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,7 +112,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
|
||||||
// create token source
|
// create token source
|
||||||
credentials := &token.QQBotCredentials{
|
credentials := &token.QQBotCredentials{
|
||||||
AppID: c.config.AppID,
|
AppID: c.config.AppID,
|
||||||
AppSecret: c.config.AppSecret,
|
AppSecret: c.config.AppSecret(),
|
||||||
}
|
}
|
||||||
c.tokenSource = token.NewQQBotTokenSource(credentials)
|
c.tokenSource = token.NewQQBotTokenSource(credentials)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,13 +37,13 @@ type slackMessageRef struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*SlackChannel, error) {
|
func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*SlackChannel, error) {
|
||||||
if cfg.BotToken == "" || cfg.AppToken == "" {
|
if cfg.BotToken() == "" || cfg.AppToken() == "" {
|
||||||
return nil, fmt.Errorf("slack bot_token and app_token are required")
|
return nil, fmt.Errorf("slack bot_token and app_token are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
api := slack.New(
|
api := slack.New(
|
||||||
cfg.BotToken,
|
cfg.BotToken(),
|
||||||
slack.OptionAppLevelToken(cfg.AppToken),
|
slack.OptionAppLevelToken(cfg.AppToken()),
|
||||||
)
|
)
|
||||||
|
|
||||||
socketClient := socketmode.New(api)
|
socketClient := socketmode.New(api)
|
||||||
|
|
@ -515,7 +515,7 @@ func (c *SlackChannel) downloadSlackFile(file slack.File) string {
|
||||||
return utils.DownloadFile(downloadURL, file.Name, utils.DownloadOptions{
|
return utils.DownloadFile(downloadURL, file.Name, utils.DownloadOptions{
|
||||||
LoggerPrefix: "slack",
|
LoggerPrefix: "slack",
|
||||||
ExtraHeaders: map[string]string{
|
ExtraHeaders: map[string]string{
|
||||||
"Authorization": "Bearer " + c.config.BotToken,
|
"Authorization": "Bearer " + c.config.BotToken(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,10 +102,8 @@ func TestNewSlackChannel(t *testing.T) {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
|
|
||||||
t.Run("missing bot token", func(t *testing.T) {
|
t.Run("missing bot token", func(t *testing.T) {
|
||||||
cfg := config.SlackConfig{
|
cfg := config.SlackConfig{}
|
||||||
BotToken: "",
|
cfg.SetAppToken("xapp-test")
|
||||||
AppToken: "xapp-test",
|
|
||||||
}
|
|
||||||
_, err := NewSlackChannel(cfg, msgBus)
|
_, err := NewSlackChannel(cfg, msgBus)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for missing bot_token, got nil")
|
t.Error("expected error for missing bot_token, got nil")
|
||||||
|
|
@ -113,10 +111,8 @@ func TestNewSlackChannel(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("missing app token", func(t *testing.T) {
|
t.Run("missing app token", func(t *testing.T) {
|
||||||
cfg := config.SlackConfig{
|
cfg := config.SlackConfig{}
|
||||||
BotToken: "xoxb-test",
|
cfg.SetBotToken("xoxb-test")
|
||||||
AppToken: "",
|
|
||||||
}
|
|
||||||
_, err := NewSlackChannel(cfg, msgBus)
|
_, err := NewSlackChannel(cfg, msgBus)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for missing app_token, got nil")
|
t.Error("expected error for missing app_token, got nil")
|
||||||
|
|
@ -125,10 +121,10 @@ func TestNewSlackChannel(t *testing.T) {
|
||||||
|
|
||||||
t.Run("valid config", func(t *testing.T) {
|
t.Run("valid config", func(t *testing.T) {
|
||||||
cfg := config.SlackConfig{
|
cfg := config.SlackConfig{
|
||||||
BotToken: "xoxb-test",
|
|
||||||
AppToken: "xapp-test",
|
|
||||||
AllowFrom: []string{"U123"},
|
AllowFrom: []string{"U123"},
|
||||||
}
|
}
|
||||||
|
cfg.SetBotToken("xoxb-test")
|
||||||
|
cfg.SetAppToken("xapp-test")
|
||||||
ch, err := NewSlackChannel(cfg, msgBus)
|
ch, err := NewSlackChannel(cfg, msgBus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
|
@ -147,10 +143,10 @@ func TestSlackChannelIsAllowed(t *testing.T) {
|
||||||
|
|
||||||
t.Run("empty allowlist allows all", func(t *testing.T) {
|
t.Run("empty allowlist allows all", func(t *testing.T) {
|
||||||
cfg := config.SlackConfig{
|
cfg := config.SlackConfig{
|
||||||
BotToken: "xoxb-test",
|
|
||||||
AppToken: "xapp-test",
|
|
||||||
AllowFrom: []string{},
|
AllowFrom: []string{},
|
||||||
}
|
}
|
||||||
|
cfg.SetBotToken("xoxb-test")
|
||||||
|
cfg.SetAppToken("xapp-test")
|
||||||
ch, _ := NewSlackChannel(cfg, msgBus)
|
ch, _ := NewSlackChannel(cfg, msgBus)
|
||||||
if !ch.IsAllowed("U_ANYONE") {
|
if !ch.IsAllowed("U_ANYONE") {
|
||||||
t.Error("empty allowlist should allow all users")
|
t.Error("empty allowlist should allow all users")
|
||||||
|
|
@ -159,10 +155,10 @@ func TestSlackChannelIsAllowed(t *testing.T) {
|
||||||
|
|
||||||
t.Run("allowlist restricts users", func(t *testing.T) {
|
t.Run("allowlist restricts users", func(t *testing.T) {
|
||||||
cfg := config.SlackConfig{
|
cfg := config.SlackConfig{
|
||||||
BotToken: "xoxb-test",
|
|
||||||
AppToken: "xapp-test",
|
|
||||||
AllowFrom: []string{"U_ALLOWED"},
|
AllowFrom: []string{"U_ALLOWED"},
|
||||||
}
|
}
|
||||||
|
cfg.SetBotToken("xoxb-test")
|
||||||
|
cfg.SetAppToken("xapp-test")
|
||||||
ch, _ := NewSlackChannel(cfg, msgBus)
|
ch, _ := NewSlackChannel(cfg, msgBus)
|
||||||
if !ch.IsAllowed("U_ALLOWED") {
|
if !ch.IsAllowed("U_ALLOWED") {
|
||||||
t.Error("allowed user should pass allowlist check")
|
t.Error("allowed user should pass allowlist check")
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
|
||||||
}
|
}
|
||||||
opts = append(opts, telego.WithLogger(logger.NewLogger("telego")))
|
opts = append(opts, telego.WithLogger(logger.NewLogger("telego")))
|
||||||
|
|
||||||
bot, err := telego.NewBot(telegramCfg.Token, opts...)
|
bot, err := telego.NewBot(telegramCfg.Token(), opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
|
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@ func NewWeComAIBotChannel(
|
||||||
cfg config.WeComAIBotConfig,
|
cfg config.WeComAIBotConfig,
|
||||||
messageBus *bus.MessageBus,
|
messageBus *bus.MessageBus,
|
||||||
) (*WeComAIBotChannel, error) {
|
) (*WeComAIBotChannel, error) {
|
||||||
if cfg.Token == "" || cfg.EncodingAESKey == "" {
|
if cfg.Token() == "" || cfg.EncodingAESKey() == "" {
|
||||||
return nil, fmt.Errorf("token and encoding_aes_key are required for WeCom AI Bot")
|
return nil, fmt.Errorf("token and encoding_aes_key are required for WeCom AI Bot")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -331,7 +331,7 @@ func (c *WeComAIBotChannel) handleVerification(
|
||||||
})
|
})
|
||||||
|
|
||||||
// Verify signature
|
// Verify signature
|
||||||
if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
|
if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, echostr) {
|
||||||
logger.ErrorC("wecom_aibot", "Signature verification failed")
|
logger.ErrorC("wecom_aibot", "Signature verification failed")
|
||||||
http.Error(w, "Signature verification failed", http.StatusUnauthorized)
|
http.Error(w, "Signature verification failed", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
|
|
@ -339,7 +339,7 @@ func (c *WeComAIBotChannel) handleVerification(
|
||||||
|
|
||||||
// Decrypt echostr
|
// Decrypt echostr
|
||||||
// For WeCom AI Bot (智能机器人), receiveid should be empty string
|
// For WeCom AI Bot (智能机器人), receiveid should be empty string
|
||||||
decrypted, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
|
decrypted, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey(), "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("wecom_aibot", "Failed to decrypt echostr", map[string]any{
|
logger.ErrorCF("wecom_aibot", "Failed to decrypt echostr", map[string]any{
|
||||||
"error": err,
|
"error": err,
|
||||||
|
|
@ -398,7 +398,7 @@ func (c *WeComAIBotChannel) handleMessageCallback(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify signature
|
// Verify signature
|
||||||
if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
|
if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
|
||||||
logger.ErrorC("wecom_aibot", "Signature verification failed")
|
logger.ErrorC("wecom_aibot", "Signature verification failed")
|
||||||
http.Error(w, "Signature verification failed", http.StatusUnauthorized)
|
http.Error(w, "Signature verification failed", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
|
|
@ -406,7 +406,7 @@ func (c *WeComAIBotChannel) handleMessageCallback(
|
||||||
|
|
||||||
// Decrypt message
|
// Decrypt message
|
||||||
// For WeCom AI Bot (智能机器人), receiveid is empty string
|
// For WeCom AI Bot (智能机器人), receiveid is empty string
|
||||||
decrypted, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
|
decrypted, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey(), "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("wecom_aibot", "Failed to decrypt message", map[string]any{
|
logger.ErrorCF("wecom_aibot", "Failed to decrypt message", map[string]any{
|
||||||
"error": err,
|
"error": err,
|
||||||
|
|
@ -840,7 +840,7 @@ func (c *WeComAIBotChannel) encryptResponse(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate signature
|
// Generate signature
|
||||||
signature := computeSignature(c.config.Token, timestamp, nonce, encrypted)
|
signature := computeSignature(c.config.Token(), timestamp, nonce, encrypted)
|
||||||
|
|
||||||
// Build encrypted response
|
// Build encrypted response
|
||||||
encryptedResp := WeComAIBotEncryptedResponse{
|
encryptedResp := WeComAIBotEncryptedResponse{
|
||||||
|
|
@ -875,7 +875,7 @@ func (c *WeComAIBotChannel) encryptEmptyResponse(timestamp, nonce string) string
|
||||||
|
|
||||||
// encryptMessage encrypts a plain text message for WeCom AI Bot
|
// encryptMessage encrypts a plain text message for WeCom AI Bot
|
||||||
func (c *WeComAIBotChannel) encryptMessage(plaintext, receiveid string) (string, error) {
|
func (c *WeComAIBotChannel) encryptMessage(plaintext, receiveid string) (string, error) {
|
||||||
aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey)
|
aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,11 @@ import (
|
||||||
|
|
||||||
func TestNewWeComAIBotChannel(t *testing.T) {
|
func TestNewWeComAIBotChannel(t *testing.T) {
|
||||||
t.Run("success with valid config", func(t *testing.T) {
|
t.Run("success with valid config", func(t *testing.T) {
|
||||||
cfg := config.WeComAIBotConfig{
|
cfg := config.WeComAIBotConfig{}
|
||||||
Enabled: true,
|
cfg.Enabled = true
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567")
|
||||||
WebhookPath: "/webhook/test",
|
cfg.WebhookPath = "/webhook/test"
|
||||||
}
|
|
||||||
|
|
||||||
messageBus := bus.NewMessageBus()
|
messageBus := bus.NewMessageBus()
|
||||||
ch, err := NewWeComAIBotChannel(cfg, messageBus)
|
ch, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
@ -33,10 +32,9 @@ func TestNewWeComAIBotChannel(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("error with missing token", func(t *testing.T) {
|
t.Run("error with missing token", func(t *testing.T) {
|
||||||
cfg := config.WeComAIBotConfig{
|
cfg := config.WeComAIBotConfig{}
|
||||||
Enabled: true,
|
cfg.Enabled = true
|
||||||
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567")
|
||||||
}
|
|
||||||
|
|
||||||
messageBus := bus.NewMessageBus()
|
messageBus := bus.NewMessageBus()
|
||||||
_, err := NewWeComAIBotChannel(cfg, messageBus)
|
_, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
@ -47,10 +45,9 @@ func TestNewWeComAIBotChannel(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("error with missing encoding key", func(t *testing.T) {
|
t.Run("error with missing encoding key", func(t *testing.T) {
|
||||||
cfg := config.WeComAIBotConfig{
|
cfg := config.WeComAIBotConfig{}
|
||||||
Enabled: true,
|
cfg.Enabled = true
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
}
|
|
||||||
|
|
||||||
messageBus := bus.NewMessageBus()
|
messageBus := bus.NewMessageBus()
|
||||||
_, err := NewWeComAIBotChannel(cfg, messageBus)
|
_, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
@ -62,11 +59,10 @@ func TestNewWeComAIBotChannel(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWeComAIBotChannelStartStop(t *testing.T) {
|
func TestWeComAIBotChannelStartStop(t *testing.T) {
|
||||||
cfg := config.WeComAIBotConfig{
|
cfg := config.WeComAIBotConfig{}
|
||||||
Enabled: true,
|
cfg.Enabled = true
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567")
|
||||||
}
|
|
||||||
|
|
||||||
messageBus := bus.NewMessageBus()
|
messageBus := bus.NewMessageBus()
|
||||||
ch, err := NewWeComAIBotChannel(cfg, messageBus)
|
ch, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
@ -97,11 +93,10 @@ func TestWeComAIBotChannelStartStop(t *testing.T) {
|
||||||
|
|
||||||
func TestWeComAIBotChannelWebhookPath(t *testing.T) {
|
func TestWeComAIBotChannelWebhookPath(t *testing.T) {
|
||||||
t.Run("default path", func(t *testing.T) {
|
t.Run("default path", func(t *testing.T) {
|
||||||
cfg := config.WeComAIBotConfig{
|
cfg := config.WeComAIBotConfig{}
|
||||||
Enabled: true,
|
cfg.Enabled = true
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567")
|
||||||
}
|
|
||||||
|
|
||||||
messageBus := bus.NewMessageBus()
|
messageBus := bus.NewMessageBus()
|
||||||
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
@ -114,12 +109,11 @@ func TestWeComAIBotChannelWebhookPath(t *testing.T) {
|
||||||
|
|
||||||
t.Run("custom path", func(t *testing.T) {
|
t.Run("custom path", func(t *testing.T) {
|
||||||
customPath := "/custom/webhook"
|
customPath := "/custom/webhook"
|
||||||
cfg := config.WeComAIBotConfig{
|
cfg := config.WeComAIBotConfig{}
|
||||||
Enabled: true,
|
cfg.Enabled = true
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567")
|
||||||
WebhookPath: customPath,
|
cfg.WebhookPath = customPath
|
||||||
}
|
|
||||||
|
|
||||||
messageBus := bus.NewMessageBus()
|
messageBus := bus.NewMessageBus()
|
||||||
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
@ -131,11 +125,10 @@ func TestWeComAIBotChannelWebhookPath(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateStreamID(t *testing.T) {
|
func TestGenerateStreamID(t *testing.T) {
|
||||||
cfg := config.WeComAIBotConfig{
|
cfg := config.WeComAIBotConfig{}
|
||||||
Enabled: true,
|
cfg.Enabled = true
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567")
|
||||||
}
|
|
||||||
|
|
||||||
messageBus := bus.NewMessageBus()
|
messageBus := bus.NewMessageBus()
|
||||||
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
@ -158,11 +151,10 @@ func TestGenerateStreamID(t *testing.T) {
|
||||||
|
|
||||||
func TestEncryptDecrypt(t *testing.T) {
|
func TestEncryptDecrypt(t *testing.T) {
|
||||||
// Use a valid 43-character base64 key (企业微信标准格式)
|
// Use a valid 43-character base64 key (企业微信标准格式)
|
||||||
cfg := config.WeComAIBotConfig{
|
cfg := config.WeComAIBotConfig{}
|
||||||
Enabled: true,
|
cfg.Enabled = true
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", // 43 characters
|
cfg.SetEncodingAESKey("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG") // 43 characters
|
||||||
}
|
|
||||||
|
|
||||||
messageBus := bus.NewMessageBus()
|
messageBus := bus.NewMessageBus()
|
||||||
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
@ -181,7 +173,7 @@ func TestEncryptDecrypt(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt
|
// Decrypt
|
||||||
decrypted, err := decryptMessageWithVerify(encrypted, cfg.EncodingAESKey, receiveid)
|
decrypted, err := decryptMessageWithVerify(encrypted, cfg.EncodingAESKey(), receiveid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to decrypt message: %v", err)
|
t.Fatalf("Failed to decrypt message: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ type PKCS7Padding struct{}
|
||||||
|
|
||||||
// NewWeComAppChannel creates a new WeCom App channel instance
|
// NewWeComAppChannel creates a new WeCom App channel instance
|
||||||
func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (*WeComAppChannel, error) {
|
func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (*WeComAppChannel, error) {
|
||||||
if cfg.CorpID == "" || cfg.CorpSecret == "" || cfg.AgentID == 0 {
|
if cfg.CorpID == "" || cfg.CorpSecret() == "" || cfg.AgentID == 0 {
|
||||||
return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required")
|
return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -497,9 +497,9 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify signature
|
// Verify signature
|
||||||
if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
|
if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, echostr) {
|
||||||
logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{
|
logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{
|
||||||
"token": c.config.Token,
|
"token": c.config.Token(),
|
||||||
"msg_signature": msgSignature,
|
"msg_signature": msgSignature,
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
"nonce": nonce,
|
"nonce": nonce,
|
||||||
|
|
@ -513,10 +513,10 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
|
||||||
// Decrypt echostr with CorpID verification
|
// Decrypt echostr with CorpID verification
|
||||||
// For WeCom App (自建应用), receiveid should be corp_id
|
// For WeCom App (自建应用), receiveid should be corp_id
|
||||||
logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]any{
|
logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]any{
|
||||||
"encoding_aes_key": c.config.EncodingAESKey,
|
"encoding_aes_key": c.config.EncodingAESKey(),
|
||||||
"corp_id": c.config.CorpID,
|
"corp_id": c.config.CorpID,
|
||||||
})
|
})
|
||||||
decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID)
|
decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey(), c.config.CorpID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{
|
logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -575,7 +575,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify signature
|
// Verify signature
|
||||||
if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
|
if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
|
||||||
logger.WarnC("wecom_app", "Message signature verification failed")
|
logger.WarnC("wecom_app", "Message signature verification failed")
|
||||||
http.Error(w, "Invalid signature", http.StatusForbidden)
|
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
|
|
@ -583,7 +583,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
||||||
|
|
||||||
// Decrypt message with CorpID verification
|
// Decrypt message with CorpID verification
|
||||||
// For WeCom App (自建应用), receiveid should be corp_id
|
// For WeCom App (自建应用), receiveid should be corp_id
|
||||||
decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID)
|
decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey(), c.config.CorpID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{
|
logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -689,7 +689,7 @@ func (c *WeComAppChannel) tokenRefreshLoop() {
|
||||||
// refreshAccessToken gets a new access token from WeCom API
|
// refreshAccessToken gets a new access token from WeCom API
|
||||||
func (c *WeComAppChannel) refreshAccessToken() error {
|
func (c *WeComAppChannel) refreshAccessToken() error {
|
||||||
apiURL := fmt.Sprintf("%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s",
|
apiURL := fmt.Sprintf("%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s",
|
||||||
wecomAPIBase, url.QueryEscape(c.config.CorpID), url.QueryEscape(c.config.CorpSecret))
|
wecomAPIBase, url.QueryEscape(c.config.CorpID), url.QueryEscape(c.config.CorpSecret()))
|
||||||
|
|
||||||
resp, err := http.Get(apiURL)
|
resp, err := http.Get(apiURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -91,10 +91,10 @@ func TestNewWeComAppChannel(t *testing.T) {
|
||||||
|
|
||||||
t.Run("missing corp_id", func(t *testing.T) {
|
t.Run("missing corp_id", func(t *testing.T) {
|
||||||
cfg := config.WeComAppConfig{
|
cfg := config.WeComAppConfig{
|
||||||
CorpID: "",
|
CorpID: "",
|
||||||
CorpSecret: "test_secret",
|
AgentID: 1000002,
|
||||||
AgentID: 1000002,
|
|
||||||
}
|
}
|
||||||
|
cfg.SetCorpSecret("test_secret")
|
||||||
_, err := NewWeComAppChannel(cfg, msgBus)
|
_, err := NewWeComAppChannel(cfg, msgBus)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for missing corp_id, got nil")
|
t.Error("expected error for missing corp_id, got nil")
|
||||||
|
|
@ -103,9 +103,8 @@ func TestNewWeComAppChannel(t *testing.T) {
|
||||||
|
|
||||||
t.Run("missing corp_secret", func(t *testing.T) {
|
t.Run("missing corp_secret", func(t *testing.T) {
|
||||||
cfg := config.WeComAppConfig{
|
cfg := config.WeComAppConfig{
|
||||||
CorpID: "test_corp_id",
|
CorpID: "test_corp_id",
|
||||||
CorpSecret: "",
|
AgentID: 1000002,
|
||||||
AgentID: 1000002,
|
|
||||||
}
|
}
|
||||||
_, err := NewWeComAppChannel(cfg, msgBus)
|
_, err := NewWeComAppChannel(cfg, msgBus)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -115,10 +114,10 @@ func TestNewWeComAppChannel(t *testing.T) {
|
||||||
|
|
||||||
t.Run("missing agent_id", func(t *testing.T) {
|
t.Run("missing agent_id", func(t *testing.T) {
|
||||||
cfg := config.WeComAppConfig{
|
cfg := config.WeComAppConfig{
|
||||||
CorpID: "test_corp_id",
|
CorpID: "test_corp_id",
|
||||||
CorpSecret: "test_secret",
|
AgentID: 0,
|
||||||
AgentID: 0,
|
|
||||||
}
|
}
|
||||||
|
cfg.SetCorpSecret("test_secret")
|
||||||
_, err := NewWeComAppChannel(cfg, msgBus)
|
_, err := NewWeComAppChannel(cfg, msgBus)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for missing agent_id, got nil")
|
t.Error("expected error for missing agent_id, got nil")
|
||||||
|
|
@ -127,11 +126,11 @@ func TestNewWeComAppChannel(t *testing.T) {
|
||||||
|
|
||||||
t.Run("valid config", func(t *testing.T) {
|
t.Run("valid config", func(t *testing.T) {
|
||||||
cfg := config.WeComAppConfig{
|
cfg := config.WeComAppConfig{
|
||||||
CorpID: "test_corp_id",
|
CorpID: "test_corp_id",
|
||||||
CorpSecret: "test_secret",
|
AgentID: 1000002,
|
||||||
AgentID: 1000002,
|
AllowFrom: []string{"user1", "user2"},
|
||||||
AllowFrom: []string{"user1", "user2"},
|
|
||||||
}
|
}
|
||||||
|
cfg.SetCorpSecret("test_secret")
|
||||||
ch, err := NewWeComAppChannel(cfg, msgBus)
|
ch, err := NewWeComAppChannel(cfg, msgBus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
|
@ -150,11 +149,11 @@ func TestWeComAppChannelIsAllowed(t *testing.T) {
|
||||||
|
|
||||||
t.Run("empty allowlist allows all", func(t *testing.T) {
|
t.Run("empty allowlist allows all", func(t *testing.T) {
|
||||||
cfg := config.WeComAppConfig{
|
cfg := config.WeComAppConfig{
|
||||||
CorpID: "test_corp_id",
|
CorpID: "test_corp_id",
|
||||||
CorpSecret: "test_secret",
|
AgentID: 1000002,
|
||||||
AgentID: 1000002,
|
AllowFrom: []string{},
|
||||||
AllowFrom: []string{},
|
|
||||||
}
|
}
|
||||||
|
cfg.SetCorpSecret("test_secret")
|
||||||
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
||||||
if !ch.IsAllowed("any_user") {
|
if !ch.IsAllowed("any_user") {
|
||||||
t.Error("empty allowlist should allow all users")
|
t.Error("empty allowlist should allow all users")
|
||||||
|
|
@ -163,11 +162,11 @@ func TestWeComAppChannelIsAllowed(t *testing.T) {
|
||||||
|
|
||||||
t.Run("allowlist restricts users", func(t *testing.T) {
|
t.Run("allowlist restricts users", func(t *testing.T) {
|
||||||
cfg := config.WeComAppConfig{
|
cfg := config.WeComAppConfig{
|
||||||
CorpID: "test_corp_id",
|
CorpID: "test_corp_id",
|
||||||
CorpSecret: "test_secret",
|
AgentID: 1000002,
|
||||||
AgentID: 1000002,
|
AllowFrom: []string{"allowed_user"},
|
||||||
AllowFrom: []string{"allowed_user"},
|
|
||||||
}
|
}
|
||||||
|
cfg.SetCorpSecret("test_secret")
|
||||||
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
||||||
if !ch.IsAllowed("allowed_user") {
|
if !ch.IsAllowed("allowed_user") {
|
||||||
t.Error("allowed user should pass allowlist check")
|
t.Error("allowed user should pass allowlist check")
|
||||||
|
|
@ -180,12 +179,11 @@ func TestWeComAppChannelIsAllowed(t *testing.T) {
|
||||||
|
|
||||||
func TestWeComAppVerifySignature(t *testing.T) {
|
func TestWeComAppVerifySignature(t *testing.T) {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
cfg := config.WeComAppConfig{
|
cfg := config.WeComAppConfig{}
|
||||||
CorpID: "test_corp_id",
|
cfg.CorpID = "test_corp_id"
|
||||||
CorpSecret: "test_secret",
|
cfg.SetCorpSecret("test_secret")
|
||||||
AgentID: 1000002,
|
cfg.AgentID = 1000002
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
}
|
|
||||||
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
||||||
|
|
||||||
t.Run("valid signature", func(t *testing.T) {
|
t.Run("valid signature", func(t *testing.T) {
|
||||||
|
|
@ -194,7 +192,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
|
||||||
msgEncrypt := "test_message"
|
msgEncrypt := "test_message"
|
||||||
expectedSig := generateSignatureApp("test_token", timestamp, nonce, msgEncrypt)
|
expectedSig := generateSignatureApp("test_token", timestamp, nonce, msgEncrypt)
|
||||||
|
|
||||||
if !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
|
if !verifySignature(ch.config.Token(), expectedSig, timestamp, nonce, msgEncrypt) {
|
||||||
t.Error("valid signature should pass verification")
|
t.Error("valid signature should pass verification")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -204,21 +202,20 @@ func TestWeComAppVerifySignature(t *testing.T) {
|
||||||
nonce := "test_nonce"
|
nonce := "test_nonce"
|
||||||
msgEncrypt := "test_message"
|
msgEncrypt := "test_message"
|
||||||
|
|
||||||
if verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
|
if verifySignature(ch.config.Token(), "invalid_sig", timestamp, nonce, msgEncrypt) {
|
||||||
t.Error("invalid signature should fail verification")
|
t.Error("invalid signature should fail verification")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) {
|
t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) {
|
||||||
cfgEmpty := config.WeComAppConfig{
|
cfgEmpty := config.WeComAppConfig{}
|
||||||
CorpID: "test_corp_id",
|
cfgEmpty.CorpID = "test_corp_id"
|
||||||
CorpSecret: "test_secret",
|
cfgEmpty.SetCorpSecret("test_secret")
|
||||||
AgentID: 1000002,
|
cfgEmpty.AgentID = 1000002
|
||||||
Token: "",
|
cfgEmpty.SetToken("")
|
||||||
}
|
|
||||||
chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)
|
chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)
|
||||||
|
|
||||||
if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
|
if verifySignature(chEmpty.config.Token(), "any_sig", "any_ts", "any_nonce", "any_msg") {
|
||||||
t.Error("empty token should reject verification (fail-closed)")
|
t.Error("empty token should reject verification (fail-closed)")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -228,19 +225,18 @@ func TestWeComAppDecryptMessage(t *testing.T) {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
|
|
||||||
t.Run("decrypt without AES key", func(t *testing.T) {
|
t.Run("decrypt without AES key", func(t *testing.T) {
|
||||||
cfg := config.WeComAppConfig{
|
cfg := config.WeComAppConfig{}
|
||||||
CorpID: "test_corp_id",
|
cfg.CorpID = "test_corp_id"
|
||||||
CorpSecret: "test_secret",
|
cfg.SetCorpSecret("test_secret")
|
||||||
AgentID: 1000002,
|
cfg.AgentID = 1000002
|
||||||
EncodingAESKey: "",
|
cfg.SetEncodingAESKey("")
|
||||||
}
|
|
||||||
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
||||||
|
|
||||||
// Without AES key, message should be base64 decoded only
|
// Without AES key, message should be base64 decoded only
|
||||||
plainText := "hello world"
|
plainText := "hello world"
|
||||||
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
|
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
|
||||||
|
|
||||||
result, err := decryptMessage(encoded, ch.config.EncodingAESKey)
|
result, err := decryptMessage(encoded, ch.config.EncodingAESKey())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -252,11 +248,11 @@ func TestWeComAppDecryptMessage(t *testing.T) {
|
||||||
t.Run("decrypt with AES key", func(t *testing.T) {
|
t.Run("decrypt with AES key", func(t *testing.T) {
|
||||||
aesKey := generateTestAESKeyApp()
|
aesKey := generateTestAESKeyApp()
|
||||||
cfg := config.WeComAppConfig{
|
cfg := config.WeComAppConfig{
|
||||||
CorpID: "test_corp_id",
|
CorpID: "test_corp_id",
|
||||||
CorpSecret: "test_secret",
|
AgentID: 1000002,
|
||||||
AgentID: 1000002,
|
|
||||||
EncodingAESKey: aesKey,
|
|
||||||
}
|
}
|
||||||
|
cfg.SetCorpSecret("test_secret")
|
||||||
|
cfg.SetEncodingAESKey(aesKey)
|
||||||
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
||||||
|
|
||||||
originalMsg := "<xml><Content>Hello</Content></xml>"
|
originalMsg := "<xml><Content>Hello</Content></xml>"
|
||||||
|
|
@ -265,7 +261,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
|
||||||
t.Fatalf("failed to encrypt test message: %v", err)
|
t.Fatalf("failed to encrypt test message: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := decryptMessage(encrypted, ch.config.EncodingAESKey)
|
result, err := decryptMessage(encrypted, ch.config.EncodingAESKey())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -276,29 +272,28 @@ func TestWeComAppDecryptMessage(t *testing.T) {
|
||||||
|
|
||||||
t.Run("invalid base64", func(t *testing.T) {
|
t.Run("invalid base64", func(t *testing.T) {
|
||||||
cfg := config.WeComAppConfig{
|
cfg := config.WeComAppConfig{
|
||||||
CorpID: "test_corp_id",
|
CorpID: "test_corp_id",
|
||||||
CorpSecret: "test_secret",
|
AgentID: 1000002,
|
||||||
AgentID: 1000002,
|
|
||||||
EncodingAESKey: "",
|
|
||||||
}
|
}
|
||||||
|
cfg.SetCorpSecret("test_secret")
|
||||||
|
cfg.SetEncodingAESKey("")
|
||||||
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
||||||
|
|
||||||
_, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
|
_, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for invalid base64, got nil")
|
t.Error("expected error for invalid base64, got nil")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("invalid AES key", func(t *testing.T) {
|
t.Run("invalid AES key", func(t *testing.T) {
|
||||||
cfg := config.WeComAppConfig{
|
cfg := config.WeComAppConfig{}
|
||||||
CorpID: "test_corp_id",
|
cfg.CorpID = "test_corp_id"
|
||||||
CorpSecret: "test_secret",
|
cfg.SetCorpSecret("test_secret")
|
||||||
AgentID: 1000002,
|
cfg.AgentID = 1000002
|
||||||
EncodingAESKey: "invalid_key",
|
cfg.SetEncodingAESKey("invalid_key")
|
||||||
}
|
|
||||||
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
||||||
|
|
||||||
_, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
|
_, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for invalid AES key, got nil")
|
t.Error("expected error for invalid AES key, got nil")
|
||||||
}
|
}
|
||||||
|
|
@ -306,17 +301,16 @@ func TestWeComAppDecryptMessage(t *testing.T) {
|
||||||
|
|
||||||
t.Run("ciphertext too short", func(t *testing.T) {
|
t.Run("ciphertext too short", func(t *testing.T) {
|
||||||
aesKey := generateTestAESKeyApp()
|
aesKey := generateTestAESKeyApp()
|
||||||
cfg := config.WeComAppConfig{
|
cfg := config.WeComAppConfig{}
|
||||||
CorpID: "test_corp_id",
|
cfg.CorpID = "test_corp_id"
|
||||||
CorpSecret: "test_secret",
|
cfg.SetCorpSecret("test_secret")
|
||||||
AgentID: 1000002,
|
cfg.AgentID = 1000002
|
||||||
EncodingAESKey: aesKey,
|
cfg.SetEncodingAESKey(aesKey)
|
||||||
}
|
|
||||||
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
||||||
|
|
||||||
// Encrypt a very short message that results in ciphertext less than block size
|
// Encrypt a very short message that results in ciphertext less than block size
|
||||||
shortData := make([]byte, 8)
|
shortData := make([]byte, 8)
|
||||||
_, err := decryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey)
|
_, err := decryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for short ciphertext, got nil")
|
t.Error("expected error for short ciphertext, got nil")
|
||||||
}
|
}
|
||||||
|
|
@ -326,13 +320,12 @@ func TestWeComAppDecryptMessage(t *testing.T) {
|
||||||
func TestWeComAppHandleVerification(t *testing.T) {
|
func TestWeComAppHandleVerification(t *testing.T) {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
aesKey := generateTestAESKeyApp()
|
aesKey := generateTestAESKeyApp()
|
||||||
cfg := config.WeComAppConfig{
|
cfg := config.WeComAppConfig{}
|
||||||
CorpID: "test_corp_id",
|
cfg.CorpID = "test_corp_id"
|
||||||
CorpSecret: "test_secret",
|
cfg.SetCorpSecret("test_secret")
|
||||||
AgentID: 1000002,
|
cfg.AgentID = 1000002
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
EncodingAESKey: aesKey,
|
cfg.SetEncodingAESKey(aesKey)
|
||||||
}
|
|
||||||
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
||||||
|
|
||||||
t.Run("valid verification request", func(t *testing.T) {
|
t.Run("valid verification request", func(t *testing.T) {
|
||||||
|
|
@ -394,13 +387,12 @@ func TestWeComAppHandleVerification(t *testing.T) {
|
||||||
func TestWeComAppHandleMessageCallback(t *testing.T) {
|
func TestWeComAppHandleMessageCallback(t *testing.T) {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
aesKey := generateTestAESKeyApp()
|
aesKey := generateTestAESKeyApp()
|
||||||
cfg := config.WeComAppConfig{
|
cfg := config.WeComAppConfig{}
|
||||||
CorpID: "test_corp_id",
|
cfg.CorpID = "test_corp_id"
|
||||||
CorpSecret: "test_secret",
|
cfg.SetCorpSecret("test_secret")
|
||||||
AgentID: 1000002,
|
cfg.AgentID = 1000002
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
EncodingAESKey: aesKey,
|
cfg.SetEncodingAESKey(aesKey)
|
||||||
}
|
|
||||||
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
||||||
|
|
||||||
t.Run("valid message callback", func(t *testing.T) {
|
t.Run("valid message callback", func(t *testing.T) {
|
||||||
|
|
@ -509,10 +501,10 @@ func TestWeComAppHandleMessageCallback(t *testing.T) {
|
||||||
func TestWeComAppProcessMessage(t *testing.T) {
|
func TestWeComAppProcessMessage(t *testing.T) {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
cfg := config.WeComAppConfig{
|
cfg := config.WeComAppConfig{
|
||||||
CorpID: "test_corp_id",
|
CorpID: "test_corp_id",
|
||||||
CorpSecret: "test_secret",
|
AgentID: 1000002,
|
||||||
AgentID: 1000002,
|
|
||||||
}
|
}
|
||||||
|
cfg.SetCorpSecret("test_secret")
|
||||||
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
||||||
|
|
||||||
t.Run("process text message", func(t *testing.T) {
|
t.Run("process text message", func(t *testing.T) {
|
||||||
|
|
@ -594,12 +586,11 @@ func TestWeComAppProcessMessage(t *testing.T) {
|
||||||
|
|
||||||
func TestWeComAppHandleWebhook(t *testing.T) {
|
func TestWeComAppHandleWebhook(t *testing.T) {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
cfg := config.WeComAppConfig{
|
cfg := config.WeComAppConfig{}
|
||||||
CorpID: "test_corp_id",
|
cfg.CorpID = "test_corp_id"
|
||||||
CorpSecret: "test_secret",
|
cfg.SetCorpSecret("test_secret")
|
||||||
AgentID: 1000002,
|
cfg.AgentID = 1000002
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
}
|
|
||||||
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
||||||
|
|
||||||
t.Run("GET request calls verification", func(t *testing.T) {
|
t.Run("GET request calls verification", func(t *testing.T) {
|
||||||
|
|
@ -666,10 +657,10 @@ func TestWeComAppHandleWebhook(t *testing.T) {
|
||||||
func TestWeComAppHandleHealth(t *testing.T) {
|
func TestWeComAppHandleHealth(t *testing.T) {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
cfg := config.WeComAppConfig{
|
cfg := config.WeComAppConfig{
|
||||||
CorpID: "test_corp_id",
|
CorpID: "test_corp_id",
|
||||||
CorpSecret: "test_secret",
|
AgentID: 1000002,
|
||||||
AgentID: 1000002,
|
|
||||||
}
|
}
|
||||||
|
cfg.SetCorpSecret("test_secret")
|
||||||
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/health/wecom-app", nil)
|
req := httptest.NewRequest(http.MethodGet, "/health/wecom-app", nil)
|
||||||
|
|
@ -695,10 +686,10 @@ func TestWeComAppHandleHealth(t *testing.T) {
|
||||||
func TestWeComAppAccessToken(t *testing.T) {
|
func TestWeComAppAccessToken(t *testing.T) {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
cfg := config.WeComAppConfig{
|
cfg := config.WeComAppConfig{
|
||||||
CorpID: "test_corp_id",
|
CorpID: "test_corp_id",
|
||||||
CorpSecret: "test_secret",
|
AgentID: 1000002,
|
||||||
AgentID: 1000002,
|
|
||||||
}
|
}
|
||||||
|
cfg.SetCorpSecret("test_secret")
|
||||||
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
||||||
|
|
||||||
t.Run("get empty access token initially", func(t *testing.T) {
|
t.Run("get empty access token initially", func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ type WeComBotReplyMessage struct {
|
||||||
|
|
||||||
// NewWeComBotChannel creates a new WeCom Bot channel instance
|
// NewWeComBotChannel creates a new WeCom Bot channel instance
|
||||||
func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComBotChannel, error) {
|
func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComBotChannel, error) {
|
||||||
if cfg.Token == "" || cfg.WebhookURL == "" {
|
if cfg.Token() == "" || cfg.WebhookURL == "" {
|
||||||
return nil, fmt.Errorf("wecom token and webhook_url are required")
|
return nil, fmt.Errorf("wecom token and webhook_url are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -216,7 +216,7 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify signature
|
// Verify signature
|
||||||
if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
|
if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, echostr) {
|
||||||
logger.WarnC("wecom", "Signature verification failed")
|
logger.WarnC("wecom", "Signature verification failed")
|
||||||
http.Error(w, "Invalid signature", http.StatusForbidden)
|
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
|
|
@ -225,7 +225,7 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons
|
||||||
// Decrypt echostr
|
// Decrypt echostr
|
||||||
// For AIBOT (智能机器人), receiveid should be empty string ""
|
// For AIBOT (智能机器人), receiveid should be empty string ""
|
||||||
// Reference: https://developer.work.weixin.qq.com/document/path/101033
|
// Reference: https://developer.work.weixin.qq.com/document/path/101033
|
||||||
decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
|
decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey(), "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{
|
logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -278,7 +278,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify signature
|
// Verify signature
|
||||||
if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
|
if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
|
||||||
logger.WarnC("wecom", "Message signature verification failed")
|
logger.WarnC("wecom", "Message signature verification failed")
|
||||||
http.Error(w, "Invalid signature", http.StatusForbidden)
|
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
|
|
@ -287,7 +287,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
||||||
// Decrypt message
|
// Decrypt message
|
||||||
// For AIBOT (智能机器人), receiveid should be empty string ""
|
// For AIBOT (智能机器人), receiveid should be empty string ""
|
||||||
// Reference: https://developer.work.weixin.qq.com/document/path/101033
|
// Reference: https://developer.work.weixin.qq.com/document/path/101033
|
||||||
decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
|
decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey(), "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{
|
logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
|
||||||
|
|
@ -89,10 +89,9 @@ func TestNewWeComBotChannel(t *testing.T) {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
|
|
||||||
t.Run("missing token", func(t *testing.T) {
|
t.Run("missing token", func(t *testing.T) {
|
||||||
cfg := config.WeComConfig{
|
cfg := config.WeComConfig{}
|
||||||
Token: "",
|
cfg.SetToken("")
|
||||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||||
}
|
|
||||||
_, err := NewWeComBotChannel(cfg, msgBus)
|
_, err := NewWeComBotChannel(cfg, msgBus)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for missing token, got nil")
|
t.Error("expected error for missing token, got nil")
|
||||||
|
|
@ -100,10 +99,9 @@ func TestNewWeComBotChannel(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("missing webhook_url", func(t *testing.T) {
|
t.Run("missing webhook_url", func(t *testing.T) {
|
||||||
cfg := config.WeComConfig{
|
cfg := config.WeComConfig{}
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
WebhookURL: "",
|
cfg.WebhookURL = ""
|
||||||
}
|
|
||||||
_, err := NewWeComBotChannel(cfg, msgBus)
|
_, err := NewWeComBotChannel(cfg, msgBus)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for missing webhook_url, got nil")
|
t.Error("expected error for missing webhook_url, got nil")
|
||||||
|
|
@ -111,11 +109,10 @@ func TestNewWeComBotChannel(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("valid config", func(t *testing.T) {
|
t.Run("valid config", func(t *testing.T) {
|
||||||
cfg := config.WeComConfig{
|
cfg := config.WeComConfig{}
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||||
AllowFrom: []string{"user1", "user2"},
|
cfg.AllowFrom = []string{"user1", "user2"}
|
||||||
}
|
|
||||||
ch, err := NewWeComBotChannel(cfg, msgBus)
|
ch, err := NewWeComBotChannel(cfg, msgBus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
|
@ -133,11 +130,10 @@ func TestWeComBotChannelIsAllowed(t *testing.T) {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
|
|
||||||
t.Run("empty allowlist allows all", func(t *testing.T) {
|
t.Run("empty allowlist allows all", func(t *testing.T) {
|
||||||
cfg := config.WeComConfig{
|
cfg := config.WeComConfig{}
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||||
AllowFrom: []string{},
|
cfg.AllowFrom = []string{}
|
||||||
}
|
|
||||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
if !ch.IsAllowed("any_user") {
|
if !ch.IsAllowed("any_user") {
|
||||||
t.Error("empty allowlist should allow all users")
|
t.Error("empty allowlist should allow all users")
|
||||||
|
|
@ -145,11 +141,10 @@ func TestWeComBotChannelIsAllowed(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("allowlist restricts users", func(t *testing.T) {
|
t.Run("allowlist restricts users", func(t *testing.T) {
|
||||||
cfg := config.WeComConfig{
|
cfg := config.WeComConfig{}
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||||
AllowFrom: []string{"allowed_user"},
|
cfg.AllowFrom = []string{"allowed_user"}
|
||||||
}
|
|
||||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
if !ch.IsAllowed("allowed_user") {
|
if !ch.IsAllowed("allowed_user") {
|
||||||
t.Error("allowed user should pass allowlist check")
|
t.Error("allowed user should pass allowlist check")
|
||||||
|
|
@ -162,10 +157,9 @@ func TestWeComBotChannelIsAllowed(t *testing.T) {
|
||||||
|
|
||||||
func TestWeComBotVerifySignature(t *testing.T) {
|
func TestWeComBotVerifySignature(t *testing.T) {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
cfg := config.WeComConfig{
|
cfg := config.WeComConfig{}
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||||
}
|
|
||||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
t.Run("valid signature", func(t *testing.T) {
|
t.Run("valid signature", func(t *testing.T) {
|
||||||
|
|
@ -174,7 +168,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
|
||||||
msgEncrypt := "test_message"
|
msgEncrypt := "test_message"
|
||||||
expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt)
|
expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt)
|
||||||
|
|
||||||
if !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
|
if !verifySignature(ch.config.Token(), expectedSig, timestamp, nonce, msgEncrypt) {
|
||||||
t.Error("valid signature should pass verification")
|
t.Error("valid signature should pass verification")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -184,21 +178,20 @@ func TestWeComBotVerifySignature(t *testing.T) {
|
||||||
nonce := "test_nonce"
|
nonce := "test_nonce"
|
||||||
msgEncrypt := "test_message"
|
msgEncrypt := "test_message"
|
||||||
|
|
||||||
if verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
|
if verifySignature(ch.config.Token(), "invalid_sig", timestamp, nonce, msgEncrypt) {
|
||||||
t.Error("invalid signature should fail verification")
|
t.Error("invalid signature should fail verification")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) {
|
t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) {
|
||||||
cfgEmpty := config.WeComConfig{
|
cfgEmpty := config.WeComConfig{}
|
||||||
Token: "",
|
cfgEmpty.SetToken("")
|
||||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
cfgEmpty.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||||
}
|
|
||||||
chEmpty := &WeComBotChannel{
|
chEmpty := &WeComBotChannel{
|
||||||
config: cfgEmpty,
|
config: cfgEmpty,
|
||||||
}
|
}
|
||||||
|
|
||||||
if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
|
if verifySignature(chEmpty.config.Token(), "any_sig", "any_ts", "any_nonce", "any_msg") {
|
||||||
t.Error("empty token should reject verification (fail-closed)")
|
t.Error("empty token should reject verification (fail-closed)")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -208,18 +201,17 @@ func TestWeComBotDecryptMessage(t *testing.T) {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
|
|
||||||
t.Run("decrypt without AES key", func(t *testing.T) {
|
t.Run("decrypt without AES key", func(t *testing.T) {
|
||||||
cfg := config.WeComConfig{
|
cfg := config.WeComConfig{}
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||||
EncodingAESKey: "",
|
cfg.SetEncodingAESKey("")
|
||||||
}
|
|
||||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
// Without AES key, message should be base64 decoded only
|
// Without AES key, message should be base64 decoded only
|
||||||
plainText := "hello world"
|
plainText := "hello world"
|
||||||
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
|
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
|
||||||
|
|
||||||
result, err := decryptMessage(encoded, ch.config.EncodingAESKey)
|
result, err := decryptMessage(encoded, ch.config.EncodingAESKey())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -230,11 +222,10 @@ func TestWeComBotDecryptMessage(t *testing.T) {
|
||||||
|
|
||||||
t.Run("decrypt with AES key", func(t *testing.T) {
|
t.Run("decrypt with AES key", func(t *testing.T) {
|
||||||
aesKey := generateTestAESKey()
|
aesKey := generateTestAESKey()
|
||||||
cfg := config.WeComConfig{
|
cfg := config.WeComConfig{}
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||||
EncodingAESKey: aesKey,
|
cfg.SetEncodingAESKey(aesKey)
|
||||||
}
|
|
||||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
originalMsg := "<xml><Content>Hello</Content></xml>"
|
originalMsg := "<xml><Content>Hello</Content></xml>"
|
||||||
|
|
@ -243,7 +234,7 @@ func TestWeComBotDecryptMessage(t *testing.T) {
|
||||||
t.Fatalf("failed to encrypt test message: %v", err)
|
t.Fatalf("failed to encrypt test message: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := decryptMessage(encrypted, ch.config.EncodingAESKey)
|
result, err := decryptMessage(encrypted, ch.config.EncodingAESKey())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -253,28 +244,26 @@ func TestWeComBotDecryptMessage(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("invalid base64", func(t *testing.T) {
|
t.Run("invalid base64", func(t *testing.T) {
|
||||||
cfg := config.WeComConfig{
|
cfg := config.WeComConfig{}
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||||
EncodingAESKey: "",
|
cfg.SetEncodingAESKey("")
|
||||||
}
|
|
||||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
_, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
|
_, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for invalid base64, got nil")
|
t.Error("expected error for invalid base64, got nil")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("invalid AES key", func(t *testing.T) {
|
t.Run("invalid AES key", func(t *testing.T) {
|
||||||
cfg := config.WeComConfig{
|
cfg := config.WeComConfig{}
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||||
EncodingAESKey: "invalid_key",
|
cfg.SetEncodingAESKey("invalid_key")
|
||||||
}
|
|
||||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
_, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
|
_, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for invalid AES key, got nil")
|
t.Error("expected error for invalid AES key, got nil")
|
||||||
}
|
}
|
||||||
|
|
@ -338,11 +327,10 @@ func TestWeComBotPKCS7Unpad(t *testing.T) {
|
||||||
func TestWeComBotHandleVerification(t *testing.T) {
|
func TestWeComBotHandleVerification(t *testing.T) {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
aesKey := generateTestAESKey()
|
aesKey := generateTestAESKey()
|
||||||
cfg := config.WeComConfig{
|
cfg := config.WeComConfig{}
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
EncodingAESKey: aesKey,
|
cfg.SetEncodingAESKey(aesKey)
|
||||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||||
}
|
|
||||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
t.Run("valid verification request", func(t *testing.T) {
|
t.Run("valid verification request", func(t *testing.T) {
|
||||||
|
|
@ -404,11 +392,10 @@ func TestWeComBotHandleVerification(t *testing.T) {
|
||||||
func TestWeComBotHandleMessageCallback(t *testing.T) {
|
func TestWeComBotHandleMessageCallback(t *testing.T) {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
aesKey := generateTestAESKey()
|
aesKey := generateTestAESKey()
|
||||||
cfg := config.WeComConfig{
|
cfg := config.WeComConfig{}
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
EncodingAESKey: aesKey,
|
cfg.SetEncodingAESKey(aesKey)
|
||||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||||
}
|
|
||||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
runBotMessageCallback := func(t *testing.T, jsonMsg string) *httptest.ResponseRecorder {
|
runBotMessageCallback := func(t *testing.T, jsonMsg string) *httptest.ResponseRecorder {
|
||||||
|
|
@ -530,10 +517,9 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
|
||||||
|
|
||||||
func TestWeComBotProcessMessage(t *testing.T) {
|
func TestWeComBotProcessMessage(t *testing.T) {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
cfg := config.WeComConfig{
|
cfg := config.WeComConfig{}
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||||
}
|
|
||||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
t.Run("process direct text message", func(t *testing.T) {
|
t.Run("process direct text message", func(t *testing.T) {
|
||||||
|
|
@ -599,10 +585,9 @@ func TestWeComBotProcessMessage(t *testing.T) {
|
||||||
|
|
||||||
func TestWeComBotHandleWebhook(t *testing.T) {
|
func TestWeComBotHandleWebhook(t *testing.T) {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
cfg := config.WeComConfig{
|
cfg := config.WeComConfig{}
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||||
}
|
|
||||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
t.Run("GET request calls verification", func(t *testing.T) {
|
t.Run("GET request calls verification", func(t *testing.T) {
|
||||||
|
|
@ -668,10 +653,9 @@ func TestWeComBotHandleWebhook(t *testing.T) {
|
||||||
|
|
||||||
func TestWeComBotHandleHealth(t *testing.T) {
|
func TestWeComBotHandleHealth(t *testing.T) {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
cfg := config.WeComConfig{
|
cfg := config.WeComConfig{}
|
||||||
Token: "test_token",
|
cfg.SetToken("test_token")
|
||||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||||
}
|
|
||||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/health/wecom", nil)
|
req := httptest.NewRequest(http.MethodGet, "/health/wecom", nil)
|
||||||
|
|
|
||||||
225
pkg/config/REFACTORING_SUMMARY.md
Normal file
225
pkg/config/REFACTORING_SUMMARY.md
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
# Security Configuration Refactoring Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Successfully refactored `pkg/config/config.go` to support a separate `security.yml` file for storing all sensitive data (API keys, tokens, secrets, passwords).
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### New Files Created
|
||||||
|
|
||||||
|
1. **`pkg/config/security.go`** (New file)
|
||||||
|
- Defines `SecurityConfig` structure for all sensitive data
|
||||||
|
- Implements `LoadSecurityConfig()` to load from YAML
|
||||||
|
- Implements `SaveSecurityConfig()` to save with secure permissions (0o600)
|
||||||
|
- Implements `ResolveReference()` to resolve `ref:` prefixed strings
|
||||||
|
- Supports all model, channel, web tool, and skills security entries
|
||||||
|
|
||||||
|
2. **`pkg/config/security_test.go`** (New file)
|
||||||
|
- Comprehensive unit tests for security config loading
|
||||||
|
- Tests for reference resolution (models, channels, web tools, skills)
|
||||||
|
- Tests for file I/O operations
|
||||||
|
|
||||||
|
3. **`pkg/config/security_integration_test.go`** (New file)
|
||||||
|
- Integration tests for full workflow
|
||||||
|
- Tests backward compatibility with direct values
|
||||||
|
- Tests mixed usage of references and direct values
|
||||||
|
- Tests error handling for invalid references
|
||||||
|
|
||||||
|
4. **`security.example.yml`** (New file)
|
||||||
|
- Template for users to copy and fill in
|
||||||
|
- Includes all possible security entries with placeholder values
|
||||||
|
- Located at project root
|
||||||
|
|
||||||
|
5. **`pkg/config/SECURITY_CONFIG.md`** (New file)
|
||||||
|
- Complete documentation for the security config feature
|
||||||
|
- Usage examples and reference format guide
|
||||||
|
- Migration guide from old config
|
||||||
|
- Security best practices
|
||||||
|
|
||||||
|
6. **`pkg/config/example_security_usage.go`** (New file)
|
||||||
|
- Practical examples in Go comment format
|
||||||
|
- Shows complete workflow from creation to usage
|
||||||
|
- Lists all available reference paths
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
|
||||||
|
1. **`pkg/config/config.go`**
|
||||||
|
- Added `applySecurityConfig()` function to resolve all `ref:` references
|
||||||
|
- Modified `LoadConfig()` to:
|
||||||
|
- Load security config from `security.yml`
|
||||||
|
- Apply security references to all config fields
|
||||||
|
- Maintain backward compatibility with direct values
|
||||||
|
- Updated warning message to suggest using `security.yml`
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### Reference Format
|
||||||
|
|
||||||
|
Uses dot notation for referencing values:
|
||||||
|
- Models: `ref:model_list.<model_name>.api_key`
|
||||||
|
- Channels: `ref:channels.<channel_name>.<field>`
|
||||||
|
- Web Tools: `ref:web.<provider>.<field>`
|
||||||
|
- Skills: `ref:skills.<registry>.<field>`
|
||||||
|
|
||||||
|
### Supported Security Entries
|
||||||
|
|
||||||
|
**Models:**
|
||||||
|
- API keys for all model configurations
|
||||||
|
|
||||||
|
**Channels:**
|
||||||
|
- Telegram: token
|
||||||
|
- Feishu: app_secret, encrypt_key, verification_token
|
||||||
|
- Discord: token
|
||||||
|
- QQ: app_secret
|
||||||
|
- DingTalk: client_secret
|
||||||
|
- Slack: bot_token, app_token
|
||||||
|
- Matrix: access_token
|
||||||
|
- LINE: channel_secret, channel_access_token
|
||||||
|
- OneBot: access_token
|
||||||
|
- WeCom: token, encoding_aes_key
|
||||||
|
- WeComApp: corp_secret, token, encoding_aes_key
|
||||||
|
- WeComAIBot: token, encoding_aes_key
|
||||||
|
- Pico: token
|
||||||
|
- IRC: password, nickserv_password, sasl_password
|
||||||
|
|
||||||
|
**Web Tools:**
|
||||||
|
- Brave: api_key
|
||||||
|
- Tavily: api_key
|
||||||
|
- Perplexity: api_key
|
||||||
|
- GLMSearch: api_key
|
||||||
|
|
||||||
|
**Skills:**
|
||||||
|
- GitHub: token
|
||||||
|
- ClawHub: auth_token
|
||||||
|
|
||||||
|
### Backward Compatibility
|
||||||
|
|
||||||
|
- Direct values in `config.json` still work
|
||||||
|
- Mixed usage of references and direct values is supported
|
||||||
|
- Optional security file (if missing, only references fail)
|
||||||
|
- No breaking changes to existing configurations
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
All tests pass successfully:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./pkg/config -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Test coverage includes:
|
||||||
|
- ✅ Unit tests for reference resolution
|
||||||
|
- ✅ Integration tests for full workflow
|
||||||
|
- ✅ Backward compatibility tests
|
||||||
|
- ✅ Error handling tests
|
||||||
|
- ✅ File I/O and permission tests
|
||||||
|
- ✅ All existing config tests still pass
|
||||||
|
|
||||||
|
## Usage Example
|
||||||
|
|
||||||
|
### config.json
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_base": "https://api.openai.com/v1",
|
||||||
|
"api_key": "ref:model_list.gpt-5.4.api_key"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"channels": {
|
||||||
|
"telegram": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "ref:channels.telegram.token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### security.yml
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
gpt-5.4:
|
||||||
|
api_key: "sk-proj-actual-key-here"
|
||||||
|
|
||||||
|
channels:
|
||||||
|
telegram:
|
||||||
|
token: "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
|
||||||
|
1. Copy `security.example.yml` to `~/.picoclaw/security.yml`
|
||||||
|
2. Fill in actual API keys and tokens
|
||||||
|
3. Update `config.json` to use `ref:` references
|
||||||
|
4. Set proper permissions: `chmod 600 ~/.picoclaw/security.yml`
|
||||||
|
5. Test with `picoclaw --version`
|
||||||
|
|
||||||
|
## Security Benefits
|
||||||
|
|
||||||
|
1. **Separation of concerns**: Configuration and secrets are in separate files
|
||||||
|
2. **Easier sharing**: Config can be shared without exposing secrets
|
||||||
|
3. **Better version control**: `security.yml` can be added to `.gitignore`
|
||||||
|
4. **Flexible deployment**: Different environments can use different security files
|
||||||
|
5. **Secure file permissions**: Saved with `0o600` by default
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### File Loading Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
LoadConfig()
|
||||||
|
├─ Load config.json
|
||||||
|
├─ Detect version
|
||||||
|
├─ Parse config based on version
|
||||||
|
├─ Load security.yml (optional)
|
||||||
|
├─ Apply security references
|
||||||
|
│ └─ Resolve all "ref:" prefixes
|
||||||
|
├─ Parse environment variables
|
||||||
|
├─ Resolve API keys (file://, enc://)
|
||||||
|
├─ Expand multi-key models
|
||||||
|
└─ Validate and return
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reference Resolution
|
||||||
|
|
||||||
|
The `ResolveReference()` function:
|
||||||
|
1. Checks if string starts with `ref:`
|
||||||
|
2. Parses the dot-notation path
|
||||||
|
3. Navigates the security config structure
|
||||||
|
4. Returns the actual value
|
||||||
|
5. Returns error if path doesn't exist
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Clear error messages with full context
|
||||||
|
- Includes the reference path and field name
|
||||||
|
- Fails early on invalid references
|
||||||
|
- Maintains backward compatibility
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
Added dependency: `gopkg.in/yaml.v3` for YAML parsing
|
||||||
|
|
||||||
|
## Files Modified Summary
|
||||||
|
|
||||||
|
- **Created**: 6 new files (security.go, tests, docs, examples)
|
||||||
|
- **Modified**: 1 file (config.go - added security integration)
|
||||||
|
- **Lines added**: ~1000+ lines (including tests and documentation)
|
||||||
|
- **Backward compatible**: ✅ Yes
|
||||||
|
- **Breaking changes**: ❌ None
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Update main README to mention security.yml
|
||||||
|
2. Add security.yml to .gitignore
|
||||||
|
3. Update documentation with security config examples
|
||||||
|
4. Consider adding migration tool for existing users
|
||||||
|
5. Add validation for security.yml schema
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The refactoring successfully implements a secure, flexible, and backward-compatible way to manage sensitive configuration data. All tests pass and the feature is ready for use.
|
||||||
551
pkg/config/SECURITY_CONFIG.md
Normal file
551
pkg/config/SECURITY_CONFIG.md
Normal file
|
|
@ -0,0 +1,551 @@
|
||||||
|
# Security Configuration Refactoring
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This refactoring introduces a `security.yml` file to store all sensitive data (API keys, tokens, secrets, passwords) separately from the main configuration. This improves security by:
|
||||||
|
|
||||||
|
1. **Separation of concerns**: Configuration settings and secrets are in separate files
|
||||||
|
2. **Easier sharing**: The main config can be shared without exposing sensitive data
|
||||||
|
3. **Better version control**: `security.yml` can be added to `.gitignore`
|
||||||
|
4. **Flexible deployment**: Different environments can use different security files
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.picoclaw/
|
||||||
|
├── config.json # Main configuration (safe to share)
|
||||||
|
└── security.yml # Security data (never share)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Configuration
|
||||||
|
|
||||||
|
In your `config.json`, use `ref:` references to point to values in `security.yml`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_base": "https://api.openai.com/v1",
|
||||||
|
"api_key": "ref:model_list.gpt-5.4.api_key"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"channels": {
|
||||||
|
"telegram": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "ref:channels.telegram.token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Configuration
|
||||||
|
|
||||||
|
In your `security.yml`, store the actual values:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
gpt-5.4:
|
||||||
|
api_keys:
|
||||||
|
- "sk-your-actual-api-key-1"
|
||||||
|
- "sk-your-actual-api-key-2" # Optional: Multiple keys for failover
|
||||||
|
claude-sonnet-4.6:
|
||||||
|
api_keys:
|
||||||
|
- "sk-your-actual-anthropic-key" # Single key in array format
|
||||||
|
|
||||||
|
channels:
|
||||||
|
telegram:
|
||||||
|
token: "your-telegram-bot-token"
|
||||||
|
|
||||||
|
web:
|
||||||
|
brave:
|
||||||
|
api_keys:
|
||||||
|
- "BSAyour-brave-api-key-1"
|
||||||
|
- "BSAyour-brave-api-key-2" # Optional: Multiple keys for failover
|
||||||
|
tavily:
|
||||||
|
api_keys:
|
||||||
|
- "tvly-your-tavily-api-key" # Single key in array format
|
||||||
|
glm_search:
|
||||||
|
api_key: "your-glm-search-api-key" # GLMSearch uses single key format
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference Format
|
||||||
|
|
||||||
|
### Model API Keys
|
||||||
|
|
||||||
|
Format: `ref:model_list.<model_name>.api_key`
|
||||||
|
|
||||||
|
Example: `ref:model_list.gpt-5.4.api_key`
|
||||||
|
|
||||||
|
### Channel Tokens/Secrets
|
||||||
|
|
||||||
|
Format: `ref:channels.<channel_name>.<field>`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `ref:channels.telegram.token`
|
||||||
|
- `ref:channels.feishu.app_secret`
|
||||||
|
- `ref:channels.feishu.encrypt_key`
|
||||||
|
- `ref:channels.feishu.verification_token`
|
||||||
|
- `ref:channels.discord.token`
|
||||||
|
- `ref:channels.qq.app_secret`
|
||||||
|
- `ref:channels.dingtalk.client_secret`
|
||||||
|
- `ref:channels.slack.bot_token`
|
||||||
|
- `ref:channels.slack.app_token`
|
||||||
|
- `ref:channels.matrix.access_token`
|
||||||
|
- `ref:channels.line.channel_secret`
|
||||||
|
- `ref:channels.line.channel_access_token`
|
||||||
|
- `ref:channels.onebot.access_token`
|
||||||
|
- `ref:channels.wecom.token`
|
||||||
|
- `ref:channels.wecom.encoding_aes_key`
|
||||||
|
- `ref:channels.wecom_app.corp_secret`
|
||||||
|
- `ref:channels.wecom_app.token`
|
||||||
|
- `ref:channels.wecom_app.encoding_aes_key`
|
||||||
|
- `ref:channels.wecom_aibot.token`
|
||||||
|
- `ref:channels.wecom_aibot.encoding_aes_key`
|
||||||
|
- `ref:channels.pico.token`
|
||||||
|
- `ref:channels.irc.password`
|
||||||
|
- `ref:channels.irc.nickserv_password`
|
||||||
|
- `ref:channels.irc.sasl_password`
|
||||||
|
|
||||||
|
### Web Tool API Keys
|
||||||
|
|
||||||
|
Format: `ref:web.<provider>.<field>`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `ref:web.brave.api_key`
|
||||||
|
- `ref:web.tavily.api_key`
|
||||||
|
- `ref:web.perplexity.api_key`
|
||||||
|
- `ref:web.glm_search.api_key`
|
||||||
|
|
||||||
|
### Skills Registry Tokens
|
||||||
|
|
||||||
|
Format: `ref:skills.<registry>.<field>`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `ref:skills.github.token`
|
||||||
|
- `ref:skills.clawhub.auth_token`
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
The refactoring maintains full backward compatibility:
|
||||||
|
|
||||||
|
1. **Direct values**: You can still use direct values in `config.json` (not recommended for production)
|
||||||
|
2. **Mixed usage**: You can mix `ref:` references and direct values
|
||||||
|
3. **Optional security file**: If `security.yml` doesn't exist, all references will fail (but direct values still work)
|
||||||
|
|
||||||
|
### API Key Formats in security.yml
|
||||||
|
|
||||||
|
**Models (gpt-5.4, claude-sonnet-4.6, etc.):**
|
||||||
|
- Must use `api_keys` (array) format
|
||||||
|
- Both single and multiple keys use array format
|
||||||
|
|
||||||
|
**Web Tools (Brave, Tavily, Perplexity):**
|
||||||
|
- Must use `api_keys` (array) format
|
||||||
|
- Both single and multiple keys use array format
|
||||||
|
|
||||||
|
**Web Tools (GLMSearch):**
|
||||||
|
- Must use `api_key` (single string) format
|
||||||
|
- Does NOT support array format
|
||||||
|
|
||||||
|
**Channels (Telegram, Discord, etc.):**
|
||||||
|
- Use single field names (e.g., `token`, `app_secret`)
|
||||||
|
- Each channel uses its specific field names
|
||||||
|
|
||||||
|
### Single Key (Models)
|
||||||
|
|
||||||
|
Use array format with one element:
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
gpt-5.4:
|
||||||
|
api_keys:
|
||||||
|
- "sk-your-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
In `config.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_key": "ref:model_list.gpt-5.4.api_key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Single Key (GLMSearch)
|
||||||
|
|
||||||
|
Use single string format:
|
||||||
|
```yaml
|
||||||
|
web:
|
||||||
|
glm_search:
|
||||||
|
api_key: "your-glm-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
In `config.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_key": "ref:web.glm_search.api_key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### Step 1: Create security.yml
|
||||||
|
|
||||||
|
Copy the example template:
|
||||||
|
```bash
|
||||||
|
cp security.example.yml ~/.picoclaw/security.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Fill in your actual values
|
||||||
|
|
||||||
|
Edit `~/.picoclaw/security.yml` and replace placeholder values with your actual API keys and tokens.
|
||||||
|
|
||||||
|
### Step 3: Update config.json
|
||||||
|
|
||||||
|
Replace sensitive values in `~/.picoclaw/config.json` with `ref:` references:
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-your-actual-api-key-here"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "ref:model_list.gpt-5.4.api_key"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Verify
|
||||||
|
|
||||||
|
Restart PicoClaw and verify it loads correctly:
|
||||||
|
```bash
|
||||||
|
picoclaw --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
1. **Never commit `security.yml`** to version control
|
||||||
|
2. **Set file permissions**: `chmod 600 ~/.picoclaw/security.yml`
|
||||||
|
3. **Use different keys** for different environments (dev, staging, production)
|
||||||
|
4. **Rotate keys regularly** and update `security.yml`
|
||||||
|
5. **Backup securely**: Encrypt backups containing `security.yml`
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### LoadSecurityConfig
|
||||||
|
|
||||||
|
```go
|
||||||
|
func LoadSecurityConfig(securityPath string) (*SecurityConfig, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Loads the security configuration from `security.yml`. Returns an empty `SecurityConfig` if the file doesn't exist.
|
||||||
|
|
||||||
|
### SaveSecurityConfig
|
||||||
|
|
||||||
|
```go
|
||||||
|
func SaveSecurityConfig(securityPath string, sec *SecurityConfig) error
|
||||||
|
```
|
||||||
|
|
||||||
|
Saves the security configuration to `security.yml` with `0o600` permissions.
|
||||||
|
|
||||||
|
### ResolveReference
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (sec *SecurityConfig) ResolveReference(ref string) (string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolves a reference string (e.g., `"ref:model_list.test.api_key"`) and returns the actual value.
|
||||||
|
|
||||||
|
### SecurityPath
|
||||||
|
|
||||||
|
```go
|
||||||
|
func SecurityPath(configPath string) string
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the path to `security.yml` relative to the config file.
|
||||||
|
|
||||||
|
## Example: Complete Configuration
|
||||||
|
|
||||||
|
### config.json
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "~/picoclaw-workspace",
|
||||||
|
"model_name": "gpt-5.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_base": "https://api.openai.com/v1",
|
||||||
|
"api_key": "ref:model_list.gpt-5.4.api_key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
|
"api_base": "https://api.anthropic.com/v1",
|
||||||
|
"api_key": "ref:model_list.claude-sonnet-4.6.api_key"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"channels": {
|
||||||
|
"telegram": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "ref:channels.telegram.token"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"brave": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "ref:web.brave.api_key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### security.yml
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
gpt-5.4:
|
||||||
|
api_keys:
|
||||||
|
- "sk-proj-actual-openai-key-1"
|
||||||
|
- "sk-proj-actual-openai-key-2"
|
||||||
|
claude-sonnet-4.6:
|
||||||
|
api_keys:
|
||||||
|
- "sk-ant-actual-anthropic-key" # Single key in array format
|
||||||
|
|
||||||
|
channels:
|
||||||
|
telegram:
|
||||||
|
token: "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||||
|
|
||||||
|
web:
|
||||||
|
brave:
|
||||||
|
api_keys:
|
||||||
|
- "BSAactualbravekey-1"
|
||||||
|
- "BSAactualbravekey-2"
|
||||||
|
tavily:
|
||||||
|
api_keys:
|
||||||
|
- "tvly-your-tavily-key" # Single key in array format
|
||||||
|
glm_search:
|
||||||
|
api_key: "your-glm-key" # GLMSearch uses single key format
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The refactoring includes comprehensive tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./pkg/config -run TestSecurityConfig
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Error: "model security entry not found"
|
||||||
|
|
||||||
|
- Ensure the model name in your reference matches exactly in `security.yml`
|
||||||
|
- Check that the `model_list` section exists in `security.yml`
|
||||||
|
- For models with indexed names (e.g., "gpt-5.4:0"), ensure the exact name is used or check the base name without index
|
||||||
|
|
||||||
|
### Error: "failed to load security config"
|
||||||
|
|
||||||
|
- Verify `security.yml` exists in the same directory as `config.json`
|
||||||
|
- Check the YAML syntax is valid (use a YAML validator)
|
||||||
|
- Ensure file permissions allow reading
|
||||||
|
|
||||||
|
### Error: "unknown reference path"
|
||||||
|
|
||||||
|
- Verify the reference format is correct
|
||||||
|
- Check the path structure matches the examples above
|
||||||
|
- Ensure all required sections exist in `security.yml`
|
||||||
|
|
||||||
|
## Advanced Features
|
||||||
|
|
||||||
|
### Multiple API Keys (Load Balancing & Failover)
|
||||||
|
|
||||||
|
Both models and web tools support multiple API keys for improved reliability:
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- **Load balancing**: Requests are distributed across multiple keys
|
||||||
|
- **Failover**: Automatic switching to another key if one fails
|
||||||
|
- **Rate limit management**: Distribute usage across multiple keys
|
||||||
|
- **High availability**: Reduce downtime during API provider issues
|
||||||
|
|
||||||
|
#### Example: Model with Multiple Keys
|
||||||
|
|
||||||
|
**security.yml:**
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
gpt-5.4:
|
||||||
|
api_keys:
|
||||||
|
- "sk-proj-key-1"
|
||||||
|
- "sk-proj-key-2"
|
||||||
|
- "sk-proj-key-3"
|
||||||
|
```
|
||||||
|
|
||||||
|
**config.json:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "ref:model_list.gpt-5.4.api_key"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Example: Web Tool with Multiple Keys
|
||||||
|
|
||||||
|
**security.yml:**
|
||||||
|
```yaml
|
||||||
|
web:
|
||||||
|
brave:
|
||||||
|
api_keys:
|
||||||
|
- "BSA-key-1"
|
||||||
|
- "BSA-key-2"
|
||||||
|
tavily:
|
||||||
|
api_keys:
|
||||||
|
- "tvly-your-key" # Single key in array format
|
||||||
|
glm_search:
|
||||||
|
api_key: "your-glm-key" # GLMSearch uses single key format
|
||||||
|
```
|
||||||
|
|
||||||
|
**config.json:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"brave": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "ref:web.brave.api_key"
|
||||||
|
},
|
||||||
|
"tavily": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "ref:web.tavily.api_key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Supported Formats
|
||||||
|
|
||||||
|
**Models - Single key:**
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
gpt-5.4:
|
||||||
|
api_keys:
|
||||||
|
- "sk-your-key" # Array with one element
|
||||||
|
```
|
||||||
|
|
||||||
|
**Models - Multiple keys:**
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
gpt-5.4:
|
||||||
|
api_keys:
|
||||||
|
- "sk-your-key-1"
|
||||||
|
- "sk-your-key-2"
|
||||||
|
- "sk-your-key-3"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Web Tools (Brave/Tavily/Perplexity) - Single key:**
|
||||||
|
```yaml
|
||||||
|
web:
|
||||||
|
brave:
|
||||||
|
api_keys:
|
||||||
|
- "BSA-your-key" # Array with one element
|
||||||
|
```
|
||||||
|
|
||||||
|
**Web Tools (Brave/Tavily/Perplexity) - Multiple keys:**
|
||||||
|
```yaml
|
||||||
|
web:
|
||||||
|
brave:
|
||||||
|
api_keys:
|
||||||
|
- "BSA-key-1"
|
||||||
|
- "BSA-key-2"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Web Tool (GLMSearch) - Single key only:**
|
||||||
|
```yaml
|
||||||
|
web:
|
||||||
|
glm_search:
|
||||||
|
api_key: "your-glm-key" # Single string (NOT array)
|
||||||
|
```
|
||||||
|
|
||||||
|
All formats work identically in `config.json` - you always use the same reference format:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_key": "ref:model_list.gpt-5.4.api_key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model Indexing for Load Balancing
|
||||||
|
|
||||||
|
When you have multiple models with the same base name but different API keys, you can use indexed names:
|
||||||
|
|
||||||
|
**security.yml:**
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
gpt-5.4:
|
||||||
|
api_keys:
|
||||||
|
- "sk-proj-key-1"
|
||||||
|
- "sk-proj-key-2"
|
||||||
|
```
|
||||||
|
|
||||||
|
The system will automatically expand this into multiple model entries with fallback support.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
You can override any security value using environment variables:
|
||||||
|
|
||||||
|
**For models:**
|
||||||
|
```bash
|
||||||
|
export PICOCLAW_MODEL_LIST_GPT-5.4_API_KEY="sk-from-env"
|
||||||
|
```
|
||||||
|
|
||||||
|
**For channels:**
|
||||||
|
```bash
|
||||||
|
export PICOCLAW_CHANNELS_TELEGRAM_TOKEN="token-from-env"
|
||||||
|
```
|
||||||
|
|
||||||
|
**For web tools:**
|
||||||
|
```bash
|
||||||
|
export PICOCLAW_WEB_BRAVE_API_KEY="key-from-env"
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment variables follow this pattern: `PICOCLAW_<SECTION>_<KEY1>_<KEY2>_<FIELD>` with dots replaced by underscores and converted to uppercase.
|
||||||
|
|
||||||
|
### Multiple API Keys Not Working
|
||||||
|
|
||||||
|
- Ensure you're using `api_keys` (plural) in `security.yml` for models and web tools (except GLMSearch)
|
||||||
|
- Check that the array format is correct in YAML (proper indentation)
|
||||||
|
- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format)
|
||||||
|
- GLMSearch MUST use `api_key` (single string format)
|
||||||
|
- The reference in `config.json` is the same regardless of single or multiple keys
|
||||||
|
|
||||||
|
### Load Balancing/Failover Issues
|
||||||
|
|
||||||
|
- Verify all API keys in the `api_keys` array are valid
|
||||||
|
- Check that all keys have the same rate limits and permissions
|
||||||
|
- Monitor logs to see which keys are being used and failing
|
||||||
1198
pkg/config/config.go
1198
pkg/config/config.go
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,8 @@
|
||||||
|
|
||||||
package config
|
package config
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
type agentDefaultsV0 struct {
|
type agentDefaultsV0 struct {
|
||||||
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
||||||
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
||||||
|
|
@ -42,16 +44,670 @@ type agentsConfigV0 struct {
|
||||||
// This struct is used for loading legacy config files (version 0).
|
// This struct is used for loading legacy config files (version 0).
|
||||||
// It is unexported since it's only used internally for migration.
|
// It is unexported since it's only used internally for migration.
|
||||||
type configV0 struct {
|
type configV0 struct {
|
||||||
Agents agentsConfigV0 `json:"agents"`
|
Agents agentsConfigV0 `json:"agents"`
|
||||||
Bindings []AgentBinding `json:"bindings,omitempty"`
|
Bindings []AgentBinding `json:"bindings,omitempty"`
|
||||||
Session SessionConfig `json:"session,omitempty"`
|
Session SessionConfig `json:"session,omitempty"`
|
||||||
Channels ChannelsConfig `json:"channels"`
|
Channels channelsConfigV0 `json:"channels"`
|
||||||
Providers ProvidersConfig `json:"providers,omitempty"`
|
Providers providersConfigV0 `json:"providers,omitempty"`
|
||||||
ModelList []ModelConfig `json:"model_list"`
|
ModelList []modelConfigV0 `json:"model_list"`
|
||||||
Gateway GatewayConfig `json:"gateway"`
|
Gateway GatewayConfig `json:"gateway"`
|
||||||
Tools ToolsConfig `json:"tools"`
|
Tools toolsConfigV0 `json:"tools"`
|
||||||
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
||||||
Devices DevicesConfig `json:"devices"`
|
Devices DevicesConfig `json:"devices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolsConfigV0 struct {
|
||||||
|
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
|
||||||
|
AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
|
||||||
|
Web webToolsConfigV0 `json:"web"`
|
||||||
|
Cron CronToolsConfig `json:"cron"`
|
||||||
|
Exec ExecConfig `json:"exec"`
|
||||||
|
Skills skillsToolsConfigV0 `json:"skills"`
|
||||||
|
MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
|
||||||
|
MCP MCPConfig `json:"mcp"`
|
||||||
|
AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"`
|
||||||
|
EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
|
||||||
|
FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
|
||||||
|
I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"`
|
||||||
|
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
|
||||||
|
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
|
||||||
|
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
|
||||||
|
ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
|
||||||
|
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
|
||||||
|
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
|
||||||
|
SpawnStatus ToolConfig `json:"spawn_status" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"`
|
||||||
|
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
|
||||||
|
Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
|
||||||
|
WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
|
||||||
|
WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type channelsConfigV0 struct {
|
||||||
|
WhatsApp WhatsAppConfig `json:"whatsapp"`
|
||||||
|
Telegram telegramConfigV0 `json:"telegram"`
|
||||||
|
Feishu feishuConfigV0 `json:"feishu"`
|
||||||
|
Discord discordConfigV0 `json:"discord"`
|
||||||
|
MaixCam maixcamConfigV0 `json:"maixcam"`
|
||||||
|
QQ qqConfigV0 `json:"qq"`
|
||||||
|
DingTalk dingtalkConfigV0 `json:"dingtalk"`
|
||||||
|
Slack slackConfigV0 `json:"slack"`
|
||||||
|
Matrix matrixConfigV0 `json:"matrix"`
|
||||||
|
LINE lineConfigV0 `json:"line"`
|
||||||
|
OneBot onebotConfigV0 `json:"onebot"`
|
||||||
|
WeCom wecomConfigV0 `json:"wecom"`
|
||||||
|
WeComApp wecomappConfigV0 `json:"wecom_app"`
|
||||||
|
WeComAIBot wecomaibotConfigV0 `json:"wecom_aibot"`
|
||||||
|
Pico picoConfigV0 `json:"pico"`
|
||||||
|
IRC ircConfigV0 `json:"irc"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *channelsConfigV0) ToChannelsConfig() (ChannelsConfig, ChannelsSecurity) {
|
||||||
|
telegram, telegramSecurity := v.Telegram.ToTelegramConfig()
|
||||||
|
feishu, feishuSecurity := v.Feishu.ToFeishuConfig()
|
||||||
|
discord, discordSecurity := v.Discord.ToDiscordConfig()
|
||||||
|
maixcam := v.MaixCam.ToMaixCamConfig()
|
||||||
|
qq, qqSecurity := v.QQ.ToQQConfig()
|
||||||
|
dingtalk, dingtalkSecurity := v.DingTalk.ToDingTalkConfig()
|
||||||
|
slack, slackSecurity := v.Slack.ToSlackConfig()
|
||||||
|
matrix, matrixSecurity := v.Matrix.ToMatrixConfig()
|
||||||
|
line, lineSecurity := v.LINE.ToLINEConfig()
|
||||||
|
onebot, onebotSecurity := v.OneBot.ToOneBotConfig()
|
||||||
|
wecom, wecomSecurity := v.WeCom.ToWeComConfig()
|
||||||
|
wecomapp, wecomappSecurity := v.WeComApp.ToWeComAppConfig()
|
||||||
|
wecomaibot, wecomaibotSecurity := v.WeComAIBot.ToWeComAIBotConfig()
|
||||||
|
pico, picoSecurity := v.Pico.ToPicoConfig()
|
||||||
|
irc, ircSecurity := v.IRC.ToIRCConfig()
|
||||||
|
|
||||||
|
return ChannelsConfig{
|
||||||
|
WhatsApp: v.WhatsApp,
|
||||||
|
Telegram: telegram,
|
||||||
|
Feishu: feishu,
|
||||||
|
Discord: discord,
|
||||||
|
MaixCam: maixcam,
|
||||||
|
QQ: qq,
|
||||||
|
DingTalk: dingtalk,
|
||||||
|
Slack: slack,
|
||||||
|
Matrix: matrix,
|
||||||
|
LINE: line,
|
||||||
|
OneBot: onebot,
|
||||||
|
WeCom: wecom,
|
||||||
|
WeComApp: wecomapp,
|
||||||
|
WeComAIBot: wecomaibot,
|
||||||
|
Pico: pico,
|
||||||
|
IRC: irc,
|
||||||
|
}, ChannelsSecurity{
|
||||||
|
Telegram: &telegramSecurity,
|
||||||
|
Feishu: &feishuSecurity,
|
||||||
|
Discord: &discordSecurity,
|
||||||
|
QQ: &qqSecurity,
|
||||||
|
DingTalk: &dingtalkSecurity,
|
||||||
|
Slack: &slackSecurity,
|
||||||
|
Matrix: &matrixSecurity,
|
||||||
|
LINE: &lineSecurity,
|
||||||
|
OneBot: &onebotSecurity,
|
||||||
|
WeCom: &wecomSecurity,
|
||||||
|
WeComApp: &wecomappSecurity,
|
||||||
|
WeComAIBot: &wecomaibotSecurity,
|
||||||
|
Pico: &picoSecurity,
|
||||||
|
IRC: &ircSecurity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type qqConfigV0 struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
|
||||||
|
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
|
||||||
|
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
MaxMessageLength int `json:"max_message_length" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"`
|
||||||
|
MaxBase64FileSizeMiB int64 `json:"max_base64_file_size_mib" env:"PICOCLAW_CHANNELS_QQ_MAX_BASE64_FILE_SIZE_MIB"`
|
||||||
|
SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *qqConfigV0) ToQQConfig() (QQConfig, QQSecurity) {
|
||||||
|
return QQConfig{
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
AppID: v.AppID,
|
||||||
|
AllowFrom: v.AllowFrom,
|
||||||
|
GroupTrigger: v.GroupTrigger,
|
||||||
|
MaxMessageLength: v.MaxMessageLength,
|
||||||
|
MaxBase64FileSizeMiB: v.MaxBase64FileSizeMiB,
|
||||||
|
SendMarkdown: v.SendMarkdown,
|
||||||
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
|
}, QQSecurity{
|
||||||
|
AppSecret: v.AppSecret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type telegramConfigV0 struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
|
||||||
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
||||||
|
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
|
||||||
|
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
Typing TypingConfig `json:"typing,omitempty"`
|
||||||
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
|
||||||
|
UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *telegramConfigV0) ToTelegramConfig() (TelegramConfig, TelegramSecurity) {
|
||||||
|
return TelegramConfig{
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
token: v.Token,
|
||||||
|
BaseURL: v.BaseURL,
|
||||||
|
Proxy: v.Proxy,
|
||||||
|
AllowFrom: v.AllowFrom,
|
||||||
|
GroupTrigger: v.GroupTrigger,
|
||||||
|
Typing: v.Typing,
|
||||||
|
Placeholder: v.Placeholder,
|
||||||
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
|
UseMarkdownV2: v.UseMarkdownV2,
|
||||||
|
}, TelegramSecurity{
|
||||||
|
Token: v.Token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type feishuConfigV0 struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
|
||||||
|
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
|
||||||
|
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
|
||||||
|
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
|
||||||
|
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
|
||||||
|
RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"`
|
||||||
|
IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *feishuConfigV0) ToFeishuConfig() (FeishuConfig, FeishuSecurity) {
|
||||||
|
return FeishuConfig{
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
AppID: v.AppID,
|
||||||
|
appSecret: v.AppSecret,
|
||||||
|
AllowFrom: v.AllowFrom,
|
||||||
|
GroupTrigger: v.GroupTrigger,
|
||||||
|
Placeholder: v.Placeholder,
|
||||||
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
|
}, FeishuSecurity{
|
||||||
|
AppSecret: v.AppSecret,
|
||||||
|
EncryptKey: v.EncryptKey,
|
||||||
|
VerificationToken: v.VerificationToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type discordConfigV0 struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
|
||||||
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
|
||||||
|
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
|
||||||
|
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
Typing TypingConfig `json:"typing,omitempty"`
|
||||||
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *discordConfigV0) ToDiscordConfig() (DiscordConfig, DiscordSecurity) {
|
||||||
|
return DiscordConfig{
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
token: v.Token,
|
||||||
|
Proxy: v.Proxy,
|
||||||
|
AllowFrom: v.AllowFrom,
|
||||||
|
MentionOnly: v.MentionOnly,
|
||||||
|
GroupTrigger: v.GroupTrigger,
|
||||||
|
Typing: v.Typing,
|
||||||
|
Placeholder: v.Placeholder,
|
||||||
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
|
}, DiscordSecurity{
|
||||||
|
Token: v.Token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type maixcamConfigV0 struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
|
||||||
|
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
|
||||||
|
Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MAIXCAM_REASONING_CHANNEL_ID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *maixcamConfigV0) ToMaixCamConfig() MaixCamConfig {
|
||||||
|
return MaixCamConfig{
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
Host: v.Host,
|
||||||
|
Port: v.Port,
|
||||||
|
AllowFrom: v.AllowFrom,
|
||||||
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type dingtalkConfigV0 struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
|
||||||
|
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
|
||||||
|
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *dingtalkConfigV0) ToDingTalkConfig() (DingTalkConfig, DingTalkSecurity) {
|
||||||
|
return DingTalkConfig{
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
ClientID: v.ClientID,
|
||||||
|
clientSecret: v.ClientSecret,
|
||||||
|
AllowFrom: v.AllowFrom,
|
||||||
|
GroupTrigger: v.GroupTrigger,
|
||||||
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
|
}, DingTalkSecurity{
|
||||||
|
ClientSecret: v.ClientSecret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type slackConfigV0 struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
|
||||||
|
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
|
||||||
|
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
Typing TypingConfig `json:"typing,omitempty"`
|
||||||
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *slackConfigV0) ToSlackConfig() (SlackConfig, SlackSecurity) {
|
||||||
|
return SlackConfig{
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
botToken: v.BotToken,
|
||||||
|
appToken: v.AppToken,
|
||||||
|
AllowFrom: v.AllowFrom,
|
||||||
|
GroupTrigger: v.GroupTrigger,
|
||||||
|
Typing: v.Typing,
|
||||||
|
Placeholder: v.Placeholder,
|
||||||
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
|
}, SlackSecurity{
|
||||||
|
BotToken: v.BotToken,
|
||||||
|
AppToken: v.AppToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type matrixConfigV0 struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
|
||||||
|
Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
|
||||||
|
UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
|
||||||
|
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
|
||||||
|
DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
|
||||||
|
JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
|
||||||
|
MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *matrixConfigV0) ToMatrixConfig() (MatrixConfig, MatrixSecurity) {
|
||||||
|
return MatrixConfig{
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
Homeserver: v.Homeserver,
|
||||||
|
UserID: v.UserID,
|
||||||
|
accessToken: v.AccessToken,
|
||||||
|
DeviceID: v.DeviceID,
|
||||||
|
JoinOnInvite: v.JoinOnInvite,
|
||||||
|
MessageFormat: v.MessageFormat,
|
||||||
|
AllowFrom: v.AllowFrom,
|
||||||
|
GroupTrigger: v.GroupTrigger,
|
||||||
|
Placeholder: v.Placeholder,
|
||||||
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
|
}, MatrixSecurity{
|
||||||
|
AccessToken: v.AccessToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type lineConfigV0 struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
|
||||||
|
ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
|
||||||
|
ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
|
||||||
|
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
|
||||||
|
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
|
||||||
|
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
Typing TypingConfig `json:"typing,omitempty"`
|
||||||
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *lineConfigV0) ToLINEConfig() (LINEConfig, LINESecurity) {
|
||||||
|
return LINEConfig{
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
channelSecret: v.ChannelSecret,
|
||||||
|
channelAccessToken: v.ChannelAccessToken,
|
||||||
|
WebhookHost: v.WebhookHost,
|
||||||
|
WebhookPort: v.WebhookPort,
|
||||||
|
WebhookPath: v.WebhookPath,
|
||||||
|
AllowFrom: v.AllowFrom,
|
||||||
|
GroupTrigger: v.GroupTrigger,
|
||||||
|
Typing: v.Typing,
|
||||||
|
Placeholder: v.Placeholder,
|
||||||
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
|
}, LINESecurity{
|
||||||
|
ChannelSecret: v.ChannelSecret,
|
||||||
|
ChannelAccessToken: v.ChannelAccessToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type onebotConfigV0 struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
|
||||||
|
WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
|
||||||
|
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
|
||||||
|
ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
|
||||||
|
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
Typing TypingConfig `json:"typing,omitempty"`
|
||||||
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *onebotConfigV0) ToOneBotConfig() (OneBotConfig, OneBotSecurity) {
|
||||||
|
return OneBotConfig{
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
WSUrl: v.WSUrl,
|
||||||
|
accessToken: v.AccessToken,
|
||||||
|
ReconnectInterval: v.ReconnectInterval,
|
||||||
|
GroupTriggerPrefix: v.GroupTriggerPrefix,
|
||||||
|
AllowFrom: v.AllowFrom,
|
||||||
|
GroupTrigger: v.GroupTrigger,
|
||||||
|
Typing: v.Typing,
|
||||||
|
Placeholder: v.Placeholder,
|
||||||
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
|
}, OneBotSecurity{
|
||||||
|
AccessToken: v.AccessToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type wecomConfigV0 struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
|
||||||
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
|
||||||
|
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
|
||||||
|
WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
|
||||||
|
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
|
||||||
|
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
|
||||||
|
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
|
||||||
|
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *wecomConfigV0) ToWeComConfig() (WeComConfig, WeComSecurity) {
|
||||||
|
return WeComConfig{
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
token: v.Token,
|
||||||
|
encodingAESKey: v.EncodingAESKey,
|
||||||
|
WebhookURL: v.WebhookURL,
|
||||||
|
WebhookHost: v.WebhookHost,
|
||||||
|
WebhookPort: v.WebhookPort,
|
||||||
|
WebhookPath: v.WebhookPath,
|
||||||
|
AllowFrom: v.AllowFrom,
|
||||||
|
ReplyTimeout: v.ReplyTimeout,
|
||||||
|
GroupTrigger: v.GroupTrigger,
|
||||||
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
|
}, WeComSecurity{
|
||||||
|
Token: v.Token,
|
||||||
|
EncodingAESKey: v.EncodingAESKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type wecomappConfigV0 struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
|
||||||
|
CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
|
||||||
|
CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
|
||||||
|
AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
|
||||||
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
|
||||||
|
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
|
||||||
|
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
|
||||||
|
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
|
||||||
|
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
|
||||||
|
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *wecomappConfigV0) ToWeComAppConfig() (WeComAppConfig, WeComAppSecurity) {
|
||||||
|
return WeComAppConfig{
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
CorpID: v.CorpID,
|
||||||
|
corpSecret: v.CorpSecret,
|
||||||
|
AgentID: v.AgentID,
|
||||||
|
token: v.Token,
|
||||||
|
encodingAESKey: v.EncodingAESKey,
|
||||||
|
WebhookHost: v.WebhookHost,
|
||||||
|
WebhookPort: v.WebhookPort,
|
||||||
|
WebhookPath: v.WebhookPath,
|
||||||
|
AllowFrom: v.AllowFrom,
|
||||||
|
ReplyTimeout: v.ReplyTimeout,
|
||||||
|
GroupTrigger: v.GroupTrigger,
|
||||||
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
|
}, WeComAppSecurity{
|
||||||
|
CorpSecret: v.CorpSecret,
|
||||||
|
Token: v.Token,
|
||||||
|
EncodingAESKey: v.EncodingAESKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type wecomaibotConfigV0 struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
|
||||||
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
|
||||||
|
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
|
||||||
|
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
|
||||||
|
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
|
||||||
|
MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"`
|
||||||
|
WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *wecomaibotConfigV0) ToWeComAIBotConfig() (WeComAIBotConfig, WeComAIBotSecurity) {
|
||||||
|
return WeComAIBotConfig{
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
token: v.Token,
|
||||||
|
encodingAESKey: v.EncodingAESKey,
|
||||||
|
WebhookPath: v.WebhookPath,
|
||||||
|
AllowFrom: v.AllowFrom,
|
||||||
|
ReplyTimeout: v.ReplyTimeout,
|
||||||
|
MaxSteps: v.MaxSteps,
|
||||||
|
WelcomeMessage: v.WelcomeMessage,
|
||||||
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
|
}, WeComAIBotSecurity{
|
||||||
|
Token: v.Token,
|
||||||
|
EncodingAESKey: v.EncodingAESKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type picoConfigV0 struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
|
||||||
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
|
||||||
|
AllowTokenQuery bool `json:"allow_token_query,omitempty"`
|
||||||
|
AllowOrigins []string `json:"allow_origins,omitempty"`
|
||||||
|
PingInterval int `json:"ping_interval,omitempty"`
|
||||||
|
ReadTimeout int `json:"read_timeout,omitempty"`
|
||||||
|
WriteTimeout int `json:"write_timeout,omitempty"`
|
||||||
|
MaxConnections int `json:"max_connections,omitempty"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"`
|
||||||
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *picoConfigV0) ToPicoConfig() (PicoConfig, PicoSecurity) {
|
||||||
|
return PicoConfig{
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
token: v.Token,
|
||||||
|
AllowTokenQuery: v.AllowTokenQuery,
|
||||||
|
AllowOrigins: v.AllowOrigins,
|
||||||
|
PingInterval: v.PingInterval,
|
||||||
|
ReadTimeout: v.ReadTimeout,
|
||||||
|
WriteTimeout: v.WriteTimeout,
|
||||||
|
MaxConnections: v.MaxConnections,
|
||||||
|
AllowFrom: v.AllowFrom,
|
||||||
|
Placeholder: v.Placeholder,
|
||||||
|
}, PicoSecurity{
|
||||||
|
Token: v.Token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ircConfigV0 struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"`
|
||||||
|
Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"`
|
||||||
|
TLS bool `json:"tls" env:"PICOCLAW_CHANNELS_IRC_TLS"`
|
||||||
|
Nick string `json:"nick" env:"PICOCLAW_CHANNELS_IRC_NICK"`
|
||||||
|
User string `json:"user,omitempty" env:"PICOCLAW_CHANNELS_IRC_USER"`
|
||||||
|
RealName string `json:"real_name,omitempty" env:"PICOCLAW_CHANNELS_IRC_REAL_NAME"`
|
||||||
|
Password string `json:"password" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"`
|
||||||
|
NickServPassword string `json:"nickserv_password" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"`
|
||||||
|
SASLUser string `json:"sasl_user" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"`
|
||||||
|
SASLPassword string `json:"sasl_password" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"`
|
||||||
|
Channels FlexibleStringSlice `json:"channels" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"`
|
||||||
|
RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" env:"PICOCLAW_CHANNELS_IRC_REQUEST_CAPS"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_IRC_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
Typing TypingConfig `json:"typing,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *ircConfigV0) ToIRCConfig() (IRCConfig, IRCSecurity) {
|
||||||
|
return IRCConfig{
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
Server: v.Server,
|
||||||
|
TLS: v.TLS,
|
||||||
|
Nick: v.Nick,
|
||||||
|
User: v.User,
|
||||||
|
RealName: v.RealName,
|
||||||
|
password: v.Password,
|
||||||
|
nickServPassword: v.NickServPassword,
|
||||||
|
SASLUser: v.SASLUser,
|
||||||
|
saslPassword: v.SASLPassword,
|
||||||
|
Channels: v.Channels,
|
||||||
|
RequestCaps: v.RequestCaps,
|
||||||
|
AllowFrom: v.AllowFrom,
|
||||||
|
GroupTrigger: v.GroupTrigger,
|
||||||
|
Typing: v.Typing,
|
||||||
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
|
}, IRCSecurity{
|
||||||
|
Password: v.Password,
|
||||||
|
NickServPassword: v.NickServPassword,
|
||||||
|
SASLPassword: v.SASLPassword,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type providersConfigV0 struct {
|
||||||
|
Anthropic providerConfigV0 `json:"anthropic"`
|
||||||
|
OpenAI openAIProviderConfigV0 `json:"openai"`
|
||||||
|
LiteLLM providerConfigV0 `json:"litellm"`
|
||||||
|
OpenRouter providerConfigV0 `json:"openrouter"`
|
||||||
|
Groq providerConfigV0 `json:"groq"`
|
||||||
|
Zhipu providerConfigV0 `json:"zhipu"`
|
||||||
|
VLLM providerConfigV0 `json:"vllm"`
|
||||||
|
Gemini providerConfigV0 `json:"gemini"`
|
||||||
|
Nvidia providerConfigV0 `json:"nvidia"`
|
||||||
|
Ollama providerConfigV0 `json:"ollama"`
|
||||||
|
Moonshot providerConfigV0 `json:"moonshot"`
|
||||||
|
ShengSuanYun providerConfigV0 `json:"shengsuanyun"`
|
||||||
|
DeepSeek providerConfigV0 `json:"deepseek"`
|
||||||
|
Cerebras providerConfigV0 `json:"cerebras"`
|
||||||
|
Vivgrid providerConfigV0 `json:"vivgrid"`
|
||||||
|
VolcEngine providerConfigV0 `json:"volcengine"`
|
||||||
|
GitHubCopilot providerConfigV0 `json:"github_copilot"`
|
||||||
|
Antigravity providerConfigV0 `json:"antigravity"`
|
||||||
|
Qwen providerConfigV0 `json:"qwen"`
|
||||||
|
Mistral providerConfigV0 `json:"mistral"`
|
||||||
|
Avian providerConfigV0 `json:"avian"`
|
||||||
|
Minimax providerConfigV0 `json:"minimax"`
|
||||||
|
LongCat providerConfigV0 `json:"longcat"`
|
||||||
|
ModelScope providerConfigV0 `json:"modelscope"`
|
||||||
|
Novita providerConfigV0 `json:"novita"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
||||||
|
// Note: WebSearch is an optimization option and doesn't count as "non-empty"
|
||||||
|
func (p providersConfigV0) IsEmpty() bool {
|
||||||
|
return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" &&
|
||||||
|
p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" &&
|
||||||
|
p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" &&
|
||||||
|
p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" &&
|
||||||
|
p.Groq.APIKey == "" && p.Groq.APIBase == "" &&
|
||||||
|
p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" &&
|
||||||
|
p.VLLM.APIKey == "" && p.VLLM.APIBase == "" &&
|
||||||
|
p.Gemini.APIKey == "" && p.Gemini.APIBase == "" &&
|
||||||
|
p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" &&
|
||||||
|
p.Ollama.APIKey == "" && p.Ollama.APIBase == "" &&
|
||||||
|
p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" &&
|
||||||
|
p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" &&
|
||||||
|
p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" &&
|
||||||
|
p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" &&
|
||||||
|
p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" &&
|
||||||
|
p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" &&
|
||||||
|
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
|
||||||
|
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
||||||
|
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
|
||||||
|
p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
|
||||||
|
p.Avian.APIKey == "" && p.Avian.APIBase == "" &&
|
||||||
|
p.Minimax.APIKey == "" && p.Minimax.APIBase == "" &&
|
||||||
|
p.LongCat.APIKey == "" && p.LongCat.APIBase == "" &&
|
||||||
|
p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" &&
|
||||||
|
p.Novita.APIKey == "" && p.Novita.APIBase == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type providerConfigV0 struct {
|
||||||
|
APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
|
||||||
|
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
|
||||||
|
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
|
||||||
|
RequestTimeout int `json:"request_timeout,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_REQUEST_TIMEOUT"`
|
||||||
|
AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
|
||||||
|
ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON implements custom JSON marshaling for providersConfig
|
||||||
|
// to omit the entire section when empty
|
||||||
|
func (p providersConfigV0) MarshalJSON() ([]byte, error) {
|
||||||
|
if p.IsEmpty() {
|
||||||
|
return []byte("null"), nil
|
||||||
|
}
|
||||||
|
type Alias providersConfigV0
|
||||||
|
return json.Marshal((*Alias)(&p))
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIProviderConfigV0 struct {
|
||||||
|
providerConfigV0
|
||||||
|
WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type modelConfigV0 struct {
|
||||||
|
// Required fields
|
||||||
|
ModelName string `json:"model_name"` // User-facing alias for the model
|
||||||
|
Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6")
|
||||||
|
|
||||||
|
// HTTP-based providers
|
||||||
|
APIBase string `json:"api_base,omitempty"` // API endpoint URL
|
||||||
|
APIKey string `json:"api_key"` // API authentication key (single key)
|
||||||
|
APIKeys []string `json:"api_keys,omitempty"` // API authentication keys (multiple keys for failover)
|
||||||
|
Proxy string `json:"proxy,omitempty"` // HTTP proxy URL
|
||||||
|
Fallbacks []string `json:"fallbacks,omitempty"` // Fallback model names for failover
|
||||||
|
|
||||||
|
// Special providers (CLI-based, OAuth, etc.)
|
||||||
|
AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token
|
||||||
|
ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc
|
||||||
|
Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers
|
||||||
|
|
||||||
|
// Optional optimizations
|
||||||
|
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")
|
||||||
|
RequestTimeout int `json:"request_timeout,omitempty"`
|
||||||
|
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *configV0) migrateChannelConfigs() {
|
func (c *configV0) migrateChannelConfigs() {
|
||||||
|
|
@ -92,17 +748,257 @@ func (c *configV0) Migrate() (*Config, error) {
|
||||||
// Copy other top-level fields
|
// Copy other top-level fields
|
||||||
cfg.Bindings = c.Bindings
|
cfg.Bindings = c.Bindings
|
||||||
cfg.Session = c.Session
|
cfg.Session = c.Session
|
||||||
cfg.Channels = c.Channels
|
var secChannels ChannelsSecurity
|
||||||
|
cfg.Channels, secChannels = c.Channels.ToChannelsConfig()
|
||||||
cfg.Gateway = c.Gateway
|
cfg.Gateway = c.Gateway
|
||||||
cfg.Tools = c.Tools
|
var secWeb WebToolsSecurity
|
||||||
|
cfg.Tools.Web, secWeb = c.Tools.Web.ToWebToolsConfig()
|
||||||
|
cfg.Tools.Cron = c.Tools.Cron
|
||||||
|
cfg.Tools.Exec = c.Tools.Exec
|
||||||
|
var secSkills SkillsSecurity
|
||||||
|
cfg.Tools.Skills, secSkills = c.Tools.Skills.ToSkillsToolsConfig()
|
||||||
|
cfg.Tools.MediaCleanup = c.Tools.MediaCleanup
|
||||||
|
cfg.Tools.MCP = c.Tools.MCP
|
||||||
|
cfg.Tools.AppendFile = c.Tools.AppendFile
|
||||||
|
cfg.Tools.EditFile = c.Tools.EditFile
|
||||||
|
cfg.Tools.FindSkills = c.Tools.FindSkills
|
||||||
|
cfg.Tools.I2C = c.Tools.I2C
|
||||||
|
cfg.Tools.InstallSkill = c.Tools.InstallSkill
|
||||||
|
cfg.Tools.ListDir = c.Tools.ListDir
|
||||||
|
cfg.Tools.Message = c.Tools.Message
|
||||||
|
cfg.Tools.ReadFile = c.Tools.ReadFile
|
||||||
|
cfg.Tools.SendFile = c.Tools.SendFile
|
||||||
|
cfg.Tools.Spawn = c.Tools.Spawn
|
||||||
|
cfg.Tools.SpawnStatus = c.Tools.SpawnStatus
|
||||||
|
cfg.Tools.SPI = c.Tools.SPI
|
||||||
|
cfg.Tools.Subagent = c.Tools.Subagent
|
||||||
|
cfg.Tools.WebFetch = c.Tools.WebFetch
|
||||||
|
cfg.Tools.AllowReadPaths = c.Tools.AllowReadPaths
|
||||||
|
cfg.Tools.AllowWritePaths = c.Tools.AllowWritePaths
|
||||||
cfg.Heartbeat = c.Heartbeat
|
cfg.Heartbeat = c.Heartbeat
|
||||||
cfg.Devices = c.Devices
|
cfg.Devices = c.Devices
|
||||||
|
|
||||||
|
secModels := make(map[string]ModelSecurityEntry, 0)
|
||||||
// Only override ModelList if user provided values
|
// Only override ModelList if user provided values
|
||||||
if len(c.ModelList) > 0 {
|
if len(c.ModelList) > 0 {
|
||||||
cfg.ModelList = c.ModelList
|
// Convert []modelConfigV0 to []ModelConfig
|
||||||
|
cfg.ModelList = make([]*ModelConfig, len(c.ModelList))
|
||||||
|
for i, m := range c.ModelList {
|
||||||
|
// Merge APIKey and APIKeys, deduplicating
|
||||||
|
mergedKeys := MergeAPIKeys(m.APIKey, m.APIKeys)
|
||||||
|
|
||||||
|
cfg.ModelList[i] = &ModelConfig{
|
||||||
|
ModelName: m.ModelName,
|
||||||
|
Model: m.Model,
|
||||||
|
APIBase: m.APIBase,
|
||||||
|
Proxy: m.Proxy,
|
||||||
|
Fallbacks: m.Fallbacks,
|
||||||
|
AuthMethod: m.AuthMethod,
|
||||||
|
ConnectMode: m.ConnectMode,
|
||||||
|
Workspace: m.Workspace,
|
||||||
|
RPM: m.RPM,
|
||||||
|
MaxTokensField: m.MaxTokensField,
|
||||||
|
RequestTimeout: m.RequestTimeout,
|
||||||
|
ThinkingLevel: m.ThinkingLevel,
|
||||||
|
apiKeys: mergedKeys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names := toNameIndex(cfg.ModelList)
|
||||||
|
for i, m := range c.ModelList {
|
||||||
|
// Merge APIKey and APIKeys, deduplicating
|
||||||
|
mergedKeys := MergeAPIKeys(m.APIKey, m.APIKeys)
|
||||||
|
secModels[names[i]] = ModelSecurityEntry{
|
||||||
|
APIKeys: mergedKeys,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg.WithSecurity(&SecurityConfig{
|
||||||
|
ModelList: secModels,
|
||||||
|
Channels: secChannels,
|
||||||
|
Web: secWeb,
|
||||||
|
Skills: secSkills,
|
||||||
|
})
|
||||||
cfg.Version = CurrentVersion
|
cfg.Version = CurrentVersion
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type webToolsConfigV0 struct {
|
||||||
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
|
||||||
|
Brave braveConfigV0 ` json:"brave"`
|
||||||
|
Tavily tavilyConfigV0 ` json:"tavily"`
|
||||||
|
DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"`
|
||||||
|
Perplexity perplexityConfigV0 ` json:"perplexity"`
|
||||||
|
SearXNG SearXNGConfig ` json:"searxng"`
|
||||||
|
GLMSearch glmSearchConfigV0 ` json:"glm_search"`
|
||||||
|
PreferNative bool ` json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"`
|
||||||
|
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 braveConfigV0 struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
||||||
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
|
||||||
|
APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"`
|
||||||
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *braveConfigV0) ToBraveConfig() (BraveConfig, BraveSecurity) {
|
||||||
|
return BraveConfig{
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
MaxResults: v.MaxResults,
|
||||||
|
}, BraveSecurity{
|
||||||
|
APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tavilyConfigV0 struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
|
||||||
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
|
||||||
|
APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"`
|
||||||
|
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
|
||||||
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *tavilyConfigV0) ToTavilyConfig() (TavilyConfig, TavilySecurity) {
|
||||||
|
return TavilyConfig{
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
BaseURL: v.BaseURL,
|
||||||
|
MaxResults: v.MaxResults,
|
||||||
|
}, TavilySecurity{
|
||||||
|
APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type perplexityConfigV0 struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
|
||||||
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
|
||||||
|
APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"`
|
||||||
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *perplexityConfigV0) ToPerplexityConfig() (PerplexityConfig, PerplexitySecurity) {
|
||||||
|
return PerplexityConfig{
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
MaxResults: v.MaxResults,
|
||||||
|
}, PerplexitySecurity{
|
||||||
|
APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type glmSearchConfigV0 struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"`
|
||||||
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"`
|
||||||
|
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"`
|
||||||
|
SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *glmSearchConfigV0) ToGLMSearchConfig() (GLMSearchConfig, GLMSearchSecurity) {
|
||||||
|
return GLMSearchConfig{
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
apiKey: v.APIKey,
|
||||||
|
BaseURL: v.BaseURL,
|
||||||
|
SearchEngine: v.SearchEngine,
|
||||||
|
}, GLMSearchSecurity{
|
||||||
|
APIKey: v.APIKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity) {
|
||||||
|
brave, braveSecurity := v.Brave.ToBraveConfig()
|
||||||
|
tavily, tavilySecurity := v.Tavily.ToTavilyConfig()
|
||||||
|
perplexity, perplexitySecurity := v.Perplexity.ToPerplexityConfig()
|
||||||
|
glmSearch, glmSearchSecurity := v.GLMSearch.ToGLMSearchConfig()
|
||||||
|
|
||||||
|
return WebToolsConfig{
|
||||||
|
ToolConfig: v.ToolConfig,
|
||||||
|
Brave: brave,
|
||||||
|
Tavily: tavily,
|
||||||
|
DuckDuckGo: v.DuckDuckGo,
|
||||||
|
Perplexity: perplexity,
|
||||||
|
SearXNG: v.SearXNG,
|
||||||
|
GLMSearch: glmSearch,
|
||||||
|
PreferNative: v.PreferNative,
|
||||||
|
Proxy: v.Proxy,
|
||||||
|
FetchLimitBytes: v.FetchLimitBytes,
|
||||||
|
Format: v.Format,
|
||||||
|
PrivateHostWhitelist: v.PrivateHostWhitelist,
|
||||||
|
}, WebToolsSecurity{
|
||||||
|
Brave: &braveSecurity,
|
||||||
|
Tavily: &tavilySecurity,
|
||||||
|
Perplexity: &perplexitySecurity,
|
||||||
|
GLMSearch: &glmSearchSecurity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type skillsToolsConfigV0 struct {
|
||||||
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
|
||||||
|
Registries skillsRegistriesConfigV0 ` json:"registries"`
|
||||||
|
Github skillsGithubConfigV0 ` json:"github"`
|
||||||
|
MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"`
|
||||||
|
SearchCache SearchCacheConfig ` json:"search_cache"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type skillsRegistriesConfigV0 struct {
|
||||||
|
ClawHub clawHubRegistryConfigV0 `json:"clawhub"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type clawHubRegistryConfigV0 struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"`
|
||||||
|
BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"`
|
||||||
|
AuthToken string `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"`
|
||||||
|
SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"`
|
||||||
|
SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *clawHubRegistryConfigV0) ToClawHubRegistryConfig() (ClawHubRegistryConfig, ClawHubSecurity) {
|
||||||
|
return ClawHubRegistryConfig{
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
BaseURL: v.BaseURL,
|
||||||
|
authToken: v.AuthToken,
|
||||||
|
SearchPath: v.SearchPath,
|
||||||
|
SkillsPath: v.SkillsPath,
|
||||||
|
}, ClawHubSecurity{
|
||||||
|
AuthToken: v.AuthToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type skillsGithubConfigV0 struct {
|
||||||
|
Token string `json:"token" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_TOKEN"`
|
||||||
|
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *skillsGithubConfigV0) ToSkillsGithubConfig() (SkillsGithubConfig, GithubSecurity) {
|
||||||
|
return SkillsGithubConfig{
|
||||||
|
token: v.Token,
|
||||||
|
Proxy: v.Proxy,
|
||||||
|
}, GithubSecurity{
|
||||||
|
Token: v.Token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *skillsRegistriesConfigV0) ToSkillsRegistriesConfig() (SkillsRegistriesConfig, *ClawHubSecurity) {
|
||||||
|
clawHub, clawHubSecurity := v.ClawHub.ToClawHubRegistryConfig()
|
||||||
|
|
||||||
|
return SkillsRegistriesConfig{
|
||||||
|
ClawHub: clawHub,
|
||||||
|
}, &clawHubSecurity
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *skillsToolsConfigV0) ToSkillsToolsConfig() (SkillsToolsConfig, SkillsSecurity) {
|
||||||
|
registries, registriesSecurity := v.Registries.ToSkillsRegistriesConfig()
|
||||||
|
github, githubSecurity := v.Github.ToSkillsGithubConfig()
|
||||||
|
|
||||||
|
return SkillsToolsConfig{
|
||||||
|
ToolConfig: v.ToolConfig,
|
||||||
|
Registries: registries,
|
||||||
|
Github: github,
|
||||||
|
MaxConcurrentSearches: v.MaxConcurrentSearches,
|
||||||
|
SearchCache: v.SearchCache,
|
||||||
|
}, SkillsSecurity{
|
||||||
|
Github: &githubSecurity,
|
||||||
|
ClawHub: registriesSecurity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/credential"
|
"github.com/sipeed/picoclaw/pkg/credential"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -78,18 +81,19 @@ func TestAgentModelConfig_MarshalObject(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProvidersConfig_IsEmpty(t *testing.T) {
|
func TestProvidersConfig_IsEmpty(t *testing.T) {
|
||||||
var empty ProvidersConfig
|
var empty providersConfigV0
|
||||||
|
t.Logf("empty: %+v", empty)
|
||||||
if !empty.IsEmpty() {
|
if !empty.IsEmpty() {
|
||||||
t.Fatal("empty ProvidersConfig should report empty")
|
t.Fatal("empty providersConfig should report empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
novita := ProvidersConfig{
|
novita := providersConfigV0{
|
||||||
Novita: ProviderConfig{
|
Novita: providerConfigV0{
|
||||||
APIKey: "test-key",
|
APIKey: "test-key",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if novita.IsEmpty() {
|
if novita.IsEmpty() {
|
||||||
t.Fatal("ProvidersConfig with novita settings should not report empty")
|
t.Fatal("providersConfig with novita settings should not report empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -305,7 +309,7 @@ func TestDefaultConfig_WebTools(t *testing.T) {
|
||||||
if cfg.Tools.Web.Brave.MaxResults != 5 {
|
if cfg.Tools.Web.Brave.MaxResults != 5 {
|
||||||
t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults)
|
t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults)
|
||||||
}
|
}
|
||||||
if len(cfg.Tools.Web.Brave.APIKeys) != 0 {
|
if len(cfg.Tools.Web.Brave.APIKeys()) != 0 {
|
||||||
t.Error("Brave API key should be empty by default")
|
t.Error("Brave API key should be empty by default")
|
||||||
}
|
}
|
||||||
if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
|
if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
|
||||||
|
|
@ -671,7 +675,20 @@ func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) {
|
||||||
func TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) {
|
func TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
cfgPath := filepath.Join(dir, "config.json")
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
const original = `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}`
|
const original = `{"version":1,"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}`
|
||||||
|
if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
secPath := filepath.Join(dir, SecurityConfigFile)
|
||||||
|
const securityConfig = `
|
||||||
|
model_list:
|
||||||
|
test:0:
|
||||||
|
api_keys:
|
||||||
|
- "sk-plaintext"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(secPath, []byte(securityConfig), 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil {
|
if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil {
|
||||||
t.Fatalf("setup: %v", err)
|
t.Fatalf("setup: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -684,10 +701,10 @@ func TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) {
|
||||||
t.Fatalf("LoadConfig: %v", err)
|
t.Fatalf("LoadConfig: %v", err)
|
||||||
}
|
}
|
||||||
// In-memory value must be the resolved plaintext.
|
// In-memory value must be the resolved plaintext.
|
||||||
if cfg.ModelList[0].APIKey != "sk-plaintext" {
|
if cfg.ModelList[0].APIKey() != "sk-plaintext" {
|
||||||
t.Errorf("in-memory api_key = %q, want %q", cfg.ModelList[0].APIKey, "sk-plaintext")
|
t.Errorf("in-memory api_key = %q, want %q", cfg.ModelList[0].APIKey(), "sk-plaintext")
|
||||||
}
|
}
|
||||||
// The file on disk must remain unchanged — LoadConfig must not write anything.
|
// The file on disk must remain unchanged — no need upgrade version
|
||||||
raw, _ := os.ReadFile(cfgPath)
|
raw, _ := os.ReadFile(cfgPath)
|
||||||
if string(raw) != original {
|
if string(raw) != original {
|
||||||
t.Errorf("LoadConfig must not modify the config file; got:\n%s", string(raw))
|
t.Errorf("LoadConfig must not modify the config file; got:\n%s", string(raw))
|
||||||
|
|
@ -704,15 +721,19 @@ func TestSaveConfig_EncryptsPlaintextAPIKey(t *testing.T) {
|
||||||
mustSetupSSHKey(t)
|
mustSetupSSHKey(t)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
cfg.ModelList = []ModelConfig{
|
cfg.ModelList = []*ModelConfig{
|
||||||
{ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"},
|
{ModelName: "test", Model: "openai/gpt-4", apiKeys: []string{"sk-plaintext"}},
|
||||||
|
}
|
||||||
|
cfg.security = &SecurityConfig{
|
||||||
|
ModelList: map[string]ModelSecurityEntry{"test:0": {APIKeys: []string{"sk-plaintext"}}},
|
||||||
}
|
}
|
||||||
if err := SaveConfig(cfgPath, cfg); err != nil {
|
if err := SaveConfig(cfgPath, cfg); err != nil {
|
||||||
t.Fatalf("SaveConfig: %v", err)
|
t.Fatalf("SaveConfig: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disk must contain enc://, not the raw key.
|
// Disk must contain enc://, not the raw key.
|
||||||
raw, _ := os.ReadFile(cfgPath)
|
secPath := filepath.Join(dir, SecurityConfigFile)
|
||||||
|
raw, _ := os.ReadFile(secPath)
|
||||||
if !strings.Contains(string(raw), "enc://") {
|
if !strings.Contains(string(raw), "enc://") {
|
||||||
t.Errorf("saved file should contain enc://, got:\n%s", string(raw))
|
t.Errorf("saved file should contain enc://, got:\n%s", string(raw))
|
||||||
}
|
}
|
||||||
|
|
@ -725,8 +746,8 @@ func TestSaveConfig_EncryptsPlaintextAPIKey(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadConfig after SaveConfig: %v", err)
|
t.Fatalf("LoadConfig after SaveConfig: %v", err)
|
||||||
}
|
}
|
||||||
if cfg2.ModelList[0].APIKey != "sk-plaintext" {
|
if cfg2.ModelList[0].APIKey() != "sk-plaintext" {
|
||||||
t.Errorf("loaded api_key = %q, want %q", cfg2.ModelList[0].APIKey, "sk-plaintext")
|
t.Errorf("loaded api_key = %q, want %q", cfg2.ModelList[0].APIKey(), "sk-plaintext")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -762,10 +783,17 @@ func TestLoadConfig_FileRefNotSealed(t *testing.T) {
|
||||||
if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil {
|
if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil {
|
||||||
t.Fatalf("setup: %v", err)
|
t.Fatalf("setup: %v", err)
|
||||||
}
|
}
|
||||||
data := `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"file://openai.key"}]}`
|
data := `{"version":1,"model_list":[{"model_name":"test","model":"openai/gpt-4"}]}`
|
||||||
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
||||||
t.Fatalf("setup: %v", err)
|
t.Fatalf("setup: %v", err)
|
||||||
}
|
}
|
||||||
|
secPath := filepath.Join(dir, SecurityConfigFile)
|
||||||
|
if err := saveSecurityConfig(
|
||||||
|
secPath,
|
||||||
|
&SecurityConfig{ModelList: map[string]ModelSecurityEntry{"test:0": {APIKeys: []string{"file://openai.key"}}}},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("saveSecurityConfig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
||||||
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
|
||||||
|
|
@ -774,7 +802,7 @@ func TestLoadConfig_FileRefNotSealed(t *testing.T) {
|
||||||
t.Fatalf("LoadConfig: %v", err)
|
t.Fatalf("LoadConfig: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, _ := os.ReadFile(cfgPath)
|
raw, _ := os.ReadFile(secPath)
|
||||||
if !strings.Contains(string(raw), "file://openai.key") {
|
if !strings.Contains(string(raw), "file://openai.key") {
|
||||||
t.Error("file:// reference should be preserved unchanged in the config file")
|
t.Error("file:// reference should be preserved unchanged in the config file")
|
||||||
}
|
}
|
||||||
|
|
@ -794,23 +822,28 @@ func TestSaveConfig_MixedKeys(t *testing.T) {
|
||||||
|
|
||||||
// Pre-encrypt one key so we have a genuine enc:// value to put in the config.
|
// Pre-encrypt one key so we have a genuine enc:// value to put in the config.
|
||||||
if err := SaveConfig(cfgPath, &Config{
|
if err := SaveConfig(cfgPath, &Config{
|
||||||
ModelList: []ModelConfig{
|
ModelList: []*ModelConfig{
|
||||||
{ModelName: "pre", Model: "openai/gpt-4", APIKey: "sk-already-plain"},
|
{ModelName: "pre", Model: "openai/gpt-4"},
|
||||||
|
},
|
||||||
|
security: &SecurityConfig{
|
||||||
|
ModelList: map[string]ModelSecurityEntry{
|
||||||
|
"pre:0": {APIKeys: []string{"sk-already-plain"}},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("setup SaveConfig: %v", err)
|
t.Fatalf("setup SaveConfig: %v", err)
|
||||||
}
|
}
|
||||||
raw, _ := os.ReadFile(cfgPath)
|
raw, _ := os.ReadFile(filepath.Join(dir, SecurityConfigFile))
|
||||||
// Extract the enc:// value from the saved file.
|
// Extract the enc:// value from the saved file.
|
||||||
var tmp struct {
|
var tmp struct {
|
||||||
ModelList []struct {
|
ModelList map[string]struct {
|
||||||
APIKey string `json:"api_key"`
|
APIKeys []string `yaml:"api_keys"`
|
||||||
} `json:"model_list"`
|
} `yaml:"model_list"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, &tmp); err != nil || len(tmp.ModelList) == 0 {
|
if err := yaml.Unmarshal(raw, &tmp); err != nil || len(tmp.ModelList) == 0 {
|
||||||
t.Fatalf("setup: could not parse saved config: %v", err)
|
t.Fatalf("setup: could not parse saved config: %v", err)
|
||||||
}
|
}
|
||||||
alreadyEncrypted := tmp.ModelList[0].APIKey
|
alreadyEncrypted := tmp.ModelList["pre:0"].APIKeys[0]
|
||||||
if !strings.HasPrefix(alreadyEncrypted, "enc://") {
|
if !strings.HasPrefix(alreadyEncrypted, "enc://") {
|
||||||
t.Fatalf("setup: expected enc:// key, got %q", alreadyEncrypted)
|
t.Fatalf("setup: expected enc:// key, got %q", alreadyEncrypted)
|
||||||
}
|
}
|
||||||
|
|
@ -824,19 +857,28 @@ func TestSaveConfig_MixedKeys(t *testing.T) {
|
||||||
t.Fatalf("setup: %v", err)
|
t.Fatalf("setup: %v", err)
|
||||||
}
|
}
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
ModelList: []ModelConfig{
|
ModelList: []*ModelConfig{
|
||||||
{ModelName: "plain", Model: "openai/gpt-4", APIKey: "sk-new-plaintext"},
|
{ModelName: "plain", Model: "openai/gpt-4", apiKeys: []string{"sk-new-plaintext"}},
|
||||||
{ModelName: "enc", Model: "openai/gpt-4", APIKey: alreadyEncrypted},
|
{ModelName: "enc", Model: "openai/gpt-4", apiKeys: []string{alreadyEncrypted}},
|
||||||
{ModelName: "file", Model: "openai/gpt-4", APIKey: "file://api.key"},
|
{ModelName: "file", Model: "openai/gpt-4", apiKeys: []string{"file://api.key"}},
|
||||||
|
},
|
||||||
|
security: &SecurityConfig{
|
||||||
|
ModelList: map[string]ModelSecurityEntry{
|
||||||
|
"plain:0": {APIKeys: []string{"sk-new-plaintext"}},
|
||||||
|
"enc:0": {APIKeys: []string{alreadyEncrypted}},
|
||||||
|
"file:0": {APIKeys: []string{"file://api.key"}},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := SaveConfig(cfgPath, cfg); err != nil {
|
if err := SaveConfig(cfgPath, cfg); err != nil {
|
||||||
t.Fatalf("SaveConfig: %v", err)
|
t.Fatalf("SaveConfig: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, _ = os.ReadFile(cfgPath)
|
raw, _ = os.ReadFile(filepath.Join(dir, SecurityConfigFile))
|
||||||
s := string(raw)
|
s := string(raw)
|
||||||
|
|
||||||
|
t.Logf("saved file:\n%s", s)
|
||||||
|
|
||||||
// 1. Plaintext must be encrypted.
|
// 1. Plaintext must be encrypted.
|
||||||
if strings.Contains(s, "sk-new-plaintext") {
|
if strings.Contains(s, "sk-new-plaintext") {
|
||||||
t.Error("plaintext key must not appear in saved file")
|
t.Error("plaintext key must not appear in saved file")
|
||||||
|
|
@ -857,7 +899,7 @@ func TestSaveConfig_MixedKeys(t *testing.T) {
|
||||||
}
|
}
|
||||||
byName := make(map[string]string)
|
byName := make(map[string]string)
|
||||||
for _, m := range cfg2.ModelList {
|
for _, m := range cfg2.ModelList {
|
||||||
byName[m.ModelName] = m.APIKey
|
byName[m.ModelName] = m.APIKey()
|
||||||
}
|
}
|
||||||
if byName["plain"] != "sk-new-plaintext" {
|
if byName["plain"] != "sk-new-plaintext" {
|
||||||
t.Errorf("plain model api_key = %q, want %q", byName["plain"], "sk-new-plaintext")
|
t.Errorf("plain model api_key = %q, want %q", byName["plain"], "sk-new-plaintext")
|
||||||
|
|
@ -881,26 +923,26 @@ func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) {
|
||||||
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
||||||
mustSetupSSHKey(t)
|
mustSetupSSHKey(t)
|
||||||
if err := SaveConfig(cfgPath, &Config{
|
if err := SaveConfig(cfgPath, &Config{
|
||||||
ModelList: []ModelConfig{
|
ModelList: []*ModelConfig{
|
||||||
{ModelName: "m", Model: "openai/gpt-4", APIKey: "sk-secret"},
|
{ModelName: "m", Model: "openai/gpt-4", apiKeys: []string{"sk-secret"}},
|
||||||
|
},
|
||||||
|
security: &SecurityConfig{
|
||||||
|
ModelList: map[string]ModelSecurityEntry{
|
||||||
|
"m:0": {APIKeys: []string{"sk-secret"}},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("setup SaveConfig: %v", err)
|
t.Fatalf("setup SaveConfig: %v", err)
|
||||||
}
|
}
|
||||||
raw, _ := os.ReadFile(cfgPath)
|
raw, err := LoadConfig(cfgPath)
|
||||||
var tmp struct {
|
assert.NoError(t, err)
|
||||||
ModelList []struct {
|
encValue := raw.security.ModelList["m:0"].APIKeys[0]
|
||||||
APIKey string `json:"api_key"`
|
assert.NotEmpty(t, encValue)
|
||||||
} `json:"model_list"`
|
assert.Equal(t, "enc://", encValue[:6])
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &tmp); err != nil {
|
|
||||||
t.Fatalf("setup parse: %v", err)
|
|
||||||
}
|
|
||||||
encValue := tmp.ModelList[0].APIKey
|
|
||||||
|
|
||||||
// Write a mixed config: enc:// + plaintext + file://
|
// Write a mixed config: enc:// + plaintext + file://
|
||||||
keyFile := filepath.Join(dir, "api.key")
|
keyFile := filepath.Join(dir, "api.key")
|
||||||
if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil {
|
if err = os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil {
|
||||||
t.Fatalf("setup: %v", err)
|
t.Fatalf("setup: %v", err)
|
||||||
}
|
}
|
||||||
mixed, _ := json.Marshal(map[string]any{
|
mixed, _ := json.Marshal(map[string]any{
|
||||||
|
|
@ -910,14 +952,24 @@ func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) {
|
||||||
{"model_name": "file", "model": "openai/gpt-4", "api_key": "file://api.key"},
|
{"model_name": "file", "model": "openai/gpt-4", "api_key": "file://api.key"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err := os.WriteFile(cfgPath, mixed, 0o600); err != nil {
|
if err = os.WriteFile(cfgPath, mixed, 0o600); err != nil {
|
||||||
t.Fatalf("setup write: %v", err)
|
t.Fatalf("setup write: %v", err)
|
||||||
}
|
}
|
||||||
|
secs, _ := yaml.Marshal(map[string]any{
|
||||||
|
"model_list": map[string]map[string]any{
|
||||||
|
"enc:0": {"api_keys": []string{encValue}},
|
||||||
|
"plain:0": {"api_keys": []string{"sk-plain"}},
|
||||||
|
"file:0": {"api_keys": []string{"file://api.key"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err = os.WriteFile(filepath.Join(dir, SecurityConfigFile), secs, 0o600); err != nil {
|
||||||
|
t.Fatalf("security write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Now clear the passphrase — LoadConfig must fail because enc:// cannot be decrypted.
|
// Now clear the passphrase — LoadConfig must fail because enc:// cannot be decrypted.
|
||||||
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
|
||||||
|
|
||||||
_, err := LoadConfig(cfgPath)
|
_, err = LoadConfig(cfgPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("LoadConfig should fail when enc:// key is present and no passphrase is set")
|
t.Fatal("LoadConfig should fail when enc:// key is present and no passphrase is set")
|
||||||
}
|
}
|
||||||
|
|
@ -945,14 +997,15 @@ func TestSaveConfig_UsesPassphraseProvider(t *testing.T) {
|
||||||
t.Cleanup(func() { credential.PassphraseProvider = orig })
|
t.Cleanup(func() { credential.PassphraseProvider = orig })
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
cfg.ModelList = []ModelConfig{
|
cfg.ModelList = []*ModelConfig{
|
||||||
{ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"},
|
{ModelName: "test", Model: "openai/gpt-4"},
|
||||||
}
|
}
|
||||||
|
cfg.security.ModelList["test:0"] = ModelSecurityEntry{APIKeys: []string{"sk-plaintext"}}
|
||||||
if err := SaveConfig(cfgPath, cfg); err != nil {
|
if err := SaveConfig(cfgPath, cfg); err != nil {
|
||||||
t.Fatalf("SaveConfig: %v", err)
|
t.Fatalf("SaveConfig: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, _ := os.ReadFile(cfgPath)
|
raw, _ := os.ReadFile(filepath.Join(dir, SecurityConfigFile))
|
||||||
if !strings.Contains(string(raw), "enc://") {
|
if !strings.Contains(string(raw), "enc://") {
|
||||||
t.Errorf("SaveConfig should have encrypted plaintext key via PassphraseProvider; got:\n%s", raw)
|
t.Errorf("SaveConfig should have encrypted plaintext key via PassphraseProvider; got:\n%s", raw)
|
||||||
}
|
}
|
||||||
|
|
@ -995,7 +1048,7 @@ func TestLoadConfig_UsesPassphraseProvider(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadConfig: %v", err)
|
t.Fatalf("LoadConfig: %v", err)
|
||||||
}
|
}
|
||||||
if cfg.ModelList[0].APIKey != plainKey {
|
if cfg.ModelList[0].APIKey() != plainKey {
|
||||||
t.Errorf("api_key = %q, want %q", cfg.ModelList[0].APIKey, plainKey)
|
t.Errorf("api_key = %q, want %q", cfg.ModelList[0].APIKey(), plainKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,6 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
Telegram: TelegramConfig{
|
Telegram: TelegramConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Token: "",
|
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
Typing: TypingConfig{Enabled: true},
|
Typing: TypingConfig{Enabled: true},
|
||||||
Placeholder: PlaceholderConfig{
|
Placeholder: PlaceholderConfig{
|
||||||
|
|
@ -63,16 +62,12 @@ func DefaultConfig() *Config {
|
||||||
UseMarkdownV2: false,
|
UseMarkdownV2: false,
|
||||||
},
|
},
|
||||||
Feishu: FeishuConfig{
|
Feishu: FeishuConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
AppID: "",
|
AppID: "",
|
||||||
AppSecret: "",
|
AllowFrom: FlexibleStringSlice{},
|
||||||
EncryptKey: "",
|
|
||||||
VerificationToken: "",
|
|
||||||
AllowFrom: FlexibleStringSlice{},
|
|
||||||
},
|
},
|
||||||
Discord: DiscordConfig{
|
Discord: DiscordConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Token: "",
|
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
MentionOnly: false,
|
MentionOnly: false,
|
||||||
},
|
},
|
||||||
|
|
@ -85,28 +80,23 @@ func DefaultConfig() *Config {
|
||||||
QQ: QQConfig{
|
QQ: QQConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
AppID: "",
|
AppID: "",
|
||||||
AppSecret: "",
|
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
MaxMessageLength: 2000,
|
MaxMessageLength: 2000,
|
||||||
MaxBase64FileSizeMiB: 0,
|
MaxBase64FileSizeMiB: 0,
|
||||||
},
|
},
|
||||||
DingTalk: DingTalkConfig{
|
DingTalk: DingTalkConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
ClientID: "",
|
ClientID: "",
|
||||||
ClientSecret: "",
|
AllowFrom: FlexibleStringSlice{},
|
||||||
AllowFrom: FlexibleStringSlice{},
|
|
||||||
},
|
},
|
||||||
Slack: SlackConfig{
|
Slack: SlackConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
BotToken: "",
|
|
||||||
AppToken: "",
|
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
Matrix: MatrixConfig{
|
Matrix: MatrixConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Homeserver: "https://matrix.org",
|
Homeserver: "https://matrix.org",
|
||||||
UserID: "",
|
UserID: "",
|
||||||
AccessToken: "",
|
|
||||||
DeviceID: "",
|
DeviceID: "",
|
||||||
JoinOnInvite: true,
|
JoinOnInvite: true,
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
|
@ -119,51 +109,40 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
LINE: LINEConfig{
|
LINE: LINEConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
ChannelSecret: "",
|
WebhookHost: "0.0.0.0",
|
||||||
ChannelAccessToken: "",
|
WebhookPort: 18791,
|
||||||
WebhookHost: "0.0.0.0",
|
WebhookPath: "/webhook/line",
|
||||||
WebhookPort: 18791,
|
AllowFrom: FlexibleStringSlice{},
|
||||||
WebhookPath: "/webhook/line",
|
GroupTrigger: GroupTriggerConfig{MentionOnly: true},
|
||||||
AllowFrom: FlexibleStringSlice{},
|
|
||||||
GroupTrigger: GroupTriggerConfig{MentionOnly: true},
|
|
||||||
},
|
},
|
||||||
OneBot: OneBotConfig{
|
OneBot: OneBotConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
WSUrl: "ws://127.0.0.1:3001",
|
WSUrl: "ws://127.0.0.1:3001",
|
||||||
AccessToken: "",
|
ReconnectInterval: 5,
|
||||||
ReconnectInterval: 5,
|
AllowFrom: FlexibleStringSlice{},
|
||||||
GroupTriggerPrefix: []string{},
|
|
||||||
AllowFrom: FlexibleStringSlice{},
|
|
||||||
},
|
},
|
||||||
WeCom: WeComConfig{
|
WeCom: WeComConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Token: "",
|
WebhookURL: "",
|
||||||
EncodingAESKey: "",
|
WebhookHost: "0.0.0.0",
|
||||||
WebhookURL: "",
|
WebhookPort: 18793,
|
||||||
WebhookHost: "0.0.0.0",
|
WebhookPath: "/webhook/wecom",
|
||||||
WebhookPort: 18793,
|
AllowFrom: FlexibleStringSlice{},
|
||||||
WebhookPath: "/webhook/wecom",
|
ReplyTimeout: 5,
|
||||||
AllowFrom: FlexibleStringSlice{},
|
|
||||||
ReplyTimeout: 5,
|
|
||||||
},
|
},
|
||||||
WeComApp: WeComAppConfig{
|
WeComApp: WeComAppConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
CorpID: "",
|
CorpID: "",
|
||||||
CorpSecret: "",
|
AgentID: 0,
|
||||||
AgentID: 0,
|
WebhookHost: "0.0.0.0",
|
||||||
Token: "",
|
WebhookPort: 18792,
|
||||||
EncodingAESKey: "",
|
WebhookPath: "/webhook/wecom-app",
|
||||||
WebhookHost: "0.0.0.0",
|
AllowFrom: FlexibleStringSlice{},
|
||||||
WebhookPort: 18792,
|
ReplyTimeout: 5,
|
||||||
WebhookPath: "/webhook/wecom-app",
|
|
||||||
AllowFrom: FlexibleStringSlice{},
|
|
||||||
ReplyTimeout: 5,
|
|
||||||
},
|
},
|
||||||
WeComAIBot: WeComAIBotConfig{
|
WeComAIBot: WeComAIBotConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Token: "",
|
|
||||||
EncodingAESKey: "",
|
|
||||||
WebhookPath: "/webhook/wecom-aibot",
|
WebhookPath: "/webhook/wecom-aibot",
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
ReplyTimeout: 5,
|
ReplyTimeout: 5,
|
||||||
|
|
@ -172,7 +151,6 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
Pico: PicoConfig{
|
Pico: PicoConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Token: "",
|
|
||||||
PingInterval: 30,
|
PingInterval: 30,
|
||||||
ReadTimeout: 60,
|
ReadTimeout: 60,
|
||||||
WriteTimeout: 10,
|
WriteTimeout: 10,
|
||||||
|
|
@ -180,7 +158,7 @@ func DefaultConfig() *Config {
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []ModelConfig{
|
ModelList: []*ModelConfig{
|
||||||
// ============================================
|
// ============================================
|
||||||
// Add your API key to the model you want to use
|
// Add your API key to the model you want to use
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
@ -190,7 +168,6 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "glm-4.7",
|
ModelName: "glm-4.7",
|
||||||
Model: "zhipu/glm-4.7",
|
Model: "zhipu/glm-4.7",
|
||||||
APIBase: "https://open.bigmodel.cn/api/paas/v4",
|
APIBase: "https://open.bigmodel.cn/api/paas/v4",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// OpenAI - https://platform.openai.com/api-keys
|
// OpenAI - https://platform.openai.com/api-keys
|
||||||
|
|
@ -198,7 +175,6 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "gpt-5.4",
|
ModelName: "gpt-5.4",
|
||||||
Model: "openai/gpt-5.4",
|
Model: "openai/gpt-5.4",
|
||||||
APIBase: "https://api.openai.com/v1",
|
APIBase: "https://api.openai.com/v1",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Anthropic Claude - https://console.anthropic.com/settings/keys
|
// Anthropic Claude - https://console.anthropic.com/settings/keys
|
||||||
|
|
@ -206,7 +182,6 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "claude-sonnet-4.6",
|
ModelName: "claude-sonnet-4.6",
|
||||||
Model: "anthropic/claude-sonnet-4.6",
|
Model: "anthropic/claude-sonnet-4.6",
|
||||||
APIBase: "https://api.anthropic.com/v1",
|
APIBase: "https://api.anthropic.com/v1",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// DeepSeek - https://platform.deepseek.com/
|
// DeepSeek - https://platform.deepseek.com/
|
||||||
|
|
@ -214,7 +189,6 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "deepseek-chat",
|
ModelName: "deepseek-chat",
|
||||||
Model: "deepseek/deepseek-chat",
|
Model: "deepseek/deepseek-chat",
|
||||||
APIBase: "https://api.deepseek.com/v1",
|
APIBase: "https://api.deepseek.com/v1",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Google Gemini - https://ai.google.dev/
|
// Google Gemini - https://ai.google.dev/
|
||||||
|
|
@ -222,7 +196,6 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "gemini-2.0-flash",
|
ModelName: "gemini-2.0-flash",
|
||||||
Model: "gemini/gemini-2.0-flash-exp",
|
Model: "gemini/gemini-2.0-flash-exp",
|
||||||
APIBase: "https://generativelanguage.googleapis.com/v1beta",
|
APIBase: "https://generativelanguage.googleapis.com/v1beta",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Qwen (通义千问) - https://dashscope.console.aliyun.com/apiKey
|
// Qwen (通义千问) - https://dashscope.console.aliyun.com/apiKey
|
||||||
|
|
@ -230,7 +203,6 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "qwen-plus",
|
ModelName: "qwen-plus",
|
||||||
Model: "qwen/qwen-plus",
|
Model: "qwen/qwen-plus",
|
||||||
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Moonshot (月之暗面) - https://platform.moonshot.cn/console/api-keys
|
// Moonshot (月之暗面) - https://platform.moonshot.cn/console/api-keys
|
||||||
|
|
@ -238,7 +210,6 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "moonshot-v1-8k",
|
ModelName: "moonshot-v1-8k",
|
||||||
Model: "moonshot/moonshot-v1-8k",
|
Model: "moonshot/moonshot-v1-8k",
|
||||||
APIBase: "https://api.moonshot.cn/v1",
|
APIBase: "https://api.moonshot.cn/v1",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Groq - https://console.groq.com/keys
|
// Groq - https://console.groq.com/keys
|
||||||
|
|
@ -246,7 +217,6 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "llama-3.3-70b",
|
ModelName: "llama-3.3-70b",
|
||||||
Model: "groq/llama-3.3-70b-versatile",
|
Model: "groq/llama-3.3-70b-versatile",
|
||||||
APIBase: "https://api.groq.com/openai/v1",
|
APIBase: "https://api.groq.com/openai/v1",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// OpenRouter (100+ models) - https://openrouter.ai/keys
|
// OpenRouter (100+ models) - https://openrouter.ai/keys
|
||||||
|
|
@ -254,13 +224,11 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "openrouter-auto",
|
ModelName: "openrouter-auto",
|
||||||
Model: "openrouter/auto",
|
Model: "openrouter/auto",
|
||||||
APIBase: "https://openrouter.ai/api/v1",
|
APIBase: "https://openrouter.ai/api/v1",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ModelName: "openrouter-gpt-5.4",
|
ModelName: "openrouter-gpt-5.4",
|
||||||
Model: "openrouter/openai/gpt-5.4",
|
Model: "openrouter/openai/gpt-5.4",
|
||||||
APIBase: "https://openrouter.ai/api/v1",
|
APIBase: "https://openrouter.ai/api/v1",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// NVIDIA - https://build.nvidia.com/
|
// NVIDIA - https://build.nvidia.com/
|
||||||
|
|
@ -268,7 +236,6 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "nemotron-4-340b",
|
ModelName: "nemotron-4-340b",
|
||||||
Model: "nvidia/nemotron-4-340b-instruct",
|
Model: "nvidia/nemotron-4-340b-instruct",
|
||||||
APIBase: "https://integrate.api.nvidia.com/v1",
|
APIBase: "https://integrate.api.nvidia.com/v1",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Cerebras - https://inference.cerebras.ai/
|
// Cerebras - https://inference.cerebras.ai/
|
||||||
|
|
@ -276,7 +243,6 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "cerebras-llama-3.3-70b",
|
ModelName: "cerebras-llama-3.3-70b",
|
||||||
Model: "cerebras/llama-3.3-70b",
|
Model: "cerebras/llama-3.3-70b",
|
||||||
APIBase: "https://api.cerebras.ai/v1",
|
APIBase: "https://api.cerebras.ai/v1",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Vivgrid - https://vivgrid.com
|
// Vivgrid - https://vivgrid.com
|
||||||
|
|
@ -284,7 +250,6 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "vivgrid-auto",
|
ModelName: "vivgrid-auto",
|
||||||
Model: "vivgrid/auto",
|
Model: "vivgrid/auto",
|
||||||
APIBase: "https://api.vivgrid.com/v1",
|
APIBase: "https://api.vivgrid.com/v1",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Volcengine (火山引擎) - https://console.volcengine.com/ark
|
// Volcengine (火山引擎) - https://console.volcengine.com/ark
|
||||||
|
|
@ -292,13 +257,11 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "ark-code-latest",
|
ModelName: "ark-code-latest",
|
||||||
Model: "volcengine/ark-code-latest",
|
Model: "volcengine/ark-code-latest",
|
||||||
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
|
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ModelName: "doubao-pro",
|
ModelName: "doubao-pro",
|
||||||
Model: "volcengine/doubao-pro-32k",
|
Model: "volcengine/doubao-pro-32k",
|
||||||
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
|
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ShengsuanYun (神算云)
|
// ShengsuanYun (神算云)
|
||||||
|
|
@ -306,7 +269,6 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "deepseek-v3",
|
ModelName: "deepseek-v3",
|
||||||
Model: "shengsuanyun/deepseek-v3",
|
Model: "shengsuanyun/deepseek-v3",
|
||||||
APIBase: "https://api.shengsuanyun.com/v1",
|
APIBase: "https://api.shengsuanyun.com/v1",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Antigravity (Google Cloud Code Assist) - OAuth only
|
// Antigravity (Google Cloud Code Assist) - OAuth only
|
||||||
|
|
@ -329,7 +291,6 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "llama3",
|
ModelName: "llama3",
|
||||||
Model: "ollama/llama3",
|
Model: "ollama/llama3",
|
||||||
APIBase: "http://localhost:11434/v1",
|
APIBase: "http://localhost:11434/v1",
|
||||||
APIKey: "ollama",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Mistral AI - https://console.mistral.ai/api-keys
|
// Mistral AI - https://console.mistral.ai/api-keys
|
||||||
|
|
@ -337,7 +298,6 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "mistral-small",
|
ModelName: "mistral-small",
|
||||||
Model: "mistral/mistral-small-latest",
|
Model: "mistral/mistral-small-latest",
|
||||||
APIBase: "https://api.mistral.ai/v1",
|
APIBase: "https://api.mistral.ai/v1",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Avian - https://avian.io
|
// Avian - https://avian.io
|
||||||
|
|
@ -345,13 +305,11 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "deepseek-v3.2",
|
ModelName: "deepseek-v3.2",
|
||||||
Model: "avian/deepseek/deepseek-v3.2",
|
Model: "avian/deepseek/deepseek-v3.2",
|
||||||
APIBase: "https://api.avian.io/v1",
|
APIBase: "https://api.avian.io/v1",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ModelName: "kimi-k2.5",
|
ModelName: "kimi-k2.5",
|
||||||
Model: "avian/moonshotai/kimi-k2.5",
|
Model: "avian/moonshotai/kimi-k2.5",
|
||||||
APIBase: "https://api.avian.io/v1",
|
APIBase: "https://api.avian.io/v1",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Minimax - https://api.minimaxi.com/
|
// Minimax - https://api.minimaxi.com/
|
||||||
|
|
@ -359,7 +317,6 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "MiniMax-M2.5",
|
ModelName: "MiniMax-M2.5",
|
||||||
Model: "minimax/MiniMax-M2.5",
|
Model: "minimax/MiniMax-M2.5",
|
||||||
APIBase: "https://api.minimaxi.com/v1",
|
APIBase: "https://api.minimaxi.com/v1",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// LongCat - https://longcat.chat/platform
|
// LongCat - https://longcat.chat/platform
|
||||||
|
|
@ -367,7 +324,6 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "LongCat-Flash-Thinking",
|
ModelName: "LongCat-Flash-Thinking",
|
||||||
Model: "longcat/LongCat-Flash-Thinking",
|
Model: "longcat/LongCat-Flash-Thinking",
|
||||||
APIBase: "https://api.longcat.chat/openai",
|
APIBase: "https://api.longcat.chat/openai",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ModelScope (魔搭社区) - https://modelscope.cn/my/tokens
|
// ModelScope (魔搭社区) - https://modelscope.cn/my/tokens
|
||||||
|
|
@ -375,7 +331,6 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "modelscope-qwen",
|
ModelName: "modelscope-qwen",
|
||||||
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
|
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||||
APIBase: "https://api-inference.modelscope.cn/v1",
|
APIBase: "https://api-inference.modelscope.cn/v1",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// VLLM (local) - http://localhost:8000
|
// VLLM (local) - http://localhost:8000
|
||||||
|
|
@ -383,7 +338,6 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "local-model",
|
ModelName: "local-model",
|
||||||
Model: "vllm/custom-model",
|
Model: "vllm/custom-model",
|
||||||
APIBase: "http://localhost:8000/v1",
|
APIBase: "http://localhost:8000/v1",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Azure OpenAI - https://portal.azure.com
|
// Azure OpenAI - https://portal.azure.com
|
||||||
|
|
@ -392,7 +346,6 @@ func DefaultConfig() *Config {
|
||||||
ModelName: "azure-gpt5",
|
ModelName: "azure-gpt5",
|
||||||
Model: "azure/my-gpt5-deployment",
|
Model: "azure/my-gpt5-deployment",
|
||||||
APIBase: "https://your-resource.openai.azure.com",
|
APIBase: "https://your-resource.openai.azure.com",
|
||||||
APIKey: "",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Gateway: GatewayConfig{
|
Gateway: GatewayConfig{
|
||||||
|
|
@ -418,14 +371,10 @@ func DefaultConfig() *Config {
|
||||||
Format: "plaintext",
|
Format: "plaintext",
|
||||||
Brave: BraveConfig{
|
Brave: BraveConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
APIKey: "",
|
|
||||||
APIKeys: nil,
|
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
},
|
},
|
||||||
Tavily: TavilyConfig{
|
Tavily: TavilyConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
APIKey: "",
|
|
||||||
APIKeys: nil,
|
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
},
|
},
|
||||||
DuckDuckGo: DuckDuckGoConfig{
|
DuckDuckGo: DuckDuckGoConfig{
|
||||||
|
|
@ -434,8 +383,6 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
Perplexity: PerplexityConfig{
|
Perplexity: PerplexityConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
APIKey: "",
|
|
||||||
APIKeys: nil,
|
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
},
|
},
|
||||||
SearXNG: SearXNGConfig{
|
SearXNG: SearXNGConfig{
|
||||||
|
|
@ -445,7 +392,6 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
GLMSearch: GLMSearchConfig{
|
GLMSearch: GLMSearchConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
APIKey: "",
|
|
||||||
BaseURL: "https://open.bigmodel.cn/api/paas/v4/web_search",
|
BaseURL: "https://open.bigmodel.cn/api/paas/v4/web_search",
|
||||||
SearchEngine: "search_std",
|
SearchEngine: "search_std",
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
|
|
@ -559,5 +505,10 @@ func DefaultConfig() *Config {
|
||||||
BuildTime: BuildTime,
|
BuildTime: BuildTime,
|
||||||
GoVersion: GoVersion,
|
GoVersion: GoVersion,
|
||||||
},
|
},
|
||||||
|
security: &SecurityConfig{
|
||||||
|
ModelList: map[string]ModelSecurityEntry{},
|
||||||
|
Channels: ChannelsSecurity{},
|
||||||
|
Web: WebToolsSecurity{},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
423
pkg/config/example_security_usage.go
Normal file
423
pkg/config/example_security_usage.go
Normal file
|
|
@ -0,0 +1,423 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
// This file demonstrates how to use the security configuration feature
|
||||||
|
// It's not meant to be compiled, just for documentation purposes
|
||||||
|
|
||||||
|
/*
|
||||||
|
Package config
|
||||||
|
|
||||||
|
# Example: Using Security Configuration
|
||||||
|
|
||||||
|
## 1. Create security.yml
|
||||||
|
|
||||||
|
File: ~/.picoclaw/security.yml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Model API Keys
|
||||||
|
# Note: Use 'api_keys' array for multiple keys (load balancing/failover)
|
||||||
|
# Single key should be provided as an array with one element
|
||||||
|
model_list:
|
||||||
|
|
||||||
|
gpt-5.4:
|
||||||
|
api_keys:
|
||||||
|
- "sk-proj-your-actual-openai-key-1"
|
||||||
|
- "sk-proj-your-actual-openai-key-2" # Failover key
|
||||||
|
claude-sonnet-4.6:
|
||||||
|
api_keys:
|
||||||
|
- "sk-ant-your-actual-anthropic-key" # Single key in array format
|
||||||
|
|
||||||
|
# Channel Tokens
|
||||||
|
channels:
|
||||||
|
|
||||||
|
telegram:
|
||||||
|
token: "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||||
|
discord:
|
||||||
|
token: "your-discord-bot-token"
|
||||||
|
|
||||||
|
# Web Tool Keys
|
||||||
|
# Note: Use 'api_keys' array for multiple keys (load balancing/failover)
|
||||||
|
# For GLMSearch, use 'api_key' (single string)
|
||||||
|
web:
|
||||||
|
|
||||||
|
brave:
|
||||||
|
api_keys:
|
||||||
|
- "BSAyour-brave-api-key-1"
|
||||||
|
- "BSAyour-brave-api-key-2" # Failover key
|
||||||
|
tavily:
|
||||||
|
api_keys:
|
||||||
|
- "tvly-your-tavily-api-key" # Single key in array format
|
||||||
|
glm_search:
|
||||||
|
api_key: "your-glm-search-api-key" # Single key (not array)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Update config.json to use references
|
||||||
|
|
||||||
|
File: ~/.picoclaw/config.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "~/picoclaw-workspace",
|
||||||
|
"model_name": "gpt-5.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_base": "https://api.openai.com/v1",
|
||||||
|
"api_key": "ref:model_list.gpt-5.4.api_key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
|
"api_base": "https://api.anthropic.com/v1",
|
||||||
|
"api_key": "ref:model_list.claude-sonnet-4.6.api_key"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"channels": {
|
||||||
|
"telegram": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "ref:channels.telegram.token"
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "ref:channels.discord.token"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"brave": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "ref:web.brave.api_key"
|
||||||
|
},
|
||||||
|
"tavily": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "ref:web.tavily.api_key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Set proper permissions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod 600 ~/.picoclaw/security.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Add to .gitignore
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
# Security configuration
|
||||||
|
security.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Verify it works
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw --version
|
||||||
|
```
|
||||||
|
|
||||||
|
# Available Reference Paths
|
||||||
|
|
||||||
|
## Model API Keys
|
||||||
|
- ref:model_list.<model_name>.api_key
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- ref:model_list.gpt-5.4.api_key
|
||||||
|
- ref:model_list.claude-sonnet-4.6.api_key
|
||||||
|
|
||||||
|
**Note:** In security.yml, use `api_keys` (array) format for models.
|
||||||
|
Both single and multiple keys should use the array format.
|
||||||
|
|
||||||
|
## Channel Tokens/Secrets
|
||||||
|
- ref:channels.telegram.token
|
||||||
|
- ref:channels.feishu.app_secret
|
||||||
|
- ref:channels.feishu.encrypt_key
|
||||||
|
- ref:channels.feishu.verification_token
|
||||||
|
- ref:channels.discord.token
|
||||||
|
- ref:channels.qq.app_secret
|
||||||
|
- ref:channels.dingtalk.client_secret
|
||||||
|
- ref:channels.slack.bot_token
|
||||||
|
- ref:channels.slack.app_token
|
||||||
|
- ref:channels.matrix.access_token
|
||||||
|
- ref:channels.line.channel_secret
|
||||||
|
- ref:channels.line.channel_access_token
|
||||||
|
- ref:channels.onebot.access_token
|
||||||
|
- ref:channels.wecom.token
|
||||||
|
- ref:channels.wecom.encoding_aes_key
|
||||||
|
- ref:channels.wecom_app.corp_secret
|
||||||
|
- ref:channels.wecom_app.token
|
||||||
|
- ref:channels.wecom_app.encoding_aes_key
|
||||||
|
- ref:channels.wecom_aibot.token
|
||||||
|
- ref:channels.wecom_aibot.encoding_aes_key
|
||||||
|
- ref:channels.pico.token
|
||||||
|
- ref:channels.irc.password
|
||||||
|
- ref:channels.irc.nickserv_password
|
||||||
|
- ref:channels.irc.sasl_password
|
||||||
|
|
||||||
|
## Web Tool API Keys
|
||||||
|
- ref:web.brave.api_key
|
||||||
|
- ref:web.tavily.api_key
|
||||||
|
- ref:web.perplexity.api_key
|
||||||
|
- ref:web.glm_search.api_key
|
||||||
|
|
||||||
|
**Note:**
|
||||||
|
- Brave, Tavily, Perplexity: Use `api_keys` (array) format in security.yml
|
||||||
|
- GLMSearch: Use `api_key` (single string) format in security.yml
|
||||||
|
|
||||||
|
## Skills Registry Tokens
|
||||||
|
- ref:skills.github.token
|
||||||
|
- ref:skills.clawhub.auth_token
|
||||||
|
|
||||||
|
# Backward Compatibility
|
||||||
|
|
||||||
|
You can still use direct values in config.json if needed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "local-model",
|
||||||
|
"model": "ollama/llama3",
|
||||||
|
"api_base": "http://localhost:11434/v1",
|
||||||
|
"api_key": "ollama" // Direct value (no reference)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also mix references and direct values:
|
||||||
|
|
||||||
|
```json
|
||||||
|
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "cloud-model",
|
||||||
|
"api_key": "ref:model_list.cloud-model.api_key" // From security.yml
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "local-model",
|
||||||
|
"api_key": "ollama" // Direct value
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
# Migration from Old Config
|
||||||
|
|
||||||
|
## Step 1: Backup your config
|
||||||
|
```bash
|
||||||
|
cp ~/.picoclaw/config.json ~/.picoclaw/config.json.backup
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Copy the example security file
|
||||||
|
```bash
|
||||||
|
cp security.example.yml ~/.picoclaw/security.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Fill in your API keys
|
||||||
|
Edit ~/.picoclaw/security.yml and replace placeholders with your actual keys.
|
||||||
|
|
||||||
|
## Step 4: Update config.json references
|
||||||
|
Replace sensitive values in ~/.picoclaw/config.json with ref: references.
|
||||||
|
|
||||||
|
## Step 5: Test
|
||||||
|
```bash
|
||||||
|
picoclaw --version
|
||||||
|
```
|
||||||
|
|
||||||
|
If everything works, you can delete the backup:
|
||||||
|
```bash
|
||||||
|
rm ~/.picoclaw/config.json.backup
|
||||||
|
```
|
||||||
|
|
||||||
|
# Advanced Features
|
||||||
|
|
||||||
|
## Multiple API Keys (Load Balancing & Failover)
|
||||||
|
|
||||||
|
You can configure multiple API keys for both models and web tools to enable:
|
||||||
|
- **Load balancing**: Requests are distributed across multiple keys
|
||||||
|
- **Failover**: If a key fails, the system automatically switches to another key
|
||||||
|
|
||||||
|
### Example: Model with Multiple Keys
|
||||||
|
|
||||||
|
**security.yml:**
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
|
||||||
|
gpt-5.4:
|
||||||
|
api_keys:
|
||||||
|
- "sk-proj-key-1"
|
||||||
|
- "sk-proj-key-2"
|
||||||
|
- "sk-proj-key-3"
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**config.json:**
|
||||||
|
```json
|
||||||
|
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "ref:model_list.gpt-5.4.api_key"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Web Tool with Multiple Keys
|
||||||
|
|
||||||
|
**security.yml:**
|
||||||
|
```yaml
|
||||||
|
web:
|
||||||
|
|
||||||
|
brave:
|
||||||
|
api_keys:
|
||||||
|
- "BSA-key-1"
|
||||||
|
- "BSA-key-2"
|
||||||
|
tavily:
|
||||||
|
api_keys:
|
||||||
|
- "tvly-your-key" # Single key in array format
|
||||||
|
glm_search:
|
||||||
|
api_key: "your-glm-key" # GLMSearch uses single key format
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**config.json:**
|
||||||
|
```json
|
||||||
|
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"brave": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "ref:web.brave.api_key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### Single Key
|
||||||
|
|
||||||
|
Use array format with one element:
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
|
||||||
|
gpt-5.4:
|
||||||
|
api_keys:
|
||||||
|
- "sk-proj-your-key" # Single key in array format
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Keys (Load Balancing & Failover)
|
||||||
|
|
||||||
|
Use array format with multiple elements:
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
|
||||||
|
gpt-5.4:
|
||||||
|
api_keys:
|
||||||
|
- "sk-proj-key-1"
|
||||||
|
- "sk-proj-key-2"
|
||||||
|
- "sk-proj-key-3"
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** All model keys in security.yml must use the `api_keys` (plural) array format.
|
||||||
|
The single `api_key` (singular) format is NOT supported for models.
|
||||||
|
|
||||||
|
### Model Index Matching
|
||||||
|
|
||||||
|
The system supports intelligent model name matching in security.yml:
|
||||||
|
|
||||||
|
**Example 1: Exact Match**
|
||||||
|
```yaml
|
||||||
|
# config.json
|
||||||
|
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4:0"
|
||||||
|
}
|
||||||
|
|
||||||
|
# security.yml (exact match with index)
|
||||||
|
model_list:
|
||||||
|
|
||||||
|
gpt-5.4:0:
|
||||||
|
api_keys: ["key-1"]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example 2: Base Name Match**
|
||||||
|
```yaml
|
||||||
|
# config.json
|
||||||
|
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4:0"
|
||||||
|
}
|
||||||
|
|
||||||
|
# security.yml (base name without index)
|
||||||
|
model_list:
|
||||||
|
|
||||||
|
gpt-5.4:
|
||||||
|
api_keys: ["key-1"]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Both methods work. The base name match allows you to use simpler keys in security.yml
|
||||||
|
even when your config uses indexed model names for load balancing.
|
||||||
|
|
||||||
|
### Security File Permissions
|
||||||
|
|
||||||
|
The security file should have restricted permissions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod 600 ~/.picoclaw/security.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures only the owner can read and write the file.
|
||||||
|
|
||||||
|
# Security Best Practices
|
||||||
|
|
||||||
|
1. Never commit security.yml to version control
|
||||||
|
2. Set file permissions: chmod 600 ~/.picoclaw/security.yml
|
||||||
|
3. Use different keys for different environments
|
||||||
|
4. Rotate keys regularly and update security.yml
|
||||||
|
5. Encrypt backups containing security.yml
|
||||||
|
|
||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
## Error: "model security entry not found"
|
||||||
|
- Check that the model name in config.json matches exactly in security.yml
|
||||||
|
- Verify the model_list section exists in security.yml
|
||||||
|
|
||||||
|
## Error: "failed to load security config"
|
||||||
|
- Ensure security.yml exists in the same directory as config.json
|
||||||
|
- Check YAML syntax is valid
|
||||||
|
- Verify file permissions allow reading
|
||||||
|
|
||||||
|
## Error: "unknown reference path"
|
||||||
|
- Verify the reference format is correct
|
||||||
|
- Check the path structure matches the examples above
|
||||||
|
- Ensure all required sections exist in security.yml
|
||||||
|
*/
|
||||||
|
package config
|
||||||
|
|
||||||
|
// This file is documentation only
|
||||||
|
|
@ -26,10 +26,10 @@ func buildModelWithProtocol(protocol, model string) string {
|
||||||
return protocol + "/" + model
|
return protocol + "/" + model
|
||||||
}
|
}
|
||||||
|
|
||||||
// v0ConvertProvidersToModelList converts the old ProvidersConfig to a slice of ModelConfig.
|
// v0ConvertProvidersToModelList converts the old providersConfigV0 to a slice of ModelConfig.
|
||||||
// This enables backward compatibility with existing configurations.
|
// This enables backward compatibility with existing configurations.
|
||||||
// It preserves the user's configured model from agents.defaults.model when possible.
|
// It preserves the user's configured model from agents.defaults.model when possible.
|
||||||
func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
func v0ConvertProvidersToModelList(cfg *configV0) []modelConfigV0 {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -41,7 +41,7 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
// protocol is the protocol prefix for the model field
|
// protocol is the protocol prefix for the model field
|
||||||
protocol string
|
protocol string
|
||||||
// buildConfig creates the ModelConfig from ProviderConfig
|
// buildConfig creates the ModelConfig from ProviderConfig
|
||||||
buildConfig func(p ProvidersConfig) (ModelConfig, bool)
|
buildConfig func(p providersConfigV0) (modelConfigV0, bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user's configured provider and model
|
// Get user's configured provider and model
|
||||||
|
|
@ -50,7 +50,7 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
|
|
||||||
p := cfg.Providers
|
p := cfg.Providers
|
||||||
|
|
||||||
var result []ModelConfig
|
var result []modelConfigV0
|
||||||
|
|
||||||
// Track if we've applied the legacy model name fix (only for first provider)
|
// Track if we've applied the legacy model name fix (only for first provider)
|
||||||
legacyModelNameApplied := false
|
legacyModelNameApplied := false
|
||||||
|
|
@ -60,11 +60,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"openai", "gpt"},
|
providerNames: []string{"openai", "gpt"},
|
||||||
protocol: "openai",
|
protocol: "openai",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" {
|
if p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "openai",
|
ModelName: "openai",
|
||||||
Model: "openai/gpt-5.4",
|
Model: "openai/gpt-5.4",
|
||||||
APIKey: p.OpenAI.APIKey,
|
APIKey: p.OpenAI.APIKey,
|
||||||
|
|
@ -78,11 +78,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"anthropic", "claude"},
|
providerNames: []string{"anthropic", "claude"},
|
||||||
protocol: "anthropic",
|
protocol: "anthropic",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" {
|
if p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "anthropic",
|
ModelName: "anthropic",
|
||||||
Model: "anthropic/claude-sonnet-4.6",
|
Model: "anthropic/claude-sonnet-4.6",
|
||||||
APIKey: p.Anthropic.APIKey,
|
APIKey: p.Anthropic.APIKey,
|
||||||
|
|
@ -96,11 +96,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"litellm"},
|
providerNames: []string{"litellm"},
|
||||||
protocol: "litellm",
|
protocol: "litellm",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" {
|
if p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "litellm",
|
ModelName: "litellm",
|
||||||
Model: "litellm/auto",
|
Model: "litellm/auto",
|
||||||
APIKey: p.LiteLLM.APIKey,
|
APIKey: p.LiteLLM.APIKey,
|
||||||
|
|
@ -113,11 +113,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"openrouter"},
|
providerNames: []string{"openrouter"},
|
||||||
protocol: "openrouter",
|
protocol: "openrouter",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" {
|
if p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "openrouter",
|
ModelName: "openrouter",
|
||||||
Model: "openrouter/auto",
|
Model: "openrouter/auto",
|
||||||
APIKey: p.OpenRouter.APIKey,
|
APIKey: p.OpenRouter.APIKey,
|
||||||
|
|
@ -130,11 +130,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"groq"},
|
providerNames: []string{"groq"},
|
||||||
protocol: "groq",
|
protocol: "groq",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.Groq.APIKey == "" && p.Groq.APIBase == "" {
|
if p.Groq.APIKey == "" && p.Groq.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "groq",
|
ModelName: "groq",
|
||||||
Model: "groq/llama-3.1-70b-versatile",
|
Model: "groq/llama-3.1-70b-versatile",
|
||||||
APIKey: p.Groq.APIKey,
|
APIKey: p.Groq.APIKey,
|
||||||
|
|
@ -147,11 +147,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"zhipu", "glm"},
|
providerNames: []string{"zhipu", "glm"},
|
||||||
protocol: "zhipu",
|
protocol: "zhipu",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" {
|
if p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "zhipu",
|
ModelName: "zhipu",
|
||||||
Model: "zhipu/glm-4",
|
Model: "zhipu/glm-4",
|
||||||
APIKey: p.Zhipu.APIKey,
|
APIKey: p.Zhipu.APIKey,
|
||||||
|
|
@ -164,11 +164,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"vllm"},
|
providerNames: []string{"vllm"},
|
||||||
protocol: "vllm",
|
protocol: "vllm",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.VLLM.APIKey == "" && p.VLLM.APIBase == "" {
|
if p.VLLM.APIKey == "" && p.VLLM.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "vllm",
|
ModelName: "vllm",
|
||||||
Model: "vllm/auto",
|
Model: "vllm/auto",
|
||||||
APIKey: p.VLLM.APIKey,
|
APIKey: p.VLLM.APIKey,
|
||||||
|
|
@ -181,11 +181,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"gemini", "google"},
|
providerNames: []string{"gemini", "google"},
|
||||||
protocol: "gemini",
|
protocol: "gemini",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.Gemini.APIKey == "" && p.Gemini.APIBase == "" {
|
if p.Gemini.APIKey == "" && p.Gemini.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "gemini",
|
ModelName: "gemini",
|
||||||
Model: "gemini/gemini-pro",
|
Model: "gemini/gemini-pro",
|
||||||
APIKey: p.Gemini.APIKey,
|
APIKey: p.Gemini.APIKey,
|
||||||
|
|
@ -198,11 +198,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"nvidia"},
|
providerNames: []string{"nvidia"},
|
||||||
protocol: "nvidia",
|
protocol: "nvidia",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" {
|
if p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "nvidia",
|
ModelName: "nvidia",
|
||||||
Model: "nvidia/meta/llama-3.1-8b-instruct",
|
Model: "nvidia/meta/llama-3.1-8b-instruct",
|
||||||
APIKey: p.Nvidia.APIKey,
|
APIKey: p.Nvidia.APIKey,
|
||||||
|
|
@ -215,11 +215,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"ollama"},
|
providerNames: []string{"ollama"},
|
||||||
protocol: "ollama",
|
protocol: "ollama",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.Ollama.APIKey == "" && p.Ollama.APIBase == "" {
|
if p.Ollama.APIKey == "" && p.Ollama.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "ollama",
|
ModelName: "ollama",
|
||||||
Model: "ollama/llama3",
|
Model: "ollama/llama3",
|
||||||
APIKey: p.Ollama.APIKey,
|
APIKey: p.Ollama.APIKey,
|
||||||
|
|
@ -232,11 +232,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"moonshot", "kimi"},
|
providerNames: []string{"moonshot", "kimi"},
|
||||||
protocol: "moonshot",
|
protocol: "moonshot",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" {
|
if p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "moonshot",
|
ModelName: "moonshot",
|
||||||
Model: "moonshot/kimi",
|
Model: "moonshot/kimi",
|
||||||
APIKey: p.Moonshot.APIKey,
|
APIKey: p.Moonshot.APIKey,
|
||||||
|
|
@ -249,11 +249,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"shengsuanyun"},
|
providerNames: []string{"shengsuanyun"},
|
||||||
protocol: "shengsuanyun",
|
protocol: "shengsuanyun",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" {
|
if p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "shengsuanyun",
|
ModelName: "shengsuanyun",
|
||||||
Model: "shengsuanyun/auto",
|
Model: "shengsuanyun/auto",
|
||||||
APIKey: p.ShengSuanYun.APIKey,
|
APIKey: p.ShengSuanYun.APIKey,
|
||||||
|
|
@ -266,11 +266,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"deepseek"},
|
providerNames: []string{"deepseek"},
|
||||||
protocol: "deepseek",
|
protocol: "deepseek",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" {
|
if p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "deepseek",
|
ModelName: "deepseek",
|
||||||
Model: "deepseek/deepseek-chat",
|
Model: "deepseek/deepseek-chat",
|
||||||
APIKey: p.DeepSeek.APIKey,
|
APIKey: p.DeepSeek.APIKey,
|
||||||
|
|
@ -283,11 +283,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"cerebras"},
|
providerNames: []string{"cerebras"},
|
||||||
protocol: "cerebras",
|
protocol: "cerebras",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" {
|
if p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "cerebras",
|
ModelName: "cerebras",
|
||||||
Model: "cerebras/llama-3.3-70b",
|
Model: "cerebras/llama-3.3-70b",
|
||||||
APIKey: p.Cerebras.APIKey,
|
APIKey: p.Cerebras.APIKey,
|
||||||
|
|
@ -300,11 +300,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"vivgrid"},
|
providerNames: []string{"vivgrid"},
|
||||||
protocol: "vivgrid",
|
protocol: "vivgrid",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" {
|
if p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "vivgrid",
|
ModelName: "vivgrid",
|
||||||
Model: "vivgrid/auto",
|
Model: "vivgrid/auto",
|
||||||
APIKey: p.Vivgrid.APIKey,
|
APIKey: p.Vivgrid.APIKey,
|
||||||
|
|
@ -317,11 +317,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"volcengine", "doubao"},
|
providerNames: []string{"volcengine", "doubao"},
|
||||||
protocol: "volcengine",
|
protocol: "volcengine",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" {
|
if p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "volcengine",
|
ModelName: "volcengine",
|
||||||
Model: "volcengine/doubao-pro",
|
Model: "volcengine/doubao-pro",
|
||||||
APIKey: p.VolcEngine.APIKey,
|
APIKey: p.VolcEngine.APIKey,
|
||||||
|
|
@ -334,11 +334,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"github_copilot", "copilot"},
|
providerNames: []string{"github_copilot", "copilot"},
|
||||||
protocol: "github-copilot",
|
protocol: "github-copilot",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && p.GitHubCopilot.ConnectMode == "" {
|
if p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && p.GitHubCopilot.ConnectMode == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "github-copilot",
|
ModelName: "github-copilot",
|
||||||
Model: "github-copilot/gpt-5.4",
|
Model: "github-copilot/gpt-5.4",
|
||||||
APIBase: p.GitHubCopilot.APIBase,
|
APIBase: p.GitHubCopilot.APIBase,
|
||||||
|
|
@ -349,11 +349,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"antigravity"},
|
providerNames: []string{"antigravity"},
|
||||||
protocol: "antigravity",
|
protocol: "antigravity",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.Antigravity.APIKey == "" && p.Antigravity.AuthMethod == "" {
|
if p.Antigravity.APIKey == "" && p.Antigravity.AuthMethod == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "antigravity",
|
ModelName: "antigravity",
|
||||||
Model: "antigravity/gemini-2.0-flash",
|
Model: "antigravity/gemini-2.0-flash",
|
||||||
APIKey: p.Antigravity.APIKey,
|
APIKey: p.Antigravity.APIKey,
|
||||||
|
|
@ -364,11 +364,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"qwen", "tongyi"},
|
providerNames: []string{"qwen", "tongyi"},
|
||||||
protocol: "qwen",
|
protocol: "qwen",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.Qwen.APIKey == "" && p.Qwen.APIBase == "" {
|
if p.Qwen.APIKey == "" && p.Qwen.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "qwen",
|
ModelName: "qwen",
|
||||||
Model: "qwen/qwen-max",
|
Model: "qwen/qwen-max",
|
||||||
APIKey: p.Qwen.APIKey,
|
APIKey: p.Qwen.APIKey,
|
||||||
|
|
@ -381,11 +381,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"mistral"},
|
providerNames: []string{"mistral"},
|
||||||
protocol: "mistral",
|
protocol: "mistral",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.Mistral.APIKey == "" && p.Mistral.APIBase == "" {
|
if p.Mistral.APIKey == "" && p.Mistral.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "mistral",
|
ModelName: "mistral",
|
||||||
Model: "mistral/mistral-small-latest",
|
Model: "mistral/mistral-small-latest",
|
||||||
APIKey: p.Mistral.APIKey,
|
APIKey: p.Mistral.APIKey,
|
||||||
|
|
@ -398,11 +398,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"avian"},
|
providerNames: []string{"avian"},
|
||||||
protocol: "avian",
|
protocol: "avian",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.Avian.APIKey == "" && p.Avian.APIBase == "" {
|
if p.Avian.APIKey == "" && p.Avian.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "avian",
|
ModelName: "avian",
|
||||||
Model: "avian/deepseek/deepseek-v3.2",
|
Model: "avian/deepseek/deepseek-v3.2",
|
||||||
APIKey: p.Avian.APIKey,
|
APIKey: p.Avian.APIKey,
|
||||||
|
|
@ -415,11 +415,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"longcat"},
|
providerNames: []string{"longcat"},
|
||||||
protocol: "longcat",
|
protocol: "longcat",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.LongCat.APIKey == "" && p.LongCat.APIBase == "" {
|
if p.LongCat.APIKey == "" && p.LongCat.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "longcat",
|
ModelName: "longcat",
|
||||||
Model: "longcat/LongCat-Flash-Thinking",
|
Model: "longcat/LongCat-Flash-Thinking",
|
||||||
APIKey: p.LongCat.APIKey,
|
APIKey: p.LongCat.APIKey,
|
||||||
|
|
@ -432,11 +432,11 @@ func v0ConvertProvidersToModelList(cfg *configV0) []ModelConfig {
|
||||||
{
|
{
|
||||||
providerNames: []string{"modelscope"},
|
providerNames: []string{"modelscope"},
|
||||||
protocol: "modelscope",
|
protocol: "modelscope",
|
||||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||||
if p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" {
|
if p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" {
|
||||||
return ModelConfig{}, false
|
return modelConfigV0{}, false
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return modelConfigV0{
|
||||||
ModelName: "modelscope",
|
ModelName: "modelscope",
|
||||||
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
|
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||||
APIKey: p.ModelScope.APIKey,
|
APIKey: p.ModelScope.APIKey,
|
||||||
|
|
@ -485,7 +485,27 @@ func loadConfigV0(data []byte) (migratable, error) {
|
||||||
|
|
||||||
// Auto-migrate: if only legacy providers config exists, convert to model_list
|
// Auto-migrate: if only legacy providers config exists, convert to model_list
|
||||||
if len(v0.ModelList) == 0 && !v0.Providers.IsEmpty() {
|
if len(v0.ModelList) == 0 && !v0.Providers.IsEmpty() {
|
||||||
v0.ModelList = v0ConvertProvidersToModelList(&v0)
|
newModelList := v0ConvertProvidersToModelList(&v0)
|
||||||
|
// Convert []ModelConfig to []modelConfigV0
|
||||||
|
v0.ModelList = make([]modelConfigV0, len(newModelList))
|
||||||
|
for i, m := range newModelList {
|
||||||
|
v0.ModelList[i] = modelConfigV0{
|
||||||
|
ModelName: m.ModelName,
|
||||||
|
Model: m.Model,
|
||||||
|
APIBase: m.APIBase,
|
||||||
|
Proxy: m.Proxy,
|
||||||
|
Fallbacks: m.Fallbacks,
|
||||||
|
AuthMethod: m.AuthMethod,
|
||||||
|
ConnectMode: m.ConnectMode,
|
||||||
|
Workspace: m.Workspace,
|
||||||
|
RPM: m.RPM,
|
||||||
|
MaxTokensField: m.MaxTokensField,
|
||||||
|
RequestTimeout: m.RequestTimeout,
|
||||||
|
ThinkingLevel: m.ThinkingLevel,
|
||||||
|
APIKey: m.APIKey,
|
||||||
|
APIKeys: m.APIKeys,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &v0, nil
|
return &v0, nil
|
||||||
|
|
|
||||||
|
|
@ -103,8 +103,8 @@ func TestMigration_Integration_LegacyConfigWithoutWorkspace(t *testing.T) {
|
||||||
if !cfg.Channels.Telegram.Enabled {
|
if !cfg.Channels.Telegram.Enabled {
|
||||||
t.Error("Telegram.Enabled should be true")
|
t.Error("Telegram.Enabled should be true")
|
||||||
}
|
}
|
||||||
if cfg.Channels.Telegram.Token != "test-token" {
|
if cfg.Channels.Telegram.Token() != "test-token" {
|
||||||
t.Errorf("Telegram.Token = %q, want %q", cfg.Channels.Telegram.Token, "test-token")
|
t.Errorf("Telegram.Token = %q, want %q", cfg.Channels.Telegram.Token(), "test-token")
|
||||||
}
|
}
|
||||||
if cfg.Gateway.Port != 18790 {
|
if cfg.Gateway.Port != 18790 {
|
||||||
t.Errorf("Gateway.Port = %d, want %d", cfg.Gateway.Port, 18790)
|
t.Errorf("Gateway.Port = %d, want %d", cfg.Gateway.Port, 18790)
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,9 @@ import (
|
||||||
|
|
||||||
func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
|
func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
|
||||||
cfg := &configV0{
|
cfg := &configV0{
|
||||||
Providers: ProvidersConfig{
|
Providers: providersConfigV0{
|
||||||
OpenAI: OpenAIProviderConfig{
|
OpenAI: openAIProviderConfigV0{
|
||||||
ProviderConfig: ProviderConfig{
|
providerConfigV0: providerConfigV0{
|
||||||
APIKey: "sk-test-key",
|
APIKey: "sk-test-key",
|
||||||
APIBase: "https://custom.api.com/v1",
|
APIBase: "https://custom.api.com/v1",
|
||||||
},
|
},
|
||||||
|
|
@ -41,9 +41,8 @@ func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
|
||||||
|
|
||||||
func TestConvertProvidersToModelList_Anthropic(t *testing.T) {
|
func TestConvertProvidersToModelList_Anthropic(t *testing.T) {
|
||||||
cfg := &configV0{
|
cfg := &configV0{
|
||||||
Providers: ProvidersConfig{
|
Providers: providersConfigV0{
|
||||||
Anthropic: ProviderConfig{
|
Anthropic: providerConfigV0{
|
||||||
APIKey: "ant-key",
|
|
||||||
APIBase: "https://custom.anthropic.com",
|
APIBase: "https://custom.anthropic.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -65,9 +64,8 @@ func TestConvertProvidersToModelList_Anthropic(t *testing.T) {
|
||||||
|
|
||||||
func TestConvertProvidersToModelList_LiteLLM(t *testing.T) {
|
func TestConvertProvidersToModelList_LiteLLM(t *testing.T) {
|
||||||
cfg := &configV0{
|
cfg := &configV0{
|
||||||
Providers: ProvidersConfig{
|
Providers: providersConfigV0{
|
||||||
LiteLLM: ProviderConfig{
|
LiteLLM: providerConfigV0{
|
||||||
APIKey: "litellm-key",
|
|
||||||
APIBase: "http://localhost:4000/v1",
|
APIBase: "http://localhost:4000/v1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -92,10 +90,10 @@ func TestConvertProvidersToModelList_LiteLLM(t *testing.T) {
|
||||||
|
|
||||||
func TestConvertProvidersToModelList_Multiple(t *testing.T) {
|
func TestConvertProvidersToModelList_Multiple(t *testing.T) {
|
||||||
cfg := &configV0{
|
cfg := &configV0{
|
||||||
Providers: ProvidersConfig{
|
Providers: providersConfigV0{
|
||||||
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "openai-key"}},
|
OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "openai-key"}},
|
||||||
Groq: ProviderConfig{APIKey: "groq-key"},
|
Groq: providerConfigV0{APIKey: "groq-key"},
|
||||||
Zhipu: ProviderConfig{APIKey: "zhipu-key"},
|
Zhipu: providerConfigV0{APIKey: "zhipu-key"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,7 +118,7 @@ func TestConvertProvidersToModelList_Multiple(t *testing.T) {
|
||||||
|
|
||||||
func TestConvertProvidersToModelList_Empty(t *testing.T) {
|
func TestConvertProvidersToModelList_Empty(t *testing.T) {
|
||||||
cfg := &configV0{
|
cfg := &configV0{
|
||||||
Providers: ProvidersConfig{},
|
Providers: providersConfigV0{},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := v0ConvertProvidersToModelList(cfg)
|
result := v0ConvertProvidersToModelList(cfg)
|
||||||
|
|
@ -139,31 +137,34 @@ func TestConvertProvidersToModelList_Nil(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
|
func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
|
||||||
|
// This test verifies that when providers have at least one configured field,
|
||||||
|
// they are converted. GitHubCopilot has ConnectMode set, Antigravity has AuthMethod.
|
||||||
|
// Other providers have no configuration, so they won't be converted.
|
||||||
cfg := &configV0{
|
cfg := &configV0{
|
||||||
Providers: ProvidersConfig{
|
Providers: providersConfigV0{
|
||||||
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "key1"}},
|
OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "key1"}},
|
||||||
LiteLLM: ProviderConfig{APIKey: "key-litellm", APIBase: "http://localhost:4000/v1"},
|
LiteLLM: providerConfigV0{APIKey: "key-litellm", APIBase: "http://localhost:4000/v1"},
|
||||||
Anthropic: ProviderConfig{APIKey: "key2"},
|
Anthropic: providerConfigV0{APIKey: "key2"},
|
||||||
OpenRouter: ProviderConfig{APIKey: "key3"},
|
OpenRouter: providerConfigV0{APIKey: "key3"},
|
||||||
Groq: ProviderConfig{APIKey: "key4"},
|
Groq: providerConfigV0{APIKey: "key4"},
|
||||||
Zhipu: ProviderConfig{APIKey: "key5"},
|
Zhipu: providerConfigV0{APIKey: "key5"},
|
||||||
VLLM: ProviderConfig{APIKey: "key6"},
|
VLLM: providerConfigV0{APIKey: "key6"},
|
||||||
Gemini: ProviderConfig{APIKey: "key7"},
|
Gemini: providerConfigV0{APIKey: "key7"},
|
||||||
Nvidia: ProviderConfig{APIKey: "key8"},
|
Nvidia: providerConfigV0{APIKey: "key8"},
|
||||||
Ollama: ProviderConfig{APIKey: "key9"},
|
Ollama: providerConfigV0{APIKey: "key9"},
|
||||||
Moonshot: ProviderConfig{APIKey: "key10"},
|
Moonshot: providerConfigV0{APIKey: "key10"},
|
||||||
ShengSuanYun: ProviderConfig{APIKey: "key11"},
|
ShengSuanYun: providerConfigV0{APIKey: "key11"},
|
||||||
DeepSeek: ProviderConfig{APIKey: "key12"},
|
DeepSeek: providerConfigV0{APIKey: "key12"},
|
||||||
Cerebras: ProviderConfig{APIKey: "key13"},
|
Cerebras: providerConfigV0{APIKey: "key13"},
|
||||||
Vivgrid: ProviderConfig{APIKey: "key14"},
|
Vivgrid: providerConfigV0{APIKey: "key14"},
|
||||||
VolcEngine: ProviderConfig{APIKey: "key15"},
|
VolcEngine: providerConfigV0{APIKey: "key15"},
|
||||||
GitHubCopilot: ProviderConfig{ConnectMode: "grpc"},
|
GitHubCopilot: providerConfigV0{ConnectMode: "grpc"},
|
||||||
Antigravity: ProviderConfig{AuthMethod: "oauth"},
|
Antigravity: providerConfigV0{AuthMethod: "oauth"},
|
||||||
Qwen: ProviderConfig{APIKey: "key17"},
|
Qwen: providerConfigV0{APIKey: "key17"},
|
||||||
Mistral: ProviderConfig{APIKey: "key18"},
|
Mistral: providerConfigV0{APIKey: "key18"},
|
||||||
Avian: ProviderConfig{APIKey: "key19"},
|
Avian: providerConfigV0{APIKey: "key19"},
|
||||||
LongCat: ProviderConfig{APIKey: "key-longcat"},
|
LongCat: providerConfigV0{APIKey: "key-longcat"},
|
||||||
ModelScope: ProviderConfig{APIKey: "key-modelscope"},
|
ModelScope: providerConfigV0{APIKey: "key-modelscope"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,9 +178,9 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
|
||||||
|
|
||||||
func TestConvertProvidersToModelList_Proxy(t *testing.T) {
|
func TestConvertProvidersToModelList_Proxy(t *testing.T) {
|
||||||
cfg := &configV0{
|
cfg := &configV0{
|
||||||
Providers: ProvidersConfig{
|
Providers: providersConfigV0{
|
||||||
OpenAI: OpenAIProviderConfig{
|
OpenAI: openAIProviderConfigV0{
|
||||||
ProviderConfig: ProviderConfig{
|
providerConfigV0: providerConfigV0{
|
||||||
APIKey: "key",
|
APIKey: "key",
|
||||||
Proxy: "http://proxy:8080",
|
Proxy: "http://proxy:8080",
|
||||||
},
|
},
|
||||||
|
|
@ -200,9 +201,9 @@ func TestConvertProvidersToModelList_Proxy(t *testing.T) {
|
||||||
|
|
||||||
func TestConvertProvidersToModelList_RequestTimeout(t *testing.T) {
|
func TestConvertProvidersToModelList_RequestTimeout(t *testing.T) {
|
||||||
cfg := &configV0{
|
cfg := &configV0{
|
||||||
Providers: ProvidersConfig{
|
Providers: providersConfigV0{
|
||||||
Ollama: ProviderConfig{
|
Ollama: providerConfigV0{
|
||||||
APIKey: "ollama-key",
|
APIBase: "http://localhost:11434",
|
||||||
RequestTimeout: 300,
|
RequestTimeout: 300,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -221,9 +222,9 @@ func TestConvertProvidersToModelList_RequestTimeout(t *testing.T) {
|
||||||
|
|
||||||
func TestConvertProvidersToModelList_AuthMethod(t *testing.T) {
|
func TestConvertProvidersToModelList_AuthMethod(t *testing.T) {
|
||||||
cfg := &configV0{
|
cfg := &configV0{
|
||||||
Providers: ProvidersConfig{
|
Providers: providersConfigV0{
|
||||||
OpenAI: OpenAIProviderConfig{
|
OpenAI: openAIProviderConfigV0{
|
||||||
ProviderConfig: ProviderConfig{
|
providerConfigV0: providerConfigV0{
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -247,8 +248,8 @@ func TestConvertProvidersToModelList_PreservesUserModel_DeepSeek(t *testing.T) {
|
||||||
Model: "deepseek-reasoner",
|
Model: "deepseek-reasoner",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: providersConfigV0{
|
||||||
DeepSeek: ProviderConfig{APIKey: "sk-deepseek"},
|
DeepSeek: providerConfigV0{APIKey: "sk-deepseek"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -272,8 +273,8 @@ func TestConvertProvidersToModelList_PreservesUserModel_OpenAI(t *testing.T) {
|
||||||
Model: "gpt-4-turbo",
|
Model: "gpt-4-turbo",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: providersConfigV0{
|
||||||
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "sk-openai"}},
|
OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "sk-openai"}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -296,8 +297,8 @@ func TestConvertProvidersToModelList_PreservesUserModel_Anthropic(t *testing.T)
|
||||||
Model: "claude-opus-4-20250514",
|
Model: "claude-opus-4-20250514",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: providersConfigV0{
|
||||||
Anthropic: ProviderConfig{APIKey: "sk-ant"},
|
Anthropic: providerConfigV0{APIKey: "sk-ant"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -320,8 +321,8 @@ func TestConvertProvidersToModelList_PreservesUserModel_Qwen(t *testing.T) {
|
||||||
Model: "qwen-plus",
|
Model: "qwen-plus",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: providersConfigV0{
|
||||||
Qwen: ProviderConfig{APIKey: "sk-qwen"},
|
Qwen: providerConfigV0{APIKey: "sk-qwen"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -344,8 +345,8 @@ func TestConvertProvidersToModelList_UsesDefaultWhenNoUserModel(t *testing.T) {
|
||||||
Model: "", // no model specified
|
Model: "", // no model specified
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: providersConfigV0{
|
||||||
DeepSeek: ProviderConfig{APIKey: "sk-deepseek"},
|
DeepSeek: providerConfigV0{APIKey: "sk-deepseek"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -369,9 +370,9 @@ func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *tes
|
||||||
Model: "deepseek-reasoner",
|
Model: "deepseek-reasoner",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: providersConfigV0{
|
||||||
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "sk-openai"}},
|
OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "sk-openai"}},
|
||||||
DeepSeek: ProviderConfig{APIKey: "sk-deepseek"},
|
DeepSeek: providerConfigV0{APIKey: "sk-deepseek"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -400,13 +401,13 @@ func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
providerAlias string
|
providerAlias string
|
||||||
expectedModel string
|
expectedModel string
|
||||||
provider ProviderConfig
|
provider providerConfigV0
|
||||||
}{
|
}{
|
||||||
{"gpt", "openai/gpt-4-custom", ProviderConfig{APIKey: "key"}},
|
{"gpt", "openai/gpt-4-custom", providerConfigV0{APIKey: "key"}},
|
||||||
{"claude", "anthropic/claude-custom", ProviderConfig{APIKey: "key"}},
|
{"claude", "anthropic/claude-custom", providerConfigV0{APIKey: "key"}},
|
||||||
{"doubao", "volcengine/doubao-custom", ProviderConfig{APIKey: "key"}},
|
{"doubao", "volcengine/doubao-custom", providerConfigV0{APIKey: "key"}},
|
||||||
{"tongyi", "qwen/qwen-custom", ProviderConfig{APIKey: "key"}},
|
{"tongyi", "qwen/qwen-custom", providerConfigV0{APIKey: "key"}},
|
||||||
{"kimi", "moonshot/kimi-custom", ProviderConfig{APIKey: "key"}},
|
{"kimi", "moonshot/kimi-custom", providerConfigV0{APIKey: "key"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|
@ -421,13 +422,13 @@ func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{},
|
Providers: providersConfigV0{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the appropriate provider config
|
// Set the appropriate provider config
|
||||||
switch tt.providerAlias {
|
switch tt.providerAlias {
|
||||||
case "gpt":
|
case "gpt":
|
||||||
cfg.Providers.OpenAI = OpenAIProviderConfig{ProviderConfig: tt.provider}
|
cfg.Providers.OpenAI = openAIProviderConfigV0{providerConfigV0: tt.provider}
|
||||||
case "claude":
|
case "claude":
|
||||||
cfg.Providers.Anthropic = tt.provider
|
cfg.Providers.Anthropic = tt.provider
|
||||||
case "doubao":
|
case "doubao":
|
||||||
|
|
@ -473,8 +474,10 @@ func TestConvertProvidersToModelList_NoProviderField_SingleProvider(t *testing.T
|
||||||
Model: "glm-4.7",
|
Model: "glm-4.7",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: providersConfigV0{
|
||||||
Zhipu: ProviderConfig{APIKey: "test-zhipu-key"},
|
Zhipu: providerConfigV0{
|
||||||
|
APIKey: "test-zhipu-key",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -506,9 +509,9 @@ func TestConvertProvidersToModelList_NoProviderField_MultipleProviders(t *testin
|
||||||
Model: "some-model",
|
Model: "some-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: providersConfigV0{
|
||||||
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "openai-key"}},
|
OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "openai-key"}},
|
||||||
Zhipu: ProviderConfig{APIKey: "zhipu-key"},
|
Zhipu: providerConfigV0{APIKey: "zhipu-key"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -539,8 +542,8 @@ func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) {
|
||||||
Model: "",
|
Model: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: providersConfigV0{
|
||||||
Zhipu: ProviderConfig{APIKey: "zhipu-key"},
|
Zhipu: providerConfigV0{APIKey: "zhipu-key"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -592,8 +595,8 @@ func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T)
|
||||||
Model: "openrouter/auto", // Model already has protocol prefix
|
Model: "openrouter/auto", // Model already has protocol prefix
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: providersConfigV0{
|
||||||
OpenRouter: ProviderConfig{APIKey: "sk-or-test"},
|
OpenRouter: providerConfigV0{APIKey: "sk-or-test"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,20 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetModelConfig_Found(t *testing.T) {
|
func TestGetModelConfig_Found(t *testing.T) {
|
||||||
cfg := &Config{
|
cfg := (&Config{
|
||||||
ModelList: []ModelConfig{
|
Version: CurrentVersion,
|
||||||
{ModelName: "test-model", Model: "openai/gpt-4o", APIKey: "key1"},
|
ModelList: []*ModelConfig{
|
||||||
{ModelName: "other-model", Model: "anthropic/claude", APIKey: "key2"},
|
{ModelName: "test-model", Model: "openai/gpt-4o"},
|
||||||
|
{ModelName: "other-model", Model: "anthropic/claude"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&SecurityConfig{ModelList: map[string]ModelSecurityEntry{
|
||||||
|
"test-model:0": {
|
||||||
|
APIKeys: []string{"key1"},
|
||||||
|
},
|
||||||
|
"other-model:0": {
|
||||||
|
APIKeys: []string{"key2"},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
|
||||||
result, err := cfg.GetModelConfig("test-model")
|
result, err := cfg.GetModelConfig("test-model")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -30,11 +38,17 @@ func TestGetModelConfig_Found(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetModelConfig_NotFound(t *testing.T) {
|
func TestGetModelConfig_NotFound(t *testing.T) {
|
||||||
cfg := &Config{
|
cfg := (&Config{
|
||||||
ModelList: []ModelConfig{
|
ModelList: []*ModelConfig{
|
||||||
{ModelName: "test-model", Model: "openai/gpt-4o", APIKey: "key1"},
|
{ModelName: "test-model", Model: "openai/gpt-4o"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&SecurityConfig{
|
||||||
|
ModelList: map[string]ModelSecurityEntry{
|
||||||
|
"test-model:0": {
|
||||||
|
APIKeys: []string{"key1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
_, err := cfg.GetModelConfig("nonexistent")
|
_, err := cfg.GetModelConfig("nonexistent")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -44,7 +58,7 @@ func TestGetModelConfig_NotFound(t *testing.T) {
|
||||||
|
|
||||||
func TestGetModelConfig_EmptyList(t *testing.T) {
|
func TestGetModelConfig_EmptyList(t *testing.T) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
ModelList: []ModelConfig{},
|
ModelList: []*ModelConfig{},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := cfg.GetModelConfig("any-model")
|
_, err := cfg.GetModelConfig("any-model")
|
||||||
|
|
@ -54,13 +68,25 @@ func TestGetModelConfig_EmptyList(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetModelConfig_RoundRobin(t *testing.T) {
|
func TestGetModelConfig_RoundRobin(t *testing.T) {
|
||||||
cfg := &Config{
|
cfg := (&Config{
|
||||||
ModelList: []ModelConfig{
|
ModelList: []*ModelConfig{
|
||||||
{ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKey: "key1"},
|
{ModelName: "lb-model", Model: "openai/gpt-4o-1"},
|
||||||
{ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKey: "key2"},
|
{ModelName: "lb-model", Model: "openai/gpt-4o-2"},
|
||||||
{ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKey: "key3"},
|
{ModelName: "lb-model", Model: "openai/gpt-4o-3"},
|
||||||
},
|
},
|
||||||
}
|
}).WithSecurity(&SecurityConfig{
|
||||||
|
ModelList: map[string]ModelSecurityEntry{
|
||||||
|
"lb-model:0": {
|
||||||
|
APIKeys: []string{"key1"},
|
||||||
|
},
|
||||||
|
"lb-model:1": {
|
||||||
|
APIKeys: []string{"key2"},
|
||||||
|
},
|
||||||
|
"lb-model:2": {
|
||||||
|
APIKeys: []string{"key3"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// Test round-robin distribution
|
// Test round-robin distribution
|
||||||
results := make(map[string]int)
|
results := make(map[string]int)
|
||||||
|
|
@ -84,10 +110,10 @@ func TestGetModelConfig_RoundRobinStartsFromFirstMatch(t *testing.T) {
|
||||||
rrCounter.Store(0)
|
rrCounter.Store(0)
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
ModelList: []ModelConfig{
|
ModelList: []*ModelConfig{
|
||||||
{ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKey: "key1"},
|
{ModelName: "lb-model", Model: "openai/gpt-4o-1", apiKeys: []string{"key1"}},
|
||||||
{ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKey: "key2"},
|
{ModelName: "lb-model", Model: "openai/gpt-4o-2", apiKeys: []string{"key2"}},
|
||||||
{ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKey: "key3"},
|
{ModelName: "lb-model", Model: "openai/gpt-4o-3", apiKeys: []string{"key3"}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,9 +138,9 @@ func TestGetModelConfig_RoundRobinStartsFromFirstMatch(t *testing.T) {
|
||||||
|
|
||||||
func TestGetModelConfig_Concurrent(t *testing.T) {
|
func TestGetModelConfig_Concurrent(t *testing.T) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
ModelList: []ModelConfig{
|
ModelList: []*ModelConfig{
|
||||||
{ModelName: "concurrent-model", Model: "openai/gpt-4o-1", APIKey: "key1"},
|
{ModelName: "concurrent-model", Model: "openai/gpt-4o-1", apiKeys: []string{"key1"}},
|
||||||
{ModelName: "concurrent-model", Model: "openai/gpt-4o-2", APIKey: "key2"},
|
{ModelName: "concurrent-model", Model: "openai/gpt-4o-2", apiKeys: []string{"key2"}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,7 +260,7 @@ func TestConfig_ValidateModelList(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "valid list",
|
name: "valid list",
|
||||||
config: &Config{
|
config: &Config{
|
||||||
ModelList: []ModelConfig{
|
ModelList: []*ModelConfig{
|
||||||
{ModelName: "test1", Model: "openai/gpt-4o"},
|
{ModelName: "test1", Model: "openai/gpt-4o"},
|
||||||
{ModelName: "test2", Model: "anthropic/claude"},
|
{ModelName: "test2", Model: "anthropic/claude"},
|
||||||
},
|
},
|
||||||
|
|
@ -244,7 +270,7 @@ func TestConfig_ValidateModelList(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "invalid entry",
|
name: "invalid entry",
|
||||||
config: &Config{
|
config: &Config{
|
||||||
ModelList: []ModelConfig{
|
ModelList: []*ModelConfig{
|
||||||
{ModelName: "test1", Model: "openai/gpt-4o"},
|
{ModelName: "test1", Model: "openai/gpt-4o"},
|
||||||
{ModelName: "", Model: "anthropic/claude"}, // missing model_name
|
{ModelName: "", Model: "anthropic/claude"}, // missing model_name
|
||||||
},
|
},
|
||||||
|
|
@ -255,7 +281,7 @@ func TestConfig_ValidateModelList(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "empty list",
|
name: "empty list",
|
||||||
config: &Config{
|
config: &Config{
|
||||||
ModelList: []ModelConfig{},
|
ModelList: []*ModelConfig{},
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
|
|
@ -263,10 +289,7 @@ func TestConfig_ValidateModelList(t *testing.T) {
|
||||||
// Load balancing: multiple entries with same model_name are allowed
|
// Load balancing: multiple entries with same model_name are allowed
|
||||||
name: "duplicate model_name for load balancing",
|
name: "duplicate model_name for load balancing",
|
||||||
config: &Config{
|
config: &Config{
|
||||||
ModelList: []ModelConfig{
|
ModelList: []*ModelConfig{},
|
||||||
{ModelName: "gpt-4", Model: "openai/gpt-4o", APIKey: "key1"},
|
|
||||||
{ModelName: "gpt-4", Model: "openai/gpt-4-turbo", APIKey: "key2"},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
wantErr: false, // Changed: duplicates are allowed for load balancing
|
wantErr: false, // Changed: duplicates are allowed for load balancing
|
||||||
},
|
},
|
||||||
|
|
@ -274,7 +297,7 @@ func TestConfig_ValidateModelList(t *testing.T) {
|
||||||
// Load balancing: non-adjacent entries with same model_name are also allowed
|
// Load balancing: non-adjacent entries with same model_name are also allowed
|
||||||
name: "duplicate model_name non-adjacent for load balancing",
|
name: "duplicate model_name non-adjacent for load balancing",
|
||||||
config: &Config{
|
config: &Config{
|
||||||
ModelList: []ModelConfig{
|
ModelList: []*ModelConfig{
|
||||||
{ModelName: "model-a", Model: "openai/gpt-4o"},
|
{ModelName: "model-a", Model: "openai/gpt-4o"},
|
||||||
{ModelName: "model-b", Model: "anthropic/claude"},
|
{ModelName: "model-b", Model: "anthropic/claude"},
|
||||||
{ModelName: "model-a", Model: "openai/gpt-4-turbo"},
|
{ModelName: "model-a", Model: "openai/gpt-4-turbo"},
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,15 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExpandMultiKeyModels_SingleKey(t *testing.T) {
|
func TestExpandMultiKeyModels_SingleKey(t *testing.T) {
|
||||||
models := []ModelConfig{
|
models := []*ModelConfig{
|
||||||
{
|
{
|
||||||
ModelName: "gpt-4",
|
ModelName: "gpt-4",
|
||||||
Model: "openai/gpt-4o",
|
Model: "openai/gpt-4o",
|
||||||
APIKey: "single-key",
|
apiKeys: []string{"single-key"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := ExpandMultiKeyModels(models)
|
result := expandMultiKeyModels(models)
|
||||||
|
|
||||||
if len(result) != 1 {
|
if len(result) != 1 {
|
||||||
t.Fatalf("expected 1 model, got %d", len(result))
|
t.Fatalf("expected 1 model, got %d", len(result))
|
||||||
|
|
@ -23,8 +23,8 @@ func TestExpandMultiKeyModels_SingleKey(t *testing.T) {
|
||||||
t.Errorf("expected model_name 'gpt-4', got %q", result[0].ModelName)
|
t.Errorf("expected model_name 'gpt-4', got %q", result[0].ModelName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result[0].APIKey != "single-key" {
|
if result[0].APIKey() != "single-key" {
|
||||||
t.Errorf("expected api_key 'single-key', got %q", result[0].APIKey)
|
t.Errorf("expected api_key 'single-key', got %q", result[0].APIKey())
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result[0].Fallbacks) != 0 {
|
if len(result[0].Fallbacks) != 0 {
|
||||||
|
|
@ -33,16 +33,16 @@ func TestExpandMultiKeyModels_SingleKey(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) {
|
func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) {
|
||||||
models := []ModelConfig{
|
models := []*ModelConfig{
|
||||||
{
|
{
|
||||||
ModelName: "glm-4.7",
|
ModelName: "glm-4.7",
|
||||||
Model: "zhipu/glm-4.7",
|
Model: "zhipu/glm-4.7",
|
||||||
APIBase: "https://api.example.com",
|
APIBase: "https://api.example.com",
|
||||||
APIKeys: []string{"key1", "key2", "key3"},
|
apiKeys: []string{"key1", "key2", "key3"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := ExpandMultiKeyModels(models)
|
result := expandMultiKeyModels(models)
|
||||||
|
|
||||||
// Should expand to 3 models
|
// Should expand to 3 models
|
||||||
if len(result) != 3 {
|
if len(result) != 3 {
|
||||||
|
|
@ -54,8 +54,8 @@ func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) {
|
||||||
if primary.ModelName != "glm-4.7" {
|
if primary.ModelName != "glm-4.7" {
|
||||||
t.Errorf("expected primary model_name 'glm-4.7', got %q", primary.ModelName)
|
t.Errorf("expected primary model_name 'glm-4.7', got %q", primary.ModelName)
|
||||||
}
|
}
|
||||||
if primary.APIKey != "key1" {
|
if primary.APIKey() != "key1" {
|
||||||
t.Errorf("expected primary api_key 'key1', got %q", primary.APIKey)
|
t.Errorf("expected primary api_key 'key1', got %q", primary.APIKey())
|
||||||
}
|
}
|
||||||
if len(primary.Fallbacks) != 2 {
|
if len(primary.Fallbacks) != 2 {
|
||||||
t.Errorf("expected 2 fallbacks, got %d", len(primary.Fallbacks))
|
t.Errorf("expected 2 fallbacks, got %d", len(primary.Fallbacks))
|
||||||
|
|
@ -72,8 +72,8 @@ func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) {
|
||||||
if second.ModelName != "glm-4.7__key_1" {
|
if second.ModelName != "glm-4.7__key_1" {
|
||||||
t.Errorf("expected second model_name 'glm-4.7__key_1', got %q", second.ModelName)
|
t.Errorf("expected second model_name 'glm-4.7__key_1', got %q", second.ModelName)
|
||||||
}
|
}
|
||||||
if second.APIKey != "key2" {
|
if second.APIKey() != "key2" {
|
||||||
t.Errorf("expected second api_key 'key2', got %q", second.APIKey)
|
t.Errorf("expected second api_key 'key2', got %q", second.APIKey())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Third entry should be key3
|
// Third entry should be key3
|
||||||
|
|
@ -81,22 +81,21 @@ func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) {
|
||||||
if third.ModelName != "glm-4.7__key_2" {
|
if third.ModelName != "glm-4.7__key_2" {
|
||||||
t.Errorf("expected third model_name 'glm-4.7__key_2', got %q", third.ModelName)
|
t.Errorf("expected third model_name 'glm-4.7__key_2', got %q", third.ModelName)
|
||||||
}
|
}
|
||||||
if third.APIKey != "key3" {
|
if third.APIKey() != "key3" {
|
||||||
t.Errorf("expected third api_key 'key3', got %q", third.APIKey)
|
t.Errorf("expected third api_key 'key3', got %q", third.APIKey())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExpandMultiKeyModels_APIKeyAndAPIKeys(t *testing.T) {
|
func TestExpandMultiKeyModels_APIKeyAndAPIKeys(t *testing.T) {
|
||||||
models := []ModelConfig{
|
models := []*ModelConfig{
|
||||||
{
|
{
|
||||||
ModelName: "gpt-4",
|
ModelName: "gpt-4",
|
||||||
Model: "openai/gpt-4o",
|
Model: "openai/gpt-4o",
|
||||||
APIKey: "key0",
|
apiKeys: []string{"key0", "key1", "key2"},
|
||||||
APIKeys: []string{"key1", "key2"},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := ExpandMultiKeyModels(models)
|
result := expandMultiKeyModels(models)
|
||||||
|
|
||||||
// Should expand to 3 models (key0 from APIKey + key1, key2 from APIKeys)
|
// Should expand to 3 models (key0 from APIKey + key1, key2 from APIKeys)
|
||||||
if len(result) != 3 {
|
if len(result) != 3 {
|
||||||
|
|
@ -105,8 +104,8 @@ func TestExpandMultiKeyModels_APIKeyAndAPIKeys(t *testing.T) {
|
||||||
|
|
||||||
// Primary should use key0
|
// Primary should use key0
|
||||||
primary := result[2]
|
primary := result[2]
|
||||||
if primary.APIKey != "key0" {
|
if primary.APIKey() != "key0" {
|
||||||
t.Errorf("expected primary api_key 'key0', got %q", primary.APIKey)
|
t.Errorf("expected primary api_key 'key0', got %q", primary.APIKey())
|
||||||
}
|
}
|
||||||
if len(primary.Fallbacks) != 2 {
|
if len(primary.Fallbacks) != 2 {
|
||||||
t.Errorf("expected 2 fallbacks, got %d", len(primary.Fallbacks))
|
t.Errorf("expected 2 fallbacks, got %d", len(primary.Fallbacks))
|
||||||
|
|
@ -114,16 +113,15 @@ func TestExpandMultiKeyModels_APIKeyAndAPIKeys(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExpandMultiKeyModels_WithExistingFallbacks(t *testing.T) {
|
func TestExpandMultiKeyModels_WithExistingFallbacks(t *testing.T) {
|
||||||
models := []ModelConfig{
|
modelCfg := &ModelConfig{
|
||||||
{
|
ModelName: "gpt-4",
|
||||||
ModelName: "gpt-4",
|
Model: "openai/gpt-4o",
|
||||||
Model: "openai/gpt-4o",
|
|
||||||
APIKeys: []string{"key1", "key2"},
|
|
||||||
Fallbacks: []string{"claude-3"},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
modelCfg.apiKeys = []string{"key0", "key1"} // Use internal field for multi-key testing
|
||||||
|
modelCfg.Fallbacks = []string{"claude-3"}
|
||||||
|
models := []*ModelConfig{modelCfg}
|
||||||
|
|
||||||
result := ExpandMultiKeyModels(models)
|
result := expandMultiKeyModels(models)
|
||||||
|
|
||||||
primary := result[1]
|
primary := result[1]
|
||||||
// With 2 keys, we get 1 key fallback + 1 existing fallback = 2 total
|
// With 2 keys, we get 1 key fallback + 1 existing fallback = 2 total
|
||||||
|
|
@ -141,16 +139,15 @@ func TestExpandMultiKeyModels_WithExistingFallbacks(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExpandMultiKeyModels_EmptyAPIKeys(t *testing.T) {
|
func TestExpandMultiKeyModels_EmptyAPIKeys(t *testing.T) {
|
||||||
models := []ModelConfig{
|
models := []*ModelConfig{
|
||||||
{
|
{
|
||||||
ModelName: "gpt-4",
|
ModelName: "gpt-4",
|
||||||
Model: "openai/gpt-4o",
|
Model: "openai/gpt-4o",
|
||||||
APIKey: "",
|
apiKeys: []string{},
|
||||||
APIKeys: []string{},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := ExpandMultiKeyModels(models)
|
result := expandMultiKeyModels(models)
|
||||||
|
|
||||||
// Should keep as-is with no changes
|
// Should keep as-is with no changes
|
||||||
if len(result) != 1 {
|
if len(result) != 1 {
|
||||||
|
|
@ -163,25 +160,25 @@ func TestExpandMultiKeyModels_EmptyAPIKeys(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExpandMultiKeyModels_Deduplication(t *testing.T) {
|
func TestExpandMultiKeyModels_Deduplication(t *testing.T) {
|
||||||
models := []ModelConfig{
|
models := []*ModelConfig{
|
||||||
{
|
{
|
||||||
ModelName: "gpt-4",
|
ModelName: "gpt-4",
|
||||||
Model: "openai/gpt-4o",
|
Model: "openai/gpt-4o",
|
||||||
APIKey: "key1",
|
apiKeys: []string{"key1", "key2", "key1"}, // Duplicate key1
|
||||||
APIKeys: []string{"key1", "key2", "key1"}, // Duplicate key1
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := ExpandMultiKeyModels(models)
|
result := expandMultiKeyModels(models)
|
||||||
|
|
||||||
|
t.Logf("result: %#v", result)
|
||||||
// Should only create 2 models (deduplicated keys)
|
// Should only create 2 models (deduplicated keys)
|
||||||
if len(result) != 2 {
|
if len(result) != 2 {
|
||||||
t.Fatalf("expected 2 models (deduplicated), got %d", len(result))
|
t.Fatalf("expected 2 models (deduplicated), got %d", len(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
primary := result[1]
|
primary := result[1]
|
||||||
if primary.APIKey != "key1" {
|
if primary.APIKey() != "key1" {
|
||||||
t.Errorf("expected primary api_key 'key1', got %q", primary.APIKey)
|
t.Errorf("expected primary api_key 'key1', got %q", primary.APIKey())
|
||||||
}
|
}
|
||||||
if len(primary.Fallbacks) != 1 {
|
if len(primary.Fallbacks) != 1 {
|
||||||
t.Errorf("expected 1 fallback, got %d", len(primary.Fallbacks))
|
t.Errorf("expected 1 fallback, got %d", len(primary.Fallbacks))
|
||||||
|
|
@ -189,21 +186,20 @@ func TestExpandMultiKeyModels_Deduplication(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
|
func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
|
||||||
models := []ModelConfig{
|
modelCfg := &ModelConfig{
|
||||||
{
|
ModelName: "gpt-4",
|
||||||
ModelName: "gpt-4",
|
Model: "openai/gpt-4o",
|
||||||
Model: "openai/gpt-4o",
|
APIBase: "https://api.example.com",
|
||||||
APIBase: "https://api.example.com",
|
Proxy: "http://proxy:8080",
|
||||||
APIKeys: []string{"key1", "key2"},
|
RPM: 60,
|
||||||
Proxy: "http://proxy:8080",
|
MaxTokensField: "max_completion_tokens",
|
||||||
RPM: 60,
|
RequestTimeout: 30,
|
||||||
MaxTokensField: "max_completion_tokens",
|
ThinkingLevel: "high",
|
||||||
RequestTimeout: 30,
|
|
||||||
ThinkingLevel: "high",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
modelCfg.apiKeys = []string{"key0", "key1"} // Use internal field for multi-key testing
|
||||||
|
models := []*ModelConfig{modelCfg}
|
||||||
|
|
||||||
result := ExpandMultiKeyModels(models)
|
result := expandMultiKeyModels(models)
|
||||||
|
|
||||||
// Check primary entry preserves all fields
|
// Check primary entry preserves all fields
|
||||||
primary := result[1]
|
primary := result[1]
|
||||||
|
|
@ -250,13 +246,13 @@ func TestMergeAPIKeys(t *testing.T) {
|
||||||
expected: nil,
|
expected: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "only apiKey",
|
name: "only ApiKey",
|
||||||
apiKey: "key1",
|
apiKey: "key1",
|
||||||
apiKeys: nil,
|
apiKeys: nil,
|
||||||
expected: []string{"key1"},
|
expected: []string{"key1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "only apiKeys",
|
name: "only ApiKeys",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
apiKeys: []string{"key1", "key2"},
|
apiKeys: []string{"key1", "key2"},
|
||||||
expected: []string{"key1", "key2"},
|
expected: []string{"key1", "key2"},
|
||||||
|
|
|
||||||
205
pkg/config/security.go
Normal file
205
pkg/config/security.go
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/caarlos0/env/v11"
|
||||||
|
"github.com/tencent-connect/botgo/log"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SecurityConfigFile = "security.yml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SecurityConfig stores all sensitive data (API keys, tokens, secrets, passwords)
|
||||||
|
// This data is loaded from security.yml and kept separate from the main config
|
||||||
|
type SecurityConfig struct {
|
||||||
|
// Model API keys. Map key is model_name, can include suffix like "abc:0", "abc:1"
|
||||||
|
// for load balancing with same model_name. The suffix ":N" is used to distinguish
|
||||||
|
// multiple configs that share the same base model_name.
|
||||||
|
ModelList map[string]ModelSecurityEntry `yaml:"model_list,omitempty"`
|
||||||
|
|
||||||
|
// Channel tokens/secrets
|
||||||
|
Channels ChannelsSecurity `yaml:"channels,omitempty"`
|
||||||
|
|
||||||
|
Web WebToolsSecurity `yaml:"web,omitempty"`
|
||||||
|
Skills SkillsSecurity `yaml:"skills,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelSecurityEntry stores security data for a model
|
||||||
|
type ModelSecurityEntry struct {
|
||||||
|
APIKeys []string `yaml:"api_keys,omitempty"` // API authentication keys (multiple keys for failover)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelsSecurity stores channel-related security data
|
||||||
|
type ChannelsSecurity struct {
|
||||||
|
Telegram *TelegramSecurity `yaml:"telegram,omitempty"`
|
||||||
|
Feishu *FeishuSecurity `yaml:"feishu,omitempty"`
|
||||||
|
Discord *DiscordSecurity `yaml:"discord,omitempty"`
|
||||||
|
QQ *QQSecurity `yaml:"qq,omitempty"`
|
||||||
|
DingTalk *DingTalkSecurity `yaml:"dingtalk,omitempty"`
|
||||||
|
Slack *SlackSecurity `yaml:"slack,omitempty"`
|
||||||
|
Matrix *MatrixSecurity `yaml:"matrix,omitempty"`
|
||||||
|
LINE *LINESecurity `yaml:"line,omitempty"`
|
||||||
|
OneBot *OneBotSecurity `yaml:"onebot,omitempty"`
|
||||||
|
WeCom *WeComSecurity `yaml:"wecom,omitempty"`
|
||||||
|
WeComApp *WeComAppSecurity `yaml:"wecom_app,omitempty"`
|
||||||
|
WeComAIBot *WeComAIBotSecurity `yaml:"wecom_aibot,omitempty"`
|
||||||
|
Pico *PicoSecurity `yaml:"pico,omitempty"`
|
||||||
|
IRC *IRCSecurity `yaml:"irc,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TelegramSecurity struct {
|
||||||
|
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuSecurity struct {
|
||||||
|
AppSecret string `yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
|
||||||
|
EncryptKey string `yaml:"encrypt_key,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
|
||||||
|
VerificationToken string `yaml:"verification_token,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiscordSecurity struct {
|
||||||
|
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QQSecurity struct {
|
||||||
|
AppSecret string `yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DingTalkSecurity struct {
|
||||||
|
ClientSecret string `yaml:"client_secret,omitempty" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SlackSecurity struct {
|
||||||
|
BotToken string `yaml:"bot_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
|
||||||
|
AppToken string `yaml:"app_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatrixSecurity struct {
|
||||||
|
AccessToken string `yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LINESecurity struct {
|
||||||
|
ChannelSecret string `yaml:"channel_secret,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
|
||||||
|
ChannelAccessToken string `yaml:"channel_access_token,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OneBotSecurity struct {
|
||||||
|
AccessToken string `yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WeComSecurity struct {
|
||||||
|
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
|
||||||
|
EncodingAESKey string `yaml:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WeComAppSecurity struct {
|
||||||
|
CorpSecret string `yaml:"corp_secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
|
||||||
|
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
|
||||||
|
EncodingAESKey string `yaml:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WeComAIBotSecurity struct {
|
||||||
|
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
|
||||||
|
EncodingAESKey string `yaml:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PicoSecurity struct {
|
||||||
|
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IRCSecurity struct {
|
||||||
|
Password string `yaml:"password,omitempty" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"`
|
||||||
|
NickServPassword string `yaml:"nickserv_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"`
|
||||||
|
SASLPassword string `yaml:"sasl_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebToolsSecurity struct {
|
||||||
|
Brave *BraveSecurity `yaml:"brave,omitempty"`
|
||||||
|
Tavily *TavilySecurity `yaml:"tavily,omitempty"`
|
||||||
|
Perplexity *PerplexitySecurity `yaml:"perplexity,omitempty"`
|
||||||
|
GLMSearch *GLMSearchSecurity `yaml:"glm_search,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BraveSecurity struct {
|
||||||
|
APIKeys []string `yaml:"api_keys,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TavilySecurity struct {
|
||||||
|
APIKeys []string `yaml:"api_keys,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PerplexitySecurity struct {
|
||||||
|
APIKeys []string `yaml:"api_keys,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GLMSearchSecurity struct {
|
||||||
|
APIKey string `yaml:"api_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SkillsSecurity struct {
|
||||||
|
Github *GithubSecurity `yaml:"github,omitempty"`
|
||||||
|
ClawHub *ClawHubSecurity `yaml:"clawhub,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GithubSecurity struct {
|
||||||
|
Token string `yaml:"token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClawHubSecurity struct {
|
||||||
|
AuthToken string `yaml:"auth_token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// securityPath returns the path to security.yml relative to the config file
|
||||||
|
func securityPath(configPath string) string {
|
||||||
|
configDir := filepath.Dir(configPath)
|
||||||
|
return filepath.Join(configDir, SecurityConfigFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSecurityConfig loads the security configuration from security.yml
|
||||||
|
// Returns an empty SecurityConfig if the file doesn't exist
|
||||||
|
func loadSecurityConfig(securityPath string) (*SecurityConfig, error) {
|
||||||
|
data, err := os.ReadFile(securityPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &SecurityConfig{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to read security config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sec SecurityConfig
|
||||||
|
if err := yaml.Unmarshal(data, &sec); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse security config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No need to validate model_name format here - both formats are supported:
|
||||||
|
// - "model-name:0" (with index for multiple entries)
|
||||||
|
// - "model-name" (without index for single entry or default to index 0)
|
||||||
|
|
||||||
|
if err := env.Parse(&sec); err != nil {
|
||||||
|
log.Errorf("failed to parse environment variables: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &sec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveSecurityConfig saves the security configuration to security.yml
|
||||||
|
func saveSecurityConfig(securityPath string, sec *SecurityConfig) error {
|
||||||
|
data, err := yaml.Marshal(sec)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal security config: %w", err)
|
||||||
|
}
|
||||||
|
return fileutil.WriteFileAtomic(securityPath, data, 0o600)
|
||||||
|
}
|
||||||
472
pkg/config/security_integration_test.go
Normal file
472
pkg/config/security_integration_test.go
Normal file
|
|
@ -0,0 +1,472 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Test JSON unmarshal of private fields
|
||||||
|
func TestJSONUnmarshalPrivateFields(t *testing.T) {
|
||||||
|
//nolint: govet
|
||||||
|
type testStruct struct {
|
||||||
|
PublicField string `json:"public"`
|
||||||
|
privateField string `json:"private"`
|
||||||
|
}
|
||||||
|
|
||||||
|
data := `{"public": "pub", "private": "priv"}`
|
||||||
|
var s testStruct
|
||||||
|
if err := json.Unmarshal([]byte(data), &s); err != nil {
|
||||||
|
t.Fatalf("JSON unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("PublicField: %s", s.PublicField)
|
||||||
|
t.Logf("privateField: %s", s.privateField)
|
||||||
|
|
||||||
|
if s.PublicField != "pub" {
|
||||||
|
t.Errorf("PublicField = %q, want 'pub'", s.PublicField)
|
||||||
|
}
|
||||||
|
// This should fail because privateField is unexported
|
||||||
|
if s.privateField != "priv" {
|
||||||
|
t.Logf("privateField = %q, want 'priv' - THIS IS EXPECTED TO FAIL", s.privateField)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecurityConfigIntegration(t *testing.T) {
|
||||||
|
t.Run("Full workflow with security references", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create config.json with references
|
||||||
|
configPath := filepath.Join(tmpDir, "config.json")
|
||||||
|
configContent := `{
|
||||||
|
"version": 1,
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "test-model",
|
||||||
|
"model": "openai/test-model",
|
||||||
|
"api_base": "https://api.openai.com/v1",
|
||||||
|
"api_key": "ref:model_list.test-model.api_key"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"channels": {
|
||||||
|
"telegram": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "ref:channels.telegram.token"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"brave": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "ref:web.brave.api_key"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
"github": {
|
||||||
|
"token": "ref:skills.github.token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
err := os.WriteFile(configPath, []byte(configContent), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create security.yml with actual values
|
||||||
|
securityPath := filepath.Join(tmpDir, "security.yml")
|
||||||
|
securityContent := `model_list:
|
||||||
|
test-model:
|
||||||
|
api_keys:
|
||||||
|
- "sk-test-api-key-12345"
|
||||||
|
|
||||||
|
channels:
|
||||||
|
telegram:
|
||||||
|
token: "123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||||
|
|
||||||
|
web:
|
||||||
|
brave:
|
||||||
|
api_keys:
|
||||||
|
- "BSAbrave-api-key-67890"
|
||||||
|
|
||||||
|
skills:
|
||||||
|
github:
|
||||||
|
token: "ghp_github-token-abc123"`
|
||||||
|
err = os.WriteFile(securityPath, []byte(securityContent), 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Load config and verify references are resolved
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, cfg)
|
||||||
|
|
||||||
|
// Verify model API key is resolved
|
||||||
|
assert.Equal(t, 1, len(cfg.ModelList))
|
||||||
|
assert.Equal(t, "test-model", cfg.ModelList[0].ModelName)
|
||||||
|
assert.Equal(t, "sk-test-api-key-12345", cfg.ModelList[0].apiKeys[0])
|
||||||
|
|
||||||
|
// Verify channel token is resolved
|
||||||
|
assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.token)
|
||||||
|
|
||||||
|
// Verify web tool API key is resolved
|
||||||
|
assert.Equal(t, "BSAbrave-api-key-67890", cfg.Tools.Web.Brave.APIKey())
|
||||||
|
|
||||||
|
// Verify skills token is resolved
|
||||||
|
assert.Equal(t, "ghp_github-token-abc123", cfg.Tools.Skills.Github.token)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecurityConfigWithAPIKeysArray(t *testing.T) {
|
||||||
|
t.Run("Multiple API keys via security", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create config with APIKeys array
|
||||||
|
configPath := filepath.Join(tmpDir, "config.json")
|
||||||
|
configContent := `{
|
||||||
|
"version": 1,
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "multi-key-model",
|
||||||
|
"model": "openai/multi-key-model"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
err := os.WriteFile(configPath, []byte(configContent), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create security.yml
|
||||||
|
securityPath := filepath.Join(tmpDir, "security.yml")
|
||||||
|
securityContent := `model_list:
|
||||||
|
multi-key-model:0:
|
||||||
|
api_key: "sk-key-1"
|
||||||
|
api_keys:
|
||||||
|
- "sk-key-1"
|
||||||
|
- "sk-key-2"
|
||||||
|
- "sk-key-3"
|
||||||
|
`
|
||||||
|
err = os.WriteFile(securityPath, []byte(securityContent), 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Load config
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Logf("Config: %+v", cfg.ModelList)
|
||||||
|
for _, m := range cfg.ModelList {
|
||||||
|
t.Logf("Model: %+v", m)
|
||||||
|
}
|
||||||
|
// Verify multi-key expansion works
|
||||||
|
assert.Equal(t, 3, len(cfg.ModelList))
|
||||||
|
assert.Equal(t, "multi-key-model", cfg.ModelList[2].ModelName)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllSecurityKeysAccessible(t *testing.T) {
|
||||||
|
t.Run("All security keys accessible via Key() methods including file://", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create test files for file:// references
|
||||||
|
modelAPIKeyFile := filepath.Join(tmpDir, "model_api_key.txt")
|
||||||
|
err := os.WriteFile(modelAPIKeyFile, []byte("sk-model-from-file-12345"), 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
braveAPIKeyFile := filepath.Join(tmpDir, "brave_api_key.txt")
|
||||||
|
err = os.WriteFile(braveAPIKeyFile, []byte("BSA-brave-from-file-67890"), 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tavilyAPIKeyFile := filepath.Join(tmpDir, "tavily_api_key.txt")
|
||||||
|
err = os.WriteFile(tavilyAPIKeyFile, []byte("tvly-tavily-from-file-11111"), 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
perplexityAPIKeyFile := filepath.Join(tmpDir, "perplexity_api_key.txt")
|
||||||
|
err = os.WriteFile(perplexityAPIKeyFile, []byte("pplx-perplexity-from-file-22222"), 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
githubTokenFile := filepath.Join(tmpDir, "github_token.txt")
|
||||||
|
err = os.WriteFile(githubTokenFile, []byte("ghp-github-from-file-abc123"), 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
clawhubAuthTokenFile := filepath.Join(tmpDir, "clawhub_auth_token.txt")
|
||||||
|
err = os.WriteFile(clawhubAuthTokenFile, []byte("clawhub-auth-token-from-file"), 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create config.json without sensitive values (they'll be in security.yml)
|
||||||
|
configPath := filepath.Join(tmpDir, "config.json")
|
||||||
|
configContent := `{
|
||||||
|
"version": 1,
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "test-model-1",
|
||||||
|
"model": "openai/test-model-1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"channels": {
|
||||||
|
"telegram": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"feishu": {
|
||||||
|
"enabled": true,
|
||||||
|
"app_id": "test_app_id"
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"dingtalk": {
|
||||||
|
"enabled": true,
|
||||||
|
"client_id": "test_client_id"
|
||||||
|
},
|
||||||
|
"slack": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"matrix": {
|
||||||
|
"enabled": true,
|
||||||
|
"homeserver": "https://matrix.org",
|
||||||
|
"user_id": "@test:matrix.org"
|
||||||
|
},
|
||||||
|
"line": {
|
||||||
|
"enabled": true,
|
||||||
|
"webhook_host": "localhost",
|
||||||
|
"webhook_port": 8080,
|
||||||
|
"webhook_path": "/webhook"
|
||||||
|
},
|
||||||
|
"onebot": {
|
||||||
|
"enabled": true,
|
||||||
|
"ws_url": "ws://localhost:8080"
|
||||||
|
},
|
||||||
|
"wecom": {
|
||||||
|
"enabled": true,
|
||||||
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook"
|
||||||
|
},
|
||||||
|
"wecom_app": {
|
||||||
|
"enabled": true,
|
||||||
|
"corp_id": "test_corp_id",
|
||||||
|
"agent_id": 123456
|
||||||
|
},
|
||||||
|
"wecom_aibot": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"pico": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"irc": {
|
||||||
|
"enabled": true,
|
||||||
|
"server": "irc.example.com",
|
||||||
|
"nick": "testbot"
|
||||||
|
},
|
||||||
|
"qq": {
|
||||||
|
"enabled": true,
|
||||||
|
"app_id": "test_qq_app_id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"brave": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"tavily": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"glm_search": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
"github": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
err = os.WriteFile(configPath, []byte(configContent), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create security.yml with file:// references and plaintext values
|
||||||
|
securityPath := filepath.Join(tmpDir, "security.yml")
|
||||||
|
securityContent := `model_list:
|
||||||
|
test-model-1:
|
||||||
|
api_keys:
|
||||||
|
- "file://model_api_key.txt"
|
||||||
|
|
||||||
|
channels:
|
||||||
|
telegram:
|
||||||
|
token: "123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||||
|
feishu:
|
||||||
|
app_secret: "feishu_test_app_secret"
|
||||||
|
encrypt_key: "feishu_test_encrypt_key"
|
||||||
|
verification_token: "feishu_test_verification_token"
|
||||||
|
discord:
|
||||||
|
token: "discord_test_bot_token_xyz"
|
||||||
|
dingtalk:
|
||||||
|
client_secret: "dingtalk_test_client_secret"
|
||||||
|
slack:
|
||||||
|
bot_token: "xoxb-slack-bot-token-123"
|
||||||
|
app_token: "xapp-slack-app-token-456"
|
||||||
|
matrix:
|
||||||
|
access_token: "matrix_test_access_token"
|
||||||
|
line:
|
||||||
|
channel_secret: "line_test_channel_secret"
|
||||||
|
channel_access_token: "line_test_channel_access_token"
|
||||||
|
onebot:
|
||||||
|
access_token: "onebot_test_access_token"
|
||||||
|
wecom:
|
||||||
|
token: "wecom_test_webhook_token"
|
||||||
|
encoding_aes_key: "wecom_test_aes_key"
|
||||||
|
wecom_app:
|
||||||
|
corp_secret: "wecom_app_test_corp_secret"
|
||||||
|
token: "wecom_app_test_token"
|
||||||
|
encoding_aes_key: "wecom_app_test_aes_key"
|
||||||
|
wecom_aibot:
|
||||||
|
token: "wecom_aibot_test_token"
|
||||||
|
encoding_aes_key: "wecom_aibot_test_aes_key"
|
||||||
|
pico:
|
||||||
|
token: "pico_test_token"
|
||||||
|
irc:
|
||||||
|
password: "irc_test_password"
|
||||||
|
nickserv_password: "irc_test_nickserv_password"
|
||||||
|
sasl_password: "irc_test_sasl_password"
|
||||||
|
qq:
|
||||||
|
app_secret: "qq_test_app_secret"
|
||||||
|
|
||||||
|
web:
|
||||||
|
brave:
|
||||||
|
api_keys:
|
||||||
|
- "file://brave_api_key.txt"
|
||||||
|
tavily:
|
||||||
|
api_keys:
|
||||||
|
- "file://tavily_api_key.txt"
|
||||||
|
perplexity:
|
||||||
|
api_keys:
|
||||||
|
- "file://perplexity_api_key.txt"
|
||||||
|
glm_search:
|
||||||
|
api_key: "glm-test-glm-search-key"
|
||||||
|
|
||||||
|
skills:
|
||||||
|
github:
|
||||||
|
token: "file://github_token.txt"
|
||||||
|
clawhub:
|
||||||
|
auth_token: "file://clawhub_auth_token.txt"
|
||||||
|
`
|
||||||
|
err = os.WriteFile(securityPath, []byte(securityContent), 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Load config and verify all security keys are accessible
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, cfg)
|
||||||
|
|
||||||
|
// Verify Model API keys
|
||||||
|
assert.Equal(t, 1, len(cfg.ModelList))
|
||||||
|
assert.Equal(t, "test-model-1", cfg.ModelList[0].ModelName)
|
||||||
|
// file:// reference should be resolved
|
||||||
|
assert.Equal(t, "sk-model-from-file-12345", cfg.ModelList[0].APIKey())
|
||||||
|
t.Logf("Model APIKey(): %s", cfg.ModelList[0].APIKey())
|
||||||
|
|
||||||
|
// Verify Channel tokens via Key() methods
|
||||||
|
// Telegram
|
||||||
|
assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.Token())
|
||||||
|
t.Logf("Telegram Token(): %s", cfg.Channels.Telegram.Token())
|
||||||
|
|
||||||
|
// Feishu
|
||||||
|
assert.Equal(t, "feishu_test_app_secret", cfg.Channels.Feishu.AppSecret())
|
||||||
|
assert.Equal(t, "feishu_test_encrypt_key", cfg.Channels.Feishu.EncryptKey())
|
||||||
|
assert.Equal(t, "feishu_test_verification_token", cfg.Channels.Feishu.VerificationToken())
|
||||||
|
t.Logf("Feishu AppSecret(): %s", cfg.Channels.Feishu.AppSecret())
|
||||||
|
t.Logf("Feishu EncryptKey(): %s", cfg.Channels.Feishu.EncryptKey())
|
||||||
|
t.Logf("Feishu VerificationToken(): %s", cfg.Channels.Feishu.VerificationToken())
|
||||||
|
|
||||||
|
// Discord
|
||||||
|
assert.Equal(t, "discord_test_bot_token_xyz", cfg.Channels.Discord.Token())
|
||||||
|
t.Logf("Discord Token(): %s", cfg.Channels.Discord.Token())
|
||||||
|
|
||||||
|
// DingTalk
|
||||||
|
assert.Equal(t, "dingtalk_test_client_secret", cfg.Channels.DingTalk.ClientSecret())
|
||||||
|
t.Logf("DingTalk ClientSecret(): %s", cfg.Channels.DingTalk.ClientSecret())
|
||||||
|
|
||||||
|
// Slack
|
||||||
|
assert.Equal(t, "xoxb-slack-bot-token-123", cfg.Channels.Slack.BotToken())
|
||||||
|
assert.Equal(t, "xapp-slack-app-token-456", cfg.Channels.Slack.AppToken())
|
||||||
|
t.Logf("Slack BotToken(): %s", cfg.Channels.Slack.BotToken())
|
||||||
|
t.Logf("Slack AppToken(): %s", cfg.Channels.Slack.AppToken())
|
||||||
|
|
||||||
|
// Matrix
|
||||||
|
assert.Equal(t, "matrix_test_access_token", cfg.Channels.Matrix.AccessToken())
|
||||||
|
t.Logf("Matrix AccessToken(): %s", cfg.Channels.Matrix.AccessToken())
|
||||||
|
|
||||||
|
// LINE
|
||||||
|
assert.Equal(t, "line_test_channel_secret", cfg.Channels.LINE.ChannelSecret())
|
||||||
|
assert.Equal(t, "line_test_channel_access_token", cfg.Channels.LINE.ChannelAccessToken())
|
||||||
|
t.Logf("LINE ChannelSecret(): %s", cfg.Channels.LINE.ChannelSecret())
|
||||||
|
t.Logf("LINE ChannelAccessToken(): %s", cfg.Channels.LINE.ChannelAccessToken())
|
||||||
|
|
||||||
|
// OneBot
|
||||||
|
assert.Equal(t, "onebot_test_access_token", cfg.Channels.OneBot.AccessToken())
|
||||||
|
t.Logf("OneBot AccessToken(): %s", cfg.Channels.OneBot.AccessToken())
|
||||||
|
|
||||||
|
// WeCom
|
||||||
|
assert.Equal(t, "wecom_test_webhook_token", cfg.Channels.WeCom.Token())
|
||||||
|
assert.Equal(t, "wecom_test_aes_key", cfg.Channels.WeCom.EncodingAESKey())
|
||||||
|
t.Logf("WeCom Token(): %s", cfg.Channels.WeCom.Token())
|
||||||
|
t.Logf("WeCom EncodingAESKey(): %s", cfg.Channels.WeCom.EncodingAESKey())
|
||||||
|
|
||||||
|
// WeCom App
|
||||||
|
assert.Equal(t, "wecom_app_test_corp_secret", cfg.Channels.WeComApp.CorpSecret())
|
||||||
|
assert.Equal(t, "wecom_app_test_token", cfg.Channels.WeComApp.Token())
|
||||||
|
assert.Equal(t, "wecom_app_test_aes_key", cfg.Channels.WeComApp.EncodingAESKey())
|
||||||
|
t.Logf("WeComApp CorpSecret(): %s", cfg.Channels.WeComApp.CorpSecret())
|
||||||
|
t.Logf("WeComApp Token(): %s", cfg.Channels.WeComApp.Token())
|
||||||
|
t.Logf("WeComApp EncodingAESKey(): %s", cfg.Channels.WeComApp.EncodingAESKey())
|
||||||
|
|
||||||
|
// WeCom AI Bot
|
||||||
|
assert.Equal(t, "wecom_aibot_test_token", cfg.Channels.WeComAIBot.Token())
|
||||||
|
assert.Equal(t, "wecom_aibot_test_aes_key", cfg.Channels.WeComAIBot.EncodingAESKey())
|
||||||
|
t.Logf("WeComAIBot Token(): %s", cfg.Channels.WeComAIBot.Token())
|
||||||
|
t.Logf("WeComAIBot EncodingAESKey(): %s", cfg.Channels.WeComAIBot.EncodingAESKey())
|
||||||
|
|
||||||
|
// Pico
|
||||||
|
assert.Equal(t, "pico_test_token", cfg.Channels.Pico.Token())
|
||||||
|
t.Logf("Pico Token(): %s", cfg.Channels.Pico.Token())
|
||||||
|
|
||||||
|
// IRC
|
||||||
|
assert.Equal(t, "irc_test_password", cfg.Channels.IRC.Password())
|
||||||
|
assert.Equal(t, "irc_test_nickserv_password", cfg.Channels.IRC.NickServPassword())
|
||||||
|
assert.Equal(t, "irc_test_sasl_password", cfg.Channels.IRC.SASLPassword())
|
||||||
|
t.Logf("IRC Password(): %s", cfg.Channels.IRC.Password())
|
||||||
|
t.Logf("IRC NickServPassword(): %s", cfg.Channels.IRC.NickServPassword())
|
||||||
|
t.Logf("IRC SASLPassword(): %s", cfg.Channels.IRC.SASLPassword())
|
||||||
|
|
||||||
|
// QQ
|
||||||
|
assert.Equal(t, "qq_test_app_secret", cfg.Channels.QQ.AppSecret())
|
||||||
|
t.Logf("QQ AppSecret(): %s", cfg.Channels.QQ.AppSecret())
|
||||||
|
|
||||||
|
// Verify Web tool API keys
|
||||||
|
assert.Equal(t, "BSA-brave-from-file-67890", cfg.Tools.Web.Brave.APIKey())
|
||||||
|
t.Logf("Brave APIKey(): %s", cfg.Tools.Web.Brave.APIKey())
|
||||||
|
|
||||||
|
assert.Equal(t, "tvly-tavily-from-file-11111", cfg.Tools.Web.Tavily.APIKey())
|
||||||
|
t.Logf("Tavily APIKey(): %s", cfg.Tools.Web.Tavily.APIKey())
|
||||||
|
|
||||||
|
assert.Equal(t, "pplx-perplexity-from-file-22222", cfg.Tools.Web.Perplexity.APIKey())
|
||||||
|
t.Logf("Perplexity APIKey(): %s", cfg.Tools.Web.Perplexity.APIKey())
|
||||||
|
|
||||||
|
// GLM Search - Note: GLM uses SetAPIKey (lowercase) internally
|
||||||
|
t.Logf("GLMSearch APIKey(): %s", cfg.Tools.Web.GLMSearch.APIKey())
|
||||||
|
assert.Equal(t, "glm-test-glm-search-key", cfg.Tools.Web.GLMSearch.APIKey())
|
||||||
|
|
||||||
|
// Verify Skills tokens
|
||||||
|
assert.Equal(t, "ghp-github-from-file-abc123", cfg.Tools.Skills.Github.Token())
|
||||||
|
t.Logf("Github Token(): %s", cfg.Tools.Skills.Github.Token())
|
||||||
|
|
||||||
|
assert.Equal(t, "clawhub-auth-token-from-file", cfg.Tools.Skills.Registries.ClawHub.AuthToken())
|
||||||
|
t.Logf("ClawHub AuthToken(): %s", cfg.Tools.Skills.Registries.ClawHub.AuthToken())
|
||||||
|
|
||||||
|
t.Log("All security keys are successfully accessible via their respective Key() methods")
|
||||||
|
})
|
||||||
|
}
|
||||||
90
pkg/config/security_test.go
Normal file
90
pkg/config/security_test.go
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSecurityConfig(t *testing.T) {
|
||||||
|
t.Run("LoadNonExistent", func(t *testing.T) {
|
||||||
|
sec, err := loadSecurityConfig("/nonexistent/security.yml")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, sec)
|
||||||
|
assert.Empty(t, sec.ModelList)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecurityPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configDir string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "standard path",
|
||||||
|
configDir: "/home/user/.picoclaw/config.json",
|
||||||
|
want: "/home/user/.picoclaw/security.yml",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested path",
|
||||||
|
configDir: "/path/to/config/myconfig.json",
|
||||||
|
want: "/path/to/config/security.yml",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := securityPath(tt.configDir)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveAndLoadSecurityConfig(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
secPath := filepath.Join(tmpDir, "security.yml")
|
||||||
|
|
||||||
|
original := &SecurityConfig{
|
||||||
|
ModelList: map[string]ModelSecurityEntry{
|
||||||
|
"model1:0": {
|
||||||
|
APIKeys: []string{"key1", "key2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Channels: ChannelsSecurity{
|
||||||
|
Telegram: &TelegramSecurity{
|
||||||
|
Token: "telegram-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Web: WebToolsSecurity{
|
||||||
|
Brave: &BraveSecurity{
|
||||||
|
APIKeys: []string{"brave-api-key"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save
|
||||||
|
err := saveSecurityConfig(secPath, original)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify file was created with correct permissions
|
||||||
|
info, err := os.Stat(secPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, os.FileMode(0o600), info.Mode())
|
||||||
|
|
||||||
|
// Load
|
||||||
|
loaded, err := loadSecurityConfig(secPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, original.ModelList, loaded.ModelList)
|
||||||
|
assert.Equal(t, original.Channels.Telegram.Token, loaded.Channels.Telegram.Token)
|
||||||
|
assert.EqualValues(t, original.Web.Brave.APIKeys, loaded.Web.Brave.APIKeys)
|
||||||
|
}
|
||||||
|
|
@ -981,13 +981,16 @@ func (c *PicoClawConfig) ToStandardConfig() *config.Config {
|
||||||
cfg.Agents.Defaults.ModelFallbacks = c.Agents.Defaults.ModelFallbacks
|
cfg.Agents.Defaults.ModelFallbacks = c.Agents.Defaults.ModelFallbacks
|
||||||
|
|
||||||
for _, m := range c.ModelList {
|
for _, m := range c.ModelList {
|
||||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
mc := &config.ModelConfig{
|
||||||
ModelName: m.ModelName,
|
ModelName: m.ModelName,
|
||||||
Model: m.Model,
|
Model: m.Model,
|
||||||
APIBase: m.APIBase,
|
APIBase: m.APIBase,
|
||||||
APIKey: m.APIKey,
|
|
||||||
Proxy: m.Proxy,
|
Proxy: m.Proxy,
|
||||||
})
|
}
|
||||||
|
if m.APIKey != "" {
|
||||||
|
mc.SetAPIKey(m.APIKey)
|
||||||
|
}
|
||||||
|
cfg.ModelList = append(cfg.ModelList, mc)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.Channels = c.Channels.ToStandardChannels()
|
cfg.Channels = c.Channels.ToStandardChannels()
|
||||||
|
|
@ -1020,59 +1023,107 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
|
||||||
Enabled: c.WhatsApp.Enabled,
|
Enabled: c.WhatsApp.Enabled,
|
||||||
BridgeURL: c.WhatsApp.BridgeURL,
|
BridgeURL: c.WhatsApp.BridgeURL,
|
||||||
},
|
},
|
||||||
Telegram: config.TelegramConfig{
|
Telegram: func() config.TelegramConfig {
|
||||||
Enabled: c.Telegram.Enabled,
|
tc := config.TelegramConfig{
|
||||||
Token: c.Telegram.Token,
|
Enabled: c.Telegram.Enabled,
|
||||||
Proxy: c.Telegram.Proxy,
|
Proxy: c.Telegram.Proxy,
|
||||||
},
|
}
|
||||||
Feishu: config.FeishuConfig{
|
if c.Telegram.Token != "" {
|
||||||
Enabled: c.Feishu.Enabled,
|
tc.SetToken(c.Telegram.Token)
|
||||||
AppID: c.Feishu.AppID,
|
}
|
||||||
AppSecret: c.Feishu.AppSecret,
|
return tc
|
||||||
EncryptKey: c.Feishu.EncryptKey,
|
}(),
|
||||||
VerificationToken: c.Feishu.VerificationToken,
|
Feishu: func() config.FeishuConfig {
|
||||||
},
|
fc := config.FeishuConfig{
|
||||||
Discord: config.DiscordConfig{
|
Enabled: c.Feishu.Enabled,
|
||||||
Enabled: c.Discord.Enabled,
|
AppID: c.Feishu.AppID,
|
||||||
Token: c.Discord.Token,
|
}
|
||||||
MentionOnly: c.Discord.MentionOnly,
|
if c.Feishu.AppSecret != "" {
|
||||||
},
|
fc.SetAppSecret(c.Feishu.AppSecret)
|
||||||
|
}
|
||||||
|
if c.Feishu.EncryptKey != "" {
|
||||||
|
fc.SetEncryptKey(c.Feishu.EncryptKey)
|
||||||
|
}
|
||||||
|
if c.Feishu.VerificationToken != "" {
|
||||||
|
fc.SetVerificationToken(c.Feishu.VerificationToken)
|
||||||
|
}
|
||||||
|
return fc
|
||||||
|
}(),
|
||||||
|
Discord: func() config.DiscordConfig {
|
||||||
|
dc := config.DiscordConfig{
|
||||||
|
Enabled: c.Discord.Enabled,
|
||||||
|
MentionOnly: c.Discord.MentionOnly,
|
||||||
|
}
|
||||||
|
if c.Discord.Token != "" {
|
||||||
|
dc.SetToken(c.Discord.Token)
|
||||||
|
}
|
||||||
|
return dc
|
||||||
|
}(),
|
||||||
MaixCam: config.MaixCamConfig{
|
MaixCam: config.MaixCamConfig{
|
||||||
Enabled: c.MaixCam.Enabled,
|
Enabled: c.MaixCam.Enabled,
|
||||||
Host: c.MaixCam.Host,
|
Host: c.MaixCam.Host,
|
||||||
Port: c.MaixCam.Port,
|
Port: c.MaixCam.Port,
|
||||||
},
|
},
|
||||||
QQ: config.QQConfig{
|
QQ: func() config.QQConfig {
|
||||||
Enabled: c.QQ.Enabled,
|
qc := config.QQConfig{
|
||||||
AppID: c.QQ.AppID,
|
Enabled: c.QQ.Enabled,
|
||||||
AppSecret: c.QQ.AppSecret,
|
AppID: c.QQ.AppID,
|
||||||
},
|
}
|
||||||
DingTalk: config.DingTalkConfig{
|
if c.QQ.AppSecret != "" {
|
||||||
Enabled: c.DingTalk.Enabled,
|
qc.SetAppSecret(c.QQ.AppSecret)
|
||||||
ClientID: c.DingTalk.ClientID,
|
}
|
||||||
ClientSecret: c.DingTalk.ClientSecret,
|
return qc
|
||||||
},
|
}(),
|
||||||
Slack: config.SlackConfig{
|
DingTalk: func() config.DingTalkConfig {
|
||||||
Enabled: c.Slack.Enabled,
|
dt := config.DingTalkConfig{
|
||||||
BotToken: c.Slack.BotToken,
|
Enabled: c.DingTalk.Enabled,
|
||||||
AppToken: c.Slack.AppToken,
|
ClientID: c.DingTalk.ClientID,
|
||||||
},
|
}
|
||||||
Matrix: config.MatrixConfig{
|
if c.DingTalk.ClientSecret != "" {
|
||||||
Enabled: c.Matrix.Enabled,
|
dt.SetClientSecret(c.DingTalk.ClientSecret)
|
||||||
Homeserver: c.Matrix.Homeserver,
|
}
|
||||||
UserID: c.Matrix.UserID,
|
return dt
|
||||||
AccessToken: c.Matrix.AccessToken,
|
}(),
|
||||||
AllowFrom: c.Matrix.AllowFrom,
|
Slack: func() config.SlackConfig {
|
||||||
JoinOnInvite: true,
|
sc := config.SlackConfig{
|
||||||
},
|
Enabled: c.Slack.Enabled,
|
||||||
LINE: config.LINEConfig{
|
}
|
||||||
Enabled: c.LINE.Enabled,
|
if c.Slack.BotToken != "" {
|
||||||
ChannelSecret: c.LINE.ChannelSecret,
|
sc.SetBotToken(c.Slack.BotToken)
|
||||||
ChannelAccessToken: c.LINE.ChannelAccessToken,
|
}
|
||||||
WebhookHost: c.LINE.WebhookHost,
|
if c.Slack.AppToken != "" {
|
||||||
WebhookPort: c.LINE.WebhookPort,
|
sc.SetAppToken(c.Slack.AppToken)
|
||||||
WebhookPath: c.LINE.WebhookPath,
|
}
|
||||||
},
|
return sc
|
||||||
|
}(),
|
||||||
|
Matrix: func() config.MatrixConfig {
|
||||||
|
mc := config.MatrixConfig{
|
||||||
|
Enabled: c.Matrix.Enabled,
|
||||||
|
Homeserver: c.Matrix.Homeserver,
|
||||||
|
UserID: c.Matrix.UserID,
|
||||||
|
AllowFrom: c.Matrix.AllowFrom,
|
||||||
|
JoinOnInvite: true,
|
||||||
|
}
|
||||||
|
if c.Matrix.AccessToken != "" {
|
||||||
|
mc.SetAccessToken(c.Matrix.AccessToken)
|
||||||
|
}
|
||||||
|
return mc
|
||||||
|
}(),
|
||||||
|
LINE: func() config.LINEConfig {
|
||||||
|
lc := config.LINEConfig{
|
||||||
|
Enabled: c.LINE.Enabled,
|
||||||
|
WebhookHost: c.LINE.WebhookHost,
|
||||||
|
WebhookPort: c.LINE.WebhookPort,
|
||||||
|
WebhookPath: c.LINE.WebhookPath,
|
||||||
|
}
|
||||||
|
if c.LINE.ChannelSecret != "" {
|
||||||
|
lc.SetChannelSecret(c.LINE.ChannelSecret)
|
||||||
|
}
|
||||||
|
if c.LINE.ChannelAccessToken != "" {
|
||||||
|
lc.SetChannelAccessToken(c.LINE.ChannelAccessToken)
|
||||||
|
}
|
||||||
|
return lc
|
||||||
|
}(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1084,30 +1135,44 @@ func (c GatewayConfig) ToStandardGateway() config.GatewayConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c ToolsConfig) ToStandardTools() config.ToolsConfig {
|
func (c ToolsConfig) ToStandardTools() config.ToolsConfig {
|
||||||
|
brave := config.BraveConfig{
|
||||||
|
Enabled: c.Web.Brave.Enabled,
|
||||||
|
MaxResults: c.Web.Brave.MaxResults,
|
||||||
|
}
|
||||||
|
if c.Web.Brave.APIKey != "" {
|
||||||
|
brave.SetAPIKey(c.Web.Brave.APIKey)
|
||||||
|
}
|
||||||
|
if len(c.Web.Brave.APIKeys) > 0 {
|
||||||
|
brave.SetAPIKeys(c.Web.Brave.APIKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
tavily := config.TavilyConfig{
|
||||||
|
Enabled: c.Web.Tavily.Enabled,
|
||||||
|
BaseURL: c.Web.Tavily.BaseURL,
|
||||||
|
MaxResults: c.Web.Tavily.MaxResults,
|
||||||
|
}
|
||||||
|
if c.Web.Tavily.APIKey != "" {
|
||||||
|
tavily.SetAPIKey(c.Web.Tavily.APIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
perplexity := config.PerplexityConfig{
|
||||||
|
Enabled: c.Web.Perplexity.Enabled,
|
||||||
|
MaxResults: c.Web.Perplexity.MaxResults,
|
||||||
|
}
|
||||||
|
if c.Web.Perplexity.APIKey != "" {
|
||||||
|
perplexity.SetAPIKey(c.Web.Perplexity.APIKey)
|
||||||
|
}
|
||||||
|
|
||||||
return config.ToolsConfig{
|
return config.ToolsConfig{
|
||||||
Web: config.WebToolsConfig{
|
Web: config.WebToolsConfig{
|
||||||
Brave: config.BraveConfig{
|
Brave: brave,
|
||||||
Enabled: c.Web.Brave.Enabled,
|
Tavily: tavily,
|
||||||
APIKey: c.Web.Brave.APIKey,
|
|
||||||
APIKeys: c.Web.Brave.APIKeys,
|
|
||||||
MaxResults: c.Web.Brave.MaxResults,
|
|
||||||
},
|
|
||||||
Tavily: config.TavilyConfig{
|
|
||||||
Enabled: c.Web.Tavily.Enabled,
|
|
||||||
APIKey: c.Web.Tavily.APIKey,
|
|
||||||
BaseURL: c.Web.Tavily.BaseURL,
|
|
||||||
MaxResults: c.Web.Tavily.MaxResults,
|
|
||||||
},
|
|
||||||
DuckDuckGo: config.DuckDuckGoConfig{
|
DuckDuckGo: config.DuckDuckGoConfig{
|
||||||
Enabled: c.Web.DuckDuckGo.Enabled,
|
Enabled: c.Web.DuckDuckGo.Enabled,
|
||||||
MaxResults: c.Web.DuckDuckGo.MaxResults,
|
MaxResults: c.Web.DuckDuckGo.MaxResults,
|
||||||
},
|
},
|
||||||
Perplexity: config.PerplexityConfig{
|
Perplexity: perplexity,
|
||||||
Enabled: c.Web.Perplexity.Enabled,
|
Proxy: c.Web.Proxy,
|
||||||
APIKey: c.Web.Perplexity.APIKey,
|
|
||||||
MaxResults: c.Web.Perplexity.MaxResults,
|
|
||||||
},
|
|
||||||
Proxy: c.Web.Proxy,
|
|
||||||
},
|
},
|
||||||
Cron: config.CronToolsConfig{
|
Cron: config.CronToolsConfig{
|
||||||
ExecTimeoutMinutes: c.Cron.ExecTimeoutMinutes,
|
ExecTimeoutMinutes: c.Cron.ExecTimeoutMinutes,
|
||||||
|
|
|
||||||
|
|
@ -697,7 +697,7 @@ func TestToStandardConfig(t *testing.T) {
|
||||||
for _, m := range stdCfg.ModelList {
|
for _, m := range stdCfg.ModelList {
|
||||||
if m.ModelName == "claude-sonnet-4-20250514" {
|
if m.ModelName == "claude-sonnet-4-20250514" {
|
||||||
foundModel = true
|
foundModel = true
|
||||||
foundAPIKey = m.APIKey
|
foundAPIKey = m.APIKey()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -711,8 +711,8 @@ func TestToStandardConfig(t *testing.T) {
|
||||||
if !stdCfg.Channels.Telegram.Enabled {
|
if !stdCfg.Channels.Telegram.Enabled {
|
||||||
t.Error("telegram should be enabled")
|
t.Error("telegram should be enabled")
|
||||||
}
|
}
|
||||||
if stdCfg.Channels.Telegram.Token != "test-token" {
|
if stdCfg.Channels.Telegram.Token() != "test-token" {
|
||||||
t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token)
|
t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token())
|
||||||
}
|
}
|
||||||
|
|
||||||
if stdCfg.Gateway.Port != 8080 {
|
if stdCfg.Gateway.Port != 8080 {
|
||||||
|
|
|
||||||
|
|
@ -413,7 +413,7 @@ func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) {
|
||||||
|
|
||||||
func TestCreateProvider_ClaudeCli(t *testing.T) {
|
func TestCreateProvider_ClaudeCli(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.ModelList = []config.ModelConfig{
|
cfg.ModelList = []*config.ModelConfig{
|
||||||
{ModelName: "claude-sonnet-4.6", Model: "claude-cli/claude-sonnet-4.6", Workspace: "/test/ws"},
|
{ModelName: "claude-sonnet-4.6", Model: "claude-cli/claude-sonnet-4.6", Workspace: "/test/ws"},
|
||||||
}
|
}
|
||||||
cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
|
cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
|
||||||
|
|
@ -434,7 +434,7 @@ func TestCreateProvider_ClaudeCli(t *testing.T) {
|
||||||
|
|
||||||
func TestCreateProvider_ClaudeCode(t *testing.T) {
|
func TestCreateProvider_ClaudeCode(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.ModelList = []config.ModelConfig{
|
cfg.ModelList = []*config.ModelConfig{
|
||||||
{ModelName: "claude-code", Model: "claude-cli/claude-code"},
|
{ModelName: "claude-code", Model: "claude-cli/claude-code"},
|
||||||
}
|
}
|
||||||
cfg.Agents.Defaults.ModelName = "claude-code"
|
cfg.Agents.Defaults.ModelName = "claude-code"
|
||||||
|
|
@ -450,7 +450,7 @@ func TestCreateProvider_ClaudeCode(t *testing.T) {
|
||||||
|
|
||||||
func TestCreateProvider_ClaudeCodec(t *testing.T) {
|
func TestCreateProvider_ClaudeCodec(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.ModelList = []config.ModelConfig{
|
cfg.ModelList = []*config.ModelConfig{
|
||||||
{ModelName: "claudecode", Model: "claude-cli/claudecode"},
|
{ModelName: "claudecode", Model: "claude-cli/claudecode"},
|
||||||
}
|
}
|
||||||
cfg.Agents.Defaults.ModelName = "claudecode"
|
cfg.Agents.Defaults.ModelName = "claudecode"
|
||||||
|
|
@ -466,7 +466,7 @@ func TestCreateProvider_ClaudeCodec(t *testing.T) {
|
||||||
|
|
||||||
func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) {
|
func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.ModelList = []config.ModelConfig{
|
cfg.ModelList = []*config.ModelConfig{
|
||||||
{ModelName: "claude-cli", Model: "claude-cli/claude-sonnet"},
|
{ModelName: "claude-cli", Model: "claude-cli/claude-sonnet"},
|
||||||
}
|
}
|
||||||
cfg.Agents.Defaults.ModelName = "claude-cli"
|
cfg.Agents.Defaults.ModelName = "claude-cli"
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
return provider, modelID, nil
|
return provider, modelID, nil
|
||||||
}
|
}
|
||||||
// OpenAI with API key
|
// OpenAI with API key
|
||||||
if cfg.APIKey == "" && cfg.APIBase == "" {
|
if cfg.APIKey() == "" && cfg.APIBase == "" {
|
||||||
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
||||||
}
|
}
|
||||||
apiBase := cfg.APIBase
|
apiBase := cfg.APIBase
|
||||||
|
|
@ -88,7 +88,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
apiBase = getDefaultAPIBase(protocol)
|
apiBase = getDefaultAPIBase(protocol)
|
||||||
}
|
}
|
||||||
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
cfg.APIKey,
|
cfg.APIKey(),
|
||||||
apiBase,
|
apiBase,
|
||||||
cfg.Proxy,
|
cfg.Proxy,
|
||||||
cfg.MaxTokensField,
|
cfg.MaxTokensField,
|
||||||
|
|
@ -98,7 +98,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
case "azure", "azure-openai":
|
case "azure", "azure-openai":
|
||||||
// Azure OpenAI uses deployment-based URLs, api-key header auth,
|
// Azure OpenAI uses deployment-based URLs, api-key header auth,
|
||||||
// and always sends max_completion_tokens.
|
// and always sends max_completion_tokens.
|
||||||
if cfg.APIKey == "" {
|
if cfg.APIKey() == "" {
|
||||||
return nil, "", fmt.Errorf("api_key is required for azure protocol")
|
return nil, "", fmt.Errorf("api_key is required for azure protocol")
|
||||||
}
|
}
|
||||||
if cfg.APIBase == "" {
|
if cfg.APIBase == "" {
|
||||||
|
|
@ -107,7 +107,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return azure.NewProviderWithTimeout(
|
return azure.NewProviderWithTimeout(
|
||||||
cfg.APIKey,
|
cfg.APIKey(),
|
||||||
cfg.APIBase,
|
cfg.APIBase,
|
||||||
cfg.Proxy,
|
cfg.Proxy,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
|
|
@ -118,7 +118,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
"vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian",
|
"vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian",
|
||||||
"minimax", "longcat", "modelscope", "novita":
|
"minimax", "longcat", "modelscope", "novita":
|
||||||
// All other OpenAI-compatible HTTP providers
|
// All other OpenAI-compatible HTTP providers
|
||||||
if cfg.APIKey == "" && cfg.APIBase == "" {
|
if cfg.APIKey() == "" && cfg.APIBase == "" {
|
||||||
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
||||||
}
|
}
|
||||||
apiBase := cfg.APIBase
|
apiBase := cfg.APIBase
|
||||||
|
|
@ -126,7 +126,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
apiBase = getDefaultAPIBase(protocol)
|
apiBase = getDefaultAPIBase(protocol)
|
||||||
}
|
}
|
||||||
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
cfg.APIKey,
|
cfg.APIKey(),
|
||||||
apiBase,
|
apiBase,
|
||||||
cfg.Proxy,
|
cfg.Proxy,
|
||||||
cfg.MaxTokensField,
|
cfg.MaxTokensField,
|
||||||
|
|
@ -147,11 +147,11 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
if apiBase == "" {
|
if apiBase == "" {
|
||||||
apiBase = "https://api.anthropic.com/v1"
|
apiBase = "https://api.anthropic.com/v1"
|
||||||
}
|
}
|
||||||
if cfg.APIKey == "" {
|
if cfg.APIKey() == "" {
|
||||||
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
|
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
|
||||||
}
|
}
|
||||||
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
cfg.APIKey,
|
cfg.APIKey(),
|
||||||
apiBase,
|
apiBase,
|
||||||
cfg.Proxy,
|
cfg.Proxy,
|
||||||
cfg.MaxTokensField,
|
cfg.MaxTokensField,
|
||||||
|
|
@ -164,11 +164,11 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
if apiBase == "" {
|
if apiBase == "" {
|
||||||
apiBase = "https://api.anthropic.com/v1"
|
apiBase = "https://api.anthropic.com/v1"
|
||||||
}
|
}
|
||||||
if cfg.APIKey == "" {
|
if cfg.APIKey() == "" {
|
||||||
return nil, "", fmt.Errorf("api_key is required for anthropic-messages protocol (model: %s)", cfg.Model)
|
return nil, "", fmt.Errorf("api_key is required for anthropic-messages protocol (model: %s)", cfg.Model)
|
||||||
}
|
}
|
||||||
return anthropicmessages.NewProviderWithTimeout(
|
return anthropicmessages.NewProviderWithTimeout(
|
||||||
cfg.APIKey,
|
cfg.APIKey(),
|
||||||
apiBase,
|
apiBase,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
|
||||||
|
|
@ -89,9 +89,9 @@ func TestCreateProviderFromConfig_OpenAI(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "test-openai",
|
ModelName: "test-openai",
|
||||||
Model: "openai/gpt-4o",
|
Model: "openai/gpt-4o",
|
||||||
APIKey: "test-key",
|
|
||||||
APIBase: "https://api.example.com/v1",
|
APIBase: "https://api.example.com/v1",
|
||||||
}
|
}
|
||||||
|
cfg.SetAPIKey("test-key")
|
||||||
|
|
||||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -129,8 +129,8 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "test-" + tt.protocol,
|
ModelName: "test-" + tt.protocol,
|
||||||
Model: tt.protocol + "/test-model",
|
Model: tt.protocol + "/test-model",
|
||||||
APIKey: "test-key",
|
|
||||||
}
|
}
|
||||||
|
cfg.SetAPIKey("test-key")
|
||||||
|
|
||||||
provider, _, err := CreateProviderFromConfig(cfg)
|
provider, _, err := CreateProviderFromConfig(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -155,9 +155,9 @@ func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "test-litellm",
|
ModelName: "test-litellm",
|
||||||
Model: "litellm/my-proxy-alias",
|
Model: "litellm/my-proxy-alias",
|
||||||
APIKey: "test-key",
|
|
||||||
APIBase: "http://localhost:4000/v1",
|
APIBase: "http://localhost:4000/v1",
|
||||||
}
|
}
|
||||||
|
cfg.SetAPIKey("test-key")
|
||||||
|
|
||||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -175,9 +175,9 @@ func TestCreateProviderFromConfig_LongCat(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "test-longcat",
|
ModelName: "test-longcat",
|
||||||
Model: "longcat/LongCat-Flash-Thinking",
|
Model: "longcat/LongCat-Flash-Thinking",
|
||||||
APIKey: "test-key",
|
|
||||||
APIBase: "https://api.longcat.chat/openai",
|
APIBase: "https://api.longcat.chat/openai",
|
||||||
}
|
}
|
||||||
|
cfg.SetAPIKey("test-key")
|
||||||
|
|
||||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -198,9 +198,9 @@ func TestCreateProviderFromConfig_ModelScope(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "test-modelscope",
|
ModelName: "test-modelscope",
|
||||||
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
|
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||||
APIKey: "test-key",
|
|
||||||
APIBase: "https://api-inference.modelscope.cn/v1",
|
APIBase: "https://api-inference.modelscope.cn/v1",
|
||||||
}
|
}
|
||||||
|
cfg.SetAPIKey("test-key")
|
||||||
|
|
||||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -227,8 +227,8 @@ func TestCreateProviderFromConfig_Novita(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "test-novita",
|
ModelName: "test-novita",
|
||||||
Model: "novita/deepseek/deepseek-v3.2",
|
Model: "novita/deepseek/deepseek-v3.2",
|
||||||
APIKey: "test-key",
|
|
||||||
}
|
}
|
||||||
|
cfg.SetAPIKey("test-key")
|
||||||
|
|
||||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -255,8 +255,8 @@ func TestCreateProviderFromConfig_Anthropic(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "test-anthropic",
|
ModelName: "test-anthropic",
|
||||||
Model: "anthropic/claude-sonnet-4.6",
|
Model: "anthropic/claude-sonnet-4.6",
|
||||||
APIKey: "test-key",
|
|
||||||
}
|
}
|
||||||
|
cfg.SetAPIKey("test-key")
|
||||||
|
|
||||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -340,8 +340,8 @@ func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "test-unknown",
|
ModelName: "test-unknown",
|
||||||
Model: "unknown-protocol/model",
|
Model: "unknown-protocol/model",
|
||||||
APIKey: "test-key",
|
|
||||||
}
|
}
|
||||||
|
cfg.SetAPIKey("test-key")
|
||||||
|
|
||||||
_, _, err := CreateProviderFromConfig(cfg)
|
_, _, err := CreateProviderFromConfig(cfg)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -382,6 +382,7 @@ func TestCreateProviderFromConfig_RequestTimeoutPropagation(t *testing.T) {
|
||||||
APIBase: server.URL,
|
APIBase: server.URL,
|
||||||
RequestTimeout: 1,
|
RequestTimeout: 1,
|
||||||
}
|
}
|
||||||
|
cfg.SetAPIKey("test-key")
|
||||||
|
|
||||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -411,9 +412,9 @@ func TestCreateProviderFromConfig_Azure(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "azure-gpt5",
|
ModelName: "azure-gpt5",
|
||||||
Model: "azure/my-gpt5-deployment",
|
Model: "azure/my-gpt5-deployment",
|
||||||
APIKey: "test-azure-key",
|
|
||||||
APIBase: "https://my-resource.openai.azure.com",
|
APIBase: "https://my-resource.openai.azure.com",
|
||||||
}
|
}
|
||||||
|
cfg.SetAPIKey("test-azure-key")
|
||||||
|
|
||||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -431,9 +432,9 @@ func TestCreateProviderFromConfig_AzureOpenAIAlias(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "azure-gpt4",
|
ModelName: "azure-gpt4",
|
||||||
Model: "azure-openai/my-deployment",
|
Model: "azure-openai/my-deployment",
|
||||||
APIKey: "test-azure-key",
|
|
||||||
APIBase: "https://my-resource.openai.azure.com",
|
APIBase: "https://my-resource.openai.azure.com",
|
||||||
}
|
}
|
||||||
|
cfg.SetAPIKey("test-azure-key")
|
||||||
|
|
||||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -464,8 +465,8 @@ func TestCreateProviderFromConfig_AzureMissingAPIBase(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "azure-gpt5",
|
ModelName: "azure-gpt5",
|
||||||
Model: "azure/my-gpt5-deployment",
|
Model: "azure/my-gpt5-deployment",
|
||||||
APIKey: "test-azure-key",
|
|
||||||
}
|
}
|
||||||
|
cfg.SetAPIKey("test-azure-key")
|
||||||
|
|
||||||
_, _, err := CreateProviderFromConfig(cfg)
|
_, _, err := CreateProviderFromConfig(cfg)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,13 @@ import (
|
||||||
func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) {
|
func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.ModelName = "test-openrouter"
|
cfg.Agents.Defaults.ModelName = "test-openrouter"
|
||||||
cfg.ModelList = []config.ModelConfig{
|
modelCfg := &config.ModelConfig{
|
||||||
{
|
ModelName: "test-openrouter",
|
||||||
ModelName: "test-openrouter",
|
Model: "openrouter/auto",
|
||||||
Model: "openrouter/auto",
|
APIBase: "https://openrouter.ai/api/v1",
|
||||||
APIKey: "sk-or-test",
|
|
||||||
APIBase: "https://openrouter.ai/api/v1",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
modelCfg.SetAPIKey("sk-or-test")
|
||||||
|
cfg.ModelList = []*config.ModelConfig{modelCfg}
|
||||||
|
|
||||||
provider, _, err := CreateProvider(cfg)
|
provider, _, err := CreateProvider(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -32,7 +31,7 @@ func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) {
|
||||||
func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) {
|
func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.ModelName = "test-codex"
|
cfg.Agents.Defaults.ModelName = "test-codex"
|
||||||
cfg.ModelList = []config.ModelConfig{
|
cfg.ModelList = []*config.ModelConfig{
|
||||||
{
|
{
|
||||||
ModelName: "test-codex",
|
ModelName: "test-codex",
|
||||||
Model: "codex-cli/codex-model",
|
Model: "codex-cli/codex-model",
|
||||||
|
|
@ -53,7 +52,7 @@ func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) {
|
||||||
func TestCreateProviderReturnsClaudeCliProviderForClaudeCli(t *testing.T) {
|
func TestCreateProviderReturnsClaudeCliProviderForClaudeCli(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.ModelName = "test-claude-cli"
|
cfg.Agents.Defaults.ModelName = "test-claude-cli"
|
||||||
cfg.ModelList = []config.ModelConfig{
|
cfg.ModelList = []*config.ModelConfig{
|
||||||
{
|
{
|
||||||
ModelName: "test-claude-cli",
|
ModelName: "test-claude-cli",
|
||||||
Model: "claude-cli/claude-sonnet",
|
Model: "claude-cli/claude-sonnet",
|
||||||
|
|
@ -86,7 +85,7 @@ func TestCreateProviderReturnsClaudeProviderForAnthropicOAuth(t *testing.T) {
|
||||||
|
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.ModelName = "test-claude-oauth"
|
cfg.Agents.Defaults.ModelName = "test-claude-oauth"
|
||||||
cfg.ModelList = []config.ModelConfig{
|
cfg.ModelList = []*config.ModelConfig{
|
||||||
{
|
{
|
||||||
ModelName: "test-claude-oauth",
|
ModelName: "test-claude-oauth",
|
||||||
Model: "anthropic/claude-sonnet-4.6",
|
Model: "anthropic/claude-sonnet-4.6",
|
||||||
|
|
|
||||||
|
|
@ -168,8 +168,8 @@ func (t *GroqTranscriber) Name() string {
|
||||||
func DetectTranscriber(cfg *config.Config) Transcriber {
|
func DetectTranscriber(cfg *config.Config) Transcriber {
|
||||||
// return any model-list entry that uses the groq/ protocol.
|
// return any model-list entry that uses the groq/ protocol.
|
||||||
for _, mc := range cfg.ModelList {
|
for _, mc := range cfg.ModelList {
|
||||||
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
|
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey() != "" {
|
||||||
return NewGroqTranscriber(mc.APIKey)
|
return NewGroqTranscriber(mc.APIKey())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -36,30 +36,45 @@ func TestDetectTranscriber(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "groq via model list",
|
name: "groq via model list",
|
||||||
cfg: &config.Config{
|
cfg: (&config.Config{
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{Model: "openai/gpt-4o", APIKey: "sk-openai"},
|
{ModelName: "openai", Model: "openai/gpt-4o"},
|
||||||
{Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"},
|
{ModelName: "groq", Model: "groq/llama-3.3-70b"},
|
||||||
},
|
},
|
||||||
},
|
}).WithSecurity(&config.SecurityConfig{
|
||||||
|
ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"openai": {
|
||||||
|
APIKeys: []string{"sk-openai"},
|
||||||
|
},
|
||||||
|
"groq": {
|
||||||
|
APIKeys: []string{"sk-groq-model"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
wantName: "groq",
|
wantName: "groq",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "groq model list entry without key is skipped",
|
name: "groq model list entry without key is skipped",
|
||||||
cfg: &config.Config{
|
cfg: &config.Config{
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{Model: "groq/llama-3.3-70b", APIKey: ""},
|
{Model: "groq/llama-3.3-70b"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantNil: true,
|
wantNil: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "provider key takes priority over model list",
|
name: "provider key takes priority over model list",
|
||||||
cfg: &config.Config{
|
cfg: (&config.Config{
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"},
|
{ModelName: "groq", Model: "groq/llama-3.3-70b"},
|
||||||
},
|
},
|
||||||
},
|
}).WithSecurity(&config.SecurityConfig{
|
||||||
|
ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"groq": {
|
||||||
|
APIKeys: []string{"sk-groq-model"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
wantName: "groq",
|
wantName: "groq",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
184
security.example.yml
Normal file
184
security.example.yml
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
# PicoClaw Security Configuration
|
||||||
|
# This file stores all sensitive data (API keys, tokens, secrets, passwords)
|
||||||
|
# Keep this file secure and never commit it to version control
|
||||||
|
# Copy this file to security.yml and fill in your actual values
|
||||||
|
|
||||||
|
# Model API Keys
|
||||||
|
# Use dot notation references in config.json like: "ref:model_list.gpt-5.4.api_key"
|
||||||
|
# IMPORTANT: Use 'api_keys' (array) format - both single and multiple keys
|
||||||
|
model_list:
|
||||||
|
# Example: OpenAI GPT-5.4 (multiple keys for load balancing/failover)
|
||||||
|
gpt-5.4:
|
||||||
|
api_keys:
|
||||||
|
- "your-openai-api-key-1"
|
||||||
|
- "your-openai-api-key-2" # Optional: failover key
|
||||||
|
|
||||||
|
# Example: Claude Sonnet (single key in array format)
|
||||||
|
claude-sonnet-4.6:
|
||||||
|
api_keys:
|
||||||
|
- "your-anthropic-api-key-here" # Single key MUST be in array format
|
||||||
|
|
||||||
|
# Example: Zhipu GLM
|
||||||
|
glm-4.7:
|
||||||
|
api_key: "your-zhipu-api-key-here"
|
||||||
|
|
||||||
|
# Example: DeepSeek
|
||||||
|
deepseek-chat:
|
||||||
|
api_key: "your-deepseek-api-key-here"
|
||||||
|
|
||||||
|
# Example: Google Gemini
|
||||||
|
gemini-2.0-flash:
|
||||||
|
api_key: "your-gemini-api-key-here"
|
||||||
|
|
||||||
|
# Example: Qwen
|
||||||
|
qwen-plus:
|
||||||
|
api_key: "your-qwen-api-key-here"
|
||||||
|
|
||||||
|
# Example: Moonshot
|
||||||
|
moonshot-v1-8k:
|
||||||
|
api_key: "your-moonshot-api-key-here"
|
||||||
|
|
||||||
|
# Example: Groq
|
||||||
|
llama-3.3-70b:
|
||||||
|
api_key: "your-groq-api-key-here"
|
||||||
|
|
||||||
|
# Example: OpenRouter
|
||||||
|
openrouter-auto:
|
||||||
|
api_key: "your-openrouter-api-key-here"
|
||||||
|
openrouter-gpt-5.4:
|
||||||
|
api_key: "your-openrouter-api-key-here"
|
||||||
|
|
||||||
|
# Example: NVIDIA
|
||||||
|
nemotron-4-340b:
|
||||||
|
api_key: "your-nvidia-api-key-here"
|
||||||
|
|
||||||
|
# Example: Cerebras
|
||||||
|
cerebras-llama-3.3-70b:
|
||||||
|
api_key: "your-cerebras-api-key-here"
|
||||||
|
|
||||||
|
# Example: Vivgrid
|
||||||
|
vivgrid-auto:
|
||||||
|
api_key: "your-vivgrid-api-key-here"
|
||||||
|
|
||||||
|
# Example: Volcengine
|
||||||
|
ark-code-latest:
|
||||||
|
api_key: "your-volcengine-api-key-here"
|
||||||
|
doubao-pro:
|
||||||
|
api_key: "your-volcengine-api-key-here"
|
||||||
|
|
||||||
|
# Example: ShengsuanYun
|
||||||
|
deepseek-v3:
|
||||||
|
api_key: "your-shengsuanyun-api-key-here"
|
||||||
|
|
||||||
|
# Example: Mistral
|
||||||
|
mistral-small:
|
||||||
|
api_key: "your-mistral-api-key-here"
|
||||||
|
|
||||||
|
# Example: Avian
|
||||||
|
deepseek-v3.2:
|
||||||
|
api_key: "your-avian-api-key-here"
|
||||||
|
kimi-k2.5:
|
||||||
|
api_key: "your-avian-api-key-here"
|
||||||
|
|
||||||
|
# Example: Minimax
|
||||||
|
MiniMax-M2.5:
|
||||||
|
api_key: "your-minimax-api-key-here"
|
||||||
|
|
||||||
|
# Example: LongCat
|
||||||
|
LongCat-Flash-Thinking:
|
||||||
|
api_key: "your-longcat-api-key-here"
|
||||||
|
|
||||||
|
# Example: ModelScope
|
||||||
|
modelscope-qwen:
|
||||||
|
api_key: "your-modelscope-api-key-here"
|
||||||
|
|
||||||
|
# Example: VLLM (local, usually no real key needed)
|
||||||
|
local-model:
|
||||||
|
api_key: ""
|
||||||
|
|
||||||
|
# Example: Azure OpenAI
|
||||||
|
azure-gpt5:
|
||||||
|
api_key: "your-azure-api-key-here"
|
||||||
|
|
||||||
|
# Channel Tokens and Secrets
|
||||||
|
channels:
|
||||||
|
telegram:
|
||||||
|
token: "your-telegram-bot-token"
|
||||||
|
|
||||||
|
feishu:
|
||||||
|
app_secret: "your-feishu-app-secret"
|
||||||
|
encrypt_key: "your-feishu-encrypt-key"
|
||||||
|
verification_token: "your-feishu-verification-token"
|
||||||
|
|
||||||
|
discord:
|
||||||
|
token: "your-discord-bot-token"
|
||||||
|
|
||||||
|
qq:
|
||||||
|
app_secret: "your-qq-app-secret"
|
||||||
|
|
||||||
|
dingtalk:
|
||||||
|
client_secret: "your-dingtalk-client-secret"
|
||||||
|
|
||||||
|
slack:
|
||||||
|
bot_token: "your-slack-bot-token"
|
||||||
|
app_token: "your-slack-app-token"
|
||||||
|
|
||||||
|
matrix:
|
||||||
|
access_token: "your-matrix-access-token"
|
||||||
|
|
||||||
|
line:
|
||||||
|
channel_secret: "your-line-channel-secret"
|
||||||
|
channel_access_token: "your-line-channel-access-token"
|
||||||
|
|
||||||
|
onebot:
|
||||||
|
access_token: "your-onebot-access-token"
|
||||||
|
|
||||||
|
wecom:
|
||||||
|
token: "your-wecom-token"
|
||||||
|
encoding_aes_key: "your-wecom-encoding-aes-key"
|
||||||
|
|
||||||
|
wecom_app:
|
||||||
|
corp_secret: "your-wecom-app-corp-secret"
|
||||||
|
token: "your-wecom-app-token"
|
||||||
|
encoding_aes_key: "your-wecom-app-encoding-aes-key"
|
||||||
|
|
||||||
|
wecom_aibot:
|
||||||
|
token: "your-wecom-aibot-token"
|
||||||
|
encoding_aes_key: "your-wecom-aibot-encoding-aes-key"
|
||||||
|
|
||||||
|
pico:
|
||||||
|
token: "your-pico-token"
|
||||||
|
|
||||||
|
irc:
|
||||||
|
password: "your-irc-password"
|
||||||
|
nickserv_password: "your-irc-nickserv-password"
|
||||||
|
sasl_password: "your-irc-sasl-password"
|
||||||
|
|
||||||
|
# Web Tool API Keys
|
||||||
|
# IMPORTANT: Use 'api_keys' (array) for Brave, Tavily, Perplexity
|
||||||
|
# Use 'api_key' (single string) for GLMSearch only
|
||||||
|
web:
|
||||||
|
brave:
|
||||||
|
api_keys:
|
||||||
|
- "your-brave-api-key-1"
|
||||||
|
- "your-brave-api-key-2" # Optional: failover key
|
||||||
|
|
||||||
|
tavily:
|
||||||
|
api_keys:
|
||||||
|
- "your-tavily-api-key" # Single key MUST be in array format
|
||||||
|
|
||||||
|
perplexity:
|
||||||
|
api_keys:
|
||||||
|
- "your-perplexity-api-key-1"
|
||||||
|
- "your-perplexity-api-key-2"
|
||||||
|
|
||||||
|
glm_search:
|
||||||
|
api_key: "your-glm-search-api-key" # GLMSearch uses single string format (NOT array)
|
||||||
|
|
||||||
|
# Skills Registry Tokens
|
||||||
|
skills:
|
||||||
|
github:
|
||||||
|
token: "your-github-token"
|
||||||
|
|
||||||
|
clawhub:
|
||||||
|
auth_token: "your-clawhub-auth-token"
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// registerConfigRoutes binds configuration management endpoints to the ServeMux.
|
// registerConfigRoutes binds configuration management endpoints to the ServeMux.
|
||||||
|
|
@ -45,7 +46,7 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
|
|
||||||
var cfg config.Config
|
var cfg config.Config
|
||||||
if err := json.Unmarshal(body, &cfg); err != nil {
|
if err = json.Unmarshal(body, &cfg); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -63,6 +64,14 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.Infof("new config: %+v", cfg)
|
||||||
|
oldCfg, err := config.LoadConfig(h.configPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.SecurityCopyFrom(oldCfg)
|
||||||
|
|
||||||
if err := config.SaveConfig(h.configPath, &cfg); err != nil {
|
if err := config.SaveConfig(h.configPath, &cfg); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -150,6 +159,8 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newCfg.SecurityCopyFrom(cfg)
|
||||||
|
|
||||||
if err := config.SaveConfig(h.configPath, &newCfg); err != nil {
|
if err := config.SaveConfig(h.configPath, &newCfg); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -175,17 +186,17 @@ func validateConfig(cfg *config.Config) []string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pico channel: token required when enabled
|
// Pico channel: token required when enabled
|
||||||
if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token == "" {
|
if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token() == "" {
|
||||||
errs = append(errs, "channels.pico.token is required when pico channel is enabled")
|
errs = append(errs, "channels.pico.token is required when pico channel is enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Telegram: token required when enabled
|
// Telegram: token required when enabled
|
||||||
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token == "" {
|
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token() == "" {
|
||||||
errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")
|
errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discord: token required when enabled
|
// Discord: token required when enabled
|
||||||
if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token == "" {
|
if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token() == "" {
|
||||||
errs = append(errs, "channels.discord.token is required when discord channel is enabled")
|
errs = append(errs, "channels.discord.token is required when discord channel is enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testin
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"version": 1,
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.picoclaw/workspace"
|
"workspace": "~/.picoclaw/workspace"
|
||||||
|
|
@ -27,7 +28,7 @@ func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testin
|
||||||
{
|
{
|
||||||
"model_name": "custom-default",
|
"model_name": "custom-default",
|
||||||
"model": "openai/gpt-4o",
|
"model": "openai/gpt-4o",
|
||||||
"api_key": "sk-default"
|
"api_keys": ["sk-default"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}`))
|
}`))
|
||||||
|
|
|
||||||
|
|
@ -159,10 +159,10 @@ func (h *Handler) gatewayStartReady() (bool, string, error) {
|
||||||
return false, fmt.Sprintf("default model %q is invalid", modelName), nil
|
return false, fmt.Sprintf("default model %q is invalid", modelName), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hasModelConfiguration(*modelCfg) {
|
if !hasModelConfiguration(modelCfg) {
|
||||||
return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil
|
return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil
|
||||||
}
|
}
|
||||||
if requiresRuntimeProbe(*modelCfg) && !probeLocalModelAvailability(*modelCfg) {
|
if requiresRuntimeProbe(modelCfg) && !probeLocalModelAvailability(modelCfg) {
|
||||||
return false, fmt.Sprintf("default model %q is not reachable", modelName), nil
|
return false, fmt.Sprintf("default model %q is not reachable", modelName), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ func TestGatewayStartReady_ValidDefaultModel(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||||
cfg.ModelList[0].APIKey = "test-key"
|
cfg.ModelList[0].SetAPIKey("test-key")
|
||||||
err := config.SaveConfig(configPath, cfg)
|
err := config.SaveConfig(configPath, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SaveConfig() error = %v", err)
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
|
@ -144,7 +144,7 @@ func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||||
cfg.ModelList[0].APIKey = ""
|
cfg.ModelList[0].SetAPIKey("")
|
||||||
cfg.ModelList[0].AuthMethod = ""
|
cfg.ModelList[0].AuthMethod = ""
|
||||||
err := config.SaveConfig(configPath, cfg)
|
err := config.SaveConfig(configPath, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -177,7 +177,7 @@ func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadConfig() error = %v", err)
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
}
|
}
|
||||||
cfg.ModelList = []config.ModelConfig{{
|
cfg.ModelList = []*config.ModelConfig{{
|
||||||
ModelName: "local-vllm",
|
ModelName: "local-vllm",
|
||||||
Model: "vllm/custom-model",
|
Model: "vllm/custom-model",
|
||||||
APIBase: "http://localhost:8000/v1",
|
APIBase: "http://localhost:8000/v1",
|
||||||
|
|
@ -214,7 +214,7 @@ func TestGatewayStartReady_LocalModelWithRunningService(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadConfig() error = %v", err)
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
}
|
}
|
||||||
cfg.ModelList = []config.ModelConfig{{
|
cfg.ModelList = []*config.ModelConfig{{
|
||||||
ModelName: "local-vllm",
|
ModelName: "local-vllm",
|
||||||
Model: "vllm/custom-model",
|
Model: "vllm/custom-model",
|
||||||
APIBase: "http://127.0.0.1:8000/v1",
|
APIBase: "http://127.0.0.1:8000/v1",
|
||||||
|
|
@ -249,12 +249,12 @@ func TestGatewayStartReady_RemoteVLLMWithAPIKeyDoesNotProbe(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadConfig() error = %v", err)
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
}
|
}
|
||||||
cfg.ModelList = []config.ModelConfig{{
|
cfg.ModelList = []*config.ModelConfig{{
|
||||||
ModelName: "remote-vllm",
|
ModelName: "remote-vllm",
|
||||||
Model: "vllm/custom-model",
|
Model: "vllm/custom-model",
|
||||||
APIBase: "https://models.example.com/v1",
|
APIBase: "https://models.example.com/v1",
|
||||||
APIKey: "remote-key",
|
|
||||||
}}
|
}}
|
||||||
|
cfg.ModelList[0o0].SetAPIKey("remote-key")
|
||||||
cfg.Agents.Defaults.ModelName = "remote-vllm"
|
cfg.Agents.Defaults.ModelName = "remote-vllm"
|
||||||
err = config.SaveConfig(configPath, cfg)
|
err = config.SaveConfig(configPath, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -284,7 +284,7 @@ func TestGatewayStartReady_LocalOllamaUsesDefaultProbeBase(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadConfig() error = %v", err)
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
}
|
}
|
||||||
cfg.ModelList = []config.ModelConfig{{
|
cfg.ModelList = []*config.ModelConfig{{
|
||||||
ModelName: "local-ollama",
|
ModelName: "local-ollama",
|
||||||
Model: "ollama/llama3",
|
Model: "ollama/llama3",
|
||||||
}}
|
}}
|
||||||
|
|
@ -312,7 +312,7 @@ func TestGatewayStartReady_OAuthModelRequiresStoredCredential(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadConfig() error = %v", err)
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
}
|
}
|
||||||
cfg.ModelList = []config.ModelConfig{{
|
cfg.ModelList = []*config.ModelConfig{{
|
||||||
ModelName: "openai-oauth",
|
ModelName: "openai-oauth",
|
||||||
Model: "openai/gpt-5.4",
|
Model: "openai/gpt-5.4",
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
|
|
@ -483,12 +483,12 @@ func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||||
cfg.ModelList[0].APIKey = "test-key"
|
cfg.ModelList[0].SetAPIKey("test-key")
|
||||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{
|
||||||
ModelName: "second-model",
|
ModelName: "second-model",
|
||||||
Model: "openai/gpt-4.1",
|
Model: "openai/gpt-4.1",
|
||||||
APIKey: "second-key",
|
|
||||||
})
|
})
|
||||||
|
cfg.ModelList[len(cfg.ModelList)-1].SetAPIKey("second-key")
|
||||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
t.Fatalf("SaveConfig() error = %v", err)
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -627,7 +627,7 @@ func TestGatewayRestartKeepsRunningProcessWhenPreconditionsFail(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||||
cfg.ModelList[0].APIKey = ""
|
cfg.ModelList[0].SetAPIKey("")
|
||||||
cfg.ModelList[0].AuthMethod = ""
|
cfg.ModelList[0].AuthMethod = ""
|
||||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
t.Fatalf("SaveConfig() error = %v", err)
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
|
@ -680,7 +680,7 @@ func TestGatewayRestartKeepsOldProcessWhenItDoesNotExitInTime(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||||
cfg.ModelList[0].APIKey = "test-key"
|
cfg.ModelList[0].SetAPIKey("test-key")
|
||||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
t.Fatalf("SaveConfig() error = %v", err)
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -741,7 +741,7 @@ func TestGatewayRestartReturnsErrorStatusWhenReplacementFailsToStart(t *testing.
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||||
cfg.ModelList[0].APIKey = "test-key"
|
cfg.ModelList[0].SetAPIKey("test-key")
|
||||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
t.Fatalf("SaveConfig() error = %v", err)
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@ var (
|
||||||
probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel
|
probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel
|
||||||
)
|
)
|
||||||
|
|
||||||
func hasModelConfiguration(m config.ModelConfig) bool {
|
func hasModelConfiguration(m *config.ModelConfig) bool {
|
||||||
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
|
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
|
||||||
apiKey := strings.TrimSpace(m.APIKey)
|
apiKey := strings.TrimSpace(m.APIKey())
|
||||||
|
|
||||||
if authMethod == "oauth" || authMethod == "token" {
|
if authMethod == "oauth" || authMethod == "token" {
|
||||||
if provider, ok := oauthProviderForModel(m.Model); ok {
|
if provider, ok := oauthProviderForModel(m.Model); ok {
|
||||||
|
|
@ -44,7 +44,7 @@ func hasModelConfiguration(m config.ModelConfig) bool {
|
||||||
|
|
||||||
// isModelConfigured reports whether a model is currently available to use.
|
// isModelConfigured reports whether a model is currently available to use.
|
||||||
// Local models must be reachable; remote/API-key models only need saved config.
|
// Local models must be reachable; remote/API-key models only need saved config.
|
||||||
func isModelConfigured(m config.ModelConfig) bool {
|
func isModelConfigured(m *config.ModelConfig) bool {
|
||||||
if !hasModelConfiguration(m) {
|
if !hasModelConfiguration(m) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -54,7 +54,7 @@ func isModelConfigured(m config.ModelConfig) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func requiresRuntimeProbe(m config.ModelConfig) bool {
|
func requiresRuntimeProbe(m *config.ModelConfig) bool {
|
||||||
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
|
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
|
||||||
if authMethod == "local" {
|
if authMethod == "local" {
|
||||||
return true
|
return true
|
||||||
|
|
@ -75,7 +75,7 @@ func requiresRuntimeProbe(m config.ModelConfig) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func probeLocalModelAvailability(m config.ModelConfig) bool {
|
func probeLocalModelAvailability(m *config.ModelConfig) bool {
|
||||||
apiBase := modelProbeAPIBase(m)
|
apiBase := modelProbeAPIBase(m)
|
||||||
protocol, modelID := splitModel(m.Model)
|
protocol, modelID := splitModel(m.Model)
|
||||||
switch protocol {
|
switch protocol {
|
||||||
|
|
@ -95,7 +95,7 @@ func probeLocalModelAvailability(m config.ModelConfig) bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func modelProbeAPIBase(m config.ModelConfig) string {
|
func modelProbeAPIBase(m *config.ModelConfig) string {
|
||||||
if apiBase := strings.TrimSpace(m.APIBase); apiBase != "" {
|
if apiBase := strings.TrimSpace(m.APIBase); apiBase != "" {
|
||||||
return normalizeModelProbeAPIBase(apiBase)
|
return normalizeModelProbeAPIBase(apiBase)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(len(cfg.ModelList))
|
wg.Add(len(cfg.ModelList))
|
||||||
for i, m := range cfg.ModelList {
|
for i, m := range cfg.ModelList {
|
||||||
go func(i int, m config.ModelConfig) {
|
go func(i int, m *config.ModelConfig) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
configured[i] = isModelConfigured(m)
|
configured[i] = isModelConfigured(m)
|
||||||
}(i, m)
|
}(i, m)
|
||||||
|
|
@ -72,7 +72,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
||||||
ModelName: m.ModelName,
|
ModelName: m.ModelName,
|
||||||
Model: m.Model,
|
Model: m.Model,
|
||||||
APIBase: m.APIBase,
|
APIBase: m.APIBase,
|
||||||
APIKey: maskAPIKey(m.APIKey),
|
APIKey: maskAPIKey(m.APIKey()),
|
||||||
Proxy: m.Proxy,
|
Proxy: m.Proxy,
|
||||||
AuthMethod: m.AuthMethod,
|
AuthMethod: m.AuthMethod,
|
||||||
ConnectMode: m.ConnectMode,
|
ConnectMode: m.ConnectMode,
|
||||||
|
|
@ -122,7 +122,7 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.ModelList = append(cfg.ModelList, mc)
|
cfg.ModelList = append(cfg.ModelList, &mc)
|
||||||
|
|
||||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||||
|
|
@ -180,11 +180,11 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// Preserve the existing API key when the caller omits it (empty string).
|
// Preserve the existing API key when the caller omits it (empty string).
|
||||||
// This lets the UI update api_base / proxy without clearing the stored secret.
|
// This lets the UI update api_base / proxy without clearing the stored secret.
|
||||||
if mc.APIKey == "" {
|
if mc.APIKey() == "" {
|
||||||
mc.APIKey = cfg.ModelList[idx].APIKey
|
mc.SetAPIKey(cfg.ModelList[idx].APIKey())
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.ModelList[idx] = mc
|
cfg.ModelList[idx] = &mc
|
||||||
|
|
||||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadConfig() error = %v", err)
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
}
|
}
|
||||||
cfg.ModelList = []config.ModelConfig{
|
cfg.ModelList = []*config.ModelConfig{
|
||||||
{
|
{
|
||||||
ModelName: "openai-oauth",
|
ModelName: "openai-oauth",
|
||||||
Model: "openai/gpt-5.4",
|
Model: "openai/gpt-5.4",
|
||||||
|
|
@ -78,7 +78,6 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
|
||||||
ModelName: "vllm-remote",
|
ModelName: "vllm-remote",
|
||||||
Model: "vllm/custom-model",
|
Model: "vllm/custom-model",
|
||||||
APIBase: "https://models.example.com/v1",
|
APIBase: "https://models.example.com/v1",
|
||||||
APIKey: "remote-key",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ModelName: "copilot-gpt-5.4",
|
ModelName: "copilot-gpt-5.4",
|
||||||
|
|
@ -87,6 +86,11 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
cfg.WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"vllm-remote": {
|
||||||
|
APIKeys: []string{"remote-key"},
|
||||||
|
},
|
||||||
|
}})
|
||||||
cfg.Agents.Defaults.ModelName = "openai-oauth"
|
cfg.Agents.Defaults.ModelName = "openai-oauth"
|
||||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
t.Fatalf("SaveConfig() error = %v", err)
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
|
@ -152,7 +156,7 @@ func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadConfig() error = %v", err)
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
}
|
}
|
||||||
cfg.ModelList = []config.ModelConfig{{
|
cfg.ModelList = []*config.ModelConfig{{
|
||||||
ModelName: "claude-oauth",
|
ModelName: "claude-oauth",
|
||||||
Model: "anthropic/claude-sonnet-4.6",
|
Model: "anthropic/claude-sonnet-4.6",
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
|
|
@ -215,7 +219,7 @@ func TestHandleListModels_ProbesLocalModelsConcurrently(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadConfig() error = %v", err)
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
}
|
}
|
||||||
cfg.ModelList = []config.ModelConfig{
|
cfg.ModelList = []*config.ModelConfig{
|
||||||
{
|
{
|
||||||
ModelName: "local-vllm-a",
|
ModelName: "local-vllm-a",
|
||||||
Model: "vllm/custom-a",
|
Model: "vllm/custom-a",
|
||||||
|
|
@ -274,7 +278,7 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadConfig() error = %v", err)
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
}
|
}
|
||||||
cfg.ModelList = []config.ModelConfig{{
|
cfg.ModelList = []*config.ModelConfig{{
|
||||||
ModelName: "vllm-local",
|
ModelName: "vllm-local",
|
||||||
Model: "vllm/custom-model",
|
Model: "vllm/custom-model",
|
||||||
APIBase: "http://0.0.0.0:8000/v1",
|
APIBase: "http://0.0.0.0:8000/v1",
|
||||||
|
|
|
||||||
|
|
@ -776,28 +776,28 @@ func modelBelongsToProvider(provider, model string) bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultModelConfigForProvider(provider, authMethod string) config.ModelConfig {
|
func defaultModelConfigForProvider(provider, authMethod string) *config.ModelConfig {
|
||||||
switch provider {
|
switch provider {
|
||||||
case oauthProviderOpenAI:
|
case oauthProviderOpenAI:
|
||||||
return config.ModelConfig{
|
return &config.ModelConfig{
|
||||||
ModelName: "gpt-5.4",
|
ModelName: "gpt-5.4",
|
||||||
Model: "openai/gpt-5.4",
|
Model: "openai/gpt-5.4",
|
||||||
AuthMethod: authMethod,
|
AuthMethod: authMethod,
|
||||||
}
|
}
|
||||||
case oauthProviderAnthropic:
|
case oauthProviderAnthropic:
|
||||||
return config.ModelConfig{
|
return &config.ModelConfig{
|
||||||
ModelName: "claude-sonnet-4.6",
|
ModelName: "claude-sonnet-4.6",
|
||||||
Model: "anthropic/claude-sonnet-4.6",
|
Model: "anthropic/claude-sonnet-4.6",
|
||||||
AuthMethod: authMethod,
|
AuthMethod: authMethod,
|
||||||
}
|
}
|
||||||
case oauthProviderGoogleAntigravity:
|
case oauthProviderGoogleAntigravity:
|
||||||
return config.ModelConfig{
|
return &config.ModelConfig{
|
||||||
ModelName: "gemini-flash",
|
ModelName: "gemini-flash",
|
||||||
Model: "antigravity/gemini-3-flash",
|
Model: "antigravity/gemini-3-flash",
|
||||||
AuthMethod: authMethod,
|
AuthMethod: authMethod,
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return config.ModelConfig{}
|
return &config.ModelConfig{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadConfig error: %v", err)
|
t.Fatalf("LoadConfig error: %v", err)
|
||||||
}
|
}
|
||||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{
|
||||||
ModelName: "gpt-5.4",
|
ModelName: "gpt-5.4",
|
||||||
Model: "openai/gpt-5.4",
|
Model: "openai/gpt-5.4",
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
|
|
@ -229,12 +229,18 @@ func setupOAuthTestEnv(t *testing.T) (string, func()) {
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.ModelList = []config.ModelConfig{{
|
cfg.ModelList = []*config.ModelConfig{{
|
||||||
ModelName: "custom-default",
|
ModelName: "custom-default",
|
||||||
Model: "openai/gpt-4o",
|
Model: "openai/gpt-4o",
|
||||||
APIKey: "sk-default",
|
|
||||||
}}
|
}}
|
||||||
cfg.Agents.Defaults.ModelName = "custom-default"
|
cfg.Agents.Defaults.ModelName = "custom-default"
|
||||||
|
cfg.WithSecurity(&config.SecurityConfig{
|
||||||
|
ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"custom-default": {
|
||||||
|
APIKeys: []string{"sk-default"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
configPath := filepath.Join(tmp, "config.json")
|
configPath := filepath.Join(tmp, "config.json")
|
||||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
"token": cfg.Channels.Pico.Token,
|
"token": cfg.Channels.Pico.Token(),
|
||||||
"ws_url": wsURL,
|
"ws_url": wsURL,
|
||||||
"enabled": cfg.Channels.Pico.Enabled,
|
"enabled": cfg.Channels.Pico.Enabled,
|
||||||
})
|
})
|
||||||
|
|
@ -74,7 +74,7 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
token := generateSecureToken()
|
token := generateSecureToken()
|
||||||
cfg.Channels.Pico.Token = token
|
cfg.Channels.Pico.SetToken(token)
|
||||||
|
|
||||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||||
|
|
@ -110,8 +110,8 @@ func (h *Handler) ensurePicoChannel(callerOrigin string) (bool, error) {
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Channels.Pico.Token == "" {
|
if cfg.Channels.Pico.Token() == "" {
|
||||||
cfg.Channels.Pico.Token = generateSecureToken()
|
cfg.Channels.Pico.SetToken(generateSecureToken())
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,7 +150,7 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
"token": cfg.Channels.Pico.Token,
|
"token": cfg.Channels.Pico.Token(),
|
||||||
"ws_url": wsURL,
|
"ws_url": wsURL,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"changed": changed,
|
"changed": changed,
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ func TestEnsurePicoChannel_FreshConfig(t *testing.T) {
|
||||||
if !cfg.Channels.Pico.Enabled {
|
if !cfg.Channels.Pico.Enabled {
|
||||||
t.Error("expected Pico to be enabled after setup")
|
t.Error("expected Pico to be enabled after setup")
|
||||||
}
|
}
|
||||||
if cfg.Channels.Pico.Token == "" {
|
if cfg.Channels.Pico.Token() == "" {
|
||||||
t.Error("expected a non-empty token after setup")
|
t.Error("expected a non-empty token after setup")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -121,7 +121,7 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) {
|
||||||
// Pre-configure with custom user settings
|
// Pre-configure with custom user settings
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Channels.Pico.Enabled = true
|
cfg.Channels.Pico.Enabled = true
|
||||||
cfg.Channels.Pico.Token = "user-custom-token"
|
cfg.Channels.Pico.SetToken("user-custom-token")
|
||||||
cfg.Channels.Pico.AllowTokenQuery = true
|
cfg.Channels.Pico.AllowTokenQuery = true
|
||||||
cfg.Channels.Pico.AllowOrigins = []string{"https://myapp.example.com"}
|
cfg.Channels.Pico.AllowOrigins = []string{"https://myapp.example.com"}
|
||||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
|
@ -143,8 +143,8 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) {
|
||||||
t.Fatalf("LoadConfig() error = %v", err)
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Channels.Pico.Token != "user-custom-token" {
|
if cfg.Channels.Pico.Token() != "user-custom-token" {
|
||||||
t.Errorf("token = %q, want %q", cfg.Channels.Pico.Token, "user-custom-token")
|
t.Errorf("token = %q, want %q", cfg.Channels.Pico.Token(), "user-custom-token")
|
||||||
}
|
}
|
||||||
if !cfg.Channels.Pico.AllowTokenQuery {
|
if !cfg.Channels.Pico.AllowTokenQuery {
|
||||||
t.Error("user's allow_token_query=true must be preserved")
|
t.Error("user's allow_token_query=true must be preserved")
|
||||||
|
|
@ -166,7 +166,7 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg1, _ := config.LoadConfig(configPath)
|
cfg1, _ := config.LoadConfig(configPath)
|
||||||
token1 := cfg1.Channels.Pico.Token
|
token1 := cfg1.Channels.Pico.Token()
|
||||||
|
|
||||||
// Second call should be a no-op
|
// Second call should be a no-op
|
||||||
changed, err := h.ensurePicoChannel(origin)
|
changed, err := h.ensurePicoChannel(origin)
|
||||||
|
|
@ -178,7 +178,7 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg2, _ := config.LoadConfig(configPath)
|
cfg2, _ := config.LoadConfig(configPath)
|
||||||
if cfg2.Channels.Pico.Token != token1 {
|
if cfg2.Channels.Pico.Token() != token1 {
|
||||||
t.Error("token should not change on subsequent calls")
|
t.Error("token should not change on subsequent calls")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue