From cf2f140c0889c653bd20d344b4f793403e7c7afd Mon Sep 17 00:00:00 2001 From: Zexi Li Date: Thu, 12 Mar 2026 07:21:20 +0800 Subject: [PATCH] feat(llm): add login-info api (#24431) --- cmd/climc/shell/llm/llm.go | 1 + pkg/apis/llm/llm.go | 8 ++++ pkg/apis/llm/llm_const.go | 10 +++++ pkg/llm/drivers/llm_container/base_driver.go | 4 ++ pkg/llm/drivers/llm_container/dify.go | 19 ++++---- pkg/llm/drivers/llm_container/openclaw.go | 44 +++++++++++++++++-- pkg/llm/models/llm.go | 46 ++++++++++++++++++++ pkg/llm/models/llm_container_driver.go | 22 ++++------ pkg/llm/tasks/llm/llm_create_task.go | 30 +++---------- pkg/llm/tasks/llm/llm_sync_status_task.go | 4 ++ 10 files changed, 138 insertions(+), 50 deletions(-) diff --git a/cmd/climc/shell/llm/llm.go b/cmd/climc/shell/llm/llm.go index 20a96854fd..a6989e5f54 100644 --- a/cmd/climc/shell/llm/llm.go +++ b/cmd/climc/shell/llm/llm.go @@ -19,6 +19,7 @@ func init() { cmd.BatchPerform("restart", new(options.LLMRestartOptions)) cmd.Get("probed-models", new(options.LLMIdOptions)) cmd.Get("url", new(options.LLMIdOptions)) + cmd.Get("login-info", new(options.LLMIdOptions)) cmd.Custom(shell.CustomActionGet, "available-network", new(options.LLMAvailableNetworkOptions)) cmd.Perform("save-instant-model", new(options.LLMSaveInstantModelOptions)) cmd.Perform("quick-models", new(options.LLMQuickModelsOptions)) diff --git a/pkg/apis/llm/llm.go b/pkg/apis/llm/llm.go index 8dfe115f39..cf6fe9bf86 100644 --- a/pkg/apis/llm/llm.go +++ b/pkg/apis/llm/llm.go @@ -202,3 +202,11 @@ type LLMVolumeInput struct { VolumeId string `json:"volume_id"` AutoStart bool `json:"auto_start"` } + +// LLMLoginInfo is the response for GET /llms//login-info: login URL and credentials. +type LLMLoginInfo struct { + LoginUrl string `json:"login_url"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Extra map[string]string `json:"extra,omitempty"` +} diff --git a/pkg/apis/llm/llm_const.go b/pkg/apis/llm/llm_const.go index 39f7edcf32..4a3f92be62 100644 --- a/pkg/apis/llm/llm_const.go +++ b/pkg/apis/llm/llm_const.go @@ -79,3 +79,13 @@ const ( const ( LLM_PROBE_INSTANT_MODEl_INTERVAL_SECOND = 120 // 2 minute ) + +type LLMEnvKey string + +const ( + LLM_OPENCLAW_GATEWAY_TOKEN = LLMEnvKey("OPENCLAW_GATEWAY_TOKEN") + LLM_OPENCLAW_AUTH_USERNAME = LLMEnvKey("AUTH_USERNAME") + LLM_OPENCLAW_CUSTOM_USER = LLMEnvKey("CUSTOM_USER") + LLM_OPENCLAW_AUTH_PASSWORD = LLMEnvKey("AUTH_PASSWORD") + LLM_OPENCLAW_PASSWORD = LLMEnvKey("PASSWORD") +) diff --git a/pkg/llm/drivers/llm_container/base_driver.go b/pkg/llm/drivers/llm_container/base_driver.go index 87d484fc10..e3d185ecfc 100644 --- a/pkg/llm/drivers/llm_container/base_driver.go +++ b/pkg/llm/drivers/llm_container/base_driver.go @@ -29,6 +29,10 @@ func (b *baseDriver) GetPrimaryImageId(sku *models.SLLMSku) string { return sku.LLMImageId } +func (b *baseDriver) GetPrimaryContainer(ctx context.Context, llm *models.SLLM, containers []*computeapi.PodContainerDesc) (*computeapi.PodContainerDesc, error) { + return containers[0], nil +} + func (b *baseDriver) GetMountedModels(sku *models.SLLMSku) []string { return sku.MountedModels } diff --git a/pkg/llm/drivers/llm_container/dify.go b/pkg/llm/drivers/llm_container/dify.go index a34deb8c4c..00bbace1a1 100644 --- a/pkg/llm/drivers/llm_container/dify.go +++ b/pkg/llm/drivers/llm_container/dify.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "strings" "yunion.io/x/pkg/errors" @@ -78,6 +79,15 @@ func (d *dify) GetPrimaryImageId(sku *models.SLLMSku) string { return "" } +func (d *dify) GetPrimaryContainer(ctx context.Context, llm *models.SLLM, containers []*computeapi.PodContainerDesc) (*computeapi.PodContainerDesc, error) { + for _, ctr := range containers { + if strings.HasSuffix(ctr.Name, api.DIFY_API_KEY) { + return ctr, nil + } + } + return nil, errors.Error("api container not found") +} + func (d *dify) ValidateCreateData(ctx context.Context, userCred mcclient.TokenCredential, input *api.LLMSkuCreateInput) (*api.LLMSkuCreateInput, error) { if input.LLMSpec == nil || input.LLMSpec.Dify == nil { return nil, errors.Wrap(httperrors.ErrInputParameter, "dify SKU requires llm_spec with type dify and image ids") @@ -154,15 +164,6 @@ func (d *dify) ValidateUpdateData(ctx context.Context, userCred mcclient.TokenCr return input, nil } -// GetContainerSpec is required by ILLMContainerDriver but not used for Dify; pod creation uses GetContainerSpecs. Return the first container so the interface is satisfied. -func (d *dify) GetContainerSpec(ctx context.Context, llm *models.SLLM, image *models.SLLMImage, sku *models.SLLMSku, props []string, devices []computeapi.SIsolatedDevice, diskId string) *computeapi.PodContainerCreateInput { - specs := d.GetContainerSpecs(ctx, llm, image, sku, props, devices, diskId) - if len(specs) == 0 { - return nil - } - return specs[0] -} - // GetContainerSpecs returns all Dify pod containers (postgres, redis, api, worker, nginx, etc.). Uses effective spec (llm + sku merged by driver). func (d *dify) GetContainerSpecs(ctx context.Context, llm *models.SLLM, image *models.SLLMImage, sku *models.SLLMSku, props []string, devices []computeapi.SIsolatedDevice, diskId string) []*computeapi.PodContainerCreateInput { spec := d.GetEffectiveSpec(llm, sku) diff --git a/pkg/llm/drivers/llm_container/openclaw.go b/pkg/llm/drivers/llm_container/openclaw.go index 2e523f12f1..c1c5c16f6a 100644 --- a/pkg/llm/drivers/llm_container/openclaw.go +++ b/pkg/llm/drivers/llm_container/openclaw.go @@ -251,6 +251,8 @@ func (c *openclaw) GetContainerSpecs(ctx context.Context, llm *models.SLLM, imag MountPath: openclawDataDir, }, } + httpAuthUsername := "admin" + httpAuthPassword := "clawadmin@123" openclawSpec := computeapi.ContainerSpec{ ContainerSpec: commonapi.ContainerSpec{ Image: image.ToContainerImage(), @@ -270,10 +272,10 @@ func (c *openclaw) GetContainerSpecs(ctx context.Context, llm *models.SLLM, imag // {Key: "MOONSHOT_API_KEY", Value: "abc"}, // {Key: "OPENCLAW_PRIMARY_MODEL", Value: "moonshot/kimi-k2.5"}, // Auth - {Key: "AUTH_USERNAME", Value: "admin"}, - {Key: "CUSTOM_USER", Value: "admin"}, - {Key: "AUTH_PASSWORD", Value: "clawadmin@123"}, - {Key: "PASSWORD", Value: "clawadmin@123"}, + {Key: string(api.LLM_OPENCLAW_AUTH_USERNAME), Value: httpAuthUsername}, + {Key: string(api.LLM_OPENCLAW_CUSTOM_USER), Value: httpAuthUsername}, + {Key: string(api.LLM_OPENCLAW_AUTH_PASSWORD), Value: httpAuthPassword}, + {Key: string(api.LLM_OPENCLAW_PASSWORD), Value: httpAuthPassword}, // // Browser sidecar // {Key: "BROWSER_CDP_URL", Value: "http://localhost" + ":" + openclawBrowserCDPPort}, // {Key: "BROWSER_DEFAULT_PROFILE", Value: "openclaw"}, @@ -341,6 +343,40 @@ func (c *openclaw) GetLLMUrl(ctx context.Context, userCred mcclient.TokenCredent return fmt.Sprintf("https://%s:%d", firstIP, 3001), nil } +// GetLoginInfo returns OpenClaw web UI login credentials (same defaults as container env). +func (c *openclaw) GetLoginInfo(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM) (*api.LLMLoginInfo, error) { + ctr, err := llm.GetLLMSContainer(ctx) + if err != nil { + return nil, errors.Wrap(err, "get llm cloud container") + } + if ctr.Spec == nil { + return nil, errors.Wrap(errors.ErrEmpty, "no Spec") + } + var ( + username string + password string + gatewayToken string + ) + for _, env := range ctr.Spec.Envs { + if env.Key == string(api.LLM_OPENCLAW_AUTH_USERNAME) { + username = env.Value + } + if env.Key == string(api.LLM_OPENCLAW_AUTH_PASSWORD) { + password = env.Value + } + if env.Key == string(api.LLM_OPENCLAW_GATEWAY_TOKEN) { + gatewayToken = env.Value + } + } + return &api.LLMLoginInfo{ + Username: username, + Password: password, + Extra: map[string]string{ + string(api.LLM_OPENCLAW_GATEWAY_TOKEN): gatewayToken, + }, + }, nil +} + func (c *openclaw) GetProbedInstantModelsExt(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM, mdlIds ...string) (map[string]api.LLMInternalInstantMdlInfo, error) { return nil, nil } diff --git a/pkg/llm/models/llm.go b/pkg/llm/models/llm.go index 7d513ce0eb..5c8d31d0a1 100644 --- a/pkg/llm/models/llm.go +++ b/pkg/llm/models/llm.go @@ -409,6 +409,23 @@ func (llm *SLLM) GetLLMContainer() (*SLLMContainer, error) { return GetLLMContainerManager().FetchByLLMId(llm.Id) } +func (llm *SLLM) SyncLLMContainer(ctx context.Context, userCred mcclient.TokenCredential, server *computeapi.ServerDetails) (*SLLMContainer, error) { + curCtr, _ := llm.GetLLMContainer() + if curCtr != nil { + return curCtr, nil + } + drv := llm.GetLLMContainerDriver() + ctr, err := drv.GetPrimaryContainer(ctx, llm, server.Containers) + if err != nil { + return nil, errors.Wrap(err, "GetPrimaryContainer") + } + llmCtr, err := GetLLMContainerManager().CreateOnLLM(ctx, userCred, llm.GetOwnerId(), llm, ctr.Id, ctr.Name) + if nil != err { + return nil, errors.Wrapf(err, "create llm container on llm %s", ctr.Id) + } + return llmCtr, nil +} + func (llm *SLLM) GetLLMContainerDriver() ILLMContainerDriver { sku, _ := llm.GetLLMSku(llm.LLMSkuId) return sku.GetLLMContainerDriver() @@ -692,6 +709,35 @@ func (llm *SLLM) GetDetailsUrl(ctx context.Context, userCred mcclient.TokenCrede return output, nil } +func (llm *SLLM) GetDetailsLoginInfo(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject) (*api.LLMLoginInfo, error) { + if llm.CmpId == "" { + return nil, nil + } + output := new(api.LLMLoginInfo) + loginUrl, err := llm.GetLLMUrl(ctx, userCred) + if err != nil { + return nil, errors.Wrap(err, "GetLLMUrl") + } + output.LoginUrl = loginUrl + drv := llm.GetLLMContainerDriver() + if loginInfoDrv, ok := drv.(ILLMContainerLoginInfo); ok { + info, err := loginInfoDrv.GetLoginInfo(ctx, userCred, llm) + if err != nil { + return nil, errors.Wrap(err, "GetLoginInfo") + } + if info.Username != "" { + output.Username = info.Username + } + if info.Password != "" { + output.Password = info.Password + } + if len(info.Extra) > 0 { + output.Extra = info.Extra + } + } + return output, nil +} + func fetchNetworks(ctx context.Context, userCred mcclient.TokenCredential, networkIds []string) (map[string]computeapi.NetworkDetails, error) { s := auth.GetSession(ctx, userCred, "") params := computeoptions.ServerListOptions{} diff --git a/pkg/llm/models/llm_container_driver.go b/pkg/llm/models/llm_container_driver.go index d300e2902f..37610832d4 100644 --- a/pkg/llm/models/llm_container_driver.go +++ b/pkg/llm/models/llm_container_driver.go @@ -78,15 +78,11 @@ type ILLMContainerInstantModel interface { DownloadModel(ctx context.Context, userCred mcclient.TokenCredential, llm *SLLM, tmpDir string, modelName string, modelTag string) (string, []string, error) } -// ILLMContainerDriverMultiContainer is an optional interface for drivers that create a pod with multiple containers (e.g. Dify). If not implemented, the driver is assumed to provide a single container via GetContainerSpec. -type ILLMContainerDriverMultiContainer interface { - GetContainerSpecs(ctx context.Context, llm *SLLM, image *SLLMImage, sku *SLLMSku, props []string, devices []computeapi.SIsolatedDevice, diskId string) []*computeapi.PodContainerCreateInput -} - type ILLMContainerDriver interface { GetType() llm.LLMContainerType // GetContainerSpecs returns one or more container specs. If nil or empty, caller falls back to GetContainerSpec for a single container. - GetContainerSpec(ctx context.Context, llm *SLLM, image *SLLMImage, sku *SLLMSku, props []string, devices []computeapi.SIsolatedDevice, diskId string) *computeapi.PodContainerCreateInput + GetContainerSpecs(ctx context.Context, llm *SLLM, image *SLLMImage, sku *SLLMSku, props []string, devices []computeapi.SIsolatedDevice, diskId string) []*computeapi.PodContainerCreateInput + GetPrimaryContainer(ctx context.Context, llm *SLLM, containers []*computeapi.PodContainerDesc) (*computeapi.PodContainerDesc, error) // StartLLM is called after the pod is running. For drivers that need to start the model process inside the container (e.g. vLLM), it runs the start command via exec and waits for health; on failure returns an error. For drivers that need no extra step (e.g. Ollama), it returns nil. StartLLM(ctx context.Context, userCred mcclient.TokenCredential, llm *SLLM) error @@ -117,6 +113,11 @@ type ILLMContainerMCPAgent interface { GetLLMUrl(ctx context.Context, userCred mcclient.TokenCredential, llm *SLLM) (string, error) } +// ILLMContainerLoginInfo is an optional interface for drivers that provide web login credentials (e.g. Dify, OpenClaw). If not implemented, GetDetailsLoginInfo returns only login_url. +type ILLMContainerLoginInfo interface { + GetLoginInfo(ctx context.Context, userCred mcclient.TokenCredential, llm *SLLM) (*llm.LLMLoginInfo, error) +} + var ( llmContainerDrivers = newDrivers() ) @@ -146,12 +147,5 @@ func GetLLMContainerInstantModelDriver(typ llm.LLMContainerType) (ILLMContainerI // GetDriverPodContainers returns the container(s) for the given driver. If the driver implements ILLMContainerDriverMultiContainer, GetContainerSpecs is used; otherwise a single-element slice from GetContainerSpec is returned. func GetDriverPodContainers(ctx context.Context, drv ILLMContainerDriver, llm *SLLM, image *SLLMImage, sku *SLLMSku, props []string, devices []computeapi.SIsolatedDevice, diskId string) []*computeapi.PodContainerCreateInput { - if multi, ok := drv.(ILLMContainerDriverMultiContainer); ok { - return multi.GetContainerSpecs(ctx, llm, image, sku, props, devices, diskId) - } - spec := drv.GetContainerSpec(ctx, llm, image, sku, props, devices, diskId) - if spec == nil { - return nil - } - return []*computeapi.PodContainerCreateInput{spec} + return drv.GetContainerSpecs(ctx, llm, image, sku, props, devices, diskId) } diff --git a/pkg/llm/tasks/llm/llm_create_task.go b/pkg/llm/tasks/llm/llm_create_task.go index 58b890c4cc..10a8fdf000 100644 --- a/pkg/llm/tasks/llm/llm_create_task.go +++ b/pkg/llm/tasks/llm/llm_create_task.go @@ -148,23 +148,9 @@ func (task *LLMCreateTask) OnLLMRefreshStatusComplete(ctx context.Context, llm * } } - // 创建应用容器记录(多容器 driver 如 Dify 不创建 SLLMContainer) - drv := llm.GetLLMContainerDriver() - _, isMulti := drv.(models.ILLMContainerDriverMultiContainer) - if !isMulti { - if len(server.Containers) != 1 { - task.taskFailed(ctx, llm, errors.Errorf("expected 1 containers, but got %d", len(server.Containers))) - return - } - llmCtr := models.GetSvrLLMContainer(server.Containers) - if llmCtr == nil { - task.taskFailed(ctx, llm, errors.Errorf("cannot find app container")) - return - } - if _, err := models.GetLLMContainerManager().CreateOnLLM(ctx, task.GetUserCred(), llm.GetOwnerId(), llm, llmCtr.Id, llmCtr.Name); nil != err { - task.taskFailed(ctx, llm, errors.Wrap(err, "create llm container on llm")) - return - } + if _, err := llm.SyncLLMContainer(ctx, task.GetUserCred(), server); err != nil { + task.taskFailed(ctx, llm, errors.Wrap(err, "SyncLLMContainer")) + return } // When AutoStart was true, compute auto-starts the server so LLMStartTask is never run. We must run StartLLM here. @@ -175,12 +161,10 @@ func (task *LLMCreateTask) OnLLMRefreshStatusComplete(ctx context.Context, llm * task.taskFailed(ctx, llm, errors.Wrap(err, "WaitServerStatus VM_RUNNING")) return } - if !isMulti { - _, err = llm.WaitContainerStatus(ctx, task.GetUserCred(), []string{computeapi.CONTAINER_STATUS_RUNNING}, 120) - if err != nil { - task.taskFailed(ctx, llm, errors.Wrap(err, "WaitContainerStatus")) - return - } + _, err = llm.WaitContainerStatus(ctx, task.GetUserCred(), []string{computeapi.CONTAINER_STATUS_RUNNING}, 120) + if err != nil { + task.taskFailed(ctx, llm, errors.Wrap(err, "WaitContainerStatus")) + return } err = llm.GetLLMContainerDriver().StartLLM(ctx, task.GetUserCred(), llm) if err != nil { diff --git a/pkg/llm/tasks/llm/llm_sync_status_task.go b/pkg/llm/tasks/llm/llm_sync_status_task.go index 3a2791e122..33d63514ea 100644 --- a/pkg/llm/tasks/llm/llm_sync_status_task.go +++ b/pkg/llm/tasks/llm/llm_sync_status_task.go @@ -84,6 +84,10 @@ func (task *LLMSyncStatusTask) OnInit(ctx context.Context, obj db.IStandaloneMod if err != nil { return nil, errors.Wrap(err, "WaitServerStatus") } + // 同步本地 llm container + if _, err := llm.SyncLLMContainer(ctx, task.UserCred, srv); err != nil { + return nil, errors.Wrap(err, "SyncLLMContainer") + } if utils.IsInArray(srv.Status, []string{ computeapi.POD_STATUS_CRASH_LOOP_BACK_OFF,