Files
gochat/backend/internal/service/copilot_config_service.go
T

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"
}
}