feat(copilot): complete runtime provider configuration

This commit is contained in:
2026-07-13 14:57:28 +08:00
parent 6b42853c0b
commit 0a69d80f7c
17 changed files with 1163 additions and 319 deletions
+12 -11
View File
@@ -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)
}
+10 -9
View File
@@ -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"`
+9 -8
View File
@@ -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
}
}
+226 -71
View File
@@ -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
+17
View File
@@ -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")
}
+472 -120
View File
@@ -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")
}
+7 -3
View File
@@ -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 或完整请求体。