Compare commits

...
1 Commits
Author SHA1 Message Date
CityFun 8644579eda feat: Agent Model Account support custom config (#11933)
* feat: Agent Model Account support custom config

* feat: Add agent gatewayControlUi config
2026-02-24 18:35:38 +08:00
24 changed files with 492 additions and 125 deletions
+58 -40
View File
@@ -9,6 +9,9 @@ type AgentCreateReq struct {
BridgePort int `json:"bridgePort" validate:"required"`
Provider string `json:"provider" validate:"required"`
Model string `json:"model" validate:"required"`
APIType string `json:"apiType"`
MaxTokens int `json:"maxTokens"`
ContextWindow int `json:"contextWindow"`
AccountID uint `json:"accountId"`
APIKey string `json:"apiKey"`
BaseURL string `json:"baseURL"`
@@ -28,26 +31,29 @@ type AgentCreateReq struct {
}
type AgentItem struct {
ID uint `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
ProviderName string `json:"providerName"`
Model string `json:"model"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Token string `json:"token"`
Status string `json:"status"`
Message string `json:"message"`
AppInstallID uint `json:"appInstallId"`
AccountID uint `json:"accountId"`
AppVersion string `json:"appVersion"`
Container string `json:"containerName"`
WebUIPort int `json:"webUIPort"`
BridgePort int `json:"bridgePort"`
Path string `json:"path"`
ConfigPath string `json:"configPath"`
Upgradable bool `json:"upgradable"`
CreatedAt time.Time `json:"createdAt"`
ID uint `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
ProviderName string `json:"providerName"`
Model string `json:"model"`
APIType string `json:"apiType"`
MaxTokens int `json:"maxTokens"`
ContextWindow int `json:"contextWindow"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Token string `json:"token"`
Status string `json:"status"`
Message string `json:"message"`
AppInstallID uint `json:"appInstallId"`
AccountID uint `json:"accountId"`
AppVersion string `json:"appVersion"`
Container string `json:"containerName"`
WebUIPort int `json:"webUIPort"`
BridgePort int `json:"bridgePort"`
Path string `json:"path"`
ConfigPath string `json:"configPath"`
Upgradable bool `json:"upgradable"`
CreatedAt time.Time `json:"createdAt"`
}
type AgentDeleteReq struct {
@@ -67,20 +73,28 @@ type AgentModelConfigUpdateReq struct {
}
type AgentAccountCreateReq struct {
Provider string `json:"provider" validate:"required"`
Name string `json:"name" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
BaseURL string `json:"baseURL"`
Remark string `json:"remark"`
Provider string `json:"provider" validate:"required"`
Name string `json:"name" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
BaseURL string `json:"baseURL"`
Model string `json:"model"`
APIType string `json:"apiType"`
MaxTokens int `json:"maxTokens"`
ContextWindow int `json:"contextWindow"`
Remark string `json:"remark"`
}
type AgentAccountUpdateReq struct {
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
BaseURL string `json:"baseURL"`
Remark string `json:"remark"`
SyncAgents bool `json:"syncAgents"`
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
BaseURL string `json:"baseURL"`
Model string `json:"model"`
APIType string `json:"apiType"`
MaxTokens int `json:"maxTokens"`
ContextWindow int `json:"contextWindow"`
Remark string `json:"remark"`
SyncAgents bool `json:"syncAgents"`
}
type AgentAccountVerifyReq struct {
@@ -100,15 +114,19 @@ type AgentAccountSearch struct {
}
type AgentAccountInfo struct {
ID uint `json:"id"`
Provider string `json:"provider"`
ProviderName string `json:"providerName"`
Name string `json:"name"`
APIKey string `json:"apiKey"`
BaseURL string `json:"baseUrl"`
Verified bool `json:"verified"`
Remark string `json:"remark"`
CreatedAt time.Time `json:"createdAt"`
ID uint `json:"id"`
Provider string `json:"provider"`
ProviderName string `json:"providerName"`
Name string `json:"name"`
APIKey string `json:"apiKey"`
BaseURL string `json:"baseUrl"`
Model string `json:"model"`
APIType string `json:"apiType"`
MaxTokens int `json:"maxTokens"`
ContextWindow int `json:"contextWindow"`
Verified bool `json:"verified"`
Remark string `json:"remark"`
CreatedAt time.Time `json:"createdAt"`
}
type ProviderModelInfo struct {
+14 -11
View File
@@ -2,15 +2,18 @@ package model
type Agent struct {
BaseModel
Name string `json:"name" gorm:"not null;unique"`
Provider string `json:"provider"`
Model string `json:"model"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Token string `json:"token"`
Status string `json:"status"`
Message string `json:"message"`
AppInstallID uint `json:"appInstallId"`
AccountID uint `json:"accountId"`
ConfigPath string `json:"configPath"`
Name string `json:"name" gorm:"not null;unique"`
Provider string `json:"provider"`
Model string `json:"model"`
APIType string `json:"apiType"`
MaxTokens int `json:"maxTokens"`
ContextWindow int `json:"contextWindow"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Token string `json:"token"`
Status string `json:"status"`
Message string `json:"message"`
AppInstallID uint `json:"appInstallId"`
AccountID uint `json:"accountId"`
ConfigPath string `json:"configPath"`
}
+10 -6
View File
@@ -2,12 +2,16 @@ package model
type AgentAccount struct {
BaseModel
Provider string `json:"provider"`
Name string `json:"name"`
APIKey string `json:"apiKey"`
BaseURL string `json:"baseUrl"`
Verified bool `json:"verified"`
Remark string `json:"remark"`
Provider string `json:"provider"`
Name string `json:"name"`
APIKey string `json:"apiKey"`
BaseURL string `json:"baseUrl"`
Model string `json:"model"`
APIType string `json:"apiType"`
MaxTokens int `json:"maxTokens"`
ContextWindow int `json:"contextWindow"`
Verified bool `json:"verified"`
Remark string `json:"remark"`
}
func (AgentAccount) TableName() string {
+22 -11
View File
@@ -1,6 +1,8 @@
package provider
import "strings"
import (
"strings"
)
type Model struct {
ID string
@@ -21,13 +23,13 @@ var catalog = map[string]Meta{
"ollama": {
Key: "ollama",
DisplayName: "Ollama",
Sort: 1,
Sort: 10,
Enabled: true,
},
"deepseek": {
Key: "deepseek",
DisplayName: "DeepSeek",
Sort: 2,
Sort: 20,
DefaultBaseURL: "https://api.deepseek.com/v1",
EnvKey: "DEEPSEEK_API_KEY",
Enabled: true,
@@ -37,10 +39,19 @@ var catalog = map[string]Meta{
{ID: "deepseek/deepseek-r1:1.5b", Name: "DeepSeek R1 1.5B"},
},
},
"custom": {
Key: "custom",
DisplayName: "Custom",
Sort: 30,
DefaultBaseURL: "",
EnvKey: "CUSTOM_API_KEY",
Enabled: true,
Models: []Model{},
},
"openai": {
Key: "openai",
DisplayName: "OpenAI",
Sort: 3,
Sort: 40,
DefaultBaseURL: "https://api.openai.com/v1",
EnvKey: "OPENAI_API_KEY",
Enabled: true,
@@ -56,7 +67,7 @@ var catalog = map[string]Meta{
"anthropic": {
Key: "anthropic",
DisplayName: "Anthropic",
Sort: 4,
Sort: 50,
DefaultBaseURL: "https://api.anthropic.com",
EnvKey: "ANTHROPIC_API_KEY",
Enabled: true,
@@ -71,7 +82,7 @@ var catalog = map[string]Meta{
"gemini": {
Key: "gemini",
DisplayName: "Gemini",
Sort: 5,
Sort: 60,
DefaultBaseURL: "https://generativelanguage.googleapis.com",
EnvKey: "GEMINI_API_KEY",
Enabled: true,
@@ -87,7 +98,7 @@ var catalog = map[string]Meta{
"minimax": {
Key: "minimax",
DisplayName: "MiniMax (CN)",
Sort: 6,
Sort: 70,
DefaultBaseURL: "https://api.minimaxi.com/anthropic",
EnvKey: "MINIMAX_API_KEY",
Enabled: true,
@@ -99,7 +110,7 @@ var catalog = map[string]Meta{
"moonshot": {
Key: "moonshot",
DisplayName: "Moonshot (Global)",
Sort: 7,
Sort: 80,
DefaultBaseURL: "https://api.moonshot.ai/v1",
EnvKey: "MOONSHOT_API_KEY",
Enabled: true,
@@ -112,7 +123,7 @@ var catalog = map[string]Meta{
"kimi": {
Key: "kimi",
DisplayName: "Kimi (CN)",
Sort: 8,
Sort: 90,
DefaultBaseURL: "https://api.moonshot.cn/v1",
EnvKey: "KIMI_API_KEY",
Enabled: true,
@@ -125,7 +136,7 @@ var catalog = map[string]Meta{
"kimi-coding": {
Key: "kimi-coding",
DisplayName: "Kimi Coding",
Sort: 9,
Sort: 100,
DefaultBaseURL: "https://api.moonshot.cn/anthropic/v1",
EnvKey: "KIMI_API_KEY",
Enabled: true,
@@ -136,7 +147,7 @@ var catalog = map[string]Meta{
"qwen": {
Key: "qwen",
DisplayName: "Qwen",
Sort: 10,
Sort: 110,
DefaultBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
EnvKey: "QWEN_API_KEY",
Enabled: false,
+215 -50
View File
@@ -81,6 +81,7 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
if provider != "ollama" && strings.TrimSpace(account.APIKey) == "" {
return nil, buserr.New("ErrAgentApiKeyRequired")
}
apiType, maxTokens, contextWindow := resolveRuntimeParams(provider, account.APIType, account.MaxTokens, account.ContextWindow)
if err := checkPortExist(req.WebUIPort); err != nil {
return nil, err
}
@@ -109,6 +110,9 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
params := map[string]interface{}{
"PROVIDER": provider,
"MODEL": req.Model,
"API_TYPE": apiType,
"MAX_TOKENS": maxTokens,
"CONTEXT_WINDOW": contextWindow,
"BASE_URL": baseURL,
"API_KEY": account.APIKey,
"OPENCLAW_GATEWAY_TOKEN": token,
@@ -147,22 +151,25 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
}
configPath := path.Join(appInstall.GetPath(), "data", "conf", "openclaw.json")
agent := &model.Agent{
Name: req.Name,
Provider: provider,
Model: req.Model,
BaseURL: baseURL,
APIKey: account.APIKey,
Token: token,
Status: appInstall.Status,
Message: appInstall.Message,
AppInstallID: appInstall.ID,
AccountID: account.ID,
ConfigPath: configPath,
Name: req.Name,
Provider: provider,
Model: req.Model,
APIType: apiType,
MaxTokens: maxTokens,
ContextWindow: contextWindow,
BaseURL: baseURL,
APIKey: account.APIKey,
Token: token,
Status: appInstall.Status,
Message: appInstall.Message,
AppInstallID: appInstall.ID,
AccountID: account.ID,
ConfigPath: configPath,
}
if err := agentRepo.Create(agent); err != nil {
return nil, err
}
go a.writeConfigWithRetry(appInstall, provider, req.Model, baseURL, req.APIKey, token, agent.ID)
go a.writeConfigWithRetry(appInstall, provider, req.Model, apiType, maxTokens, contextWindow, baseURL, account.APIKey, token, agent.ID)
item := buildAgentItem(agent, appInstall, nil)
return &item, nil
@@ -287,6 +294,7 @@ func (a AgentService) UpdateModelConfig(req dto.AgentModelConfigUpdateReq) error
if provider != "ollama" && strings.TrimSpace(account.APIKey) == "" {
return buserr.New("ErrAgentApiKeyRequired")
}
apiType, maxTokens, contextWindow := resolveRuntimeParams(provider, account.APIType, account.MaxTokens, account.ContextWindow)
confDir := ""
if agent.ConfigPath != "" {
confDir = path.Dir(agent.ConfigPath)
@@ -299,11 +307,14 @@ func (a AgentService) UpdateModelConfig(req dto.AgentModelConfigUpdateReq) error
if confDir == "" {
return buserr.New("ErrRecordNotFound")
}
if err := writeOpenclawConfig(confDir, provider, modelName, baseURL, account.APIKey, agent.Token); err != nil {
if err := writeOpenclawConfig(confDir, provider, modelName, apiType, maxTokens, contextWindow, baseURL, account.APIKey, agent.Token); err != nil {
return err
}
agent.Provider = provider
agent.Model = modelName
agent.APIType = apiType
agent.MaxTokens = maxTokens
agent.ContextWindow = contextWindow
agent.BaseURL = baseURL
agent.APIKey = account.APIKey
agent.AccountID = account.ID
@@ -338,7 +349,10 @@ func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
return buserr.New("ErrAgentApiKeyRequired")
}
baseURL := strings.TrimSpace(req.BaseURL)
if baseURL == "" {
if provider == "custom" && baseURL == "" {
return buserr.New("ErrAgentBaseURLRequired")
}
if provider != "custom" && baseURL == "" {
if defaultURL, ok := providerDefaultBaseURL(provider); ok {
baseURL = defaultURL
}
@@ -349,16 +363,36 @@ func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
if exist, _ := agentAccountRepo.GetFirst(repo.WithByProvider(provider), repo.WithByName(req.Name)); exist != nil && exist.ID > 0 {
return buserr.New("ErrRecordExist")
}
modelName := strings.TrimSpace(req.Model)
apiType := normalizeAPIType(req.APIType)
if provider == "custom" {
if modelName == "" {
return fmt.Errorf("model is required")
}
if !isSupportedAPIType(apiType) {
return fmt.Errorf("apiType is invalid")
}
}
if err := a.VerifyAccount(dto.AgentAccountVerifyReq{Provider: provider, BaseURL: baseURL, APIKey: apiKey}); err != nil {
return err
}
_, maxTokens, contextWindow := resolveRuntimeParams(provider, apiType, req.MaxTokens, req.ContextWindow)
account := &model.AgentAccount{
Provider: provider,
Name: req.Name,
APIKey: apiKey,
BaseURL: baseURL,
Verified: true,
Remark: req.Remark,
Provider: provider,
Name: req.Name,
APIKey: apiKey,
BaseURL: baseURL,
Model: "",
APIType: apiType,
MaxTokens: 0,
ContextWindow: 0,
Verified: true,
Remark: req.Remark,
}
if provider == "custom" {
account.Model = normalizeCustomModel(modelName)
account.MaxTokens = maxTokens
account.ContextWindow = contextWindow
}
return agentAccountRepo.Create(account)
}
@@ -370,7 +404,10 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
}
provider := strings.ToLower(strings.TrimSpace(account.Provider))
baseURL := strings.TrimSpace(req.BaseURL)
if baseURL == "" {
if provider == "custom" && baseURL == "" {
return buserr.New("ErrAgentBaseURLRequired")
}
if provider != "custom" && baseURL == "" {
if defaultURL, ok := providerDefaultBaseURL(provider); ok {
baseURL = defaultURL
}
@@ -378,12 +415,31 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
if provider == "ollama" && baseURL == "" {
return buserr.New("ErrAgentBaseURLRequired")
}
apiType := normalizeAPIType(req.APIType)
if provider == "custom" && strings.TrimSpace(req.Model) == "" {
return fmt.Errorf("model is required")
}
if provider == "custom" && !isSupportedAPIType(apiType) {
return fmt.Errorf("apiType is invalid")
}
if provider != "custom" {
apiType = normalizeAPIType(account.APIType)
}
_, maxTokens, contextWindow := resolveRuntimeParams(provider, apiType, req.MaxTokens, req.ContextWindow)
if err := a.VerifyAccount(dto.AgentAccountVerifyReq{Provider: provider, BaseURL: baseURL, APIKey: req.APIKey}); err != nil {
return err
}
account.Name = req.Name
account.APIKey = req.APIKey
account.BaseURL = baseURL
if provider == "custom" {
account.Model = normalizeCustomModel(req.Model)
}
account.APIType = apiType
if provider == "custom" {
account.MaxTokens = maxTokens
account.ContextWindow = contextWindow
}
account.Remark = req.Remark
account.Verified = true
if err := agentAccountRepo.Save(account); err != nil {
@@ -412,15 +468,19 @@ func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.Age
items := make([]dto.AgentAccountInfo, 0, len(list))
for _, item := range list {
items = append(items, dto.AgentAccountInfo{
ID: item.ID,
Provider: item.Provider,
ProviderName: providerDisplayName(item.Provider),
Name: item.Name,
APIKey: item.APIKey,
BaseURL: item.BaseURL,
Verified: item.Verified,
Remark: item.Remark,
CreatedAt: item.CreatedAt,
ID: item.ID,
Provider: item.Provider,
ProviderName: providerDisplayName(item.Provider),
Name: item.Name,
APIKey: item.APIKey,
BaseURL: item.BaseURL,
Model: item.Model,
APIType: item.APIType,
MaxTokens: item.MaxTokens,
ContextWindow: item.ContextWindow,
Verified: item.Verified,
Remark: item.Remark,
CreatedAt: item.CreatedAt,
})
}
return count, items, nil
@@ -447,6 +507,9 @@ func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error {
if provider == "ollama" {
return nil
}
if provider == "custom" {
return nil
}
return verifyProvider(provider, baseURL, apiKey)
}
@@ -642,12 +705,16 @@ func (a AgentService) syncAgentsByAccount(account *model.AgentAccount) error {
if confDir == "" {
continue
}
if err := writeOpenclawConfig(confDir, account.Provider, agent.Model, baseURL, account.APIKey, agent.Token); err != nil {
apiType, maxTokens, contextWindow := resolveRuntimeParams(account.Provider, account.APIType, account.MaxTokens, account.ContextWindow)
if err := writeOpenclawConfig(confDir, account.Provider, agent.Model, apiType, maxTokens, contextWindow, baseURL, account.APIKey, agent.Token); err != nil {
return err
}
agent.BaseURL = baseURL
agent.APIKey = account.APIKey
agent.Provider = account.Provider
agent.APIType = apiType
agent.MaxTokens = maxTokens
agent.ContextWindow = contextWindow
_ = agentRepo.Save(&agent)
}
return nil
@@ -713,20 +780,23 @@ func verifyMinimax(baseURL, apiKey string) error {
func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map[string]interface{}) dto.AgentItem {
item := dto.AgentItem{
ID: agent.ID,
Name: agent.Name,
Provider: agent.Provider,
ProviderName: providerDisplayName(agent.Provider),
Model: agent.Model,
BaseURL: agent.BaseURL,
APIKey: maskKey(agent.APIKey),
Token: agent.Token,
Status: agent.Status,
Message: agent.Message,
AppInstallID: agent.AppInstallID,
AccountID: agent.AccountID,
ConfigPath: agent.ConfigPath,
CreatedAt: agent.CreatedAt,
ID: agent.ID,
Name: agent.Name,
Provider: agent.Provider,
ProviderName: providerDisplayName(agent.Provider),
Model: agent.Model,
APIType: agent.APIType,
MaxTokens: agent.MaxTokens,
ContextWindow: agent.ContextWindow,
BaseURL: agent.BaseURL,
APIKey: maskKey(agent.APIKey),
Token: agent.Token,
Status: agent.Status,
Message: agent.Message,
AppInstallID: agent.AppInstallID,
AccountID: agent.AccountID,
ConfigPath: agent.ConfigPath,
CreatedAt: agent.CreatedAt,
}
if appInstall != nil && appInstall.ID > 0 {
item.Container = appInstall.ContainerName
@@ -792,7 +862,7 @@ func (a AgentService) waitAndDeleteAgent(agentID uint, appInstallID uint) {
}
}
func (a AgentService) writeConfigWithRetry(appInstall *model.AppInstall, provider, modelName, baseURL, apiKey, token string, agentID uint) {
func (a AgentService) writeConfigWithRetry(appInstall *model.AppInstall, provider, modelName, apiType string, maxTokens, contextWindow int, baseURL, apiKey, token string, agentID uint) {
if appInstall == nil {
return
}
@@ -805,7 +875,7 @@ func (a AgentService) writeConfigWithRetry(appInstall *model.AppInstall, provide
time.Sleep(time.Second)
}
confDir := path.Join(appInstall.GetPath(), "data", "conf")
if err := writeOpenclawConfig(confDir, provider, modelName, baseURL, apiKey, token); err != nil {
if err := writeOpenclawConfig(confDir, provider, modelName, apiType, maxTokens, contextWindow, baseURL, apiKey, token); err != nil {
global.LOG.Errorf("write openclaw config failed: %v", err)
agent, errGet := agentRepo.GetFirst(repo.WithByID(agentID))
if errGet == nil && agent != nil {
@@ -848,7 +918,8 @@ type gatewayConfig struct {
}
type gatewayControlUi struct {
AllowInsecureAuth bool `json:"allowInsecureAuth"`
DangerouslyDisableDeviceAuth bool `json:"dangerouslyDisableDeviceAuth"`
DangerouslyAllowHostHeaderOriginFallback bool `json:"dangerouslyAllowHostHeaderOriginFallback"`
}
type gatewayAuth struct {
@@ -897,7 +968,7 @@ type modelCost struct {
CacheWrite float64 `json:"cacheWrite"`
}
func writeOpenclawConfig(confDir, provider, modelName, baseURL, apiKey, token string) error {
func writeOpenclawConfig(confDir, provider, modelName, apiType string, maxTokens, contextWindow int, baseURL, apiKey, token string) error {
if strings.TrimSpace(confDir) == "" {
return fmt.Errorf("config dir is required")
}
@@ -924,7 +995,8 @@ func writeOpenclawConfig(confDir, provider, modelName, baseURL, apiKey, token st
Token: token,
},
ControlUi: gatewayControlUi{
AllowInsecureAuth: true,
DangerouslyDisableDeviceAuth: true,
DangerouslyAllowHostHeaderOriginFallback: true,
},
},
Agents: agentsConfig{
@@ -1038,6 +1110,43 @@ func writeOpenclawConfig(confDir, provider, modelName, baseURL, apiKey, token st
},
},
}
} else if provider == "custom" {
primary := modelName
if !strings.Contains(primary, "/") {
primary = "custom/" + strings.TrimSpace(primary)
}
cfg.Agents.Defaults.Model.Primary = primary
base := strings.TrimSpace(baseURL)
plainKey := strings.TrimSpace(apiKey)
if !strings.Contains(modelName, "/") {
modelName = primary
}
customModelID := modelID
if parts := strings.SplitN(modelName, "/", 2); len(parts) == 2 {
customModelID = parts[1]
}
useAPIType, useMaxTokens, useContextWindow := resolveRuntimeParams(provider, apiType, maxTokens, contextWindow)
cfg.Models = &modelsConfig{
Mode: "merge",
Providers: map[string]modelProvider{
"custom": {
ApiKey: plainKey,
BaseUrl: base,
Api: useAPIType,
Models: []modelEntry{
{
ID: customModelID,
Name: customModelID,
Reasoning: strings.Contains(strings.ToLower(customModelID), "reason") || strings.Contains(strings.ToLower(customModelID), "thinking"),
Input: []string{"text"},
ContextWindow: useContextWindow,
MaxTokens: useMaxTokens,
Cost: modelCost{},
},
},
},
},
}
} else if provider == "ollama" {
cfg.Agents.Defaults.Model.Primary = modelName
cfg.Models = &modelsConfig{
@@ -1275,6 +1384,62 @@ func toInt(value interface{}) int {
}
}
func normalizeCustomModel(modelName string) string {
trim := strings.TrimSpace(modelName)
if parts := strings.SplitN(trim, "/", 2); len(parts) == 2 {
if strings.EqualFold(parts[0], "custom") {
return strings.TrimSpace(parts[1])
}
}
return trim
}
func normalizeAPIType(apiType string) string {
trim := strings.ToLower(strings.TrimSpace(apiType))
if trim == "" {
return "openai-completions"
}
return trim
}
func isSupportedAPIType(apiType string) bool {
switch normalizeAPIType(apiType) {
case "openai-completions", "openai-responses":
return true
default:
return false
}
}
func resolveRuntimeParams(provider, apiType string, maxTokens, contextWindow int) (string, int, int) {
resolvedAPI := normalizeAPIType(apiType)
resolvedMaxTokens := maxTokens
resolvedContextWindow := contextWindow
if resolvedMaxTokens <= 0 {
switch provider {
case "deepseek":
resolvedMaxTokens = 8192
case "minimax", "kimi-coding", "custom":
resolvedMaxTokens = 8192
default:
resolvedMaxTokens = 8192
}
}
if resolvedContextWindow <= 0 {
switch provider {
case "deepseek":
resolvedContextWindow = 128000
case "minimax", "kimi-coding":
resolvedContextWindow = 200000
case "custom":
resolvedContextWindow = 128000
default:
resolvedContextWindow = 256000
}
}
return resolvedAPI, resolvedMaxTokens, resolvedContextWindow
}
func generateToken() string {
bytes := make([]byte, 24)
if _, err := rand.Read(bytes); err != nil {
+1
View File
@@ -65,6 +65,7 @@ func InitAgentDB() {
migrations.AddWebsiteAcmeAccountColumn,
migrations.AddAgentTables,
migrations.MigrateOpenclawAgents,
migrations.AddAgentCustomModelFields,
})
if err := m.Migrate(); err != nil {
global.LOG.Error(err)
+16
View File
@@ -941,3 +941,19 @@ var MigrateOpenclawAgents = &gormigrate.Migration{
return nil
},
}
var AddAgentCustomModelFields = &gormigrate.Migration{
ID: "20260224-add-agent-custom-model-fields",
Migrate: func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.Agent{}, &model.AgentAccount{}); err != nil {
return err
}
if err := tx.Model(&model.AgentAccount{}).Where("api_type = '' OR api_type IS NULL").Update("api_type", "openai-completions").Error; err != nil {
return err
}
if err := tx.Model(&model.Agent{}).Where("api_type = '' OR api_type IS NULL").Update("api_type", "openai-completions").Error; err != nil {
return err
}
return nil
},
}
+18
View File
@@ -243,6 +243,9 @@ export namespace AI {
bridgePort: number;
provider: string;
model: string;
apiType: string;
maxTokens: number;
contextWindow: number;
accountId: number;
apiKey: string;
baseURL: string;
@@ -267,6 +270,9 @@ export namespace AI {
provider: string;
providerName: string;
model: string;
apiType: string;
maxTokens: number;
contextWindow: number;
baseUrl: string;
apiKey: string;
token: string;
@@ -317,6 +323,10 @@ export namespace AI {
name: string;
apiKey: string;
baseURL: string;
model: string;
apiType: string;
maxTokens: number;
contextWindow: number;
remark: string;
}
@@ -325,6 +335,10 @@ export namespace AI {
name: string;
apiKey: string;
baseURL: string;
model: string;
apiType: string;
maxTokens: number;
contextWindow: number;
remark: string;
syncAgents: boolean;
}
@@ -343,6 +357,10 @@ export namespace AI {
name: string;
apiKey: string;
baseUrl: string;
model: string;
apiType: string;
maxTokens: number;
contextWindow: number;
verified: boolean;
remark: string;
createdAt: string;
+2
View File
@@ -713,6 +713,8 @@ const message = {
feishuSaveSuccess: 'Saved successfully',
pairingCodeRequired: 'Please enter pairing code',
pairingApproveSuccess: 'Pairing approved successfully',
customModelHelper: 'For custom model accounts, model names must start with custom/.',
customProviderHelper: 'Custom model providers do not validate whether the account is available.',
},
model: {
model: 'Models',
+2
View File
@@ -709,6 +709,8 @@ const message = {
feishuSaveSuccess: 'Saved successfully',
pairingCodeRequired: 'Please enter pairing code',
pairingApproveSuccess: 'Pairing approved successfully',
customModelHelper: 'En la cuenta de modelo personalizada, el nombre del modelo debe empezar por custom/',
customProviderHelper: 'En el proveedor de modelo personalizado no se valida si la cuenta está disponible',
},
model: {
model: 'Modelo',
+2
View File
@@ -698,6 +698,8 @@ const message = {
feishuSaveSuccess: 'Saved successfully',
pairingCodeRequired: 'Please enter pairing code',
pairingApproveSuccess: 'Pairing approved successfully',
customModelHelper: 'カスタムモデルアカウントでは、モデル名は必ず custom/ で始めてください',
customProviderHelper: 'カスタムモデルプロバイダーでは、アカウントの有効性を検証しません',
},
model: {
model: 'モデル',
+2
View File
@@ -695,6 +695,8 @@ const message = {
feishuSaveSuccess: 'Saved successfully',
pairingCodeRequired: 'Please enter pairing code',
pairingApproveSuccess: 'Pairing approved successfully',
customModelHelper: '사용자 정의 모델 계정의 모델명은 반드시 custom/ 로 시작해야 합니다',
customProviderHelper: '사용자 정의 모델 공급자는 계정 사용 가능 여부를 검증하지 않습니다',
},
model: {
model: '모델',
+2
View File
@@ -710,6 +710,8 @@ const message = {
feishuSaveSuccess: 'Saved successfully',
pairingCodeRequired: 'Please enter pairing code',
pairingApproveSuccess: 'Pairing approved successfully',
customModelHelper: 'Akaun model tersuai, nama model mesti bermula dengan custom/',
customProviderHelper: 'Penyedia model tersuai tidak mengesahkan sama ada akaun boleh digunakan',
},
model: {
model: 'Model',
+2
View File
@@ -707,6 +707,8 @@ const message = {
feishuSaveSuccess: 'Saved successfully',
pairingCodeRequired: 'Please enter pairing code',
pairingApproveSuccess: 'Pairing approved successfully',
customModelHelper: 'Conta de modelo personalizada: o nome do modelo deve começar com custom/',
customProviderHelper: 'Provedores de modelo personalizados não validam se a conta está disponível',
},
model: {
model: 'Modelo',
+2
View File
@@ -703,6 +703,8 @@ const message = {
feishuSaveSuccess: 'Saved successfully',
pairingCodeRequired: 'Please enter pairing code',
pairingApproveSuccess: 'Pairing approved successfully',
customModelHelper: 'Для пользовательской учетной записи модели имя модели должно начинаться с custom/',
customProviderHelper: 'Для пользовательского провайдера модели доступность учетной записи не проверяется',
},
model: {
model: 'Модель',
+2
View File
@@ -717,6 +717,8 @@ const message = {
feishuSaveSuccess: 'Saved successfully',
pairingCodeRequired: 'Please enter pairing code',
pairingApproveSuccess: 'Pairing approved successfully',
customModelHelper: 'Özel model hesabında model adı custom/ ile başlamalıdır',
customProviderHelper: 'Özel model sağlayıcısında hesabın kullanılabilirliği doğrulanmaz',
},
model: {
model: 'Model',
+2
View File
@@ -685,6 +685,8 @@ const message = {
feishuSaveSuccess: '保存成功',
pairingCodeRequired: '請輸入配對碼',
pairingApproveSuccess: '配對成功',
customModelHelper: '自訂模型帳號,模型需固定以 custom/ 開頭',
customProviderHelper: '自訂模型供應商不驗證帳號是否可用',
},
model: {
model: '模型',
+2
View File
@@ -687,6 +687,8 @@ const message = {
feishuSaveSuccess: '保存成功',
pairingCodeRequired: '请输入配对码',
pairingApproveSuccess: '配对成功',
customModelHelper: '自定义模型账号,模型固定以 custom/ 开头',
customProviderHelper: '自定义模型供应商不验证账号是否可用',
},
model: {
model: '模型',
+8
View File
@@ -0,0 +1,8 @@
import i18n from '@/lang';
export const getAgentProviderDisplayName = (provider: string, displayName?: string): string => {
if (provider === 'custom' || displayName === 'Custom') {
return i18n.global.t('container.custom');
}
return displayName || provider;
};
@@ -44,9 +44,13 @@
</el-form-item>
<el-form-item :label="$t('aiTools.model.model')" prop="model">
<el-input v-if="manualModel" v-model="form.model" />
<el-select v-else v-model="form.model" filterable @change="handleModelChange">
<el-option v-for="item in filteredModels" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<span class="input-help" v-if="form.provider == 'custom'">
{{ $t('aiTools.agents.customModelHelper') }}
</span>
</el-form-item>
<el-form-item :label="$t('aiTools.agents.account')" prop="accountId">
<el-select v-model="form.accountId" @change="handleAccountChange">
@@ -95,6 +99,7 @@ import { createAgent, getAgentProviders, pageAgentAccounts } from '@/api/modules
import { AI } from '@/api/interface/ai';
import { getAppByKey, getAppDetail } from '@/api/modules/app';
import { getRandomStr, newUUID } from '@/utils/util';
import { getAgentProviderDisplayName } from '@/utils/agent';
import { App } from '@/api/interface/app';
import AdvancedSetting from '@/components/advanced-setting/index.vue';
import AccountAddDialog from '@/views/ai/agents/model/add/index.vue';
@@ -120,6 +125,9 @@ const form = reactive({
provider: 'deepseek',
accountId: undefined as unknown as number,
model: '',
apiType: 'openai-completions',
maxTokens: 8192,
contextWindow: 128000,
apiKey: '',
baseURL: '',
token: '',
@@ -175,7 +183,7 @@ const loadProviders = async () => {
const data = res.data || [];
providerOptions.value = data.map((item) => ({
value: item.provider,
label: item.displayName || item.provider,
label: getAgentProviderDisplayName(item.provider, item.displayName),
}));
providerModels.value = data.reduce((acc, item) => {
acc[item.provider] = item.models || [];
@@ -246,6 +254,12 @@ const handleAccountChange = () => {
if (selected) {
form.baseURL = selected.baseUrl || '';
form.apiKey = selected.apiKey || '';
form.apiType = selected.apiType || 'openai-completions';
form.maxTokens = selected.maxTokens || 8192;
form.contextWindow = selected.contextWindow || 128000;
if (selected.provider === 'custom' && selected.model && !manualModel.value) {
form.model = `custom/${selected.model}`;
}
}
setDefaultModel();
};
@@ -257,6 +271,13 @@ const setDefaultModel = () => {
const models = filteredModels.value;
if (models.length > 0 && !form.model) {
form.model = models[0].id;
return;
}
if (form.provider === 'custom') {
const selected = accountOptions.value.find((item) => item.id === form.accountId);
if (selected?.model && !form.model) {
form.model = `custom/${selected.model}`;
}
}
};
@@ -276,6 +297,9 @@ const submit = async () => {
bridgePort: form.bridgePort,
provider: form.provider,
model: form.model,
apiType: form.apiType,
maxTokens: form.maxTokens,
contextWindow: form.contextWindow,
accountId: form.accountId,
apiKey: form.apiKey,
baseURL: form.baseURL,
@@ -15,6 +15,9 @@
<el-select v-else v-model="form.model" filterable>
<el-option v-for="item in modelOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<span class="input-help" v-if="provdier == 'custom'">
{{ $t('aiTools.agents.customModelHelper') }}
</span>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="saveModel">
@@ -44,6 +47,7 @@ const agentId = ref(0);
const providerModels = ref<Record<string, AI.ProviderModelInfo[]>>({});
const accountOptions = ref<AI.AgentAccountItem[]>([]);
const modelOptions = ref<AI.ProviderModelInfo[]>([]);
const provdier = ref('');
const form = reactive({
accountId: undefined as unknown as number,
@@ -89,6 +93,13 @@ const handleAccountChange = () => {
form.model = '';
return;
}
provdier.value = selected.provider;
if (selected.provider === 'custom') {
form.manualModel = true;
form.model = selected.model ? `custom/${selected.model}` : form.model;
modelOptions.value = [];
return;
}
setModelsByProvider(selected.provider);
if (!form.manualModel && (!form.model || !form.model.startsWith(`${selected.provider}/`))) {
form.model = modelOptions.value.length > 0 ? modelOptions.value[0].id : '';
@@ -96,10 +107,14 @@ const handleAccountChange = () => {
};
const handleManualModelChange = (val: unknown) => {
const selected = accountOptions.value.find((item) => item.id === form.accountId);
if (selected?.provider === 'custom' && !Boolean(val)) {
form.manualModel = true;
return;
}
if (Boolean(val)) {
return;
}
const selected = accountOptions.value.find((item) => item.id === form.accountId);
if (!selected) {
form.model = '';
return;
@@ -126,12 +141,15 @@ const load = async (agent: AI.AgentItem) => {
form.accountId = currentAccount.id;
setModelsByProvider(currentAccount.provider);
const inProviderModels = modelOptions.value.some((item) => item.id === agent.model);
form.manualModel = !inProviderModels;
form.manualModel = currentAccount.provider === 'custom' || !inProviderModels;
if (agent.model && (form.manualModel || agent.model.startsWith(`${currentAccount.provider}/`))) {
form.model = agent.model;
} else {
form.model = modelOptions.value.length > 0 ? modelOptions.value[0].id : '';
}
if (currentAccount.provider === 'custom' && currentAccount.model && !form.model) {
form.model = `custom/${currentAccount.model}`;
}
} finally {
loading.value = false;
}
+2 -1
View File
@@ -38,7 +38,7 @@
min-width="120"
>
<template #default="{ row }">
{{ row.providerName || row.provider }}
<span>{{ getAgentProviderDisplayName(row.provider, row.providerName) }}</span>
<div>
<span>{{ row.model }}</span>
</div>
@@ -115,6 +115,7 @@ import TerminalDialog from '@/views/container/container/terminal/index.vue';
import i18n from '@/lang';
import PortJumpDialog from '@/components/port-jump/index.vue';
import DockerStatus from '@/views/container/docker-status/index.vue';
import { getAgentProviderDisplayName } from '@/utils/agent';
const items = ref<AI.AgentItem[]>([]);
const loading = ref(false);
@@ -16,9 +16,25 @@
</el-form-item>
<el-form-item :label="$t('aiTools.agents.apiKey')" prop="apiKey">
<el-input v-model="form.apiKey" type="password" show-password />
<span class="input-help">{{ $t('aiTools.agents.customProviderHelper') }}</span>
</el-form-item>
<el-form-item :label="$t('aiTools.agents.baseUrl')" prop="baseURL">
<el-input v-model="form.baseURL" :disabled="form.provider !== 'ollama'" />
<el-input v-model="form.baseURL" :disabled="form.provider !== 'ollama' && form.provider !== 'custom'" />
</el-form-item>
<el-form-item :label="$t('aiTools.model.model')" prop="model" v-if="form.provider === 'custom'">
<el-input v-model="form.model" placeholder="gpt-4o-mini" />
</el-form-item>
<el-form-item :label="'API ' + $t('commons.table.type')" prop="apiType" v-if="form.provider === 'custom'">
<el-select v-model="form.apiType">
<el-option label="openai-completions" value="openai-completions" />
<el-option label="openai-responses" value="openai-responses" />
</el-select>
</el-form-item>
<el-form-item label="Max Tokens" prop="maxTokens" v-if="form.provider === 'custom'">
<el-input-number v-model="form.maxTokens" :min="1" :max="2000000" />
</el-form-item>
<el-form-item label="Context Window" prop="contextWindow" v-if="form.provider === 'custom'">
<el-input-number v-model="form.contextWindow" :min="1" :max="2000000" />
</el-form-item>
<el-form-item :label="$t('website.remark')" prop="remark">
<el-input v-model="form.remark" />
@@ -45,6 +61,7 @@ import { FormInstance } from 'element-plus';
import { Rules } from '@/global/form-rules';
import { createAgentAccount, getAgentProviders, updateAgentAccount } from '@/api/modules/ai';
import i18n from '@/lang';
import { getAgentProviderDisplayName } from '@/utils/agent';
const emit = defineEmits(['search']);
@@ -59,6 +76,10 @@ const form = reactive({
provider: '',
name: '',
baseURL: '',
model: '',
apiType: 'openai-completions',
maxTokens: 8192,
contextWindow: 128000,
apiKey: '',
remark: '',
syncAgents: false,
@@ -73,6 +94,8 @@ const rules = reactive({
name: [Rules.requiredInput],
apiKey: [Rules.requiredInput],
baseURL: [Rules.requiredInput],
model: [Rules.requiredInput],
apiType: [Rules.requiredSelect],
});
const submit = async () => {
@@ -88,6 +111,10 @@ const submit = async () => {
name: form.name,
baseURL: form.baseURL,
apiKey: form.apiKey,
model: form.model,
apiType: form.apiType,
maxTokens: form.maxTokens,
contextWindow: form.contextWindow,
remark: form.remark,
syncAgents: form.syncAgents,
});
@@ -97,6 +124,10 @@ const submit = async () => {
name: form.name,
baseURL: form.baseURL,
apiKey: form.apiKey,
model: form.model,
apiType: form.apiType,
maxTokens: form.maxTokens,
contextWindow: form.contextWindow,
remark: form.remark,
});
}
@@ -111,6 +142,10 @@ const handleClose = () => {
formRef.value?.resetFields();
loading.value = false;
form.id = 0;
form.model = '';
form.apiType = 'openai-completions';
form.maxTokens = 8192;
form.contextWindow = 128000;
form.syncAgents = false;
};
@@ -120,6 +155,10 @@ interface OpenParams {
name?: string;
baseURL?: string;
apiKey?: string;
model?: string;
apiType?: string;
maxTokens?: number;
contextWindow?: number;
remark?: string;
}
@@ -132,6 +171,10 @@ const openDrawer = async (params?: OpenParams) => {
form.name = params.name || '';
form.baseURL = params.baseURL || '';
form.apiKey = params.apiKey || '';
form.model = params.model || '';
form.apiType = params.apiType || 'openai-completions';
form.maxTokens = params.maxTokens || 8192;
form.contextWindow = params.contextWindow || 128000;
form.remark = params.remark || '';
form.syncAgents = false;
return;
@@ -140,6 +183,10 @@ const openDrawer = async (params?: OpenParams) => {
form.name = '';
form.baseURL = '';
form.apiKey = '';
form.model = '';
form.apiType = 'openai-completions';
form.maxTokens = 8192;
form.contextWindow = 128000;
form.remark = '';
form.syncAgents = false;
if (providerOptions.value.length === 0) {
@@ -158,7 +205,7 @@ const loadProviders = async () => {
const data = res.data || [];
providerOptions.value = data.map((item) => ({
value: item.provider,
label: item.displayName || item.provider,
label: getAgentProviderDisplayName(item.provider, item.displayName),
}));
providerBaseURL.value = data.reduce((acc, item) => {
acc[item.provider] = item.baseUrl || '';
@@ -171,6 +218,14 @@ const loadProviders = async () => {
};
const handleProviderChange = () => {
if (form.provider === 'custom') {
form.baseURL = '';
form.apiType = form.apiType || 'openai-completions';
form.maxTokens = form.maxTokens || 8192;
form.contextWindow = form.contextWindow || 128000;
form.model = form.model || '';
return;
}
if (form.provider !== 'ollama') {
form.baseURL = providerBaseURL.value[form.provider] || '';
} else {
+6 -1
View File
@@ -14,7 +14,7 @@
<el-table-column :label="$t('commons.table.name')" prop="name" min-width="200" />
<el-table-column :label="$t('aiTools.agents.provider')" prop="provider" width="120">
<template #default="{ row }">
{{ row.providerName || row.provider }}
{{ getAgentProviderDisplayName(row.provider, row.providerName) }}
</template>
</el-table-column>
<el-table-column :label="$t('aiTools.agents.baseUrl')" prop="baseUrl" min-width="200" />
@@ -53,6 +53,7 @@ import AddDialog from '@/views/ai/agents/model/add/index.vue';
import { ElMessageBox } from 'element-plus';
import i18n from '@/lang';
import { dateFormat } from '@/utils/util';
import { getAgentProviderDisplayName } from '@/utils/agent';
const items = ref<AI.AgentAccountItem[]>([]);
const addRef = ref();
@@ -103,6 +104,10 @@ const onEdit = (row: AI.AgentAccountItem) => {
name: row.name,
baseURL: row.baseUrl,
apiKey: row.apiKey,
model: row.model,
apiType: row.apiType,
maxTokens: row.maxTokens,
contextWindow: row.contextWindow,
remark: row.remark,
});
}