From 0a69d80f7c74918ef4d4c9dec901bbb23ee1baa2 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 12 Jul 2026 22:54:33 +0800 Subject: [PATCH] feat(copilot): complete runtime provider configuration --- backend/internal/app/bootstrap.go | 23 +- .../api/v1/captain_preference_handler_test.go | 25 +- .../handler/api/v1/copilot_config_handler.go | 105 ++++ backend/internal/llm/anthropic_provider.go | 19 +- backend/internal/llm/openai_provider.go | 17 +- backend/internal/llm/provider_manager.go | 297 ++++++--- backend/internal/llm/provider_manager_test.go | 16 +- .../repository/installation_config_repo.go | 20 + backend/internal/router/router.go | 17 + .../service/captain_assistant_service.go | 2 + .../service/captain_preference_service.go | 204 ++++-- .../service/captain_task_extended_service.go | 4 + .../internal/service/captain_task_service.go | 6 + .../service/copilot_config_service.go | 592 ++++++++++++++---- .../service/copilot_config_service_test.go | 116 ++-- backend/internal/service/copilot_service.go | 10 +- .../plans/2026-07-12-copilot-configuration.md | 9 +- 17 files changed, 1163 insertions(+), 319 deletions(-) create mode 100644 backend/internal/handler/api/v1/copilot_config_handler.go diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index 4ec1be9e..e3f7b423 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -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), diff --git a/backend/internal/handler/api/v1/captain_preference_handler_test.go b/backend/internal/handler/api/v1/captain_preference_handler_test.go index 917d54ad..a91a06c3 100644 --- a/backend/internal/handler/api/v1/captain_preference_handler_test.go +++ b/backend/internal/handler/api/v1/captain_preference_handler_test.go @@ -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) { diff --git a/backend/internal/handler/api/v1/copilot_config_handler.go b/backend/internal/handler/api/v1/copilot_config_handler.go new file mode 100644 index 00000000..06d3cdb5 --- /dev/null +++ b/backend/internal/handler/api/v1/copilot_config_handler.go @@ -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) +} diff --git a/backend/internal/llm/anthropic_provider.go b/backend/internal/llm/anthropic_provider.go index c2c2705a..9a2d51eb 100644 --- a/backend/internal/llm/anthropic_provider.go +++ b/backend/internal/llm/anthropic_provider.go @@ -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"` diff --git a/backend/internal/llm/openai_provider.go b/backend/internal/llm/openai_provider.go index f01795c7..484f18d7 100644 --- a/backend/internal/llm/openai_provider.go +++ b/backend/internal/llm/openai_provider.go @@ -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 -} \ No newline at end of file +} diff --git a/backend/internal/llm/provider_manager.go b/backend/internal/llm/provider_manager.go index 908918c9..8c076d19 100644 --- a/backend/internal/llm/provider_manager.go +++ b/backend/internal/llm/provider_manager.go @@ -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) diff --git a/backend/internal/llm/provider_manager_test.go b/backend/internal/llm/provider_manager_test.go index b5fa2165..d4260b08 100644 --- a/backend/internal/llm/provider_manager_test.go +++ b/backend/internal/llm/provider_manager_test.go @@ -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{ diff --git a/backend/internal/repository/installation_config_repo.go b/backend/internal/repository/installation_config_repo.go index c1a26d58..eb01fbf5 100644 --- a/backend/internal/repository/installation_config_repo.go +++ b/backend/internal/repository/installation_config_repo.go @@ -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 diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 837e677e..4741339a 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) diff --git a/backend/internal/service/captain_assistant_service.go b/backend/internal/service/captain_assistant_service.go index c9d87955..3d03f50a 100644 --- a/backend/internal/service/captain_assistant_service.go +++ b/backend/internal/service/captain_assistant_service.go @@ -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) diff --git a/backend/internal/service/captain_preference_service.go b/backend/internal/service/captain_preference_service.go index 64b0fc6d..925c722d 100644 --- a/backend/internal/service/captain_preference_service.go +++ b/backend/internal/service/captain_preference_service.go @@ -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 diff --git a/backend/internal/service/captain_task_extended_service.go b/backend/internal/service/captain_task_extended_service.go index 0df5a5f2..46f53e10 100644 --- a/backend/internal/service/captain_task_extended_service.go +++ b/backend/internal/service/captain_task_extended_service.go @@ -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) diff --git a/backend/internal/service/captain_task_service.go b/backend/internal/service/captain_task_service.go index ca350fa1..c5b7b7de 100644 --- a/backend/internal/service/captain_task_service.go +++ b/backend/internal/service/captain_task_service.go @@ -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") } diff --git a/backend/internal/service/copilot_config_service.go b/backend/internal/service/copilot_config_service.go index c2116562..dd488134 100644 --- a/backend/internal/service/copilot_config_service.go +++ b/backend/internal/service/copilot_config_service.go @@ -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 + } +} diff --git a/backend/internal/service/copilot_config_service_test.go b/backend/internal/service/copilot_config_service_test.go index 251cb80b..e1a36240 100644 --- a/backend/internal/service/copilot_config_service_test.go +++ b/backend/internal/service/copilot_config_service_test.go @@ -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") +} diff --git a/backend/internal/service/copilot_service.go b/backend/internal/service/copilot_service.go index 37ee7f9d..4ecfa628 100644 --- a/backend/internal/service/copilot_service.go +++ b/backend/internal/service/copilot_service.go @@ -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)}, diff --git a/docs/plans/2026-07-12-copilot-configuration.md b/docs/plans/2026-07-12-copilot-configuration.md index e90b8198..e73828e0 100644 --- a/docs/plans/2026-07-12-copilot-configuration.md +++ b/docs/plans/2026-07-12-copilot-configuration.md @@ -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 或完整请求体。