432 lines
11 KiB
Go
432 lines
11 KiB
Go
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
|
||
|
|
}
|