diff --git a/agent/app/provider/catalog.go b/agent/app/provider/catalog.go index ccc598e35023..b59d80ec6e06 100644 --- a/agent/app/provider/catalog.go +++ b/agent/app/provider/catalog.go @@ -40,20 +40,21 @@ type Meta struct { var catalog = map[string]Meta{ "custom": { Key: "custom", DisplayName: "Custom", Sort: 10, DefaultAPIType: "openai-completions", EnvKey: "CUSTOM_API_KEY", - APIConfigs: editableAPIConfigs(true, "openai-completions", "openai-responses", "anthropic-messages", "openai-images"), + APIConfigs: editableAPIConfigs(true, "openai-completions", "openai-responses", "anthropic-messages", "openai-images", "openai-embeddings"), }, "ollama": { Key: "ollama", DisplayName: "Ollama", Sort: 15, DefaultAPIType: "openai-responses", - APIConfigs: editableAPIConfigs(false, "openai-responses", "openai-completions"), + APIConfigs: editableAPIConfigs(false, "openai-responses", "openai-completions", "openai-embeddings"), }, "vllm": { Key: "vllm", DisplayName: "vLLM", Sort: 20, DefaultAPIType: "openai-completions", EnvKey: "VLLM_API_KEY", - APIConfigs: editableAPIConfigs(false, "openai-completions", "openai-responses", "anthropic-messages", "openai-images"), + APIConfigs: editableAPIConfigs(false, "openai-completions", "openai-responses", "anthropic-messages", "openai-images", "openai-embeddings"), }, "deepseek": { Key: "deepseek", DisplayName: "DeepSeek", Sort: 25, DefaultAPIType: "openai-completions", EnvKey: "DEEPSEEK_API_KEY", APIConfigs: []APIConfig{ {APIType: "openai-completions", BaseURL: "https://api.deepseek.com"}, + {APIType: "openai-responses", BaseURL: "https://api.deepseek.com"}, anthropicAPIConfig("https://api.deepseek.com/anthropic", AuthModeXAPIKey), }, Models: []Model{{ID: "deepseek-v4-flash", Name: "deepseek-v4-flash"}, {ID: "deepseek-v4-pro", Name: "deepseek-v4-pro"}}, @@ -131,6 +132,10 @@ var catalog = map[string]Meta{ {APIType: "openai-responses", BaseURL: "https://api.openai.com/v1"}, {APIType: "openai-completions", BaseURL: "https://api.openai.com/v1"}, {APIType: "openai-images", BaseURL: "https://api.openai.com/v1"}, + {APIType: "openai-embeddings", BaseURL: "https://api.openai.com/v1", Models: []Model{ + {ID: "text-embedding-3-small", Name: "text-embedding-3-small"}, + {ID: "text-embedding-3-large", Name: "text-embedding-3-large"}, + }}, }, Models: []Model{{ID: "gpt-5.4", Name: "gpt-5.4"}, {ID: "gpt-5.4-pro", Name: "gpt-5.4-pro"}, {ID: "gpt-5.4-mini", Name: "gpt-5.4-mini"}, {ID: "gpt-5.4-nano", Name: "gpt-5.4-nano"}}, }, @@ -296,7 +301,7 @@ func DefaultModels(key, apiType string) []Model { if len(config.Models) > 0 { return append([]Model(nil), config.Models...) } - if IsImageAPIType(config.APIType) { + if IsImageAPIType(config.APIType) || IsEmbeddingAPIType(config.APIType) { return nil } break @@ -359,7 +364,7 @@ func ResolveBaseURL(key, apiType, requested string) (string, error) { if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "", fmt.Errorf("invalid base url") } - if key == "custom" && IsImageAPIType(config.APIType) { + if key == "custom" && (IsImageAPIType(config.APIType) || IsEmbeddingAPIType(config.APIType)) { return baseURL, nil } parsed.Path = normalizeEndpointPath(config.APIType, parsed.Path) @@ -381,6 +386,8 @@ func normalizeEndpointPath(apiType, value string) string { suffixes = []string{"/responses"} case "anthropic-messages": suffixes = []string{"/v1/messages", "/messages"} + case "openai-embeddings": + suffixes = []string{"/v1/embeddings", "/embeddings"} } for _, suffix := range suffixes { if strings.HasSuffix(strings.ToLower(path), suffix) { @@ -390,6 +397,10 @@ func normalizeEndpointPath(apiType, value string) string { return path } +func IsEmbeddingAPIType(apiType string) bool { + return apiType == "openai-embeddings" +} + func IsImageAPIType(apiType string) bool { switch apiType { case "openai-images", "dashscope-images", "minimax-images", "openrouter-images": diff --git a/agent/app/provider/openclaw.go b/agent/app/provider/openclaw.go index 9b119f2fa142..9f6861554bc4 100644 --- a/agent/app/provider/openclaw.go +++ b/agent/app/provider/openclaw.go @@ -23,7 +23,7 @@ func BuildOpenClawProviderPatch(provider, modelName, apiType, authMode, baseURL, if _, ok := FindAPIConfig(provider, resolvedAPIType); !ok { resolvedAPIType = DefaultAPIType(provider) } - if IsImageAPIType(resolvedAPIType) { + if IsImageAPIType(resolvedAPIType) || IsEmbeddingAPIType(resolvedAPIType) { return nil, fmt.Errorf("api type %s does not support text generation", resolvedAPIType) } resolvedAuthMode, err := ResolveAuthMode(provider, resolvedAPIType, authMode) diff --git a/agent/app/provider/verify.go b/agent/app/provider/verify.go index 318c50ba3f5c..148847eb299b 100644 --- a/agent/app/provider/verify.go +++ b/agent/app/provider/verify.go @@ -81,6 +81,10 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri } switch apiType { + case "openai-embeddings": + request.URL = embeddingVerifyURL(baseURL) + headers["Authorization"] = "Bearer " + apiKey + request.Body = mustJSON(map[string]interface{}{"model": model, "input": "ping"}) case "openai-images": request.URL = imageVerifyURL(provider, baseURL, "/images/generations") headers["Authorization"] = "Bearer " + apiKey @@ -131,6 +135,17 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri return request } +func embeddingVerifyURL(baseURL string) string { + lowerBaseURL := strings.ToLower(baseURL) + if strings.HasSuffix(lowerBaseURL, "/embeddings") { + return baseURL + } + if strings.HasSuffix(lowerBaseURL, "/v1") { + return baseURL + "/embeddings" + } + return baseURL + "/v1/embeddings" +} + func imageVerifyURL(provider, baseURL, endpoint string) string { if provider == "custom" || strings.HasSuffix(strings.ToLower(baseURL), endpoint) { return baseURL diff --git a/agent/app/repo/agent_account.go b/agent/app/repo/agent_account.go index 0f99b2fbfecf..18246cab55a2 100644 --- a/agent/app/repo/agent_account.go +++ b/agent/app/repo/agent_account.go @@ -86,7 +86,7 @@ func (a AgentAccountRepo) CountTextByProviders(providers []string) (map[string]i Model(&model.AgentAccount{}). Select("provider, COUNT(*) as count"). Where("provider IN ?", normalizedProviders). - Where("api_type NOT LIKE ?", "%-images"). + Scopes(WithTextAPIType()). Group("provider"). Scan(&rows).Error; err != nil { return nil, err diff --git a/agent/app/repo/common.go b/agent/app/repo/common.go index 0ee5a6441e81..e9fa4c712c1d 100644 --- a/agent/app/repo/common.go +++ b/agent/app/repo/common.go @@ -108,7 +108,7 @@ func WithByAPIType(apiType string) DBOption { func WithTextAPIType() DBOption { return func(g *gorm.DB) *gorm.DB { - return g.Where("api_type NOT LIKE ?", "%-images") + return g.Where("api_type NOT LIKE ? AND api_type <> ?", "%-images", "openai-embeddings") } } diff --git a/agent/app/service/agents_utils.go b/agent/app/service/agents_utils.go index dac2e63acf9e..7153f5824ace 100644 --- a/agent/app/service/agents_utils.go +++ b/agent/app/service/agents_utils.go @@ -116,7 +116,7 @@ func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, mode return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired") } imageAPI := providercatalog.IsImageAPIType(resolvedAPIType) - if validateAvailability && (imageAPI || !providercatalog.SkipVerification(provider)) { + if validateAvailability && (imageAPI || providercatalog.IsEmbeddingAPIType(resolvedAPIType) || !providercatalog.SkipVerification(provider)) { if err := providercatalog.VerifyAccount(provider, resolvedAPIType, resolvedAuthMode, resolvedBaseURL, resolvedAPIKey, modelID); err != nil { return resolvedAgentAccountInput{}, err } diff --git a/agent/app/service/app_upgrade.go b/agent/app/service/app_upgrade.go index 9046d0acb879..2a768abc03e0 100644 --- a/agent/app/service/app_upgrade.go +++ b/agent/app/service/app_upgrade.go @@ -426,7 +426,7 @@ func (u *appUpgradeContext) cutover(t *task.Task) error { logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("Run"), i18n.GetMsgByKey("App")) t.LogStart(logStr) - if out, upErr := compose.UpWithoutPull(u.original.GetComposePath()); upErr != nil { + if out, upErr := compose.UpWithoutBuild(u.original.GetComposePath()); upErr != nil { if out != "" { upErr = fmt.Errorf("%s: %w", out, upErr) } @@ -545,7 +545,7 @@ func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) { return u.finishRollback() } if u.phase < appUpgradeMutated { - if out, err := compose.UpWithoutPull(u.original.GetComposePath()); err != nil { + if out, err := compose.UpWithoutBuild(u.original.GetComposePath()); err != nil { if out != "" { err = fmt.Errorf("%s: %w", out, err) } @@ -563,14 +563,14 @@ func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) { if u.backupFile != "" { _ = u.restoreManagedFiles() if err := handleAppRecover(&u.original, t, u.backupFile, true, "", ""); err != nil { - _, _ = compose.UpWithoutPull(u.original.GetComposePath()) + _, _ = compose.UpWithoutBuild(u.original.GetComposePath()) return errors.Join(rollbackErr, err) } } else { if err := u.restoreManagedFiles(); err != nil { return errors.Join(rollbackErr, err) } - if out, err := compose.UpWithoutPull(u.original.GetComposePath()); err != nil { + if out, err := compose.UpWithoutBuild(u.original.GetComposePath()); err != nil { if out != "" { err = fmt.Errorf("%s: %w", out, err) } diff --git a/agent/app/service/backup_app.go b/agent/app/service/backup_app.go index 8501eb284cff..361fb7252e64 100644 --- a/agent/app/service/backup_app.go +++ b/agent/app/service/backup_app.go @@ -232,7 +232,7 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF } defer func() { if isRollback { - _, _ = compose.UpWithoutPull(install.GetComposePath()) + _, _ = compose.UpWithoutBuild(install.GetComposePath()) } else { _, _ = compose.Up(install.GetComposePath()) } diff --git a/agent/utils/compose/compose.go b/agent/utils/compose/compose.go index abcfdf662b07..eaec38668ab4 100644 --- a/agent/utils/compose/compose.go +++ b/agent/utils/compose/compose.go @@ -48,7 +48,7 @@ func Up(filePath string, projectName ...string) (string, error) { return cmd.NewCommandMgr(cmd.WithTimeout(20*time.Minute)).RunWithStdout(base, args...) } -func UpWithoutPull(filePath string, projectName ...string) (string, error) { +func UpWithoutBuild(filePath string, projectName ...string) (string, error) { if err := checkCmd(); err != nil { return "", err } @@ -58,11 +58,11 @@ func UpWithoutPull(filePath string, projectName ...string) (string, error) { return cmd.NewCommandMgr(cmd.WithTimeout(20*time.Minute)).RunWithStdout(base, args...) } -func upArgs(filePath string, withoutPull bool) []string { +func upArgs(filePath string, withoutBuild bool) []string { args := loadFiles(filePath) args = append(args, "up", "-d") - if withoutPull { - args = append(args, "--pull", "never", "--no-build") + if withoutBuild { + args = append(args, "--no-build") } return args } diff --git a/agent/utils/docker/compose.go b/agent/utils/docker/compose.go index 14c475d9604a..715a51da1eba 100644 --- a/agent/utils/docker/compose.go +++ b/agent/utils/docker/compose.go @@ -1,14 +1,15 @@ package docker import ( - "bufio" "bytes" "context" "fmt" "path" "strings" + "github.com/compose-spec/compose-go/v2/dotenv" "github.com/compose-spec/compose-go/v2/loader" + "github.com/compose-spec/compose-go/v2/template" "github.com/compose-spec/compose-go/v2/types" "github.com/joho/godotenv" "gopkg.in/yaml.v3" @@ -91,7 +92,7 @@ func (e *Environment) UnmarshalYAML(value *yaml.Node) error { } func GetImagesFromDockerCompose(env, yml []byte) ([]string, error) { - envVars, err := loadEnvFile(env) + envVars, err := dotenv.Parse(bytes.NewReader(env)) if err != nil { return nil, fmt.Errorf("load env failed: %v", err) } @@ -104,43 +105,16 @@ func GetImagesFromDockerCompose(env, yml []byte) ([]string, error) { var images []string for _, service := range compose.Services { if service.Image != "" { - resolvedImage := replaceEnvVars(service.Image, envVars) + resolvedImage, err := template.Substitute(service.Image, func(key string) (string, bool) { + value, ok := envVars[key] + return value, ok + }) + if err != nil { + return nil, fmt.Errorf("resolve image failed: %v", err) + } images = append(images, resolvedImage) } } return images, nil } - -func loadEnvFile(env []byte) (map[string]string, error) { - envVars := make(map[string]string) - - scanner := bufio.NewScanner(bytes.NewReader(env)) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - parts := strings.SplitN(line, "=", 2) - if len(parts) == 2 { - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - value = strings.Trim(value, `"'`) - envVars[key] = value - } - } - - return envVars, scanner.Err() -} - -func replaceEnvVars(input string, envVars map[string]string) string { - return re.GetRegex(re.ComposeEnvVarPattern).ReplaceAllStringFunc(input, func(match string) string { - varName := match[2 : len(match)-1] - if value, exists := envVars[varName]; exists { - return value - } - return match - }) -} diff --git a/frontend/src/components/api-type-tag/index.vue b/frontend/src/components/api-type-tag/index.vue index 58a8fc244ff5..9b75e942742c 100644 --- a/frontend/src/components/api-type-tag/index.vue +++ b/frontend/src/components/api-type-tag/index.vue @@ -1,9 +1,12 @@