- Updated .env.example to include new configuration options for AI gateway and WebUI secret key. - Modified Nginx configuration to support additional API routes for model management and migration. - Implemented new API endpoints for discovering organization-level LLM models and managing hosted mail services. - Enhanced AI gateway logic to support organization-specific model access and permissions. - Improved error handling and response structures in the AI and mail APIs. - Added integration tests for new features and updated existing tests for model access control.
235 lines
6.1 KiB
Go
235 lines
6.1 KiB
Go
package ai
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/ultisuite/ulti-backend/internal/llm"
|
|
)
|
|
|
|
const orgSettingsSingletonID = 1
|
|
|
|
type orgLLMPolicy struct {
|
|
DefaultProviderID string `json:"default_provider_id"`
|
|
Providers []llm.Provider `json:"providers"`
|
|
EnforceOrgProviders bool `json:"enforce_org_providers"`
|
|
AllowUserOverride bool `json:"allow_user_override"`
|
|
ContactDiscoveryModel string `json:"contact_discovery_model,omitempty"`
|
|
}
|
|
|
|
func LoadEffectiveLLMSettings(ctx context.Context, db *pgxpool.Pool, externalUserID string) (llm.Settings, error) {
|
|
if db == nil {
|
|
return llm.Settings{}, fmt.Errorf("database unavailable")
|
|
}
|
|
org, err := loadOrgLLMPolicy(ctx, db)
|
|
if err != nil {
|
|
return llm.Settings{}, err
|
|
}
|
|
user, err := loadUserLLMSettings(ctx, db, externalUserID)
|
|
if err != nil {
|
|
return llm.Settings{}, err
|
|
}
|
|
|
|
if org.EnforceOrgProviders && len(org.Providers) > 0 {
|
|
if !org.AllowUserOverride {
|
|
return orgToSettings(org), nil
|
|
}
|
|
merged := orgToSettings(org)
|
|
if strings.TrimSpace(user.DefaultProviderID) != "" {
|
|
merged.DefaultProviderID = user.DefaultProviderID
|
|
}
|
|
if strings.TrimSpace(user.ContactDiscoveryModel) != "" {
|
|
merged.ContactDiscoveryModel = user.ContactDiscoveryModel
|
|
}
|
|
if strings.TrimSpace(user.ContactDiscoveryProvider) != "" {
|
|
merged.ContactDiscoveryProvider = user.ContactDiscoveryProvider
|
|
}
|
|
return merged, nil
|
|
}
|
|
|
|
if len(user.Providers) > 0 {
|
|
return user, nil
|
|
}
|
|
if len(org.Providers) > 0 {
|
|
return orgToSettings(org), nil
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func orgToSettings(org orgLLMPolicy) llm.Settings {
|
|
return llm.Settings{
|
|
DefaultProviderID: org.DefaultProviderID,
|
|
Providers: org.Providers,
|
|
ContactDiscoveryModel: org.ContactDiscoveryModel,
|
|
ContactDiscoveryProvider: org.DefaultProviderID,
|
|
}
|
|
}
|
|
|
|
// LoadOrgLLMSettings returns org-level LLM provider configuration.
|
|
func LoadOrgLLMSettings(ctx context.Context, db *pgxpool.Pool) (llm.Settings, error) {
|
|
org, err := loadOrgLLMPolicy(ctx, db)
|
|
if err != nil {
|
|
return llm.Settings{}, err
|
|
}
|
|
return orgToSettings(org), nil
|
|
}
|
|
|
|
func loadOrgLLMPolicy(ctx context.Context, db *pgxpool.Pool) (orgLLMPolicy, error) {
|
|
var raw []byte
|
|
err := db.QueryRow(ctx, `
|
|
SELECT settings->'llm' FROM org_settings WHERE id = $1
|
|
`, orgSettingsSingletonID).Scan(&raw)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return orgLLMPolicy{}, nil
|
|
}
|
|
return orgLLMPolicy{}, err
|
|
}
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return orgLLMPolicy{}, nil
|
|
}
|
|
var out orgLLMPolicy
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return orgLLMPolicy{}, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func loadUserLLMSettings(ctx context.Context, db *pgxpool.Pool, externalUserID string) (llm.Settings, error) {
|
|
var raw []byte
|
|
err := db.QueryRow(ctx, `
|
|
SELECT COALESCE(s.preferences->'llm', '{}'::jsonb)
|
|
FROM users u
|
|
LEFT JOIN settings s ON s.user_id = u.id
|
|
WHERE u.external_id = $1
|
|
`, externalUserID).Scan(&raw)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return llm.Settings{}, nil
|
|
}
|
|
return llm.Settings{}, err
|
|
}
|
|
var out llm.Settings
|
|
if len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return llm.Settings{}, err
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func IsAssistantEnabled(ctx context.Context, db *pgxpool.Pool, deployEnabled bool) (AssistantPolicy, bool) {
|
|
policy, err := LoadAssistantPolicy(ctx, db)
|
|
if err != nil {
|
|
policy = AssistantPolicy{}
|
|
}
|
|
enabled := policy.Enabled || deployEnabled || isPluginEnabled(ctx, db, "ai-assistant")
|
|
return policy, enabled
|
|
}
|
|
|
|
func isPluginEnabled(ctx context.Context, db *pgxpool.Pool, pluginID string) bool {
|
|
if db == nil {
|
|
return false
|
|
}
|
|
var raw []byte
|
|
err := db.QueryRow(ctx, `
|
|
SELECT settings->'plugins' FROM org_settings WHERE id = $1
|
|
`, orgSettingsSingletonID).Scan(&raw)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return false
|
|
}
|
|
var plugins []struct {
|
|
ID string `json:"id"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
if err := json.Unmarshal(raw, &plugins); err != nil {
|
|
return false
|
|
}
|
|
for _, plugin := range plugins {
|
|
if plugin.ID == pluginID {
|
|
return plugin.Enabled
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func LoadAssistantPolicy(ctx context.Context, db *pgxpool.Pool) (AssistantPolicy, error) {
|
|
defaults := AssistantPolicy{
|
|
Enabled: false,
|
|
PublicPath: "/ai",
|
|
EmbedDefaultTemporary: true,
|
|
EnabledTools: []string{"mail", "drive", "contacts", "search"},
|
|
ChatSyncEnabled: true,
|
|
ChatNCPath: "/.ultimail/ai/chats",
|
|
}
|
|
if db == nil {
|
|
return defaults, nil
|
|
}
|
|
var raw []byte
|
|
err := db.QueryRow(ctx, `
|
|
SELECT settings->'ai_assistant' FROM org_settings WHERE id = $1
|
|
`, orgSettingsSingletonID).Scan(&raw)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return defaults, nil
|
|
}
|
|
return defaults, err
|
|
}
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return defaults, nil
|
|
}
|
|
var stored AssistantPolicy
|
|
if err := json.Unmarshal(raw, &stored); err != nil {
|
|
return defaults, err
|
|
}
|
|
if stored.PublicPath == "" {
|
|
stored.PublicPath = defaults.PublicPath
|
|
}
|
|
if stored.ChatNCPath == "" {
|
|
stored.ChatNCPath = defaults.ChatNCPath
|
|
}
|
|
if len(stored.EnabledTools) == 0 {
|
|
stored.EnabledTools = defaults.EnabledTools
|
|
}
|
|
return stored, nil
|
|
}
|
|
|
|
func LoadQuotaLimits(ctx context.Context, db *pgxpool.Pool) (QuotaLimits, error) {
|
|
defaults := QuotaLimits{RequestsPerDay: 100, TokensPerMonth: 500_000}
|
|
if db == nil {
|
|
return defaults, nil
|
|
}
|
|
var raw []byte
|
|
err := db.QueryRow(ctx, `
|
|
SELECT settings->'usage_quotas' FROM org_settings WHERE id = $1
|
|
`, orgSettingsSingletonID).Scan(&raw)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return defaults, nil
|
|
}
|
|
return defaults, err
|
|
}
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return defaults, nil
|
|
}
|
|
var stored map[string]any
|
|
if err := json.Unmarshal(raw, &stored); err != nil {
|
|
return defaults, err
|
|
}
|
|
if v, ok := stored["llm_requests_per_day"].(float64); ok && v > 0 {
|
|
defaults.RequestsPerDay = int(v)
|
|
}
|
|
if v, ok := stored["llm_tokens_per_month"].(float64); ok && v > 0 {
|
|
defaults.TokensPerMonth = int64(v)
|
|
}
|
|
return defaults, nil
|
|
}
|