Files
CLIProxyAPI/internal/runtime/executor/claude_executor_tool_state.go
Luis Pater a2976eb8a3 fix(claude): share OAuth tool alias store across scoped executor copies
- Pre-allocate `oauthToolAliases` in Claude and Kimi executor constructors so request copies created via `ForAPIKey` reuse the same store
- Replace struct-level mutex with package-level mutex for lazy store allocation to prevent copying mutexes by value

Closes: #6401
2026-10-06 05:45:17 +08:00

145 lines
4.1 KiB
Go

package executor
import (
"net/http"
"sync"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/tidwall/gjson"
)
const claudeOAuthToolAliasStateLimit = 1024
// claudeOAuthToolAliasStoreInitMu guards lazy allocation for executors built
// without a constructor. It lives outside ClaudeExecutor because ForAPIKey
// copies the executor by value, and a mutex field would be copied with it.
var claudeOAuthToolAliasStoreInitMu sync.Mutex
type claudeOAuthToolAliasStore struct {
mu sync.Mutex
entries map[string]map[string]string
order []string
}
func (s *claudeOAuthToolAliasStore) load(keys []string) (map[string]string, bool) {
if s == nil {
return nil, false
}
s.mu.Lock()
defer s.mu.Unlock()
for _, key := range keys {
aliases, ok := s.entries[key]
if !ok {
continue
}
return cloneClaudeOAuthToolAliasMap(aliases), true
}
return nil, false
}
func (s *claudeOAuthToolAliasStore) save(keys []string, aliases map[string]string) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.entries == nil {
s.entries = make(map[string]map[string]string)
}
for _, key := range keys {
if key == "" {
continue
}
if _, exists := s.entries[key]; !exists {
s.order = append(s.order, key)
}
s.entries[key] = cloneClaudeOAuthToolAliasMap(aliases)
}
for len(s.order) > claudeOAuthToolAliasStateLimit {
oldest := s.order[0]
s.order = s.order[1:]
delete(s.entries, oldest)
}
}
func cloneClaudeOAuthToolAliasMap(source map[string]string) map[string]string {
if len(source) == 0 {
return map[string]string{}
}
clone := make(map[string]string, len(source))
for key, value := range source {
clone[key] = value
}
return clone
}
func (e *ClaudeExecutor) claudeOAuthToolAliasStore() *claudeOAuthToolAliasStore {
claudeOAuthToolAliasStoreInitMu.Lock()
defer claudeOAuthToolAliasStoreInitMu.Unlock()
if e.oauthToolAliases == nil {
e.oauthToolAliases = &claudeOAuthToolAliasStore{}
}
return e.oauthToolAliases
}
func claudeOAuthToolAliasKeys(payload []byte, messageID string) []string {
keys := make([]string, 0, 2)
if previousMessageID := gjson.GetBytes(payload, "thread.previous_message_id").String(); previousMessageID != "" {
keys = append(keys, "message:"+previousMessageID)
}
if messageID != "" {
keys = append(keys, "message:"+messageID)
}
return keys
}
func claudeThreadContinuationNeedsAliasState(payload []byte) bool {
if gjson.GetBytes(payload, "thread.type").String() != "continue" {
return false
}
previousMessageID := gjson.GetBytes(payload, "thread.previous_message_id")
if !previousMessageID.Exists() || previousMessageID.String() == "" {
return false
}
tools := gjson.GetBytes(payload, "tools")
return !tools.Exists() || !tools.IsArray() || len(tools.Array()) == 0
}
func (e *ClaudeExecutor) prepareClaudeOAuthToolNamesForRequest(payload []byte, options claudeMCPAliasOptions) ([]byte, map[string]string, error) {
if claudeThreadContinuationNeedsAliasState(payload) {
aliases, ok := e.claudeOAuthToolAliasStore().load(claudeOAuthToolAliasKeys(payload, ""))
if !ok {
return nil, nil, newClaudeThreadNotFoundError()
}
return payload, aliases, nil
}
remapped, aliases := prepareClaudeOAuthToolNamesForUpstream(payload, options)
return remapped, aliases, nil
}
func (e *ClaudeExecutor) rememberClaudeOAuthToolAliases(payload []byte, aliases map[string]string, messageID string) {
if gjson.GetBytes(payload, "thread.type").String() == "" {
return
}
keys := claudeOAuthToolAliasKeys(payload, messageID)
if len(keys) == 0 {
return
}
e.claudeOAuthToolAliasStore().save(keys, aliases)
}
type claudeThreadNotFoundError struct {
statusErr
}
func newClaudeThreadNotFoundError() claudeThreadNotFoundError {
return claudeThreadNotFoundError{statusErr: statusErr{
code: http.StatusNotFound,
msg: "No thread state was found for the requested previous_message_id. Replay the full conversation with thread create to start a new Thread.",
}}
}
func (claudeThreadNotFoundError) IsRequestScoped() bool { return true }
var _ cliproxyexecutor.RequestScopedError = claudeThreadNotFoundError{}