mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-04-20 22:51:45 +00:00
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.
1362 lines
42 KiB
Go
1362 lines
42 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
|
)
|
|
|
|
func TestFillFirstSelectorPick_Deterministic(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
selector := &FillFirstSelector{}
|
|
auths := []*Auth{
|
|
{ID: "b"},
|
|
{ID: "a"},
|
|
{ID: "c"},
|
|
}
|
|
|
|
got, err := selector.Pick(context.Background(), "gemini", "", cliproxyexecutor.Options{}, auths)
|
|
if err != nil {
|
|
t.Fatalf("Pick() error = %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("Pick() auth = nil")
|
|
}
|
|
if got.ID != "a" {
|
|
t.Fatalf("Pick() auth.ID = %q, want %q", got.ID, "a")
|
|
}
|
|
}
|
|
|
|
func TestRoundRobinSelectorPick_CyclesDeterministic(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
selector := &RoundRobinSelector{}
|
|
auths := []*Auth{
|
|
{ID: "b"},
|
|
{ID: "a"},
|
|
{ID: "c"},
|
|
}
|
|
|
|
want := []string{"a", "b", "c", "a", "b"}
|
|
for i, id := range want {
|
|
got, err := selector.Pick(context.Background(), "gemini", "", cliproxyexecutor.Options{}, auths)
|
|
if err != nil {
|
|
t.Fatalf("Pick() #%d error = %v", i, err)
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("Pick() #%d auth = nil", i)
|
|
}
|
|
if got.ID != id {
|
|
t.Fatalf("Pick() #%d auth.ID = %q, want %q", i, got.ID, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRoundRobinSelectorPick_PriorityBuckets(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
selector := &RoundRobinSelector{}
|
|
auths := []*Auth{
|
|
{ID: "c", Attributes: map[string]string{"priority": "0"}},
|
|
{ID: "a", Attributes: map[string]string{"priority": "10"}},
|
|
{ID: "b", Attributes: map[string]string{"priority": "10"}},
|
|
}
|
|
|
|
want := []string{"a", "b", "a", "b"}
|
|
for i, id := range want {
|
|
got, err := selector.Pick(context.Background(), "mixed", "", cliproxyexecutor.Options{}, auths)
|
|
if err != nil {
|
|
t.Fatalf("Pick() #%d error = %v", i, err)
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("Pick() #%d auth = nil", i)
|
|
}
|
|
if got.ID != id {
|
|
t.Fatalf("Pick() #%d auth.ID = %q, want %q", i, got.ID, id)
|
|
}
|
|
if got.ID == "c" {
|
|
t.Fatalf("Pick() #%d unexpectedly selected lower priority auth", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFillFirstSelectorPick_PriorityFallbackCooldown(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
selector := &FillFirstSelector{}
|
|
now := time.Now()
|
|
model := "test-model"
|
|
|
|
high := &Auth{
|
|
ID: "high",
|
|
Attributes: map[string]string{"priority": "10"},
|
|
ModelStates: map[string]*ModelState{
|
|
model: {
|
|
Status: StatusActive,
|
|
Unavailable: true,
|
|
NextRetryAfter: now.Add(30 * time.Minute),
|
|
Quota: QuotaState{
|
|
Exceeded: true,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
low := &Auth{ID: "low", Attributes: map[string]string{"priority": "0"}}
|
|
|
|
got, err := selector.Pick(context.Background(), "mixed", model, cliproxyexecutor.Options{}, []*Auth{high, low})
|
|
if err != nil {
|
|
t.Fatalf("Pick() error = %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("Pick() auth = nil")
|
|
}
|
|
if got.ID != "low" {
|
|
t.Fatalf("Pick() auth.ID = %q, want %q", got.ID, "low")
|
|
}
|
|
}
|
|
|
|
func TestRoundRobinSelectorPick_Concurrent(t *testing.T) {
|
|
selector := &RoundRobinSelector{}
|
|
auths := []*Auth{
|
|
{ID: "b"},
|
|
{ID: "a"},
|
|
{ID: "c"},
|
|
}
|
|
|
|
start := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
errCh := make(chan error, 1)
|
|
|
|
goroutines := 32
|
|
iterations := 100
|
|
for i := 0; i < goroutines; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
for j := 0; j < iterations; j++ {
|
|
got, err := selector.Pick(context.Background(), "gemini", "", cliproxyexecutor.Options{}, auths)
|
|
if err != nil {
|
|
select {
|
|
case errCh <- err:
|
|
default:
|
|
}
|
|
return
|
|
}
|
|
if got == nil {
|
|
select {
|
|
case errCh <- errors.New("Pick() returned nil auth"):
|
|
default:
|
|
}
|
|
return
|
|
}
|
|
if got.ID == "" {
|
|
select {
|
|
case errCh <- errors.New("Pick() returned auth with empty ID"):
|
|
default:
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
close(start)
|
|
wg.Wait()
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
t.Fatalf("concurrent Pick() error = %v", err)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestSelectorPick_AllCooldownReturnsModelCooldownError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
model := "test-model"
|
|
now := time.Now()
|
|
next := now.Add(60 * time.Second)
|
|
auths := []*Auth{
|
|
{
|
|
ID: "a",
|
|
ModelStates: map[string]*ModelState{
|
|
model: {
|
|
Status: StatusActive,
|
|
Unavailable: true,
|
|
NextRetryAfter: next,
|
|
Quota: QuotaState{
|
|
Exceeded: true,
|
|
NextRecoverAt: next,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
ID: "b",
|
|
ModelStates: map[string]*ModelState{
|
|
model: {
|
|
Status: StatusActive,
|
|
Unavailable: true,
|
|
NextRetryAfter: next,
|
|
Quota: QuotaState{
|
|
Exceeded: true,
|
|
NextRecoverAt: next,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
t.Run("mixed provider redacts provider field", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
selector := &FillFirstSelector{}
|
|
_, err := selector.Pick(context.Background(), "mixed", model, cliproxyexecutor.Options{}, auths)
|
|
if err == nil {
|
|
t.Fatalf("Pick() error = nil")
|
|
}
|
|
|
|
var mce *modelCooldownError
|
|
if !errors.As(err, &mce) {
|
|
t.Fatalf("Pick() error = %T, want *modelCooldownError", err)
|
|
}
|
|
if mce.StatusCode() != http.StatusTooManyRequests {
|
|
t.Fatalf("StatusCode() = %d, want %d", mce.StatusCode(), http.StatusTooManyRequests)
|
|
}
|
|
|
|
headers := mce.Headers()
|
|
if got := headers.Get("Retry-After"); got == "" {
|
|
t.Fatalf("Headers().Get(Retry-After) = empty")
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal([]byte(mce.Error()), &payload); err != nil {
|
|
t.Fatalf("json.Unmarshal(Error()) error = %v", err)
|
|
}
|
|
rawErr, ok := payload["error"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("Error() payload missing error object: %v", payload)
|
|
}
|
|
if got, _ := rawErr["code"].(string); got != "model_cooldown" {
|
|
t.Fatalf("Error().error.code = %q, want %q", got, "model_cooldown")
|
|
}
|
|
if _, ok := rawErr["provider"]; ok {
|
|
t.Fatalf("Error().error.provider exists for mixed provider: %v", rawErr["provider"])
|
|
}
|
|
})
|
|
|
|
t.Run("non-mixed provider includes provider field", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
selector := &FillFirstSelector{}
|
|
_, err := selector.Pick(context.Background(), "gemini", model, cliproxyexecutor.Options{}, auths)
|
|
if err == nil {
|
|
t.Fatalf("Pick() error = nil")
|
|
}
|
|
|
|
var mce *modelCooldownError
|
|
if !errors.As(err, &mce) {
|
|
t.Fatalf("Pick() error = %T, want *modelCooldownError", err)
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal([]byte(mce.Error()), &payload); err != nil {
|
|
t.Fatalf("json.Unmarshal(Error()) error = %v", err)
|
|
}
|
|
rawErr, ok := payload["error"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("Error() payload missing error object: %v", payload)
|
|
}
|
|
if got, _ := rawErr["provider"].(string); got != "gemini" {
|
|
t.Fatalf("Error().error.provider = %q, want %q", got, "gemini")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestIsAuthBlockedForModel_UnavailableWithoutNextRetryIsNotBlocked(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Now()
|
|
model := "test-model"
|
|
auth := &Auth{
|
|
ID: "a",
|
|
ModelStates: map[string]*ModelState{
|
|
model: {
|
|
Status: StatusActive,
|
|
Unavailable: true,
|
|
Quota: QuotaState{
|
|
Exceeded: true,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
blocked, reason, next := isAuthBlockedForModel(auth, model, now)
|
|
if blocked {
|
|
t.Fatalf("blocked = true, want false")
|
|
}
|
|
if reason != blockReasonNone {
|
|
t.Fatalf("reason = %v, want %v", reason, blockReasonNone)
|
|
}
|
|
if !next.IsZero() {
|
|
t.Fatalf("next = %v, want zero", next)
|
|
}
|
|
}
|
|
|
|
func TestFillFirstSelectorPick_ThinkingSuffixFallsBackToBaseModelState(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
selector := &FillFirstSelector{}
|
|
now := time.Now()
|
|
|
|
baseModel := "test-model"
|
|
requestedModel := "test-model(high)"
|
|
|
|
high := &Auth{
|
|
ID: "high",
|
|
Attributes: map[string]string{"priority": "10"},
|
|
ModelStates: map[string]*ModelState{
|
|
baseModel: {
|
|
Status: StatusActive,
|
|
Unavailable: true,
|
|
NextRetryAfter: now.Add(30 * time.Minute),
|
|
Quota: QuotaState{
|
|
Exceeded: true,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
low := &Auth{
|
|
ID: "low",
|
|
Attributes: map[string]string{"priority": "0"},
|
|
}
|
|
|
|
got, err := selector.Pick(context.Background(), "mixed", requestedModel, cliproxyexecutor.Options{}, []*Auth{high, low})
|
|
if err != nil {
|
|
t.Fatalf("Pick() error = %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("Pick() auth = nil")
|
|
}
|
|
if got.ID != "low" {
|
|
t.Fatalf("Pick() auth.ID = %q, want %q", got.ID, "low")
|
|
}
|
|
}
|
|
|
|
func TestRoundRobinSelectorPick_ThinkingSuffixSharesCursor(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
selector := &RoundRobinSelector{}
|
|
auths := []*Auth{
|
|
{ID: "b"},
|
|
{ID: "a"},
|
|
}
|
|
|
|
first, err := selector.Pick(context.Background(), "gemini", "test-model(high)", cliproxyexecutor.Options{}, auths)
|
|
if err != nil {
|
|
t.Fatalf("Pick() first error = %v", err)
|
|
}
|
|
second, err := selector.Pick(context.Background(), "gemini", "test-model(low)", cliproxyexecutor.Options{}, auths)
|
|
if err != nil {
|
|
t.Fatalf("Pick() second error = %v", err)
|
|
}
|
|
if first == nil || second == nil {
|
|
t.Fatalf("Pick() returned nil auth")
|
|
}
|
|
if first.ID != "a" {
|
|
t.Fatalf("Pick() first auth.ID = %q, want %q", first.ID, "a")
|
|
}
|
|
if second.ID != "b" {
|
|
t.Fatalf("Pick() second auth.ID = %q, want %q", second.ID, "b")
|
|
}
|
|
}
|
|
|
|
func TestRoundRobinSelectorPick_CursorKeyCap(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
selector := &RoundRobinSelector{maxKeys: 2}
|
|
auths := []*Auth{{ID: "a"}}
|
|
|
|
_, _ = selector.Pick(context.Background(), "gemini", "m1", cliproxyexecutor.Options{}, auths)
|
|
_, _ = selector.Pick(context.Background(), "gemini", "m2", cliproxyexecutor.Options{}, auths)
|
|
_, _ = selector.Pick(context.Background(), "gemini", "m3", cliproxyexecutor.Options{}, auths)
|
|
|
|
selector.mu.Lock()
|
|
defer selector.mu.Unlock()
|
|
|
|
if selector.cursors == nil {
|
|
t.Fatalf("selector.cursors = nil")
|
|
}
|
|
if len(selector.cursors) != 1 {
|
|
t.Fatalf("len(selector.cursors) = %d, want %d", len(selector.cursors), 1)
|
|
}
|
|
if _, ok := selector.cursors["gemini:m3"]; !ok {
|
|
t.Fatalf("selector.cursors missing key %q", "gemini:m3")
|
|
}
|
|
}
|
|
|
|
func TestRoundRobinSelectorPick_GeminiCLICredentialGrouping(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
selector := &RoundRobinSelector{}
|
|
|
|
// Simulate two gemini-cli credentials, each with multiple projects:
|
|
// Credential A (parent = "cred-a.json") has 3 projects
|
|
// Credential B (parent = "cred-b.json") has 2 projects
|
|
auths := []*Auth{
|
|
{ID: "cred-a.json::proj-a1", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
|
|
{ID: "cred-a.json::proj-a2", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
|
|
{ID: "cred-a.json::proj-a3", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
|
|
{ID: "cred-b.json::proj-b1", Attributes: map[string]string{"gemini_virtual_parent": "cred-b.json"}},
|
|
{ID: "cred-b.json::proj-b2", Attributes: map[string]string{"gemini_virtual_parent": "cred-b.json"}},
|
|
}
|
|
|
|
// Two-level round-robin: consecutive picks must alternate between credentials.
|
|
// Credential group order is randomized, but within each call the group cursor
|
|
// advances by 1, so consecutive picks should cycle through different parents.
|
|
picks := make([]string, 6)
|
|
parents := make([]string, 6)
|
|
for i := 0; i < 6; i++ {
|
|
got, err := selector.Pick(context.Background(), "gemini-cli", "gemini-2.5-pro", cliproxyexecutor.Options{}, auths)
|
|
if err != nil {
|
|
t.Fatalf("Pick() #%d error = %v", i, err)
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("Pick() #%d auth = nil", i)
|
|
}
|
|
picks[i] = got.ID
|
|
parents[i] = got.Attributes["gemini_virtual_parent"]
|
|
}
|
|
|
|
// Verify property: consecutive picks must alternate between credential groups.
|
|
for i := 1; i < len(parents); i++ {
|
|
if parents[i] == parents[i-1] {
|
|
t.Fatalf("Pick() #%d and #%d both from same parent %q (IDs: %q, %q); expected alternating credentials",
|
|
i-1, i, parents[i], picks[i-1], picks[i])
|
|
}
|
|
}
|
|
|
|
// Verify property: each credential's projects are picked in sequence (round-robin within group).
|
|
credPicks := map[string][]string{}
|
|
for i, id := range picks {
|
|
credPicks[parents[i]] = append(credPicks[parents[i]], id)
|
|
}
|
|
for parent, ids := range credPicks {
|
|
for i := 1; i < len(ids); i++ {
|
|
if ids[i] == ids[i-1] {
|
|
t.Fatalf("Credential %q picked same project %q twice in a row", parent, ids[i])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtractSessionID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
payload string
|
|
want string
|
|
}{
|
|
{
|
|
name: "valid_claude_code_format",
|
|
payload: `{"metadata":{"user_id":"user_3f221fe75652cf9a89a31647f16274bb8036a9b85ac4dc226a4df0efec8dc04d_account__session_ac980658-63bd-4fb3-97ba-8da64cb1e344"}}`,
|
|
want: "claude:ac980658-63bd-4fb3-97ba-8da64cb1e344",
|
|
},
|
|
{
|
|
name: "json_user_id_with_session_id",
|
|
payload: `{"metadata":{"user_id":"{\"device_id\":\"be82c3aee1e0c2d74535bacc85f9f559228f02dd8a17298cf522b71e6c375714\",\"account_uuid\":\"\",\"session_id\":\"e26d4046-0f88-4b09-bb5b-f863ab5fb24e\"}"}}`,
|
|
want: "claude:e26d4046-0f88-4b09-bb5b-f863ab5fb24e",
|
|
},
|
|
{
|
|
name: "json_user_id_without_session_id",
|
|
payload: `{"metadata":{"user_id":"{\"device_id\":\"abc123\"}"}}`,
|
|
want: `user:{"device_id":"abc123"}`,
|
|
},
|
|
{
|
|
name: "no_session_but_user_id",
|
|
payload: `{"metadata":{"user_id":"user_abc123"}}`,
|
|
want: "user:user_abc123",
|
|
},
|
|
{
|
|
name: "conversation_id",
|
|
payload: `{"conversation_id":"conv-12345"}`,
|
|
want: "conv:conv-12345",
|
|
},
|
|
{
|
|
name: "no_metadata",
|
|
payload: `{"model":"claude-3"}`,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "empty_payload",
|
|
payload: ``,
|
|
want: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := extractSessionID([]byte(tt.payload))
|
|
if got != tt.want {
|
|
t.Errorf("extractSessionID() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSessionAffinitySelector_SameSessionSameAuth(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fallback := &RoundRobinSelector{}
|
|
selector := NewSessionAffinitySelector(fallback)
|
|
|
|
auths := []*Auth{
|
|
{ID: "auth-a"},
|
|
{ID: "auth-b"},
|
|
{ID: "auth-c"},
|
|
}
|
|
|
|
// Use valid UUID format for session ID
|
|
payload := []byte(`{"metadata":{"user_id":"user_xxx_account__session_ac980658-63bd-4fb3-97ba-8da64cb1e344"}}`)
|
|
opts := cliproxyexecutor.Options{OriginalRequest: payload}
|
|
|
|
// Same session should always pick the same auth
|
|
first, err := selector.Pick(context.Background(), "claude", "claude-3", opts, auths)
|
|
if err != nil {
|
|
t.Fatalf("Pick() error = %v", err)
|
|
}
|
|
if first == nil {
|
|
t.Fatalf("Pick() returned nil")
|
|
}
|
|
|
|
// Verify consistency: same session, same auths -> same result
|
|
for i := 0; i < 10; i++ {
|
|
got, err := selector.Pick(context.Background(), "claude", "claude-3", opts, auths)
|
|
if err != nil {
|
|
t.Fatalf("Pick() #%d error = %v", i, err)
|
|
}
|
|
if got.ID != first.ID {
|
|
t.Fatalf("Pick() #%d auth.ID = %q, want %q (same session should pick same auth)", i, got.ID, first.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSessionAffinitySelector_NoSessionFallback(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fallback := &FillFirstSelector{}
|
|
selector := NewSessionAffinitySelector(fallback)
|
|
|
|
auths := []*Auth{
|
|
{ID: "auth-b"},
|
|
{ID: "auth-a"},
|
|
{ID: "auth-c"},
|
|
}
|
|
|
|
// No session in payload, should fallback to FillFirstSelector (picks "auth-a" after sorting)
|
|
payload := []byte(`{"model":"claude-3"}`)
|
|
opts := cliproxyexecutor.Options{OriginalRequest: payload}
|
|
|
|
got, err := selector.Pick(context.Background(), "claude", "claude-3", opts, auths)
|
|
if err != nil {
|
|
t.Fatalf("Pick() error = %v", err)
|
|
}
|
|
if got.ID != "auth-a" {
|
|
t.Fatalf("Pick() auth.ID = %q, want %q (should fallback to FillFirst)", got.ID, "auth-a")
|
|
}
|
|
}
|
|
|
|
func TestSessionAffinitySelector_DifferentSessionsDifferentAuths(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fallback := &RoundRobinSelector{}
|
|
selector := NewSessionAffinitySelector(fallback)
|
|
|
|
auths := []*Auth{
|
|
{ID: "auth-a"},
|
|
{ID: "auth-b"},
|
|
{ID: "auth-c"},
|
|
}
|
|
|
|
// Use valid UUID format for session IDs
|
|
session1 := []byte(`{"metadata":{"user_id":"user_xxx_account__session_11111111-1111-1111-1111-111111111111"}}`)
|
|
session2 := []byte(`{"metadata":{"user_id":"user_xxx_account__session_22222222-2222-2222-2222-222222222222"}}`)
|
|
|
|
opts1 := cliproxyexecutor.Options{OriginalRequest: session1}
|
|
opts2 := cliproxyexecutor.Options{OriginalRequest: session2}
|
|
|
|
auth1, _ := selector.Pick(context.Background(), "claude", "claude-3", opts1, auths)
|
|
auth2, _ := selector.Pick(context.Background(), "claude", "claude-3", opts2, auths)
|
|
|
|
// Different sessions may or may not pick different auths (depends on hash collision)
|
|
// But each session should be consistent
|
|
for i := 0; i < 5; i++ {
|
|
got1, _ := selector.Pick(context.Background(), "claude", "claude-3", opts1, auths)
|
|
got2, _ := selector.Pick(context.Background(), "claude", "claude-3", opts2, auths)
|
|
if got1.ID != auth1.ID {
|
|
t.Fatalf("session1 Pick() #%d inconsistent: got %q, want %q", i, got1.ID, auth1.ID)
|
|
}
|
|
if got2.ID != auth2.ID {
|
|
t.Fatalf("session2 Pick() #%d inconsistent: got %q, want %q", i, got2.ID, auth2.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRoundRobinSelectorPick_SingleParentFallsBackToFlat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
selector := &RoundRobinSelector{}
|
|
|
|
// All auths from the same parent - should fall back to flat round-robin
|
|
// because there's only one credential group (no benefit from two-level).
|
|
auths := []*Auth{
|
|
{ID: "cred-a.json::proj-a1", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
|
|
{ID: "cred-a.json::proj-a2", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
|
|
{ID: "cred-a.json::proj-a3", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
|
|
}
|
|
|
|
// With single parent group, parentOrder has length 1, so it uses flat round-robin.
|
|
// Sorted by ID: proj-a1, proj-a2, proj-a3
|
|
want := []string{
|
|
"cred-a.json::proj-a1",
|
|
"cred-a.json::proj-a2",
|
|
"cred-a.json::proj-a3",
|
|
"cred-a.json::proj-a1",
|
|
}
|
|
|
|
for i, expectedID := range want {
|
|
got, err := selector.Pick(context.Background(), "gemini-cli", "gemini-2.5-pro", cliproxyexecutor.Options{}, auths)
|
|
if err != nil {
|
|
t.Fatalf("Pick() #%d error = %v", i, err)
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("Pick() #%d auth = nil", i)
|
|
}
|
|
if got.ID != expectedID {
|
|
t.Fatalf("Pick() #%d auth.ID = %q, want %q", i, got.ID, expectedID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSessionAffinitySelector_FailoverWhenAuthUnavailable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fallback := &RoundRobinSelector{}
|
|
selector := NewSessionAffinitySelectorWithConfig(SessionAffinityConfig{
|
|
Fallback: fallback,
|
|
TTL: time.Minute,
|
|
})
|
|
defer selector.Stop()
|
|
|
|
auths := []*Auth{
|
|
{ID: "auth-a"},
|
|
{ID: "auth-b"},
|
|
{ID: "auth-c"},
|
|
}
|
|
|
|
payload := []byte(`{"metadata":{"user_id":"user_xxx_account__session_failover-test-uuid"}}`)
|
|
opts := cliproxyexecutor.Options{OriginalRequest: payload}
|
|
|
|
// First pick establishes binding
|
|
first, err := selector.Pick(context.Background(), "claude", "claude-3", opts, auths)
|
|
if err != nil {
|
|
t.Fatalf("Pick() error = %v", err)
|
|
}
|
|
|
|
// Remove the bound auth from available list (simulating rate limit)
|
|
availableWithoutFirst := make([]*Auth, 0, len(auths)-1)
|
|
for _, a := range auths {
|
|
if a.ID != first.ID {
|
|
availableWithoutFirst = append(availableWithoutFirst, a)
|
|
}
|
|
}
|
|
|
|
// With failover enabled, should pick a new auth
|
|
second, err := selector.Pick(context.Background(), "claude", "claude-3", opts, availableWithoutFirst)
|
|
if err != nil {
|
|
t.Fatalf("Pick() after failover error = %v", err)
|
|
}
|
|
if second.ID == first.ID {
|
|
t.Fatalf("Pick() after failover returned same auth %q, expected different", first.ID)
|
|
}
|
|
|
|
// Subsequent picks should consistently return the new binding
|
|
for i := 0; i < 5; i++ {
|
|
got, _ := selector.Pick(context.Background(), "claude", "claude-3", opts, availableWithoutFirst)
|
|
if got.ID != second.ID {
|
|
t.Fatalf("Pick() #%d after failover inconsistent: got %q, want %q", i, got.ID, second.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRoundRobinSelectorPick_MixedVirtualAndNonVirtualFallsBackToFlat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
selector := &RoundRobinSelector{}
|
|
|
|
// Mix of virtual and non-virtual auths (e.g., a regular gemini-cli auth without projects
|
|
// alongside virtual ones). Should fall back to flat round-robin.
|
|
auths := []*Auth{
|
|
{ID: "cred-a.json::proj-a1", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
|
|
{ID: "cred-regular.json"}, // no gemini_virtual_parent
|
|
}
|
|
|
|
// groupByVirtualParent returns nil when any auth lacks the attribute,
|
|
// so flat round-robin is used. Sorted by ID: cred-a.json::proj-a1, cred-regular.json
|
|
want := []string{
|
|
"cred-a.json::proj-a1",
|
|
"cred-regular.json",
|
|
"cred-a.json::proj-a1",
|
|
}
|
|
|
|
for i, expectedID := range want {
|
|
got, err := selector.Pick(context.Background(), "gemini-cli", "", cliproxyexecutor.Options{}, auths)
|
|
if err != nil {
|
|
t.Fatalf("Pick() #%d error = %v", i, err)
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("Pick() #%d auth = nil", i)
|
|
}
|
|
if got.ID != expectedID {
|
|
t.Fatalf("Pick() #%d auth.ID = %q, want %q", i, got.ID, expectedID)
|
|
}
|
|
}
|
|
}
|
|
func TestExtractSessionID_ClaudeCodePriorityOverHeader(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Claude Code metadata.user_id should have highest priority, even when X-Session-ID header is present
|
|
headers := make(http.Header)
|
|
headers.Set("X-Session-ID", "header-session-id")
|
|
|
|
payload := []byte(`{"metadata":{"user_id":"user_xxx_account__session_ac980658-63bd-4fb3-97ba-8da64cb1e344"}}`)
|
|
|
|
got := ExtractSessionID(headers, payload, nil)
|
|
want := "claude:ac980658-63bd-4fb3-97ba-8da64cb1e344"
|
|
if got != want {
|
|
t.Errorf("ExtractSessionID() = %q, want %q (Claude Code should have highest priority over header)", got, want)
|
|
}
|
|
}
|
|
|
|
func TestExtractSessionID_ClaudeCodePriorityOverIdempotencyKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Claude Code metadata.user_id should have highest priority, even when idempotency_key is present
|
|
metadata := map[string]any{"idempotency_key": "idem-12345"}
|
|
payload := []byte(`{"metadata":{"user_id":"user_xxx_account__session_ac980658-63bd-4fb3-97ba-8da64cb1e344"}}`)
|
|
|
|
got := ExtractSessionID(nil, payload, metadata)
|
|
want := "claude:ac980658-63bd-4fb3-97ba-8da64cb1e344"
|
|
if got != want {
|
|
t.Errorf("ExtractSessionID() = %q, want %q (Claude Code should have highest priority over idempotency_key)", got, want)
|
|
}
|
|
}
|
|
|
|
func TestExtractSessionID_Headers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
headers := make(http.Header)
|
|
headers.Set("X-Session-ID", "my-explicit-session")
|
|
|
|
got := ExtractSessionID(headers, nil, nil)
|
|
want := "header:my-explicit-session"
|
|
if got != want {
|
|
t.Errorf("ExtractSessionID() with header = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// TestExtractSessionID_IdempotencyKey verifies that idempotency_key is intentionally
|
|
// ignored for session affinity (it's auto-generated per-request, causing cache misses).
|
|
func TestExtractSessionID_IdempotencyKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
metadata := map[string]any{"idempotency_key": "idem-12345"}
|
|
|
|
got := ExtractSessionID(nil, nil, metadata)
|
|
// idempotency_key is disabled - should return empty (no payload to hash)
|
|
if got != "" {
|
|
t.Errorf("ExtractSessionID() with idempotency_key = %q, want empty (idempotency_key is disabled)", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractSessionID_MessageHashFallback(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// First request (user only) generates short hash
|
|
firstRequestPayload := []byte(`{"messages":[{"role":"user","content":"Hello world"}]}`)
|
|
shortHash := ExtractSessionID(nil, firstRequestPayload, nil)
|
|
if shortHash == "" {
|
|
t.Error("ExtractSessionID() first request should return short hash")
|
|
}
|
|
if !strings.HasPrefix(shortHash, "msg:") {
|
|
t.Errorf("ExtractSessionID() = %q, want prefix 'msg:'", shortHash)
|
|
}
|
|
|
|
// Multi-turn with assistant generates full hash (different from short hash)
|
|
multiTurnPayload := []byte(`{"messages":[
|
|
{"role":"user","content":"Hello world"},
|
|
{"role":"assistant","content":"Hi! How can I help?"},
|
|
{"role":"user","content":"Tell me a joke"}
|
|
]}`)
|
|
fullHash := ExtractSessionID(nil, multiTurnPayload, nil)
|
|
if fullHash == "" {
|
|
t.Error("ExtractSessionID() multi-turn should return full hash")
|
|
}
|
|
if fullHash == shortHash {
|
|
t.Error("Full hash should differ from short hash (includes assistant)")
|
|
}
|
|
|
|
// Same multi-turn payload should produce same hash
|
|
fullHash2 := ExtractSessionID(nil, multiTurnPayload, nil)
|
|
if fullHash != fullHash2 {
|
|
t.Errorf("ExtractSessionID() not stable: got %q then %q", fullHash, fullHash2)
|
|
}
|
|
}
|
|
|
|
func TestExtractSessionID_ClaudeAPITopLevelSystem(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Claude API: system prompt in top-level "system" field (array format)
|
|
arraySystem := []byte(`{
|
|
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
|
|
"system": [{"type": "text", "text": "You are Claude Code"}]
|
|
}`)
|
|
got1 := ExtractSessionID(nil, arraySystem, nil)
|
|
if got1 == "" || !strings.HasPrefix(got1, "msg:") {
|
|
t.Errorf("ExtractSessionID() with array system = %q, want msg:* prefix", got1)
|
|
}
|
|
|
|
// Claude API: system prompt in top-level "system" field (string format)
|
|
stringSystem := []byte(`{
|
|
"messages": [{"role": "user", "content": "Hello"}],
|
|
"system": "You are Claude Code"
|
|
}`)
|
|
got2 := ExtractSessionID(nil, stringSystem, nil)
|
|
if got2 == "" || !strings.HasPrefix(got2, "msg:") {
|
|
t.Errorf("ExtractSessionID() with string system = %q, want msg:* prefix", got2)
|
|
}
|
|
|
|
// Multi-turn with top-level system should produce stable hash
|
|
multiTurn := []byte(`{
|
|
"messages": [
|
|
{"role": "user", "content": "Hello"},
|
|
{"role": "assistant", "content": "Hi!"},
|
|
{"role": "user", "content": "Help me"}
|
|
],
|
|
"system": "You are Claude Code"
|
|
}`)
|
|
got3 := ExtractSessionID(nil, multiTurn, nil)
|
|
if got3 == "" {
|
|
t.Error("ExtractSessionID() multi-turn with top-level system should return hash")
|
|
}
|
|
if got3 == got2 {
|
|
t.Error("Multi-turn hash should differ from first-turn hash (includes assistant)")
|
|
}
|
|
}
|
|
|
|
func TestExtractSessionID_GeminiFormat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Gemini format with systemInstruction and contents
|
|
payload := []byte(`{
|
|
"systemInstruction": {"parts": [{"text": "You are a helpful assistant."}]},
|
|
"contents": [
|
|
{"role": "user", "parts": [{"text": "Hello Gemini"}]},
|
|
{"role": "model", "parts": [{"text": "Hi there!"}]}
|
|
]
|
|
}`)
|
|
|
|
got := ExtractSessionID(nil, payload, nil)
|
|
if got == "" {
|
|
t.Error("ExtractSessionID() with Gemini format should return hash-based session ID")
|
|
}
|
|
if !strings.HasPrefix(got, "msg:") {
|
|
t.Errorf("ExtractSessionID() = %q, want prefix 'msg:'", got)
|
|
}
|
|
|
|
// Same payload should produce same hash
|
|
got2 := ExtractSessionID(nil, payload, nil)
|
|
if got != got2 {
|
|
t.Errorf("ExtractSessionID() not stable: got %q then %q", got, got2)
|
|
}
|
|
|
|
// Different user message should produce different hash
|
|
differentPayload := []byte(`{
|
|
"systemInstruction": {"parts": [{"text": "You are a helpful assistant."}]},
|
|
"contents": [
|
|
{"role": "user", "parts": [{"text": "Hello different"}]},
|
|
{"role": "model", "parts": [{"text": "Hi there!"}]}
|
|
]
|
|
}`)
|
|
got3 := ExtractSessionID(nil, differentPayload, nil)
|
|
if got == got3 {
|
|
t.Errorf("ExtractSessionID() should produce different hash for different user message")
|
|
}
|
|
}
|
|
|
|
func TestExtractSessionID_OpenAIResponsesAPI(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
firstTurn := []byte(`{
|
|
"instructions": "You are Codex, based on GPT-5.",
|
|
"input": [
|
|
{"type": "message", "role": "developer", "content": [{"type": "input_text", "text": "system instructions"}]},
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hi"}]}
|
|
]
|
|
}`)
|
|
|
|
got1 := ExtractSessionID(nil, firstTurn, nil)
|
|
if got1 == "" {
|
|
t.Error("ExtractSessionID() should return hash for OpenAI Responses API format")
|
|
}
|
|
if !strings.HasPrefix(got1, "msg:") {
|
|
t.Errorf("ExtractSessionID() = %q, want prefix 'msg:'", got1)
|
|
}
|
|
|
|
secondTurn := []byte(`{
|
|
"instructions": "You are Codex, based on GPT-5.",
|
|
"input": [
|
|
{"type": "message", "role": "developer", "content": [{"type": "input_text", "text": "system instructions"}]},
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hi"}]},
|
|
{"type": "reasoning", "summary": [{"type": "summary_text", "text": "thinking..."}], "encrypted_content": "xxx"},
|
|
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Hello!"}]},
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "what can you do"}]}
|
|
]
|
|
}`)
|
|
|
|
got2 := ExtractSessionID(nil, secondTurn, nil)
|
|
if got2 == "" {
|
|
t.Error("ExtractSessionID() should return hash for second turn")
|
|
}
|
|
|
|
if got1 == got2 {
|
|
t.Log("First turn and second turn have different hashes (expected: second includes assistant)")
|
|
}
|
|
|
|
thirdTurn := []byte(`{
|
|
"instructions": "You are Codex, based on GPT-5.",
|
|
"input": [
|
|
{"type": "message", "role": "developer", "content": [{"type": "input_text", "text": "system instructions"}]},
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hi"}]},
|
|
{"type": "reasoning", "summary": [{"type": "summary_text", "text": "thinking..."}], "encrypted_content": "xxx"},
|
|
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Hello!"}]},
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "what can you do"}]},
|
|
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "I can help with..."}]},
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "thanks"}]}
|
|
]
|
|
}`)
|
|
|
|
got3 := ExtractSessionID(nil, thirdTurn, nil)
|
|
if got2 != got3 {
|
|
t.Errorf("Second and third turn should have same hash (same first assistant): got %q vs %q", got2, got3)
|
|
}
|
|
}
|
|
|
|
func TestSessionAffinitySelector_ThreeScenarios(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fallback := &RoundRobinSelector{}
|
|
selector := NewSessionAffinitySelectorWithConfig(SessionAffinityConfig{
|
|
Fallback: fallback,
|
|
TTL: time.Minute,
|
|
})
|
|
defer selector.Stop()
|
|
|
|
auths := []*Auth{{ID: "auth-a"}, {ID: "auth-b"}, {ID: "auth-c"}}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
scenario string
|
|
payload []byte
|
|
}{
|
|
{
|
|
name: "OpenAI_Scenario1_NewRequest",
|
|
scenario: "new",
|
|
payload: []byte(`{"messages":[{"role":"system","content":"You are helpful"},{"role":"user","content":"Hello"}]}`),
|
|
},
|
|
{
|
|
name: "OpenAI_Scenario2_SecondTurn",
|
|
scenario: "second",
|
|
payload: []byte(`{"messages":[{"role":"system","content":"You are helpful"},{"role":"user","content":"Hello"},{"role":"assistant","content":"Hi there!"},{"role":"user","content":"Help me"}]}`),
|
|
},
|
|
{
|
|
name: "OpenAI_Scenario3_ManyTurns",
|
|
scenario: "many",
|
|
payload: []byte(`{"messages":[{"role":"system","content":"You are helpful"},{"role":"user","content":"Hello"},{"role":"assistant","content":"Hi there!"},{"role":"user","content":"Help me"},{"role":"assistant","content":"Sure!"},{"role":"user","content":"Thanks"}]}`),
|
|
},
|
|
{
|
|
name: "Gemini_Scenario1_NewRequest",
|
|
scenario: "new",
|
|
payload: []byte(`{"systemInstruction":{"parts":[{"text":"You are helpful"}]},"contents":[{"role":"user","parts":[{"text":"Hello Gemini"}]}]}`),
|
|
},
|
|
{
|
|
name: "Gemini_Scenario2_SecondTurn",
|
|
scenario: "second",
|
|
payload: []byte(`{"systemInstruction":{"parts":[{"text":"You are helpful"}]},"contents":[{"role":"user","parts":[{"text":"Hello Gemini"}]},{"role":"model","parts":[{"text":"Hi!"}]},{"role":"user","parts":[{"text":"Help"}]}]}`),
|
|
},
|
|
{
|
|
name: "Gemini_Scenario3_ManyTurns",
|
|
scenario: "many",
|
|
payload: []byte(`{"systemInstruction":{"parts":[{"text":"You are helpful"}]},"contents":[{"role":"user","parts":[{"text":"Hello Gemini"}]},{"role":"model","parts":[{"text":"Hi!"}]},{"role":"user","parts":[{"text":"Help"}]},{"role":"model","parts":[{"text":"Sure!"}]},{"role":"user","parts":[{"text":"Thanks"}]}]}`),
|
|
},
|
|
{
|
|
name: "Claude_Scenario1_NewRequest",
|
|
scenario: "new",
|
|
payload: []byte(`{"messages":[{"role":"user","content":"Hello Claude"}]}`),
|
|
},
|
|
{
|
|
name: "Claude_Scenario2_SecondTurn",
|
|
scenario: "second",
|
|
payload: []byte(`{"messages":[{"role":"user","content":"Hello Claude"},{"role":"assistant","content":"Hello!"},{"role":"user","content":"Help me"}]}`),
|
|
},
|
|
{
|
|
name: "Claude_Scenario3_ManyTurns",
|
|
scenario: "many",
|
|
payload: []byte(`{"messages":[{"role":"user","content":"Hello Claude"},{"role":"assistant","content":"Hello!"},{"role":"user","content":"Help"},{"role":"assistant","content":"Sure!"},{"role":"user","content":"Thanks"}]}`),
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
opts := cliproxyexecutor.Options{OriginalRequest: tc.payload}
|
|
picked, err := selector.Pick(context.Background(), "provider", "model", opts, auths)
|
|
if err != nil {
|
|
t.Fatalf("Pick() error = %v", err)
|
|
}
|
|
if picked == nil {
|
|
t.Fatal("Pick() returned nil")
|
|
}
|
|
t.Logf("%s: picked %s", tc.name, picked.ID)
|
|
})
|
|
}
|
|
|
|
t.Run("Scenario2And3_SameAuth", func(t *testing.T) {
|
|
openaiS2 := []byte(`{"messages":[{"role":"system","content":"Stable test"},{"role":"user","content":"First msg"},{"role":"assistant","content":"Response"},{"role":"user","content":"Second"}]}`)
|
|
openaiS3 := []byte(`{"messages":[{"role":"system","content":"Stable test"},{"role":"user","content":"First msg"},{"role":"assistant","content":"Response"},{"role":"user","content":"Second"},{"role":"assistant","content":"More"},{"role":"user","content":"Third"}]}`)
|
|
|
|
opts2 := cliproxyexecutor.Options{OriginalRequest: openaiS2}
|
|
opts3 := cliproxyexecutor.Options{OriginalRequest: openaiS3}
|
|
|
|
picked2, _ := selector.Pick(context.Background(), "test", "model", opts2, auths)
|
|
picked3, _ := selector.Pick(context.Background(), "test", "model", opts3, auths)
|
|
|
|
if picked2.ID != picked3.ID {
|
|
t.Errorf("Scenario2 and Scenario3 should pick same auth: got %s vs %s", picked2.ID, picked3.ID)
|
|
}
|
|
})
|
|
|
|
t.Run("Scenario1To2_InheritBinding", func(t *testing.T) {
|
|
s1 := []byte(`{"messages":[{"role":"system","content":"Inherit test"},{"role":"user","content":"Initial"}]}`)
|
|
s2 := []byte(`{"messages":[{"role":"system","content":"Inherit test"},{"role":"user","content":"Initial"},{"role":"assistant","content":"Reply"},{"role":"user","content":"Continue"}]}`)
|
|
|
|
opts1 := cliproxyexecutor.Options{OriginalRequest: s1}
|
|
opts2 := cliproxyexecutor.Options{OriginalRequest: s2}
|
|
|
|
picked1, _ := selector.Pick(context.Background(), "inherit", "model", opts1, auths)
|
|
picked2, _ := selector.Pick(context.Background(), "inherit", "model", opts2, auths)
|
|
|
|
if picked1.ID != picked2.ID {
|
|
t.Errorf("Scenario2 should inherit Scenario1 binding: got %s vs %s", picked1.ID, picked2.ID)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSessionAffinitySelector_MultiModelSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fallback := &RoundRobinSelector{}
|
|
selector := NewSessionAffinitySelectorWithConfig(SessionAffinityConfig{
|
|
Fallback: fallback,
|
|
TTL: time.Minute,
|
|
})
|
|
defer selector.Stop()
|
|
|
|
// auth-a supports only model-a, auth-b supports only model-b
|
|
authA := &Auth{ID: "auth-a"}
|
|
authB := &Auth{ID: "auth-b"}
|
|
|
|
// Same session ID for all requests
|
|
payload := []byte(`{"metadata":{"user_id":"user_xxx_account__session_multi-model-test"}}`)
|
|
opts := cliproxyexecutor.Options{OriginalRequest: payload}
|
|
|
|
// Request model-a with only auth-a available for that model
|
|
authsForModelA := []*Auth{authA}
|
|
pickedA, err := selector.Pick(context.Background(), "provider", "model-a", opts, authsForModelA)
|
|
if err != nil {
|
|
t.Fatalf("Pick() for model-a error = %v", err)
|
|
}
|
|
if pickedA.ID != "auth-a" {
|
|
t.Fatalf("Pick() for model-a = %q, want auth-a", pickedA.ID)
|
|
}
|
|
|
|
// Request model-b with only auth-b available for that model
|
|
authsForModelB := []*Auth{authB}
|
|
pickedB, err := selector.Pick(context.Background(), "provider", "model-b", opts, authsForModelB)
|
|
if err != nil {
|
|
t.Fatalf("Pick() for model-b error = %v", err)
|
|
}
|
|
if pickedB.ID != "auth-b" {
|
|
t.Fatalf("Pick() for model-b = %q, want auth-b", pickedB.ID)
|
|
}
|
|
|
|
// Switch back to model-a - should still get auth-a (separate binding per model)
|
|
pickedA2, err := selector.Pick(context.Background(), "provider", "model-a", opts, authsForModelA)
|
|
if err != nil {
|
|
t.Fatalf("Pick() for model-a (2nd) error = %v", err)
|
|
}
|
|
if pickedA2.ID != "auth-a" {
|
|
t.Fatalf("Pick() for model-a (2nd) = %q, want auth-a", pickedA2.ID)
|
|
}
|
|
|
|
// Verify bindings are stable for multiple calls
|
|
for i := 0; i < 5; i++ {
|
|
gotA, _ := selector.Pick(context.Background(), "provider", "model-a", opts, authsForModelA)
|
|
gotB, _ := selector.Pick(context.Background(), "provider", "model-b", opts, authsForModelB)
|
|
if gotA.ID != "auth-a" {
|
|
t.Fatalf("Pick() #%d for model-a = %q, want auth-a", i, gotA.ID)
|
|
}
|
|
if gotB.ID != "auth-b" {
|
|
t.Fatalf("Pick() #%d for model-b = %q, want auth-b", i, gotB.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtractSessionID_MultimodalContent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// First request generates short hash
|
|
firstRequestPayload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"Hello world"},{"type":"image","source":{"data":"..."}}]}]}`)
|
|
shortHash := ExtractSessionID(nil, firstRequestPayload, nil)
|
|
if shortHash == "" {
|
|
t.Error("ExtractSessionID() first request should return short hash")
|
|
}
|
|
if !strings.HasPrefix(shortHash, "msg:") {
|
|
t.Errorf("ExtractSessionID() = %q, want prefix 'msg:'", shortHash)
|
|
}
|
|
|
|
// Multi-turn generates full hash
|
|
multiTurnPayload := []byte(`{"messages":[
|
|
{"role":"user","content":[{"type":"text","text":"Hello world"},{"type":"image","source":{"data":"..."}}]},
|
|
{"role":"assistant","content":"I see an image!"},
|
|
{"role":"user","content":"What is it?"}
|
|
]}`)
|
|
fullHash := ExtractSessionID(nil, multiTurnPayload, nil)
|
|
if fullHash == "" {
|
|
t.Error("ExtractSessionID() multimodal multi-turn should return full hash")
|
|
}
|
|
if fullHash == shortHash {
|
|
t.Error("Full hash should differ from short hash")
|
|
}
|
|
|
|
// Different user content produces different hash
|
|
differentPayload := []byte(`{"messages":[
|
|
{"role":"user","content":[{"type":"text","text":"Different content"}]},
|
|
{"role":"assistant","content":"I see something different!"}
|
|
]}`)
|
|
differentHash := ExtractSessionID(nil, differentPayload, nil)
|
|
if fullHash == differentHash {
|
|
t.Errorf("ExtractSessionID() should produce different hash for different content")
|
|
}
|
|
}
|
|
|
|
func TestSessionAffinitySelector_CrossProviderIsolation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fallback := &RoundRobinSelector{}
|
|
selector := NewSessionAffinitySelectorWithConfig(SessionAffinityConfig{
|
|
Fallback: fallback,
|
|
TTL: time.Minute,
|
|
})
|
|
defer selector.Stop()
|
|
|
|
authClaude := &Auth{ID: "auth-claude"}
|
|
authGemini := &Auth{ID: "auth-gemini"}
|
|
|
|
// Same session ID for both providers
|
|
payload := []byte(`{"metadata":{"user_id":"user_xxx_account__session_cross-provider-test"}}`)
|
|
opts := cliproxyexecutor.Options{OriginalRequest: payload}
|
|
|
|
// Request via claude provider
|
|
pickedClaude, err := selector.Pick(context.Background(), "claude", "claude-3", opts, []*Auth{authClaude})
|
|
if err != nil {
|
|
t.Fatalf("Pick() for claude error = %v", err)
|
|
}
|
|
if pickedClaude.ID != "auth-claude" {
|
|
t.Fatalf("Pick() for claude = %q, want auth-claude", pickedClaude.ID)
|
|
}
|
|
|
|
// Same session but via gemini provider should get different auth
|
|
pickedGemini, err := selector.Pick(context.Background(), "gemini", "gemini-2.5-pro", opts, []*Auth{authGemini})
|
|
if err != nil {
|
|
t.Fatalf("Pick() for gemini error = %v", err)
|
|
}
|
|
if pickedGemini.ID != "auth-gemini" {
|
|
t.Fatalf("Pick() for gemini = %q, want auth-gemini", pickedGemini.ID)
|
|
}
|
|
|
|
// Verify both bindings remain stable
|
|
for i := 0; i < 5; i++ {
|
|
gotC, _ := selector.Pick(context.Background(), "claude", "claude-3", opts, []*Auth{authClaude})
|
|
gotG, _ := selector.Pick(context.Background(), "gemini", "gemini-2.5-pro", opts, []*Auth{authGemini})
|
|
if gotC.ID != "auth-claude" {
|
|
t.Fatalf("Pick() #%d for claude = %q, want auth-claude", i, gotC.ID)
|
|
}
|
|
if gotG.ID != "auth-gemini" {
|
|
t.Fatalf("Pick() #%d for gemini = %q, want auth-gemini", i, gotG.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSessionCache_GetAndRefresh(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cache := NewSessionCache(100 * time.Millisecond)
|
|
defer cache.Stop()
|
|
|
|
cache.Set("session1", "auth1")
|
|
|
|
// Verify initial value
|
|
got, ok := cache.GetAndRefresh("session1")
|
|
if !ok || got != "auth1" {
|
|
t.Fatalf("GetAndRefresh() = %q, %v, want auth1, true", got, ok)
|
|
}
|
|
|
|
// Wait half TTL and access again (should refresh)
|
|
time.Sleep(60 * time.Millisecond)
|
|
got, ok = cache.GetAndRefresh("session1")
|
|
if !ok || got != "auth1" {
|
|
t.Fatalf("GetAndRefresh() after 60ms = %q, %v, want auth1, true", got, ok)
|
|
}
|
|
|
|
// Wait another 60ms (total 120ms from original, but TTL refreshed at 60ms)
|
|
// Entry should still be valid because TTL was refreshed
|
|
time.Sleep(60 * time.Millisecond)
|
|
got, ok = cache.GetAndRefresh("session1")
|
|
if !ok || got != "auth1" {
|
|
t.Fatalf("GetAndRefresh() after refresh = %q, %v, want auth1, true (TTL should have been refreshed)", got, ok)
|
|
}
|
|
|
|
// Now wait full TTL without access
|
|
time.Sleep(110 * time.Millisecond)
|
|
got, ok = cache.GetAndRefresh("session1")
|
|
if ok {
|
|
t.Fatalf("GetAndRefresh() after expiry = %q, %v, want '', false", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestSessionAffinitySelector_RoundRobinDistribution(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fallback := &RoundRobinSelector{}
|
|
selector := NewSessionAffinitySelectorWithConfig(SessionAffinityConfig{
|
|
Fallback: fallback,
|
|
TTL: time.Minute,
|
|
})
|
|
defer selector.Stop()
|
|
|
|
auths := []*Auth{
|
|
{ID: "auth-a"},
|
|
{ID: "auth-b"},
|
|
{ID: "auth-c"},
|
|
}
|
|
|
|
sessionCount := 12
|
|
counts := make(map[string]int)
|
|
for i := 0; i < sessionCount; i++ {
|
|
payload := []byte(fmt.Sprintf(`{"metadata":{"user_id":"user_xxx_account__session_%08d-0000-0000-0000-000000000000"}}`, i))
|
|
opts := cliproxyexecutor.Options{OriginalRequest: payload}
|
|
got, err := selector.Pick(context.Background(), "provider", "model", opts, auths)
|
|
if err != nil {
|
|
t.Fatalf("Pick() session %d error = %v", i, err)
|
|
}
|
|
counts[got.ID]++
|
|
}
|
|
|
|
expected := sessionCount / len(auths)
|
|
for _, auth := range auths {
|
|
got := counts[auth.ID]
|
|
if got != expected {
|
|
t.Errorf("auth %s got %d sessions, want %d (round-robin should distribute evenly)", auth.ID, got, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSessionAffinitySelector_Concurrent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fallback := &RoundRobinSelector{}
|
|
selector := NewSessionAffinitySelectorWithConfig(SessionAffinityConfig{
|
|
Fallback: fallback,
|
|
TTL: time.Minute,
|
|
})
|
|
defer selector.Stop()
|
|
|
|
auths := []*Auth{
|
|
{ID: "auth-a"},
|
|
{ID: "auth-b"},
|
|
{ID: "auth-c"},
|
|
}
|
|
|
|
payload := []byte(`{"metadata":{"user_id":"user_xxx_account__session_concurrent-test"}}`)
|
|
opts := cliproxyexecutor.Options{OriginalRequest: payload}
|
|
|
|
// First pick to establish binding
|
|
first, err := selector.Pick(context.Background(), "claude", "claude-3", opts, auths)
|
|
if err != nil {
|
|
t.Fatalf("Initial Pick() error = %v", err)
|
|
}
|
|
expectedID := first.ID
|
|
|
|
start := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
errCh := make(chan error, 1)
|
|
|
|
goroutines := 32
|
|
iterations := 50
|
|
for i := 0; i < goroutines; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
for j := 0; j < iterations; j++ {
|
|
got, err := selector.Pick(context.Background(), "claude", "claude-3", opts, auths)
|
|
if err != nil {
|
|
select {
|
|
case errCh <- err:
|
|
default:
|
|
}
|
|
return
|
|
}
|
|
if got.ID != expectedID {
|
|
select {
|
|
case errCh <- fmt.Errorf("concurrent Pick() returned %q, want %q", got.ID, expectedID):
|
|
default:
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
close(start)
|
|
wg.Wait()
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
t.Fatalf("concurrent Pick() error = %v", err)
|
|
default:
|
|
}
|
|
}
|