Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user