package auth import ( "context" "encoding/json" "fmt" "hash/fnv" "math" "net/http" "sort" "strconv" "strings" "sync" "time" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/router-for-me/CLIProxyAPI/v7/internal/credentialweight" "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" cliproxysession "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/session" ) // RoundRobinSelector provides a simple provider scoped round-robin selection strategy. type RoundRobinSelector struct { mu sync.Mutex cursors map[string]int maxKeys int } // WeightedRoundRobinSelector provides smooth weighted round-robin selection. type WeightedRoundRobinSelector struct { mu sync.Mutex states map[string]*smoothWeightedState maxKeys int } type smoothWeightedState struct { current map[string]int64 weights map[string]int64 } type weightedSelectorStateModelKey struct{} func withWeightedSelectorStateModel(ctx context.Context, selector Selector, routeModel string) context.Context { if _, ok := selector.(*WeightedRoundRobinSelector); !ok || strings.TrimSpace(routeModel) == "" { return ctx } return context.WithValue(ctx, weightedSelectorStateModelKey{}, routeModel) } func weightedSelectorStateModel(ctx context.Context, availabilityModel string) string { if ctx != nil { if routeModel, ok := ctx.Value(weightedSelectorStateModelKey{}).(string); ok && strings.TrimSpace(routeModel) != "" { return routeModel } } return availabilityModel } // FillFirstSelector selects the first available credential (deterministic ordering). // This "burns" one account before moving to the next, which can help stagger // rolling-window subscription caps (e.g. chat message limits). type FillFirstSelector struct{} type blockReason int const ( blockReasonNone blockReason = iota blockReasonCooldown blockReasonDisabled blockReasonOther ) type modelCooldownError struct { model string resetIn time.Duration provider string } func newModelCooldownError(model, provider string, resetIn time.Duration) *modelCooldownError { if resetIn < 0 { resetIn = 0 } return &modelCooldownError{ model: model, provider: provider, resetIn: resetIn, } } func (e *modelCooldownError) Error() string { modelName := e.model if modelName == "" { modelName = "requested model" } message := fmt.Sprintf("All credentials for model %s are cooling down", modelName) if e.provider != "" { message = fmt.Sprintf("%s via provider %s", message, e.provider) } resetSeconds := int(math.Ceil(e.resetIn.Seconds())) if resetSeconds < 0 { resetSeconds = 0 } displayDuration := e.resetIn if displayDuration > 0 && displayDuration < time.Second { displayDuration = time.Second } else { displayDuration = displayDuration.Round(time.Second) } errorBody := map[string]any{ "code": "model_cooldown", "message": message, "model": e.model, "reset_time": displayDuration.String(), "reset_seconds": resetSeconds, } if e.provider != "" { errorBody["provider"] = e.provider } payload := map[string]any{"error": errorBody} data, err := json.Marshal(payload) if err != nil { return fmt.Sprintf(`{"error":{"code":"model_cooldown","message":"%s"}}`, message) } return string(data) } func (e *modelCooldownError) StatusCode() int { return http.StatusTooManyRequests } func (e *modelCooldownError) Headers() http.Header { headers := make(http.Header) headers.Set("Content-Type", "application/json") resetSeconds := int(math.Ceil(e.resetIn.Seconds())) if resetSeconds < 0 { resetSeconds = 0 } headers.Set("Retry-After", strconv.Itoa(resetSeconds)) return headers } func authPriority(auth *Auth) int { if auth == nil || auth.Attributes == nil { return 0 } raw := strings.TrimSpace(auth.Attributes["priority"]) if raw == "" { return 0 } parsed, err := strconv.Atoi(raw) if err != nil { return 0 } return parsed } func authWeight(auth *Auth) int64 { if auth == nil { return credentialweight.Default } if rawWeight, ok := auth.Attributes[AttributeWeight]; ok && strings.TrimSpace(rawWeight) != "" { weight, errParse := credentialweight.ParseString(rawWeight) if errParse != nil { return 0 } return weight } if rawWeight, ok := auth.Metadata[AttributeWeight]; ok { weight, errParse := credentialweight.ParseValue(rawWeight) if errParse != nil { return 0 } return weight } return credentialweight.Default } func canonicalModelKey(model string) string { model = strings.TrimSpace(model) if model == "" { return "" } parsed := thinking.ParseSuffix(model) modelName := strings.TrimSpace(parsed.ModelName) if modelName == "" { return model } return modelName } func authWebsocketsEnabled(auth *Auth) bool { if auth == nil { return false } if len(auth.Attributes) > 0 { if raw := strings.TrimSpace(auth.Attributes["websockets"]); raw != "" { parsed, errParse := strconv.ParseBool(raw) if errParse == nil { return parsed } } } if len(auth.Metadata) == 0 { return false } raw, ok := auth.Metadata["websockets"] if !ok || raw == nil { return false } switch v := raw.(type) { case bool: return v case string: parsed, errParse := strconv.ParseBool(strings.TrimSpace(v)) if errParse == nil { return parsed } default: } return false } func preferCodexWebsocketAuths(ctx context.Context, provider string, available []*Auth) []*Auth { if len(available) == 0 { return available } if !cliproxyexecutor.DownstreamWebsocket(ctx) { return available } if !strings.EqualFold(strings.TrimSpace(provider), "codex") { return available } wsEnabled := make([]*Auth, 0, len(available)) for i := 0; i < len(available); i++ { candidate := available[i] if authWebsocketsEnabled(candidate) { wsEnabled = append(wsEnabled, candidate) } } if len(wsEnabled) > 0 { return wsEnabled } return available } func collectAvailableByPriority(auths []*Auth, model string, now time.Time) (available map[int][]*Auth, cooldownCount int, earliest time.Time) { available = make(map[int][]*Auth) for i := 0; i < len(auths); i++ { candidate := auths[i] blocked, reason, next := isAuthBlockedForModel(candidate, model, now) if !blocked { priority := authPriority(candidate) available[priority] = append(available[priority], candidate) continue } if reason == blockReasonCooldown { cooldownCount++ if !next.IsZero() && (earliest.IsZero() || next.Before(earliest)) { earliest = next } } } return available, cooldownCount, earliest } func getAvailableAuths(auths []*Auth, provider, model string, now time.Time) ([]*Auth, error) { return getAvailableAuthsWithPriorityMode(auths, provider, model, now, false) } func getAvailableAuthsAcrossPriorities(auths []*Auth, provider, model string, now time.Time) ([]*Auth, error) { return getAvailableAuthsWithPriorityMode(auths, provider, model, now, true) } func getAvailableAuthsWithPriorityMode(auths []*Auth, provider, model string, now time.Time, allPriorities bool) ([]*Auth, error) { if len(auths) == 0 { return nil, &Error{Code: "auth_not_found", Message: "no auth candidates"} } availableByPriority, cooldownCount, earliest := collectAvailableByPriority(auths, model, now) if len(availableByPriority) == 0 { if cooldownCount == len(auths) && !earliest.IsZero() { providerForError := provider if providerForError == "mixed" { providerForError = "" } resetIn := earliest.Sub(now) if resetIn < 0 { resetIn = 0 } return nil, newModelCooldownError(model, providerForError, resetIn) } return nil, &Error{Code: "auth_unavailable", Message: "no auth available"} } return availableAuthsFromPriorityBuckets(availableByPriority, allPriorities), nil } // availableAuthsFromPriorityBuckets flattens availability buckets into a stable, ID-sorted slice. // When allPriorities is false only the highest available priority tier is returned. // When allPriorities is true every tier is merged, so the result carries no priority ordering: // use it for membership checks or feed it to highestPriorityAuths, never as a priority-ordered // selection order. func availableAuthsFromPriorityBuckets(availableByPriority map[int][]*Auth, allPriorities bool) []*Auth { var candidates []*Auth if allPriorities { total := 0 for _, bucket := range availableByPriority { total += len(bucket) } candidates = make([]*Auth, 0, total) for _, bucket := range availableByPriority { candidates = append(candidates, bucket...) } } else { bestPriority := 0 found := false for priority := range availableByPriority { if !found || priority > bestPriority { bestPriority = priority found = true } } bucket := availableByPriority[bestPriority] candidates = make([]*Auth, 0, len(bucket)) candidates = append(candidates, bucket...) } if len(candidates) > 1 { sort.Slice(candidates, func(i, j int) bool { return candidates[i].ID < candidates[j].ID }) } return candidates } // highestPriorityAuths narrows an availability slice to its highest priority tier while // preserving the input order. The input slice is returned unchanged when every candidate // already shares the highest priority, so the common single-tier case allocates nothing. func highestPriorityAuths(auths []*Auth) []*Auth { if len(auths) <= 1 { return auths } bestPriority := 0 bestCount := 0 for _, auth := range auths { priority := authPriority(auth) switch { case bestCount == 0 || priority > bestPriority: bestPriority = priority bestCount = 1 case priority == bestPriority: bestCount++ } } if bestCount == len(auths) { return auths } highest := make([]*Auth, 0, bestCount) for _, auth := range auths { if authPriority(auth) == bestPriority { highest = append(highest, auth) } } return highest } // 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) { _ = opts now := time.Now() available, err := getAvailableAuths(auths, provider, model, now) if err != nil { return nil, err } available = preferCodexWebsocketAuths(ctx, provider, available) key := provider + ":" + canonicalModelKey(model) s.mu.Lock() if s.cursors == nil { s.cursors = make(map[string]int) } limit := s.maxKeys if limit <= 0 { limit = 4096 } s.ensureCursorKey(key, limit) index := s.cursors[key] if index >= 2_147_483_640 { index = 0 } s.cursors[key] = index + 1 s.mu.Unlock() return available[index%len(available)], nil } // ensureCursorKey ensures the cursor map has capacity for the given key. // Must be called with s.mu held. func (s *RoundRobinSelector) ensureCursorKey(key string, limit int) { if _, ok := s.cursors[key]; !ok && len(s.cursors) >= limit { s.cursors = make(map[string]int) } } func positiveWeightAuths(auths []*Auth) []*Auth { weightedCandidates := make([]*Auth, 0, len(auths)) for _, auth := range auths { if authWeight(auth) > 0 { weightedCandidates = append(weightedCandidates, auth) } } return weightedCandidates } // Pick selects the next available auth using smooth weighted round-robin. func (s *WeightedRoundRobinSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) { _ = opts available, errAvailable := getAvailableAuths(positiveWeightAuths(auths), provider, model, time.Now()) if errAvailable != nil { return nil, errAvailable } available = preferCodexWebsocketAuths(ctx, provider, available) stateModel := weightedSelectorStateModel(ctx, model) key := provider + ":" + canonicalModelKey(stateModel) s.mu.Lock() defer s.mu.Unlock() if s.states == nil { s.states = make(map[string]*smoothWeightedState) } limit := s.maxKeys if limit <= 0 { limit = 4096 } if _, ok := s.states[key]; !ok && len(s.states) >= limit { s.states = make(map[string]*smoothWeightedState) } state := s.states[key] if state == nil { state = &smoothWeightedState{} s.states[key] = state } weights := authWeightVector(available) state.prepare(weights) picked := pickSmoothWeightedAuth(available, state.current) if picked == nil { return nil, &Error{Code: "auth_unavailable", Message: "no auth available with positive weight"} } return picked, nil } func (s *smoothWeightedState) prepare(weights map[string]int64) { if s.current == nil || !weightVectorsEqual(s.weights, weights) { s.current = make(map[string]int64) } s.weights = weights } func weightVectorsEqual(left, right map[string]int64) bool { if len(left) != len(right) { return false } for authID, weight := range left { if right[authID] != weight { return false } } return true } func authWeightVector(auths []*Auth) map[string]int64 { weights := make(map[string]int64, len(auths)) for _, auth := range auths { if auth == nil { continue } if weight := authWeight(auth); weight > 0 { weights[auth.ID] = weight } } return weights } func pickSmoothWeightedAuth(auths []*Auth, current map[string]int64) *Auth { active := make(map[string]struct{}, len(auths)) var picked *Auth var pickedCurrent int64 var totalWeight int64 for _, auth := range auths { weight := authWeight(auth) if auth == nil || weight <= 0 { continue } active[auth.ID] = struct{}{} current[auth.ID] = saturatingAddInt64(current[auth.ID], weight) totalWeight = saturatingAddInt64(totalWeight, weight) if picked == nil || current[auth.ID] > pickedCurrent { picked = auth pickedCurrent = current[auth.ID] } } for authID := range current { if _, ok := active[authID]; !ok { delete(current, authID) } } if picked == nil { return nil } current[picked.ID] = saturatingAddInt64(current[picked.ID], -totalWeight) return picked } func saturatingAddInt64(value, delta int64) int64 { if delta > 0 && value > math.MaxInt64-delta { return math.MaxInt64 } if delta < 0 && value < math.MinInt64-delta { return math.MinInt64 } return value + delta } // Pick selects the first available auth for the provider in a deterministic manner. func (s *FillFirstSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) { _ = opts now := time.Now() available, err := getAvailableAuths(auths, provider, model, now) if err != nil { return nil, err } available = preferCodexWebsocketAuths(ctx, provider, available) return available[0], nil } func isAuthBlockedForModel(auth *Auth, model string, now time.Time) (bool, blockReason, time.Time) { if auth == nil { return true, blockReasonOther, time.Time{} } if auth.Disabled || auth.Status == StatusDisabled { return true, blockReasonDisabled, time.Time{} } if auth.Quota.Exceeded && auth.Quota.Reason == "credential_quota" && auth.Quota.NextRecoverAt.After(now) { return true, blockReasonCooldown, auth.Quota.NextRecoverAt } if model != "" { if len(auth.ModelStates) > 0 { modelKey := canonicalModelKey(model) matched := false blocked := false blockedReason := blockReasonNone nextRetry := time.Time{} for stateModel, state := range auth.ModelStates { if state == nil || canonicalModelKey(stateModel) != modelKey { continue } matched = true if state.Status == StatusDisabled { return true, blockReasonDisabled, time.Time{} } stateBlocked, reason, next := availabilityBlock(state.Unavailable, state.Quota.Exceeded, state.NextRetryAfter, state.Quota.NextRecoverAt, now) if !stateBlocked { continue } if next.IsZero() { return true, reason, time.Time{} } if !blocked || next.After(nextRetry) || (next.Equal(nextRetry) && reason == blockReasonCooldown) { blocked = true blockedReason = reason nextRetry = next } } if matched { return blocked, blockedReason, nextRetry } return false, blockReasonNone, time.Time{} } return availabilityBlock(auth.Unavailable, auth.Quota.Exceeded, auth.NextRetryAfter, auth.Quota.NextRecoverAt, now) } return availabilityBlock(auth.Unavailable, auth.Quota.Exceeded, auth.NextRetryAfter, auth.Quota.NextRecoverAt, now) } func availabilityBlock(unavailable, quotaExceeded bool, nextRetryAfter, nextRecoverAt, now time.Time) (bool, blockReason, time.Time) { if !unavailable && !quotaExceeded { return false, blockReasonNone, time.Time{} } hasRecoveryTime := !nextRetryAfter.IsZero() || !nextRecoverAt.IsZero() var next time.Time for _, candidate := range []time.Time{nextRetryAfter, nextRecoverAt} { if candidate.After(now) && (next.IsZero() || candidate.After(next)) { next = candidate } } if !next.IsZero() { if quotaExceeded { return true, blockReasonCooldown, next } return true, blockReasonOther, next } if hasRecoveryTime { return false, blockReasonNone, time.Time{} } return true, blockReasonOther, time.Time{} } // SessionAffinitySelector wraps another selector with session-sticky behavior. // It extracts session ID from multiple sources and maintains session-to-auth // mappings with automatic failover when the bound auth becomes unavailable. type SessionAffinitySelector struct { fallback Selector cache *SessionCache } // SessionAffinityConfig configures the session affinity selector. type SessionAffinityConfig struct { Fallback Selector TTL time.Duration } // NewSessionAffinitySelector creates a new session-aware selector. func NewSessionAffinitySelector(fallback Selector) *SessionAffinitySelector { return NewSessionAffinitySelectorWithConfig(SessionAffinityConfig{ Fallback: fallback, TTL: time.Hour, }) } // NewSessionAffinitySelectorWithConfig creates a selector with custom configuration. func NewSessionAffinitySelectorWithConfig(cfg SessionAffinityConfig) *SessionAffinitySelector { if cfg.Fallback == nil { cfg.Fallback = &RoundRobinSelector{} } if cfg.TTL <= 0 { cfg.TTL = time.Hour } return &SessionAffinitySelector{ fallback: cfg.Fallback, cache: NewSessionCache(cfg.TTL), } } // Pick selects an auth with session affinity when possible. // Explicit Claude Code, Codex, OpenCode, pi, and request-body session signals // precede execution metadata, stable derived identity, and the legacy hash fallback. // // An established binding outranks credential priority: a bound credential that is still // available is reused even when a higher-priority credential recovers. Credential priority // applies to cold bindings, requests without a session, and genuine bound-credential // failover, so the fallback selector only ever receives the highest available priority tier. // // Note: The cache key includes provider, session ID, and model to handle cases where // a session uses multiple models (e.g., gemini-2.5-pro and gemini-3-flash-preview) // that may be supported by different auth credentials, and to avoid cross-provider conflicts. func (s *SessionAffinitySelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) { entry := selectorLogEntry(ctx) if opts.Metadata == nil { opts.Metadata = make(map[string]any) } opts.Metadata[cliproxyexecutor.SessionAffinityProviderMetadataKey] = provider opts.Metadata[cliproxyexecutor.SessionAffinityModelMetadataKey] = model primaryID, fallbackID := extractSessionIDs(opts.Headers, opts.OriginalRequest, opts.Metadata) now := time.Now() availabilityCandidates := auths if _, weighted := s.fallback.(*WeightedRoundRobinSelector); weighted { availabilityCandidates = positiveWeightAuths(auths) } if primaryID == "" { fallbackAuths, errAvailable := getAvailableAuths(availabilityCandidates, provider, model, now) if errAvailable != nil { return nil, errAvailable } entry.Debugf("session-affinity: no session ID extracted, falling back to default selector | provider=%s model=%s", provider, model) return s.fallback.Pick(ctx, provider, model, opts, fallbackAuths) } // A single availability pass serves both lookups: the bound credential is validated against // every priority tier, while the fallback selector keeps seeing only the highest tier. available, err := getAvailableAuthsAcrossPriorities(availabilityCandidates, provider, model, now) if err != nil { return nil, err } fallbackAuths := highestPriorityAuths(available) modelKey := canonicalModelKey(model) cacheKey := provider + "::" + primaryID + "::" + modelKey fallbackKey := "" if fallbackID != "" && fallbackID != primaryID { fallbackKey = provider + "::" + fallbackID + "::" + modelKey } bind := func(authID string) { if fallbackKey != "" { s.cache.SetAliases(authID, cacheKey, fallbackKey) return } s.cache.Set(cacheKey, authID) } if cachedAuthID, ok := s.cache.GetAndRefresh(cacheKey); ok { for _, auth := range available { if auth.ID == cachedAuthID { bind(auth.ID) entry.Infof("session-affinity: cache hit | session=%s auth=%s provider=%s model=%s", truncateSessionID(primaryID), auth.ID, provider, model) return auth, nil } } // Cached auth not available, reselect via fallback selector for even distribution auth, err := s.fallback.Pick(ctx, provider, model, opts, fallbackAuths) if err != nil { return nil, err } bind(auth.ID) entry.Infof("session-affinity: cache hit but auth unavailable, reselected | session=%s auth=%s provider=%s model=%s", truncateSessionID(primaryID), auth.ID, provider, model) return auth, nil } if fallbackKey != "" { if cachedAuthID, ok := s.cache.Get(fallbackKey); ok { for _, auth := range available { if auth.ID == cachedAuthID { bind(auth.ID) entry.Infof("session-affinity: fallback cache hit | session=%s fallback=%s auth=%s provider=%s model=%s", truncateSessionID(primaryID), truncateSessionID(fallbackID), auth.ID, provider, model) return auth, nil } } } } auth, err := s.fallback.Pick(ctx, provider, model, opts, fallbackAuths) if err != nil { return nil, err } bind(auth.ID) entry.Infof("session-affinity: cache miss, new binding | session=%s auth=%s provider=%s model=%s", truncateSessionID(primaryID), auth.ID, provider, model) return auth, nil } func selectorLogEntry(ctx context.Context) *log.Entry { if ctx == nil { return log.NewEntry(log.StandardLogger()) } if reqID := logging.GetRequestID(ctx); reqID != "" { return log.WithField("request_id", reqID) } return log.NewEntry(log.StandardLogger()) } // truncateSessionID shortens session ID for logging (first 8 chars + "...") func truncateSessionID(id string) string { if len(id) <= 20 { return id } return id[:8] + "..." } // Stop releases resources held by the selector. func (s *SessionAffinitySelector) Stop() { if s.cache != nil { s.cache.Stop() } } // InvalidateAuth removes all session bindings for a specific auth. // Called when an auth becomes rate-limited or unavailable. func (s *SessionAffinitySelector) InvalidateAuth(authID string) { if s.cache != nil { s.cache.InvalidateAuth(authID) } } // OnResult handles session affinity binding or release based on execution outcome. func (s *SessionAffinitySelector) OnResult(res Result) { if s == nil || s.cache == nil || res.AuthID == "" { return } primaryID, fallbackID := extractSessionIDs(res.Options.Headers, res.Options.OriginalRequest, res.Options.Metadata) if primaryID == "" && fallbackID == "" { return } ns := res.Provider if raw, ok := res.Options.Metadata[cliproxyexecutor.SessionAffinityProviderMetadataKey].(string); ok && raw != "" { ns = raw } nsModel := canonicalModelKey(res.Model) if raw, ok := res.Options.Metadata[cliproxyexecutor.SessionAffinityModelMetadataKey].(string); ok && raw != "" { nsModel = canonicalModelKey(raw) } cacheKey := ns + "::" + primaryID + "::" + nsModel var fallbackKey string if fallbackID != "" && fallbackID != primaryID { fallbackKey = ns + "::" + fallbackID + "::" + nsModel } if res.Success { s.cache.Touch(cacheKey, res.AuthID) if fallbackKey != "" { s.cache.Touch(fallbackKey, res.AuthID) } return } if res.Error != nil && shouldSkipCredentialCooldown(res.Error) { return } s.cache.CompareAndDelete(cacheKey, res.AuthID) if fallbackKey != "" { s.cache.CompareAndDelete(fallbackKey, res.AuthID) } } // normalizedSessionCandidate validates an explicit client-provided session signal. // It keeps opaque printable IDs intact while rejecting values that are unsafe or // implausibly large for routing keys and logs. func normalizedSessionCandidate(raw string) string { return cliproxysession.NormalizeExplicitID(raw) } func sessionHeaderValue(headers http.Header, name string) string { if headers == nil { return "" } if value := normalizedSessionCandidate(headers.Get(name)); value != "" { return value } for key, values := range headers { if !strings.EqualFold(key, name) { continue } for _, raw := range values { if value := normalizedSessionCandidate(raw); value != "" { return value } } } return "" } // ExtractSessionID extracts a session identifier from explicit client signals, // then falls back to execution metadata, derived identity, and message history. // Priority order: // 1. X-Claude-Code-Session-Id // 2. Claude Code metadata.user_id session // 3. Session-Id / Session_id (Codex and compatible clients) // 4. X-Session-ID // 5. X-Session-Affinity (OpenCode) // 6. X-Client-Request-Id (pi Responses) // 7. session_id / sessionId // 8. prompt_cache_key, with conversation / conversation.id as an alias // 9. metadata.user_id and conversation_id legacy body fields // 10. explicit execution session metadata // 11. stable context-derived session identity // 12. stable hash from initial message content func ExtractSessionID(headers http.Header, payload []byte, metadata map[string]any) string { primary, _ := extractSessionIDs(headers, payload, metadata) return primary } // extractSessionIDs returns (primaryID, fallbackID) for session affinity. // fallbackID preserves an earlier binding when a stronger body identifier appears // later, and lets callers bind both identifiers when both are present. func extractSessionIDs(headers http.Header, payload []byte, metadata map[string]any) (string, string) { if sid := sessionHeaderValue(headers, "X-Claude-Code-Session-Id"); sid != "" { return "claude:" + sid, "" } if sid := cliproxysession.ClaudeMetadataSessionID(payload); sid != "" { return "claude:" + sid, "" } if sid := sessionHeaderValue(headers, "Session-Id"); sid != "" { return "codex:" + sid, "" } if sid := sessionHeaderValue(headers, "Session_id"); sid != "" { return "codex:" + sid, "" } if sid := sessionHeaderValue(headers, "X-Session-ID"); sid != "" { return "header:" + sid, "" } if sid := sessionHeaderValue(headers, "X-Session-Affinity"); sid != "" { return "affinity:" + sid, "" } if sid := sessionHeaderValue(headers, "X-Client-Request-Id"); sid != "" { return "clientreq:" + sid, "" } if len(payload) > 0 { for _, path := range []string{"session_id", "sessionId"} { if sid := normalizedSessionCandidate(gjson.GetBytes(payload, path).String()); sid != "" { return "session:" + sid, "" } } conversationID := "" conversation := gjson.GetBytes(payload, "conversation") if sid := normalizedSessionCandidate(conversation.Get("id").String()); sid != "" { conversationID = "conv:" + sid } else if conversation.Type == gjson.String { if sid := normalizedSessionCandidate(conversation.String()); sid != "" { conversationID = "conv:" + sid } } if sid := normalizedSessionCandidate(gjson.GetBytes(payload, "prompt_cache_key").String()); sid != "" { return "pck:" + sid, conversationID } if conversationID != "" { return conversationID, "" } if userID := normalizedSessionCandidate(gjson.GetBytes(payload, "metadata.user_id").String()); userID != "" { return "user:" + userID, "" } if conversationID := normalizedSessionCandidate(gjson.GetBytes(payload, "conversation_id").String()); conversationID != "" { return "conv:" + conversationID, "" } } if executionID, ok := metadata[cliproxyexecutor.ExecutionSessionMetadataKey].(string); ok { if executionID = normalizedSessionCandidate(executionID); executionID != "" { return "execution:" + executionID, "" } } if derivedID := normalizedSessionCandidate(cliproxysession.DerivedID(metadata)); derivedID != "" { return "derived:" + derivedID, "" } if len(payload) == 0 { return "", "" } return extractMessageHashIDs(payload) } func extractMessageHashIDs(payload []byte) (primaryID, fallbackID string) { var systemPrompt, firstUserMsg, firstAssistantMsg string // OpenAI/Claude messages format messages := gjson.GetBytes(payload, "messages") if messages.Exists() && messages.IsArray() { messages.ForEach(func(_, msg gjson.Result) bool { role := msg.Get("role").String() content := extractMessageContent(msg.Get("content")) if content == "" { return true } switch role { case "system": if systemPrompt == "" { systemPrompt = truncateString(content, 100) } case "user": if firstUserMsg == "" { firstUserMsg = truncateString(content, 100) } case "assistant": if firstAssistantMsg == "" { firstAssistantMsg = truncateString(content, 100) } } if systemPrompt != "" && firstUserMsg != "" && firstAssistantMsg != "" { return false } return true }) } // Claude API: top-level "system" field (array or string) if systemPrompt == "" { topSystem := gjson.GetBytes(payload, "system") if topSystem.Exists() { if topSystem.IsArray() { topSystem.ForEach(func(_, part gjson.Result) bool { if text := part.Get("text").String(); text != "" && systemPrompt == "" { systemPrompt = truncateString(text, 100) return false } return true }) } else if topSystem.Type == gjson.String { systemPrompt = truncateString(topSystem.String(), 100) } } } // Gemini format if systemPrompt == "" && firstUserMsg == "" { sysInstr := gjson.GetBytes(payload, "systemInstruction.parts") if sysInstr.Exists() && sysInstr.IsArray() { sysInstr.ForEach(func(_, part gjson.Result) bool { if text := part.Get("text").String(); text != "" && systemPrompt == "" { systemPrompt = truncateString(text, 100) return false } return true }) } contents := gjson.GetBytes(payload, "contents") if contents.Exists() && contents.IsArray() { contents.ForEach(func(_, msg gjson.Result) bool { role := msg.Get("role").String() msg.Get("parts").ForEach(func(_, part gjson.Result) bool { text := part.Get("text").String() if text == "" { return true } switch role { case "user": if firstUserMsg == "" { firstUserMsg = truncateString(text, 100) } case "model": if firstAssistantMsg == "" { firstAssistantMsg = truncateString(text, 100) } } return false }) if firstUserMsg != "" && firstAssistantMsg != "" { return false } return true }) } } // OpenAI Responses API format (v1/responses) if systemPrompt == "" && firstUserMsg == "" { if instr := gjson.GetBytes(payload, "instructions").String(); instr != "" { systemPrompt = truncateString(instr, 100) } input := gjson.GetBytes(payload, "input") if input.Exists() && input.IsArray() { input.ForEach(func(_, item gjson.Result) bool { itemType := item.Get("type").String() if itemType == "reasoning" { return true } // Skip non-message typed items (function_call, function_call_output, etc.) // but allow items with no type that have a role (inline message format). if itemType != "" && itemType != "message" { return true } role := item.Get("role").String() if itemType == "" && role == "" { return true } // Handle both string content and array content (multimodal). content := item.Get("content") var text string if content.Type == gjson.String { text = content.String() } else { text = extractResponsesAPIContent(content) } if text == "" { return true } switch role { case "developer", "system": if systemPrompt == "" { systemPrompt = truncateString(text, 100) } case "user": if firstUserMsg == "" { firstUserMsg = truncateString(text, 100) } case "assistant": if firstAssistantMsg == "" { firstAssistantMsg = truncateString(text, 100) } } if firstUserMsg != "" && firstAssistantMsg != "" { return false } return true }) } } if systemPrompt == "" && firstUserMsg == "" { return "", "" } shortHash := computeSessionHash(systemPrompt, firstUserMsg, "") if firstAssistantMsg == "" { return shortHash, "" } fullHash := computeSessionHash(systemPrompt, firstUserMsg, firstAssistantMsg) return fullHash, shortHash } func computeSessionHash(systemPrompt, userMsg, assistantMsg string) string { h := fnv.New64a() if systemPrompt != "" { h.Write([]byte("sys:" + systemPrompt + "\n")) } if userMsg != "" { h.Write([]byte("usr:" + userMsg + "\n")) } if assistantMsg != "" { h.Write([]byte("ast:" + assistantMsg + "\n")) } return fmt.Sprintf("msg:%016x", h.Sum64()) } func truncateString(s string, maxLen int) string { if len(s) > maxLen { return s[:maxLen] } return s } // extractMessageContent extracts text content from a message content field. // Handles both string content and array content (multimodal messages). // For array content, extracts text from all text-type elements. func extractMessageContent(content gjson.Result) string { // String content: "Hello world" if content.Type == gjson.String { return content.String() } // Array content: [{"type":"text","text":"Hello"},{"type":"image",...}] if content.IsArray() { var texts []string content.ForEach(func(_, part gjson.Result) bool { // Handle Claude format: {"type":"text","text":"content"} if part.Get("type").String() == "text" { if text := part.Get("text").String(); text != "" { texts = append(texts, text) } } // Handle OpenAI format: {"type":"text","text":"content"} // Same structure as Claude, already handled above return true }) if len(texts) > 0 { return strings.Join(texts, " ") } } return "" } func extractResponsesAPIContent(content gjson.Result) string { if !content.IsArray() { return "" } var texts []string content.ForEach(func(_, part gjson.Result) bool { partType := part.Get("type").String() if partType == "input_text" || partType == "output_text" || partType == "text" { if text := part.Get("text").String(); text != "" { texts = append(texts, text) } } return true }) if len(texts) > 0 { return strings.Join(texts, " ") } return "" } // extractSessionID is kept for backward compatibility. // Deprecated: Use ExtractSessionID instead. func extractSessionID(payload []byte) string { return ExtractSessionID(nil, payload, nil) }