Files
descrybe/apps/api/internal/aiprovider/roles.go
T

181 lines
5.5 KiB
Go
Raw Normal View History

package aiprovider
import (
"context"
"strings"
"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) != "" {
return s.completerFromEndpoint(ep)
}
}
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)
}
return s.resolvePlatformRoleCompleter(ctx, RoleProcessing)
case RoleDocsAPI, RoleSupport:
return s.resolvePlatformRoleCompleter(ctx, role)
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: ModeInternalLabel,
})
}
}
return nil, ModeInternalLabel, false, nil
}
if role == RoleProcessing {
key := strings.TrimSpace(s.Env.OpenAIAPIKey)
model := strings.TrimSpace(s.Env.OpenAIModel)
if key != "" && model != "" {
return s.completerFromEndpoint(RoleEndpoint{
APIKey: key,
BaseURL: strings.TrimSpace(s.Env.OpenAIBaseURL),
Model: model,
UsingBYOK: false,
ModeLabel: ModeInternalLabel,
})
}
}
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
}