7c24d54ca8
When multiple auth credentials are configured, requests from the same
session are now routed to the same credential, improving upstream prompt
cache hit rates and maintaining context continuity.
Core components:
- SessionAffinitySelector: wraps RoundRobin/FillFirst selectors with
session-to-auth binding; automatic failover when bound auth is
unavailable, re-binding via the fallback selector for even distribution
- SessionCache: TTL-based in-memory cache with background cleanup
goroutine, supporting per-session and per-auth invalidation
- StoppableSelector interface: lifecycle hook for selectors holding
resources, called during Manager.StopAutoRefresh()
Session ID extraction priority (extractSessionIDs):
1. metadata.user_id with Claude Code session format (old
user_{hash}_session_{uuid} and new JSON {session_id} format)
2. X-Session-ID header (generic client support)
3. metadata.user_id (non-Claude format, used as-is)
4. conversation_id field
5. Stable FNV hash from system prompt + first user/assistant messages
(fallback for clients with no explicit session ID); returns both a
full hash (primaryID) and a short hash without assistant content
(fallbackID) to inherit bindings from the first turn
Multi-format message hash covers OpenAI messages, Claude system array,
Gemini contents/systemInstruction, and OpenAI Responses API input items
(including inline messages with role but no type field).
Configuration (config.yaml routing section):
- session-affinity: bool (default false)
- session-affinity-ttl: duration string (default "1h")
- claude-code-session-affinity: bool (deprecated, alias for above)
All three fields trigger selector rebuild on config hot reload.
Side effect: Idempotency-Key header is no longer auto-generated with a
random UUID when absent — only forwarded when explicitly provided by the
client, to avoid polluting session hash extraction.
261 lines
8.2 KiB
Go
261 lines
8.2 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 (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
|
|
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
|
)
|
|
|
|
// Builder constructs a Service instance with customizable providers.
|
|
// It provides a fluent interface for configuring all aspects of the service
|
|
// including authentication, file watching, HTTP server options, and lifecycle hooks.
|
|
type Builder struct {
|
|
// cfg holds the application configuration.
|
|
cfg *config.Config
|
|
|
|
// 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
|
|
|
|
// 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
|
|
|
|
// serverOptions contains additional server configuration options.
|
|
serverOptions []api.ServerOption
|
|
}
|
|
|
|
// Hooks allows callers to plug into service lifecycle stages.
|
|
// These callbacks provide opportunities to perform custom initialization
|
|
// and cleanup operations during service startup and shutdown.
|
|
type Hooks struct {
|
|
// OnBeforeStart is called before the service starts, allowing configuration
|
|
// modifications or additional setup.
|
|
OnBeforeStart func(*config.Config)
|
|
|
|
// OnAfterStart is called after the service has started successfully,
|
|
// providing access to the service instance for additional operations.
|
|
OnAfterStart func(*Service)
|
|
}
|
|
|
|
// NewBuilder creates a Builder with default dependencies left unset.
|
|
// Use the fluent interface methods to configure the service before calling Build().
|
|
//
|
|
// Returns:
|
|
// - *Builder: A new builder instance ready for configuration
|
|
func NewBuilder() *Builder {
|
|
return &Builder{}
|
|
}
|
|
|
|
// WithConfig sets the configuration instance used by the service.
|
|
//
|
|
// Parameters:
|
|
// - cfg: The application configuration
|
|
//
|
|
// Returns:
|
|
// - *Builder: The builder instance for method chaining
|
|
func (b *Builder) WithConfig(cfg *config.Config) *Builder {
|
|
b.cfg = cfg
|
|
return b
|
|
}
|
|
|
|
// WithConfigPath sets the absolute configuration file path used for reload watching.
|
|
//
|
|
// Parameters:
|
|
// - path: The absolute path to the configuration file
|
|
//
|
|
// Returns:
|
|
// - *Builder: The builder instance for method chaining
|
|
func (b *Builder) WithConfigPath(path string) *Builder {
|
|
b.configPath = path
|
|
return b
|
|
}
|
|
|
|
// WithTokenClientProvider overrides the provider responsible for token-backed clients.
|
|
func (b *Builder) WithTokenClientProvider(provider TokenClientProvider) *Builder {
|
|
b.tokenProvider = provider
|
|
return b
|
|
}
|
|
|
|
// WithAPIKeyClientProvider overrides the provider responsible for API key-backed clients.
|
|
func (b *Builder) WithAPIKeyClientProvider(provider APIKeyClientProvider) *Builder {
|
|
b.apiKeyProvider = provider
|
|
return b
|
|
}
|
|
|
|
// WithWatcherFactory allows customizing the watcher factory that handles reloads.
|
|
func (b *Builder) WithWatcherFactory(factory WatcherFactory) *Builder {
|
|
b.watcherFactory = factory
|
|
return b
|
|
}
|
|
|
|
// WithHooks registers lifecycle hooks executed around service startup.
|
|
func (b *Builder) WithHooks(h Hooks) *Builder {
|
|
b.hooks = h
|
|
return b
|
|
}
|
|
|
|
// WithAuthManager overrides the authentication manager used for token lifecycle operations.
|
|
func (b *Builder) WithAuthManager(mgr *sdkAuth.Manager) *Builder {
|
|
b.authManager = mgr
|
|
return b
|
|
}
|
|
|
|
// WithRequestAccessManager overrides the request authentication manager.
|
|
func (b *Builder) WithRequestAccessManager(mgr *sdkaccess.Manager) *Builder {
|
|
b.accessManager = mgr
|
|
return b
|
|
}
|
|
|
|
// WithCoreAuthManager overrides the runtime auth manager responsible for request execution.
|
|
func (b *Builder) WithCoreAuthManager(mgr *coreauth.Manager) *Builder {
|
|
b.coreManager = mgr
|
|
return b
|
|
}
|
|
|
|
// WithServerOptions appends server configuration options used during construction.
|
|
func (b *Builder) WithServerOptions(opts ...api.ServerOption) *Builder {
|
|
b.serverOptions = append(b.serverOptions, opts...)
|
|
return b
|
|
}
|
|
|
|
// WithLocalManagementPassword configures a password that is only accepted from localhost management requests.
|
|
func (b *Builder) WithLocalManagementPassword(password string) *Builder {
|
|
if password == "" {
|
|
return b
|
|
}
|
|
b.serverOptions = append(b.serverOptions, api.WithLocalManagementPassword(password))
|
|
return b
|
|
}
|
|
|
|
// WithPostAuthHook registers a hook to be called after an Auth record is created
|
|
// but before it is persisted to storage.
|
|
func (b *Builder) WithPostAuthHook(hook coreauth.PostAuthHook) *Builder {
|
|
if hook == nil {
|
|
return b
|
|
}
|
|
b.serverOptions = append(b.serverOptions, api.WithPostAuthHook(hook))
|
|
return b
|
|
}
|
|
|
|
// Build validates inputs, applies defaults, and returns a ready-to-run service.
|
|
func (b *Builder) Build() (*Service, error) {
|
|
if b.cfg == nil {
|
|
return nil, fmt.Errorf("cliproxy: configuration is required")
|
|
}
|
|
if b.configPath == "" {
|
|
return nil, fmt.Errorf("cliproxy: configuration path is required")
|
|
}
|
|
|
|
tokenProvider := b.tokenProvider
|
|
if tokenProvider == nil {
|
|
tokenProvider = NewFileTokenClientProvider()
|
|
}
|
|
|
|
apiKeyProvider := b.apiKeyProvider
|
|
if apiKeyProvider == nil {
|
|
apiKeyProvider = NewAPIKeyClientProvider()
|
|
}
|
|
|
|
watcherFactory := b.watcherFactory
|
|
if watcherFactory == nil {
|
|
watcherFactory = defaultWatcherFactory
|
|
}
|
|
|
|
authManager := b.authManager
|
|
if authManager == nil {
|
|
authManager = newDefaultAuthManager()
|
|
}
|
|
|
|
accessManager := b.accessManager
|
|
if accessManager == nil {
|
|
accessManager = sdkaccess.NewManager()
|
|
}
|
|
|
|
configaccess.Register(&b.cfg.SDKConfig)
|
|
accessManager.SetProviders(sdkaccess.RegisteredProviders())
|
|
|
|
coreManager := b.coreManager
|
|
if coreManager == nil {
|
|
tokenStore := sdkAuth.GetTokenStore()
|
|
if dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok && b.cfg != nil {
|
|
dirSetter.SetBaseDir(b.cfg.AuthDir)
|
|
}
|
|
|
|
strategy := ""
|
|
sessionAffinity := false
|
|
sessionAffinityTTL := time.Hour
|
|
if b.cfg != nil {
|
|
strategy = strings.ToLower(strings.TrimSpace(b.cfg.Routing.Strategy))
|
|
// Support both legacy ClaudeCodeSessionAffinity and new universal SessionAffinity
|
|
sessionAffinity = b.cfg.Routing.ClaudeCodeSessionAffinity || b.cfg.Routing.SessionAffinity
|
|
if ttlStr := strings.TrimSpace(b.cfg.Routing.SessionAffinityTTL); ttlStr != "" {
|
|
if parsed, err := time.ParseDuration(ttlStr); err == nil && parsed > 0 {
|
|
sessionAffinityTTL = parsed
|
|
}
|
|
}
|
|
}
|
|
var selector coreauth.Selector
|
|
switch strategy {
|
|
case "fill-first", "fillfirst", "ff":
|
|
selector = &coreauth.FillFirstSelector{}
|
|
default:
|
|
selector = &coreauth.RoundRobinSelector{}
|
|
}
|
|
|
|
// Wrap with session affinity if enabled (failover is always on)
|
|
if sessionAffinity {
|
|
selector = coreauth.NewSessionAffinitySelectorWithConfig(coreauth.SessionAffinityConfig{
|
|
Fallback: selector,
|
|
TTL: sessionAffinityTTL,
|
|
})
|
|
}
|
|
|
|
coreManager = coreauth.NewManager(tokenStore, selector, nil)
|
|
}
|
|
// Attach a default RoundTripper provider so providers can opt-in per-auth transports.
|
|
coreManager.SetRoundTripperProvider(newDefaultRoundTripperProvider())
|
|
coreManager.SetConfig(b.cfg)
|
|
coreManager.SetOAuthModelAlias(b.cfg.OAuthModelAlias)
|
|
|
|
service := &Service{
|
|
cfg: b.cfg,
|
|
configPath: b.configPath,
|
|
tokenProvider: tokenProvider,
|
|
apiKeyProvider: apiKeyProvider,
|
|
watcherFactory: watcherFactory,
|
|
hooks: b.hooks,
|
|
authManager: authManager,
|
|
accessManager: accessManager,
|
|
coreManager: coreManager,
|
|
serverOptions: append([]api.ServerOption(nil), b.serverOptions...),
|
|
}
|
|
return service, nil
|
|
}
|