mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-12 23:28:09 +08:00
- Add routing.session-affinity-subagents (defaulting to true) to RoutingConfig. - Unify subagent parent credential inheritance across all providers by removing hardcoded provider/model blacklists. - When session-affinity-subagents is false, isolate subagents and distribute them via the fallback selector. - Ensure changing session-affinity-subagents is a no-op when session-affinity is false. - Preserve alias isolation and failure isolation invariants. Closes: #5417
313 lines
12 KiB
Go
313 lines
12 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
|
)
|
|
|
|
// TestSessionAffinityAntigravitySubagentInheritsParentByDefault reproduces issue #5417:
|
|
// Subagents across all providers (including Antigravity and Gemini) should inherit
|
|
// the parent session's credential by default to maximize prompt and KV cache reuse.
|
|
func TestSessionAffinityAntigravitySubagentInheritsParentByDefault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
selector := NewSessionAffinitySelectorWithConfig(SessionAffinityConfig{
|
|
Fallback: &RoundRobinSelector{},
|
|
TTL: time.Minute,
|
|
})
|
|
defer selector.Stop()
|
|
|
|
auths := []*Auth{
|
|
{ID: "auth-ag-1", Provider: "antigravity"},
|
|
{ID: "auth-ag-2", Provider: "antigravity"},
|
|
}
|
|
|
|
// 1. Parent request binds to auth-ag-1.
|
|
parentOpts := cliproxyexecutor.Options{
|
|
Headers: http.Header{"X-Claude-Code-Session-Id": []string{"claude-root-100"}},
|
|
OriginalRequest: []byte(`{"messages":[{"role":"user","content":"parent task"}]}`),
|
|
Metadata: map[string]any{},
|
|
}
|
|
parentAuth, errParent := selector.Pick(context.Background(), "antigravity", "gemini-3.7-flash-high", parentOpts, auths)
|
|
if errParent != nil {
|
|
t.Fatalf("parent Pick() error = %v", errParent)
|
|
}
|
|
if parentAuth.ID != "auth-ag-1" {
|
|
t.Fatalf("parent auth = %q, want auth-ag-1", parentAuth.ID)
|
|
}
|
|
|
|
// 2. Subagent request carries child agent ID and should inherit parent auth-ag-1 by default.
|
|
subagentOpts := cliproxyexecutor.Options{
|
|
Headers: http.Header{
|
|
"X-Claude-Code-Session-Id": []string{"claude-root-100"},
|
|
"X-Claude-Code-Agent-Id": []string{"subagent-001"},
|
|
},
|
|
OriginalRequest: []byte(`{"messages":[{"role":"user","content":"subagent task"}]}`),
|
|
Metadata: map[string]any{},
|
|
}
|
|
subagentAuth, errSub := selector.Pick(context.Background(), "antigravity", "gemini-3.7-flash-high", subagentOpts, auths)
|
|
if errSub != nil {
|
|
t.Fatalf("subagent Pick() error = %v", errSub)
|
|
}
|
|
if subagentAuth.ID != parentAuth.ID {
|
|
t.Fatalf("subagent did not inherit parent auth %q, got %q", parentAuth.ID, subagentAuth.ID)
|
|
}
|
|
|
|
// 3. Subsequent turn of subagent must stay sticky to the inherited auth.
|
|
subagentTurn2Auth, errSub2 := selector.Pick(context.Background(), "antigravity", "gemini-3.7-flash-high", subagentOpts, auths)
|
|
if errSub2 != nil {
|
|
t.Fatalf("subagent turn 2 Pick() error = %v", errSub2)
|
|
}
|
|
if subagentTurn2Auth.ID != "auth-ag-1" {
|
|
t.Fatalf("subagent turn 2 did not stay sticky: got %q, want auth-ag-1", subagentTurn2Auth.ID)
|
|
}
|
|
}
|
|
|
|
// TestSessionAffinityGeminiSubagentInheritsParentWhenExplicitlyTrue verifies that when
|
|
// SubagentAffinity is explicitly set to true, Gemini subagents inherit the parent's auth.
|
|
func TestSessionAffinityGeminiSubagentInheritsParentWhenExplicitlyTrue(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
enabled := true
|
|
selector := NewSessionAffinitySelectorWithConfig(SessionAffinityConfig{
|
|
Fallback: &RoundRobinSelector{},
|
|
TTL: time.Minute,
|
|
SubagentAffinity: &enabled,
|
|
})
|
|
defer selector.Stop()
|
|
|
|
auths := []*Auth{
|
|
{ID: "auth-gem-1", Provider: "gemini"},
|
|
{ID: "auth-gem-2", Provider: "gemini"},
|
|
}
|
|
|
|
// 1. Parent request binds to auth-gem-1.
|
|
parentOpts := cliproxyexecutor.Options{
|
|
Headers: http.Header{"X-Claude-Code-Session-Id": []string{"gem-root-200"}},
|
|
OriginalRequest: []byte(`{"messages":[{"role":"user","content":"parent gemini task"}]}`),
|
|
Metadata: map[string]any{},
|
|
}
|
|
parentAuth, errParent := selector.Pick(context.Background(), "gemini", "gemini-2.5-pro", parentOpts, auths)
|
|
if errParent != nil {
|
|
t.Fatalf("parent Pick() error = %v", errParent)
|
|
}
|
|
if parentAuth.ID != "auth-gem-1" {
|
|
t.Fatalf("parent auth = %q, want auth-gem-1", parentAuth.ID)
|
|
}
|
|
|
|
// 2. Subagent request inherits parent auth-gem-1.
|
|
subagentOpts := cliproxyexecutor.Options{
|
|
Headers: http.Header{
|
|
"X-Claude-Code-Session-Id": []string{"gem-root-200"},
|
|
"X-Claude-Code-Agent-Id": []string{"gem-sub-001"},
|
|
},
|
|
OriginalRequest: []byte(`{"messages":[{"role":"user","content":"subagent gemini task"}]}`),
|
|
Metadata: map[string]any{},
|
|
}
|
|
subagentAuth, errSub := selector.Pick(context.Background(), "gemini", "gemini-2.5-pro", subagentOpts, auths)
|
|
if errSub != nil {
|
|
t.Fatalf("subagent Pick() error = %v", errSub)
|
|
}
|
|
if subagentAuth.ID != parentAuth.ID {
|
|
t.Fatalf("subagent did not inherit parent auth %q, got %q", parentAuth.ID, subagentAuth.ID)
|
|
}
|
|
}
|
|
|
|
// TestSessionAffinitySubagentIsolatesWhenExplicitlyFalse verifies that when
|
|
// SubagentAffinity is false, subagents do not inherit parent credentials and balance across pool.
|
|
func TestSessionAffinitySubagentIsolatesWhenExplicitlyFalse(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
disabled := false
|
|
selector := NewSessionAffinitySelectorWithConfig(SessionAffinityConfig{
|
|
Fallback: &RoundRobinSelector{},
|
|
TTL: time.Minute,
|
|
SubagentAffinity: &disabled,
|
|
})
|
|
defer selector.Stop()
|
|
|
|
auths := []*Auth{
|
|
{ID: "auth-1", Provider: "claude"},
|
|
{ID: "auth-2", Provider: "claude"},
|
|
}
|
|
|
|
// 1. Parent request binds to auth-1.
|
|
parentOpts := cliproxyexecutor.Options{
|
|
Headers: http.Header{"X-Claude-Code-Session-Id": []string{"sess-iso-300"}},
|
|
OriginalRequest: []byte(`{"messages":[{"role":"user","content":"parent task"}]}`),
|
|
Metadata: map[string]any{},
|
|
}
|
|
parentAuth, errParent := selector.Pick(context.Background(), "claude", "claude-3-7-sonnet", parentOpts, auths)
|
|
if errParent != nil {
|
|
t.Fatalf("parent Pick() error = %v", errParent)
|
|
}
|
|
if parentAuth.ID != "auth-1" {
|
|
t.Fatalf("parent auth = %q, want auth-1", parentAuth.ID)
|
|
}
|
|
|
|
// 2. Subagent request should NOT inherit auth-1; it picks auth-2 via fallback round-robin.
|
|
subagentOpts := cliproxyexecutor.Options{
|
|
Headers: http.Header{
|
|
"X-Claude-Code-Session-Id": []string{"sess-iso-300"},
|
|
"X-Claude-Code-Agent-Id": []string{"sub-iso-001"},
|
|
},
|
|
OriginalRequest: []byte(`{"messages":[{"role":"user","content":"subagent task"}]}`),
|
|
Metadata: map[string]any{},
|
|
}
|
|
subagentAuth, errSub := selector.Pick(context.Background(), "claude", "claude-3-7-sonnet", subagentOpts, auths)
|
|
if errSub != nil {
|
|
t.Fatalf("subagent Pick() error = %v", errSub)
|
|
}
|
|
if subagentAuth.ID == parentAuth.ID {
|
|
t.Fatalf("subagent incorrectly inherited parent auth %q when SubagentAffinity is false", parentAuth.ID)
|
|
}
|
|
if subagentAuth.ID != "auth-2" {
|
|
t.Fatalf("subagent auth = %q, want auth-2", subagentAuth.ID)
|
|
}
|
|
}
|
|
|
|
// TestSessionAffinitySubagentFailureIsolation verifies that failure on a subagent session
|
|
// only deletes the subagent's own binding, leaving the parent's binding intact.
|
|
func TestSessionAffinitySubagentFailureIsolation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
selector := NewSessionAffinitySelectorWithConfig(SessionAffinityConfig{
|
|
Fallback: &RoundRobinSelector{},
|
|
TTL: time.Minute,
|
|
})
|
|
defer selector.Stop()
|
|
|
|
auths := []*Auth{
|
|
{ID: "auth-1", Provider: "antigravity"},
|
|
{ID: "auth-2", Provider: "antigravity"},
|
|
}
|
|
|
|
// 1. Parent request binds to auth-1.
|
|
parentOpts := cliproxyexecutor.Options{
|
|
Headers: http.Header{"X-Claude-Code-Session-Id": []string{"sess-fail-400"}},
|
|
OriginalRequest: []byte(`{"messages":[{"role":"user","content":"parent task"}]}`),
|
|
Metadata: map[string]any{},
|
|
}
|
|
parentAuth, errParent := selector.Pick(context.Background(), "antigravity", "gemini-3.7-flash-high", parentOpts, auths)
|
|
if errParent != nil {
|
|
t.Fatalf("parent Pick() error = %v", errParent)
|
|
}
|
|
if parentAuth.ID != "auth-1" {
|
|
t.Fatalf("parent auth = %q, want auth-1", parentAuth.ID)
|
|
}
|
|
|
|
// Parent cache key must be bound to auth-1.
|
|
parentCacheKey := "antigravity::claude:sess-fail-400::gemini-3.7-flash-high"
|
|
if bound, ok := selector.cache.Get(parentCacheKey); !ok || bound != "auth-1" {
|
|
t.Fatalf("parent cache binding = (%q, %v), want (auth-1, true)", bound, ok)
|
|
}
|
|
|
|
// 2. Subagent inherits auth-1 and binds its own cache key.
|
|
subagentOpts := cliproxyexecutor.Options{
|
|
Headers: http.Header{
|
|
"X-Claude-Code-Session-Id": []string{"sess-fail-400"},
|
|
"X-Claude-Code-Agent-Id": []string{"sub-fail-001"},
|
|
},
|
|
OriginalRequest: []byte(`{"messages":[{"role":"user","content":"subagent task"}]}`),
|
|
Metadata: map[string]any{},
|
|
}
|
|
subagentAuth, errSub := selector.Pick(context.Background(), "antigravity", "gemini-3.7-flash-high", subagentOpts, auths)
|
|
if errSub != nil {
|
|
t.Fatalf("subagent Pick() error = %v", errSub)
|
|
}
|
|
if subagentAuth.ID != "auth-1" {
|
|
t.Fatalf("subagent auth = %q, want auth-1", subagentAuth.ID)
|
|
}
|
|
|
|
subagentCacheKey := "antigravity::claude:sess-fail-400:agent:sub-fail-001::gemini-3.7-flash-high"
|
|
if bound, ok := selector.cache.Get(subagentCacheKey); !ok || bound != "auth-1" {
|
|
t.Fatalf("subagent cache binding = (%q, %v), want (auth-1, true)", bound, ok)
|
|
}
|
|
|
|
// 3. Subagent fails with an upstream error via OnResult.
|
|
selector.OnResult(Result{
|
|
AuthID: "auth-1",
|
|
Success: false,
|
|
Options: subagentOpts,
|
|
})
|
|
|
|
// 4. Subagent's cache key must be cleared.
|
|
if bound, ok := selector.cache.Get(subagentCacheKey); ok {
|
|
t.Fatalf("subagent cache binding was not cleared after failure: got %q", bound)
|
|
}
|
|
|
|
// 5. Parent session binding in cache MUST still remain intact on auth-1.
|
|
if bound, ok := selector.cache.Get(parentCacheKey); !ok || bound != "auth-1" {
|
|
t.Fatalf("parent binding was damaged by subagent failure: got (%q, %v), want (auth-1, true)", bound, ok)
|
|
}
|
|
|
|
// 6. Parent request turn 2 must still hit auth-1.
|
|
parentTurn2Auth, errParent2 := selector.Pick(context.Background(), "antigravity", "gemini-3.7-flash-high", parentOpts, auths)
|
|
if errParent2 != nil {
|
|
t.Fatalf("parent turn 2 Pick() error = %v", errParent2)
|
|
}
|
|
if parentTurn2Auth.ID != "auth-1" {
|
|
t.Fatalf("parent turn 2 auth = %q, want auth-1", parentTurn2Auth.ID)
|
|
}
|
|
}
|
|
|
|
// TestSessionAffinitySubagentAliasIsolation verifies that subagents bind only their
|
|
// own cacheKey and never register an alias that overrides the parent's fallbackKey.
|
|
func TestSessionAffinitySubagentAliasIsolation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
selector := NewSessionAffinitySelectorWithConfig(SessionAffinityConfig{
|
|
Fallback: &RoundRobinSelector{},
|
|
TTL: time.Minute,
|
|
})
|
|
defer selector.Stop()
|
|
|
|
auths := []*Auth{
|
|
{ID: "auth-1", Provider: "antigravity"},
|
|
{ID: "auth-2", Provider: "antigravity"},
|
|
}
|
|
|
|
// 1. Parent binds auth-1.
|
|
parentOpts := cliproxyexecutor.Options{
|
|
Headers: http.Header{"X-Claude-Code-Session-Id": []string{"sess-alias-500"}},
|
|
OriginalRequest: []byte(`{"messages":[{"role":"user","content":"parent task"}]}`),
|
|
Metadata: map[string]any{},
|
|
}
|
|
_, errParent := selector.Pick(context.Background(), "antigravity", "gemini-3.7-flash-high", parentOpts, auths)
|
|
if errParent != nil {
|
|
t.Fatalf("parent Pick() error = %v", errParent)
|
|
}
|
|
|
|
// 2. Subagent picks and inherits auth-1.
|
|
subagentOpts := cliproxyexecutor.Options{
|
|
Headers: http.Header{
|
|
"X-Claude-Code-Session-Id": []string{"sess-alias-500"},
|
|
"X-Claude-Code-Agent-Id": []string{"sub-alias-001"},
|
|
},
|
|
OriginalRequest: []byte(`{"messages":[{"role":"user","content":"sub task"}]}`),
|
|
Metadata: map[string]any{},
|
|
}
|
|
_, errSub := selector.Pick(context.Background(), "antigravity", "gemini-3.7-flash-high", subagentOpts, auths)
|
|
if errSub != nil {
|
|
t.Fatalf("subagent Pick() error = %v", errSub)
|
|
}
|
|
|
|
// 3. Invalidate subagent's auth by calling OnResult(Success: false).
|
|
selector.OnResult(Result{
|
|
AuthID: "auth-1",
|
|
Success: false,
|
|
Options: subagentOpts,
|
|
})
|
|
|
|
// Parent fallbackKey in cache MUST still resolve to auth-1, proving subagent did not alias it.
|
|
parentCacheKey := "antigravity::claude:sess-alias-500::gemini-3.7-flash-high"
|
|
val, ok := selector.cache.Get(parentCacheKey)
|
|
if !ok || val != "auth-1" {
|
|
t.Fatalf("parent cache key %s = (%q, %v), want (auth-1, true)", parentCacheKey, val, ok)
|
|
}
|
|
}
|