mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-18 04:10:51 +08:00
rebuild branch
This commit is contained in:
32
sdk/cliproxy/auth/errors.go
Normal file
32
sdk/cliproxy/auth/errors.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package auth
|
||||
|
||||
// Error describes an authentication related failure in a provider agnostic format.
|
||||
type Error struct {
|
||||
// Code is a short machine readable identifier.
|
||||
Code string `json:"code,omitempty"`
|
||||
// Message is a human readable description of the failure.
|
||||
Message string `json:"message"`
|
||||
// Retryable indicates whether a retry might fix the issue automatically.
|
||||
Retryable bool `json:"retryable"`
|
||||
// HTTPStatus optionally records an HTTP-like status code for the error.
|
||||
HTTPStatus int `json:"http_status,omitempty"`
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e *Error) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if e.Code == "" {
|
||||
return e.Message
|
||||
}
|
||||
return e.Code + ": " + e.Message
|
||||
}
|
||||
|
||||
// StatusCode implements optional status accessor for manager decision making.
|
||||
func (e *Error) StatusCode() int {
|
||||
if e == nil {
|
||||
return 0
|
||||
}
|
||||
return e.HTTPStatus
|
||||
}
|
||||
1206
sdk/cliproxy/auth/manager.go
Normal file
1206
sdk/cliproxy/auth/manager.go
Normal file
File diff suppressed because it is too large
Load Diff
79
sdk/cliproxy/auth/selector.go
Normal file
79
sdk/cliproxy/auth/selector.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||
)
|
||||
|
||||
// RoundRobinSelector provides a simple provider scoped round-robin selection strategy.
|
||||
type RoundRobinSelector struct {
|
||||
mu sync.Mutex
|
||||
cursors map[string]int
|
||||
}
|
||||
|
||||
// Pick selects the next available auth for the provider in a round-robin manner.
|
||||
func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {
|
||||
_ = ctx
|
||||
_ = opts
|
||||
if len(auths) == 0 {
|
||||
return nil, &Error{Code: "auth_not_found", Message: "no auth candidates"}
|
||||
}
|
||||
if s.cursors == nil {
|
||||
s.cursors = make(map[string]int)
|
||||
}
|
||||
available := make([]*Auth, 0, len(auths))
|
||||
now := time.Now()
|
||||
for i := 0; i < len(auths); i++ {
|
||||
candidate := auths[i]
|
||||
if isAuthBlockedForModel(candidate, model, now) {
|
||||
continue
|
||||
}
|
||||
available = append(available, candidate)
|
||||
}
|
||||
if len(available) == 0 {
|
||||
return nil, &Error{Code: "auth_unavailable", Message: "no auth available"}
|
||||
}
|
||||
key := provider + ":" + model
|
||||
s.mu.Lock()
|
||||
index := s.cursors[key]
|
||||
|
||||
if index >= 2_147_483_640 {
|
||||
index = 0
|
||||
}
|
||||
|
||||
s.cursors[key] = index + 1
|
||||
s.mu.Unlock()
|
||||
// log.Debugf("available: %d, index: %d, key: %d", len(available), index, index%len(available))
|
||||
return available[index%len(available)], nil
|
||||
}
|
||||
|
||||
func isAuthBlockedForModel(auth *Auth, model string, now time.Time) bool {
|
||||
if auth == nil {
|
||||
return true
|
||||
}
|
||||
if auth.Disabled || auth.Status == StatusDisabled {
|
||||
return true
|
||||
}
|
||||
if model != "" && len(auth.ModelStates) > 0 {
|
||||
if state, ok := auth.ModelStates[model]; ok && state != nil {
|
||||
if state.Status == StatusDisabled {
|
||||
return true
|
||||
}
|
||||
if state.Unavailable {
|
||||
if state.NextRetryAfter.IsZero() {
|
||||
return false
|
||||
}
|
||||
if state.NextRetryAfter.After(now) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if auth.Unavailable && auth.NextRetryAfter.After(now) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
19
sdk/cliproxy/auth/status.go
Normal file
19
sdk/cliproxy/auth/status.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package auth
|
||||
|
||||
// Status represents the lifecycle state of an Auth entry.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
// StatusUnknown means the auth state could not be determined.
|
||||
StatusUnknown Status = "unknown"
|
||||
// StatusActive indicates the auth is valid and ready for execution.
|
||||
StatusActive Status = "active"
|
||||
// StatusPending indicates the auth is waiting for an external action, such as MFA.
|
||||
StatusPending Status = "pending"
|
||||
// StatusRefreshing indicates the auth is undergoing a refresh flow.
|
||||
StatusRefreshing Status = "refreshing"
|
||||
// StatusError indicates the auth is temporarily unavailable due to errors.
|
||||
StatusError Status = "error"
|
||||
// StatusDisabled marks the auth as intentionally disabled.
|
||||
StatusDisabled Status = "disabled"
|
||||
)
|
||||
13
sdk/cliproxy/auth/store.go
Normal file
13
sdk/cliproxy/auth/store.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package auth
|
||||
|
||||
import "context"
|
||||
|
||||
// Store abstracts persistence of Auth state across restarts.
|
||||
type Store interface {
|
||||
// List returns all auth records stored in the backend.
|
||||
List(ctx context.Context) ([]*Auth, error)
|
||||
// SaveAuth persists the provided auth record, replacing any existing one with same ID.
|
||||
SaveAuth(ctx context.Context, auth *Auth) error
|
||||
// Delete removes the auth record identified by id.
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
289
sdk/cliproxy/auth/types.go
Normal file
289
sdk/cliproxy/auth/types.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Auth encapsulates the runtime state and metadata associated with a single credential.
|
||||
type Auth struct {
|
||||
// ID uniquely identifies the auth record across restarts.
|
||||
ID string `json:"id"`
|
||||
// Provider is the upstream provider key (e.g. "gemini", "claude").
|
||||
Provider string `json:"provider"`
|
||||
// Label is an optional human readable label for logging.
|
||||
Label string `json:"label,omitempty"`
|
||||
// Status is the lifecycle status managed by the AuthManager.
|
||||
Status Status `json:"status"`
|
||||
// StatusMessage holds a short description for the current status.
|
||||
StatusMessage string `json:"status_message,omitempty"`
|
||||
// Disabled indicates the auth is intentionally disabled by operator.
|
||||
Disabled bool `json:"disabled"`
|
||||
// Unavailable flags transient provider unavailability (e.g. quota exceeded).
|
||||
Unavailable bool `json:"unavailable"`
|
||||
// ProxyURL overrides the global proxy setting for this auth if provided.
|
||||
ProxyURL string `json:"proxy_url,omitempty"`
|
||||
// Attributes stores provider specific metadata needed by executors (immutable configuration).
|
||||
Attributes map[string]string `json:"attributes,omitempty"`
|
||||
// Metadata stores runtime mutable provider state (e.g. tokens, cookies).
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
// Quota captures recent quota information for load balancers.
|
||||
Quota QuotaState `json:"quota"`
|
||||
// LastError stores the last failure encountered while executing or refreshing.
|
||||
LastError *Error `json:"last_error,omitempty"`
|
||||
// CreatedAt is the creation timestamp in UTC.
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// UpdatedAt is the last modification timestamp in UTC.
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// LastRefreshedAt records the last successful refresh time in UTC.
|
||||
LastRefreshedAt time.Time `json:"last_refreshed_at"`
|
||||
// NextRefreshAfter is the earliest time a refresh should retrigger.
|
||||
NextRefreshAfter time.Time `json:"next_refresh_after"`
|
||||
// NextRetryAfter is the earliest time a retry should retrigger.
|
||||
NextRetryAfter time.Time `json:"next_retry_after"`
|
||||
// ModelStates tracks per-model runtime availability data.
|
||||
ModelStates map[string]*ModelState `json:"model_states,omitempty"`
|
||||
|
||||
// Runtime carries non-serialisable data used during execution (in-memory only).
|
||||
Runtime any `json:"-"`
|
||||
}
|
||||
|
||||
// QuotaState contains limiter tracking data for a credential.
|
||||
type QuotaState struct {
|
||||
// Exceeded indicates the credential recently hit a quota error.
|
||||
Exceeded bool `json:"exceeded"`
|
||||
// Reason provides an optional provider specific human readable description.
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// NextRecoverAt is when the credential may become available again.
|
||||
NextRecoverAt time.Time `json:"next_recover_at"`
|
||||
}
|
||||
|
||||
// ModelState captures the execution state for a specific model under an auth entry.
|
||||
type ModelState struct {
|
||||
// Status reflects the lifecycle status for this model.
|
||||
Status Status `json:"status"`
|
||||
// StatusMessage provides an optional short description of the status.
|
||||
StatusMessage string `json:"status_message,omitempty"`
|
||||
// Unavailable mirrors whether the model is temporarily blocked for retries.
|
||||
Unavailable bool `json:"unavailable"`
|
||||
// NextRetryAfter defines the per-model retry time.
|
||||
NextRetryAfter time.Time `json:"next_retry_after"`
|
||||
// LastError records the latest error observed for this model.
|
||||
LastError *Error `json:"last_error,omitempty"`
|
||||
// Quota retains quota information if this model hit rate limits.
|
||||
Quota QuotaState `json:"quota"`
|
||||
// UpdatedAt tracks the last update timestamp for this model state.
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Clone shallow copies the Auth structure, duplicating maps to avoid accidental mutation.
|
||||
func (a *Auth) Clone() *Auth {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
copyAuth := *a
|
||||
if len(a.Attributes) > 0 {
|
||||
copyAuth.Attributes = make(map[string]string, len(a.Attributes))
|
||||
for key, value := range a.Attributes {
|
||||
copyAuth.Attributes[key] = value
|
||||
}
|
||||
}
|
||||
if len(a.Metadata) > 0 {
|
||||
copyAuth.Metadata = make(map[string]any, len(a.Metadata))
|
||||
for key, value := range a.Metadata {
|
||||
copyAuth.Metadata[key] = value
|
||||
}
|
||||
}
|
||||
if len(a.ModelStates) > 0 {
|
||||
copyAuth.ModelStates = make(map[string]*ModelState, len(a.ModelStates))
|
||||
for key, state := range a.ModelStates {
|
||||
copyAuth.ModelStates[key] = state.Clone()
|
||||
}
|
||||
}
|
||||
copyAuth.Runtime = a.Runtime
|
||||
return ©Auth
|
||||
}
|
||||
|
||||
// Clone duplicates a model state including nested error details.
|
||||
func (m *ModelState) Clone() *ModelState {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
copyState := *m
|
||||
if m.LastError != nil {
|
||||
copyState.LastError = &Error{
|
||||
Code: m.LastError.Code,
|
||||
Message: m.LastError.Message,
|
||||
Retryable: m.LastError.Retryable,
|
||||
HTTPStatus: m.LastError.HTTPStatus,
|
||||
}
|
||||
}
|
||||
return ©State
|
||||
}
|
||||
|
||||
func (a *Auth) AccountInfo() (string, string) {
|
||||
if a == nil {
|
||||
return "", ""
|
||||
}
|
||||
if strings.ToLower(a.Provider) == "gemini-web" {
|
||||
if a.Metadata != nil {
|
||||
if v, ok := a.Metadata["secure_1psid"].(string); ok && v != "" {
|
||||
return "cookie", v
|
||||
}
|
||||
if v, ok := a.Metadata["__Secure-1PSID"].(string); ok && v != "" {
|
||||
return "cookie", v
|
||||
}
|
||||
}
|
||||
if a.Attributes != nil {
|
||||
if v := a.Attributes["secure_1psid"]; v != "" {
|
||||
return "cookie", v
|
||||
}
|
||||
if v := a.Attributes["api_key"]; v != "" {
|
||||
return "cookie", v
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.Metadata != nil {
|
||||
if v, ok := a.Metadata["email"].(string); ok {
|
||||
return "oauth", v
|
||||
}
|
||||
} else if a.Attributes != nil {
|
||||
if v := a.Attributes["api_key"]; v != "" {
|
||||
return "api_key", v
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// ExpirationTime attempts to extract the credential expiration timestamp from metadata.
|
||||
// It inspects common keys such as "expired", "expire", "expires_at", and also
|
||||
// nested "token" objects to remain compatible with legacy auth file formats.
|
||||
func (a *Auth) ExpirationTime() (time.Time, bool) {
|
||||
if a == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if ts, ok := expirationFromMap(a.Metadata); ok {
|
||||
return ts, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
var (
|
||||
refreshLeadMu sync.RWMutex
|
||||
refreshLeadFactories = make(map[string]func() *time.Duration)
|
||||
)
|
||||
|
||||
func RegisterRefreshLeadProvider(provider string, factory func() *time.Duration) {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
if provider == "" || factory == nil {
|
||||
return
|
||||
}
|
||||
refreshLeadMu.Lock()
|
||||
refreshLeadFactories[provider] = factory
|
||||
refreshLeadMu.Unlock()
|
||||
}
|
||||
|
||||
var expireKeys = [...]string{"expired", "expire", "expires_at", "expiresAt", "expiry", "expires"}
|
||||
|
||||
func expirationFromMap(meta map[string]any) (time.Time, bool) {
|
||||
if meta == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
for _, key := range expireKeys {
|
||||
if v, ok := meta[key]; ok {
|
||||
if ts, ok1 := parseTimeValue(v); ok1 {
|
||||
return ts, true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, nestedKey := range []string{"token", "Token"} {
|
||||
if nested, ok := meta[nestedKey]; ok {
|
||||
switch val := nested.(type) {
|
||||
case map[string]any:
|
||||
if ts, ok1 := expirationFromMap(val); ok1 {
|
||||
return ts, true
|
||||
}
|
||||
case map[string]string:
|
||||
temp := make(map[string]any, len(val))
|
||||
for k, v := range val {
|
||||
temp[k] = v
|
||||
}
|
||||
if ts, ok1 := expirationFromMap(temp); ok1 {
|
||||
return ts, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func ProviderRefreshLead(provider string, runtime any) *time.Duration {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
if runtime != nil {
|
||||
if eval, ok := runtime.(interface{ RefreshLead() *time.Duration }); ok {
|
||||
if lead := eval.RefreshLead(); lead != nil && *lead > 0 {
|
||||
return lead
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshLeadMu.RLock()
|
||||
factory := refreshLeadFactories[provider]
|
||||
refreshLeadMu.RUnlock()
|
||||
if factory == nil {
|
||||
return nil
|
||||
}
|
||||
if lead := factory(); lead != nil && *lead > 0 {
|
||||
return lead
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseTimeValue(v any) (time.Time, bool) {
|
||||
switch value := v.(type) {
|
||||
case string:
|
||||
s := strings.TrimSpace(value)
|
||||
if s == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339,
|
||||
time.RFC3339Nano,
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if ts, err := time.Parse(layout, s); err == nil {
|
||||
return ts, true
|
||||
}
|
||||
}
|
||||
if unix, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
return normaliseUnix(unix), true
|
||||
}
|
||||
case float64:
|
||||
return normaliseUnix(int64(value)), true
|
||||
case int64:
|
||||
return normaliseUnix(value), true
|
||||
case json.Number:
|
||||
if i, err := value.Int64(); err == nil {
|
||||
return normaliseUnix(i), true
|
||||
}
|
||||
if f, err := value.Float64(); err == nil {
|
||||
return normaliseUnix(int64(f)), true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func normaliseUnix(raw int64) time.Time {
|
||||
if raw <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
// Heuristic: treat values with millisecond precision (>1e12) accordingly.
|
||||
if raw > 1_000_000_000_000 {
|
||||
return time.UnixMilli(raw)
|
||||
}
|
||||
return time.Unix(raw, 0)
|
||||
}
|
||||
Reference in New Issue
Block a user