Files

450 lines
12 KiB
Go
Raw Permalink Normal View History

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 {
2026-08-16 16:57:36 +02:00
// Prefer legacy openai JSON fields before OPENAI_* env so a mock .env
// cannot override a configured platform DB endpoint.
if out.BaseURL == "" {
2026-08-16 16:57:36 +02:00
out.BaseURL = firstNonEmpty(strings.TrimSpace(doc.OpenAI.BaseURL), strings.TrimSpace(s.Env.OpenAIBaseURL))
}
if out.Model == "" {
2026-08-16 16:57:36 +02:00
out.Model = firstNonEmpty(strings.TrimSpace(doc.OpenAI.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
}
2026-08-16 16:57:36 +02:00
// processing: prefer legacy openai (DB) then env when the role slot has no key,
// even if provider/model/base meta was partially written.
if role == AIRoleProcessing {
legacy, err := s.resolveLegacyOpenAI(doc)
if err != nil {
return ResolvedAIConfig{}, err
}
2026-08-16 16:57:36 +02:00
if strings.TrimSpace(legacy.APIKey) != "" {
return ResolvedAIConfig{
Role: role,
Provider: firstNonEmpty(strings.TrimSpace(st.Provider), defaultAIProvider),
APIKey: legacy.APIKey,
BaseURL: firstNonEmpty(strings.TrimSpace(st.BaseURL), legacy.BaseURL),
Model: firstNonEmpty(strings.TrimSpace(st.Model), legacy.Model),
Enabled: true,
Extras: copyStringMap(st.Extras),
Source: legacy.Source,
}, nil
}
if !hasRoleMeta {
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
}