183 lines
5.7 KiB
Go
183 lines
5.7 KiB
Go
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) != "" {
|
|
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: 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
|
|
}
|