feat(copilot): complete runtime provider configuration
This commit is contained in:
@@ -2,8 +2,6 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -565,16 +563,18 @@ func Bootstrap(env string) (*App, error) {
|
||||
// through the settings page. The stable manager reference is injected into
|
||||
// services and hot-swaps its underlying provider after page updates.
|
||||
copilotProviderManager := llm.NewProviderManager()
|
||||
encryptionKey := sha256.Sum256([]byte(cfg.JWT.Secret))
|
||||
copilotEncryptor, err := security.NewEncryptor(security.EncryptionConfig{
|
||||
AESKey: base64.StdEncoding.EncodeToString(encryptionKey[:]),
|
||||
KeyVersion: 1,
|
||||
Enabled: true,
|
||||
copilotProviderManager.SetAccountModelResolver(func(ctx context.Context, accountID uint, feature string) (string, error) {
|
||||
account, err := accountRepo.FindByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
models := map[string]string{}
|
||||
if len(account.CaptainModels) > 0 {
|
||||
_ = json.Unmarshal(account.CaptainModels, &models)
|
||||
}
|
||||
return strings.TrimSpace(models[feature]), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize Copilot credential encryption: %w", err)
|
||||
}
|
||||
copilotConfigService := service.NewCopilotConfigService(installationConfigRepo, copilotEncryptor, copilotProviderManager)
|
||||
copilotConfigService := service.NewCopilotConfigService(installationConfigRepo, copilotProviderManager)
|
||||
if err := copilotConfigService.Initialize(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize Copilot provider configuration: %w", err)
|
||||
}
|
||||
@@ -848,6 +848,7 @@ func Bootstrap(env string) (*App, error) {
|
||||
CaptainCustomTool: v1.NewCaptainCustomToolHandler(captainCustomToolService),
|
||||
CaptainTask: v1.NewCaptainTaskHandler(captainTaskService),
|
||||
CaptainPreference: v1.NewCaptainPreferenceHandler(captainPreferenceService),
|
||||
CopilotConfig: v1.NewCopilotConfigHandler(copilotConfigService, captainPreferenceService),
|
||||
CaptainTaskExtended: v1.NewCaptainTaskExtendedHandler(captainTaskExtendedService),
|
||||
CaptainAssistantResponse: v1.NewCaptainAssistantResponseHandler(captainAssistantResponseService),
|
||||
CaptainBulkAction: v1.NewCaptainBulkActionHandler(captainBulkActionService),
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/gochat/gochat/internal/llm"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/security"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/datatypes"
|
||||
@@ -40,10 +39,8 @@ func newCaptainPreferenceFixture(t *testing.T) *captainPreferenceFixture {
|
||||
prefRepo := repository.NewCaptainPreferenceRepo(db)
|
||||
accountRepo := repository.NewAccountRepo(db)
|
||||
installationConfigRepo := repository.NewInstallationConfigRepo(db)
|
||||
encryptor, err := security.NewEncryptor(security.DefaultEncryptionConfig())
|
||||
require.NoError(t, err)
|
||||
manager := llm.NewProviderManager()
|
||||
copilotConfigService := service.NewCopilotConfigService(installationConfigRepo, encryptor, manager)
|
||||
copilotConfigService := service.NewCopilotConfigService(installationConfigRepo, manager)
|
||||
preferenceService := service.NewCaptainPreferenceService(prefRepo, accountRepo)
|
||||
preferenceService.SetCopilotConfigService(copilotConfigService)
|
||||
handler := NewCaptainPreferenceHandler(preferenceService)
|
||||
@@ -105,13 +102,13 @@ func TestCaptainPreferencesGetReturnsRawChatwootConfig(t *testing.T) {
|
||||
|
||||
features := payload["features"].(map[string]any)
|
||||
editor := features["editor"].(map[string]any)
|
||||
require.Equal(t, false, editor["enabled"])
|
||||
require.Equal(t, "gpt-4.1-mini", editor["default"])
|
||||
require.Equal(t, "gpt-4.1-mini", editor["selected"])
|
||||
require.Equal(t, true, editor["enabled"])
|
||||
require.Equal(t, "gpt-4o-mini", editor["default"])
|
||||
require.Equal(t, "gpt-4o-mini", editor["selected"])
|
||||
require.NotEmpty(t, editor["models"].([]any))
|
||||
}
|
||||
|
||||
func TestCaptainPreferencesUpdateProviderConfiguration(t *testing.T) {
|
||||
func TestCaptainPreferencesCannotUpdatePlatformProviderConfiguration(t *testing.T) {
|
||||
f := newCaptainPreferenceFixture(t)
|
||||
|
||||
w := f.request(http.MethodPut, f.path(""), map[string]any{
|
||||
@@ -125,10 +122,7 @@ func TestCaptainPreferencesUpdateProviderConfiguration(t *testing.T) {
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
payload := decodeCaptainPreferencePayload(t, w)
|
||||
providerConfig := payload["provider_config"].(map[string]any)
|
||||
require.Equal(t, "openai_compatible", providerConfig["provider"])
|
||||
require.Equal(t, "https://llm.example.com/v1", providerConfig["base_url"])
|
||||
require.Equal(t, "example-model", providerConfig["model"])
|
||||
require.Equal(t, true, providerConfig["api_key_configured"])
|
||||
require.Equal(t, false, providerConfig["configured"])
|
||||
require.NotContains(t, w.Body.String(), "secret-key-value")
|
||||
}
|
||||
|
||||
@@ -163,15 +157,16 @@ func TestCaptainPreferencesUpdateMergesAccountModelsAndFeatures(t *testing.T) {
|
||||
require.False(t, featureValues["assistant"])
|
||||
}
|
||||
|
||||
func TestCaptainPreferencesUpdateRejectsNonAdminAndInvalidModel(t *testing.T) {
|
||||
func TestCaptainPreferencesUpdateRejectsNonAdminAndAcceptsCustomModel(t *testing.T) {
|
||||
f := newCaptainPreferenceFixture(t)
|
||||
|
||||
w := f.request(http.MethodPut, f.path("/as-agent"), map[string]any{"captain_models": map[string]any{"editor": "gpt-4.1"}})
|
||||
require.Equal(t, http.StatusUnauthorized, w.Code, w.Body.String())
|
||||
|
||||
w = f.request(http.MethodPut, f.path(""), map[string]any{"captain_models": map[string]any{"editor": "not-a-model"}})
|
||||
require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String())
|
||||
require.Contains(t, decodeCaptainPreferencePayload(t, w)["error"], "not a valid model")
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
features := decodeCaptainPreferencePayload(t, w)["features"].(map[string]any)
|
||||
require.Equal(t, "not-a-model", features["editor"].(map[string]any)["selected"])
|
||||
}
|
||||
|
||||
func TestCaptainPreferencesInvalidAccountID(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/llm"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
"github.com/gochat/gochat/pkg/response"
|
||||
)
|
||||
|
||||
// CopilotConfigHandler exposes typed platform and account configuration APIs.
|
||||
// Platform mutations are registered behind middleware.SuperAdmin; account
|
||||
// configuration remains administrator scoped.
|
||||
type CopilotConfigHandler struct {
|
||||
platform *service.CopilotConfigService
|
||||
account *service.CaptainPreferenceService
|
||||
}
|
||||
|
||||
func NewCopilotConfigHandler(platform *service.CopilotConfigService, account *service.CaptainPreferenceService) *CopilotConfigHandler {
|
||||
return &CopilotConfigHandler{platform: platform, account: account}
|
||||
}
|
||||
|
||||
func (h *CopilotConfigHandler) PlatformGet(c *gin.Context) {
|
||||
payload, err := h.platform.Get(c.Request.Context())
|
||||
if err != nil {
|
||||
handleServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func (h *CopilotConfigHandler) PlatformUpdate(c *gin.Context) {
|
||||
var input service.CopilotProviderConfigInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
payload, err := h.platform.Update(c.Request.Context(), input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func (h *CopilotConfigHandler) PlatformTest(c *gin.Context) {
|
||||
var input service.CopilotProviderConfigInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
payload, err := h.platform.Test(c.Request.Context(), input)
|
||||
if err != nil {
|
||||
status := http.StatusUnprocessableEntity
|
||||
if errors.Is(err, llm.ErrProviderNotConfigured) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func (h *CopilotConfigHandler) AccountGet(c *gin.Context) {
|
||||
if !captainPreferencesCanUpdate(c) {
|
||||
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "administrator role required")
|
||||
return
|
||||
}
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
payload, err := h.account.GetConfig(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
handleServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func (h *CopilotConfigHandler) AccountUpdate(c *gin.Context) {
|
||||
if !captainPreferencesCanUpdate(c) {
|
||||
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "administrator role required")
|
||||
return
|
||||
}
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
var input service.UpdateCaptainConfigRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
payload, err := h.account.UpdateConfig(c.Request.Context(), accountID, &input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
@@ -27,12 +27,13 @@ type AnthropicProvider struct {
|
||||
|
||||
// AnthropicProviderConfig holds configuration for creating an AnthropicProvider.
|
||||
type AnthropicProviderConfig struct {
|
||||
APIKey string
|
||||
BaseURL string // defaults to "https://api.anthropic.com"
|
||||
Model string // defaults to "claude-sonnet-4-20250514"
|
||||
EmbedModel string // not used (Anthropic has no embeddings API); kept for interface compat
|
||||
MaxRetries int // defaults to 3
|
||||
Timeout int // HTTP timeout in seconds, defaults to 60
|
||||
APIKey string
|
||||
BaseURL string // defaults to "https://api.anthropic.com"
|
||||
Model string // defaults to "claude-sonnet-4-20250514"
|
||||
EmbedModel string // not used (Anthropic has no embeddings API); kept for interface compat
|
||||
MaxRetries int // defaults to 3
|
||||
MaxRetriesSet bool // preserves an explicit zero-retry setting
|
||||
Timeout int // HTTP timeout in seconds, defaults to 60
|
||||
}
|
||||
|
||||
// NewAnthropicProvider creates a new AnthropicProvider.
|
||||
@@ -44,7 +45,7 @@ func NewAnthropicProvider(cfg AnthropicProviderConfig) *AnthropicProvider {
|
||||
if cfg.Model == "" {
|
||||
cfg.Model = "claude-sonnet-4-20250514"
|
||||
}
|
||||
if cfg.MaxRetries == 0 {
|
||||
if !cfg.MaxRetriesSet && cfg.MaxRetries == 0 {
|
||||
cfg.MaxRetries = 3
|
||||
}
|
||||
if cfg.Timeout == 0 {
|
||||
@@ -91,9 +92,9 @@ type anthropicResponse struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
} `json:"content"`
|
||||
Model string `json:"model"`
|
||||
Model string `json:"model"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
Usage struct {
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
|
||||
@@ -27,12 +27,13 @@ type OpenAIProvider struct {
|
||||
|
||||
// OpenAIProviderConfig holds configuration for creating an OpenAIProvider.
|
||||
type OpenAIProviderConfig struct {
|
||||
APIKey string
|
||||
BaseURL string // defaults to "https://api.openai.com/v1"
|
||||
Model string // defaults to "gpt-4"
|
||||
EmbedModel string // defaults to "text-embedding-3-small"
|
||||
MaxRetries int // defaults to 3
|
||||
Timeout int // HTTP timeout in seconds, defaults to 60
|
||||
APIKey string
|
||||
BaseURL string // defaults to "https://api.openai.com/v1"
|
||||
Model string // defaults to "gpt-4"
|
||||
EmbedModel string // defaults to "text-embedding-3-small"
|
||||
MaxRetries int // defaults to 3
|
||||
MaxRetriesSet bool // preserves an explicit zero-retry setting
|
||||
Timeout int // HTTP timeout in seconds, defaults to 60
|
||||
}
|
||||
|
||||
// NewOpenAIProvider creates a new OpenAIProvider with the given configuration.
|
||||
@@ -49,7 +50,7 @@ func NewOpenAIProvider(cfg OpenAIProviderConfig) *OpenAIProvider {
|
||||
if cfg.EmbedModel == "" {
|
||||
cfg.EmbedModel = "text-embedding-3-small"
|
||||
}
|
||||
if cfg.MaxRetries == 0 {
|
||||
if !cfg.MaxRetriesSet && cfg.MaxRetries == 0 {
|
||||
cfg.MaxRetries = 3
|
||||
}
|
||||
if cfg.Timeout == 0 {
|
||||
@@ -466,4 +467,4 @@ func ParseFloatEmbedding(raw []interface{}) []float64 {
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,123 +10,278 @@ import (
|
||||
|
||||
var ErrProviderNotConfigured = errors.New("Copilot provider is not configured")
|
||||
|
||||
const (
|
||||
EmbeddingModeReuseChat = "reuse_chat_credentials"
|
||||
EmbeddingModeSeparate = "separate"
|
||||
)
|
||||
|
||||
// RuntimeProviderConfig is the database-backed Copilot provider configuration.
|
||||
// It intentionally contains no enabled flag: Copilot is always available once
|
||||
// an administrator supplies provider credentials through the settings page.
|
||||
// It intentionally contains no enabled flag: Copilot is globally available and
|
||||
// reports ErrProviderNotConfigured until an administrator saves credentials.
|
||||
type RuntimeProviderConfig struct {
|
||||
Provider string
|
||||
BaseURL string
|
||||
APIKey string
|
||||
Model string
|
||||
EmbeddingModel string
|
||||
ChatProvider string
|
||||
ChatBaseURL string
|
||||
ChatAPIKey string
|
||||
ChatModel string
|
||||
|
||||
EmbeddingMode string
|
||||
EmbeddingProvider string
|
||||
EmbeddingBaseURL string
|
||||
EmbeddingAPIKey string
|
||||
EmbeddingModel string
|
||||
EmbeddingDimensions int
|
||||
|
||||
Temperature float64
|
||||
MaxTokens int
|
||||
TimeoutSeconds int
|
||||
MaxRetries int
|
||||
}
|
||||
|
||||
// AccountModelResolver returns an account-specific model for a feature. An
|
||||
// empty model means the platform ChatModel should be used.
|
||||
type AccountModelResolver func(ctx context.Context, accountID uint, feature string) (string, error)
|
||||
|
||||
type accountFeatureContextKey struct{}
|
||||
|
||||
type accountFeatureContext struct {
|
||||
AccountID uint
|
||||
Feature string
|
||||
}
|
||||
|
||||
// WithAccountFeature annotates an LLM request so ProviderManager can resolve
|
||||
// the account-specific model selected on the Copilot settings page.
|
||||
func WithAccountFeature(ctx context.Context, accountID uint, feature string) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithValue(ctx, accountFeatureContextKey{}, accountFeatureContext{
|
||||
AccountID: accountID,
|
||||
Feature: strings.TrimSpace(feature),
|
||||
})
|
||||
}
|
||||
|
||||
type providerSnapshot struct {
|
||||
chat Provider
|
||||
embedding Provider
|
||||
config RuntimeProviderConfig
|
||||
}
|
||||
|
||||
// ProviderManager is a hot-swappable Provider implementation. Services keep a
|
||||
// stable reference to the manager while settings updates atomically replace the
|
||||
// provider used by new requests.
|
||||
// providers and defaults used by new requests.
|
||||
type ProviderManager struct {
|
||||
mu sync.RWMutex
|
||||
provider Provider
|
||||
config RuntimeProviderConfig
|
||||
mu sync.RWMutex
|
||||
snapshot *providerSnapshot
|
||||
modelResolver AccountModelResolver
|
||||
}
|
||||
|
||||
func NewProviderManager() *ProviderManager {
|
||||
return &ProviderManager{}
|
||||
}
|
||||
|
||||
func (m *ProviderManager) Configure(cfg RuntimeProviderConfig) error {
|
||||
cfg.Provider = strings.TrimSpace(strings.ToLower(cfg.Provider))
|
||||
cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
|
||||
cfg.APIKey = strings.TrimSpace(cfg.APIKey)
|
||||
cfg.Model = strings.TrimSpace(cfg.Model)
|
||||
cfg.EmbeddingModel = strings.TrimSpace(cfg.EmbeddingModel)
|
||||
|
||||
if cfg.Provider == "" || cfg.APIKey == "" || cfg.Model == "" {
|
||||
return ErrProviderNotConfigured
|
||||
}
|
||||
if cfg.EmbeddingModel == "" {
|
||||
cfg.EmbeddingModel = "text-embedding-3-small"
|
||||
}
|
||||
|
||||
var provider Provider
|
||||
switch cfg.Provider {
|
||||
case "openai", "openai_compatible":
|
||||
provider = NewOpenAIProvider(OpenAIProviderConfig{
|
||||
APIKey: cfg.APIKey,
|
||||
BaseURL: cfg.BaseURL,
|
||||
Model: cfg.Model,
|
||||
EmbedModel: cfg.EmbeddingModel,
|
||||
})
|
||||
case "anthropic":
|
||||
provider = NewAnthropicProvider(AnthropicProviderConfig{
|
||||
APIKey: cfg.APIKey,
|
||||
BaseURL: cfg.BaseURL,
|
||||
Model: cfg.Model,
|
||||
})
|
||||
default:
|
||||
return fmt.Errorf("unsupported Copilot provider: %s", cfg.Provider)
|
||||
}
|
||||
func NewProviderManager() *ProviderManager { return &ProviderManager{} }
|
||||
|
||||
func (m *ProviderManager) SetAccountModelResolver(resolver AccountModelResolver) {
|
||||
m.mu.Lock()
|
||||
m.provider = provider
|
||||
m.config = cfg
|
||||
m.modelResolver = resolver
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// Configure validates and constructs all providers before atomically swapping
|
||||
// the active snapshot. A failed configuration never damages the working one.
|
||||
func (m *ProviderManager) Configure(cfg RuntimeProviderConfig) error {
|
||||
snapshot, err := buildProviderSnapshot(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.snapshot = snapshot
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildProviderSnapshot(cfg RuntimeProviderConfig) (*providerSnapshot, error) {
|
||||
cfg = normalizeRuntimeProviderConfig(cfg)
|
||||
if cfg.ChatProvider == "" || cfg.ChatAPIKey == "" || cfg.ChatModel == "" {
|
||||
return nil, ErrProviderNotConfigured
|
||||
}
|
||||
|
||||
chat, err := buildChatProvider(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
embedding, err := buildEmbeddingProvider(cfg, chat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &providerSnapshot{chat: chat, embedding: embedding, config: cfg}, nil
|
||||
}
|
||||
|
||||
func normalizeRuntimeProviderConfig(cfg RuntimeProviderConfig) RuntimeProviderConfig {
|
||||
cfg.ChatProvider = strings.ToLower(strings.TrimSpace(cfg.ChatProvider))
|
||||
cfg.ChatBaseURL = strings.TrimRight(strings.TrimSpace(cfg.ChatBaseURL), "/")
|
||||
cfg.ChatAPIKey = strings.TrimSpace(cfg.ChatAPIKey)
|
||||
cfg.ChatModel = strings.TrimSpace(cfg.ChatModel)
|
||||
cfg.EmbeddingMode = strings.ToLower(strings.TrimSpace(cfg.EmbeddingMode))
|
||||
cfg.EmbeddingProvider = strings.ToLower(strings.TrimSpace(cfg.EmbeddingProvider))
|
||||
cfg.EmbeddingBaseURL = strings.TrimRight(strings.TrimSpace(cfg.EmbeddingBaseURL), "/")
|
||||
cfg.EmbeddingAPIKey = strings.TrimSpace(cfg.EmbeddingAPIKey)
|
||||
cfg.EmbeddingModel = strings.TrimSpace(cfg.EmbeddingModel)
|
||||
|
||||
if cfg.EmbeddingMode == "" {
|
||||
cfg.EmbeddingMode = EmbeddingModeReuseChat
|
||||
}
|
||||
if cfg.EmbeddingProvider == "" {
|
||||
cfg.EmbeddingProvider = "openai"
|
||||
}
|
||||
if cfg.EmbeddingModel == "" {
|
||||
cfg.EmbeddingModel = "text-embedding-3-small"
|
||||
}
|
||||
if cfg.EmbeddingDimensions == 0 {
|
||||
cfg.EmbeddingDimensions = 1536
|
||||
}
|
||||
if cfg.MaxTokens == 0 {
|
||||
cfg.MaxTokens = 1024
|
||||
}
|
||||
if cfg.Temperature < 0 {
|
||||
cfg.Temperature = 0.7
|
||||
}
|
||||
if cfg.TimeoutSeconds == 0 {
|
||||
cfg.TimeoutSeconds = 60
|
||||
}
|
||||
if cfg.MaxRetries < 0 {
|
||||
cfg.MaxRetries = 3
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func buildChatProvider(cfg RuntimeProviderConfig) (Provider, error) {
|
||||
switch cfg.ChatProvider {
|
||||
case "openai", "openai_compatible":
|
||||
if cfg.ChatProvider == "openai_compatible" && cfg.ChatBaseURL == "" {
|
||||
return nil, fmt.Errorf("Copilot chat base URL is required for openai_compatible")
|
||||
}
|
||||
return NewOpenAIProvider(OpenAIProviderConfig{
|
||||
APIKey: cfg.ChatAPIKey,
|
||||
BaseURL: cfg.ChatBaseURL,
|
||||
Model: cfg.ChatModel,
|
||||
EmbedModel: cfg.EmbeddingModel,
|
||||
MaxRetries: cfg.MaxRetries,
|
||||
MaxRetriesSet: true,
|
||||
Timeout: cfg.TimeoutSeconds,
|
||||
}), nil
|
||||
case "anthropic":
|
||||
return NewAnthropicProvider(AnthropicProviderConfig{
|
||||
APIKey: cfg.ChatAPIKey,
|
||||
BaseURL: cfg.ChatBaseURL,
|
||||
Model: cfg.ChatModel,
|
||||
MaxRetries: cfg.MaxRetries,
|
||||
MaxRetriesSet: true,
|
||||
Timeout: cfg.TimeoutSeconds,
|
||||
}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported Copilot chat provider: %s", cfg.ChatProvider)
|
||||
}
|
||||
}
|
||||
|
||||
func buildEmbeddingProvider(cfg RuntimeProviderConfig, chat Provider) (Provider, error) {
|
||||
switch cfg.EmbeddingMode {
|
||||
case EmbeddingModeReuseChat:
|
||||
if cfg.ChatProvider == "anthropic" {
|
||||
return nil, fmt.Errorf("Anthropic chat requires a separate OpenAI-compatible embedding provider")
|
||||
}
|
||||
return chat, nil
|
||||
case EmbeddingModeSeparate:
|
||||
if cfg.EmbeddingAPIKey == "" {
|
||||
return nil, fmt.Errorf("Copilot embedding API key is required")
|
||||
}
|
||||
if cfg.EmbeddingProvider != "openai" && cfg.EmbeddingProvider != "openai_compatible" {
|
||||
return nil, fmt.Errorf("unsupported Copilot embedding provider: %s", cfg.EmbeddingProvider)
|
||||
}
|
||||
if cfg.EmbeddingProvider == "openai_compatible" && cfg.EmbeddingBaseURL == "" {
|
||||
return nil, fmt.Errorf("Copilot embedding base URL is required for openai_compatible")
|
||||
}
|
||||
return NewOpenAIProvider(OpenAIProviderConfig{
|
||||
APIKey: cfg.EmbeddingAPIKey,
|
||||
BaseURL: cfg.EmbeddingBaseURL,
|
||||
Model: cfg.ChatModel,
|
||||
EmbedModel: cfg.EmbeddingModel,
|
||||
MaxRetries: cfg.MaxRetries,
|
||||
MaxRetriesSet: true,
|
||||
Timeout: cfg.TimeoutSeconds,
|
||||
}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported Copilot embedding mode: %s", cfg.EmbeddingMode)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ProviderManager) Clear() {
|
||||
m.mu.Lock()
|
||||
m.provider = nil
|
||||
m.config = RuntimeProviderConfig{}
|
||||
m.snapshot = nil
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *ProviderManager) Snapshot() (RuntimeProviderConfig, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.config, m.provider != nil
|
||||
if m.snapshot == nil {
|
||||
return RuntimeProviderConfig{}, false
|
||||
}
|
||||
return m.snapshot.config, true
|
||||
}
|
||||
|
||||
func (m *ProviderManager) current() (Provider, RuntimeProviderConfig, error) {
|
||||
func (m *ProviderManager) current() (*providerSnapshot, AccountModelResolver, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.provider == nil {
|
||||
return nil, RuntimeProviderConfig{}, ErrProviderNotConfigured
|
||||
if m.snapshot == nil {
|
||||
return nil, m.modelResolver, ErrProviderNotConfigured
|
||||
}
|
||||
return m.provider, m.config, nil
|
||||
return m.snapshot, m.modelResolver, nil
|
||||
}
|
||||
|
||||
func resolveFeatureModel(ctx context.Context, resolver AccountModelResolver, fallback string) string {
|
||||
if resolver == nil || ctx == nil {
|
||||
return fallback
|
||||
}
|
||||
featureCtx, ok := ctx.Value(accountFeatureContextKey{}).(accountFeatureContext)
|
||||
if !ok || featureCtx.AccountID == 0 || featureCtx.Feature == "" {
|
||||
return fallback
|
||||
}
|
||||
model, err := resolver(ctx, featureCtx.AccountID, featureCtx.Feature)
|
||||
if err != nil || strings.TrimSpace(model) == "" {
|
||||
return fallback
|
||||
}
|
||||
return strings.TrimSpace(model)
|
||||
}
|
||||
|
||||
func applyRuntimeChatConfig(ctx context.Context, req ChatRequest, cfg RuntimeProviderConfig, resolver AccountModelResolver) ChatRequest {
|
||||
req.Model = resolveFeatureModel(ctx, resolver, cfg.ChatModel)
|
||||
req.Temperature = cfg.Temperature
|
||||
req.MaxTokens = cfg.MaxTokens
|
||||
return req
|
||||
}
|
||||
|
||||
func (m *ProviderManager) ChatCompletion(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
|
||||
provider, cfg, err := m.current()
|
||||
snapshot, resolver, err := m.current()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(req.Model) == "" {
|
||||
req.Model = cfg.Model
|
||||
}
|
||||
return provider.ChatCompletion(ctx, req)
|
||||
req = applyRuntimeChatConfig(ctx, req, snapshot.config, resolver)
|
||||
return snapshot.chat.ChatCompletion(ctx, req)
|
||||
}
|
||||
|
||||
func (m *ProviderManager) CreateEmbedding(ctx context.Context, req EmbeddingRequest) (*EmbeddingResponse, error) {
|
||||
provider, cfg, err := m.current()
|
||||
snapshot, _, err := m.current()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(req.Model) == "" {
|
||||
req.Model = cfg.EmbeddingModel
|
||||
}
|
||||
return provider.CreateEmbedding(ctx, req)
|
||||
req.Model = snapshot.config.EmbeddingModel
|
||||
return snapshot.embedding.CreateEmbedding(ctx, req)
|
||||
}
|
||||
|
||||
func (m *ProviderManager) ChatCompletionStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk) error) error {
|
||||
provider, cfg, err := m.current()
|
||||
snapshot, resolver, err := m.current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(req.Model) == "" {
|
||||
req.Model = cfg.Model
|
||||
}
|
||||
return provider.ChatCompletionStream(ctx, req, onChunk)
|
||||
req = applyRuntimeChatConfig(ctx, req, snapshot.config, resolver)
|
||||
return snapshot.chat.ChatCompletionStream(ctx, req, onChunk)
|
||||
}
|
||||
|
||||
var _ Provider = (*ProviderManager)(nil)
|
||||
|
||||
@@ -20,9 +20,10 @@ func TestProviderManagerRequiresPageConfiguration(t *testing.T) {
|
||||
func TestProviderManagerClearRemovesActiveProvider(t *testing.T) {
|
||||
manager := NewProviderManager()
|
||||
require.NoError(t, manager.Configure(RuntimeProviderConfig{
|
||||
Provider: "openai",
|
||||
APIKey: "test-key",
|
||||
Model: "test-model",
|
||||
ChatProvider: "openai",
|
||||
ChatAPIKey: "test-key",
|
||||
ChatModel: "test-model",
|
||||
EmbeddingMode: EmbeddingModeReuseChat,
|
||||
}))
|
||||
manager.Clear()
|
||||
_, configured := manager.Snapshot()
|
||||
@@ -44,10 +45,11 @@ func TestProviderManagerUsesConfiguredDefaultModel(t *testing.T) {
|
||||
|
||||
manager := NewProviderManager()
|
||||
require.NoError(t, manager.Configure(RuntimeProviderConfig{
|
||||
Provider: "openai_compatible",
|
||||
BaseURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
Model: "page-model",
|
||||
ChatProvider: "openai_compatible",
|
||||
ChatBaseURL: server.URL,
|
||||
ChatAPIKey: "test-key",
|
||||
ChatModel: "page-model",
|
||||
EmbeddingMode: EmbeddingModeReuseChat,
|
||||
}))
|
||||
|
||||
resp, err := manager.ChatCompletion(context.Background(), ChatRequest{
|
||||
|
||||
@@ -77,6 +77,26 @@ func (r *InstallationConfigRepo) UpsertByName(ctx context.Context, name, value s
|
||||
return r.db.WithContext(ctx).Create(&model.InstallationConfig{Name: name, Value: value}).Error
|
||||
}
|
||||
|
||||
// UpsertValues atomically updates a set of stable installation config keys.
|
||||
func (r *InstallationConfigRepo) UpsertValues(ctx context.Context, values map[string]string) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for name, value := range values {
|
||||
result := tx.Model(&model.InstallationConfig{}).
|
||||
Where("name = ?", name).
|
||||
Update("value", value)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
if err := tx.Create(&model.InstallationConfig{Name: name, Value: value}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Delete removes an InstallationConfig record by primary key (soft-delete via Base.DeletedAt).
|
||||
func (r *InstallationConfigRepo) Delete(ctx context.Context, id uint) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.InstallationConfig{}, id).Error
|
||||
|
||||
@@ -58,6 +58,7 @@ type Handlers struct {
|
||||
CaptainCustomTool *v1.CaptainCustomToolHandler
|
||||
CaptainTask *v1.CaptainTaskHandler
|
||||
CaptainPreference *v1.CaptainPreferenceHandler
|
||||
CopilotConfig *v1.CopilotConfigHandler
|
||||
CaptainTaskExtended *v1.CaptainTaskExtendedHandler
|
||||
CaptainAssistantResponse *v1.CaptainAssistantResponseHandler
|
||||
CaptainBulkAction *v1.CaptainBulkActionHandler
|
||||
@@ -1348,6 +1349,14 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
|
||||
// Captain AI routes (ref: Chatwoot namespace :captain)
|
||||
// P10 (M10): Captain Assistant + Copilot features
|
||||
copilotConfig := accountScoped.Group("/copilot/config")
|
||||
{
|
||||
copilotConfig.GET("", h.CopilotConfig.AccountGet)
|
||||
copilotConfig.GET("/", h.CopilotConfig.AccountGet)
|
||||
copilotConfig.PUT("", h.CopilotConfig.AccountUpdate)
|
||||
copilotConfig.PUT("/", h.CopilotConfig.AccountUpdate)
|
||||
}
|
||||
|
||||
captain := accountScoped.Group("/captain")
|
||||
{
|
||||
// Assistant CRUD
|
||||
@@ -2035,6 +2044,14 @@ func registerV2Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
// registerPlatformRoutes maps super-admin platform routes.
|
||||
// Reference: Chatwoot namespace :platform_app (super_admin only)
|
||||
func registerPlatformRoutes(g *gin.RouterGroup, h *Handlers) {
|
||||
copilot := g.Group("/copilot")
|
||||
copilot.Use(middleware.SuperAdmin())
|
||||
{
|
||||
copilot.GET("/config", h.CopilotConfig.PlatformGet)
|
||||
copilot.PUT("/config", h.CopilotConfig.PlatformUpdate)
|
||||
copilot.POST("/config/test", h.CopilotConfig.PlatformTest)
|
||||
}
|
||||
|
||||
// PlatformApp CRUD
|
||||
g.GET("/apps", h.PlatformApp.List)
|
||||
g.POST("/apps", h.PlatformApp.Create)
|
||||
|
||||
@@ -290,6 +290,7 @@ func (s *CaptainAssistantService) GenerateResponse(ctx context.Context, assistan
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("assistant not found: %w", err)
|
||||
}
|
||||
ctx = llm.WithAccountFeature(ctx, assistant.AccountID, "assistant")
|
||||
|
||||
// Build system prompt from assistant config and response guidelines
|
||||
cfg, _ := assistant.GetConfig()
|
||||
@@ -325,6 +326,7 @@ const captainPlaygroundFallbackMessage = "Captain assistant response generation
|
||||
|
||||
// GeneratePlaygroundResponse follows Chatwoot Captain assistant playground behavior.
|
||||
func (s *CaptainAssistantService) GeneratePlaygroundResponse(ctx context.Context, accountID, assistantID uint, req PlaygroundRequest) (map[string]any, error) {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "assistant")
|
||||
assistant, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("assistant not found: %w", err)
|
||||
|
||||
@@ -63,9 +63,9 @@ type UpdatePreferenceRequest struct {
|
||||
|
||||
// UpdateCaptainConfigRequest matches Chatwoot's Captain::PreferencesController params.
|
||||
type UpdateCaptainConfigRequest struct {
|
||||
CaptainModels map[string]string `json:"captain_models"`
|
||||
CaptainFeatures map[string]bool `json:"captain_features"`
|
||||
ProviderConfig *CopilotProviderConfigInput `json:"provider_config,omitempty"`
|
||||
CaptainModels map[string]string `json:"captain_models"`
|
||||
CaptainFeatures map[string]bool `json:"captain_features"`
|
||||
Behavior *UpdatePreferenceRequest `json:"behavior,omitempty"`
|
||||
}
|
||||
|
||||
// CaptainConfigPayload is the raw payload returned by Chatwoot preferences show/update.
|
||||
@@ -74,6 +74,17 @@ type CaptainConfigPayload struct {
|
||||
Models map[string]CaptainModelConfig `json:"models"`
|
||||
Features map[string]CaptainFeatureConfig `json:"features"`
|
||||
ProviderConfig *CopilotProviderConfigPayload `json:"provider_config"`
|
||||
Behavior *CaptainBehaviorPayload `json:"behavior"`
|
||||
}
|
||||
|
||||
type CaptainBehaviorPayload struct {
|
||||
Tone string `json:"tone"`
|
||||
Language string `json:"language"`
|
||||
MaxResponseLength int `json:"max_response_length"`
|
||||
CustomPromptSuffix string `json:"custom_prompt_suffix"`
|
||||
AutoLabelEnabled bool `json:"auto_label_enabled"`
|
||||
AutoFollowUpEnabled bool `json:"auto_follow_up_enabled"`
|
||||
AutoReplyEnabled bool `json:"auto_reply_enabled"`
|
||||
}
|
||||
|
||||
type CaptainModelConfig struct {
|
||||
@@ -138,7 +149,15 @@ var captainFeatureDefaults = map[string]string{
|
||||
"help_center_search": "text-embedding-3-small",
|
||||
}
|
||||
|
||||
var captainFeatureOrder = []string{"editor", "assistant", "copilot", "label_suggestion", "audio_transcription", "help_center_search"}
|
||||
var captainFeatureOrder = []string{"editor", "assistant", "copilot", "label_suggestion", "help_center_search"}
|
||||
|
||||
var captainFeatureEnabledDefaults = map[string]bool{
|
||||
"editor": true,
|
||||
"assistant": false,
|
||||
"copilot": true,
|
||||
"label_suggestion": false,
|
||||
"help_center_search": false,
|
||||
}
|
||||
|
||||
// --- Business logic ---
|
||||
|
||||
@@ -166,7 +185,11 @@ func (s *CaptainPreferenceService) GetConfig(ctx context.Context, accountID uint
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return captainConfigPayload(account, providerConfig), nil
|
||||
behavior, err := s.getOrDefaultPreference(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return captainConfigPayload(account, providerConfig, behavior), nil
|
||||
}
|
||||
|
||||
// UpdateConfig merges captain_models/captain_features into account settings and returns the raw payload.
|
||||
@@ -179,15 +202,6 @@ func (s *CaptainPreferenceService) UpdateConfig(ctx context.Context, accountID u
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.ProviderConfig != nil {
|
||||
if s.copilotConfigService == nil {
|
||||
return nil, fmt.Errorf("Copilot provider configuration service is unavailable")
|
||||
}
|
||||
providerConfig, err = s.copilotConfigService.Update(ctx, *req.ProviderConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
models := jsonMapString(account.CaptainModels)
|
||||
features := jsonMapBool(account.CaptainFeatures)
|
||||
@@ -200,10 +214,10 @@ func (s *CaptainPreferenceService) UpdateConfig(ctx context.Context, accountID u
|
||||
models[key] = ""
|
||||
continue
|
||||
}
|
||||
if !validCaptainModelFor(key, value) {
|
||||
return nil, fmt.Errorf("'%s' is not a valid model for %s. Allowed: %s", value, key, strings.Join(captainFeatureModels[key], ", "))
|
||||
if len(strings.TrimSpace(value)) > 255 {
|
||||
return nil, fmt.Errorf("model for %s must be at most 255 characters", key)
|
||||
}
|
||||
models[key] = value
|
||||
models[key] = strings.TrimSpace(value)
|
||||
}
|
||||
for key, value := range req.CaptainFeatures {
|
||||
if !isCaptainFeature(key) {
|
||||
@@ -217,12 +231,22 @@ func (s *CaptainPreferenceService) UpdateConfig(ctx context.Context, accountID u
|
||||
if err := s.accountRepo.Update(ctx, account); err != nil {
|
||||
return nil, fmt.Errorf("update captain preferences: %w", err)
|
||||
}
|
||||
return captainConfigPayload(account, providerConfig), nil
|
||||
behavior, err := s.getOrDefaultPreference(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Behavior != nil {
|
||||
behavior, err = s.updateOrCreatePreference(ctx, accountID, req.Behavior)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return captainConfigPayload(account, providerConfig, behavior), nil
|
||||
}
|
||||
|
||||
func (s *CaptainPreferenceService) providerConfigPayload(ctx context.Context) (*CopilotProviderConfigPayload, error) {
|
||||
if s.copilotConfigService == nil {
|
||||
return copilotProviderPayload(defaultCopilotProviderSettings(), ""), nil
|
||||
return copilotProviderPayload(defaultCopilotProviderSettings(), "", "", nil), nil
|
||||
}
|
||||
return s.copilotConfigService.Get(ctx)
|
||||
}
|
||||
@@ -238,34 +262,69 @@ func (s *CaptainPreferenceService) findAccount(ctx context.Context, accountID ui
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func captainConfigPayload(account *model.Account, providerConfig *CopilotProviderConfigPayload) *CaptainConfigPayload {
|
||||
func captainConfigPayload(account *model.Account, providerConfig *CopilotProviderConfigPayload, preference *model.CaptainPreference) *CaptainConfigPayload {
|
||||
accountModels := jsonMapString(account.CaptainModels)
|
||||
accountFeatures := jsonMapBool(account.CaptainFeatures)
|
||||
features := make(map[string]CaptainFeatureConfig, len(captainFeatureOrder))
|
||||
modelsByID := map[string]CaptainModelConfig{}
|
||||
providerName := "openai"
|
||||
embeddingProvider := "openai"
|
||||
chatModel := "gpt-4o-mini"
|
||||
embeddingModel := "text-embedding-3-small"
|
||||
if providerConfig != nil {
|
||||
providerName = providerConfig.Chat.Provider
|
||||
embeddingProvider = providerConfig.Embedding.Provider
|
||||
chatModel = providerConfig.Chat.Model
|
||||
embeddingModel = providerConfig.Embedding.Model
|
||||
}
|
||||
for _, key := range captainFeatureOrder {
|
||||
models := make([]CaptainFeatureModel, 0, len(captainFeatureModels[key]))
|
||||
for _, modelName := range captainFeatureModels[key] {
|
||||
modelConfig := captainModels[modelName]
|
||||
models = append(models, CaptainFeatureModel{
|
||||
ID: modelName,
|
||||
DisplayName: modelConfig.DisplayName,
|
||||
Provider: modelConfig.Provider,
|
||||
ComingSoon: modelConfig.ComingSoon,
|
||||
CreditMultiplier: modelConfig.CreditMultiplier,
|
||||
})
|
||||
defaultModel := chatModel
|
||||
featureProvider := providerName
|
||||
if key == "help_center_search" {
|
||||
defaultModel = embeddingModel
|
||||
featureProvider = embeddingProvider
|
||||
}
|
||||
selected := accountModels[key]
|
||||
if !validCaptainModelFor(key, selected) {
|
||||
selected = captainFeatureDefaults[key]
|
||||
if strings.TrimSpace(selected) == "" {
|
||||
selected = defaultModel
|
||||
}
|
||||
modelIDs := []string{defaultModel}
|
||||
if selected != defaultModel {
|
||||
modelIDs = append([]string{selected}, modelIDs...)
|
||||
}
|
||||
models := make([]CaptainFeatureModel, 0, len(modelIDs))
|
||||
for _, modelName := range modelIDs {
|
||||
if strings.TrimSpace(modelName) == "" {
|
||||
continue
|
||||
}
|
||||
modelConfig := CaptainModelConfig{Provider: featureProvider, DisplayName: modelName, CreditMultiplier: 1}
|
||||
modelsByID[modelName] = modelConfig
|
||||
models = append(models, CaptainFeatureModel{
|
||||
ID: modelName,
|
||||
DisplayName: modelName,
|
||||
Provider: featureProvider,
|
||||
CreditMultiplier: 1,
|
||||
})
|
||||
}
|
||||
enabled, exists := accountFeatures[key]
|
||||
if !exists {
|
||||
enabled = captainFeatureEnabledDefaults[key]
|
||||
}
|
||||
features[key] = CaptainFeatureConfig{
|
||||
Models: models,
|
||||
Default: captainFeatureDefaults[key],
|
||||
Enabled: accountFeatures[key],
|
||||
Default: defaultModel,
|
||||
Enabled: enabled,
|
||||
Selected: selected,
|
||||
}
|
||||
}
|
||||
return &CaptainConfigPayload{Providers: captainProviders, Models: captainModels, Features: features, ProviderConfig: providerConfig}
|
||||
providers := map[string]map[string]string{providerName: {"display_name": providerName}}
|
||||
return &CaptainConfigPayload{
|
||||
Providers: providers,
|
||||
Models: modelsByID,
|
||||
Features: features,
|
||||
ProviderConfig: providerConfig,
|
||||
Behavior: captainBehaviorPayload(preference),
|
||||
}
|
||||
}
|
||||
|
||||
func isCaptainFeature(key string) bool {
|
||||
@@ -312,6 +371,81 @@ func boolPtr(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
|
||||
func defaultCaptainPreference(accountID uint) *model.CaptainPreference {
|
||||
return &model.CaptainPreference{
|
||||
AccountID: accountID,
|
||||
Tone: "professional",
|
||||
Language: "auto",
|
||||
MaxResponseLength: 500,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CaptainPreferenceService) getOrDefaultPreference(ctx context.Context, accountID uint) (*model.CaptainPreference, error) {
|
||||
pref, err := s.repo.GetByAccountID(ctx, accountID)
|
||||
if err == nil {
|
||||
return pref, nil
|
||||
}
|
||||
return defaultCaptainPreference(accountID), nil
|
||||
}
|
||||
|
||||
func (s *CaptainPreferenceService) updateOrCreatePreference(ctx context.Context, accountID uint, req *UpdatePreferenceRequest) (*model.CaptainPreference, error) {
|
||||
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
||||
return nil, fmt.Errorf("validation error: %w", err)
|
||||
}
|
||||
pref, err := s.repo.GetByAccountID(ctx, accountID)
|
||||
create := err != nil
|
||||
if create {
|
||||
pref = defaultCaptainPreference(accountID)
|
||||
}
|
||||
if req.Tone != "" {
|
||||
pref.Tone = req.Tone
|
||||
}
|
||||
if req.Language != "" {
|
||||
pref.Language = req.Language
|
||||
}
|
||||
if req.ResponseGuidelines != "" {
|
||||
pref.ResponseGuidelines = req.ResponseGuidelines
|
||||
}
|
||||
if req.AutoLabelEnabled != nil {
|
||||
pref.AutoLabelEnabled = *req.AutoLabelEnabled
|
||||
}
|
||||
if req.AutoFollowUpEnabled != nil {
|
||||
pref.AutoFollowUpEnabled = *req.AutoFollowUpEnabled
|
||||
}
|
||||
if req.AutoReplyEnabled != nil {
|
||||
pref.AutoReplyEnabled = *req.AutoReplyEnabled
|
||||
}
|
||||
if req.MaxResponseLength != nil {
|
||||
pref.MaxResponseLength = *req.MaxResponseLength
|
||||
}
|
||||
if req.CustomPromptSuffix != "" {
|
||||
pref.CustomPromptSuffix = req.CustomPromptSuffix
|
||||
}
|
||||
if create {
|
||||
if err := s.repo.Create(ctx, pref); err != nil {
|
||||
return nil, fmt.Errorf("create preference: %w", err)
|
||||
}
|
||||
} else if err := s.repo.Update(ctx, pref); err != nil {
|
||||
return nil, fmt.Errorf("update preference: %w", err)
|
||||
}
|
||||
return pref, nil
|
||||
}
|
||||
|
||||
func captainBehaviorPayload(pref *model.CaptainPreference) *CaptainBehaviorPayload {
|
||||
if pref == nil {
|
||||
pref = defaultCaptainPreference(0)
|
||||
}
|
||||
return &CaptainBehaviorPayload{
|
||||
Tone: pref.Tone,
|
||||
Language: pref.Language,
|
||||
MaxResponseLength: pref.MaxResponseLength,
|
||||
CustomPromptSuffix: pref.CustomPromptSuffix,
|
||||
AutoLabelEnabled: pref.AutoLabelEnabled,
|
||||
AutoFollowUpEnabled: pref.AutoFollowUpEnabled,
|
||||
AutoReplyEnabled: pref.AutoReplyEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
// Create creates a new preference for an account.
|
||||
func (s *CaptainPreferenceService) Create(ctx context.Context, accountID uint, req *CreatePreferenceRequest) (*model.CaptainPreference, error) {
|
||||
// Validate request
|
||||
|
||||
@@ -150,6 +150,7 @@ func (s *CaptainTaskExtendedService) buildAssistantContext(ctx context.Context,
|
||||
}
|
||||
|
||||
func (s *CaptainTaskExtendedService) LabelSuggestion(ctx context.Context, accountID uint, req *ChatwootLabelSuggestionRequest) (*ChatwootTaskResult, error) {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "label_suggestion")
|
||||
if s.llmProvider == nil {
|
||||
return nil, taskError(422, "Captain is disabled")
|
||||
}
|
||||
@@ -191,6 +192,7 @@ func (s *CaptainTaskExtendedService) LabelSuggestion(ctx context.Context, accoun
|
||||
}
|
||||
|
||||
func (s *CaptainTaskExtendedService) FollowUp(ctx context.Context, accountID uint, req *ChatwootFollowUpRequest) (*ChatwootTaskResult, error) {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "assistant")
|
||||
if s.llmProvider == nil {
|
||||
return nil, taskError(422, "Captain is disabled")
|
||||
}
|
||||
@@ -232,6 +234,7 @@ func (s *CaptainTaskExtendedService) FollowUp(ctx context.Context, accountID uin
|
||||
// Reference: Chatwoot Captain::ConversationInsightController#suggest_labels
|
||||
|
||||
func (s *CaptainTaskExtendedService) SuggestLabels(ctx context.Context, accountID uint, query *LabelSuggestionQuery) (*LabelSuggestionResult, error) {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "label_suggestion")
|
||||
// Validate request
|
||||
if err := pkgvalidator.ValidateStruct(query); err != nil {
|
||||
return nil, fmt.Errorf("validation error: %w", err)
|
||||
@@ -321,6 +324,7 @@ Respond in JSON format:
|
||||
// Reference: Chatwoot Captain::ConversationInsightController + M12 PRD §Follow Up Suggestions
|
||||
|
||||
func (s *CaptainTaskExtendedService) SuggestFollowUp(ctx context.Context, accountID uint, query *FollowUpQuery) (*FollowUpResult, error) {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "assistant")
|
||||
// Validate request
|
||||
if err := pkgvalidator.ValidateStruct(query); err != nil {
|
||||
return nil, fmt.Errorf("validation error: %w", err)
|
||||
|
||||
@@ -139,6 +139,7 @@ func CaptainTaskErrorStatus(err error) (int, string, bool) {
|
||||
// 4. Build prompt with conversation context + relevant FAQ answers
|
||||
// 5. Call LLM to generate reply suggestions
|
||||
func (s *CaptainTaskService) ReplySuggestion(ctx context.Context, accountID uint, req *TaskReplySuggestionRequest) (*TaskReplySuggestionResult, error) {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "editor")
|
||||
if s.llmProvider == nil {
|
||||
return nil, taskError(422, "Captain is disabled")
|
||||
}
|
||||
@@ -226,6 +227,7 @@ func (s *CaptainTaskService) ReplySuggestion(ctx context.Context, accountID uint
|
||||
|
||||
// Summarize generates a concise summary of a conversation.
|
||||
func (s *CaptainTaskService) Summarize(ctx context.Context, accountID uint, req *TaskSummarizeRequest) (*TaskSummarizeResult, error) {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "editor")
|
||||
if s.llmProvider == nil {
|
||||
return nil, taskError(422, "Captain is disabled")
|
||||
}
|
||||
@@ -288,6 +290,7 @@ func (s *CaptainTaskService) Summarize(ctx context.Context, accountID uint, req
|
||||
|
||||
// Rewrite rewrites a draft message to improve tone, clarity, or language.
|
||||
func (s *CaptainTaskService) Rewrite(ctx context.Context, accountID uint, req *TaskRewriteRequest) (*TaskRewriteResult, error) {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "editor")
|
||||
if s.llmProvider == nil {
|
||||
return nil, taskError(422, "Captain is disabled")
|
||||
}
|
||||
@@ -543,6 +546,7 @@ func parseSuggestions(content string) []string {
|
||||
// ReplySuggestionStream streams reply suggestions via an onChunk callback.
|
||||
// The callback receives StreamChunk events; the caller (handler) writes SSE events.
|
||||
func (s *CaptainTaskService) ReplySuggestionStream(ctx context.Context, accountID uint, req *TaskReplySuggestionRequest, onChunk func(llm.StreamChunk) error) error {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "editor")
|
||||
if s.llmProvider == nil {
|
||||
return taskError(422, "Captain is disabled")
|
||||
}
|
||||
@@ -619,6 +623,7 @@ func (s *CaptainTaskService) ReplySuggestionStream(ctx context.Context, accountI
|
||||
|
||||
// SummarizeStream streams a conversation summary via an onChunk callback.
|
||||
func (s *CaptainTaskService) SummarizeStream(ctx context.Context, accountID uint, req *TaskSummarizeRequest, onChunk func(llm.StreamChunk) error) error {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "editor")
|
||||
if s.llmProvider == nil {
|
||||
return taskError(422, "Captain is disabled")
|
||||
}
|
||||
@@ -679,6 +684,7 @@ func (s *CaptainTaskService) SummarizeStream(ctx context.Context, accountID uint
|
||||
|
||||
// RewriteStream streams a rewritten message via an onChunk callback.
|
||||
func (s *CaptainTaskService) RewriteStream(ctx context.Context, accountID uint, req *TaskRewriteRequest, onChunk func(llm.StreamChunk) error) error {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "editor")
|
||||
if s.llmProvider == nil {
|
||||
return taskError(422, "Captain is disabled")
|
||||
}
|
||||
|
||||
@@ -2,65 +2,161 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/llm"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/security"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
copilotProviderConfigKey = "COPILOT_PROVIDER_CONFIG"
|
||||
copilotAPIKeyConfigKey = "COPILOT_API_KEY"
|
||||
copilotProviderConfigKey = "COPILOT_PROVIDER_CONFIG"
|
||||
copilotChatAPIKeyConfigKey = "COPILOT_CHAT_API_KEY"
|
||||
copilotEmbeddingAPIKeyKey = "COPILOT_EMBEDDING_API_KEY"
|
||||
copilotProviderHealthKey = "COPILOT_PROVIDER_HEALTH"
|
||||
copilotTestPrompt = "Reply with OK only."
|
||||
copilotEmbeddingTestText = "GoChat Copilot embedding health check"
|
||||
)
|
||||
|
||||
type CopilotProviderSettings struct {
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
EmbeddingModel string `json:"embedding_model"`
|
||||
type CopilotChatSettings struct {
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
type CopilotProviderConfigInput struct {
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
type CopilotEmbeddingSettings struct {
|
||||
Mode string `json:"mode"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
Dimensions int `json:"dimensions"`
|
||||
}
|
||||
|
||||
type CopilotGenerationSettings struct {
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
type CopilotRequestSettings struct {
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
MaxRetries int `json:"max_retries"`
|
||||
}
|
||||
|
||||
type CopilotProviderSettings struct {
|
||||
Chat CopilotChatSettings `json:"chat"`
|
||||
Embedding CopilotEmbeddingSettings `json:"embedding"`
|
||||
Generation CopilotGenerationSettings `json:"generation"`
|
||||
Request CopilotRequestSettings `json:"request"`
|
||||
}
|
||||
|
||||
type CopilotSecretInput struct {
|
||||
APIKey string `json:"api_key"`
|
||||
ClearAPIKey bool `json:"clear_api_key"`
|
||||
}
|
||||
|
||||
type CopilotChatConfigInput struct {
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
CopilotSecretInput
|
||||
}
|
||||
|
||||
type CopilotEmbeddingConfigInput struct {
|
||||
Mode string `json:"mode"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
Dimensions int `json:"dimensions"`
|
||||
CopilotSecretInput
|
||||
}
|
||||
|
||||
type CopilotProviderConfigInput struct {
|
||||
Chat CopilotChatConfigInput `json:"chat"`
|
||||
Embedding CopilotEmbeddingConfigInput `json:"embedding"`
|
||||
Generation CopilotGenerationSettings `json:"generation"`
|
||||
Request CopilotRequestSettings `json:"request"`
|
||||
}
|
||||
|
||||
type CopilotSecretPayload struct {
|
||||
Configured bool `json:"configured"`
|
||||
Masked string `json:"masked,omitempty"`
|
||||
}
|
||||
|
||||
type CopilotChatConfigPayload struct {
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
APIKey CopilotSecretPayload `json:"api_key"`
|
||||
}
|
||||
|
||||
type CopilotEmbeddingConfigPayload struct {
|
||||
Mode string `json:"mode"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
Dimensions int `json:"dimensions"`
|
||||
APIKey CopilotSecretPayload `json:"api_key"`
|
||||
}
|
||||
|
||||
type CopilotProviderCheck struct {
|
||||
OK bool `json:"ok"`
|
||||
LatencyMS int64 `json:"latency_ms"`
|
||||
Model string `json:"model"`
|
||||
Dimensions int `json:"dimensions,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type CopilotProviderHealth struct {
|
||||
TestedAt time.Time `json:"tested_at"`
|
||||
ConfigFingerprint string `json:"config_fingerprint"`
|
||||
Chat CopilotProviderCheck `json:"chat"`
|
||||
Embedding CopilotProviderCheck `json:"embedding"`
|
||||
}
|
||||
|
||||
type CopilotProviderConfigPayload struct {
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
APIKeyConfigured bool `json:"api_key_configured"`
|
||||
APIKeyMasked string `json:"api_key_masked,omitempty"`
|
||||
Configured bool `json:"configured"`
|
||||
Chat CopilotChatConfigPayload `json:"chat"`
|
||||
Embedding CopilotEmbeddingConfigPayload `json:"embedding"`
|
||||
Generation CopilotGenerationSettings `json:"generation"`
|
||||
Request CopilotRequestSettings `json:"request"`
|
||||
Configured bool `json:"configured"`
|
||||
Health *CopilotProviderHealth `json:"health,omitempty"`
|
||||
}
|
||||
|
||||
// CopilotConfigService owns the platform-wide provider configuration stored by
|
||||
// the settings page. Captain/Copilot has no environment or YAML fallback.
|
||||
// the settings page. Copilot has no environment or YAML fallback and API keys
|
||||
// are deliberately stored as plaintext installation config values.
|
||||
type CopilotConfigService struct {
|
||||
repo *repository.InstallationConfigRepo
|
||||
encryptor *security.Encryptor
|
||||
manager *llm.ProviderManager
|
||||
repo *repository.InstallationConfigRepo
|
||||
manager *llm.ProviderManager
|
||||
}
|
||||
|
||||
func NewCopilotConfigService(repo *repository.InstallationConfigRepo, encryptor *security.Encryptor, manager *llm.ProviderManager) *CopilotConfigService {
|
||||
return &CopilotConfigService{repo: repo, encryptor: encryptor, manager: manager}
|
||||
func NewCopilotConfigService(repo *repository.InstallationConfigRepo, manager *llm.ProviderManager) *CopilotConfigService {
|
||||
return &CopilotConfigService{repo: repo, manager: manager}
|
||||
}
|
||||
|
||||
func defaultCopilotProviderSettings() CopilotProviderSettings {
|
||||
return CopilotProviderSettings{
|
||||
Provider: "openai",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Model: "gpt-4o-mini",
|
||||
EmbeddingModel: "text-embedding-3-small",
|
||||
Chat: CopilotChatSettings{
|
||||
Provider: "openai",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Model: "gpt-4o-mini",
|
||||
},
|
||||
Embedding: CopilotEmbeddingSettings{
|
||||
Mode: llm.EmbeddingModeReuseChat,
|
||||
Provider: "openai",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Model: "text-embedding-3-small",
|
||||
Dimensions: 1536,
|
||||
},
|
||||
Generation: CopilotGenerationSettings{Temperature: 0.7, MaxTokens: 1024},
|
||||
Request: CopilotRequestSettings{TimeoutSeconds: 60, MaxRetries: 3},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,93 +166,209 @@ func (s *CopilotConfigService) Initialize(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
if !configured {
|
||||
s.manager.Clear()
|
||||
return nil
|
||||
}
|
||||
return s.manager.Configure(runtimeCfg)
|
||||
}
|
||||
|
||||
func (s *CopilotConfigService) Get(ctx context.Context) (*CopilotProviderConfigPayload, error) {
|
||||
settings, err := s.loadSettings(ctx)
|
||||
settings, chatKey, embeddingKey, err := s.loadConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiKey, err := s.loadAPIKey(ctx)
|
||||
health, err := s.loadMatchingHealth(ctx, settings, chatKey, embeddingKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return copilotProviderPayload(settings, apiKey), nil
|
||||
return copilotProviderPayload(settings, chatKey, embeddingKey, health), nil
|
||||
}
|
||||
|
||||
func (s *CopilotConfigService) Update(ctx context.Context, input CopilotProviderConfigInput) (*CopilotProviderConfigPayload, error) {
|
||||
currentSettings, err := s.loadSettings(ctx)
|
||||
settings, chatKey, embeddingKey, err := s.mergedConfig(ctx, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentAPIKey, err := s.loadAPIKey(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settings := currentSettings
|
||||
if strings.TrimSpace(input.Provider) != "" {
|
||||
settings.Provider = strings.ToLower(strings.TrimSpace(input.Provider))
|
||||
}
|
||||
if input.BaseURL != "" || settings.Provider == "openai" || settings.Provider == "anthropic" {
|
||||
settings.BaseURL = strings.TrimRight(strings.TrimSpace(input.BaseURL), "/")
|
||||
}
|
||||
if strings.TrimSpace(input.Model) != "" {
|
||||
settings.Model = strings.TrimSpace(input.Model)
|
||||
}
|
||||
settings = normalizeCopilotProviderSettings(settings)
|
||||
if err := validateCopilotProviderSettings(settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
apiKey := currentAPIKey
|
||||
if input.ClearAPIKey {
|
||||
apiKey = ""
|
||||
} else if strings.TrimSpace(input.APIKey) != "" {
|
||||
apiKey = strings.TrimSpace(input.APIKey)
|
||||
runtimeCfg := runtimeProviderConfig(settings, chatKey, embeddingKey)
|
||||
configured := copilotConfigComplete(settings, chatKey, embeddingKey)
|
||||
if configured {
|
||||
candidate := llm.NewProviderManager()
|
||||
if err := candidate.Configure(runtimeCfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
settingsJSON, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal Copilot provider config: %w", err)
|
||||
}
|
||||
encryptedAPIKey, err := s.encryptor.EncryptField(apiKey, security.FieldTypeAPIKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repo.UpsertByName(ctx, copilotAPIKeyConfigKey, encryptedAPIKey); err != nil {
|
||||
return nil, fmt.Errorf("save Copilot API key: %w", err)
|
||||
}
|
||||
if err := s.repo.UpsertByName(ctx, copilotProviderConfigKey, string(settingsJSON)); err != nil {
|
||||
if err := s.repo.UpsertValues(ctx, map[string]string{
|
||||
copilotProviderConfigKey: string(settingsJSON),
|
||||
copilotChatAPIKeyConfigKey: chatKey,
|
||||
copilotEmbeddingAPIKeyKey: embeddingKey,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("save Copilot provider config: %w", err)
|
||||
}
|
||||
|
||||
if apiKey == "" {
|
||||
if !configured {
|
||||
s.manager.Clear()
|
||||
return copilotProviderPayload(settings, apiKey), nil
|
||||
return copilotProviderPayload(settings, chatKey, embeddingKey, nil), nil
|
||||
}
|
||||
if err := s.manager.Configure(runtimeProviderConfig(settings, apiKey)); err != nil {
|
||||
if err := s.manager.Configure(runtimeCfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return copilotProviderPayload(settings, apiKey), nil
|
||||
return copilotProviderPayload(settings, chatKey, embeddingKey, nil), nil
|
||||
}
|
||||
|
||||
// Test validates and calls Chat and Embedding with candidate settings without
|
||||
// saving those settings. Health metadata is persisted only when it matches the
|
||||
// currently saved configuration fingerprint.
|
||||
func (s *CopilotConfigService) Test(ctx context.Context, input CopilotProviderConfigInput) (*CopilotProviderHealth, error) {
|
||||
settings, chatKey, embeddingKey, err := s.mergedConfig(ctx, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !copilotConfigComplete(settings, chatKey, embeddingKey) {
|
||||
return nil, llm.ErrProviderNotConfigured
|
||||
}
|
||||
runtimeCfg := runtimeProviderConfig(settings, chatKey, embeddingKey)
|
||||
candidate := llm.NewProviderManager()
|
||||
if err := candidate.Configure(runtimeCfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
health := &CopilotProviderHealth{
|
||||
TestedAt: time.Now().UTC(),
|
||||
ConfigFingerprint: copilotConfigFingerprint(settings, chatKey, embeddingKey),
|
||||
}
|
||||
chatStarted := time.Now()
|
||||
chatResp, chatErr := candidate.ChatCompletion(ctx, llm.ChatRequest{
|
||||
Messages: []llm.ChatMessage{{Role: "user", Content: copilotTestPrompt}},
|
||||
})
|
||||
health.Chat = CopilotProviderCheck{
|
||||
OK: chatErr == nil && chatResp != nil && len(chatResp.Choices) > 0,
|
||||
LatencyMS: time.Since(chatStarted).Milliseconds(),
|
||||
Model: settings.Chat.Model,
|
||||
}
|
||||
if chatErr != nil {
|
||||
health.Chat.Error = normalizeCopilotProviderError(chatErr)
|
||||
} else if !health.Chat.OK {
|
||||
health.Chat.Error = "chat provider returned no choices"
|
||||
}
|
||||
|
||||
embedStarted := time.Now()
|
||||
embedResp, embedErr := candidate.CreateEmbedding(ctx, llm.EmbeddingRequest{Input: []string{copilotEmbeddingTestText}})
|
||||
health.Embedding = CopilotProviderCheck{
|
||||
OK: embedErr == nil && embedResp != nil && len(embedResp.Data) > 0,
|
||||
LatencyMS: time.Since(embedStarted).Milliseconds(),
|
||||
Model: settings.Embedding.Model,
|
||||
Dimensions: settings.Embedding.Dimensions,
|
||||
}
|
||||
if embedErr != nil {
|
||||
health.Embedding.Error = normalizeCopilotProviderError(embedErr)
|
||||
} else if !health.Embedding.OK {
|
||||
health.Embedding.Error = "embedding provider returned no vectors"
|
||||
} else if len(embedResp.Data[0].Embedding) != settings.Embedding.Dimensions {
|
||||
health.Embedding.OK = false
|
||||
health.Embedding.Error = fmt.Sprintf("embedding dimensions mismatch: expected %d, got %d", settings.Embedding.Dimensions, len(embedResp.Data[0].Embedding))
|
||||
health.Embedding.Dimensions = len(embedResp.Data[0].Embedding)
|
||||
}
|
||||
|
||||
savedSettings, savedChatKey, savedEmbeddingKey, loadErr := s.loadConfig(ctx)
|
||||
if loadErr == nil && copilotConfigFingerprint(savedSettings, savedChatKey, savedEmbeddingKey) == health.ConfigFingerprint {
|
||||
raw, _ := json.Marshal(health)
|
||||
_ = s.repo.UpsertByName(ctx, copilotProviderHealthKey, string(raw))
|
||||
}
|
||||
return health, nil
|
||||
}
|
||||
|
||||
func (s *CopilotConfigService) mergedConfig(ctx context.Context, input CopilotProviderConfigInput) (CopilotProviderSettings, string, string, error) {
|
||||
settings, chatKey, embeddingKey, err := s.loadConfig(ctx)
|
||||
if err != nil {
|
||||
return settings, "", "", err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(input.Chat.Provider) != "" {
|
||||
settings.Chat.Provider = strings.ToLower(strings.TrimSpace(input.Chat.Provider))
|
||||
}
|
||||
if input.Chat.BaseURL != "" || input.Chat.Provider != "" {
|
||||
settings.Chat.BaseURL = strings.TrimRight(strings.TrimSpace(input.Chat.BaseURL), "/")
|
||||
}
|
||||
if strings.TrimSpace(input.Chat.Model) != "" {
|
||||
settings.Chat.Model = strings.TrimSpace(input.Chat.Model)
|
||||
}
|
||||
if input.Chat.ClearAPIKey {
|
||||
chatKey = ""
|
||||
} else if strings.TrimSpace(input.Chat.APIKey) != "" {
|
||||
chatKey = strings.TrimSpace(input.Chat.APIKey)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(input.Embedding.Mode) != "" {
|
||||
settings.Embedding.Mode = strings.ToLower(strings.TrimSpace(input.Embedding.Mode))
|
||||
}
|
||||
if strings.TrimSpace(input.Embedding.Provider) != "" {
|
||||
settings.Embedding.Provider = strings.ToLower(strings.TrimSpace(input.Embedding.Provider))
|
||||
}
|
||||
if input.Embedding.BaseURL != "" || input.Embedding.Provider != "" {
|
||||
settings.Embedding.BaseURL = strings.TrimRight(strings.TrimSpace(input.Embedding.BaseURL), "/")
|
||||
}
|
||||
if strings.TrimSpace(input.Embedding.Model) != "" {
|
||||
settings.Embedding.Model = strings.TrimSpace(input.Embedding.Model)
|
||||
}
|
||||
if input.Embedding.Dimensions != 0 {
|
||||
settings.Embedding.Dimensions = input.Embedding.Dimensions
|
||||
}
|
||||
if input.Embedding.ClearAPIKey {
|
||||
embeddingKey = ""
|
||||
} else if strings.TrimSpace(input.Embedding.APIKey) != "" {
|
||||
embeddingKey = strings.TrimSpace(input.Embedding.APIKey)
|
||||
}
|
||||
|
||||
if input.Generation.MaxTokens != 0 {
|
||||
settings.Generation.MaxTokens = input.Generation.MaxTokens
|
||||
}
|
||||
if input.Generation.Temperature >= 0 {
|
||||
settings.Generation.Temperature = input.Generation.Temperature
|
||||
}
|
||||
if input.Request.TimeoutSeconds != 0 {
|
||||
settings.Request.TimeoutSeconds = input.Request.TimeoutSeconds
|
||||
}
|
||||
if input.Request.MaxRetries >= 0 {
|
||||
settings.Request.MaxRetries = input.Request.MaxRetries
|
||||
}
|
||||
|
||||
settings = normalizeCopilotProviderSettings(settings)
|
||||
if err := validateCopilotProviderSettings(settings); err != nil {
|
||||
return settings, "", "", err
|
||||
}
|
||||
return settings, chatKey, embeddingKey, nil
|
||||
}
|
||||
|
||||
func (s *CopilotConfigService) loadRuntimeConfig(ctx context.Context) (llm.RuntimeProviderConfig, bool, error) {
|
||||
settings, err := s.loadSettings(ctx)
|
||||
settings, chatKey, embeddingKey, err := s.loadConfig(ctx)
|
||||
if err != nil {
|
||||
return llm.RuntimeProviderConfig{}, false, err
|
||||
}
|
||||
apiKey, err := s.loadAPIKey(ctx)
|
||||
if err != nil {
|
||||
return llm.RuntimeProviderConfig{}, false, err
|
||||
}
|
||||
if apiKey == "" {
|
||||
if !copilotConfigComplete(settings, chatKey, embeddingKey) {
|
||||
return llm.RuntimeProviderConfig{}, false, nil
|
||||
}
|
||||
return runtimeProviderConfig(settings, apiKey), true, nil
|
||||
return runtimeProviderConfig(settings, chatKey, embeddingKey), true, nil
|
||||
}
|
||||
|
||||
func (s *CopilotConfigService) loadConfig(ctx context.Context) (CopilotProviderSettings, string, string, error) {
|
||||
settings, err := s.loadSettings(ctx)
|
||||
if err != nil {
|
||||
return settings, "", "", err
|
||||
}
|
||||
chatKey, err := s.loadPlainValue(ctx, copilotChatAPIKeyConfigKey)
|
||||
if err != nil {
|
||||
return settings, "", "", err
|
||||
}
|
||||
embeddingKey, err := s.loadPlainValue(ctx, copilotEmbeddingAPIKeyKey)
|
||||
if err != nil {
|
||||
return settings, "", "", err
|
||||
}
|
||||
return settings, chatKey, embeddingKey, nil
|
||||
}
|
||||
|
||||
func (s *CopilotConfigService) loadSettings(ctx context.Context) (CopilotProviderSettings, error) {
|
||||
@@ -174,81 +386,197 @@ func (s *CopilotConfigService) loadSettings(ctx context.Context) (CopilotProvide
|
||||
return normalizeCopilotProviderSettings(settings), nil
|
||||
}
|
||||
|
||||
func (s *CopilotConfigService) loadAPIKey(ctx context.Context) (string, error) {
|
||||
record, err := s.repo.FindByName(ctx, copilotAPIKeyConfigKey)
|
||||
func (s *CopilotConfigService) loadPlainValue(ctx context.Context, key string) (string, error) {
|
||||
record, err := s.repo.FindByName(ctx, key)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load Copilot API key: %w", err)
|
||||
return "", fmt.Errorf("load %s: %w", key, err)
|
||||
}
|
||||
return strings.TrimSpace(record.Value), nil
|
||||
}
|
||||
|
||||
func (s *CopilotConfigService) loadMatchingHealth(ctx context.Context, settings CopilotProviderSettings, chatKey, embeddingKey string) (*CopilotProviderHealth, error) {
|
||||
record, err := s.repo.FindByName(ctx, copilotProviderHealthKey)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
apiKey, err := s.encryptor.DecryptField(record.Value, security.FieldTypeAPIKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, fmt.Errorf("load Copilot provider health: %w", err)
|
||||
}
|
||||
return apiKey, nil
|
||||
var health CopilotProviderHealth
|
||||
if err := json.Unmarshal([]byte(record.Value), &health); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
if health.ConfigFingerprint != copilotConfigFingerprint(settings, chatKey, embeddingKey) {
|
||||
return nil, nil
|
||||
}
|
||||
return &health, nil
|
||||
}
|
||||
|
||||
func normalizeCopilotProviderSettings(settings CopilotProviderSettings) CopilotProviderSettings {
|
||||
settings.Provider = strings.ToLower(strings.TrimSpace(settings.Provider))
|
||||
settings.BaseURL = strings.TrimRight(strings.TrimSpace(settings.BaseURL), "/")
|
||||
settings.Model = strings.TrimSpace(settings.Model)
|
||||
settings.EmbeddingModel = strings.TrimSpace(settings.EmbeddingModel)
|
||||
if settings.EmbeddingModel == "" {
|
||||
settings.EmbeddingModel = "text-embedding-3-small"
|
||||
}
|
||||
switch settings.Provider {
|
||||
settings.Chat.Provider = strings.ToLower(strings.TrimSpace(settings.Chat.Provider))
|
||||
settings.Chat.BaseURL = strings.TrimRight(strings.TrimSpace(settings.Chat.BaseURL), "/")
|
||||
settings.Chat.Model = strings.TrimSpace(settings.Chat.Model)
|
||||
settings.Embedding.Mode = strings.ToLower(strings.TrimSpace(settings.Embedding.Mode))
|
||||
settings.Embedding.Provider = strings.ToLower(strings.TrimSpace(settings.Embedding.Provider))
|
||||
settings.Embedding.BaseURL = strings.TrimRight(strings.TrimSpace(settings.Embedding.BaseURL), "/")
|
||||
settings.Embedding.Model = strings.TrimSpace(settings.Embedding.Model)
|
||||
|
||||
switch settings.Chat.Provider {
|
||||
case "openai":
|
||||
if settings.BaseURL == "" {
|
||||
settings.BaseURL = "https://api.openai.com/v1"
|
||||
if settings.Chat.BaseURL == "" {
|
||||
settings.Chat.BaseURL = "https://api.openai.com/v1"
|
||||
}
|
||||
case "anthropic":
|
||||
if settings.BaseURL == "" {
|
||||
settings.BaseURL = "https://api.anthropic.com"
|
||||
if settings.Chat.BaseURL == "" {
|
||||
settings.Chat.BaseURL = "https://api.anthropic.com"
|
||||
}
|
||||
}
|
||||
if settings.Embedding.Mode == "" {
|
||||
settings.Embedding.Mode = llm.EmbeddingModeReuseChat
|
||||
}
|
||||
if settings.Embedding.Provider == "" {
|
||||
settings.Embedding.Provider = "openai"
|
||||
}
|
||||
if settings.Embedding.BaseURL == "" && settings.Embedding.Provider == "openai" {
|
||||
settings.Embedding.BaseURL = "https://api.openai.com/v1"
|
||||
}
|
||||
if settings.Embedding.Model == "" {
|
||||
settings.Embedding.Model = "text-embedding-3-small"
|
||||
}
|
||||
if settings.Embedding.Dimensions == 0 {
|
||||
settings.Embedding.Dimensions = 1536
|
||||
}
|
||||
if settings.Generation.MaxTokens == 0 {
|
||||
settings.Generation.MaxTokens = 1024
|
||||
}
|
||||
if settings.Generation.Temperature < 0 {
|
||||
settings.Generation.Temperature = 0.7
|
||||
}
|
||||
if settings.Request.TimeoutSeconds == 0 {
|
||||
settings.Request.TimeoutSeconds = 60
|
||||
}
|
||||
if settings.Request.MaxRetries < 0 {
|
||||
settings.Request.MaxRetries = 3
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
func validateCopilotProviderSettings(settings CopilotProviderSettings) error {
|
||||
switch settings.Provider {
|
||||
case "openai", "openai_compatible", "anthropic":
|
||||
default:
|
||||
return fmt.Errorf("unsupported Copilot provider: %s", settings.Provider)
|
||||
if settings.Chat.Provider != "openai" && settings.Chat.Provider != "openai_compatible" && settings.Chat.Provider != "anthropic" {
|
||||
return fmt.Errorf("unsupported Copilot chat provider: %s", settings.Chat.Provider)
|
||||
}
|
||||
if settings.Model == "" {
|
||||
return fmt.Errorf("Copilot model is required")
|
||||
if settings.Chat.Model == "" {
|
||||
return fmt.Errorf("Copilot chat model is required")
|
||||
}
|
||||
if settings.BaseURL == "" {
|
||||
return fmt.Errorf("Copilot base URL is required")
|
||||
if err := validateCopilotURL(settings.Chat.BaseURL, "chat"); err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, err := url.Parse(settings.BaseURL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return fmt.Errorf("Copilot base URL must be a valid HTTP(S) URL")
|
||||
if settings.Embedding.Mode != llm.EmbeddingModeReuseChat && settings.Embedding.Mode != llm.EmbeddingModeSeparate {
|
||||
return fmt.Errorf("unsupported Copilot embedding mode: %s", settings.Embedding.Mode)
|
||||
}
|
||||
if settings.Chat.Provider == "anthropic" && settings.Embedding.Mode != llm.EmbeddingModeSeparate {
|
||||
return fmt.Errorf("Anthropic chat requires a separate embedding provider")
|
||||
}
|
||||
if settings.Embedding.Mode == llm.EmbeddingModeSeparate {
|
||||
if settings.Embedding.Provider != "openai" && settings.Embedding.Provider != "openai_compatible" {
|
||||
return fmt.Errorf("unsupported Copilot embedding provider: %s", settings.Embedding.Provider)
|
||||
}
|
||||
if err := validateCopilotURL(settings.Embedding.BaseURL, "embedding"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if settings.Embedding.Model == "" {
|
||||
return fmt.Errorf("Copilot embedding model is required")
|
||||
}
|
||||
if settings.Embedding.Dimensions < 1 || settings.Embedding.Dimensions > 4096 {
|
||||
return fmt.Errorf("Copilot embedding dimensions must be between 1 and 4096")
|
||||
}
|
||||
if settings.Generation.Temperature < 0 || settings.Generation.Temperature > 2 {
|
||||
return fmt.Errorf("Copilot temperature must be between 0 and 2")
|
||||
}
|
||||
if settings.Generation.MaxTokens < 64 || settings.Generation.MaxTokens > 32768 {
|
||||
return fmt.Errorf("Copilot max tokens must be between 64 and 32768")
|
||||
}
|
||||
if settings.Request.TimeoutSeconds < 5 || settings.Request.TimeoutSeconds > 300 {
|
||||
return fmt.Errorf("Copilot timeout must be between 5 and 300 seconds")
|
||||
}
|
||||
if settings.Request.MaxRetries < 0 || settings.Request.MaxRetries > 5 {
|
||||
return fmt.Errorf("Copilot max retries must be between 0 and 5")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runtimeProviderConfig(settings CopilotProviderSettings, apiKey string) llm.RuntimeProviderConfig {
|
||||
func validateCopilotURL(raw, kind string) error {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return fmt.Errorf("Copilot %s base URL must be a valid HTTP(S) URL", kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runtimeProviderConfig(settings CopilotProviderSettings, chatKey, embeddingKey string) llm.RuntimeProviderConfig {
|
||||
return llm.RuntimeProviderConfig{
|
||||
Provider: settings.Provider,
|
||||
BaseURL: settings.BaseURL,
|
||||
APIKey: apiKey,
|
||||
Model: settings.Model,
|
||||
EmbeddingModel: settings.EmbeddingModel,
|
||||
ChatProvider: settings.Chat.Provider,
|
||||
ChatBaseURL: settings.Chat.BaseURL,
|
||||
ChatAPIKey: chatKey,
|
||||
ChatModel: settings.Chat.Model,
|
||||
EmbeddingMode: settings.Embedding.Mode,
|
||||
EmbeddingProvider: settings.Embedding.Provider,
|
||||
EmbeddingBaseURL: settings.Embedding.BaseURL,
|
||||
EmbeddingAPIKey: embeddingKey,
|
||||
EmbeddingModel: settings.Embedding.Model,
|
||||
EmbeddingDimensions: settings.Embedding.Dimensions,
|
||||
Temperature: settings.Generation.Temperature,
|
||||
MaxTokens: settings.Generation.MaxTokens,
|
||||
TimeoutSeconds: settings.Request.TimeoutSeconds,
|
||||
MaxRetries: settings.Request.MaxRetries,
|
||||
}
|
||||
}
|
||||
|
||||
func copilotProviderPayload(settings CopilotProviderSettings, apiKey string) *CopilotProviderConfigPayload {
|
||||
configured := strings.TrimSpace(apiKey) != ""
|
||||
return &CopilotProviderConfigPayload{
|
||||
Provider: settings.Provider,
|
||||
BaseURL: settings.BaseURL,
|
||||
Model: settings.Model,
|
||||
APIKeyConfigured: configured,
|
||||
APIKeyMasked: maskCopilotAPIKey(apiKey),
|
||||
Configured: configured,
|
||||
func copilotConfigComplete(settings CopilotProviderSettings, chatKey, embeddingKey string) bool {
|
||||
if strings.TrimSpace(chatKey) == "" || strings.TrimSpace(settings.Chat.Model) == "" || strings.TrimSpace(settings.Chat.BaseURL) == "" {
|
||||
return false
|
||||
}
|
||||
if settings.Embedding.Mode == llm.EmbeddingModeSeparate && strings.TrimSpace(embeddingKey) == "" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func copilotProviderPayload(settings CopilotProviderSettings, chatKey, embeddingKey string, health *CopilotProviderHealth) *CopilotProviderConfigPayload {
|
||||
return &CopilotProviderConfigPayload{
|
||||
Chat: CopilotChatConfigPayload{
|
||||
Provider: settings.Chat.Provider,
|
||||
BaseURL: settings.Chat.BaseURL,
|
||||
Model: settings.Chat.Model,
|
||||
APIKey: CopilotSecretPayload{Configured: chatKey != "", Masked: maskCopilotAPIKey(chatKey)},
|
||||
},
|
||||
Embedding: CopilotEmbeddingConfigPayload{
|
||||
Mode: settings.Embedding.Mode,
|
||||
Provider: settings.Embedding.Provider,
|
||||
BaseURL: settings.Embedding.BaseURL,
|
||||
Model: settings.Embedding.Model,
|
||||
Dimensions: settings.Embedding.Dimensions,
|
||||
APIKey: CopilotSecretPayload{Configured: embeddingKey != "", Masked: maskCopilotAPIKey(embeddingKey)},
|
||||
},
|
||||
Generation: settings.Generation,
|
||||
Request: settings.Request,
|
||||
Configured: copilotConfigComplete(settings, chatKey, embeddingKey),
|
||||
Health: health,
|
||||
}
|
||||
}
|
||||
|
||||
func copilotConfigFingerprint(settings CopilotProviderSettings, chatKey, embeddingKey string) string {
|
||||
raw, _ := json.Marshal(struct {
|
||||
Settings CopilotProviderSettings `json:"settings"`
|
||||
ChatKey string `json:"chat_key"`
|
||||
EmbeddingKey string `json:"embedding_key"`
|
||||
}{settings, chatKey, embeddingKey})
|
||||
sum := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func maskCopilotAPIKey(apiKey string) string {
|
||||
@@ -260,3 +588,27 @@ func maskCopilotAPIKey(apiKey string) string {
|
||||
}
|
||||
return apiKey[:3] + "****" + apiKey[len(apiKey)-4:]
|
||||
}
|
||||
|
||||
func normalizeCopilotProviderError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
message := strings.TrimSpace(err.Error())
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded) || strings.Contains(strings.ToLower(message), "timeout"):
|
||||
return "provider request timed out"
|
||||
case strings.Contains(message, "401") || strings.Contains(message, "403"):
|
||||
return "provider authentication failed"
|
||||
case strings.Contains(message, "404"):
|
||||
return "provider endpoint or model was not found"
|
||||
case strings.Contains(message, "429"):
|
||||
return "provider rate limit exceeded"
|
||||
case strings.Contains(message, "connection refused") || strings.Contains(message, "no such host"):
|
||||
return "provider endpoint is unreachable"
|
||||
default:
|
||||
if len(message) > 240 {
|
||||
message = message[:240]
|
||||
}
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gochat/gochat/internal/llm"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/security"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
@@ -21,61 +21,105 @@ func setupCopilotConfigServiceTest(t *testing.T) (*CopilotConfigService, *gorm.D
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.InstallationConfig{}))
|
||||
encryptor, err := security.NewEncryptor(security.EncryptionConfig{
|
||||
AESKey: base64.StdEncoding.EncodeToString([]byte(strings.Repeat("k", 32))),
|
||||
KeyVersion: 1,
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
manager := llm.NewProviderManager()
|
||||
service := NewCopilotConfigService(repository.NewInstallationConfigRepo(db), encryptor, manager)
|
||||
return service, db, manager
|
||||
svc := NewCopilotConfigService(repository.NewInstallationConfigRepo(db), manager)
|
||||
return svc, db, manager
|
||||
}
|
||||
|
||||
func testCopilotInput(baseURL string) CopilotProviderConfigInput {
|
||||
return CopilotProviderConfigInput{
|
||||
Chat: CopilotChatConfigInput{
|
||||
Provider: "openai_compatible",
|
||||
BaseURL: baseURL,
|
||||
Model: "custom-model",
|
||||
CopilotSecretInput: CopilotSecretInput{APIKey: "secret-api-key"},
|
||||
},
|
||||
Embedding: CopilotEmbeddingConfigInput{
|
||||
Mode: llm.EmbeddingModeReuseChat,
|
||||
Provider: "openai_compatible",
|
||||
BaseURL: baseURL,
|
||||
Model: "custom-embedding",
|
||||
Dimensions: 3,
|
||||
},
|
||||
Generation: CopilotGenerationSettings{Temperature: 0.4, MaxTokens: 800},
|
||||
Request: CopilotRequestSettings{TimeoutSeconds: 30, MaxRetries: 0},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopilotConfigServiceDefaultsToUnconfigured(t *testing.T) {
|
||||
service, _, _ := setupCopilotConfigServiceTest(t)
|
||||
payload, err := service.Get(context.Background())
|
||||
svc, _, _ := setupCopilotConfigServiceTest(t)
|
||||
payload, err := svc.Get(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "openai", payload.Provider)
|
||||
assert.Equal(t, "gpt-4o-mini", payload.Model)
|
||||
assert.Equal(t, "openai", payload.Chat.Provider)
|
||||
assert.Equal(t, "gpt-4o-mini", payload.Chat.Model)
|
||||
assert.False(t, payload.Configured)
|
||||
}
|
||||
|
||||
func TestCopilotConfigServiceSavesEncryptedKeyAndConfiguresManager(t *testing.T) {
|
||||
service, db, manager := setupCopilotConfigServiceTest(t)
|
||||
payload, err := service.Update(context.Background(), CopilotProviderConfigInput{
|
||||
Provider: "openai_compatible",
|
||||
BaseURL: "https://llm.example.com/v1/",
|
||||
Model: "custom-model",
|
||||
APIKey: "secret-api-key",
|
||||
})
|
||||
func TestCopilotConfigServiceSavesPlaintextKeysAndConfiguresManager(t *testing.T) {
|
||||
svc, db, manager := setupCopilotConfigServiceTest(t)
|
||||
payload, err := svc.Update(context.Background(), testCopilotInput("https://llm.example.com/v1/"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, payload.Configured)
|
||||
assert.Equal(t, "sec****-key", payload.APIKeyMasked)
|
||||
assert.Equal(t, "sec****-key", payload.Chat.APIKey.Masked)
|
||||
|
||||
var stored model.InstallationConfig
|
||||
require.NoError(t, db.Where("name = ?", copilotAPIKeyConfigKey).First(&stored).Error)
|
||||
assert.NotEqual(t, "secret-api-key", stored.Value)
|
||||
assert.True(t, security.IsEncrypted(stored.Value))
|
||||
require.NoError(t, db.Where("name = ?", copilotChatAPIKeyConfigKey).First(&stored).Error)
|
||||
assert.Equal(t, "secret-api-key", stored.Value)
|
||||
|
||||
snapshot, configured := manager.Snapshot()
|
||||
assert.True(t, configured)
|
||||
assert.Equal(t, "custom-model", snapshot.Model)
|
||||
assert.Equal(t, "https://llm.example.com/v1", snapshot.BaseURL)
|
||||
assert.Equal(t, "custom-model", snapshot.ChatModel)
|
||||
assert.Equal(t, "https://llm.example.com/v1", snapshot.ChatBaseURL)
|
||||
assert.Equal(t, 0, snapshot.MaxRetries)
|
||||
}
|
||||
|
||||
func TestCopilotConfigServiceClearsActiveProvider(t *testing.T) {
|
||||
service, _, manager := setupCopilotConfigServiceTest(t)
|
||||
_, err := service.Update(context.Background(), CopilotProviderConfigInput{
|
||||
Provider: "openai",
|
||||
Model: "gpt-4o-mini",
|
||||
APIKey: "secret-api-key",
|
||||
})
|
||||
svc, _, manager := setupCopilotConfigServiceTest(t)
|
||||
_, err := svc.Update(context.Background(), testCopilotInput("https://llm.example.com/v1"))
|
||||
require.NoError(t, err)
|
||||
|
||||
payload, err := service.Update(context.Background(), CopilotProviderConfigInput{ClearAPIKey: true})
|
||||
input := testCopilotInput("https://llm.example.com/v1")
|
||||
input.Chat.APIKey = ""
|
||||
input.Chat.ClearAPIKey = true
|
||||
payload, err := svc.Update(context.Background(), input)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, payload.Configured)
|
||||
_, configured := manager.Snapshot()
|
||||
assert.False(t, configured)
|
||||
}
|
||||
|
||||
func TestCopilotConfigServiceRejectsAnthropicWithoutSeparateEmbedding(t *testing.T) {
|
||||
svc, _, _ := setupCopilotConfigServiceTest(t)
|
||||
input := testCopilotInput("https://api.anthropic.com")
|
||||
input.Chat.Provider = "anthropic"
|
||||
_, err := svc.Update(context.Background(), input)
|
||||
require.ErrorContains(t, err, "separate embedding provider")
|
||||
}
|
||||
|
||||
func TestCopilotConfigServiceTestsChatAndEmbeddingWithoutChangingSavedConfig(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/chat/completions":
|
||||
var req llm.ChatRequest
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
||||
_, _ = w.Write([]byte(`{"id":"chat-1","choices":[{"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}]}`))
|
||||
case "/embeddings":
|
||||
_, _ = w.Write([]byte(`{"data":[{"index":0,"embedding":[0.1,0.2,0.3]}],"model":"custom-embedding"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc, _, _ := setupCopilotConfigServiceTest(t)
|
||||
input := testCopilotInput(server.URL)
|
||||
health, err := svc.Test(context.Background(), input)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, health.Chat.OK)
|
||||
assert.True(t, health.Embedding.OK)
|
||||
|
||||
payload, err := svc.Get(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.False(t, payload.Configured, "testing candidate settings must not persist them")
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ func (s *CopilotService) generateCopilotMessages(ctx context.Context, accountID,
|
||||
}
|
||||
content := CopilotUnavailableMessage
|
||||
if s.llmProvider != nil {
|
||||
generated, err := s.generateAssistantContent(ctx, thread, "")
|
||||
generated, err := s.generateAssistantContent(ctx, accountID, thread, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -328,7 +328,7 @@ func (s *CopilotService) createAssistantReply(ctx context.Context, accountID, th
|
||||
}
|
||||
content := CopilotUnavailableMessage
|
||||
if s.llmProvider != nil {
|
||||
generated, err := s.generateAssistantContent(ctx, thread, userMsg.GetMessageContent())
|
||||
generated, err := s.generateAssistantContent(ctx, accountID, thread, userMsg.GetMessageContent())
|
||||
if err == nil && strings.TrimSpace(generated) != "" {
|
||||
content = generated
|
||||
} else if err != nil {
|
||||
@@ -344,7 +344,8 @@ func (s *CopilotService) createAssistantReply(ctx context.Context, accountID, th
|
||||
return s.messageRepo.GetByID(ctx, assistantMsg.ID)
|
||||
}
|
||||
|
||||
func (s *CopilotService) generateAssistantContent(ctx context.Context, thread *model.CopilotThread, content string) (string, error) {
|
||||
func (s *CopilotService) generateAssistantContent(ctx context.Context, accountID uint, thread *model.CopilotThread, content string) (string, error) {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "copilot")
|
||||
|
||||
// Build conversation history for LLM
|
||||
// Convert model.ChatMessage (from PreviousHistory) to llm.ChatMessage
|
||||
@@ -404,6 +405,7 @@ type SuggestedRepliesResult struct {
|
||||
// GetSuggestedReplies generates reply suggestions for a conversation.
|
||||
// Reference: Chatwoot Captain::AssistanceDriver#suggested_replies
|
||||
func (s *CopilotService) GetSuggestedReplies(ctx context.Context, accountID uint, conversationContext string) (*SuggestedRepliesResult, error) {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "copilot")
|
||||
systemPrompt := "You are an AI assistant helping a customer support agent. Based on the conversation context, suggest 3 concise reply options. Return them as a JSON array of strings."
|
||||
|
||||
messages := []llm.ChatMessage{
|
||||
@@ -445,6 +447,7 @@ type SummarizeConversationResult struct {
|
||||
// SummarizeConversation generates a summary of the conversation.
|
||||
// Reference: Chatwoot Captain::AssistanceDriver#summarize
|
||||
func (s *CopilotService) SummarizeConversation(ctx context.Context, accountID uint, conversationContext string) (*SummarizeConversationResult, error) {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "copilot")
|
||||
systemPrompt := "You are an AI assistant. Summarize the following customer support conversation concisely, highlighting key issues, resolution status, and any action items."
|
||||
|
||||
messages := []llm.ChatMessage{
|
||||
@@ -562,6 +565,7 @@ type TranslateResult struct {
|
||||
|
||||
// TranslateMessage translates a message to the target language using LLM.
|
||||
func (s *CopilotService) TranslateMessage(ctx context.Context, accountID uint, req *TranslateRequest) (*TranslateResult, error) {
|
||||
ctx = llm.WithAccountFeature(ctx, accountID, "copilot")
|
||||
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
|
||||
Messages: []llm.ChatMessage{
|
||||
{Role: "system", Content: fmt.Sprintf("You are a translator. Translate the user's message to %s. Return only the translated text, nothing else.", req.TargetLanguage)},
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
3. LLM Provider、Base URL、API Key 属于**平台级配置**,对整套 GoChat 安装生效。
|
||||
4. 功能开关、功能模型、回复风格属于**账户级配置**,只影响当前 Account。
|
||||
5. Assistant 的提示词、Guardrails、Response Guidelines、知识文档和 Inbox 绑定继续在 Assistant 页面管理,不塞入 Copilot 配置页。
|
||||
6. API Key 使用基于现有 JWT Secret 派生密钥的 AES-256-GCM 加密后落库;接口只返回掩码,日志禁止记录,页面不提供查看明文能力。
|
||||
6. API Key 以明文写入 `installation_configs.value`;接口只返回配置状态和掩码,日志禁止记录 Key 或 Authorization Header,页面不提供查看明文能力。
|
||||
7. Copilot 在系统中全局永久启用,不提供总开关,也不允许通过环境变量、配置文件或页面关闭。
|
||||
8. Copilot Provider 参数只从数据库读取,不定义任何 Copilot/Captain 环境变量。
|
||||
9. 除 Embedding 维度变化外,配置保存后应运行时生效,不要求重启服务。
|
||||
@@ -182,12 +182,13 @@ Assistant 自身的 Temperature、Guardrails、Response Guidelines 优先级高
|
||||
| InstallationConfig Name | 内容 |
|
||||
|---|---|
|
||||
| `COPILOT_PROVIDER_CONFIG` | 不含密钥的 JSON 配置 |
|
||||
| `COPILOT_API_KEY` | AES-256-GCM 加密后的 API Key |
|
||||
| `COPILOT_CHAT_API_KEY` | Chat Provider API Key 明文 |
|
||||
| `COPILOT_EMBEDDING_API_KEY` | 独立 Embedding Provider API Key 明文;复用 Chat 凭据时为空 |
|
||||
|
||||
### 8.2 API Key 处理要求
|
||||
|
||||
- Copilot API Key 使用 AES-256-GCM 加密后写入 `installation_configs.value`,加密密钥由现有 JWT Secret 派生。
|
||||
- 更换 JWT Secret 前必须先重新保存 Copilot API Key,否则旧密文将无法解密。
|
||||
- Copilot API Key 直接以明文写入 `installation_configs.value`,不做 AES-256-GCM 或其他应用层加密。
|
||||
- 数据库、备份和运维访问权限承担密钥保护责任;密钥不得进入普通配置导出、日志或错误响应。
|
||||
- API 响应只返回:`configured: true/false`、`masked_value: "sk-****abcd"`。
|
||||
- 更新请求中省略 `api_key` 表示保留原值;`clear_api_key: true` 才允许清除。
|
||||
- 错误日志、审计日志、连接测试响应均不得包含 Key、Authorization Header 或完整请求体。
|
||||
|
||||
Reference in New Issue
Block a user