fix(copilot): align runtime configuration contracts

This commit is contained in:
2026-07-13 14:57:28 +08:00
parent 8b9eedc0e2
commit 19cee9af11
12 changed files with 288 additions and 8 deletions
@@ -122,6 +122,8 @@ func TestCopilotConfigHandlerPlatformPermissionsAndSecretPresentation(t *testing
require.Equal(t, http.StatusOK, updated.Code, updated.Body.String())
require.NotContains(t, updated.Body.String(), "plain-secret-key")
require.Contains(t, updated.Body.String(), "pla****-key")
require.Contains(t, updated.Body.String(), `"masked_value":"pla****-key"`)
require.NotContains(t, updated.Body.String(), `"masked":`)
var stored model.InstallationConfig
require.NoError(t, f.db.Where("name = ?", "COPILOT_CHAT_API_KEY").First(&stored).Error)
@@ -140,6 +142,7 @@ func TestCopilotConfigHandlerPlatformPermissionsAndSecretPresentation(t *testing
require.Equal(t, http.StatusOK, accountPayload.Code, accountPayload.Body.String())
require.NotContains(t, accountPayload.Body.String(), "plain-secret-key")
require.NotContains(t, accountPayload.Body.String(), "pla****-key")
require.NotContains(t, accountPayload.Body.String(), "masked_value")
require.Contains(t, accountPayload.Body.String(), `"configured":true`)
agentPayload := f.request(http.MethodGet, "/forbidden"+accountPath, nil)
+20
View File
@@ -48,6 +48,12 @@ type accountFeatureContext struct {
Feature string
}
type generationOverrideContextKey struct{}
type generationOverrideContext struct {
Temperature *float64
}
// 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 {
@@ -60,6 +66,17 @@ func WithAccountFeature(ctx context.Context, accountID uint, feature string) con
})
}
// WithTemperatureOverride preserves an explicitly configured Assistant
// temperature while keeping the platform value as the default elsewhere.
func WithTemperatureOverride(ctx context.Context, temperature float64) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, generationOverrideContextKey{}, generationOverrideContext{
Temperature: &temperature,
})
}
type providerSnapshot struct {
chat Provider
embedding Provider
@@ -253,6 +270,9 @@ func resolveFeatureModel(ctx context.Context, resolver AccountModelResolver, fal
func applyRuntimeChatConfig(ctx context.Context, req ChatRequest, cfg RuntimeProviderConfig, resolver AccountModelResolver) ChatRequest {
req.Model = resolveFeatureModel(ctx, resolver, cfg.ChatModel)
req.Temperature = cfg.Temperature
if override, ok := ctx.Value(generationOverrideContextKey{}).(generationOverrideContext); ok && override.Temperature != nil {
req.Temperature = *override.Temperature
}
req.MaxTokens = cfg.MaxTokens
return req
}
@@ -51,6 +51,33 @@ func TestProviderManagerUsesAccountFeatureModelAndGenerationSettings(t *testing.
assert.Equal(t, 777, request.MaxTokens)
}
func TestProviderManagerPreservesExplicitAssistantTemperature(t *testing.T) {
var request ChatRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"chat-1","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`))
}))
defer server.Close()
manager := NewProviderManager()
require.NoError(t, manager.Configure(RuntimeProviderConfig{
ChatProvider: "openai_compatible",
ChatBaseURL: server.URL,
ChatAPIKey: "test-key",
ChatModel: "platform-model",
EmbeddingMode: EmbeddingModeReuseChat,
Temperature: 0.9,
MaxTokens: 777,
}))
ctx := WithTemperatureOverride(context.Background(), 0.2)
_, err := manager.ChatCompletion(ctx, ChatRequest{})
require.NoError(t, err)
assert.Equal(t, 0.2, request.Temperature)
assert.Equal(t, 777, request.MaxTokens)
}
func TestProviderManagerUsesSeparateEmbeddingProvider(t *testing.T) {
var chatCalls atomic.Int32
chatServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -91,6 +118,69 @@ func TestProviderManagerUsesSeparateEmbeddingProvider(t *testing.T) {
assert.Equal(t, 3, embeddingRequest.Dimensions)
}
func TestProviderManagerUsesAnthropicChatWithSeparateCompatibleEmbedding(t *testing.T) {
var (
anthropicRequest anthropicRequest
embeddingRequest EmbeddingRequest
anthropicKey string
embeddingAuth string
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/v1/messages":
anthropicKey = r.Header.Get("x-api-key")
require.NoError(t, json.NewDecoder(r.Body).Decode(&anthropicRequest))
_, _ = w.Write([]byte(`{"id":"msg-1","type":"message","role":"assistant","content":[{"type":"text","text":"anthropic ok"}],"model":"claude-test","stop_reason":"end_turn","usage":{"input_tokens":2,"output_tokens":3}}`))
case "/embeddings":
embeddingAuth = r.Header.Get("Authorization")
require.NoError(t, json.NewDecoder(r.Body).Decode(&embeddingRequest))
_, _ = w.Write([]byte(`{"data":[{"index":0,"embedding":[0.1,0.2,0.3]}],"model":"embed-test"}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
manager := NewProviderManager()
require.NoError(t, manager.Configure(RuntimeProviderConfig{
ChatProvider: "anthropic",
ChatBaseURL: server.URL,
ChatAPIKey: "anthropic-key",
ChatModel: "claude-test",
EmbeddingMode: EmbeddingModeSeparate,
EmbeddingProvider: "openai_compatible",
EmbeddingBaseURL: server.URL,
EmbeddingAPIKey: "embedding-key",
EmbeddingModel: "embed-test",
EmbeddingDimensions: 3,
Temperature: 0.4,
MaxTokens: 321,
}))
chatResponse, err := manager.ChatCompletion(context.Background(), ChatRequest{
Messages: []ChatMessage{
{Role: "system", Content: "system prompt"},
{Role: "user", Content: "hello"},
},
})
require.NoError(t, err)
require.Len(t, chatResponse.Choices, 1)
assert.Equal(t, "anthropic ok", chatResponse.Choices[0].Message.Content)
assert.Equal(t, "anthropic-key", anthropicKey)
assert.Equal(t, "claude-test", anthropicRequest.Model)
assert.Equal(t, "system prompt", anthropicRequest.System)
assert.Equal(t, 0.4, anthropicRequest.Temperature)
assert.Equal(t, 321, anthropicRequest.MaxTokens)
embeddingResponse, err := manager.CreateEmbedding(context.Background(), EmbeddingRequest{Input: []string{"hello"}})
require.NoError(t, err)
require.Len(t, embeddingResponse.Data, 1)
assert.Equal(t, "Bearer embedding-key", embeddingAuth)
assert.Equal(t, "embed-test", embeddingRequest.Model)
assert.Equal(t, 3, embeddingRequest.Dimensions)
}
func TestProviderManagerExplicitZeroRetriesDoesNotRetry(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+5
View File
@@ -103,6 +103,7 @@ func DefaultAssistantConfig() map[string]interface{} {
// Reference: Chatwoot store_accessor :config, :temperature, :feature_faq, etc.
type AssistantConfig struct {
Temperature float64 `json:"temperature,omitempty"`
TemperatureConfigured bool `json:"-"`
FeatureFAQ bool `json:"feature_faq,omitempty"`
FeatureMemory bool `json:"feature_memory,omitempty"`
FeatureContactAttributes bool `json:"feature_contact_attributes,omitempty"`
@@ -119,6 +120,10 @@ func (a *CaptainAssistant) GetConfig() (*AssistantConfig, error) {
if err := json.Unmarshal(a.Config, &cfg); err != nil {
return nil, err
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(a.Config, &fields); err == nil {
_, cfg.TemperatureConfigured = fields["temperature"]
}
return &cfg, nil
}
@@ -416,6 +416,7 @@ func (s *AutoReplyRuleService) composeLLMReply(ctx context.Context, rule *model.
cfg, _ := assistant.GetConfig()
ctx = llm.WithAccountFeature(ctx, assistant.AccountID, "assistant")
ctx = withAssistantGenerationConfig(ctx, cfg)
// Build system prompt
systemPrompt := s.promptBuilder.BuildAssistantPrompt(assistant, cfg)
@@ -435,7 +436,7 @@ func (s *AutoReplyRuleService) composeLLMReply(ctx context.Context, rule *model.
modelName := cfg.Model
temperature := cfg.Temperature
if temperature == 0 {
if temperature == 0 && !cfg.TemperatureConfigured {
temperature = 0.7
}
@@ -294,6 +294,7 @@ func (s *CaptainAssistantService) GenerateResponse(ctx context.Context, assistan
// Build system prompt from assistant config and response guidelines
cfg, _ := assistant.GetConfig()
ctx = withAssistantGenerationConfig(ctx, cfg)
systemPrompt := buildSystemPrompt(assistant, cfg)
// Build messages for LLM
@@ -363,6 +364,7 @@ func (s *CaptainAssistantService) generatePlaygroundLLMResponse(ctx context.Cont
}
cfg, _ := assistant.GetConfig()
ctx = withAssistantGenerationConfig(ctx, cfg)
messages := []llm.ChatMessage{{Role: "system", Content: buildSystemPrompt(assistant, cfg)}}
for _, message := range history {
if message.Role == "" || message.Content == "" {
@@ -387,6 +389,13 @@ func (s *CaptainAssistantService) generatePlaygroundLLMResponse(ctx context.Cont
return resp.Choices[0].Message.Content, nil
}
func withAssistantGenerationConfig(ctx context.Context, cfg *model.AssistantConfig) context.Context {
if cfg == nil || !cfg.TemperatureConfigured {
return ctx
}
return llm.WithTemperatureOverride(ctx, cfg.Temperature)
}
func (s *CaptainAssistantService) captainV2Enabled(ctx context.Context, accountID uint) bool {
flags, err := s.assistantRepo.GetAccountFeatureFlags(ctx, accountID)
if err != nil {
@@ -135,6 +135,7 @@ func (s *CaptainConversationService) generateConversationResponse(ctx context.Co
// Build system prompt from assistant config (not hardcoded)
cfg, _ := assistant.GetConfig()
ctx = withAssistantGenerationConfig(ctx, cfg)
systemPrompt := fmt.Sprintf("You are %s, a customer support assistant.", assistant.Name)
if cfg.ProductName != "" {
systemPrompt += fmt.Sprintf(" You represent the product: %s.", cfg.ProductName)
@@ -154,7 +155,7 @@ func (s *CaptainConversationService) generateConversationResponse(ctx context.Co
modelName := cfg.Model
temperature := cfg.Temperature
if temperature == 0 {
if temperature == 0 && !cfg.TemperatureConfigured {
temperature = 0.7
}
@@ -96,7 +96,7 @@ type CopilotProviderConfigInput struct {
type CopilotSecretPayload struct {
Configured bool `json:"configured"`
Masked string `json:"masked,omitempty"`
Masked string `json:"masked_value,omitempty"`
}
type CopilotChatConfigPayload struct {
@@ -0,0 +1,150 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/datatypes"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/gochat/gochat/internal/llm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
)
func TestCopilotFeatureModelsReachProviderRequests(t *testing.T) {
type capturedRequest struct {
Model string
Temperature float64
MaxTokens int
}
var (
mu sync.Mutex
captured []capturedRequest
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request llm.ChatRequest
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
mu.Lock()
captured = append(captured, capturedRequest{
Model: request.Model,
Temperature: request.Temperature,
MaxTokens: request.MaxTokens,
})
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"chat-1","choices":[{"message":{"role":"assistant","content":"billing,vip"},"finish_reason":"stop"}]}`))
}))
defer server.Close()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&model.Account{},
&model.CaptainAssistant{},
&model.Conversation{},
&model.Message{},
))
t.Cleanup(func() {
sqlDB, dbErr := db.DB()
require.NoError(t, dbErr)
require.NoError(t, sqlDB.Close())
})
account := &model.Account{
Name: "Feature Models",
CaptainModels: datatypes.JSON([]byte(`{
"editor":"editor-model",
"copilot":"copilot-model",
"assistant":"assistant-model",
"label_suggestion":"label-model"
}`)),
}
require.NoError(t, db.Create(account).Error)
assistant := &model.CaptainAssistant{
AccountID: account.ID,
Name: "Feature Assistant",
Status: model.AssistantStatusActive,
Config: json.RawMessage(`{"temperature":0.2}`),
}
require.NoError(t, db.Create(assistant).Error)
conversation := &model.Conversation{AccountID: account.ID, Status: "open"}
require.NoError(t, db.Create(conversation).Error)
require.NoError(t, db.Create(&model.Message{
AccountID: account.ID,
ConversationID: conversation.ID,
MessageType: string(model.MessageTypeIncoming),
SenderType: "contact",
Content: "I need billing help",
}).Error)
accountRepo := repository.NewAccountRepo(db)
manager := llm.NewProviderManager()
manager.SetAccountModelResolver(func(ctx context.Context, accountID uint, feature string) (string, error) {
current, findErr := accountRepo.FindByID(ctx, accountID)
if findErr != nil {
return "", findErr
}
models := map[string]string{}
require.NoError(t, json.Unmarshal(current.CaptainModels, &models))
return models[feature], nil
})
require.NoError(t, manager.Configure(llm.RuntimeProviderConfig{
ChatProvider: "openai_compatible",
ChatBaseURL: server.URL,
ChatAPIKey: "test-key",
ChatModel: "platform-model",
EmbeddingMode: llm.EmbeddingModeReuseChat,
Temperature: 0.9,
MaxTokens: 777,
}))
editorService := NewCaptainTaskService(nil, nil, nil, nil, nil, manager, nil)
_, err = editorService.Rewrite(context.Background(), account.ID, &TaskRewriteRequest{Content: "draft", Operation: "improve"})
require.NoError(t, err)
copilotService := NewCopilotService(nil, nil, nil, manager)
_, err = copilotService.SummarizeConversation(context.Background(), account.ID, "customer conversation")
require.NoError(t, err)
assistantService := NewCaptainAssistantService(repository.NewCaptainAssistantRepo(db), nil, nil, nil, manager)
_, err = assistantService.GenerateResponse(context.Background(), assistant.ID, "help me")
require.NoError(t, err)
labelService := NewCaptainTaskExtendedService(
repository.NewConversationRepo(db),
repository.NewMessageRepo(db),
nil,
nil,
manager,
)
_, err = labelService.LabelSuggestion(context.Background(), account.ID, &ChatwootLabelSuggestionRequest{
ConversationDisplayID: conversation.ID,
})
require.NoError(t, err)
mu.Lock()
defer mu.Unlock()
require.Len(t, captured, 4)
assert.Equal(t, []string{"editor-model", "copilot-model", "assistant-model", "label-model"}, []string{
captured[0].Model,
captured[1].Model,
captured[2].Model,
captured[3].Model,
})
assert.Equal(t, 0.9, captured[0].Temperature)
assert.Equal(t, 0.9, captured[1].Temperature)
assert.Equal(t, 0.2, captured[2].Temperature)
assert.Equal(t, 0.9, captured[3].Temperature)
for _, request := range captured {
assert.Equal(t, 777, request.MaxTokens)
}
}
+3 -2
View File
@@ -101,6 +101,7 @@ func (s *RAGService) Query(ctx context.Context, accountID uint, req *RAGQueryReq
applogger.L().Warnf("RAG get assistant config: %v", err)
cfg = &model.AssistantConfig{}
}
ctx = withAssistantGenerationConfig(ctx, cfg)
// Step 2: Generate embedding for the question
embedResp, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{
@@ -171,7 +172,7 @@ func (s *RAGService) Query(ctx context.Context, accountID uint, req *RAGQueryReq
// Step 6: Call LLM for answer generation
modelName := cfg.Model
temperature := cfg.Temperature
if temperature == 0 {
if temperature == 0 && !cfg.TemperatureConfigured {
temperature = 0.3 // lower temp for factual answers
}
@@ -211,7 +212,7 @@ func (s *RAGService) queryWithoutContext(ctx context.Context, assistant *model.C
modelName := cfg.Model
temperature := cfg.Temperature
if temperature == 0 {
if temperature == 0 && !cfg.TemperatureConfigured {
temperature = 0.5
}
@@ -7,7 +7,7 @@ const configuredProvider = {
provider: 'openai',
base_url: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
api_key: { configured: true, masked: 'sec****-key' },
api_key: { configured: true, masked_value: 'sec****-key' },
},
embedding: {
mode: 'reuse_chat_credentials',
@@ -296,7 +296,7 @@ const resultClass = ok =>
'CAPTAIN_SETTINGS.PROVIDER.API_KEY_CONFIGURED',
{
masked:
config.chat.api_key.masked ||
config.chat.api_key.masked_value ||
t(
'CAPTAIN_SETTINGS.PROVIDER.API_KEY_MASKED'
),
@@ -393,7 +393,7 @@ const resultClass = ok =>
'CAPTAIN_SETTINGS.PROVIDER.API_KEY_CONFIGURED',
{
masked:
config.embedding.api_key.masked ||
config.embedding.api_key.masked_value ||
t(
'CAPTAIN_SETTINGS.PROVIDER.API_KEY_MASKED'
),