658 lines
23 KiB
Go
658 lines
23 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/internal/llm"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
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 CopilotChatSettings 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 CopilotGenerationConfigInput struct {
|
|
Temperature *float64 `json:"temperature"`
|
|
MaxTokens *int `json:"max_tokens"`
|
|
}
|
|
|
|
type CopilotRequestConfigInput struct {
|
|
TimeoutSeconds *int `json:"timeout_seconds"`
|
|
MaxRetries *int `json:"max_retries"`
|
|
}
|
|
|
|
type CopilotProviderConfigInput struct {
|
|
Chat CopilotChatConfigInput `json:"chat"`
|
|
Embedding CopilotEmbeddingConfigInput `json:"embedding"`
|
|
Generation CopilotGenerationConfigInput `json:"generation"`
|
|
Request CopilotRequestConfigInput `json:"request"`
|
|
}
|
|
|
|
type CopilotSecretPayload struct {
|
|
Configured bool `json:"configured"`
|
|
Masked string `json:"masked_value,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 {
|
|
Chat CopilotChatConfigPayload `json:"chat"`
|
|
Embedding CopilotEmbeddingConfigPayload `json:"embedding"`
|
|
Generation CopilotGenerationSettings `json:"generation"`
|
|
Request CopilotRequestSettings `json:"request"`
|
|
Configured bool `json:"configured"`
|
|
AppliedAt *time.Time `json:"applied_at,omitempty"`
|
|
Health *CopilotProviderHealth `json:"health,omitempty"`
|
|
}
|
|
|
|
// CopilotConfigService owns the platform-wide provider configuration stored by
|
|
// 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
|
|
manager *llm.ProviderManager
|
|
}
|
|
|
|
func NewCopilotConfigService(repo *repository.InstallationConfigRepo, manager *llm.ProviderManager) *CopilotConfigService {
|
|
return &CopilotConfigService{repo: repo, manager: manager}
|
|
}
|
|
|
|
func defaultCopilotProviderSettings() CopilotProviderSettings {
|
|
return CopilotProviderSettings{
|
|
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},
|
|
}
|
|
}
|
|
|
|
func (s *CopilotConfigService) Initialize(ctx context.Context) error {
|
|
runtimeCfg, configured, err := s.loadRuntimeConfig(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !configured {
|
|
s.manager.Clear()
|
|
return nil
|
|
}
|
|
return s.manager.Configure(runtimeCfg)
|
|
}
|
|
|
|
func (s *CopilotConfigService) Get(ctx context.Context) (*CopilotProviderConfigPayload, error) {
|
|
settings, chatKey, embeddingKey, err := s.loadConfig(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
health, err := s.loadMatchingHealth(ctx, settings, chatKey, embeddingKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
appliedAt, err := s.loadAppliedAt(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return copilotProviderPayload(settings, chatKey, embeddingKey, appliedAt, health), nil
|
|
}
|
|
|
|
func (s *CopilotConfigService) Update(ctx context.Context, input CopilotProviderConfigInput) (*CopilotProviderConfigPayload, error) {
|
|
settings, chatKey, embeddingKey, err := s.mergedConfig(ctx, input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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)
|
|
}
|
|
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 !configured {
|
|
s.manager.Clear()
|
|
now := time.Now().UTC()
|
|
return copilotProviderPayload(settings, chatKey, embeddingKey, &now, nil), nil
|
|
}
|
|
if err := s.manager.Configure(runtimeCfg); err != nil {
|
|
return nil, err
|
|
}
|
|
now := time.Now().UTC()
|
|
return copilotProviderPayload(settings, chatKey, embeddingKey, &now, 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 != nil {
|
|
settings.Generation.MaxTokens = *input.Generation.MaxTokens
|
|
}
|
|
if input.Generation.Temperature != nil {
|
|
settings.Generation.Temperature = *input.Generation.Temperature
|
|
}
|
|
if input.Request.TimeoutSeconds != nil {
|
|
settings.Request.TimeoutSeconds = *input.Request.TimeoutSeconds
|
|
}
|
|
if input.Request.MaxRetries != nil {
|
|
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, chatKey, embeddingKey, err := s.loadConfig(ctx)
|
|
if err != nil {
|
|
return llm.RuntimeProviderConfig{}, false, err
|
|
}
|
|
if !copilotConfigComplete(settings, chatKey, embeddingKey) {
|
|
return llm.RuntimeProviderConfig{}, false, 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) {
|
|
settings := defaultCopilotProviderSettings()
|
|
record, err := s.repo.FindByName(ctx, copilotProviderConfigKey)
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return settings, nil
|
|
}
|
|
if err != nil {
|
|
return settings, fmt.Errorf("load Copilot provider config: %w", err)
|
|
}
|
|
if err := json.Unmarshal([]byte(record.Value), &settings); err != nil {
|
|
return settings, fmt.Errorf("decode Copilot provider config: %w", err)
|
|
}
|
|
return normalizeCopilotProviderSettings(settings), nil
|
|
}
|
|
|
|
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 %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
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load Copilot provider health: %w", err)
|
|
}
|
|
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 (s *CopilotConfigService) loadAppliedAt(ctx context.Context) (*time.Time, error) {
|
|
record, err := s.repo.FindByName(ctx, copilotProviderConfigKey)
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load Copilot provider applied time: %w", err)
|
|
}
|
|
appliedAt := record.UpdatedAt.UTC()
|
|
return &appliedAt, nil
|
|
}
|
|
|
|
func normalizeCopilotProviderSettings(settings CopilotProviderSettings) CopilotProviderSettings {
|
|
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.Chat.BaseURL == "" {
|
|
settings.Chat.BaseURL = "https://api.openai.com/v1"
|
|
}
|
|
case "anthropic":
|
|
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 {
|
|
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.Chat.Model == "" {
|
|
return fmt.Errorf("Copilot chat model is required")
|
|
}
|
|
if err := validateCopilotURL(settings.Chat.BaseURL, "chat"); err != nil {
|
|
return err
|
|
}
|
|
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 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)
|
|
}
|
|
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
return fmt.Errorf("Copilot %s base URL must not contain credentials, query parameters, or fragments", kind)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runtimeProviderConfig(settings CopilotProviderSettings, chatKey, embeddingKey string) llm.RuntimeProviderConfig {
|
|
return llm.RuntimeProviderConfig{
|
|
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 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, appliedAt *time.Time, 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),
|
|
AppliedAt: appliedAt,
|
|
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 {
|
|
if apiKey == "" {
|
|
return ""
|
|
}
|
|
if len(apiKey) <= 8 {
|
|
return "********"
|
|
}
|
|
return apiKey[:3] + "****" + apiKey[len(apiKey)-4:]
|
|
}
|
|
|
|
func normalizeCopilotProviderError(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
var providerErr *llm.APIError
|
|
if errors.As(err, &providerErr) {
|
|
switch providerErr.StatusCode {
|
|
case http.StatusUnauthorized, http.StatusForbidden:
|
|
return "provider authentication failed"
|
|
case http.StatusNotFound:
|
|
return "provider endpoint or model was not found"
|
|
case http.StatusTooManyRequests:
|
|
return "provider rate limit exceeded"
|
|
default:
|
|
return "provider request failed"
|
|
}
|
|
}
|
|
|
|
message := strings.ToLower(strings.TrimSpace(err.Error()))
|
|
var networkErr net.Error
|
|
switch {
|
|
case errors.Is(err, context.DeadlineExceeded) || errors.As(err, &networkErr) && networkErr.Timeout() || strings.Contains(message, "timeout"):
|
|
return "provider request timed out"
|
|
case strings.Contains(message, "connection refused") || strings.Contains(message, "no such host"):
|
|
return "provider endpoint is unreachable"
|
|
case strings.Contains(message, "unmarshal") || strings.Contains(message, "response format"):
|
|
return "provider response format is incompatible"
|
|
default:
|
|
return "provider request failed"
|
|
}
|
|
}
|