| |
| 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" |
| ) |
|
|
| |
| 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{}) |
| } |
|
|
| |
| type ConfigService struct { |
| repo ports.ConfigRepository |
| logger Logger |
| } |
|
|
| |
| func NewConfigService(repo ports.ConfigRepository, logger Logger) *ConfigService { |
| return &ConfigService{ |
| repo: repo, |
| logger: logger, |
| } |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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") |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| if err := s.setField(cfg, field, value); err != nil { |
| return err |
| } |
|
|
| return s.UpdateConfig(ctx, cfg) |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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 |
|
|
| return s.UpdateConfig(ctx, cfg) |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| func (s *ConfigService) UpdateCodexKeys(ctx context.Context, keys []config.CodexKey) error { |
| cfg, err := s.GetConfig(ctx) |
| if err != nil { |
| return err |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| func (s *ConfigService) UpdateOpenAICompatibility(ctx context.Context, entries []config.OpenAICompatibility) error { |
| cfg, err := s.GetConfig(ctx) |
| if err != nil { |
| return err |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| func (s *ConfigService) UpdateDebug(ctx context.Context, enabled bool) error { |
| return s.UpdateField(ctx, "debug", enabled) |
| } |
|
|
| |
| func (s *ConfigService) UpdateUsageStatisticsEnabled(ctx context.Context, enabled bool) error { |
| return s.UpdateField(ctx, "usage_statistics_enabled", enabled) |
| } |
|
|
| |
| func (s *ConfigService) UpdateLoggingToFile(ctx context.Context, enabled bool) error { |
| return s.UpdateField(ctx, "logging_to_file", enabled) |
| } |
|
|
| |
| func (s *ConfigService) UpdateLogsMaxTotalSizeMB(ctx context.Context, sizeMB int) error { |
| return s.UpdateField(ctx, "logs_max_total_size_mb", sizeMB) |
| } |
|
|
| |
| func (s *ConfigService) UpdateRequestLog(ctx context.Context, enabled bool) error { |
| return s.UpdateField(ctx, "request_log", enabled) |
| } |
|
|
| |
| func (s *ConfigService) UpdateWebsocketAuth(ctx context.Context, enabled bool) error { |
| return s.UpdateField(ctx, "websocket_auth", enabled) |
| } |
|
|
| |
| func (s *ConfigService) UpdateRequestRetry(ctx context.Context, retry int) error { |
| return s.UpdateField(ctx, "request_retry", retry) |
| } |
|
|
| |
| func (s *ConfigService) UpdateMaxRetryInterval(ctx context.Context, interval int) error { |
| return s.UpdateField(ctx, "max_retry_interval", interval) |
| } |
|
|
| |
| func (s *ConfigService) UpdateForceModelPrefix(ctx context.Context, enabled bool) error { |
| return s.UpdateField(ctx, "force_model_prefix", enabled) |
| } |
|
|
| |
| func (s *ConfigService) UpdateRoutingStrategy(ctx context.Context, strategy string) error { |
| return s.UpdateField(ctx, "routing_strategy", strategy) |
| } |
|
|
| |
| func (s *ConfigService) UpdateProxyURL(ctx context.Context, url string) error { |
| return s.UpdateField(ctx, "proxy_url", url) |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| func (s *ConfigService) Validate(ctx context.Context) error { |
| cfg, err := s.GetConfig(ctx) |
| if err != nil { |
| return err |
| } |
| return s.ValidateConfig(ctx, cfg) |
| } |
|
|
| |
| func (s *ConfigService) ValidateConfig(ctx context.Context, cfg *config.Config) error { |
| if cfg == nil { |
| return errors.ErrInvalidConfig |
| } |
|
|
| |
| return nil |
| } |
|
|
| |
| 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} |
|
|
| |
| 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 |
| } |
|
|
| |
|
|
| 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) |
|
|
| |
| 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 |
| } |
| } |
|
|
| |
| var _ ports.ConfigService = (*ConfigService)(nil) |
|
|