package platformsettings import ( "context" "fmt" "strings" "github.com/descrybe/descrybe-v2/apps/api/internal/security" ) const ( maxAIProviderLen = 64 maxAIBaseURLLen = 512 maxAIModelLen = 128 maxAIExtrasKeys = 32 maxAIExtrasKeyLen = 64 maxAIExtrasValLen = 2048 defaultAIProvider = "openai" ) type aiConfigStored struct { Provider string `json:"provider"` BaseURL string `json:"base_url"` Model string `json:"model"` APIKeyEnc string `json:"api_key_enc"` APIKeyLast4 string `json:"api_key_last4"` Enabled bool `json:"enabled"` Extras map[string]string `json:"extras,omitempty"` } // ValidAIRole reports whether role is a known platform AI config slot. func ValidAIRole(role string) bool { switch strings.TrimSpace(role) { case AIRoleProcessing, AIRoleVectorization, AIRoleDocsAPI, AIRoleSupport: return true default: return false } } func (s *Service) publicAIConfigs(doc storedDoc) map[string]AIConfigPublic { out := make(map[string]AIConfigPublic, len(AIRoles)) for _, role := range AIRoles { st, ok := doc.AIConfigs[role] if !ok { st = aiConfigStored{} } out[role] = s.publicAIConfig(role, st, doc.OpenAI) } return out } func (s *Service) publicAIConfig(role string, st aiConfigStored, openai openaiStored) AIConfigPublic { hasRoleData := strings.TrimSpace(st.Provider) != "" || strings.TrimSpace(st.BaseURL) != "" || strings.TrimSpace(st.Model) != "" || strings.TrimSpace(st.APIKeyEnc) != "" || st.Enabled || len(st.Extras) > 0 if role == AIRoleProcessing && !hasRoleData { oi := s.publicOpenAI(openai) return AIConfigPublic{ Role: role, Provider: defaultAIProvider, BaseURL: oi.BaseURL, Model: oi.Model, Enabled: oi.HasAPIKey, Configured: oi.Configured, HasAPIKey: oi.HasAPIKey, APIKeyLast4: oi.APIKeyLast4, APIKeyMasked: oi.APIKeyMasked, Source: oi.Source, } } hasDB := strings.TrimSpace(st.APIKeyEnc) != "" out := AIConfigPublic{ Role: role, Provider: strings.TrimSpace(st.Provider), BaseURL: strings.TrimSpace(st.BaseURL), Model: strings.TrimSpace(st.Model), Enabled: st.Enabled, Extras: copyStringMap(st.Extras), Source: SourceNone, } if hasDB { out.Configured = true out.HasAPIKey = true out.APIKeyLast4 = st.APIKeyLast4 out.APIKeyMasked = maskLast4(st.APIKeyLast4) out.Source = SourceDB return out } if out.Provider != "" || out.BaseURL != "" || out.Model != "" || out.Enabled || len(out.Extras) > 0 { out.Configured = true out.Source = SourceDB return out } if role == AIRoleVectorization { env := s.resolveVectorizationEnv() if env.APIKey != "" { out.Configured = true out.HasAPIKey = true out.APIKeyLast4 = last4(env.APIKey) out.APIKeyMasked = maskLast4(out.APIKeyLast4) out.BaseURL = env.BaseURL out.Model = env.Model out.Provider = defaultAIProvider out.Enabled = true out.Source = SourceEnv return out } } return out } func (s *Service) patchAIConfigs(doc *storedDoc, patches map[string]*AIConfigUpdate) error { if doc.AIConfigs == nil { doc.AIConfigs = map[string]aiConfigStored{} } for role, patch := range patches { role = strings.TrimSpace(role) if !ValidAIRole(role) { return ClientMsg(fmt.Sprintf("unknown ai_roles role %q (want processing|vectorization|docs_api|support)", role)) } if patch == nil { continue } st := doc.AIConfigs[role] if err := s.patchAIConfig(&st, *patch); err != nil { return err } doc.AIConfigs[role] = st } return nil } func (s *Service) patchAIConfig(st *aiConfigStored, in AIConfigUpdate) error { if in.Provider != nil { p := strings.TrimSpace(*in.Provider) if len(p) > maxAIProviderLen { return ClientMsg(fmt.Sprintf("provider exceeds %d characters", maxAIProviderLen)) } st.Provider = p } if in.BaseURL != nil { u := strings.TrimSpace(*in.BaseURL) if len(u) > maxAIBaseURLLen { return ClientMsg(fmt.Sprintf("base_url exceeds %d characters", maxAIBaseURLLen)) } if u != "" { normalized, err := security.ValidatePublicHTTPSURL(u) if err != nil || normalized == "" { return ClientMsg("invalid base_url") } u = strings.TrimRight(normalized, "/") } st.BaseURL = u } if in.Model != nil { m := strings.TrimSpace(*in.Model) if len(m) > maxAIModelLen { return ClientMsg(fmt.Sprintf("model exceeds %d characters", maxAIModelLen)) } st.Model = m } if in.Enabled != nil { st.Enabled = *in.Enabled } if in.Extras != nil { if err := patchAIExtras(&st.Extras, in.Extras); err != nil { return err } } if in.ClearAPIKey { st.APIKeyEnc = "" st.APIKeyLast4 = "" return nil } if in.APIKey != nil { plain := strings.TrimSpace(*in.APIKey) if plain == "" { return nil } enc, err := EncryptSecret(s.Key, plain) if err != nil { return err } st.APIKeyEnc = enc st.APIKeyLast4 = last4(plain) } return nil } func patchAIExtras(dst *map[string]string, patch map[string]*string) error { if patch == nil { return nil } if *dst == nil { *dst = map[string]string{} } for k, vp := range patch { key := strings.TrimSpace(k) if key == "" { return ClientMsg("extras keys must be non-empty") } if strings.ContainsAny(key, " \t\n\r") { return ClientMsg("extras keys must not contain whitespace") } if len(key) > maxAIExtrasKeyLen { return ClientMsg(fmt.Sprintf("extras key exceeds %d characters", maxAIExtrasKeyLen)) } if vp == nil { delete(*dst, key) continue } if len(*vp) > maxAIExtrasValLen { return ClientMsg(fmt.Sprintf("extras value for %q exceeds %d characters", key, maxAIExtrasValLen)) } (*dst)[key] = *vp if len(*dst) > maxAIExtrasKeys { return ClientMsg(fmt.Sprintf("extras may have at most %d keys", maxAIExtrasKeys)) } } if len(*dst) == 0 { *dst = nil } return nil } func copyStringMap(in map[string]string) map[string]string { if len(in) == 0 { return nil } out := make(map[string]string, len(in)) for k, v := range in { out[k] = v } return out } // syncProcessingFromOpenAI mirrors legacy openai into ai_roles.processing. func syncProcessingFromOpenAI(doc *storedDoc) { if doc.AIConfigs == nil { doc.AIConfigs = map[string]aiConfigStored{} } st := doc.AIConfigs[AIRoleProcessing] if strings.TrimSpace(st.Provider) == "" { st.Provider = defaultAIProvider } st.BaseURL = doc.OpenAI.BaseURL st.Model = doc.OpenAI.Model st.APIKeyEnc = doc.OpenAI.APIKeyEnc st.APIKeyLast4 = doc.OpenAI.APIKeyLast4 if strings.TrimSpace(st.APIKeyEnc) != "" { st.Enabled = true } doc.AIConfigs[AIRoleProcessing] = st } // syncOpenAIFromProcessing mirrors processing role into legacy openai. func syncOpenAIFromProcessing(doc *storedDoc) { st, ok := doc.AIConfigs[AIRoleProcessing] if !ok { return } doc.OpenAI.BaseURL = st.BaseURL doc.OpenAI.Model = st.Model doc.OpenAI.APIKeyEnc = st.APIKeyEnc doc.OpenAI.APIKeyLast4 = st.APIKeyLast4 } // ResolveAIConfig returns plaintext credentials for a role (never log the key). // processing falls back to legacy openai JSON then env when the role slot is empty. // // docs_api: config slot only until a future product hook; do not call from the // guided /docs Ask decision tree (rule-based, no LLM). func (s *Service) ResolveAIConfig(ctx context.Context, role string) (ResolvedAIConfig, error) { role = strings.TrimSpace(role) if !ValidAIRole(role) { return ResolvedAIConfig{}, ClientMsg(fmt.Sprintf("unknown ai role %q", role)) } if s == nil { return ResolvedAIConfig{Role: role, Source: SourceNone}, nil } doc, _, err := s.loadDoc(ctx) if err != nil { return ResolvedAIConfig{}, err } st, ok := doc.AIConfigs[role] hasRoleKey := ok && strings.TrimSpace(st.APIKeyEnc) != "" hasRoleMeta := ok && (strings.TrimSpace(st.Provider) != "" || strings.TrimSpace(st.BaseURL) != "" || strings.TrimSpace(st.Model) != "" || st.Enabled || len(st.Extras) > 0) if hasRoleKey { plain, err := DecryptSecret(s.Key, st.APIKeyEnc) if err != nil { return ResolvedAIConfig{}, err } out := ResolvedAIConfig{ Role: role, Provider: firstNonEmpty(strings.TrimSpace(st.Provider), defaultAIProvider), APIKey: plain, BaseURL: strings.TrimSpace(st.BaseURL), Model: strings.TrimSpace(st.Model), Enabled: st.Enabled, Extras: copyStringMap(st.Extras), Source: SourceDB, } if role == AIRoleProcessing { if out.BaseURL == "" { out.BaseURL = strings.TrimSpace(s.Env.OpenAIBaseURL) } if out.Model == "" { out.Model = strings.TrimSpace(s.Env.OpenAIModel) } } if role == AIRoleVectorization { if out.BaseURL == "" { out.BaseURL = firstNonEmpty(strings.TrimSpace(s.Env.OpenAIEmbeddingBaseURL), strings.TrimSpace(s.Env.OpenAIBaseURL)) } if out.Model == "" { out.Model = firstNonEmpty(strings.TrimSpace(s.Env.OpenAIEmbeddingModel), defaultEmbeddingModel) } } return out, nil } if role == AIRoleProcessing && !hasRoleMeta { legacy, err := s.resolveLegacyOpenAI(doc) if err != nil { return ResolvedAIConfig{}, err } return ResolvedAIConfig{ Role: role, Provider: defaultAIProvider, APIKey: legacy.APIKey, BaseURL: legacy.BaseURL, Model: legacy.Model, Enabled: legacy.APIKey != "", Source: legacy.Source, }, nil } if role == AIRoleVectorization && !hasRoleMeta { return s.resolveVectorizationEnv(), nil } out := ResolvedAIConfig{ Role: role, Provider: strings.TrimSpace(st.Provider), BaseURL: strings.TrimSpace(st.BaseURL), Model: strings.TrimSpace(st.Model), Enabled: st.Enabled, Extras: copyStringMap(st.Extras), Source: SourceNone, } if hasRoleMeta { out.Source = SourceDB } if role == AIRoleVectorization && out.APIKey == "" { env := s.resolveVectorizationEnv() if env.APIKey != "" { return env, nil } } return out, nil } const defaultEmbeddingModel = "text-embedding-3-small" // resolveVectorizationEnv uses OPENAI_EMBEDDING_* then shared OPENAI_* as bootstrap. func (s *Service) resolveVectorizationEnv() ResolvedAIConfig { out := ResolvedAIConfig{ Role: AIRoleVectorization, Provider: defaultAIProvider, Source: SourceNone, } if s == nil { return out } key := firstNonEmpty(strings.TrimSpace(s.Env.OpenAIEmbeddingAPIKey), strings.TrimSpace(s.Env.OpenAIAPIKey)) if key == "" { return out } out.APIKey = key out.BaseURL = firstNonEmpty(strings.TrimSpace(s.Env.OpenAIEmbeddingBaseURL), strings.TrimSpace(s.Env.OpenAIBaseURL)) out.Model = firstNonEmpty(strings.TrimSpace(s.Env.OpenAIEmbeddingModel), defaultEmbeddingModel) out.Enabled = true out.Source = SourceEnv return out } func (s *Service) resolveLegacyOpenAI(doc storedDoc) (ResolvedOpenAI, error) { out := ResolvedOpenAI{ BaseURL: strings.TrimSpace(doc.OpenAI.BaseURL), Model: strings.TrimSpace(doc.OpenAI.Model), Source: SourceNone, } if strings.TrimSpace(doc.OpenAI.APIKeyEnc) != "" { plain, err := DecryptSecret(s.Key, doc.OpenAI.APIKeyEnc) if err != nil { return ResolvedOpenAI{}, err } out.APIKey = plain out.Source = SourceDB if out.BaseURL == "" { out.BaseURL = strings.TrimSpace(s.Env.OpenAIBaseURL) } if out.Model == "" { out.Model = strings.TrimSpace(s.Env.OpenAIModel) } return out, nil } if strings.TrimSpace(s.Env.OpenAIAPIKey) != "" { out.APIKey = strings.TrimSpace(s.Env.OpenAIAPIKey) out.Source = SourceEnv if out.BaseURL == "" { out.BaseURL = strings.TrimSpace(s.Env.OpenAIBaseURL) } if out.Model == "" { out.Model = strings.TrimSpace(s.Env.OpenAIModel) } return out, nil } return out, nil }