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"` } type CopilotRuntimeConfigInput struct { ProviderConfig string ChatAPIKey string EmbeddingAPIKey string } // CopilotConfigService owns the platform-wide provider configuration. Saved // settings take precedence; local runtime values are never persisted. type CopilotConfigService struct { repo *repository.InstallationConfigRepo manager *llm.ProviderManager } func NewCopilotConfigService(repo *repository.InstallationConfigRepo, manager *llm.ProviderManager) *CopilotConfigService { if manager == nil { manager = llm.NewProviderManager() } 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 { return s.InitializeWithRuntime(ctx, CopilotRuntimeConfigInput{}) } func (s *CopilotConfigService) InitializeWithRuntime(ctx context.Context, input CopilotRuntimeConfigInput) error { runtimeCfg, configured, err := s.loadRuntimeConfig(ctx) if err != nil { return err } if configured { return s.manager.Configure(runtimeCfg) } runtimeCfg, configured, err = parseCopilotRuntimeFallback(input) 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 } if !copilotConfigComplete(settings, chatKey, embeddingKey) { if runtimeCfg, configured := s.manager.Snapshot(); configured { settings = copilotProviderSettingsFromRuntime(runtimeCfg) chatKey = "********" if runtimeCfg.EmbeddingMode == llm.EmbeddingModeSeparate { embeddingKey = "********" } return copilotProviderPayload(settings, chatKey, embeddingKey, nil, nil), nil } } 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 parseCopilotRuntimeFallback(input CopilotRuntimeConfigInput) (llm.RuntimeProviderConfig, bool, error) { raw := strings.TrimSpace(input.ProviderConfig) chatKey := strings.TrimSpace(input.ChatAPIKey) embeddingKey := strings.TrimSpace(input.EmbeddingAPIKey) if raw == "" && chatKey == "" && embeddingKey == "" { return llm.RuntimeProviderConfig{}, false, nil } settings := defaultCopilotProviderSettings() if raw != "" { if err := json.Unmarshal([]byte(raw), &settings); err != nil { return llm.RuntimeProviderConfig{}, false, fmt.Errorf("decode runtime Copilot provider config: %w", err) } } settings = normalizeCopilotProviderSettings(settings) if err := validateCopilotProviderSettings(settings); err != nil { return llm.RuntimeProviderConfig{}, false, err } if !copilotConfigComplete(settings, chatKey, embeddingKey) { return llm.RuntimeProviderConfig{}, false, fmt.Errorf("runtime Copilot provider config is incomplete: %w", llm.ErrProviderNotConfigured) } return runtimeProviderConfig(settings, chatKey, embeddingKey), true, nil } func copilotProviderSettingsFromRuntime(cfg llm.RuntimeProviderConfig) CopilotProviderSettings { return CopilotProviderSettings{ Chat: CopilotChatSettings{Provider: cfg.ChatProvider, BaseURL: cfg.ChatBaseURL, Model: cfg.ChatModel}, Embedding: CopilotEmbeddingSettings{ Mode: cfg.EmbeddingMode, Provider: cfg.EmbeddingProvider, BaseURL: cfg.EmbeddingBaseURL, Model: cfg.EmbeddingModel, Dimensions: cfg.EmbeddingDimensions, }, Generation: CopilotGenerationSettings{Temperature: cfg.Temperature, MaxTokens: cfg.MaxTokens}, Request: CopilotRequestSettings{TimeoutSeconds: cfg.TimeoutSeconds, MaxRetries: cfg.MaxRetries}, } } 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" } }