Files

3640 lines
100 KiB
Go

// Package cliproxy provides the core service implementation for the CLI Proxy API.
// It includes service lifecycle management, authentication handling, file watching,
// and integration with various AI service providers through a unified interface.
package cliproxy
import (
"context"
"errors"
"fmt"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/api"
internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/home"
"github.com/router-for-me/CLIProxyAPI/v7/internal/homeplugins"
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher"
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff"
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/synthesizer"
"github.com/router-for-me/CLIProxyAPI/v7/internal/wsrelay"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executionregistry"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
log "github.com/sirupsen/logrus"
)
// Service wraps the proxy server lifecycle so external programs can embed the CLI proxy.
// It manages the complete lifecycle including authentication, file watching, HTTP server,
// and integration with various AI service providers.
type Service struct {
// cfg holds the current application configuration.
cfg *config.Config
// cfgMu protects concurrent access to the configuration.
cfgMu sync.RWMutex
// configUpdateMu serializes config updates across watcher + home.
configUpdateMu sync.Mutex
// configRuntimeMu orders side-effecting runtime application after config commits.
configRuntimeMu sync.Mutex
configSequence uint64
appliedRoutingState *routingRuntimeState
// configPath is the path to the configuration file.
configPath string
// tokenProvider handles loading token-based clients.
tokenProvider TokenClientProvider
// apiKeyProvider handles loading API key-based clients.
apiKeyProvider APIKeyClientProvider
// watcherFactory creates file watcher instances.
watcherFactory WatcherFactory
// hooks provides lifecycle callbacks.
hooks Hooks
// serverOptions contains additional server configuration options.
serverOptions []api.ServerOption
// server is the HTTP API server instance.
server *api.Server
// pprofServer manages the optional pprof HTTP debug server.
pprofServer *pprofServer
// serverErr channel for server startup/shutdown errors.
serverErr chan error
// watcher handles file system monitoring.
watcher *WatcherWrapper
// watcherCancel cancels the watcher context.
watcherCancel context.CancelFunc
// authUpdates channel for authentication updates.
authUpdates chan watcher.AuthUpdate
// authQueueStop cancels the auth update queue processing.
authQueueStop context.CancelFunc
// authManager handles legacy authentication operations.
authManager *sdkAuth.Manager
// accessManager handles request authentication providers.
accessManager *sdkaccess.Manager
// coreManager handles core authentication and execution.
coreManager *coreauth.Manager
// pluginHost owns dynamic plugin lifecycle and runtime capability adapters.
pluginHost *pluginhost.Host
// shutdownOnce ensures shutdown is called only once.
shutdownOnce sync.Once
// wsGateway manages websocket Gemini providers.
wsGateway *wsrelay.Manager
homeLifecycleMu sync.Mutex
homeOwnershipMu sync.Mutex
homeConfigCommitMu sync.Mutex
homeConfigStageHook func()
homeConfigCommitHook func()
homeConfigRuntimeHook func()
applyPprofConfigContextFn func(context.Context, *config.Config) bool
updateServerClientsContextFn func(context.Context, *config.Config) bool
homeSupervisor *homeSubscriberSupervisor
homeMu sync.Mutex
homeGeneration uint64
homeClient *home.Client
homeRegistry *executionregistry.Registry
homeDispatchBundle *coreauth.HomeDispatchBundle
homeDrainBound time.Duration
homeCancel context.CancelFunc
runCancel context.CancelFunc
homeLogForwarder homeLogForwarder
homeLogForwarderClient *home.Client
homePluginSyncMu sync.Mutex
homePluginSyncKey string
homePluginSyncFetch func(context.Context, sdkpluginstore.PluginSyncRequest) (sdkpluginstore.PluginSyncResponse, error)
homePluginDeleteTask func(context.Context, *config.Config, home.PluginTask) homeplugins.SyncReport
}
type homeSubscriberSupervisor struct {
cancel context.CancelFunc
done chan struct{}
publisherMu sync.Mutex
publisherDone <-chan struct{}
}
func (s *homeSubscriberSupervisor) setPublisherCompletion(done <-chan struct{}) {
if s == nil {
return
}
s.publisherMu.Lock()
s.publisherDone = done
s.publisherMu.Unlock()
}
func (s *homeSubscriberSupervisor) publisherCompletion() <-chan struct{} {
if s == nil {
return nil
}
s.publisherMu.Lock()
defer s.publisherMu.Unlock()
return s.publisherDone
}
type homeConfigWorkQueue struct {
mu sync.Mutex
items [][]byte
wake chan struct{}
}
func newHomeConfigWorkQueue() *homeConfigWorkQueue {
return &homeConfigWorkQueue{wake: make(chan struct{}, 1)}
}
func (q *homeConfigWorkQueue) enqueue(raw []byte) {
if q == nil {
return
}
item := append([]byte(nil), raw...)
q.mu.Lock()
q.items = append(q.items, item)
q.mu.Unlock()
select {
case q.wake <- struct{}{}:
default:
}
}
func (q *homeConfigWorkQueue) dequeue(ctx context.Context) ([]byte, bool) {
if q == nil || ctx == nil {
return nil, false
}
for {
if ctx.Err() != nil {
return nil, false
}
q.mu.Lock()
if ctx.Err() != nil {
q.mu.Unlock()
return nil, false
}
if len(q.items) > 0 {
item := q.items[0]
q.items[0] = nil
q.items = q.items[1:]
q.mu.Unlock()
return item, true
}
q.mu.Unlock()
select {
case <-ctx.Done():
return nil, false
case <-q.wake:
}
}
}
type homeLogForwarder interface {
Bind(*home.Client)
Deactivate(*home.Client)
Stop()
}
var startHomeLogForwarder = func(queueSize int) homeLogForwarder {
return logging.StartHomeAppLogForwarder(queueSize)
}
const (
modelRegistrationMaxWorkersPerCategory = 5
modelRegistrationMaxWorkersOpenAICompatibility = 20
homeSubscriberPreAckRetryBackoff = 100 * time.Millisecond
)
const (
modelRegistrationPhaseConfigAPIKey = iota
modelRegistrationPhaseOther
)
type modelRegistrationTask struct {
phase int
category string
run func(*openAICompatibilityRegistrationCache)
}
type executorRegistrationOptions struct {
includeBaseline bool
includePlugins bool
forceReplaceAuths bool
auths []*coreauth.Auth
}
var registerPluginExecutors = func(host *pluginhost.Host, manager *coreauth.Manager) {
if host == nil || manager == nil {
return
}
host.RegisterExecutors(manager, registry.GetGlobalRegistry())
}
// RegisterUsagePlugin registers a usage plugin on the global usage manager.
// This allows external code to monitor API usage and token consumption.
//
// Parameters:
// - plugin: The usage plugin to register
func (s *Service) RegisterUsagePlugin(plugin usage.Plugin) {
usage.RegisterPlugin(plugin)
}
func (s *Service) registerPluginAuthParser() {
var parser PluginAuthParser
if s != nil && s.pluginHost != nil {
parser = s.pluginHost
}
sdkAuth.RegisterPluginAuthParser(parser)
if s != nil && s.watcher != nil {
s.watcher.SetPluginAuthParser(parser)
}
}
func (s *Service) syncPluginRuntime(ctx context.Context) {
if !s.syncPluginRuntimeConfig(ctx) {
return
}
s.syncPluginModelRuntime(ctx)
}
func (s *Service) syncPluginRuntimeConfig(ctx context.Context) bool {
if s == nil {
sdkAuth.RegisterPluginAuthParser(nil)
return false
}
s.cfgMu.RLock()
cfg := s.cfg
s.cfgMu.RUnlock()
return s.syncPluginRuntimeConfigForConfig(ctx, cfg)
}
func (s *Service) syncPluginRuntimeConfigForConfig(ctx context.Context, cfg *config.Config) bool {
if s == nil {
sdkAuth.RegisterPluginAuthParser(nil)
return false
}
if ctx == nil {
ctx = context.Background()
}
if errContext := ctx.Err(); errContext != nil {
return false
}
if s.pluginHost != nil {
s.pluginHost.ApplyConfig(ctx, cfg)
}
if errContext := ctx.Err(); errContext != nil {
return false
}
if s.coreManager != nil {
s.coreManager.SetPluginScheduler(s.pluginHost)
}
s.registerPluginAuthParser()
if s.pluginHost == nil {
return false
}
s.pluginHost.RegisterFrontendAuthProviders()
if errContext := ctx.Err(); errContext != nil {
return false
}
if s.accessManager != nil {
s.accessManager.SetProviders(sdkaccess.RegisteredProviders())
}
s.pluginHost.RegisterUsagePlugins()
sdktranslator.SetPluginHooks(s.pluginHost)
if s.server != nil {
s.server.RefreshPluginManagementRoutes()
}
return ctx.Err() == nil
}
func (s *Service) syncPluginModelRuntime(ctx context.Context) {
if s == nil || s.pluginHost == nil || s.coreManager == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
s.pluginHost.RegisterModels(ctx, registry.GetGlobalRegistry())
if ctx.Err() != nil {
return
}
s.registerAvailableExecutors(ctx, executorRegistrationOptions{
includeBaseline: s.cfg != nil && s.cfg.Home.Enabled,
includePlugins: true,
forceReplaceAuths: true,
auths: s.coreManager.List(),
})
s.refreshPluginModelRegistrations(ctx)
if ctx.Err() != nil {
return
}
s.coreManager.RefreshSchedulerAll()
}
func (s *Service) refreshPluginModelRegistrations(ctx context.Context) {
if s == nil || s.pluginHost == nil || s.coreManager == nil {
return
}
s.registerModelsForAuthBatch(ctx, s.coreManager.List())
}
func (s *Service) registerModelsForAuthBatch(ctx context.Context, auths []*coreauth.Auth) {
if s == nil || s.coreManager == nil || len(auths) == 0 {
return
}
tasks := make([]modelRegistrationTask, 0, len(auths))
for _, auth := range auths {
if auth == nil {
continue
}
authForRegistration := auth.Clone()
tasks = append(tasks, modelRegistrationTask{
phase: modelRegistrationPhase(authForRegistration),
category: modelRegistrationCategory(authForRegistration),
run: func(compatCache *openAICompatibilityRegistrationCache) {
s.completeModelRegistrationForAuthWithCache(ctx, authForRegistration, compatCache)
},
})
}
s.runModelRegistrationTasks(ctx, tasks)
}
func (s *Service) runModelRegistrationTasks(ctx context.Context, tasks []modelRegistrationTask) {
if len(tasks) == 0 {
return
}
if ctx == nil {
ctx = context.Background()
}
configAPIKeyTasks := make([]modelRegistrationTask, 0)
otherTasks := make([]modelRegistrationTask, 0)
for _, task := range tasks {
if task.phase == modelRegistrationPhaseConfigAPIKey {
configAPIKeyTasks = append(configAPIKeyTasks, task)
continue
}
otherTasks = append(otherTasks, task)
}
compatCache := s.newOpenAICompatibilityRegistrationCache()
s.runModelRegistrationTaskPhase(ctx, configAPIKeyTasks, compatCache)
s.runModelRegistrationTaskPhase(ctx, otherTasks, compatCache)
}
func (s *Service) runModelRegistrationTaskPhase(ctx context.Context, tasks []modelRegistrationTask, compatCache *openAICompatibilityRegistrationCache) {
if len(tasks) == 0 {
return
}
grouped := make(map[string][]modelRegistrationTask)
order := make([]string, 0)
for _, task := range tasks {
if task.run == nil {
continue
}
category := strings.ToLower(strings.TrimSpace(task.category))
if category == "" {
category = "unknown"
}
if _, exists := grouped[category]; !exists {
order = append(order, category)
}
grouped[category] = append(grouped[category], task)
}
var wg sync.WaitGroup
for _, category := range order {
group := grouped[category]
workers := len(group)
maxWorkers := modelRegistrationMaxWorkersForCategory(category)
if workers > maxWorkers {
workers = maxWorkers
}
if workers <= 0 {
continue
}
taskCh := make(chan modelRegistrationTask)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for task := range taskCh {
select {
case <-ctx.Done():
return
default:
}
task.run(compatCache)
}
}()
}
go func(group []modelRegistrationTask) {
defer close(taskCh)
for _, task := range group {
select {
case <-ctx.Done():
return
case taskCh <- task:
}
}
}(group)
}
wg.Wait()
}
func modelRegistrationPhase(auth *coreauth.Auth) int {
if coreauth.IsConfigAPIKeyAuth(auth) {
return modelRegistrationPhaseConfigAPIKey
}
return modelRegistrationPhaseOther
}
func modelRegistrationCategory(auth *coreauth.Auth) string {
if auth == nil {
return "unknown"
}
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
if compatProviderKey, _, compatDetected := openAICompatInfoFromAuth(auth); compatDetected {
if compatProviderKey != "" {
provider = compatProviderKey
} else {
provider = "openai-compatibility"
}
}
if provider == "" {
provider = "unknown"
}
authKind := auth.AuthKind()
if authKind == "" {
return provider
}
return provider + ":" + authKind
}
func modelRegistrationMaxWorkersForCategory(category string) int {
category = strings.ToLower(strings.TrimSpace(category))
if strings.HasPrefix(category, "openai-compatible-") || strings.HasPrefix(category, "openai-compatibility") {
return modelRegistrationMaxWorkersOpenAICompatibility
}
return modelRegistrationMaxWorkersPerCategory
}
func (s *Service) registerModelRefreshCallback() {
// Register callback for startup and periodic model catalog refresh.
// When remote model definitions change, re-register models for affected providers.
// This intentionally rebuilds per-auth model availability from the latest catalog
// snapshot instead of preserving prior registry suppression state.
registry.SetModelRefreshCallback(func(changedProviders []string) {
if s == nil || s.coreManager == nil || len(changedProviders) == 0 {
return
}
providerSet := make(map[string]bool, len(changedProviders))
for _, p := range changedProviders {
providerSet[strings.ToLower(strings.TrimSpace(p))] = true
}
auths := s.coreManager.List()
refreshed := 0
var refreshedMu sync.Mutex
tasks := make([]modelRegistrationTask, 0, len(auths))
for _, item := range auths {
if item == nil || item.ID == "" {
continue
}
auth, ok := s.coreManager.GetByID(item.ID)
if !ok || auth == nil || auth.Disabled {
continue
}
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
if !providerSet[provider] {
continue
}
authForRefresh := auth
tasks = append(tasks, modelRegistrationTask{
phase: modelRegistrationPhase(authForRefresh),
category: modelRegistrationCategory(authForRefresh),
run: func(compatCache *openAICompatibilityRegistrationCache) {
if s.refreshModelRegistrationForAuthWithCache(authForRefresh, compatCache) {
refreshedMu.Lock()
refreshed++
refreshedMu.Unlock()
}
},
})
}
s.runModelRegistrationTasks(context.Background(), tasks)
if refreshed > 0 {
log.Infof("re-registered models for %d auth(s) due to model catalog changes: %v", refreshed, changedProviders)
}
})
}
// newDefaultAuthManager creates a default authentication manager with supported OAuth providers.
func newDefaultAuthManager() *sdkAuth.Manager {
return sdkAuth.NewManager(
sdkAuth.GetTokenStore(),
sdkAuth.NewCodexAuthenticator(),
sdkAuth.NewClaudeAuthenticator(),
sdkAuth.NewXAIAuthenticator(),
)
}
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) {
ctx = coreauth.WithSkipPersist(ctx)
for {
select {
case <-ctx.Done():
return
case update, ok := <-s.authUpdates:
if !ok {
return
}
updates := []watcher.AuthUpdate{update}
labelDrain:
for {
select {
case nextUpdate := <-s.authUpdates:
updates = append(updates, nextUpdate)
default:
break labelDrain
}
}
s.handleAuthUpdates(ctx, updates)
}
}
}
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) {
s.handleAuthUpdates(ctx, []watcher.AuthUpdate{update})
}
func (s *Service) handleAuthUpdates(ctx context.Context, updates []watcher.AuthUpdate) {
if s == nil {
return
}
updates = coalesceAuthUpdates(updates)
s.cfgMu.RLock()
cfg := s.cfg
s.cfgMu.RUnlock()
if cfg == nil || s.coreManager == nil {
return
}
registrationCtx := coreauth.WithDeferredAPIKeyModelAliasRebuild(ctx)
tasks := make([]modelRegistrationTask, 0, len(updates))
needsPluginSync := false
needsAliasRebuild := false
for _, update := range updates {
switch update.Action {
case watcher.AuthUpdateActionAdd, watcher.AuthUpdateActionModify:
if update.Auth == nil || update.Auth.ID == "" {
continue
}
auth := s.prepareCoreAuthForModelRegistration(registrationCtx, update.Auth)
if auth == nil {
continue
}
needsAliasRebuild = true
authForRegistration := auth
tasks = append(tasks, modelRegistrationTask{
phase: modelRegistrationPhase(authForRegistration),
category: modelRegistrationCategory(authForRegistration),
run: func(compatCache *openAICompatibilityRegistrationCache) {
s.completeModelRegistrationForAuthWithCache(registrationCtx, authForRegistration, compatCache)
},
})
needsPluginSync = true
case watcher.AuthUpdateActionDelete:
id := update.ID
if id == "" && update.Auth != nil {
id = update.Auth.ID
}
if id == "" {
continue
}
s.applyCoreAuthRemoval(registrationCtx, id)
needsAliasRebuild = true
default:
log.Debugf("received unknown auth update action: %v", update.Action)
}
}
if needsAliasRebuild {
s.coreManager.RefreshAPIKeyModelAlias()
}
s.runModelRegistrationTasks(registrationCtx, tasks)
if needsPluginSync {
s.syncPluginRuntime(registrationCtx)
}
}
func coalesceAuthUpdates(updates []watcher.AuthUpdate) []watcher.AuthUpdate {
if len(updates) <= 1 {
return updates
}
order := make([]string, 0, len(updates))
byID := make(map[string]watcher.AuthUpdate, len(updates))
unkeyed := make([]watcher.AuthUpdate, 0)
for _, update := range updates {
id := authUpdateID(update)
if id == "" {
unkeyed = append(unkeyed, update)
continue
}
if _, exists := byID[id]; !exists {
order = append(order, id)
}
byID[id] = update
}
if len(byID) == 0 {
return unkeyed
}
out := make([]watcher.AuthUpdate, 0, len(byID)+len(unkeyed))
for _, id := range order {
out = append(out, byID[id])
}
out = append(out, unkeyed...)
return out
}
func authUpdateID(update watcher.AuthUpdate) string {
if strings.TrimSpace(update.ID) != "" {
return strings.TrimSpace(update.ID)
}
if update.Auth != nil {
return strings.TrimSpace(update.Auth.ID)
}
return ""
}
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, // keep channel identifier as ID
Provider: "aistudio", // logical provider for switch routing
Label: channelID, // display original channel id
Status: coreauth.StatusActive,
CreatedAt: now,
UpdatedAt: now,
Attributes: map[string]string{"runtime_only": "true"},
Metadata: map[string]any{"email": channelID}, // metadata drives logging and usage tracking
}
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) {
auth = s.prepareCoreAuthForModelRegistration(ctx, auth)
if auth == nil {
return
}
s.completeModelRegistrationForAuth(ctx, auth)
s.syncPluginRuntime(ctx)
}
func (s *Service) prepareCoreAuthForModelRegistration(ctx context.Context, auth *coreauth.Auth) *coreauth.Auth {
if s == nil || s.coreManager == nil || auth == nil || auth.ID == "" {
return nil
}
auth = auth.Clone()
s.ensureExecutorsForAuthWithContext(ctx, auth, false)
// IMPORTANT: Update coreManager FIRST, before model registration.
// This ensures that configuration changes (proxy_url, prefix, etc.) take effect
// immediately for API calls, rather than waiting for model registration to complete.
op := "register"
var err error
if existing, ok := s.coreManager.GetByID(auth.ID); ok {
auth.CreatedAt = existing.CreatedAt
if !existing.Disabled && existing.Status != coreauth.StatusDisabled && !auth.Disabled && auth.Status != coreauth.StatusDisabled {
auth.LastRefreshedAt = existing.LastRefreshedAt
auth.NextRefreshAfter = existing.NextRefreshAfter
if len(auth.ModelStates) == 0 && len(existing.ModelStates) > 0 {
auth.ModelStates = existing.ModelStates
}
}
op = "update"
_, err = s.coreManager.Update(ctx, auth)
} else {
_, err = s.coreManager.Register(ctx, auth)
}
if err != nil {
log.Errorf("failed to %s auth %s: %v", op, auth.ID, err)
current, ok := s.coreManager.GetByID(auth.ID)
if !ok || current.Disabled {
GlobalModelRegistry().UnregisterClient(auth.ID)
return nil
}
auth = current
}
return auth
}
func (s *Service) completeModelRegistrationForAuth(ctx context.Context, auth *coreauth.Auth) {
s.completeModelRegistrationForAuthWithCache(ctx, auth, nil)
}
func (s *Service) completeModelRegistrationForAuthWithCache(ctx context.Context, auth *coreauth.Auth, compatCache *openAICompatibilityRegistrationCache) {
if s == nil || s.coreManager == nil || auth == nil || auth.ID == "" {
return
}
if ctx != nil && ctx.Err() != nil {
return
}
s.registerModelsForAuthWithCache(ctx, auth, compatCache)
if ctx != nil && ctx.Err() != nil {
return
}
s.coreManager.ReconcileRegistryModelStates(ctx, auth.ID)
// Refresh the scheduler entry so that the auth's supportedModelSet is rebuilt
// from the now-populated global model registry. Without this, newly added auths
// have an empty supportedModelSet (because Register/Update upserts into the
// scheduler before registerModelsForAuth runs) and are invisible to the scheduler.
s.coreManager.RefreshSchedulerEntry(auth.ID)
}
func (s *Service) applyCoreAuthRemoval(ctx context.Context, id string) {
if s == nil || id == "" {
return
}
if s.coreManager == nil {
return
}
id = strings.TrimSpace(id)
var provider string
if existing, ok := s.coreManager.GetByID(id); ok && existing != nil {
provider = strings.TrimSpace(existing.Provider)
}
GlobalModelRegistry().UnregisterClient(id)
s.coreManager.Remove(ctx, id)
if strings.EqualFold(provider, "codex") {
executor.CloseCodexWebsocketSessionsForAuthID(id, "auth_removed")
}
if strings.EqualFold(provider, "xai") {
executor.CloseXAIWebsocketSessionsForAuthID(id, "auth_removed")
}
s.syncPluginRuntime(ctx)
}
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, cfg.MaxRetryCredentials)
coreauth.SetTransientErrorCooldownSeconds(cfg.TransientErrorCooldownSeconds)
}
func (s *Service) configureCooldownStateStore(cfg *config.Config) {
_ = s.configureCooldownStateStoreContext(context.Background(), cfg, false)
}
func (s *Service) configureCooldownStateStoreContext(ctx context.Context, cfg *config.Config, persistOld bool) bool {
if s == nil || s.coreManager == nil {
return true
}
if ctx == nil {
ctx = context.Background()
}
if errContext := ctx.Err(); errContext != nil {
return false
}
return s.coreManager.SwapCooldownStateStore(ctx, s.resolveCooldownStateStore(cfg), persistOld)
}
func (s *Service) resolveCooldownStateStore(cfg *config.Config) coreauth.CooldownStateStore {
if cfg == nil || !cfg.SaveCooldownStatus || cfg.Home.Enabled {
return nil
}
authDir, errResolve := resolveCooldownStateAuthDir(cfg)
if errResolve != nil {
log.Warnf("failed to resolve cooldown state directory: %v", errResolve)
return nil
}
if authDir == "" {
return nil
}
return coreauth.NewFileCooldownStateStoreWithAuthDir(authDir, authDir)
}
func resolveCooldownStateAuthDir(cfg *config.Config) (string, error) {
if cfg == nil {
return "", nil
}
authDir, errAuthDir := util.ResolveAuthDir(cfg.AuthDir)
if errAuthDir != nil {
return "", errAuthDir
}
return authDir, nil
}
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 util.OpenAICompatibleProviderKey(providerKey), compatName, true
}
}
if strings.EqualFold(strings.TrimSpace(a.Provider), "openai-compatibility") {
compatName = strings.TrimSpace(a.Label)
providerKey = compatName
if providerKey == "" {
providerKey = "openai-compatibility"
}
return util.OpenAICompatibleProviderKey(providerKey), compatName, true
}
return "", "", false
}
type openAICompatibilityRegistrationCache struct {
byName map[string]*openAICompatibilityRegistrationEntry
}
type openAICompatibilityRegistrationEntry struct {
providerKey string
models []*ModelInfo
}
func (s *Service) newOpenAICompatibilityRegistrationCache() *openAICompatibilityRegistrationCache {
if s == nil {
return nil
}
s.cfgMu.RLock()
cfg := s.cfg
s.cfgMu.RUnlock()
if cfg == nil || len(cfg.OpenAICompatibility) == 0 {
return nil
}
cache := &openAICompatibilityRegistrationCache{
byName: make(map[string]*openAICompatibilityRegistrationEntry, len(cfg.OpenAICompatibility)),
}
for i := range cfg.OpenAICompatibility {
compat := &cfg.OpenAICompatibility[i]
if compat.Disabled {
continue
}
compatName := strings.TrimSpace(compat.Name)
key := strings.ToLower(compatName)
if _, exists := cache.byName[key]; exists {
continue
}
providerName := strings.ToLower(compatName)
if providerName == "" {
providerName = "openai-compatibility"
}
cache.byName[key] = &openAICompatibilityRegistrationEntry{
providerKey: util.OpenAICompatibleProviderKey(providerName),
models: buildOpenAICompatibilityConfigModels(compat),
}
}
if len(cache.byName) == 0 {
return nil
}
return cache
}
func (c *openAICompatibilityRegistrationCache) lookup(compatName string) (*openAICompatibilityRegistrationEntry, bool) {
if c == nil || len(c.byName) == 0 {
return nil, false
}
entry, ok := c.byName[strings.ToLower(strings.TrimSpace(compatName))]
return entry, ok
}
func (s *Service) hasNativeOpenAICompatExecutorConfig(a *coreauth.Auth, providerKey string) bool {
if a == nil {
return false
}
providerKey = strings.ToLower(strings.TrimSpace(providerKey))
if a.Attributes != nil {
if strings.TrimSpace(a.Attributes["base_url"]) != "" {
return true
}
if strings.TrimSpace(a.Attributes["compat_name"]) != "" {
return true
}
}
if strings.EqualFold(strings.TrimSpace(a.Provider), "openai-compatibility") {
return true
}
if s == nil || s.cfg == nil {
return false
}
candidates := make([]string, 0, 3)
if providerKey != "" {
candidates = append(candidates, providerKey)
}
if a.Attributes != nil {
if v := strings.TrimSpace(a.Attributes["provider_key"]); v != "" {
candidates = append(candidates, strings.ToLower(v))
}
}
if provider := strings.TrimSpace(a.Provider); provider != "" {
candidates = append(candidates, strings.ToLower(provider))
}
for i := range s.cfg.OpenAICompatibility {
compat := &s.cfg.OpenAICompatibility[i]
if compat.Disabled {
continue
}
name := strings.ToLower(strings.TrimSpace(compat.Name))
if name == "" {
continue
}
for _, candidate := range candidates {
if candidate != "" && candidate == name {
return true
}
}
}
return false
}
func (s *Service) unregisterOpenAICompatExecutor(providerKey string) {
if s == nil || s.coreManager == nil {
return
}
providerKey = strings.ToLower(strings.TrimSpace(providerKey))
if providerKey == "" {
return
}
existing, okExecutor := s.coreManager.Executor(providerKey)
if !okExecutor || existing == nil {
return
}
if _, okOpenAICompat := existing.(*executor.OpenAICompatExecutor); !okOpenAICompat {
return
}
s.coreManager.UnregisterExecutor(providerKey)
}
func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) {
s.ensureExecutorsForAuthWithContext(context.Background(), a, false)
}
func (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, forceReplace bool) {
s.ensureExecutorsForAuthWithContext(context.Background(), a, forceReplace)
}
func (s *Service) ensureExecutorsForAuthWithContext(ctx context.Context, a *coreauth.Auth, forceReplace bool) {
if a == nil || (ctx != nil && ctx.Err() != nil) {
return
}
s.registerAvailableExecutors(ctx, executorRegistrationOptions{
auths: []*coreauth.Auth{a},
forceReplaceAuths: forceReplace,
})
}
func (s *Service) registerAvailableExecutors(ctx context.Context, opts executorRegistrationOptions) {
if s == nil || s.coreManager == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
// Keep all Service-owned executor registration paths here so native, Home,
// auth-derived, and plugin executors stay in the same binding order.
if opts.includeBaseline {
s.registerExecutorsForAuths(baselineExecutorAuths(), true)
}
if len(opts.auths) > 0 {
s.registerExecutorsForAuths(opts.auths, opts.forceReplaceAuths)
}
if opts.includePlugins && s.pluginHost != nil {
registerPluginExecutors(s.pluginHost, s.coreManager)
}
}
func baselineExecutorAuths() []*coreauth.Auth {
providers := []string{
"codex",
"claude",
constant.Gemini,
constant.GeminiInteractions,
"vertex",
"aistudio",
"antigravity",
"kimi",
"xai",
"openai-compatibility",
}
auths := make([]*coreauth.Auth, 0, len(providers))
for _, provider := range providers {
auth := &coreauth.Auth{
ID: provider,
Provider: provider,
}
if provider == "openai-compatibility" {
auth.Attributes = map[string]string{"compat_name": "openai-compatibility"}
}
auths = append(auths, auth)
}
return auths
}
func (s *Service) registerExecutorsForAuths(auths []*coreauth.Auth, forceReplace bool) {
reboundCodex := false
for _, auth := range auths {
if auth != nil && strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
if reboundCodex && forceReplace {
continue
}
reboundCodex = true
}
s.registerExecutorForAuth(auth, forceReplace)
}
}
func (s *Service) registerExecutorForAuth(a *coreauth.Auth, forceReplace bool) {
if s == nil || s.coreManager == nil || a == nil {
return
}
if strings.EqualFold(strings.TrimSpace(a.Provider), "codex") {
if !forceReplace {
existingExecutor, hasExecutor := s.coreManager.Executor("codex")
if hasExecutor {
_, isCodexAutoExecutor := existingExecutor.(*executor.CodexAutoExecutor)
if isCodexAutoExecutor {
return
}
}
}
s.coreManager.RegisterExecutor(executor.NewCodexAutoExecutor(s.cfg))
return
}
// Skip disabled auth entries when (re)binding executors.
// Disabled auths can linger during config reloads (e.g., removed OpenAI-compat entries)
// and must not override active provider executors.
if a.Disabled {
return
}
if compatProviderKey, _, isCompat := openAICompatInfoFromAuth(a); isCompat {
if compatProviderKey == "" {
compatProviderKey = strings.ToLower(strings.TrimSpace(a.Provider))
}
if compatProviderKey == "" {
compatProviderKey = "openai-compatibility"
}
if !forceReplace {
if existingExecutor, hasExecutor := s.coreManager.Executor(compatProviderKey); hasExecutor {
if _, isOpenAICompatExecutor := existingExecutor.(*executor.OpenAICompatExecutor); isOpenAICompatExecutor {
return
}
}
}
s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor(compatProviderKey, s.cfg))
return
}
switch strings.ToLower(a.Provider) {
case constant.Gemini:
s.coreManager.RegisterExecutor(executor.NewGeminiExecutor(s.cfg))
case constant.GeminiInteractions:
s.coreManager.RegisterExecutor(executor.NewGeminiInteractionsExecutor(s.cfg))
case "vertex":
s.coreManager.RegisterExecutor(executor.NewGeminiVertexExecutor(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 "kimi":
s.coreManager.RegisterExecutor(executor.NewKimiExecutor(s.cfg))
case "xai":
s.coreManager.RegisterExecutor(executor.NewXAIAutoExecutor(s.cfg))
default:
providerKey := strings.ToLower(strings.TrimSpace(a.Provider))
if providerKey == "" {
providerKey = "openai-compatibility"
}
if s.pluginHost != nil &&
s.pluginHost.HasExecutorCandidateProvider(providerKey) &&
!s.hasNativeOpenAICompatExecutorConfig(a, providerKey) {
s.unregisterOpenAICompatExecutor(providerKey)
return
}
if !forceReplace {
if existingExecutor, hasExecutor := s.coreManager.Executor(providerKey); hasExecutor {
if _, isOpenAICompatExecutor := existingExecutor.(*executor.OpenAICompatExecutor); isOpenAICompatExecutor {
return
}
}
}
s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor(providerKey, s.cfg))
}
}
func (s *Service) registerResolvedModelsForAuth(a *coreauth.Auth, providerKey string, models []*ModelInfo) {
if a == nil || a.ID == "" {
return
}
providerKey = strings.ToLower(strings.TrimSpace(providerKey))
if providerKey == "" {
GlobalModelRegistry().UnregisterClient(a.ID)
return
}
normalizedModels := make([]*ModelInfo, 0, len(models))
for _, model := range models {
if model == nil {
continue
}
modelID := strings.TrimSpace(model.ID)
if modelID == "" {
continue
}
clone := *model
clone.ID = modelID
normalizedModels = append(normalizedModels, &clone)
}
if len(normalizedModels) == 0 {
GlobalModelRegistry().UnregisterClient(a.ID)
return
}
GlobalModelRegistry().RegisterClient(a.ID, providerKey, normalizedModels)
}
func (s *Service) pluginModelsForProvider(providerKey string) []*ModelInfo {
if s == nil || s.pluginHost == nil {
return nil
}
return s.pluginHost.ModelsForProvider(providerKey)
}
func (s *Service) appendPluginModels(providerKey string, models []*ModelInfo) []*ModelInfo {
pluginModels := s.pluginModelsForProvider(providerKey)
if len(pluginModels) == 0 {
return models
}
out := make([]*ModelInfo, 0, len(models)+len(pluginModels))
seen := make(map[string]struct{}, len(models)+len(pluginModels))
for _, model := range models {
if model == nil {
continue
}
modelID := strings.TrimSpace(model.ID)
if modelID != "" {
seen[modelID] = struct{}{}
}
out = append(out, model)
}
for _, model := range pluginModels {
if model == nil {
continue
}
modelID := strings.TrimSpace(model.ID)
if modelID == "" {
continue
}
if _, exists := seen[modelID]; exists {
continue
}
seen[modelID] = struct{}{}
out = append(out, model)
}
return out
}
func (s *Service) tryRegisterPluginModelsForAuth(ctx context.Context, a *coreauth.Auth, provider, authKind string, excluded []string) bool {
if s == nil || s.pluginHost == nil || a == nil {
return false
}
if ctx != nil && ctx.Err() != nil {
return true
}
result := s.pluginHost.ModelsForAuth(ctx, a)
if ctx != nil && ctx.Err() != nil {
return true
}
if !result.Handled {
return false
}
if result.Err != nil {
return true
}
activeAuth := a
providerKey := strings.ToLower(strings.TrimSpace(result.Provider))
if providerKey == "" {
providerKey = strings.ToLower(strings.TrimSpace(provider))
}
if result.Auth != nil && s.coreManager != nil {
result.Auth.ID = a.ID
if result.Auth.Provider == "" {
result.Auth.Provider = a.Provider
}
if result.Auth.FileName == "" {
result.Auth.FileName = a.FileName
}
if result.Auth.Attributes == nil {
result.Auth.Attributes = make(map[string]string)
}
for key, value := range a.Attributes {
if _, exists := result.Auth.Attributes[key]; !exists {
result.Auth.Attributes[key] = value
}
}
if updated, errUpdate := s.coreManager.Update(ctx, result.Auth); errUpdate == nil && updated != nil {
activeAuth = updated.Clone()
}
}
if activeAuth == nil {
activeAuth = a
}
if activeProvider := strings.ToLower(strings.TrimSpace(activeAuth.Provider)); activeProvider != "" {
providerKey = activeProvider
}
if providerKey == "" {
providerKey = strings.ToLower(strings.TrimSpace(provider))
}
activeAuthKind := activeAuth.AuthKind()
activeExcluded := s.oauthExcludedModels(providerKey, activeAuthKind)
if a == activeAuth && len(activeExcluded) == 0 {
activeExcluded = excluded
}
if activeAuth.Attributes != nil {
if val, ok := activeAuth.Attributes["excluded_models"]; ok && strings.TrimSpace(val) != "" {
activeExcluded = strings.Split(val, ",")
}
}
if ctx != nil && ctx.Err() != nil {
return true
}
models := applyExcludedModels(result.Models, activeExcluded)
models = applyOAuthModelAliasForAuth(s.cfg, providerKey, activeAuthKind, activeAuth.Attributes, models)
if len(models) > 0 {
s.registerResolvedModelsForAuth(activeAuth, providerKey, applyModelPrefixes(models, activeAuth.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix))
return true
}
GlobalModelRegistry().UnregisterClient(activeAuth.ID)
return true
}
func (s *Service) applyConfigUpdate(newCfg *config.Config) {
s.applyConfigUpdateWithAuthSynthesis(context.Background(), newCfg, true)
}
func (s *Service) applyWatcherConfigUpdate(newCfg *config.Config) {
s.applyConfigUpdateWithAuthSynthesis(context.Background(), newCfg, false)
}
type configCommit struct {
cfg *config.Config
sequence uint64
}
type routingRuntimeState struct {
strategy string
sessionAffinity bool
sessionAffinityTTL time.Duration
}
func normalizedRoutingRuntimeState(cfg *config.Config) routingRuntimeState {
state := routingRuntimeState{
strategy: "round-robin",
sessionAffinityTTL: time.Hour,
}
if cfg == nil {
return state
}
switch strings.ToLower(strings.TrimSpace(cfg.Routing.Strategy)) {
case "fill-first", "fillfirst", "ff":
state.strategy = "fill-first"
}
state.sessionAffinity = cfg.Routing.SessionAffinity
if ttl := strings.TrimSpace(cfg.Routing.SessionAffinityTTL); ttl != "" {
if parsed, errParse := time.ParseDuration(ttl); errParse == nil && parsed > 0 {
state.sessionAffinityTTL = parsed
}
}
return state
}
func newRoutingSelector(state routingRuntimeState) coreauth.Selector {
var selector coreauth.Selector
if state.strategy == "fill-first" {
selector = &coreauth.FillFirstSelector{}
} else {
selector = &coreauth.RoundRobinSelector{}
}
if state.sessionAffinity {
selector = coreauth.NewSessionAffinitySelectorWithConfig(coreauth.SessionAffinityConfig{
Fallback: selector,
TTL: state.sessionAffinityTTL,
})
}
return selector
}
func (s *Service) applyConfigUpdateWithAuthSynthesis(ctx context.Context, newCfg *config.Config, synthesizeConfigAuths bool) bool {
commit := s.commitConfigUpdate(newCfg)
if commit.cfg == nil {
return false
}
return s.applyConfigRuntime(ctx, commit, synthesizeConfigAuths)
}
// commitConfigUpdate applies only in-memory configuration state. Runtime work that
// may block on plugins, models, storage, or networking is deliberately deferred.
func (s *Service) commitConfigUpdate(newCfg *config.Config) configCommit {
if s == nil {
return configCommit{}
}
s.configUpdateMu.Lock()
defer s.configUpdateMu.Unlock()
if newCfg == nil {
s.cfgMu.RLock()
newCfg = s.cfg
s.cfgMu.RUnlock()
}
if newCfg == nil {
return configCommit{}
}
s.cfgMu.Lock()
s.cfg = newCfg
s.cfgMu.Unlock()
s.configSequence++
return configCommit{cfg: newCfg, sequence: s.configSequence}
}
func (s *Service) configCommitCurrent(commit configCommit) bool {
if s == nil || commit.sequence == 0 {
return false
}
s.configUpdateMu.Lock()
current := s.configSequence == commit.sequence
s.configUpdateMu.Unlock()
return current
}
func (s *Service) applyConfigRuntime(ctx context.Context, commit configCommit, synthesizeConfigAuths bool) bool {
cfg := commit.cfg
if s == nil || cfg == nil {
return false
}
s.configRuntimeMu.Lock()
defer s.configRuntimeMu.Unlock()
if !s.configCommitCurrent(commit) {
return false
}
if ctx == nil {
ctx = context.Background()
}
if errContext := ctx.Err(); errContext != nil {
return false
}
if !s.applyManagerConfig(ctx, commit) {
return false
}
if errContext := ctx.Err(); errContext != nil {
return false
}
if !s.applyPprofConfigContext(ctx, cfg) {
return false
}
if errContext := ctx.Err(); errContext != nil {
return false
}
if !s.updateServerClientsContext(ctx, cfg) {
return false
}
if errContext := ctx.Err(); errContext != nil {
return false
}
registrationCtx := coreauth.WithSkipPersist(ctx)
s.syncPluginRuntimeConfigForConfig(registrationCtx, cfg)
if errContext := ctx.Err(); errContext != nil {
return false
}
var auths []*coreauth.Auth
if s.coreManager != nil {
auths = s.coreManager.List()
}
s.registerAvailableExecutors(registrationCtx, executorRegistrationOptions{
includeBaseline: cfg.Home.Enabled,
forceReplaceAuths: true,
auths: auths,
})
if errContext := ctx.Err(); errContext != nil {
return false
}
if synthesizeConfigAuths {
s.registerConfigAPIKeyAuths(registrationCtx, cfg)
}
if errContext := ctx.Err(); errContext != nil {
return false
}
if s.coreManager != nil && !cfg.Home.Enabled && cfg.SaveCooldownStatus {
if errRestoreCooldown := s.coreManager.RestoreCooldownStates(registrationCtx); errRestoreCooldown != nil && ctx.Err() == nil {
log.Warnf("failed to restore cooldown state after config update: %v", errRestoreCooldown)
}
}
if errContext := ctx.Err(); errContext != nil {
return false
}
s.syncPluginModelRuntime(registrationCtx)
return ctx.Err() == nil
}
func (s *Service) applyManagerConfig(ctx context.Context, commit configCommit) bool {
if s == nil || s.coreManager == nil || commit.cfg == nil {
return s != nil && commit.cfg != nil
}
if ctx == nil {
ctx = context.Background()
}
if errContext := ctx.Err(); errContext != nil {
return false
}
routingState := normalizedRoutingRuntimeState(commit.cfg)
if s.appliedRoutingState == nil || *s.appliedRoutingState != routingState {
s.coreManager.SetSelector(newRoutingSelector(routingState))
s.appliedRoutingState = &routingState
}
s.applyRetryConfig(commit.cfg)
store := s.resolveCooldownStateStore(commit.cfg)
if !s.coreManager.ApplyConfigWithCooldownStateStore(ctx, commit.cfg, store) {
return false
}
s.coreManager.SetOAuthModelAlias(commit.cfg.OAuthModelAlias)
return true
}
func (s *Service) updateServerClientsContext(ctx context.Context, cfg *config.Config) bool {
if s == nil || cfg == nil || (ctx != nil && ctx.Err() != nil) {
return false
}
if s.updateServerClientsContextFn != nil {
return s.updateServerClientsContextFn(ctx, cfg)
}
if s.server == nil {
return true
}
return s.server.UpdateClientsContext(ctx, cfg)
}
func (s *Service) reloadConfigFromWatcher() bool {
if s == nil || s.watcher == nil {
return false
}
return s.watcher.ReloadConfigIfChanged()
}
func (s *Service) registerConfigAPIKeyAuths(ctx context.Context, cfg *config.Config) {
if s == nil || s.coreManager == nil || cfg == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
configSynth := synthesizer.NewConfigSynthesizer()
auths, errSynthesize := configSynth.Synthesize(&synthesizer.SynthesisContext{
Config: cfg,
Now: time.Now(),
IDGenerator: synthesizer.NewStableIDGenerator(),
})
if errSynthesize != nil {
log.Warnf("failed to synthesize config API key auths: %v", errSynthesize)
return
}
registrationCtx := coreauth.WithDeferredAPIKeyModelAliasRebuild(ctx)
tasks := make([]modelRegistrationTask, 0, len(auths))
needsAliasRebuild := false
for _, auth := range auths {
if !coreauth.IsConfigAPIKeyAuth(auth) {
continue
}
prepared := s.prepareCoreAuthForModelRegistration(registrationCtx, auth)
if prepared == nil {
continue
}
needsAliasRebuild = true
authForRegistration := prepared
tasks = append(tasks, modelRegistrationTask{
phase: modelRegistrationPhaseConfigAPIKey,
category: modelRegistrationCategory(authForRegistration),
run: func(compatCache *openAICompatibilityRegistrationCache) {
s.completeModelRegistrationForAuthWithCache(registrationCtx, authForRegistration, compatCache)
},
})
}
if needsAliasRebuild {
s.coreManager.RefreshAPIKeyModelAlias()
}
s.runModelRegistrationTasks(registrationCtx, tasks)
}
func forceHomeRuntimeConfig(cfg *config.Config) {
if cfg == nil {
return
}
cfg.APIKeys = nil
cfg.UsageStatisticsEnabled = true
cfg.DisableCooling = true
cfg.SaveCooldownStatus = false
cfg.WebsocketAuth = false
cfg.RemoteManagement.AllowRemote = false
cfg.RemoteManagement.DisableControlPanel = true
cfg.Plugins.StoreAuth = nil
}
func (s *Service) applyHomeOverlay(remoteCfg *config.Config) {
if errApply := s.applyHomeOverlayContext(context.Background(), remoteCfg); errApply != nil {
log.Warnf("failed to apply home config payload: %v", errApply)
}
}
func (s *Service) applyHomeOverlayContext(ctx context.Context, remoteCfg *config.Config) error {
return s.applyHomeOverlayWithClient(ctx, remoteCfg, nil)
}
func (s *Service) applyHomeOverlayWithClient(ctx context.Context, remoteCfg *config.Config, client *home.Client) error {
work, errStage := s.stageHomeOverlayWithClient(ctx, remoteCfg, client)
if errStage != nil {
return errStage
}
if ctx != nil {
if errContext := ctx.Err(); errContext != nil {
return errContext
}
}
if work.config != nil {
if !s.applyConfigUpdateWithAuthSynthesis(ctx, work.config, true) {
return context.Canceled
}
work.committed = true
}
if errFinalize := s.finalizeHomePluginWork(ctx, client, work); errFinalize != nil {
return errFinalize
}
return nil
}
func (s *Service) stageHomeOverlayWithClient(ctx context.Context, remoteCfg *config.Config, client *home.Client) (*homePluginFinalization, error) {
work := &homePluginFinalization{}
if s == nil || remoteCfg == nil {
return work, nil
}
if ctx == nil {
ctx = context.Background()
}
if errContext := ctx.Err(); errContext != nil {
return nil, errContext
}
s.cfgMu.RLock()
baseCfg := s.cfg
s.cfgMu.RUnlock()
if baseCfg == nil {
return work, nil
}
merged := *remoteCfg
merged.Host = baseCfg.Host
merged.Port = baseCfg.Port
merged.TLS = baseCfg.TLS
merged.Home = baseCfg.Home
storeAuth := merged.Plugins.StoreAuth
forceHomeRuntimeConfig(&merged)
syncCfg := merged
syncCfg.Plugins.StoreAuth = storeAuth
logHomeConfigChanges(baseCfg, &merged)
report, syncKey, didSync, errSync := s.syncHomePluginsWithClient(ctx, &syncCfg, client)
if errSync != nil {
return nil, fmt.Errorf("sync home plugins: %w", errSync)
}
if errContext := ctx.Err(); errContext != nil {
return nil, errContext
}
if didSync {
if errLoad := homeplugins.MarkLoadResults(&report, s.pluginHost); errLoad != nil {
return nil, fmt.Errorf("load home plugins: %w", errLoad)
}
}
if strings.TrimSpace(report.Task) != "" {
work.syncKey = syncKey
work.markSynced = true
if strings.TrimSpace(merged.Home.NodeID) != "" {
work.statusWork = append(work.statusWork, homePluginStatusWork{cfg: &merged, report: report})
}
}
taskWork, errTasks := s.stageHomePluginTasksWithClient(ctx, &merged, client)
if errTasks != nil {
return nil, fmt.Errorf("stage home plugin tasks: %w", errTasks)
}
work.taskWork = append(work.taskWork, taskWork...)
if errContext := ctx.Err(); errContext != nil {
return nil, errContext
}
work.config = &merged
return work, nil
}
func (s *Service) commitHomeConfig(lifetimeCtx, homeCtx context.Context, generation uint64, work *homePluginFinalization) bool {
if s == nil || work == nil || work.config == nil {
return false
}
s.homeConfigCommitMu.Lock()
defer s.homeConfigCommitMu.Unlock()
if !s.homeLifetimeActive(homeCtx, lifetimeCtx, generation) {
return false
}
if s.homeConfigCommitHook != nil {
s.homeConfigCommitHook()
}
if !s.homeLifetimeActive(homeCtx, lifetimeCtx, generation) {
return false
}
commit := s.commitConfigUpdate(work.config)
if commit.cfg == nil {
return false
}
work.config = commit.cfg
work.configCommit = commit
work.committed = true
return true
}
func (s *Service) homeLifetimeActive(homeCtx, lifetimeCtx context.Context, generation uint64) bool {
if s == nil || homeCtx.Err() != nil || lifetimeCtx.Err() != nil {
return false
}
s.homeMu.Lock()
active := s.homeGeneration == generation
s.homeMu.Unlock()
return active
}
func (s *Service) finalizeHomePluginWorkUntilDone(ctx, homeCtx context.Context, generation uint64, client *home.Client, work *homePluginFinalization, publish func() bool) error {
stopClose := closeHomeClientOnCancellation(ctx, client)
defer stopClose()
for {
if errContext := ctx.Err(); errContext != nil {
return errContext
}
s.homeOwnershipMu.Lock()
if !s.homeLifetimeActive(homeCtx, ctx, generation) {
s.homeOwnershipMu.Unlock()
return context.Canceled
}
errFinalize := s.finalizeHomePluginWork(ctx, client, work)
if errFinalize == nil && (publish == nil || publish()) {
s.homeOwnershipMu.Unlock()
return nil
}
s.homeOwnershipMu.Unlock()
if errFinalize == nil {
return context.Canceled
}
log.WithError(errFinalize).Warn("failed to finalize home plugins; retrying")
timer := time.NewTimer(homeSubscriberPreAckRetryBackoff)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
}
func closeHomeClientOnCancellation(ctx context.Context, client *home.Client) func() {
if ctx == nil || client == nil {
return func() {}
}
stop := make(chan struct{})
go func() {
select {
case <-ctx.Done():
client.Close()
case <-stop:
}
}()
return func() { close(stop) }
}
func logHomeConfigChanges(oldCfg, newCfg *config.Config) {
if oldCfg == nil || newCfg == nil || !newCfg.Home.Enabled || (!oldCfg.Debug && !newCfg.Debug) {
return
}
details := diff.BuildConfigChangeDetails(oldCfg, newCfg)
if len(details) == 0 {
return
}
if newCfg.Debug && !log.IsLevelEnabled(log.DebugLevel) {
util.SetLogLevel(newCfg)
}
log.Debugf("home config changes detected:")
for _, detail := range details {
log.Debugf(" %s", detail)
}
}
func (s *Service) startHomeUsageForwarder(ctx context.Context, client *home.Client) {
if s == nil || client == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
sleep := func(d time.Duration) bool {
if d <= 0 {
return true
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
go func() {
for {
select {
case <-ctx.Done():
return
default:
}
if !client.HeartbeatOK() {
if !sleep(time.Second) {
return
}
continue
}
items := redisqueue.PopOldest(64)
if len(items) == 0 {
if !sleep(500 * time.Millisecond) {
return
}
continue
}
for i := range items {
if errPush := client.LPushUsage(ctx, items[i]); errPush != nil {
for j := i; j < len(items); j++ {
redisqueue.Enqueue(items[j])
}
if !sleep(time.Second) {
return
}
break
}
}
}
}()
}
func applyHomeObservationBarrier(registry *executionregistry.Registry, revision int64) {
if registry != nil {
registry.ObserveBarrier(revision)
}
}
func applyHomeInFlightPublisherConfig(manager *coreauth.Manager, cfg internalconfig.CredentialInFlightConfig) error {
publisherCfg, errConfig := coreauth.HomeInFlightPublisherConfigFromConfig(cfg)
if errConfig != nil {
return errConfig
}
if manager != nil {
manager.ApplyHomeInFlightPublisherConfig(publisherCfg)
}
return nil
}
func (s *Service) startHomeSubscriber(ctx context.Context) {
if s == nil {
return
}
s.cfgMu.RLock()
cfg := s.cfg
s.cfgMu.RUnlock()
if cfg == nil || !cfg.Home.Enabled {
return
}
parentCtx := ctx
if parentCtx == nil {
parentCtx = context.Background()
}
s.homeLifecycleMu.Lock()
defer s.homeLifecycleMu.Unlock()
if previousSupervisor := s.homeSupervisor; previousSupervisor != nil {
s.homeConfigCommitMu.Lock()
previousSupervisor.cancel()
s.homeConfigCommitMu.Unlock()
<-previousSupervisor.done
}
if !s.drainDetachedHomeLifetime(parentCtx) {
return
}
if parentCtx.Err() != nil {
return
}
homeCtx, cancel := context.WithCancel(parentCtx)
done := make(chan struct{})
s.homeMu.Lock()
s.homeGeneration++
generation := s.homeGeneration
s.homeCancel = cancel
s.homeMu.Unlock()
supervisor := &homeSubscriberSupervisor{cancel: cancel, done: done}
s.homeSupervisor = supervisor
go s.runHomeSubscriber(homeCtx, parentCtx, cfg.Home, generation, supervisor)
}
func (s *Service) drainDetachedHomeLifetime(parentCtx context.Context) bool {
s.homeMu.Lock()
previousCancel := s.homeCancel
previousClient := s.homeClient
previousRegistry := s.homeRegistry
previousBundle := s.homeDispatchBundle
previousDrainBound := s.homeDrainBound
previousForwarder := s.homeLogForwarder
previousForwarderClient := s.homeLogForwarderClient
s.homeCancel = nil
s.homeClient = nil
s.homeRegistry = nil
s.homeDispatchBundle = nil
s.homeDrainBound = 0
s.homeLogForwarderClient = nil
s.homeMu.Unlock()
if s.coreManager != nil {
s.coreManager.ClearHomeDispatchBundle(previousBundle)
}
home.ClearCurrentIf(previousClient)
if previousCancel != nil {
previousCancel()
}
if previousForwarder != nil && previousForwarderClient == previousClient {
previousForwarder.Deactivate(previousClient)
}
if previousRegistry != nil {
if previousDrainBound <= 0 {
previousDrainBound = internalconfig.CredentialConcurrencyConfig{}.WithDefaults().CPACancelBound
}
drainCtx, cancelDrain := context.WithTimeout(context.WithoutCancel(parentCtx), previousDrainBound)
errDrain := previousRegistry.Drain(drainCtx)
cancelDrain()
if errDrain != nil {
if previousClient != nil {
previousClient.Close()
}
if parentCtx.Err() == nil {
log.WithError(errDrain).Error("failed to drain replaced Home execution registry")
s.cancelServiceRun()
}
return false
}
}
if previousClient != nil {
previousClient.Close()
}
return true
}
func (s *Service) runHomeSubscriber(homeCtx context.Context, parentCtx context.Context, homeCfg internalconfig.HomeConfig, generation uint64, supervisor *homeSubscriberSupervisor) {
defer func() {
s.homeMu.Lock()
if s.homeGeneration == generation {
s.homeCancel = nil
}
s.homeMu.Unlock()
close(supervisor.done)
}()
for homeCtx.Err() == nil {
supervisor.setPublisherCompletion(nil)
client := home.New(homeCfg)
client.SetManagedLifetime(true)
registry := executionregistry.New()
releaseCtx, releaseCancel := context.WithCancel(context.WithoutCancel(homeCtx))
releaseFlusher := home.NewReleaseFlusher(client.LimiterConfig, client.PushConcurrencyRelease)
registry.SetReleaseSink(releaseFlusher.MarkDirty)
releaseDone := make(chan struct{})
go func() {
defer close(releaseDone)
releaseFlusher.Run(releaseCtx)
}()
lifetimeCtx, lifetimeCancel := context.WithCancel(homeCtx)
cancelBound := atomic.Int64{}
cancelBound.Store(int64(internalconfig.CredentialConcurrencyConfig{}.WithDefaults().CPACancelBound))
queue := newHomeConfigWorkQueue()
ready := make(chan struct{})
var readyOnce sync.Once
var published atomic.Bool
workerDone := make(chan struct{})
go func() {
defer close(workerDone)
s.runHomeConfigWorkerWithSupervisor(lifetimeCtx, homeCtx, generation, client, registry, queue, ready, &published, &cancelBound, supervisor)
}()
errRun := client.RunConfigSubscriberLifetime(lifetimeCtx, func(raw []byte) error {
parsed, errParse := config.ParseConfigBytes(raw)
if errParse != nil {
log.Warnf("failed to parse home config payload: %v", errParse)
return errParse
}
if errSetLifecycle := client.SetLifecycleConfig(parsed.CredentialConcurrency); errSetLifecycle != nil {
log.Warnf("failed to apply Home lifecycle config: %v", errSetLifecycle)
return errSetLifecycle
}
if errPublisherConfig := applyHomeInFlightPublisherConfig(s.coreManager, parsed.CredentialInFlight); errPublisherConfig != nil {
log.Warnf("failed to apply Home in-flight publisher config: %v", errPublisherConfig)
return errPublisherConfig
}
applyHomeObservationBarrier(registry, parsed.CredentialConcurrency.ObservationBarrierRevision)
cancelBound.Store(int64(parsed.CredentialConcurrency.WithDefaults().CPACancelBound))
queue.enqueue(raw)
return nil
}, func() {
readyOnce.Do(func() { close(ready) })
})
lifetimeCancel()
<-workerDone
if publisherDone := supervisor.publisherCompletion(); publisherDone != nil {
<-publisherDone
}
s.detachHomeSubscriberLifetime(client, registry)
drainBound := time.Duration(cancelBound.Load())
drainCtx, cancelDrain := context.WithTimeout(context.WithoutCancel(parentCtx), drainBound)
errDrain := registry.Drain(drainCtx)
var errFlush error
if errDrain == nil {
errFlush = releaseFlusher.Flush(drainCtx)
}
cancelDrain()
releaseCancel()
<-releaseDone
client.Close()
if errDrain != nil {
if parentCtx.Err() == nil {
log.WithError(errDrain).Error("failed to drain Home execution registry")
s.cancelServiceRun()
}
return
}
if errFlush != nil {
if parentCtx.Err() == nil {
log.WithError(errFlush).Error("failed to flush Home concurrency releases")
s.cancelServiceRun()
}
return
}
if errRun != nil && homeCtx.Err() == nil {
log.WithError(errRun).Warn("home config subscription lifetime ended")
}
if !published.Load() && errRun != nil && !waitForHomeSubscriberRetry(homeCtx, homeSubscriberPreAckRetryBackoff) {
return
}
}
}
func (s *Service) runHomeConfigWorker(lifetimeCtx, homeCtx context.Context, generation uint64, client *home.Client, registry *executionregistry.Registry, queue *homeConfigWorkQueue, ready <-chan struct{}, published *atomic.Bool, cancelBound *atomic.Int64) {
s.runHomeConfigWorkerWithSupervisor(lifetimeCtx, homeCtx, generation, client, registry, queue, ready, published, cancelBound, nil)
}
func (s *Service) runHomeConfigWorkerWithSupervisor(lifetimeCtx, homeCtx context.Context, generation uint64, client *home.Client, registry *executionregistry.Registry, queue *homeConfigWorkQueue, ready <-chan struct{}, published *atomic.Bool, cancelBound *atomic.Int64, supervisor *homeSubscriberSupervisor) {
select {
case <-lifetimeCtx.Done():
return
case <-ready:
}
for {
if lifetimeCtx.Err() != nil {
return
}
raw, ok := queue.dequeue(lifetimeCtx)
if !ok {
return
}
if lifetimeCtx.Err() != nil {
return
}
var work *homePluginFinalization
for {
if lifetimeCtx.Err() != nil {
return
}
parsed, errParse := config.ParseConfigBytes(raw)
if errParse == nil {
work, errParse = s.stageHomeOverlayWithClient(lifetimeCtx, parsed, client)
}
if errParse == nil {
break
}
if lifetimeCtx.Err() != nil {
return
}
log.WithError(errParse).Warn("failed to stage home config; retrying")
if !waitForHomeSubscriberRetry(lifetimeCtx, homeSubscriberPreAckRetryBackoff) {
return
}
}
var publish func() bool
if !published.Load() {
publish = func() bool {
s.homeMu.Lock()
defer s.homeMu.Unlock()
if homeCtx.Err() != nil || lifetimeCtx.Err() != nil || s.homeGeneration != generation {
return false
}
s.homeClient = client
s.homeRegistry = registry
s.homeDrainBound = time.Duration(cancelBound.Load())
if s.coreManager != nil {
s.homeDispatchBundle = s.coreManager.PublishHomeDispatch(client, registry, generation)
}
home.SetCurrent(client)
if s.homeLogForwarder == nil {
s.homeLogForwarder = startHomeLogForwarder(0)
}
s.homeLogForwarder.Bind(client)
s.homeLogForwarderClient = client
published.Store(true)
return true
}
}
if s.homeConfigStageHook != nil {
s.homeConfigStageHook()
}
if !s.commitHomeConfig(lifetimeCtx, homeCtx, generation, work) {
return
}
if s.homeConfigRuntimeHook != nil {
s.homeConfigRuntimeHook()
}
if !s.homeLifetimeActive(homeCtx, lifetimeCtx, generation) || !s.applyConfigRuntime(lifetimeCtx, work.configCommit, true) {
return
}
if errFinalize := s.finalizeHomePluginWorkUntilDone(lifetimeCtx, homeCtx, generation, client, work, publish); errFinalize != nil {
if !errors.Is(errFinalize, context.Canceled) {
log.WithError(errFinalize).Warn("home plugin finalization ended")
}
return
}
if publish != nil {
s.startHomeInFlightPublisher(lifetimeCtx, client, registry, supervisor)
s.startHomeUsageForwarder(lifetimeCtx, client)
}
}
}
func (s *Service) startHomeInFlightPublisher(ctx context.Context, client *home.Client, registry *executionregistry.Registry, supervisor *homeSubscriberSupervisor) {
if s == nil || s.coreManager == nil {
return
}
done := make(chan struct{})
if supervisor != nil {
supervisor.setPublisherCompletion(done)
}
go func() {
defer close(done)
s.coreManager.StartHomeInFlightPublisher(ctx, client, registry)
}()
}
func waitForHomeSubscriberRetry(ctx context.Context, delay time.Duration) bool {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
func (s *Service) detachHomeSubscriberLifetime(client *home.Client, registry *executionregistry.Registry) {
if s == nil {
return
}
s.homeMu.Lock()
var bundle *coreauth.HomeDispatchBundle
if s.homeClient == client && s.homeRegistry == registry {
bundle = s.homeDispatchBundle
s.homeClient = nil
s.homeRegistry = nil
s.homeDispatchBundle = nil
s.homeDrainBound = 0
}
forwarder := s.homeLogForwarder
if s.homeLogForwarderClient == client {
s.homeLogForwarderClient = nil
} else {
forwarder = nil
}
s.homeMu.Unlock()
if s.coreManager != nil {
s.coreManager.ClearHomeDispatchBundle(bundle)
}
home.ClearCurrentIf(client)
if forwarder != nil {
forwarder.Deactivate(client)
}
}
func (s *Service) cancelServiceRun() {
if s == nil {
return
}
s.homeMu.Lock()
cancel := s.runCancel
if cancel == nil {
cancel = s.homeCancel
}
s.homeMu.Unlock()
if cancel != nil {
cancel()
}
}
// Run starts the service and blocks until the context is cancelled or the server stops.
// It initializes all components including authentication, file watching, HTTP server,
// and starts processing requests. The method blocks until the context is cancelled.
//
// Parameters:
// - ctx: The context for controlling the service lifecycle
//
// Returns:
// - error: An error if the service fails to start or run
func (s *Service) Run(ctx context.Context) error {
if s == nil {
return fmt.Errorf("cliproxy: service is nil")
}
if ctx == nil {
ctx = context.Background()
}
ctx, runCancel := context.WithCancel(ctx)
s.homeMu.Lock()
s.runCancel = runCancel
s.homeMu.Unlock()
defer func() {
runCancel()
s.homeMu.Lock()
if s.runCancel != nil {
s.runCancel = nil
}
s.homeMu.Unlock()
}()
usage.StartDefault(ctx)
homeEnabled := s.cfg != nil && s.cfg.Home.Enabled
if homeEnabled {
forceHomeRuntimeConfig(s.cfg)
redisqueue.SetUsageStatisticsEnabled(true)
}
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 !homeEnabled {
if errEnsureAuthDir := s.ensureAuthDir(); errEnsureAuthDir != nil {
return errEnsureAuthDir
}
}
s.applyRetryConfig(s.cfg)
s.configureCooldownStateStore(s.cfg)
s.registerPluginAuthParser()
if s.coreManager != nil && !homeEnabled {
if errLoad := s.coreManager.Load(ctx); errLoad != nil {
log.Warnf("failed to load auth store: %v", errLoad)
}
s.registerConfigAPIKeyAuths(coreauth.WithSkipPersist(ctx), s.cfg)
if s.cfg.SaveCooldownStatus {
if errRestoreCooldown := s.coreManager.RestoreCooldownStates(ctx); errRestoreCooldown != nil {
log.Warnf("failed to restore cooldown state: %v", errRestoreCooldown)
}
}
}
if !homeEnabled {
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{}
}
}
// legacy clients removed; no caches to refresh
s.ensureWebsocketGateway()
if homeEnabled {
s.registerAvailableExecutors(ctx, executorRegistrationOptions{
includeBaseline: true,
})
// Home mode does not expose in-process Redis RESP usage output; usage is forwarded to home instead.
redisqueue.SetEnabled(true)
}
// handlers no longer depend on legacy clients; pass nil slice initially
s.server = api.NewServer(s.cfg, s.coreManager, s.accessManager, s.configPath, s.serverOptions...)
s.syncPluginRuntimeConfig(ctx)
if homeEnabled {
s.syncPluginModelRuntime(ctx)
}
if s.authManager == nil {
s.authManager = newDefaultAuthManager()
}
if homeEnabled {
s.startHomeSubscriber(ctx)
}
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)
s.applyPprofConfig(s.cfg)
if s.hooks.OnAfterStart != nil {
s.hooks.OnAfterStart(s)
}
if !homeEnabled {
var watcherWrapper *WatcherWrapper
reloadCallback := func(newCfg *config.Config) { s.applyWatcherConfigUpdate(newCfg) }
watcherWrapper, errCreate := s.watcherFactory(s.configPath, s.cfg.AuthDir, reloadCallback)
if errCreate != nil {
return fmt.Errorf("cliproxy: failed to create watcher: %w", errCreate)
}
s.watcher = watcherWrapper
s.ensureAuthUpdateQueue(ctx)
if s.authUpdates != nil {
watcherWrapper.SetAuthUpdateQueue(s.authUpdates)
}
watcherWrapper.SetConfig(s.cfg)
s.registerPluginAuthParser()
watcherCtx, watcherCancel := context.WithCancel(context.Background())
s.watcherCancel = watcherCancel
if errStart := watcherWrapper.Start(watcherCtx); errStart != nil {
return fmt.Errorf("cliproxy: failed to start watcher: %w", errStart)
}
log.Info("file watcher started for config and auth directory changes")
s.syncPluginModelRuntime(ctx)
}
s.registerModelRefreshCallback()
// Prefer core auth manager auto refresh if available.
if s.coreManager != nil && !homeEnabled {
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 errServer := <-s.serverErr:
return errServer
}
}
// Shutdown gracefully stops background workers and the HTTP server.
// It ensures all resources are properly cleaned up and connections are closed.
// The shutdown is idempotent and can be called multiple times safely.
//
// Parameters:
// - ctx: The context for controlling the shutdown timeout
//
// Returns:
// - error: An error if shutdown fails
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()
}
s.homeLifecycleMu.Lock()
if supervisor := s.homeSupervisor; supervisor != nil {
s.homeConfigCommitMu.Lock()
supervisor.cancel()
s.homeConfigCommitMu.Unlock()
<-supervisor.done
}
s.homeMu.Lock()
homeCancel := s.homeCancel
homeClient := s.homeClient
homeRegistry := s.homeRegistry
homeDispatchBundle := s.homeDispatchBundle
homeForwarder := s.homeLogForwarder
homeForwarderClient := s.homeLogForwarderClient
s.homeGeneration++
s.homeCancel = nil
s.homeClient = nil
s.homeRegistry = nil
s.homeDispatchBundle = nil
s.homeDrainBound = 0
s.homeLogForwarder = nil
s.homeLogForwarderClient = nil
s.homeMu.Unlock()
if s.coreManager != nil {
s.coreManager.ClearHomeDispatchBundle(homeDispatchBundle)
}
home.ClearCurrentIf(homeClient)
if homeCancel != nil {
homeCancel()
}
if homeRegistry != nil {
if errClose := homeRegistry.Close(); errClose != nil {
log.WithError(errClose).Warn("failed to close Home execution registry during shutdown")
}
}
if homeClient != nil {
homeClient.Close()
}
if homeForwarder != nil {
if homeForwarderClient == homeClient {
homeForwarder.Deactivate(homeClient)
}
homeForwarder.Stop()
}
s.homeLifecycleMu.Unlock()
// legacy refresh loop removed; only stopping core auth manager below
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 errShutdownPprof := s.shutdownPprof(ctx); errShutdownPprof != nil {
log.Errorf("failed to stop pprof server: %v", errShutdownPprof)
if shutdownErr == nil {
shutdownErr = errShutdownPprof
}
}
// no legacy clients to persist
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
}
}
}
if s.pluginHost != nil {
sdktranslator.SetPluginHooks(nil)
sdkAuth.RegisterPluginAuthParser(nil)
if s.watcher != nil {
s.watcher.SetPluginAuthParser(nil)
}
s.pluginHost.ApplyConfig(ctx, &config.Config{})
s.pluginHost.RegisterModels(ctx, registry.GetGlobalRegistry())
s.registerAvailableExecutors(ctx, executorRegistrationOptions{
includePlugins: true,
})
s.pluginHost.RegisterFrontendAuthProviders()
s.pluginHost.ShutdownAllContext(ctx)
if s.accessManager != nil {
s.accessManager.SetProviders(sdkaccess.RegisteredProviders())
}
}
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
}
// registerModelsForAuth (re)binds provider models in the global registry using the core auth ID as client identifier.
func (s *Service) registerModelsForAuth(ctx context.Context, a *coreauth.Auth) {
s.registerModelsForAuthWithCache(ctx, a, nil)
}
func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreauth.Auth, compatCache *openAICompatibilityRegistrationCache) {
if a == nil || a.ID == "" {
return
}
if ctx == nil {
ctx = context.Background()
}
if ctx.Err() != nil {
return
}
if a.Disabled {
GlobalModelRegistry().UnregisterClient(a.ID)
return
}
authKind := a.AuthKind()
// Unregister legacy client ID (if present) to avoid double counting
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)
// The synthesizer pre-merges per-account and global exclusions into the "excluded_models" attribute.
// If this attribute is present, it represents the complete list of exclusions and overrides the global config.
if a.Attributes != nil {
if val, ok := a.Attributes["excluded_models"]; ok && strings.TrimSpace(val) != "" {
excluded = strings.Split(val, ",")
}
}
if s.tryRegisterPluginModelsForAuth(ctx, a, provider, authKind, excluded) {
return
}
if ctx.Err() != nil {
return
}
var models []*ModelInfo
switch provider {
case constant.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 constant.GeminiInteractions:
models = registry.GetGeminiModels()
if entry := s.resolveConfigInteractionsKey(a); entry != nil {
if len(entry.Models) > 0 {
models = buildGeminiConfigModels(entry)
}
if authKind == "apikey" {
excluded = entry.ExcludedModels
}
}
models = applyExcludedModels(models, excluded)
case "vertex":
// Vertex AI Gemini supports the same model identifiers as Gemini.
models = registry.GetGeminiVertexModels()
if entry := s.resolveConfigVertexCompatKey(a); entry != nil {
if len(entry.Models) > 0 {
models = buildVertexCompatConfigModels(entry)
}
if authKind == "apikey" {
excluded = entry.ExcludedModels
}
}
models = applyExcludedModels(models, excluded)
case "aistudio":
models = registry.GetAIStudioModels()
models = applyExcludedModels(models, excluded)
case "antigravity":
models = registry.GetAntigravityModels()
models = applyAntigravityFetchedModelCapabilities(models, s.fetchAntigravityModelCapabilityHintsForAuth(ctx, a))
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":
codexPlanType := ""
if a.Attributes != nil {
codexPlanType = strings.TrimSpace(a.Attributes["plan_type"])
}
switch strings.ToLower(codexPlanType) {
case "pro":
models = registry.GetCodexProModels()
case "plus":
models = registry.GetCodexPlusModels()
case "team", "business", "go":
models = registry.GetCodexTeamModels()
case "free":
models = registry.GetCodexFreeModels()
default:
models = registry.GetCodexProModels()
}
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 "kimi":
models = registry.GetKimiModels()
models = applyExcludedModels(models, excluded)
case "xai":
models = registry.GetXAIModels()
if entry := s.resolveConfigXAIKey(a); entry != nil {
if len(entry.Models) > 0 {
models = buildXAIConfigModels(entry)
}
if authKind == "apikey" {
excluded = entry.ExcludedModels
}
}
models = applyExcludedModels(models, excluded)
default:
// Handle OpenAI-compatibility providers by name using config
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
}
}
if cached, ok := compatCache.lookup(compatName); ok {
isCompatAuth = true
if providerKey == "" {
providerKey = cached.providerKey
}
if providerKey == "" {
providerKey = "openai-compatibility"
}
ms := cached.models
if len(ms) > 0 {
ms = s.appendPluginModels(providerKey, ms)
s.registerResolvedModelsForAuth(a, providerKey, applyModelPrefixes(ms, a.Prefix, s.cfg.ForceModelPrefix))
} else {
ms = s.appendPluginModels(providerKey, nil)
if len(ms) > 0 {
s.registerResolvedModelsForAuth(a, providerKey, applyModelPrefixes(ms, a.Prefix, s.cfg.ForceModelPrefix))
} else {
GlobalModelRegistry().UnregisterClient(a.ID)
}
}
return
}
for i := range s.cfg.OpenAICompatibility {
compat := &s.cfg.OpenAICompatibility[i]
if compat.Disabled {
continue
}
if strings.EqualFold(compat.Name, compatName) {
isCompatAuth = true
ms := buildOpenAICompatibilityConfigModels(compat)
// Register and return
if len(ms) > 0 {
if providerKey == "" {
providerKey = "openai-compatibility"
}
ms = s.appendPluginModels(providerKey, ms)
s.registerResolvedModelsForAuth(a, providerKey, applyModelPrefixes(ms, a.Prefix, s.cfg.ForceModelPrefix))
} else {
// Ensure stale registrations are cleared when model list becomes empty.
ms = s.appendPluginModels(providerKey, nil)
if len(ms) > 0 {
s.registerResolvedModelsForAuth(a, providerKey, applyModelPrefixes(ms, a.Prefix, s.cfg.ForceModelPrefix))
} else {
GlobalModelRegistry().UnregisterClient(a.ID)
}
}
return
}
}
if isCompatAuth {
models = s.appendPluginModels(providerKey, nil)
if len(models) > 0 {
s.registerResolvedModelsForAuth(a, providerKey, applyModelPrefixes(models, a.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix))
} else {
// No matching provider found or models removed entirely; drop any prior registration.
GlobalModelRegistry().UnregisterClient(a.ID)
}
return
}
}
}
if ctx.Err() != nil {
return
}
models = applyOAuthModelAliasForAuth(s.cfg, provider, authKind, a.Attributes, models)
if ctx.Err() != nil {
return
}
key := provider
if key == "" {
key = strings.ToLower(strings.TrimSpace(a.Provider))
}
models = s.appendPluginModels(key, models)
if len(models) > 0 {
s.registerResolvedModelsForAuth(a, key, applyModelPrefixes(models, a.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix))
return
}
GlobalModelRegistry().UnregisterClient(a.ID)
}
// refreshModelRegistrationForAuth re-applies the latest model registration for
// one auth and reconciles any concurrent auth changes that race with the
// refresh. Callers are expected to pre-filter provider membership.
//
// Re-registration is deliberate: registry cooldown/suspension state is treated
// as part of the previous registration snapshot and is cleared when the auth is
// rebound to the refreshed model catalog.
func (s *Service) refreshModelRegistrationForAuth(current *coreauth.Auth) bool {
return s.refreshModelRegistrationForAuthWithContext(context.Background(), current, nil)
}
func (s *Service) refreshModelRegistrationForAuthWithCache(current *coreauth.Auth, compatCache *openAICompatibilityRegistrationCache) bool {
return s.refreshModelRegistrationForAuthWithContext(context.Background(), current, compatCache)
}
func (s *Service) refreshModelRegistrationForAuthWithContext(ctx context.Context, current *coreauth.Auth, compatCache *openAICompatibilityRegistrationCache) bool {
if s == nil || s.coreManager == nil || current == nil || current.ID == "" {
return false
}
if ctx == nil {
ctx = context.Background()
}
if ctx.Err() != nil {
return false
}
if !current.Disabled {
s.ensureExecutorsForAuthWithContext(ctx, current, false)
}
s.registerModelsForAuthWithCache(ctx, current, compatCache)
s.coreManager.ReconcileRegistryModelStates(ctx, current.ID)
if ctx.Err() != nil {
return false
}
latest, ok := s.latestAuthForModelRegistration(current.ID)
if !ok || latest.Disabled {
GlobalModelRegistry().UnregisterClient(current.ID)
s.coreManager.RefreshSchedulerEntry(current.ID)
return false
}
// Re-apply the latest auth snapshot so concurrent auth updates cannot leave
// stale model registrations behind. This may duplicate registration work when
// no auth fields changed, but keeps the refresh path simple and correct.
s.ensureExecutorsForAuthWithContext(ctx, latest, false)
s.registerModelsForAuthWithCache(ctx, latest, compatCache)
if ctx.Err() != nil {
return false
}
s.coreManager.ReconcileRegistryModelStates(ctx, latest.ID)
s.coreManager.RefreshSchedulerEntry(current.ID)
return true
}
// latestAuthForModelRegistration returns the latest auth snapshot regardless of
// provider membership. Callers use this after a registration attempt to restore
// whichever state currently owns the client ID in the global registry.
func (s *Service) latestAuthForModelRegistration(authID string) (*coreauth.Auth, bool) {
if s == nil || s.coreManager == nil || authID == "" {
return nil, false
}
auth, ok := s.coreManager.GetByID(authID)
if !ok || auth == nil || auth.ID == "" {
return nil, false
}
return auth, true
}
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 s == nil || s.cfg == nil {
return nil
}
return s.resolveConfigGeminiKeyEntry(auth, s.cfg.GeminiKey)
}
func (s *Service) resolveConfigInteractionsKey(auth *coreauth.Auth) *config.GeminiKey {
if s == nil || s.cfg == nil {
return nil
}
return s.resolveConfigGeminiKeyEntry(auth, s.cfg.InteractionsKey)
}
func (s *Service) resolveConfigGeminiKeyEntry(auth *coreauth.Auth, entries []config.GeminiKey) *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 entries {
entry := &entries[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 s == nil || s.cfg == nil {
return nil
}
return resolveConfigCodexStyleKey(auth, s.cfg.CodexKey)
}
func (s *Service) resolveConfigXAIKey(auth *coreauth.Auth) *config.XAIKey {
if s == nil || s.cfg == nil {
return nil
}
return resolveConfigCodexStyleKey(auth, s.cfg.XAIKey)
}
func resolveConfigCodexStyleKey(auth *coreauth.Auth, entries []config.CodexKey) *config.CodexKey {
if auth == 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 entries {
entry := &entries[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
}
// matchWildcard performs case-insensitive wildcard matching where '*' matches any substring.
func matchWildcard(pattern, value string) bool {
if pattern == "" {
return false
}
// Fast path for exact match (no wildcard present).
if !strings.Contains(pattern, "*") {
return pattern == value
}
parts := strings.Split(pattern, "*")
// Handle prefix.
if prefix := parts[0]; prefix != "" {
if !strings.HasPrefix(value, prefix) {
return false
}
value = value[len(prefix):]
}
// Handle suffix.
if suffix := parts[len(parts)-1]; suffix != "" {
if !strings.HasSuffix(value, suffix) {
return false
}
value = value[:len(value)-len(suffix)]
}
// Handle middle segments in order.
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
GetDisplayName() string
}
func buildConfiguredModelInfo(model modelEntry, ownedBy, modelType string, created int64, fallbackDisplayName string, userDefined bool) *ModelInfo {
name := strings.TrimSpace(model.GetName())
alias := strings.TrimSpace(model.GetAlias())
if alias == "" {
alias = name
}
if alias == "" {
return nil
}
displayName := strings.TrimSpace(model.GetDisplayName())
if displayName == "" {
displayName = fallbackDisplayName
}
if displayName == "" {
displayName = alias
}
return &ModelInfo{
ID: alias,
Object: "model",
Created: created,
OwnedBy: ownedBy,
Type: modelType,
DisplayName: displayName,
UserDefined: userDefined,
}
}
func buildOpenAICompatibilityConfigModels(compat *config.OpenAICompatibility) []*ModelInfo {
if compat == nil || len(compat.Models) == 0 {
return nil
}
now := time.Now().Unix()
models := make([]*ModelInfo, 0, len(compat.Models))
for i := range compat.Models {
model := compat.Models[i]
modelType := "openai-compatibility"
if model.Image {
modelType = registry.OpenAIImageModelType
}
info := buildConfiguredModelInfo(model, compat.Name, modelType, now, strings.TrimSpace(model.Alias), false)
if info == nil {
continue
}
thinking := model.Thinking
if thinking == nil && !model.Image {
thinking = &registry.ThinkingSupport{Levels: []string{"low", "medium", "high"}}
}
info.Thinking = thinking
info.SupportedInputModalities = normalizeCompatConfigModalities(model.InputModalities)
info.SupportedOutputModalities = normalizeCompatConfigModalities(model.OutputModalities)
models = append(models, info)
}
return models
}
func normalizeCompatConfigModalities(raw []string) []string {
if len(raw) == 0 {
return nil
}
out := make([]string, 0, len(raw))
seen := make(map[string]struct{}, len(raw))
for _, item := range raw {
modality := strings.ToLower(strings.TrimSpace(item))
if modality == "" {
continue
}
if _, exists := seen[modality]; exists {
continue
}
seen[modality] = struct{}{}
out = append(out, modality)
}
if len(out) == 0 {
return nil
}
return out
}
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())
info := buildConfiguredModelInfo(model, ownedBy, modelType, now, name, true)
if info == nil {
continue
}
alias := info.ID
key := strings.ToLower(alias)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
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 buildXAIConfigModels(entry *config.XAIKey) []*ModelInfo {
if entry == nil {
return nil
}
return buildConfigModels(entry.Models, "xai", "xai")
}
func buildCodexConfigModels(entry *config.CodexKey) []*ModelInfo {
if entry == nil {
return nil
}
models := registry.WithCodexBuiltins(buildConfigModels(entry.Models, "openai", "openai"))
configuredDisplayNames := make(map[string]string, len(entry.Models))
seenConfiguredModels := make(map[string]struct{}, len(entry.Models))
for i := range entry.Models {
model := entry.Models[i]
alias := strings.TrimSpace(model.Alias)
if alias == "" {
alias = strings.TrimSpace(model.Name)
}
if alias == "" {
continue
}
key := strings.ToLower(alias)
if _, exists := seenConfiguredModels[key]; exists {
continue
}
seenConfiguredModels[key] = struct{}{}
displayName := strings.TrimSpace(model.DisplayName)
if displayName != "" {
configuredDisplayNames[key] = displayName
}
}
for _, model := range models {
if model == nil {
continue
}
if displayName, ok := configuredDisplayNames[strings.ToLower(model.ID)]; ok {
model.DisplayName = displayName
}
}
return models
}
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.EqualFold(trimmed, oldID) {
return newID
}
if strings.HasSuffix(trimmed, "/"+oldID) {
prefix := strings.TrimSuffix(trimmed, oldID)
return prefix + newID
}
if trimmed == "models/"+oldID {
return "models/" + newID
}
return name
}
func applyOAuthModelAlias(cfg *config.Config, provider, authKind string, models []*ModelInfo) []*ModelInfo {
return applyOAuthModelAliasForAuth(cfg, provider, authKind, nil, models)
}
func applyOAuthModelAliasForAuth(cfg *config.Config, provider, authKind string, attributes map[string]string, models []*ModelInfo) []*ModelInfo {
if len(models) == 0 {
return models
}
channel := coreauth.OAuthModelAliasChannel(provider, authKind)
if channel == "" {
return models
}
aliases := oauthModelAliasesForAuth(cfg, channel, attributes)
if len(aliases) == 0 {
return models
}
return applyOAuthModelAliasEntries(aliases, models)
}
func oauthModelAliasesForAuth(cfg *config.Config, channel string, attributes map[string]string) []config.OAuthModelAlias {
perAuthAliases := coreauth.OAuthModelAliasesFromAttributes(attributes)
if cfg == nil || len(cfg.OAuthModelAlias) == 0 {
return perAuthAliases
}
globalAliases := cfg.OAuthModelAlias[channel]
if len(perAuthAliases) == 0 {
return globalAliases
}
if len(globalAliases) == 0 {
return perAuthAliases
}
out := make([]config.OAuthModelAlias, 0, len(perAuthAliases)+len(globalAliases))
seenAlias := make(map[string]struct{}, len(perAuthAliases)+len(globalAliases))
add := func(aliases []config.OAuthModelAlias) {
for _, entry := range aliases {
alias := strings.TrimSpace(entry.Alias)
if alias == "" {
continue
}
key := strings.ToLower(alias)
if _, exists := seenAlias[key]; exists {
continue
}
seenAlias[key] = struct{}{}
out = append(out, entry)
}
}
add(perAuthAliases)
add(globalAliases)
return out
}
func applyOAuthModelAliasEntries(aliases []config.OAuthModelAlias, models []*ModelInfo) []*ModelInfo {
type aliasEntry struct {
alias string
displayName string
fork bool
}
forward := make(map[string][]aliasEntry, len(aliases))
for i := range aliases {
name := strings.TrimSpace(aliases[i].Name)
alias := strings.TrimSpace(aliases[i].Alias)
if name == "" || alias == "" {
continue
}
if strings.EqualFold(name, alias) {
continue
}
key := strings.ToLower(name)
forward[key] = append(forward[key], aliasEntry{
alias: alias,
displayName: strings.TrimSpace(aliases[i].DisplayName),
fork: aliases[i].Fork,
})
}
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
}
key := strings.ToLower(id)
entries := forward[key]
if len(entries) == 0 {
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
out = append(out, model)
continue
}
keepOriginal := false
for _, entry := range entries {
if entry.fork {
keepOriginal = true
break
}
}
if keepOriginal {
if _, exists := seen[key]; !exists {
seen[key] = struct{}{}
out = append(out, model)
}
}
addedAlias := false
for _, entry := range entries {
mappedID := strings.TrimSpace(entry.alias)
if mappedID == "" {
continue
}
if strings.EqualFold(mappedID, id) {
continue
}
aliasKey := strings.ToLower(mappedID)
if _, exists := seen[aliasKey]; exists {
continue
}
seen[aliasKey] = struct{}{}
clone := *model
clone.ID = mappedID
if entry.displayName != "" {
clone.DisplayName = entry.displayName
}
if clone.Name != "" {
clone.Name = rewriteModelInfoName(clone.Name, id, mappedID)
}
out = append(out, &clone)
addedAlias = true
}
if !keepOriginal && !addedAlias {
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
out = append(out, model)
}
}
return out
}