package aiprovider import ( "context" "errors" "fmt" "net/http" "strings" "time" "github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings" "github.com/descrybe/descrybe-v2/apps/api/internal/processing" "github.com/descrybe/descrybe-v2/apps/api/internal/security" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) var ( ErrNotConfigured = errors.New("ai provider not configured") ErrInvalidMode = errors.New("mode must be internal, popular, or custom") ErrInvalidPopular = errors.New("unknown popular provider") ErrMissingAPIKey = errors.New("api key required") ErrMissingModel = errors.New("model required") ErrMissingURL = errors.New("base url required for custom provider") ) // aiProbeTimeout bounds admin/company connection tests so a hung provider cannot // hold the HTTP request for multi-retry OpenAI client durations. const aiProbeTimeout = 45 * time.Second type Service struct { Pool *pgxpool.Pool Key []byte Env EnvConfig // Platform is optional; when set, platform OpenAI is loaded from admin // settings (DB) with EnvConfig as bootstrap fallback. Platform *platformsettings.Service // Roles is optional admin role-binding lookup (processing / embeddings / …). // When nil or a role is unset, ResolveCompleterForRole falls back to Resolve. Roles RoleEndpointSource HTTPClient *http.Client } func NewService(pool *pgxpool.Pool, env EnvConfig) *Service { keyMaterial := firstNonEmpty(env.AppEncryptionKey, env.CredentialsEncryptionKey, env.TokenSigningSecret) return &Service{ Pool: pool, Key: DeriveKey(keyMaterial, env.DatabaseURL), Env: env, // HTTPClient is optional (tests). Production uses NewOpenAIClient's // SafeHTTPClient so dial-time SSRF applies; leave nil here so platform // OPENAI_BASE_URL loopback (local models) is not overwritten. } } type stored struct { mode, popularName, baseURL, model, keyEnc, last4 string enabled bool lastTest *time.Time lastStatus *string } func (s *Service) loadStored(ctx context.Context, companyID uuid.UUID) (stored, error) { var st stored err := s.Pool.QueryRow(ctx, ` SELECT mode, popular_name, base_url, model, api_key_enc, api_key_last4, is_enabled, last_test_at, last_test_status FROM ai_providers WHERE company_id = $1`, companyID).Scan( &st.mode, &st.popularName, &st.baseURL, &st.model, &st.keyEnc, &st.last4, &st.enabled, &st.lastTest, &st.lastStatus, ) return st, err } func (s *Service) GetConfig(ctx context.Context, companyID uuid.UUID) (PublicConfig, error) { platformOK, err := s.platformConfigured(ctx) if err != nil { return PublicConfig{}, err } st, err := s.loadStored(ctx, companyID) if errors.Is(err, pgx.ErrNoRows) { return PublicConfig{ Mode: ModeInternal, Configured: false, IsEnabled: false, ActiveModeLabel: ModeInternalLabel, PlatformFallback: platformOK, PopularProviders: PopularCatalog, }, nil } if err != nil { return PublicConfig{}, err } hasKey := st.keyEnc != "" masked := "" if hasKey && st.last4 != "" { masked = "••••" + st.last4 } active := ModeInternalLabel if st.enabled && hasKey && (st.mode == ModePopular || st.mode == ModeCustom) { active = AnalyticsMode(st.mode, st.popularName) } return PublicConfig{ Mode: normalizeMode(st.mode), PopularName: st.popularName, BaseURL: st.baseURL, Model: st.model, IsEnabled: st.enabled, Configured: true, HasAPIKey: hasKey, APIKeyLast4: st.last4, APIKeyMasked: masked, LastTestAt: st.lastTest, LastTestStatus: st.lastStatus, ActiveModeLabel: active, PlatformFallback: platformOK, PopularProviders: PopularCatalog, }, nil } func (s *Service) UpdateConfig(ctx context.Context, companyID uuid.UUID, in UpdateInput) (PublicConfig, error) { mode := normalizeMode(in.Mode) if mode != ModeInternal && mode != ModePopular && mode != ModeCustom { return PublicConfig{}, ErrInvalidMode } var existing stored existing, err := s.loadStored(ctx, companyID) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return PublicConfig{}, err } hasExisting := err == nil keyEnc := "" last4v := "" if hasExisting { keyEnc = existing.keyEnc last4v = existing.last4 } if in.ClearAPIKey { keyEnc = "" last4v = "" } else if strings.TrimSpace(in.APIKey) != "" { plain := strings.TrimSpace(in.APIKey) enc, err := EncryptSecret(s.Key, plain) if err != nil { return PublicConfig{}, err } keyEnc = enc last4v = last4(plain) } popularName := "" baseURL := "" model := strings.TrimSpace(in.Model) switch mode { case ModeInternal: // Platform fallback; company key optional/cleared when switching away from BYOK. if !in.IsEnabled { keyEnc = "" last4v = "" } case ModePopular: pop, ok := FindPopular(in.PopularName) if !ok { return PublicConfig{}, ErrInvalidPopular } popularName = pop.Name baseURL = pop.BaseURL if model == "" { model = pop.DefaultModel } if !modelAllowed(pop, model) { return PublicConfig{}, ClientMsg(fmt.Sprintf("model %q is not in the %s catalog (or leave blank for default)", model, pop.Name)) } if in.IsEnabled && keyEnc == "" { return PublicConfig{}, ErrMissingAPIKey } case ModeCustom: normalized, err := validateProviderBaseURL(in.BaseURL) if err != nil { return PublicConfig{}, err } baseURL = normalized if model == "" { return PublicConfig{}, ErrMissingModel } if in.IsEnabled && keyEnc == "" { return PublicConfig{}, ErrMissingAPIKey } } _, err = s.Pool.Exec(ctx, ` INSERT INTO ai_providers ( company_id, mode, popular_name, base_url, model, api_key_enc, api_key_last4, is_enabled, updated_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8, now()) ON CONFLICT (company_id) DO UPDATE SET mode = EXCLUDED.mode, popular_name = EXCLUDED.popular_name, base_url = EXCLUDED.base_url, model = EXCLUDED.model, api_key_enc = EXCLUDED.api_key_enc, api_key_last4 = EXCLUDED.api_key_last4, is_enabled = EXCLUDED.is_enabled, updated_at = now()`, companyID, mode, popularName, baseURL, model, keyEnc, last4v, in.IsEnabled && mode != ModeInternal) if err != nil { return PublicConfig{}, err } return s.GetConfig(ctx, companyID) } func modelAllowed(pop PopularProvider, model string) bool { model = strings.TrimSpace(model) if model == "" || model == pop.DefaultModel { return true } for _, m := range pop.Models { if m == model { return true } } // Allow unknown model strings for popular providers (API may add models faster than catalog). return true } func validateProviderBaseURL(raw string) (string, error) { raw = strings.TrimSpace(raw) if raw == "" { return "", ErrMissingURL } normalized, err := security.ValidatePublicHTTPSURL(raw) if err != nil { return "", err } if normalized == "" { return "", ErrMissingURL } return strings.TrimRight(normalized, "/"), nil } // Resolve picks company BYOK completer when enabled+keyed, else platform AI // from admin settings (DB), with optional env fallback via Platform / Env. func (s *Service) Resolve(ctx context.Context, companyID uuid.UUID) (Resolved, error) { rpm := s.Env.ProcessingRPM retries := s.Env.ProcessingMaxRetries st, err := s.loadStored(ctx, companyID) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return Resolved{}, err } if err == nil && st.enabled && (st.mode == ModePopular || st.mode == ModeCustom) { key, derr := DecryptSecret(s.Key, st.keyEnc) if derr != nil { return Resolved{}, derr } if strings.TrimSpace(key) != "" && strings.TrimSpace(st.baseURL) != "" && strings.TrimSpace(st.model) != "" { client := processing.NewOpenAIClient(key, st.baseURL, st.model, rpm, retries) client.ModeLabel = AnalyticsMode(st.mode, st.popularName) if s.HTTPClient != nil { client.HTTPClient = s.HTTPClient } return Resolved{ Completer: client, ModeLabel: client.ModeLabel, UsingBYOK: true, }, nil } } platform, err := s.resolvePlatformOpenAI(ctx) if err != nil { return Resolved{}, err } if strings.TrimSpace(platform.APIKey) == "" { return Resolved{ModeLabel: ModeInternalLabel, UsingBYOK: false}, nil } client := processing.NewOpenAIClient( platform.APIKey, platform.BaseURL, platform.Model, rpm, retries, ) client.ModeLabel = ModeInternalLabel if s.HTTPClient != nil { client.HTTPClient = s.HTTPClient } return Resolved{ Completer: client, ModeLabel: ModeInternalLabel, UsingBYOK: false, }, nil } func (s *Service) platformConfigured(ctx context.Context) (bool, error) { oi, err := s.resolvePlatformOpenAI(ctx) if err != nil { return false, err } return strings.TrimSpace(oi.APIKey) != "", nil } func (s *Service) resolvePlatformOpenAI(ctx context.Context) (platformsettings.ResolvedOpenAI, error) { if s.Platform != nil { return s.Platform.ResolveOpenAI(ctx) } out := platformsettings.ResolvedOpenAI{ APIKey: strings.TrimSpace(s.Env.OpenAIAPIKey), BaseURL: strings.TrimSpace(s.Env.OpenAIBaseURL), Model: strings.TrimSpace(s.Env.OpenAIModel), Source: platformsettings.SourceNone, } if out.APIKey != "" { out.Source = platformsettings.SourceEnv } return out, nil } // ResolveCompleter implements processing.CompanyCompleterResolver (legacy callers). // Prefer ResolveCompleterForRole for new call sites. func (s *Service) ResolveCompleter(ctx context.Context, companyID uuid.UUID) (processing.Completer, string, bool, error) { r, err := s.Resolve(ctx, companyID) if err != nil { return nil, ModeInternalLabel, false, err } return r.Completer, r.ModeLabel, r.UsingBYOK, nil } // TestPlatformRole probes admin platform AI role credentials (not company BYOK). // Chat roles send a minimal completion; vectorization sends a one-token embed. // Never returns upstream error bodies (may contain key fragments). func (s *Service) TestPlatformRole(ctx context.Context, role string) (map[string]any, error) { ctx, cancel := context.WithTimeout(ctx, aiProbeTimeout) defer cancel() role = strings.TrimSpace(role) out := map[string]any{"role": role} if role == "" || !platformsettings.ValidAIRole(role) { out["status"] = "failed" out["message"] = "unknown ai role" return out, fmt.Errorf("unknown ai role %q", role) } if role == RoleVectorization { emb, err := s.ResolveEmbedderForRole(ctx, uuid.Nil, role) if err != nil { out["status"] = "failed" out["message"] = "provider resolve failed" return out, err } if emb == nil { out["status"] = "skipped" out["message"] = "Vectorization AI is not configured in admin platform settings" return out, nil } if _, err := emb.Embed(ctx, []string{"ping"}); err != nil { out["status"] = "failed" out["message"] = "connection failed — check vectorization provider, key, and model" return out, err } out["status"] = "ok" out["message"] = "Embeddings probe succeeded" return out, nil } completer, _, _, err := s.resolvePlatformRoleCompleter(ctx, role) if err != nil { out["status"] = "failed" out["message"] = "provider resolve failed" return out, err } if completer == nil { out["status"] = "skipped" out["message"] = "AI role is not configured (or disabled) in admin platform settings" return out, nil } if _, err := completer.Complete(ctx, "Reply with exactly: ok", "ping"); err != nil { out["status"] = "failed" out["message"] = "connection failed — check provider, key, base URL, and model" return out, err } out["status"] = "ok" out["message"] = "Connection probe succeeded" return out, nil } // TestConnection sends a minimal chat completion and records last_test_*. func (s *Service) TestConnection(ctx context.Context, companyID uuid.UUID) (map[string]any, error) { ctx, cancel := context.WithTimeout(ctx, aiProbeTimeout) defer cancel() resolved, err := s.Resolve(ctx, companyID) status := "ok" message := "connection successful" if err != nil { status = "failed" message = "provider resolve failed" _, _ = s.Pool.Exec(ctx, ` UPDATE ai_providers SET last_test_at = now(), last_test_status = $2, updated_at = now() WHERE company_id = $1`, companyID, status) return map[string]any{"status": status, "message": message, "mode": ModeInternalLabel}, err } if resolved.Completer == nil { status = "failed" message = "no api key configured (company BYOK or admin platform settings)" _, _ = s.Pool.Exec(ctx, ` UPDATE ai_providers SET last_test_at = now(), last_test_status = $2, updated_at = now() WHERE company_id = $1`, companyID, status) return map[string]any{"status": status, "message": message, "mode": resolved.ModeLabel}, ErrNotConfigured } _, err = resolved.Completer.Complete(ctx, "Reply with exactly: ok", "ping") if err != nil { status = "failed" // TruncateError classifies transport/auth failures without leaking secrets. message = processing.TruncateError(err) if message == "" || message == "provider error (details redacted)" { message = "connection failed — check provider, key, base URL, and model" } _, _ = s.Pool.Exec(ctx, ` UPDATE ai_providers SET last_test_at = now(), last_test_status = $2, updated_at = now() WHERE company_id = $1`, companyID, status) return map[string]any{"status": status, "message": message, "mode": resolved.ModeLabel}, err } _, _ = s.Pool.Exec(ctx, ` UPDATE ai_providers SET last_test_at = now(), last_test_status = $2, updated_at = now() WHERE company_id = $1`, companyID, status) return map[string]any{ "status": status, "message": message, "mode": resolved.ModeLabel, "byok": resolved.UsingBYOK, }, nil } func firstNonEmpty(vals ...string) string { for _, v := range vals { if strings.TrimSpace(v) != "" { return v } } return "" }