// Package services provides domain service implementations. package services import ( "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/domain/errors" "github.com/router-for-me/CLIProxyAPI/v6/internal/domain/ports" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" ) const ( latestReleaseURL = "https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest" latestReleaseUserAgent = "CLIProxyAPI" ) // Logger interface for domain services type Logger interface { Debug(ctx context.Context, message string) Info(ctx context.Context, message string) Warn(ctx context.Context, message string) Error(ctx context.Context, message string, err error) Debugf(ctx context.Context, format string, args ...interface{}) Infof(ctx context.Context, format string, args ...interface{}) } // ConfigService implements the ports.ConfigService interface type ConfigService struct { repo ports.ConfigRepository logger Logger } // NewConfigService creates a new ConfigService func NewConfigService(repo ports.ConfigRepository, logger Logger) *ConfigService { return &ConfigService{ repo: repo, logger: logger, } } // GetConfig retrieves the current configuration func (s *ConfigService) GetConfig(ctx context.Context) (*config.Config, error) { if s.logger != nil { s.logger.Debug(ctx, "retrieving configuration") } cfg, err := s.repo.Load(ctx) if err != nil { if s.logger != nil { s.logger.Error(ctx, "failed to load configuration", err) } return nil, err } return cfg, nil } // UpdateConfig updates the entire configuration func (s *ConfigService) UpdateConfig(ctx context.Context, cfg *config.Config) error { if cfg == nil { return errors.New(errors.InvalidInput, "configuration is nil") } if s.logger != nil { s.logger.Debug(ctx, "updating configuration") } // Validate before saving if err := s.ValidateConfig(ctx, cfg); err != nil { return err } if err := s.repo.Save(ctx, cfg); err != nil { if s.logger != nil { s.logger.Error(ctx, "failed to save configuration", err) } return err } if s.logger != nil { s.logger.Info(ctx, "configuration updated successfully") } return nil } // UpdateField updates a single configuration field func (s *ConfigService) UpdateField(ctx context.Context, field string, value interface{}) error { if field == "" { return errors.New(errors.InvalidInput, "field name is empty") } cfg, err := s.GetConfig(ctx) if err != nil { return err } // Map field names to config fields if err := s.setField(cfg, field, value); err != nil { return err } return s.UpdateConfig(ctx, cfg) } // setField sets a specific field on the configuration func (s *ConfigService) setField(cfg *config.Config, field string, value interface{}) error { field = strings.ToLower(field) switch field { case "debug": v, ok := value.(bool) if !ok { return errors.NewValidationError("invalid value type for debug", "debug", "expected boolean") } cfg.Debug = v case "usage_statistics_enabled": v, ok := value.(bool) if !ok { return errors.NewValidationError("invalid value type for usage_statistics_enabled", "usage_statistics_enabled", "expected boolean") } cfg.UsageStatisticsEnabled = v case "logging_to_file": v, ok := value.(bool) if !ok { return errors.NewValidationError("invalid value type for logging_to_file", "logging_to_file", "expected boolean") } cfg.LoggingToFile = v case "logs_max_total_size_mb": v, ok := toInt(value) if !ok || v < 0 { return errors.NewValidationError("invalid value for logs_max_total_size_mb", "logs_max_total_size_mb", "expected non-negative integer") } cfg.LogsMaxTotalSizeMB = v case "request_log": v, ok := value.(bool) if !ok { return errors.NewValidationError("invalid value type for request_log", "request_log", "expected boolean") } cfg.RequestLog = v case "websocket_auth": v, ok := value.(bool) if !ok { return errors.NewValidationError("invalid value type for websocket_auth", "websocket_auth", "expected boolean") } cfg.WebsocketAuth = v case "request_retry": v, ok := toInt(value) if !ok { return errors.NewValidationError("invalid value for request_retry", "request_retry", "expected integer") } cfg.RequestRetry = v case "max_retry_interval": v, ok := toInt(value) if !ok { return errors.NewValidationError("invalid value for max_retry_interval", "max_retry_interval", "expected integer") } cfg.MaxRetryInterval = v case "force_model_prefix": v, ok := value.(bool) if !ok { return errors.NewValidationError("invalid value type for force_model_prefix", "force_model_prefix", "expected boolean") } cfg.ForceModelPrefix = v case "proxy_url": v, ok := value.(string) if !ok { return errors.NewValidationError("invalid value type for proxy_url", "proxy_url", "expected string") } cfg.ProxyURL = strings.TrimSpace(v) case "routing_strategy": v, ok := value.(string) if !ok { return errors.NewValidationError("invalid value type for routing_strategy", "routing_strategy", "expected string") } normalized, ok := s.normalizeRoutingStrategy(v) if !ok { return errors.NewValidationError("invalid routing strategy", "routing_strategy", "expected 'round-robin' or 'fill-first'") } cfg.Routing.Strategy = normalized default: return errors.NewValidationError("unknown configuration field", field, "") } return nil } // UpdateAPIKeys updates the API keys list func (s *ConfigService) UpdateAPIKeys(ctx context.Context, keys []string) error { cfg, err := s.GetConfig(ctx) if err != nil { return err } cfg.APIKeys = make([]string, len(keys)) copy(cfg.APIKeys, keys) cfg.Access.Providers = nil // Reset providers cache return s.UpdateConfig(ctx, cfg) } // UpdateGeminiKeys updates the Gemini keys list func (s *ConfigService) UpdateGeminiKeys(ctx context.Context, keys []config.GeminiKey) error { cfg, err := s.GetConfig(ctx) if err != nil { return err } cfg.GeminiKey = make([]config.GeminiKey, len(keys)) copy(cfg.GeminiKey, keys) cfg.SanitizeGeminiKeys() return s.UpdateConfig(ctx, cfg) } // UpdateClaudeKeys updates the Claude keys list func (s *ConfigService) UpdateClaudeKeys(ctx context.Context, keys []config.ClaudeKey) error { cfg, err := s.GetConfig(ctx) if err != nil { return err } cfg.ClaudeKey = make([]config.ClaudeKey, len(keys)) copy(cfg.ClaudeKey, keys) cfg.SanitizeClaudeKeys() return s.UpdateConfig(ctx, cfg) } // UpdateCodexKeys updates the Codex keys list func (s *ConfigService) UpdateCodexKeys(ctx context.Context, keys []config.CodexKey) error { cfg, err := s.GetConfig(ctx) if err != nil { return err } // Filter out entries with empty base-url filtered := make([]config.CodexKey, 0, len(keys)) for _, key := range keys { if strings.TrimSpace(key.BaseURL) != "" { filtered = append(filtered, key) } } cfg.CodexKey = filtered cfg.SanitizeCodexKeys() return s.UpdateConfig(ctx, cfg) } // UpdateOpenAICompatibility updates the OpenAI compatibility entries func (s *ConfigService) UpdateOpenAICompatibility(ctx context.Context, entries []config.OpenAICompatibility) error { cfg, err := s.GetConfig(ctx) if err != nil { return err } // Filter and normalize entries filtered := make([]config.OpenAICompatibility, 0, len(entries)) for _, entry := range entries { s.normalizeOpenAICompatibilityEntry(&entry) if strings.TrimSpace(entry.BaseURL) != "" { filtered = append(filtered, entry) } } cfg.OpenAICompatibility = filtered cfg.SanitizeOpenAICompatibility() return s.UpdateConfig(ctx, cfg) } // UpdateVertexCompatKeys updates the Vertex compatibility keys func (s *ConfigService) UpdateVertexCompatKeys(ctx context.Context, keys []config.VertexCompatKey) error { cfg, err := s.GetConfig(ctx) if err != nil { return err } cfg.VertexCompatAPIKey = make([]config.VertexCompatKey, len(keys)) copy(cfg.VertexCompatAPIKey, keys) cfg.SanitizeVertexCompatKeys() return s.UpdateConfig(ctx, cfg) } // UpdateKiroKeys updates the Kiro keys list func (s *ConfigService) UpdateKiroKeys(ctx context.Context, keys []config.KiroKey) error { cfg, err := s.GetConfig(ctx) if err != nil { return err } cfg.KiroKey = make([]config.KiroKey, len(keys)) copy(cfg.KiroKey, keys) return s.UpdateConfig(ctx, cfg) } // UpdateOAuthExcludedModels updates OAuth excluded models func (s *ConfigService) UpdateOAuthExcludedModels(ctx context.Context, models map[string][]string) error { cfg, err := s.GetConfig(ctx) if err != nil { return err } cfg.OAuthExcludedModels = config.NormalizeOAuthExcludedModels(models) return s.UpdateConfig(ctx, cfg) } // UpdateOAuthModelAlias updates OAuth model aliases func (s *ConfigService) UpdateOAuthModelAlias(ctx context.Context, aliases map[string][]config.OAuthModelAlias) error { cfg, err := s.GetConfig(ctx) if err != nil { return err } cfg.OAuthModelAlias = s.sanitizedOAuthModelAlias(aliases) return s.UpdateConfig(ctx, cfg) } // UpdateAmpCode updates the AmpCode configuration func (s *ConfigService) UpdateAmpCode(ctx context.Context, ampCode config.AmpCode) error { cfg, err := s.GetConfig(ctx) if err != nil { return err } cfg.AmpCode = ampCode return s.UpdateConfig(ctx, cfg) } // UpdateAmpUpstreamURL updates the Amp upstream URL func (s *ConfigService) UpdateAmpUpstreamURL(ctx context.Context, url string) error { cfg, err := s.GetConfig(ctx) if err != nil { return err } cfg.AmpCode.UpstreamURL = strings.TrimSpace(url) return s.UpdateConfig(ctx, cfg) } // UpdateAmpModelMappings updates Amp model mappings func (s *ConfigService) UpdateAmpModelMappings(ctx context.Context, mappings []config.AmpModelMapping) error { cfg, err := s.GetConfig(ctx) if err != nil { return err } cfg.AmpCode.ModelMappings = mappings return s.UpdateConfig(ctx, cfg) } // UpdateAmpUpstreamAPIKeys updates Amp upstream API keys func (s *ConfigService) UpdateAmpUpstreamAPIKeys(ctx context.Context, keys []config.AmpUpstreamAPIKeyEntry) error { cfg, err := s.GetConfig(ctx) if err != nil { return err } cfg.AmpCode.UpstreamAPIKeys = s.normalizeAmpUpstreamAPIKeyEntries(keys) return s.UpdateConfig(ctx, cfg) } // UpdateDebug updates the debug setting func (s *ConfigService) UpdateDebug(ctx context.Context, enabled bool) error { return s.UpdateField(ctx, "debug", enabled) } // UpdateUsageStatisticsEnabled updates the usage statistics enabled setting func (s *ConfigService) UpdateUsageStatisticsEnabled(ctx context.Context, enabled bool) error { return s.UpdateField(ctx, "usage_statistics_enabled", enabled) } // UpdateLoggingToFile updates the logging to file setting func (s *ConfigService) UpdateLoggingToFile(ctx context.Context, enabled bool) error { return s.UpdateField(ctx, "logging_to_file", enabled) } // UpdateLogsMaxTotalSizeMB updates the max log size func (s *ConfigService) UpdateLogsMaxTotalSizeMB(ctx context.Context, sizeMB int) error { return s.UpdateField(ctx, "logs_max_total_size_mb", sizeMB) } // UpdateRequestLog updates the request log setting func (s *ConfigService) UpdateRequestLog(ctx context.Context, enabled bool) error { return s.UpdateField(ctx, "request_log", enabled) } // UpdateWebsocketAuth updates the websocket auth setting func (s *ConfigService) UpdateWebsocketAuth(ctx context.Context, enabled bool) error { return s.UpdateField(ctx, "websocket_auth", enabled) } // UpdateRequestRetry updates the request retry count func (s *ConfigService) UpdateRequestRetry(ctx context.Context, retry int) error { return s.UpdateField(ctx, "request_retry", retry) } // UpdateMaxRetryInterval updates the max retry interval func (s *ConfigService) UpdateMaxRetryInterval(ctx context.Context, interval int) error { return s.UpdateField(ctx, "max_retry_interval", interval) } // UpdateForceModelPrefix updates the force model prefix setting func (s *ConfigService) UpdateForceModelPrefix(ctx context.Context, enabled bool) error { return s.UpdateField(ctx, "force_model_prefix", enabled) } // UpdateRoutingStrategy updates the routing strategy func (s *ConfigService) UpdateRoutingStrategy(ctx context.Context, strategy string) error { return s.UpdateField(ctx, "routing_strategy", strategy) } // UpdateProxyURL updates the proxy URL func (s *ConfigService) UpdateProxyURL(ctx context.Context, url string) error { return s.UpdateField(ctx, "proxy_url", url) } // UpdateRemoteManagement updates the remote management settings func (s *ConfigService) UpdateRemoteManagement(ctx context.Context, allowRemote bool, secretHash string) error { cfg, err := s.GetConfig(ctx) if err != nil { return err } cfg.RemoteManagement.AllowRemote = allowRemote cfg.RemoteManagement.SecretKey = secretHash return s.UpdateConfig(ctx, cfg) } // UpdateQuotaExceeded updates the quota exceeded settings func (s *ConfigService) UpdateQuotaExceeded(ctx context.Context, switchProject, switchPreviewModel bool) error { cfg, err := s.GetConfig(ctx) if err != nil { return err } cfg.QuotaExceeded.SwitchProject = switchProject cfg.QuotaExceeded.SwitchPreviewModel = switchPreviewModel return s.UpdateConfig(ctx, cfg) } // Validate validates the current configuration func (s *ConfigService) Validate(ctx context.Context) error { cfg, err := s.GetConfig(ctx) if err != nil { return err } return s.ValidateConfig(ctx, cfg) } // ValidateConfig validates a specific configuration func (s *ConfigService) ValidateConfig(ctx context.Context, cfg *config.Config) error { if cfg == nil { return errors.ErrInvalidConfig } // Additional validation can be added here return nil } // GetLatestVersion retrieves the latest version from GitHub func (s *ConfigService) GetLatestVersion(ctx context.Context) (string, error) { if s.logger != nil { s.logger.Debug(ctx, "fetching latest version from GitHub") } client := &http.Client{Timeout: 10 * time.Second} // Get current config for proxy settings cfg, err := s.GetConfig(ctx) if err != nil { cfg = &config.Config{} } proxyURL := strings.TrimSpace(cfg.ProxyURL) if proxyURL != "" { sdkCfg := &sdkconfig.SDKConfig{ProxyURL: proxyURL} util.SetProxy(sdkCfg, client) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL, http.NoBody) if err != nil { return "", errors.Wrap(errors.InternalError, "failed to create request", err) } req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("User-Agent", latestReleaseUserAgent) resp, err := client.Do(req) if err != nil { return "", errors.Wrap(errors.ServiceUnavailable, "failed to fetch latest version", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return "", errors.New(errors.ServiceUnavailable, fmt.Sprintf("GitHub API returned status %d: %s", resp.StatusCode, string(body))) } var release struct { TagName string `json:"tag_name"` Name string `json:"name"` } if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { return "", errors.Wrap(errors.InternalError, "failed to decode response", err) } version := strings.TrimSpace(release.TagName) if version == "" { version = strings.TrimSpace(release.Name) } if version == "" { return "", errors.New(errors.ServiceUnavailable, "GitHub API returned empty version") } if s.logger != nil { s.logger.Debugf(ctx, "latest version: %s", version) } return version, nil } // Helper functions func (s *ConfigService) normalizeRoutingStrategy(strategy string) (string, bool) { normalized := strings.ToLower(strings.TrimSpace(strategy)) switch normalized { case "", "round-robin", "roundrobin", "rr": return "round-robin", true case "fill-first", "fillfirst", "ff": return "fill-first", true default: return "", false } } func (s *ConfigService) normalizeOpenAICompatibilityEntry(entry *config.OpenAICompatibility) { if entry == nil { return } entry.BaseURL = strings.TrimSpace(entry.BaseURL) entry.Headers = config.NormalizeHeaders(entry.Headers) // Deduplicate API keys existing := make(map[string]struct{}, len(entry.APIKeyEntries)) for i := range entry.APIKeyEntries { trimmed := strings.TrimSpace(entry.APIKeyEntries[i].APIKey) entry.APIKeyEntries[i].APIKey = trimmed if trimmed != "" { existing[trimmed] = struct{}{} } } } func (s *ConfigService) sanitizedOAuthModelAlias(entries map[string][]config.OAuthModelAlias) map[string][]config.OAuthModelAlias { if len(entries) == 0 { return nil } copied := make(map[string][]config.OAuthModelAlias, len(entries)) for channel, aliases := range entries { if len(aliases) == 0 { continue } copied[channel] = append([]config.OAuthModelAlias(nil), aliases...) } if len(copied) == 0 { return nil } cfg := config.Config{OAuthModelAlias: copied} cfg.SanitizeOAuthModelAlias() if len(cfg.OAuthModelAlias) == 0 { return nil } return cfg.OAuthModelAlias } func (s *ConfigService) normalizeAmpUpstreamAPIKeyEntries(entries []config.AmpUpstreamAPIKeyEntry) []config.AmpUpstreamAPIKeyEntry { if len(entries) == 0 { return nil } out := make([]config.AmpUpstreamAPIKeyEntry, 0, len(entries)) for _, entry := range entries { upstreamKey := strings.TrimSpace(entry.UpstreamAPIKey) if upstreamKey == "" { continue } apiKeys := s.normalizeAPIKeysList(entry.APIKeys) out = append(out, config.AmpUpstreamAPIKeyEntry{ UpstreamAPIKey: upstreamKey, APIKeys: apiKeys, }) } if len(out) == 0 { return nil } return out } func (s *ConfigService) normalizeAPIKeysList(keys []string) []string { if len(keys) == 0 { return nil } out := make([]string, 0, len(keys)) for _, k := range keys { trimmed := strings.TrimSpace(k) if trimmed != "" { out = append(out, trimmed) } } if len(out) == 0 { return nil } return out } func toInt(v interface{}) (int, bool) { switch val := v.(type) { case int: return val, true case int32: return int(val), true case int64: return int(val), true case float32: return int(val), true case float64: return int(val), true default: return 0, false } } // Ensure ConfigService implements the interface var _ ports.ConfigService = (*ConfigService)(nil)