src/server/internal/api/settings.go
21,402 bytes · 724 lines · capsule://quake0day/[email protected]
raw on github
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"sort"
"strconv"
"strings"
"github.com/cyberverse/server/internal/config"
"gopkg.in/yaml.v3"
)
var errInferenceUnavailable = errors.New("inference service is unavailable")
// SettingsResponse is the JSON shape exchanged with the frontend Settings page.
type SettingsResponse struct {
Doubao DoubaoSettings `json:"doubao"`
LiveKit LiveKitSettings `json:"livekit"`
ModelProviders ModelProviderSettings `json:"model_providers"`
LLM LLMSettings `json:"llm,omitempty"`
TTS TTSSettings `json:"tts,omitempty"`
ASR ASRSettings `json:"asr,omitempty"`
Inference InferenceSettings `json:"inference"`
}
type DoubaoSettings struct {
AccessToken string `json:"access_token"`
AppID string `json:"app_id"`
}
type LiveKitSettings struct {
URL string `json:"url"`
APIKey string `json:"api_key"`
APISecret string `json:"api_secret"`
}
type ModelProviderSettings struct {
DashScopeAPIKey string `json:"dashscope_api_key"`
OpenAIAPIKey string `json:"openai_api_key"`
}
type LLMSettings struct {
APIKey string `json:"api_key"`
Model string `json:"model"`
Temperature float64 `json:"temperature"`
}
type TTSSettings struct {
Model string `json:"model"`
Voice string `json:"voice"`
}
type ASRSettings struct {
ModelSize string `json:"model_size"`
Language string `json:"language"`
Device string `json:"device"`
}
type InferenceSettings struct {
GRPCAddr string `json:"grpc_addr"`
}
type launchConfigParamJSON struct {
Name string `json:"name"`
Path string `json:"path"`
Value any `json:"value"`
Readonly bool `json:"readonly"`
RequiresRestart bool `json:"requires_restart"`
Options []string `json:"options,omitempty"`
}
type launchConfigSectionJSON struct {
Key string `json:"key"`
Title string `json:"title"`
Badge string `json:"badge"`
Params []launchConfigParamJSON `json:"params"`
}
type avatarModelConfigStatus struct {
HasInferParams bool `json:"has_infer_params"`
ConfigSectionsAvailable []string `json:"config_sections_available"`
}
type avatarModelDescriptor struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
IsActive bool `json:"is_active"`
IsConfiguredDefault bool `json:"is_configured_default"`
ConfigStatus avatarModelConfigStatus `json:"config_status"`
}
type avatarModelInfoResponse struct {
ActiveModel string `json:"active_model"`
ConfiguredDefaultModel string `json:"configured_default_model"`
AvatarEnabled bool `json:"avatar_enabled"`
Models []avatarModelDescriptor `json:"models"`
ConfigStatus avatarModelConfigStatus `json:"config_status"`
}
type launchConfigResponse struct {
ActiveModel string `json:"active_model"`
ConfiguredDefaultModel string `json:"configured_default_model"`
AvatarEnabled bool `json:"avatar_enabled"`
ConfigStatus avatarModelConfigStatus `json:"config_status"`
Sections []launchConfigSectionJSON `json:"sections"`
}
// settingsField maps a UI field to an environment variable.
type settingsField struct {
envKey string
getValue func(*SettingsResponse) string
}
var settingsFields = []settingsField{
{"DOUBAO_ACCESS_TOKEN", func(s *SettingsResponse) string { return s.Doubao.AccessToken }},
{"DOUBAO_APP_ID", func(s *SettingsResponse) string { return s.Doubao.AppID }},
{"LIVEKIT_URL", func(s *SettingsResponse) string { return s.LiveKit.URL }},
{"LIVEKIT_API_KEY", func(s *SettingsResponse) string { return s.LiveKit.APIKey }},
{"LIVEKIT_API_SECRET", func(s *SettingsResponse) string { return s.LiveKit.APISecret }},
{"DASHSCOPE_API_KEY", func(s *SettingsResponse) string { return s.ModelProviders.DashScopeAPIKey }},
{"OPENAI_API_KEY", func(s *SettingsResponse) string {
if s.ModelProviders.OpenAIAPIKey != "" {
return s.ModelProviders.OpenAIAPIKey
}
return s.LLM.APIKey
}},
{"GRPC_INFERENCE_ADDR", func(s *SettingsResponse) string { return s.Inference.GRPCAddr }},
}
func (r *Router) handleGetSettings(w http.ResponseWriter, req *http.Request) {
resp := SettingsResponse{
Doubao: DoubaoSettings{
AccessToken: os.Getenv("DOUBAO_ACCESS_TOKEN"),
AppID: os.Getenv("DOUBAO_APP_ID"),
},
LiveKit: LiveKitSettings{
URL: envOrDefault("LIVEKIT_URL", r.cfg.LiveKit.URL),
APIKey: envOrDefault("LIVEKIT_API_KEY", r.cfg.LiveKit.APIKey),
APISecret: envOrDefault("LIVEKIT_API_SECRET", r.cfg.LiveKit.APISecret),
},
ModelProviders: ModelProviderSettings{
DashScopeAPIKey: os.Getenv("DASHSCOPE_API_KEY"),
OpenAIAPIKey: os.Getenv("OPENAI_API_KEY"),
},
LLM: LLMSettings{
APIKey: os.Getenv("OPENAI_API_KEY"),
Model: envOrDefault("LLM_MODEL", "gpt-4o"),
Temperature: envOrDefaultFloat("LLM_TEMPERATURE", 0.7),
},
TTS: TTSSettings{
Model: envOrDefault("TTS_MODEL", "tts-1"),
Voice: envOrDefault("TTS_VOICE", "nova"),
},
ASR: ASRSettings{
ModelSize: envOrDefault("ASR_MODEL_SIZE", "base"),
Language: envOrDefault("ASR_LANGUAGE", "auto"),
Device: envOrDefault("ASR_DEVICE", "cpu"),
},
Inference: InferenceSettings{
GRPCAddr: envOrDefault("GRPC_INFERENCE_ADDR", r.cfg.Inference.Addr),
},
}
writeJSON(w, http.StatusOK, resp)
}
func (r *Router) handleUpdateSettings(w http.ResponseWriter, req *http.Request) {
var body SettingsResponse
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid JSON"})
return
}
updates := make(map[string]string)
for _, f := range settingsFields {
val := f.getValue(&body)
// Skip empty values to avoid blanking existing config.
if val == "" {
continue
}
updates[f.envKey] = val
}
if len(updates) > 0 {
// Persist to .env file.
if r.envPath != "" {
if err := config.SaveDotenv(r.envPath, updates); err != nil {
writeJSON(w, http.StatusInternalServerError, ErrorResponse{
Error: fmt.Sprintf("failed to save .env: %v", err),
})
return
}
}
// Update process environment so subsequent GET reflects new values.
for k, v := range updates {
os.Setenv(k, v)
}
// Sync in-memory config for fields the Go struct captures.
if v, ok := updates["LIVEKIT_URL"]; ok {
r.cfg.LiveKit.URL = v
}
if v, ok := updates["LIVEKIT_API_KEY"]; ok {
r.cfg.LiveKit.APIKey = v
}
if v, ok := updates["LIVEKIT_API_SECRET"]; ok {
r.cfg.LiveKit.APISecret = v
}
if v, ok := updates["GRPC_INFERENCE_ADDR"]; ok {
r.cfg.Inference.Addr = v
}
}
writeJSON(w, http.StatusOK, map[string]string{"status": "saved"})
}
func (r *Router) handleTestConnection(w http.ResponseWriter, req *http.Request) {
if err := r.inferenceHealthError(req.Context()); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"status": "error",
"error": err.Error(),
})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// paramMeta defines special attributes for known parameter keys.
// Keys not listed here default to readonly=false, hidden=false.
var paramMeta = map[string]struct {
Readonly bool
Hidden bool
Options []string
}{
"plugin_class": {Readonly: true, Hidden: true},
"models_dir": {Readonly: true, Hidden: true},
"model_type": {Options: []string{"pro", "lite"}},
}
// Shared avatar runtime GPU-related keys are shown in a separate section.
var runtimeGPUKeys = map[string]bool{
"cuda_visible_devices": true,
"world_size": true,
"dist_keepalive_interval_s": true,
"dist_keepalive_idle_s": true,
}
var runtimeGPUKeyOrder = []string{
"cuda_visible_devices",
"world_size",
"dist_keepalive_interval_s",
"dist_keepalive_idle_s",
}
// Some models keep a small subset of GPU-related controls outside infer_params.
// These remain in the GPU section only when explicitly allowlisted here.
var modelGPUKeys = map[string]map[string]bool{
"flash_head": {
"device": true,
},
}
var modelGPUKeyOrder = map[string][]string{
"flash_head": {"device"},
}
// Readonly keys in flash_head infer_params.
var inferParamsReadonly = map[string]bool{}
func normalizeAvatarModelName(name string) string {
name = strings.TrimSpace(name)
if strings.HasPrefix(name, "avatar.") {
return strings.TrimPrefix(name, "avatar.")
}
return name
}
func displayAvatarModelName(name string) string {
switch name {
case "flash_head":
return "FlashHead"
case "live_act":
return "LiveAct"
default:
parts := strings.Split(name, "_")
for i, p := range parts {
if p == "" {
continue
}
parts[i] = strings.ToUpper(p[:1]) + p[1:]
}
return strings.Join(parts, " ")
}
}
func isModelGPUKey(modelName, key string) bool {
keys, ok := modelGPUKeys[modelName]
return ok && keys[key]
}
func orderedGPUKeys(modelName string) []string {
ordered := append([]string{}, runtimeGPUKeyOrder...)
if extraOrder, ok := modelGPUKeyOrder[modelName]; ok {
ordered = append(ordered, extraOrder...)
}
return ordered
}
func (r *Router) configuredDefaultAvatarModel() string {
if r.configPath == "" {
return ""
}
doc, err := config.ReadYAMLNode(r.configPath)
if err != nil {
return ""
}
node, err := config.GetNodeAtPath(doc, "inference.avatar.default")
if err != nil {
return ""
}
if v, ok := config.NodeValue(node, true).(string); ok {
return strings.TrimSpace(v)
}
return ""
}
func (r *Router) configuredAvatarModels() []string {
if r.configPath == "" {
return nil
}
doc, err := config.ReadYAMLNode(r.configPath)
if err != nil {
return nil
}
keys, err := config.GetMappingKeys(doc, "inference.avatar")
if err != nil {
return nil
}
models := make([]string, 0, len(keys))
for _, key := range keys {
if key == "default" || key == "enabled" || key == "runtime" {
continue
}
models = append(models, key)
}
sort.Strings(models)
return models
}
func inferParamsConfigPath(modelName string) string {
return "inference.avatar." + modelName + ".infer_params"
}
func (r *Router) inferParamsExists(modelName string) bool {
if modelName == "" || r.configPath == "" {
return false
}
doc, err := config.ReadYAMLNode(r.configPath)
if err != nil {
return false
}
_, err = config.GetMappingKeys(doc, inferParamsConfigPath(modelName))
return err == nil
}
func (r *Router) configStatusForModel(modelName string) avatarModelConfigStatus {
sections := make([]string, 0, 3)
if modelName != "" {
sections = append(sections, "avatar", "gpu")
}
hasInfer := r.inferParamsExists(modelName)
if hasInfer {
sections = append(sections, "video_output")
}
return avatarModelConfigStatus{
HasInferParams: hasInfer,
ConfigSectionsAvailable: sections,
}
}
func (r *Router) activeAvatarModel(ctx context.Context) (string, error) {
if r.orch == nil {
return "", errInferenceUnavailable
}
if !r.orch.AvatarEnabled() {
model := r.configuredDefaultAvatarModel()
if model == "" {
return "disabled", nil
}
return model, nil
}
info, err := r.orch.AvatarInfo(ctx)
if err != nil {
return "", err
}
model := normalizeAvatarModelName(info.GetModelName())
if model == "" {
return "", errors.New("avatar model name is empty")
}
return model, nil
}
func (r *Router) buildAvatarModelInfo(ctx context.Context) (*avatarModelInfoResponse, error) {
activeModel, err := r.activeAvatarModel(ctx)
if err != nil {
return nil, err
}
avatarEnabled := r.orch == nil || r.orch.AvatarEnabled()
configuredDefault := r.configuredDefaultAvatarModel()
configuredModels := r.configuredAvatarModels()
if len(configuredModels) == 0 && activeModel != "" {
configuredModels = []string{activeModel}
}
seen := map[string]bool{}
models := make([]avatarModelDescriptor, 0, len(configuredModels)+1)
for _, model := range append(configuredModels, activeModel) {
if model == "" || seen[model] {
continue
}
seen[model] = true
models = append(models, avatarModelDescriptor{
Name: model,
DisplayName: displayAvatarModelName(model),
IsActive: model == activeModel,
IsConfiguredDefault: model == configuredDefault,
ConfigStatus: r.configStatusForModel(model),
})
}
sort.Slice(models, func(i, j int) bool { return models[i].Name < models[j].Name })
return &avatarModelInfoResponse{
ActiveModel: activeModel,
ConfiguredDefaultModel: configuredDefault,
AvatarEnabled: avatarEnabled,
Models: models,
ConfigStatus: r.configStatusForModel(activeModel),
}, nil
}
func (r *Router) buildLaunchSections(modelName string) []launchConfigSectionJSON {
var sections []launchConfigSectionJSON
avatarSection := launchConfigSectionJSON{Key: "avatar", Title: "头像模型 (Avatar)", Badge: "restart"}
gpuSection := launchConfigSectionJSON{Key: "gpu", Title: "GPU 配置", Badge: "restart"}
if r.configPath != "" {
doc, err := config.ReadYAMLNode(r.configPath)
if err == nil {
modelPath := "inference.avatar." + modelName
modelGPUParams := map[string]launchConfigParamJSON{}
keys, err := config.GetMappingKeys(doc, modelPath)
if err == nil {
for _, key := range keys {
meta, hasMeta := paramMeta[key]
if hasMeta && meta.Hidden {
continue
}
node, err := config.GetNodeAtPath(doc, modelPath+"."+key)
if err != nil {
continue
}
if node.Kind != yaml.ScalarNode {
continue
}
p := launchConfigParamJSON{
Name: key,
Path: modelPath + "." + key,
Value: config.NodeValue(node, true),
Readonly: hasMeta && meta.Readonly,
RequiresRestart: true,
}
if hasMeta && len(meta.Options) > 0 {
p.Options = meta.Options
}
if isModelGPUKey(modelName, key) {
modelGPUParams[key] = p
} else {
avatarSection.Params = append(avatarSection.Params, p)
}
}
}
runtimeGPUParams := map[string]launchConfigParamJSON{}
runtimePath := "inference.avatar.runtime"
runtimeKeys, err := config.GetMappingKeys(doc, runtimePath)
if err == nil {
for _, key := range runtimeKeys {
if !runtimeGPUKeys[key] {
continue
}
node, err := config.GetNodeAtPath(doc, runtimePath+"."+key)
if err != nil {
continue
}
runtimeGPUParams[key] = launchConfigParamJSON{
Name: key,
Path: runtimePath + "." + key,
Value: config.NodeValue(node, true),
RequiresRestart: true,
}
}
}
usedGPUKeys := map[string]bool{}
for _, key := range orderedGPUKeys(modelName) {
if p, ok := modelGPUParams[key]; ok {
gpuSection.Params = append(gpuSection.Params, p)
usedGPUKeys[key] = true
continue
}
if p, ok := runtimeGPUParams[key]; ok {
gpuSection.Params = append(gpuSection.Params, p)
usedGPUKeys[key] = true
}
}
extraGPUKeySet := map[string]bool{}
for key := range modelGPUParams {
if !usedGPUKeys[key] {
extraGPUKeySet[key] = true
}
}
for key := range runtimeGPUParams {
if !usedGPUKeys[key] {
extraGPUKeySet[key] = true
}
}
extraGPUKeys := make([]string, 0, len(extraGPUKeySet))
for key := range extraGPUKeySet {
extraGPUKeys = append(extraGPUKeys, key)
}
sort.Strings(extraGPUKeys)
for _, key := range extraGPUKeys {
if p, ok := modelGPUParams[key]; ok {
gpuSection.Params = append(gpuSection.Params, p)
usedGPUKeys[key] = true
continue
}
if p, ok := runtimeGPUParams[key]; ok {
gpuSection.Params = append(gpuSection.Params, p)
usedGPUKeys[key] = true
}
}
}
}
if len(avatarSection.Params) > 0 {
sections = append(sections, avatarSection)
}
if r.inferParamsExists(modelName) {
videoSection := launchConfigSectionJSON{Key: "video_output", Title: "视频输出", Badge: "restart"}
inferPath := inferParamsConfigPath(modelName)
if doc, err := config.ReadYAMLNode(r.configPath); err == nil {
keys, err := config.GetMappingKeys(doc, inferPath)
if err == nil {
for _, key := range keys {
node, err := config.GetNodeAtPath(doc, inferPath+"."+key)
if err != nil {
continue
}
videoSection.Params = append(videoSection.Params, launchConfigParamJSON{
Name: key,
Path: inferPath + "." + key,
Value: config.NodeValue(node, false),
Readonly: inferParamsReadonly[key],
RequiresRestart: true,
})
}
}
}
if len(videoSection.Params) > 0 {
sections = append(sections, videoSection)
}
}
if len(gpuSection.Params) > 0 {
sections = append(sections, gpuSection)
}
return sections
}
func (r *Router) handleGetAvatarModelInfo(w http.ResponseWriter, req *http.Request) {
info, err := r.buildAvatarModelInfo(req.Context())
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, ErrorResponse{Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, info)
}
func (r *Router) handleGetLaunchConfig(w http.ResponseWriter, req *http.Request) {
activeModel, err := r.activeAvatarModel(req.Context())
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, ErrorResponse{Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, launchConfigResponse{
ActiveModel: activeModel,
ConfiguredDefaultModel: r.configuredDefaultAvatarModel(),
AvatarEnabled: r.orch == nil || r.orch.AvatarEnabled(),
ConfigStatus: r.configStatusForModel(activeModel),
Sections: r.buildLaunchSections(activeModel),
})
}
func (r *Router) handleUpdateLaunchConfig(w http.ResponseWriter, req *http.Request) {
var body struct {
Model string `json:"model"`
Params []struct {
Path string `json:"path"`
Value any `json:"value"`
} `json:"params"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid JSON"})
return
}
activeModel, err := r.activeAvatarModel(req.Context())
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, ErrorResponse{Error: err.Error()})
return
}
if body.Model == "" {
body.Model = activeModel
}
if body.Model != activeModel {
writeJSON(w, http.StatusBadRequest, ErrorResponse{
Error: fmt.Sprintf("model %q is not active; current active model is %q", body.Model, activeModel),
})
return
}
// Group updates by config path.
mainUpdates := map[string]string{} // dot-path -> value
modelPrefix := "inference.avatar." + body.Model + "."
runtimePrefix := "inference.avatar.runtime."
inferParamsPrefix := inferParamsConfigPath(body.Model) + "."
for _, p := range body.Params {
// Determine source and validate.
if strings.HasPrefix(p.Path, inferParamsPrefix) {
key := strings.TrimPrefix(p.Path, inferParamsPrefix)
if inferParamsReadonly[key] {
writeJSON(w, http.StatusBadRequest, ErrorResponse{
Error: fmt.Sprintf("parameter %q is readonly", p.Path),
})
return
}
mainUpdates[p.Path] = fmt.Sprintf("%v", p.Value)
} else if strings.HasPrefix(p.Path, runtimePrefix) {
key := strings.TrimPrefix(p.Path, runtimePrefix)
if !runtimeGPUKeys[key] {
writeJSON(w, http.StatusBadRequest, ErrorResponse{
Error: fmt.Sprintf("parameter %q is not a shared runtime parameter", p.Path),
})
return
}
mainUpdates[p.Path] = fmt.Sprintf("%v", p.Value)
} else if strings.HasPrefix(p.Path, modelPrefix) {
key := strings.TrimPrefix(p.Path, modelPrefix)
meta, hasMeta := paramMeta[key]
if hasMeta && meta.Readonly {
writeJSON(w, http.StatusBadRequest, ErrorResponse{
Error: fmt.Sprintf("parameter %q is readonly", p.Path),
})
return
}
mainUpdates[p.Path] = fmt.Sprintf("%v", p.Value)
} else {
writeJSON(w, http.StatusBadRequest, ErrorResponse{
Error: fmt.Sprintf("parameter %q is not in scope for model %q", p.Path, body.Model),
})
return
}
}
if len(mainUpdates) > 0 && r.configPath != "" {
doc, err := config.ReadYAMLNode(r.configPath)
if err != nil {
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
return
}
for path, val := range mainUpdates {
if err := config.SetNodeAtPath(doc, path, val); err != nil {
writeJSON(w, http.StatusInternalServerError, ErrorResponse{
Error: fmt.Sprintf("set %s: %v", path, err),
})
return
}
}
if err := config.WriteYAMLNode(r.configPath, doc); err != nil {
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
return
}
}
writeJSON(w, http.StatusOK, map[string]any{
"status": "saved",
"requires_restart": true,
})
}
func envOrDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envOrDefaultFloat(key string, def float64) float64 {
if v := os.Getenv(key); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
return f
}
}
return def
}