| |
| |
| |
| package cliproxy |
|
|
| import ( |
| "context" |
| "errors" |
| "fmt" |
| "os" |
| "strings" |
| "sync" |
| "time" |
|
|
| "github.com/router-for-me/CLIProxyAPI/v6/internal/api" |
| "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" |
| "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor" |
| _ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" |
| "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher" |
| "github.com/router-for-me/CLIProxyAPI/v6/internal/wsrelay" |
| sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" |
| sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" |
| coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" |
| "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" |
| "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" |
| log "github.com/sirupsen/logrus" |
| ) |
|
|
| |
| |
| |
| type Service struct { |
| |
| cfg *config.Config |
|
|
| |
| cfgMu sync.RWMutex |
|
|
| |
| configPath string |
|
|
| |
| tokenProvider TokenClientProvider |
|
|
| |
| apiKeyProvider APIKeyClientProvider |
|
|
| |
| watcherFactory WatcherFactory |
|
|
| |
| hooks Hooks |
|
|
| |
| serverOptions []api.ServerOption |
|
|
| |
| server *api.Server |
|
|
| |
| serverErr chan error |
|
|
| |
| watcher *WatcherWrapper |
|
|
| |
| watcherCancel context.CancelFunc |
|
|
| |
| authUpdates chan watcher.AuthUpdate |
|
|
| |
| authQueueStop context.CancelFunc |
|
|
| |
| authManager *sdkAuth.Manager |
|
|
| |
| accessManager *sdkaccess.Manager |
|
|
| |
| coreManager *coreauth.Manager |
|
|
| |
| shutdownOnce sync.Once |
|
|
| |
| wsGateway *wsrelay.Manager |
| } |
|
|
| |
| |
| |
| |
| |
| func (s *Service) RegisterUsagePlugin(plugin usage.Plugin) { |
| usage.RegisterPlugin(plugin) |
| } |
|
|
| |
| func newDefaultAuthManager() *sdkAuth.Manager { |
| return sdkAuth.NewManager( |
| sdkAuth.GetTokenStore(), |
| sdkAuth.NewGeminiAuthenticator(), |
| sdkAuth.NewCodexAuthenticator(), |
| sdkAuth.NewClaudeAuthenticator(), |
| sdkAuth.NewQwenAuthenticator(), |
| ) |
| } |
|
|
| func (s *Service) ensureAuthUpdateQueue(ctx context.Context) { |
| if s == nil { |
| return |
| } |
| if s.authUpdates == nil { |
| s.authUpdates = make(chan watcher.AuthUpdate, 256) |
| } |
| if s.authQueueStop != nil { |
| return |
| } |
| queueCtx, cancel := context.WithCancel(ctx) |
| s.authQueueStop = cancel |
| go s.consumeAuthUpdates(queueCtx) |
| } |
|
|
| func (s *Service) consumeAuthUpdates(ctx context.Context) { |
| for { |
| select { |
| case <-ctx.Done(): |
| return |
| case update, ok := <-s.authUpdates: |
| if !ok { |
| return |
| } |
| s.handleAuthUpdate(ctx, update) |
| labelDrain: |
| for { |
| select { |
| case nextUpdate := <-s.authUpdates: |
| s.handleAuthUpdate(ctx, nextUpdate) |
| default: |
| break labelDrain |
| } |
| } |
| } |
| } |
| } |
|
|
| func (s *Service) emitAuthUpdate(ctx context.Context, update watcher.AuthUpdate) { |
| if s == nil { |
| return |
| } |
| if ctx == nil { |
| ctx = context.Background() |
| } |
| if s.watcher != nil && s.watcher.DispatchRuntimeAuthUpdate(update) { |
| return |
| } |
| if s.authUpdates != nil { |
| select { |
| case s.authUpdates <- update: |
| return |
| default: |
| log.Debugf("auth update queue saturated, applying inline action=%v id=%s", update.Action, update.ID) |
| } |
| } |
| s.handleAuthUpdate(ctx, update) |
| } |
|
|
| func (s *Service) handleAuthUpdate(ctx context.Context, update watcher.AuthUpdate) { |
| if s == nil { |
| return |
| } |
| s.cfgMu.RLock() |
| cfg := s.cfg |
| s.cfgMu.RUnlock() |
| if cfg == nil || s.coreManager == nil { |
| return |
| } |
| switch update.Action { |
| case watcher.AuthUpdateActionAdd, watcher.AuthUpdateActionModify: |
| if update.Auth == nil || update.Auth.ID == "" { |
| return |
| } |
| s.applyCoreAuthAddOrUpdate(ctx, update.Auth) |
| case watcher.AuthUpdateActionDelete: |
| id := update.ID |
| if id == "" && update.Auth != nil { |
| id = update.Auth.ID |
| } |
| if id == "" { |
| return |
| } |
| s.applyCoreAuthRemoval(ctx, id) |
| default: |
| log.Debugf("received unknown auth update action: %v", update.Action) |
| } |
| } |
|
|
| func (s *Service) ensureWebsocketGateway() { |
| if s == nil { |
| return |
| } |
| if s.wsGateway != nil { |
| return |
| } |
| opts := wsrelay.Options{ |
| Path: "/v1/ws", |
| OnConnected: s.wsOnConnected, |
| OnDisconnected: s.wsOnDisconnected, |
| LogDebugf: log.Debugf, |
| LogInfof: log.Infof, |
| LogWarnf: log.Warnf, |
| } |
| s.wsGateway = wsrelay.NewManager(opts) |
| } |
|
|
| func (s *Service) wsOnConnected(channelID string) { |
| if s == nil || channelID == "" { |
| return |
| } |
| if !strings.HasPrefix(strings.ToLower(channelID), "aistudio-") { |
| return |
| } |
| if s.coreManager != nil { |
| if existing, ok := s.coreManager.GetByID(channelID); ok && existing != nil { |
| if !existing.Disabled && existing.Status == coreauth.StatusActive { |
| return |
| } |
| } |
| } |
| now := time.Now().UTC() |
| auth := &coreauth.Auth{ |
| ID: channelID, |
| Provider: "aistudio", |
| Label: channelID, |
| Status: coreauth.StatusActive, |
| CreatedAt: now, |
| UpdatedAt: now, |
| Attributes: map[string]string{"runtime_only": "true"}, |
| Metadata: map[string]any{"email": channelID}, |
| } |
| log.Infof("websocket provider connected: %s", channelID) |
| s.emitAuthUpdate(context.Background(), watcher.AuthUpdate{ |
| Action: watcher.AuthUpdateActionAdd, |
| ID: auth.ID, |
| Auth: auth, |
| }) |
| } |
|
|
| func (s *Service) wsOnDisconnected(channelID string, reason error) { |
| if s == nil || channelID == "" { |
| return |
| } |
| if reason != nil { |
| if strings.Contains(reason.Error(), "replaced by new connection") { |
| log.Infof("websocket provider replaced: %s", channelID) |
| return |
| } |
| log.Warnf("websocket provider disconnected: %s (%v)", channelID, reason) |
| } else { |
| log.Infof("websocket provider disconnected: %s", channelID) |
| } |
| ctx := context.Background() |
| s.emitAuthUpdate(ctx, watcher.AuthUpdate{ |
| Action: watcher.AuthUpdateActionDelete, |
| ID: channelID, |
| }) |
| } |
|
|
| func (s *Service) applyCoreAuthAddOrUpdate(ctx context.Context, auth *coreauth.Auth) { |
| if s == nil || auth == nil || auth.ID == "" { |
| return |
| } |
| if s.coreManager == nil { |
| return |
| } |
| auth = auth.Clone() |
| s.ensureExecutorsForAuth(auth) |
| s.registerModelsForAuth(auth) |
| if existing, ok := s.coreManager.GetByID(auth.ID); ok && existing != nil { |
| auth.CreatedAt = existing.CreatedAt |
| auth.LastRefreshedAt = existing.LastRefreshedAt |
| auth.NextRefreshAfter = existing.NextRefreshAfter |
| if _, err := s.coreManager.Update(ctx, auth); err != nil { |
| log.Errorf("failed to update auth %s: %v", auth.ID, err) |
| } |
| return |
| } |
| if _, err := s.coreManager.Register(ctx, auth); err != nil { |
| log.Errorf("failed to register auth %s: %v", auth.ID, err) |
| } |
| } |
|
|
| func (s *Service) applyCoreAuthRemoval(ctx context.Context, id string) { |
| if s == nil || id == "" { |
| return |
| } |
| if s.coreManager == nil { |
| return |
| } |
| GlobalModelRegistry().UnregisterClient(id) |
| if existing, ok := s.coreManager.GetByID(id); ok && existing != nil { |
| existing.Disabled = true |
| existing.Status = coreauth.StatusDisabled |
| if _, err := s.coreManager.Update(ctx, existing); err != nil { |
| log.Errorf("failed to disable auth %s: %v", id, err) |
| } |
| } |
| } |
|
|
| func (s *Service) applyRetryConfig(cfg *config.Config) { |
| if s == nil || s.coreManager == nil || cfg == nil { |
| return |
| } |
| maxInterval := time.Duration(cfg.MaxRetryInterval) * time.Second |
| s.coreManager.SetRetryConfig(cfg.RequestRetry, maxInterval) |
| } |
|
|
| func openAICompatInfoFromAuth(a *coreauth.Auth) (providerKey string, compatName string, ok bool) { |
| if a == nil { |
| return "", "", false |
| } |
| if len(a.Attributes) > 0 { |
| providerKey = strings.TrimSpace(a.Attributes["provider_key"]) |
| compatName = strings.TrimSpace(a.Attributes["compat_name"]) |
| if compatName != "" { |
| if providerKey == "" { |
| providerKey = compatName |
| } |
| return strings.ToLower(providerKey), compatName, true |
| } |
| } |
| if strings.EqualFold(strings.TrimSpace(a.Provider), "openai-compatibility") { |
| return "openai-compatibility", strings.TrimSpace(a.Label), true |
| } |
| return "", "", false |
| } |
|
|
| func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) { |
| if s == nil || a == nil { |
| return |
| } |
| |
| |
| |
| if a.Disabled { |
| return |
| } |
| if compatProviderKey, _, isCompat := openAICompatInfoFromAuth(a); isCompat { |
| if compatProviderKey == "" { |
| compatProviderKey = strings.ToLower(strings.TrimSpace(a.Provider)) |
| } |
| if compatProviderKey == "" { |
| compatProviderKey = "openai-compatibility" |
| } |
| s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor(compatProviderKey, s.cfg)) |
| return |
| } |
| switch strings.ToLower(a.Provider) { |
| case "gemini": |
| s.coreManager.RegisterExecutor(executor.NewGeminiExecutor(s.cfg)) |
| case "vertex": |
| s.coreManager.RegisterExecutor(executor.NewGeminiVertexExecutor(s.cfg)) |
| case "gemini-cli": |
| s.coreManager.RegisterExecutor(executor.NewGeminiCLIExecutor(s.cfg)) |
| case "aistudio": |
| if s.wsGateway != nil { |
| s.coreManager.RegisterExecutor(executor.NewAIStudioExecutor(s.cfg, a.ID, s.wsGateway)) |
| } |
| return |
| case "antigravity": |
| s.coreManager.RegisterExecutor(executor.NewAntigravityExecutor(s.cfg)) |
| case "claude": |
| s.coreManager.RegisterExecutor(executor.NewClaudeExecutor(s.cfg)) |
| case "codex": |
| s.coreManager.RegisterExecutor(executor.NewCodexExecutor(s.cfg)) |
| case "qwen": |
| s.coreManager.RegisterExecutor(executor.NewQwenExecutor(s.cfg)) |
| case "iflow": |
| s.coreManager.RegisterExecutor(executor.NewIFlowExecutor(s.cfg)) |
| case "kiro": |
| s.coreManager.RegisterExecutor(executor.NewKiroExecutor(s.cfg)) |
| case "github-copilot": |
| s.coreManager.RegisterExecutor(executor.NewGitHubCopilotExecutor(s.cfg)) |
| default: |
| providerKey := strings.ToLower(strings.TrimSpace(a.Provider)) |
| if providerKey == "" { |
| providerKey = "openai-compatibility" |
| } |
| s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor(providerKey, s.cfg)) |
| } |
| } |
|
|
| |
| func (s *Service) rebindExecutors() { |
| if s == nil || s.coreManager == nil { |
| return |
| } |
| auths := s.coreManager.List() |
| for _, auth := range auths { |
| s.ensureExecutorsForAuth(auth) |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| func (s *Service) Run(ctx context.Context) error { |
| if s == nil { |
| return fmt.Errorf("cliproxy: service is nil") |
| } |
| if ctx == nil { |
| ctx = context.Background() |
| } |
|
|
| usage.StartDefault(ctx) |
|
|
| shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) |
| defer shutdownCancel() |
| defer func() { |
| if err := s.Shutdown(shutdownCtx); err != nil { |
| log.Errorf("service shutdown returned error: %v", err) |
| } |
| }() |
|
|
| if err := s.ensureAuthDir(); err != nil { |
| return err |
| } |
|
|
| s.applyRetryConfig(s.cfg) |
|
|
| if s.coreManager != nil { |
| if errLoad := s.coreManager.Load(ctx); errLoad != nil { |
| log.Warnf("failed to load auth store: %v", errLoad) |
| } |
| } |
|
|
| tokenResult, err := s.tokenProvider.Load(ctx, s.cfg) |
| if err != nil && !errors.Is(err, context.Canceled) { |
| return err |
| } |
| if tokenResult == nil { |
| tokenResult = &TokenClientResult{} |
| } |
|
|
| apiKeyResult, err := s.apiKeyProvider.Load(ctx, s.cfg) |
| if err != nil && !errors.Is(err, context.Canceled) { |
| return err |
| } |
| if apiKeyResult == nil { |
| apiKeyResult = &APIKeyClientResult{} |
| } |
|
|
| |
|
|
| |
| s.server = api.NewServer(s.cfg, s.coreManager, s.accessManager, s.configPath, s.serverOptions...) |
|
|
| if s.authManager == nil { |
| s.authManager = newDefaultAuthManager() |
| } |
|
|
| s.ensureWebsocketGateway() |
| if s.server != nil && s.wsGateway != nil { |
| s.server.AttachWebsocketRoute(s.wsGateway.Path(), s.wsGateway.Handler()) |
| s.server.SetWebsocketAuthChangeHandler(func(oldEnabled, newEnabled bool) { |
| if oldEnabled == newEnabled { |
| return |
| } |
| if !oldEnabled && newEnabled { |
| ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| defer cancel() |
| if errStop := s.wsGateway.Stop(ctx); errStop != nil { |
| log.Warnf("failed to reset websocket connections after ws-auth change %t -> %t: %v", oldEnabled, newEnabled, errStop) |
| return |
| } |
| log.Debugf("ws-auth enabled; existing websocket sessions terminated to enforce authentication") |
| return |
| } |
| log.Debugf("ws-auth disabled; existing websocket sessions remain connected") |
| }) |
| } |
|
|
| if s.hooks.OnBeforeStart != nil { |
| s.hooks.OnBeforeStart(s.cfg) |
| } |
|
|
| s.serverErr = make(chan error, 1) |
| go func() { |
| if errStart := s.server.Start(); errStart != nil { |
| s.serverErr <- errStart |
| } else { |
| s.serverErr <- nil |
| } |
| }() |
|
|
| time.Sleep(100 * time.Millisecond) |
| fmt.Printf("API server started successfully on: %s:%d\n", s.cfg.Host, s.cfg.Port) |
|
|
| if s.hooks.OnAfterStart != nil { |
| s.hooks.OnAfterStart(s) |
| } |
|
|
| var watcherWrapper *WatcherWrapper |
| reloadCallback := func(newCfg *config.Config) { |
| previousStrategy := "" |
| s.cfgMu.RLock() |
| if s.cfg != nil { |
| previousStrategy = strings.ToLower(strings.TrimSpace(s.cfg.Routing.Strategy)) |
| } |
| s.cfgMu.RUnlock() |
|
|
| if newCfg == nil { |
| s.cfgMu.RLock() |
| newCfg = s.cfg |
| s.cfgMu.RUnlock() |
| } |
| if newCfg == nil { |
| return |
| } |
|
|
| nextStrategy := strings.ToLower(strings.TrimSpace(newCfg.Routing.Strategy)) |
| normalizeStrategy := func(strategy string) string { |
| switch strategy { |
| case "fill-first", "fillfirst", "ff": |
| return "fill-first" |
| default: |
| return "round-robin" |
| } |
| } |
| previousStrategy = normalizeStrategy(previousStrategy) |
| nextStrategy = normalizeStrategy(nextStrategy) |
| if s.coreManager != nil && previousStrategy != nextStrategy { |
| var selector coreauth.Selector |
| switch nextStrategy { |
| case "fill-first": |
| selector = &coreauth.FillFirstSelector{} |
| default: |
| selector = &coreauth.RoundRobinSelector{} |
| } |
| s.coreManager.SetSelector(selector) |
| log.Infof("routing strategy updated to %s", nextStrategy) |
| } |
|
|
| s.applyRetryConfig(newCfg) |
| if s.server != nil { |
| s.server.UpdateClients(newCfg) |
| } |
| s.cfgMu.Lock() |
| s.cfg = newCfg |
| s.cfgMu.Unlock() |
| if s.coreManager != nil { |
| s.coreManager.SetOAuthModelMappings(newCfg.OAuthModelMappings) |
| } |
| s.rebindExecutors() |
| } |
|
|
| watcherWrapper, err = s.watcherFactory(s.configPath, s.cfg.AuthDir, reloadCallback) |
| if err != nil { |
| return fmt.Errorf("cliproxy: failed to create watcher: %w", err) |
| } |
| s.watcher = watcherWrapper |
| s.ensureAuthUpdateQueue(ctx) |
| if s.authUpdates != nil { |
| watcherWrapper.SetAuthUpdateQueue(s.authUpdates) |
| } |
| watcherWrapper.SetConfig(s.cfg) |
|
|
| watcherCtx, watcherCancel := context.WithCancel(context.Background()) |
| s.watcherCancel = watcherCancel |
| if err = watcherWrapper.Start(watcherCtx); err != nil { |
| return fmt.Errorf("cliproxy: failed to start watcher: %w", err) |
| } |
| log.Info("file watcher started for config and auth directory changes") |
|
|
| |
| if s.coreManager != nil { |
| interval := 15 * time.Minute |
| s.coreManager.StartAutoRefresh(context.Background(), interval) |
| log.Infof("core auth auto-refresh started (interval=%s)", interval) |
| } |
|
|
| select { |
| case <-ctx.Done(): |
| log.Debug("service context cancelled, shutting down...") |
| return ctx.Err() |
| case err = <-s.serverErr: |
| return err |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| func (s *Service) Shutdown(ctx context.Context) error { |
| if s == nil { |
| return nil |
| } |
| var shutdownErr error |
| s.shutdownOnce.Do(func() { |
| if ctx == nil { |
| ctx = context.Background() |
| } |
|
|
| |
|
|
| if s.watcherCancel != nil { |
| s.watcherCancel() |
| } |
| if s.coreManager != nil { |
| s.coreManager.StopAutoRefresh() |
| } |
| if s.watcher != nil { |
| if err := s.watcher.Stop(); err != nil { |
| log.Errorf("failed to stop file watcher: %v", err) |
| shutdownErr = err |
| } |
| } |
| if s.wsGateway != nil { |
| if err := s.wsGateway.Stop(ctx); err != nil { |
| log.Errorf("failed to stop websocket gateway: %v", err) |
| if shutdownErr == nil { |
| shutdownErr = err |
| } |
| } |
| } |
| if s.authQueueStop != nil { |
| s.authQueueStop() |
| s.authQueueStop = nil |
| } |
|
|
| |
|
|
| if s.server != nil { |
| shutdownCtx, cancel := context.WithTimeout(ctx, 30*time.Second) |
| defer cancel() |
| if err := s.server.Stop(shutdownCtx); err != nil { |
| log.Errorf("error stopping API server: %v", err) |
| if shutdownErr == nil { |
| shutdownErr = err |
| } |
| } |
| } |
|
|
| usage.StopDefault() |
| }) |
| return shutdownErr |
| } |
|
|
| func (s *Service) ensureAuthDir() error { |
| info, err := os.Stat(s.cfg.AuthDir) |
| if err != nil { |
| if os.IsNotExist(err) { |
| if mkErr := os.MkdirAll(s.cfg.AuthDir, 0o755); mkErr != nil { |
| return fmt.Errorf("cliproxy: failed to create auth directory %s: %w", s.cfg.AuthDir, mkErr) |
| } |
| log.Infof("created missing auth directory: %s", s.cfg.AuthDir) |
| return nil |
| } |
| return fmt.Errorf("cliproxy: error checking auth directory %s: %w", s.cfg.AuthDir, err) |
| } |
| if !info.IsDir() { |
| return fmt.Errorf("cliproxy: auth path exists but is not a directory: %s", s.cfg.AuthDir) |
| } |
| return nil |
| } |
|
|
| |
| func (s *Service) registerModelsForAuth(a *coreauth.Auth) { |
| if a == nil || a.ID == "" { |
| return |
| } |
| authKind := strings.ToLower(strings.TrimSpace(a.Attributes["auth_kind"])) |
| if authKind == "" { |
| if kind, _ := a.AccountInfo(); strings.EqualFold(kind, "api_key") { |
| authKind = "apikey" |
| } |
| } |
| if a.Attributes != nil { |
| if v := strings.TrimSpace(a.Attributes["gemini_virtual_primary"]); strings.EqualFold(v, "true") { |
| GlobalModelRegistry().UnregisterClient(a.ID) |
| return |
| } |
| } |
| |
| if a.Runtime != nil { |
| if idGetter, ok := a.Runtime.(interface{ GetClientID() string }); ok { |
| if rid := idGetter.GetClientID(); rid != "" && rid != a.ID { |
| GlobalModelRegistry().UnregisterClient(rid) |
| } |
| } |
| } |
| provider := strings.ToLower(strings.TrimSpace(a.Provider)) |
| compatProviderKey, compatDisplayName, compatDetected := openAICompatInfoFromAuth(a) |
| if compatDetected { |
| provider = "openai-compatibility" |
| } |
| excluded := s.oauthExcludedModels(provider, authKind) |
| var models []*ModelInfo |
| switch provider { |
| case "gemini": |
| models = registry.GetGeminiModels() |
| if entry := s.resolveConfigGeminiKey(a); entry != nil { |
| if len(entry.Models) > 0 { |
| models = buildGeminiConfigModels(entry) |
| } |
| if authKind == "apikey" { |
| excluded = entry.ExcludedModels |
| } |
| } |
| models = applyExcludedModels(models, excluded) |
| case "vertex": |
| |
| models = registry.GetGeminiVertexModels() |
| if authKind == "apikey" { |
| if entry := s.resolveConfigVertexCompatKey(a); entry != nil && len(entry.Models) > 0 { |
| models = buildVertexCompatConfigModels(entry) |
| } |
| } |
| models = applyExcludedModels(models, excluded) |
| case "gemini-cli": |
| models = registry.GetGeminiCLIModels() |
| models = applyExcludedModels(models, excluded) |
| case "aistudio": |
| models = registry.GetAIStudioModels() |
| models = applyExcludedModels(models, excluded) |
| case "antigravity": |
| ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) |
| models = executor.FetchAntigravityModels(ctx, a, s.cfg) |
| cancel() |
| models = applyExcludedModels(models, excluded) |
| case "claude": |
| models = registry.GetClaudeModels() |
| if entry := s.resolveConfigClaudeKey(a); entry != nil { |
| if len(entry.Models) > 0 { |
| models = buildClaudeConfigModels(entry) |
| } |
| if authKind == "apikey" { |
| excluded = entry.ExcludedModels |
| } |
| } |
| models = applyExcludedModels(models, excluded) |
| case "codex": |
| models = registry.GetOpenAIModels() |
| if entry := s.resolveConfigCodexKey(a); entry != nil { |
| if len(entry.Models) > 0 { |
| models = buildCodexConfigModels(entry) |
| } |
| if authKind == "apikey" { |
| excluded = entry.ExcludedModels |
| } |
| } |
| models = applyExcludedModels(models, excluded) |
| case "qwen": |
| models = registry.GetQwenModels() |
| models = applyExcludedModels(models, excluded) |
| case "iflow": |
| models = registry.GetIFlowModels() |
| case "github-copilot": |
| models = registry.GetGitHubCopilotModels() |
| models = applyExcludedModels(models, excluded) |
| case "kiro": |
| models = registry.GetKiroModels() |
| models = applyExcludedModels(models, excluded) |
| default: |
| |
| if s.cfg != nil { |
| providerKey := provider |
| compatName := strings.TrimSpace(a.Provider) |
| isCompatAuth := false |
| if compatDetected { |
| if compatProviderKey != "" { |
| providerKey = compatProviderKey |
| } |
| if compatDisplayName != "" { |
| compatName = compatDisplayName |
| } |
| isCompatAuth = true |
| } |
| if strings.EqualFold(providerKey, "openai-compatibility") { |
| isCompatAuth = true |
| if a.Attributes != nil { |
| if v := strings.TrimSpace(a.Attributes["compat_name"]); v != "" { |
| compatName = v |
| } |
| if v := strings.TrimSpace(a.Attributes["provider_key"]); v != "" { |
| providerKey = strings.ToLower(v) |
| isCompatAuth = true |
| } |
| } |
| if providerKey == "openai-compatibility" && compatName != "" { |
| providerKey = strings.ToLower(compatName) |
| } |
| } else if a.Attributes != nil { |
| if v := strings.TrimSpace(a.Attributes["compat_name"]); v != "" { |
| compatName = v |
| isCompatAuth = true |
| } |
| if v := strings.TrimSpace(a.Attributes["provider_key"]); v != "" { |
| providerKey = strings.ToLower(v) |
| isCompatAuth = true |
| } |
| } |
| for i := range s.cfg.OpenAICompatibility { |
| compat := &s.cfg.OpenAICompatibility[i] |
| if strings.EqualFold(compat.Name, compatName) { |
| isCompatAuth = true |
| |
| ms := make([]*ModelInfo, 0, len(compat.Models)) |
| for j := range compat.Models { |
| m := compat.Models[j] |
| |
| modelID := m.Alias |
| if modelID == "" { |
| modelID = m.Name |
| } |
| ms = append(ms, &ModelInfo{ |
| ID: modelID, |
| Object: "model", |
| Created: time.Now().Unix(), |
| OwnedBy: compat.Name, |
| Type: "openai-compatibility", |
| DisplayName: modelID, |
| }) |
| } |
| |
| if len(ms) > 0 { |
| if providerKey == "" { |
| providerKey = "openai-compatibility" |
| } |
| GlobalModelRegistry().RegisterClient(a.ID, providerKey, applyModelPrefixes(ms, a.Prefix, s.cfg.ForceModelPrefix)) |
| } else { |
| |
| GlobalModelRegistry().UnregisterClient(a.ID) |
| } |
| return |
| } |
| } |
| if isCompatAuth { |
| |
| GlobalModelRegistry().UnregisterClient(a.ID) |
| return |
| } |
| } |
| } |
| models = applyOAuthModelMappings(s.cfg, provider, authKind, models) |
| if len(models) > 0 { |
| key := provider |
| if key == "" { |
| key = strings.ToLower(strings.TrimSpace(a.Provider)) |
| } |
| GlobalModelRegistry().RegisterClient(a.ID, key, applyModelPrefixes(models, a.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix)) |
| return |
| } |
|
|
| GlobalModelRegistry().UnregisterClient(a.ID) |
| } |
|
|
| func (s *Service) resolveConfigClaudeKey(auth *coreauth.Auth) *config.ClaudeKey { |
| if auth == nil || s.cfg == nil { |
| return nil |
| } |
| var attrKey, attrBase string |
| if auth.Attributes != nil { |
| attrKey = strings.TrimSpace(auth.Attributes["api_key"]) |
| attrBase = strings.TrimSpace(auth.Attributes["base_url"]) |
| } |
| for i := range s.cfg.ClaudeKey { |
| entry := &s.cfg.ClaudeKey[i] |
| cfgKey := strings.TrimSpace(entry.APIKey) |
| cfgBase := strings.TrimSpace(entry.BaseURL) |
| if attrKey != "" && attrBase != "" { |
| if strings.EqualFold(cfgKey, attrKey) && strings.EqualFold(cfgBase, attrBase) { |
| return entry |
| } |
| continue |
| } |
| if attrKey != "" && strings.EqualFold(cfgKey, attrKey) { |
| if cfgBase == "" || strings.EqualFold(cfgBase, attrBase) { |
| return entry |
| } |
| } |
| if attrKey == "" && attrBase != "" && strings.EqualFold(cfgBase, attrBase) { |
| return entry |
| } |
| } |
| if attrKey != "" { |
| for i := range s.cfg.ClaudeKey { |
| entry := &s.cfg.ClaudeKey[i] |
| if strings.EqualFold(strings.TrimSpace(entry.APIKey), attrKey) { |
| return entry |
| } |
| } |
| } |
| return nil |
| } |
|
|
| func (s *Service) resolveConfigGeminiKey(auth *coreauth.Auth) *config.GeminiKey { |
| if auth == nil || s.cfg == nil { |
| return nil |
| } |
| var attrKey, attrBase string |
| if auth.Attributes != nil { |
| attrKey = strings.TrimSpace(auth.Attributes["api_key"]) |
| attrBase = strings.TrimSpace(auth.Attributes["base_url"]) |
| } |
| for i := range s.cfg.GeminiKey { |
| entry := &s.cfg.GeminiKey[i] |
| cfgKey := strings.TrimSpace(entry.APIKey) |
| cfgBase := strings.TrimSpace(entry.BaseURL) |
| if attrKey != "" && strings.EqualFold(cfgKey, attrKey) { |
| if cfgBase == "" || strings.EqualFold(cfgBase, attrBase) { |
| return entry |
| } |
| continue |
| } |
| if attrKey == "" && attrBase != "" && strings.EqualFold(cfgBase, attrBase) { |
| return entry |
| } |
| } |
| return nil |
| } |
|
|
| func (s *Service) resolveConfigVertexCompatKey(auth *coreauth.Auth) *config.VertexCompatKey { |
| if auth == nil || s.cfg == nil { |
| return nil |
| } |
| var attrKey, attrBase string |
| if auth.Attributes != nil { |
| attrKey = strings.TrimSpace(auth.Attributes["api_key"]) |
| attrBase = strings.TrimSpace(auth.Attributes["base_url"]) |
| } |
| for i := range s.cfg.VertexCompatAPIKey { |
| entry := &s.cfg.VertexCompatAPIKey[i] |
| cfgKey := strings.TrimSpace(entry.APIKey) |
| cfgBase := strings.TrimSpace(entry.BaseURL) |
| if attrKey != "" && strings.EqualFold(cfgKey, attrKey) { |
| if cfgBase == "" || strings.EqualFold(cfgBase, attrBase) { |
| return entry |
| } |
| continue |
| } |
| if attrKey == "" && attrBase != "" && strings.EqualFold(cfgBase, attrBase) { |
| return entry |
| } |
| } |
| if attrKey != "" { |
| for i := range s.cfg.VertexCompatAPIKey { |
| entry := &s.cfg.VertexCompatAPIKey[i] |
| if strings.EqualFold(strings.TrimSpace(entry.APIKey), attrKey) { |
| return entry |
| } |
| } |
| } |
| return nil |
| } |
|
|
| func (s *Service) resolveConfigCodexKey(auth *coreauth.Auth) *config.CodexKey { |
| if auth == nil || s.cfg == nil { |
| return nil |
| } |
| var attrKey, attrBase string |
| if auth.Attributes != nil { |
| attrKey = strings.TrimSpace(auth.Attributes["api_key"]) |
| attrBase = strings.TrimSpace(auth.Attributes["base_url"]) |
| } |
| for i := range s.cfg.CodexKey { |
| entry := &s.cfg.CodexKey[i] |
| cfgKey := strings.TrimSpace(entry.APIKey) |
| cfgBase := strings.TrimSpace(entry.BaseURL) |
| if attrKey != "" && strings.EqualFold(cfgKey, attrKey) { |
| if cfgBase == "" || strings.EqualFold(cfgBase, attrBase) { |
| return entry |
| } |
| continue |
| } |
| if attrKey == "" && attrBase != "" && strings.EqualFold(cfgBase, attrBase) { |
| return entry |
| } |
| } |
| return nil |
| } |
|
|
| func (s *Service) oauthExcludedModels(provider, authKind string) []string { |
| cfg := s.cfg |
| if cfg == nil { |
| return nil |
| } |
| authKindKey := strings.ToLower(strings.TrimSpace(authKind)) |
| providerKey := strings.ToLower(strings.TrimSpace(provider)) |
| if authKindKey == "apikey" { |
| return nil |
| } |
| return cfg.OAuthExcludedModels[providerKey] |
| } |
|
|
| func applyExcludedModels(models []*ModelInfo, excluded []string) []*ModelInfo { |
| if len(models) == 0 || len(excluded) == 0 { |
| return models |
| } |
|
|
| patterns := make([]string, 0, len(excluded)) |
| for _, item := range excluded { |
| if trimmed := strings.TrimSpace(item); trimmed != "" { |
| patterns = append(patterns, strings.ToLower(trimmed)) |
| } |
| } |
| if len(patterns) == 0 { |
| return models |
| } |
|
|
| filtered := make([]*ModelInfo, 0, len(models)) |
| for _, model := range models { |
| if model == nil { |
| continue |
| } |
| modelID := strings.ToLower(strings.TrimSpace(model.ID)) |
| blocked := false |
| for _, pattern := range patterns { |
| if matchWildcard(pattern, modelID) { |
| blocked = true |
| break |
| } |
| } |
| if !blocked { |
| filtered = append(filtered, model) |
| } |
| } |
| return filtered |
| } |
|
|
| func applyModelPrefixes(models []*ModelInfo, prefix string, forceModelPrefix bool) []*ModelInfo { |
| trimmedPrefix := strings.TrimSpace(prefix) |
| if trimmedPrefix == "" || len(models) == 0 { |
| return models |
| } |
|
|
| out := make([]*ModelInfo, 0, len(models)*2) |
| seen := make(map[string]struct{}, len(models)*2) |
|
|
| addModel := func(model *ModelInfo) { |
| if model == nil { |
| return |
| } |
| id := strings.TrimSpace(model.ID) |
| if id == "" { |
| return |
| } |
| if _, exists := seen[id]; exists { |
| return |
| } |
| seen[id] = struct{}{} |
| out = append(out, model) |
| } |
|
|
| for _, model := range models { |
| if model == nil { |
| continue |
| } |
| baseID := strings.TrimSpace(model.ID) |
| if baseID == "" { |
| continue |
| } |
| if !forceModelPrefix || trimmedPrefix == baseID { |
| addModel(model) |
| } |
| clone := *model |
| clone.ID = trimmedPrefix + "/" + baseID |
| addModel(&clone) |
| } |
| return out |
| } |
|
|
| |
| func matchWildcard(pattern, value string) bool { |
| if pattern == "" { |
| return false |
| } |
|
|
| |
| if !strings.Contains(pattern, "*") { |
| return pattern == value |
| } |
|
|
| parts := strings.Split(pattern, "*") |
| |
| if prefix := parts[0]; prefix != "" { |
| if !strings.HasPrefix(value, prefix) { |
| return false |
| } |
| value = value[len(prefix):] |
| } |
|
|
| |
| if suffix := parts[len(parts)-1]; suffix != "" { |
| if !strings.HasSuffix(value, suffix) { |
| return false |
| } |
| value = value[:len(value)-len(suffix)] |
| } |
|
|
| |
| for i := 1; i < len(parts)-1; i++ { |
| segment := parts[i] |
| if segment == "" { |
| continue |
| } |
| idx := strings.Index(value, segment) |
| if idx < 0 { |
| return false |
| } |
| value = value[idx+len(segment):] |
| } |
|
|
| return true |
| } |
|
|
| type modelEntry interface { |
| GetName() string |
| GetAlias() string |
| } |
|
|
| func buildConfigModels[T modelEntry](models []T, ownedBy, modelType string) []*ModelInfo { |
| if len(models) == 0 { |
| return nil |
| } |
| now := time.Now().Unix() |
| out := make([]*ModelInfo, 0, len(models)) |
| seen := make(map[string]struct{}, len(models)) |
| for i := range models { |
| model := models[i] |
| name := strings.TrimSpace(model.GetName()) |
| alias := strings.TrimSpace(model.GetAlias()) |
| if alias == "" { |
| alias = name |
| } |
| if alias == "" { |
| continue |
| } |
| key := strings.ToLower(alias) |
| if _, exists := seen[key]; exists { |
| continue |
| } |
| seen[key] = struct{}{} |
| display := name |
| if display == "" { |
| display = alias |
| } |
| info := &ModelInfo{ |
| ID: alias, |
| Object: "model", |
| Created: now, |
| OwnedBy: ownedBy, |
| Type: modelType, |
| DisplayName: display, |
| } |
| if name != "" { |
| if upstream := registry.LookupStaticModelInfo(name); upstream != nil && upstream.Thinking != nil { |
| info.Thinking = upstream.Thinking |
| } |
| } |
| out = append(out, info) |
| } |
| return out |
| } |
|
|
| func buildVertexCompatConfigModels(entry *config.VertexCompatKey) []*ModelInfo { |
| if entry == nil { |
| return nil |
| } |
| return buildConfigModels(entry.Models, "google", "vertex") |
| } |
|
|
| func buildGeminiConfigModels(entry *config.GeminiKey) []*ModelInfo { |
| if entry == nil { |
| return nil |
| } |
| return buildConfigModels(entry.Models, "google", "gemini") |
| } |
|
|
| func buildClaudeConfigModels(entry *config.ClaudeKey) []*ModelInfo { |
| if entry == nil { |
| return nil |
| } |
| return buildConfigModels(entry.Models, "anthropic", "claude") |
| } |
|
|
| func buildCodexConfigModels(entry *config.CodexKey) []*ModelInfo { |
| if entry == nil { |
| return nil |
| } |
| return buildConfigModels(entry.Models, "openai", "openai") |
| } |
|
|
| func rewriteModelInfoName(name, oldID, newID string) string { |
| trimmed := strings.TrimSpace(name) |
| if trimmed == "" { |
| return name |
| } |
| oldID = strings.TrimSpace(oldID) |
| newID = strings.TrimSpace(newID) |
| if oldID == "" || newID == "" { |
| return name |
| } |
| if strings.EqualFold(oldID, newID) { |
| return name |
| } |
| if strings.HasSuffix(trimmed, "/"+oldID) { |
| prefix := strings.TrimSuffix(trimmed, oldID) |
| return prefix + newID |
| } |
| if trimmed == "models/"+oldID { |
| return "models/" + newID |
| } |
| return name |
| } |
|
|
| func applyOAuthModelMappings(cfg *config.Config, provider, authKind string, models []*ModelInfo) []*ModelInfo { |
| if cfg == nil || len(models) == 0 { |
| return models |
| } |
| channel := coreauth.OAuthModelMappingChannel(provider, authKind) |
| if channel == "" || len(cfg.OAuthModelMappings) == 0 { |
| return models |
| } |
| mappings := cfg.OAuthModelMappings[channel] |
| if len(mappings) == 0 { |
| return models |
| } |
| forward := make(map[string]string, len(mappings)) |
| for i := range mappings { |
| name := strings.TrimSpace(mappings[i].Name) |
| alias := strings.TrimSpace(mappings[i].Alias) |
| if name == "" || alias == "" { |
| continue |
| } |
| if strings.EqualFold(name, alias) { |
| continue |
| } |
| key := strings.ToLower(name) |
| if _, exists := forward[key]; exists { |
| continue |
| } |
| forward[key] = alias |
| } |
| if len(forward) == 0 { |
| return models |
| } |
| out := make([]*ModelInfo, 0, len(models)) |
| seen := make(map[string]struct{}, len(models)) |
| for _, model := range models { |
| if model == nil { |
| continue |
| } |
| id := strings.TrimSpace(model.ID) |
| if id == "" { |
| continue |
| } |
| mappedID := id |
| if to, ok := forward[strings.ToLower(id)]; ok && strings.TrimSpace(to) != "" { |
| mappedID = strings.TrimSpace(to) |
| } |
| uniqueKey := strings.ToLower(mappedID) |
| if _, exists := seen[uniqueKey]; exists { |
| continue |
| } |
| seen[uniqueKey] = struct{}{} |
| if mappedID == id { |
| out = append(out, model) |
| continue |
| } |
| clone := *model |
| clone.ID = mappedID |
| if clone.Name != "" { |
| clone.Name = rewriteModelInfoName(clone.Name, id, mappedID) |
| } |
| out = append(out, &clone) |
| } |
| return out |
| } |
|
|