package aiprovider import ( "context" "strings" "github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings" "github.com/descrybe/descrybe-v2/apps/api/internal/processing" "github.com/google/uuid" ) // Role identifiers — keep in sync with platformsettings.AIRole* / processing.AIRole*. // // RoleSupport is a FUTURE config slot (platformsettings.ai_roles["support"]). // Resolving a Completer when the slot is configured is allowed for future // draft-assist UIs, but support.TryAutoReplyLLM must remain the only gate for // ticket auto-replies — and that stub currently refuses. Guided /docs Ask is // rule-based and must never use RoleSupport or RoleDocsAPI. const ( RoleProcessing = processing.AIRoleProcessing RoleVectorization = processing.AIRoleVectorization RoleDocsAPI = processing.AIRoleDocsAPI RoleSupport = processing.AIRoleSupport ) // RoleEndpoint is a resolved OpenAI-compatible chat endpoint for one role. // Secrets are plaintext only in-process — never log or return to clients. type RoleEndpoint struct { APIKey string BaseURL string Model string UsingBYOK bool ModeLabel string } // RoleEndpointSource looks up admin-configured role bindings (platform / company). // ok=false means the role is unset — callers must fall back. type RoleEndpointSource interface { LookupRole(ctx context.Context, companyID uuid.UUID, role string) (ep RoleEndpoint, ok bool, err error) } // ResolveCompleterForRole prefers an injected RoleEndpointSource binding when set; // otherwise uses company BYOK then platformsettings.ResolveAIConfig for the role // (processing falls back to legacy openai JSON + OPENAI_* env when unset). func (s *Service) ResolveCompleterForRole(ctx context.Context, companyID uuid.UUID, role string) (processing.Completer, string, bool, error) { role = strings.TrimSpace(role) if role == "" { role = RoleProcessing } if s != nil && s.Roles != nil { ep, ok, err := s.Roles.LookupRole(ctx, companyID, role) if err != nil { return nil, ModeInternalLabel, false, err } if ok && strings.TrimSpace(ep.APIKey) != "" && strings.TrimSpace(ep.Model) != "" { c, mode, byok, err := s.completerFromEndpoint(ep) return s.wrapAudit(c, companyID, role, mode), mode, byok, err } } switch role { case RoleProcessing: // Full Resolve needs Pool for company BYOK; without Pool use platform/env only. if s != nil && s.Pool != nil { return s.ResolveCompleter(ctx, companyID) } c, mode, byok, err := s.resolvePlatformRoleCompleter(ctx, RoleProcessing) return s.wrapAudit(c, companyID, RoleProcessing, mode), mode, byok, err case RoleDocsAPI, RoleSupport: c, mode, byok, err := s.resolvePlatformRoleCompleter(ctx, role) return s.wrapAudit(c, companyID, role, mode), mode, byok, err default: // Vectorization uses embeddings clients — not chat Completer. return nil, ModeInternalLabel, false, nil } } // ResolveEmbedderForRole returns an OpenAI-compatible Embedder for the // vectorization role (platformsettings.AIRoleVectorization) with env fallback. // Non-vectorization roles return (nil, nil). Unset config returns (nil, nil). func (s *Service) ResolveEmbedderForRole(ctx context.Context, companyID uuid.UUID, role string) (processing.Embedder, error) { role = strings.TrimSpace(role) if role == "" { role = RoleVectorization } if role != RoleVectorization { return nil, nil } if s != nil && s.Roles != nil { ep, ok, err := s.Roles.LookupRole(ctx, companyID, role) if err != nil { return nil, err } if ok && strings.TrimSpace(ep.APIKey) != "" { model := strings.TrimSpace(ep.Model) if model == "" { model = "text-embedding-3-small" } rpm, retries := 0, 3 if s != nil { rpm = s.Env.ProcessingRPM retries = s.Env.ProcessingMaxRetries } client := processing.NewOpenAIClient(ep.APIKey, ep.BaseURL, model, rpm, retries) if s.HTTPClient != nil { client.HTTPClient = s.HTTPClient } return client, nil } } if s != nil && s.Platform != nil { return s.Platform.ResolveEmbedder(ctx) } return nil, nil } func (s *Service) resolvePlatformRoleCompleter(ctx context.Context, role string) (processing.Completer, string, bool, error) { if s == nil { return nil, ModeInternalLabel, false, nil } if s.Platform != nil { cfg, err := s.Platform.ResolveAIConfig(ctx, role) if err != nil { return nil, ModeInternalLabel, false, err } if strings.TrimSpace(cfg.APIKey) != "" { if role != RoleProcessing && !cfg.Enabled { return nil, ModeInternalLabel, false, nil } model := strings.TrimSpace(cfg.Model) if model == "" && role == RoleProcessing { model = strings.TrimSpace(s.Env.OpenAIModel) } if model != "" { return s.completerFromEndpoint(RoleEndpoint{ APIKey: cfg.APIKey, BaseURL: cfg.BaseURL, Model: model, UsingBYOK: false, ModeLabel: platformModeLabel(cfg.Source, cfg.BaseURL), }) } } return nil, ModeInternalLabel, false, nil } if role == RoleProcessing { key := strings.TrimSpace(s.Env.OpenAIAPIKey) model := strings.TrimSpace(s.Env.OpenAIModel) baseURL := strings.TrimSpace(s.Env.OpenAIBaseURL) if key != "" && model != "" { return s.completerFromEndpoint(RoleEndpoint{ APIKey: key, BaseURL: baseURL, Model: model, UsingBYOK: false, ModeLabel: platformModeLabel(platformsettings.SourceEnv, baseURL), }) } } return nil, ModeInternalLabel, false, nil } func (s *Service) completerFromEndpoint(ep RoleEndpoint) (processing.Completer, string, bool, error) { rpm := 0 retries := 0 if s != nil { rpm = s.Env.ProcessingRPM retries = s.Env.ProcessingMaxRetries } client := processing.NewOpenAIClient(ep.APIKey, ep.BaseURL, ep.Model, rpm, retries) label := strings.TrimSpace(ep.ModeLabel) if label == "" { if ep.UsingBYOK { label = ModeCustom } else { label = ModeInternalLabel } } client.ModeLabel = label if s != nil && s.HTTPClient != nil { client.HTTPClient = s.HTTPClient } return client, label, ep.UsingBYOK, nil }