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,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)
}