| package proxy |
|
|
| import ( |
| "crypto/sha256" |
| "encoding/hex" |
| "fmt" |
| "log" |
| "strings" |
| "sync" |
| "time" |
| ) |
|
|
| |
| |
| type Session struct { |
| ThreadID string |
| TurnCount int |
| AccountEmail string |
| CreatedAt time.Time |
| LastUsedAt time.Time |
|
|
| |
| ConfigID string |
| ContextID string |
|
|
| |
| ContextPageID string |
|
|
| |
| UpdatedConfigIDs []string |
|
|
| |
| OriginalDatetime string |
|
|
| |
| ModelUsed string |
|
|
| |
| |
| RawMessageCount int |
| } |
|
|
| |
| type SessionManager struct { |
| mu sync.RWMutex |
| sessions map[string]*Session |
| ttl time.Duration |
| } |
|
|
| |
| var globalSessionManager *SessionManager |
|
|
| func init() { |
| globalSessionManager = NewSessionManager(30 * time.Minute) |
| } |
|
|
| |
| func NewSessionManager(ttl time.Duration) *SessionManager { |
| sm := &SessionManager{ |
| sessions: make(map[string]*Session), |
| ttl: ttl, |
| } |
| go sm.cleanupLoop() |
| return sm |
| } |
|
|
| |
| |
| func (sm *SessionManager) Get(fingerprint string) *Session { |
| sm.mu.RLock() |
| defer sm.mu.RUnlock() |
|
|
| s, ok := sm.sessions[fingerprint] |
| if !ok { |
| return nil |
| } |
| if time.Since(s.LastUsedAt) > sm.ttl { |
| return nil |
| } |
| return s |
| } |
|
|
| |
| func (sm *SessionManager) Set(fingerprint string, session *Session) { |
| sm.mu.Lock() |
| defer sm.mu.Unlock() |
| sm.sessions[fingerprint] = session |
| } |
|
|
| |
| func (sm *SessionManager) Delete(fingerprint string) { |
| sm.mu.Lock() |
| defer sm.mu.Unlock() |
| delete(sm.sessions, fingerprint) |
| } |
|
|
| |
| func (sm *SessionManager) DeleteByAccount(email string) { |
| sm.mu.Lock() |
| defer sm.mu.Unlock() |
| for fp, s := range sm.sessions { |
| if s.AccountEmail == email { |
| delete(sm.sessions, fp) |
| } |
| } |
| } |
|
|
| |
| func (sm *SessionManager) Count() int { |
| sm.mu.RLock() |
| defer sm.mu.RUnlock() |
| return len(sm.sessions) |
| } |
|
|
| |
| func (sm *SessionManager) cleanupLoop() { |
| ticker := time.NewTicker(5 * time.Minute) |
| defer ticker.Stop() |
| for range ticker.C { |
| sm.mu.Lock() |
| now := time.Now() |
| removed := 0 |
| for fp, s := range sm.sessions { |
| if now.Sub(s.LastUsedAt) > sm.ttl { |
| delete(sm.sessions, fp) |
| removed++ |
| } |
| } |
| sm.mu.Unlock() |
| if removed > 0 { |
| log.Printf("[session] cleaned up %d expired sessions, %d remaining", removed, sm.Count()) |
| } |
| } |
| } |
|
|
| func normalizeSessionSystemContent(content string) string { |
| if content == "" { |
| return "" |
| } |
| lines := strings.Split(content, "\n") |
| filtered := make([]string, 0, len(lines)) |
| for _, line := range lines { |
| trimmed := strings.TrimSpace(line) |
| if strings.HasPrefix(trimmed, "x-anthropic-billing-header:") { |
| continue |
| } |
| filtered = append(filtered, line) |
| } |
| return strings.TrimSpace(strings.Join(filtered, "\n")) |
| } |
|
|
| func normalizeSessionUserContent(content string) string { |
| if content == "" { |
| return "" |
| } |
| return strings.TrimSpace(stripSystemReminders(content)) |
| } |
|
|
| func isMeaningfulUserMessage(msg ChatMessage) bool { |
| return msg.Role == "user" && msg.ToolCallID == "" && normalizeSessionUserContent(msg.Content) != "" |
| } |
|
|
| func shouldCountNonSystemMessage(msg ChatMessage) bool { |
| switch msg.Role { |
| case "system": |
| return false |
| case "user": |
| return isMeaningfulUserMessage(msg) |
| case "assistant": |
| return strings.TrimSpace(msg.Content) != "" || len(msg.ToolCalls) > 0 |
| case "tool": |
| return strings.TrimSpace(msg.Content) != "" || msg.ToolCallID != "" || msg.Name != "" |
| default: |
| return strings.TrimSpace(msg.Content) != "" |
| } |
| } |
|
|
| |
| |
| |
| |
| func cloneChatMessages(src []ChatMessage) []ChatMessage { |
| if src == nil { |
| return nil |
| } |
| out := make([]ChatMessage, len(src)) |
| for i, m := range src { |
| out[i] = m |
| if len(m.ToolCalls) > 0 { |
| out[i].ToolCalls = append([]ToolCall(nil), m.ToolCalls...) |
| } |
| } |
| return out |
| } |
|
|
| |
| |
| |
| func computeSessionFingerprintWithSalt(messages []ChatMessage, stableSalt string) string { |
| h := sha256.New() |
| if stableSalt != "" { |
| h.Write([]byte("salt:")) |
| h.Write([]byte(stableSalt)) |
| h.Write([]byte{'\n'}) |
| } |
| |
| for _, m := range messages { |
| if m.Role == "system" { |
| content := normalizeSessionSystemContent(m.Content) |
| if len(content) > 200 { |
| content = content[:200] |
| } |
| h.Write([]byte(content)) |
| break |
| } |
| } |
| |
| for _, m := range messages { |
| if isMeaningfulUserMessage(m) { |
| content := normalizeSessionUserContent(m.Content) |
| if len(content) > 200 { |
| content = content[:200] |
| } |
| h.Write([]byte(content)) |
| break |
| } |
| } |
| return hex.EncodeToString(h.Sum(nil))[:32] |
| } |
|
|
| func computeSessionFingerprintForRequest(messages []ChatMessage, sessionSalt string, resolvedModel string) string { |
| sessionSalt = strings.TrimSpace(sessionSalt) |
| resolvedModel = strings.TrimSpace(resolvedModel) |
| modelSalt := fmt.Sprintf("model:%d:%s", len(resolvedModel), resolvedModel) |
| if sessionSalt == "" { |
| return computeSessionFingerprintWithSalt(messages, modelSalt) |
| } |
| stableSalt := fmt.Sprintf("session:%d:%s\n%s", len(sessionSalt), sessionSalt, modelSalt) |
| return computeSessionFingerprintWithSalt(nil, stableSalt) |
| } |
|
|
| |
| |
| func computeSessionFingerprint(messages []ChatMessage) string { |
| return computeSessionFingerprintWithSalt(messages, "") |
| } |
|
|
| |
| func countUserMessages(messages []ChatMessage) int { |
| count := 0 |
| for _, m := range messages { |
| if isMeaningfulUserMessage(m) { |
| count++ |
| } |
| } |
| return count |
| } |
|
|
| |
| |
| |
| func countNonSystemMessages(messages []ChatMessage) int { |
| count := 0 |
| for _, m := range messages { |
| if shouldCountNonSystemMessage(m) { |
| count++ |
| } |
| } |
| return count |
| } |
|
|
| |
| func extractLastUserMessage(messages []ChatMessage) string { |
| for i := len(messages) - 1; i >= 0; i-- { |
| if isMeaningfulUserMessage(messages[i]) { |
| return normalizeSessionUserContent(messages[i].Content) |
| } |
| } |
| return "" |
| } |
|
|
| |
| |
| |
| |
| func needsFreshThreadRecovery(messages []ChatMessage) bool { |
| hasMeaningfulUser := false |
| hasAssistantOrToolHistory := false |
| for _, message := range messages { |
| if isMeaningfulUserMessage(message) { |
| hasMeaningfulUser = true |
| } |
| if (message.Role == "assistant" || message.Role == "tool") && shouldCountNonSystemMessage(message) { |
| hasAssistantOrToolHistory = true |
| } |
| } |
| return hasMeaningfulUser && hasAssistantOrToolHistory |
| } |
|
|
| |
| |
| |
| func buildRecoveryMessages(messages []ChatMessage, skipEntry func(ChatMessage, string) bool) []ChatMessage { |
| if !needsFreshThreadRecovery(messages) { |
| return messages |
| } |
|
|
| const ( |
| maxHistoryChars = 4000 |
| maxEntryChars = 900 |
| ) |
|
|
| lastUserIdx := -1 |
| for i := len(messages) - 1; i >= 0; i-- { |
| if isMeaningfulUserMessage(messages[i]) { |
| lastUserIdx = i |
| break |
| } |
| } |
| if lastUserIdx < 0 { |
| return messages |
| } |
|
|
| clip := func(s string, limit int) string { |
| if limit <= 0 || len(s) <= limit { |
| return s |
| } |
| return s[:limit] + "..." |
| } |
|
|
| var systemParts []string |
| for _, m := range messages { |
| if m.Role == "system" && strings.TrimSpace(m.Content) != "" { |
| systemParts = append(systemParts, strings.TrimSpace(m.Content)) |
| } |
| } |
|
|
| type historyEntry struct { |
| label string |
| content string |
| } |
|
|
| var reversed []historyEntry |
| usedChars := 0 |
| hasPostUserHistory := false |
| for i := lastUserIdx + 1; i < len(messages); i++ { |
| if (messages[i].Role == "assistant" || messages[i].Role == "tool") && shouldCountNonSystemMessage(messages[i]) { |
| hasPostUserHistory = true |
| break |
| } |
| } |
| for i := len(messages) - 1; i >= 0; i-- { |
| if i == lastUserIdx && !hasPostUserHistory { |
| continue |
| } |
| m := messages[i] |
| if m.Role == "system" { |
| continue |
| } |
|
|
| content := strings.TrimSpace(m.Content) |
| if m.Role == "user" { |
| content = normalizeSessionUserContent(m.Content) |
| } |
| if skipEntry != nil && skipEntry(m, content) { |
| continue |
| } |
|
|
| label := "" |
| switch m.Role { |
| case "user": |
| label = "User" |
| case "assistant": |
| label = "Assistant" |
| for _, toolCall := range m.ToolCalls { |
| name := strings.TrimSpace(toolCall.Function.Name) |
| if name == "" { |
| name = "tool" |
| } |
| toolCallText := "Tool call " + name |
| if args := strings.TrimSpace(toolCall.Function.Arguments); args != "" { |
| toolCallText += ": " + args |
| } |
| if content != "" { |
| content += "\n" |
| } |
| content += toolCallText |
| } |
| case "tool": |
| name := m.Name |
| if name == "" { |
| name = "tool" |
| } |
| label = fmt.Sprintf("Tool (%s)", name) |
| if content == "" && m.ToolCallID != "" { |
| content = "Tool result for " + m.ToolCallID |
| } |
| default: |
| continue |
| } |
| if content == "" { |
| continue |
| } |
|
|
| content = clip(content, maxEntryChars) |
| entryCost := len(label) + len(content) + 4 |
| if usedChars > 0 && usedChars+entryCost > maxHistoryChars { |
| break |
| } |
| usedChars += entryCost |
| reversed = append(reversed, historyEntry{label: label, content: content}) |
| } |
|
|
| var history strings.Builder |
| for i := len(reversed) - 1; i >= 0; i-- { |
| if history.Len() > 0 { |
| history.WriteString("\n\n") |
| } |
| history.WriteString(reversed[i].label) |
| history.WriteString(": ") |
| history.WriteString(reversed[i].content) |
| } |
|
|
| latest := normalizeSessionUserContent(messages[lastUserIdx].Content) |
|
|
| var prompt strings.Builder |
| prompt.WriteString("Continue this conversation on a fresh thread.\n") |
| prompt.WriteString("Use the context below and answer the latest user message directly.\n") |
| prompt.WriteString("Do not mention missing context, prior thread state, or recovery.\n") |
|
|
| if len(systemParts) > 0 { |
| prompt.WriteString("\n\nSystem instructions:\n") |
| prompt.WriteString(strings.Join(systemParts, "\n\n")) |
| } |
|
|
| if history.Len() > 0 { |
| prompt.WriteString("\n\nConversation context:\n") |
| prompt.WriteString(history.String()) |
| } |
|
|
| prompt.WriteString("\n\nLatest user message:\n") |
| prompt.WriteString(latest) |
|
|
| return []ChatMessage{{ |
| Role: "user", |
| Content: prompt.String(), |
| }} |
| } |
|
|
| func buildFreshThreadRecoveryMessages(messages []ChatMessage) []ChatMessage { |
| return buildRecoveryMessages(messages, nil) |
| } |
|
|
| func buildToolBridgeRecoveryMessages(messages []ChatMessage) []ChatMessage { |
| return buildRecoveryMessages(messages, func(msg ChatMessage, content string) bool { |
| return msg.Role == "assistant" && detectToolBridgeNoToolResponse(content) |
| }) |
| } |
|
|