Files
goclaw/internal/providers/chatgpt_oauth_router_test.go
Kai (Tam Nhu) Tran 30708ae79d feat(providers): support Codex OAuth pools with inherited routing defaults
* feat(auth): support named chatgpt oauth providers

- add provider-scoped ChatGPT OAuth routes and CLI support

- persist refresh tokens per provider and reject provider-type collisions

- wire provider OAuth setup flows in the dashboard and setup UI

Refs #448

* feat(agent): add chatgpt oauth account routing

- add agent other_config routing for manual and round-robin selection

- reuse routed provider resolution across resolver and pending loaders

- add router, parser, and agent advanced dialog coverage for multi-account use

Refs #448

* docs(api): describe chatgpt oauth routing

- document named-provider ChatGPT OAuth auth routes

- describe agent-side account routing and round-robin behavior

- update OpenAPI agent config schema and provider type enum

Refs #448

* fix(store): add missing agent key context helpers

* feat(ui): clarify chatgpt oauth account setup and routing

* docs(providers): align chatgpt oauth alias examples

* feat(agent): add codex pool activity dashboard

* fix(providers): harden codex oauth alias setup

* feat(codex-pool): improve routing dashboard UX

- redesign the Codex/OpenAI pool page around saved-pool checkpoints and live evidence

- add clearer selection, attention, and recent-proof states for pool members

- make the lower panels fill the remaining desktop viewport while staying responsive

* fix(store): resolve context helper merge duplication

* feat(oauth): add codex pool quota and observation APIs

- add quota inspection and observation endpoints for ChatGPT Subscription (OAuth) providers

- teach codex routing to surface pool activity, observation metadata, and quota-aware readiness

- extend tests and HTTP docs/OpenAPI for the new pool monitoring flows

* feat(web): add codex pool quota monitor and controls

- add provider quota fetching, readiness badges, and live routing evidence on the account pool page

- redesign pool setup and activity panels for multi-account management with localized copy updates

- keep the live monitor internally scrollable and compact the account cards for better viewport fit

* fix(web): clarify pool routing labels

- rename the recent request badge from Direct to Selected

- restore compact quota bars in the live pool cards

* feat(codex-pool): add runtime health dashboard

- derive per-provider success and failure health from routed Codex traces

- surface routing, quota, and recent request evidence in the pool UI

- align provider alias guidance and owner access with the dashboard role model

* docs(auth): document tenant scoping and key roles

* fix(auth): harden tenant and codex pool access control

* fix(providers): align codex pool runtime defaults

* feat(ui): tighten codex pool responsive layout

* feat(chatgpt-oauth): refine codex pool management UX

* feat(chatgpt-oauth): surface quota bars on provider pages

- add compact quota bars to Codex provider rows and provider detail

- fetch quota only for ready visible provider rows and ready detail aliases

- fix managed-member detail visibility and tighten provider locale copy
2026-03-27 09:35:57 +07:00

281 lines
8.8 KiB
Go

package providers
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
)
type routeEligibilityTokenSource struct {
token string
eligibility RouteEligibility
}
func (s *routeEligibilityTokenSource) Token() (string, error) {
return s.token, nil
}
func (s *routeEligibilityTokenSource) RouteEligibility(context.Context) RouteEligibility {
return s.eligibility
}
func TestChatGPTOAuthRouterRoundRobin(t *testing.T) {
var hitsA, hitsB int
serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hitsA++
writeSSEDone(w)
}))
defer serverA.Close()
serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hitsB++
writeSSEDone(w)
}))
defer serverB.Close()
tenantID := uuid.New()
registry := NewRegistry(nil)
providerA := NewCodexProvider("acct-a", &staticTokenSource{token: "token-a"}, serverA.URL, "gpt-5.4")
providerB := NewCodexProvider("acct-b", &staticTokenSource{token: "token-b"}, serverB.URL, "gpt-5.4")
providerA.retryConfig.Attempts = 1
providerB.retryConfig.Attempts = 1
registry.RegisterForTenant(tenantID, providerA)
registry.RegisterForTenant(tenantID, providerB)
router := NewChatGPTOAuthRouter(tenantID, registry, "acct-a", "round_robin", []string{"acct-b"})
if _, err := router.Chat(context.Background(), ChatRequest{
Messages: []Message{{Role: "user", Content: "first"}},
}); err != nil {
t.Fatalf("first chat failed: %v", err)
}
if _, err := router.Chat(context.Background(), ChatRequest{
Messages: []Message{{Role: "user", Content: "second"}},
}); err != nil {
t.Fatalf("second chat failed: %v", err)
}
if hitsA != 1 {
t.Fatalf("hitsA = %d, want 1", hitsA)
}
if hitsB != 1 {
t.Fatalf("hitsB = %d, want 1", hitsB)
}
}
func TestChatGPTOAuthRouterFailoverOnRetryableError(t *testing.T) {
var hitsA, hitsB int
serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hitsA++
http.Error(w, "rate limited", http.StatusTooManyRequests)
}))
defer serverA.Close()
serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hitsB++
writeSSEDone(w)
}))
defer serverB.Close()
tenantID := uuid.New()
registry := NewRegistry(nil)
providerA := NewCodexProvider("acct-a", &staticTokenSource{token: "token-a"}, serverA.URL, "gpt-5.4")
providerB := NewCodexProvider("acct-b", &staticTokenSource{token: "token-b"}, serverB.URL, "gpt-5.4")
providerA.retryConfig.Attempts = 1
providerB.retryConfig.Attempts = 1
registry.RegisterForTenant(tenantID, providerA)
registry.RegisterForTenant(tenantID, providerB)
router := NewChatGPTOAuthRouter(tenantID, registry, "acct-a", "round_robin", []string{"acct-b"})
if _, err := router.Chat(context.Background(), ChatRequest{
Messages: []Message{{Role: "user", Content: "fail over"}},
}); err != nil {
t.Fatalf("chat failed: %v", err)
}
if hitsA != 1 {
t.Fatalf("hitsA = %d, want 1", hitsA)
}
if hitsB != 1 {
t.Fatalf("hitsB = %d, want 1", hitsB)
}
}
func TestChatGPTOAuthRouterSkipsBlockedProviderBeforeFirstPick(t *testing.T) {
var hitsA, hitsB int
serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hitsA++
writeSSEDone(w)
}))
defer serverA.Close()
serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hitsB++
writeSSEDone(w)
}))
defer serverB.Close()
tenantID := uuid.New()
registry := NewRegistry(nil)
providerA := NewCodexProvider("acct-a", &routeEligibilityTokenSource{
token: "token-a",
eligibility: RouteEligibility{Class: RouteEligibilityBlocked, Reason: "reauth"},
}, serverA.URL, "gpt-5.4")
providerB := NewCodexProvider("acct-b", &routeEligibilityTokenSource{
token: "token-b",
eligibility: RouteEligibility{Class: RouteEligibilityHealthy},
}, serverB.URL, "gpt-5.4")
providerA.retryConfig.Attempts = 1
providerB.retryConfig.Attempts = 1
registry.RegisterForTenant(tenantID, providerA)
registry.RegisterForTenant(tenantID, providerB)
router := NewChatGPTOAuthRouter(tenantID, registry, "acct-a", "manual", []string{"acct-b"})
if _, err := router.Chat(context.Background(), ChatRequest{
Messages: []Message{{Role: "user", Content: "route around blocked"}},
}); err != nil {
t.Fatalf("chat failed: %v", err)
}
if hitsA != 0 {
t.Fatalf("hitsA = %d, want 0", hitsA)
}
if hitsB != 1 {
t.Fatalf("hitsB = %d, want 1", hitsB)
}
}
func TestChatGPTOAuthRouterRoundRobinPrefersHealthyBeforeUnknown(t *testing.T) {
var hitsA, hitsB, hitsC int
serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hitsA++
writeSSEDone(w)
}))
defer serverA.Close()
serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hitsB++
writeSSEDone(w)
}))
defer serverB.Close()
serverC := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hitsC++
writeSSEDone(w)
}))
defer serverC.Close()
tenantID := uuid.New()
registry := NewRegistry(nil)
providerA := NewCodexProvider("acct-a", &routeEligibilityTokenSource{
token: "token-a",
eligibility: RouteEligibility{Class: RouteEligibilityHealthy},
}, serverA.URL, "gpt-5.4")
providerB := NewCodexProvider("acct-b", &routeEligibilityTokenSource{
token: "token-b",
eligibility: RouteEligibility{Class: RouteEligibilityUnknown, Reason: "retry_later"},
}, serverB.URL, "gpt-5.4")
providerC := NewCodexProvider("acct-c", &routeEligibilityTokenSource{
token: "token-c",
eligibility: RouteEligibility{Class: RouteEligibilityHealthy},
}, serverC.URL, "gpt-5.4")
providerA.retryConfig.Attempts = 1
providerB.retryConfig.Attempts = 1
providerC.retryConfig.Attempts = 1
registry.RegisterForTenant(tenantID, providerA)
registry.RegisterForTenant(tenantID, providerB)
registry.RegisterForTenant(tenantID, providerC)
router := NewChatGPTOAuthRouter(tenantID, registry, "acct-a", "round_robin", []string{"acct-b", "acct-c"})
for i := range 2 {
if _, err := router.Chat(context.Background(), ChatRequest{
Messages: []Message{{Role: "user", Content: "prefer healthy"}},
}); err != nil {
t.Fatalf("chat %d failed: %v", i, err)
}
}
if hitsA != 1 {
t.Fatalf("hitsA = %d, want 1", hitsA)
}
if hitsB != 0 {
t.Fatalf("hitsB = %d, want 0", hitsB)
}
if hitsC != 1 {
t.Fatalf("hitsC = %d, want 1", hitsC)
}
}
func TestChatGPTOAuthRouterReportsWhenAllProvidersBlocked(t *testing.T) {
tenantID := uuid.New()
registry := NewRegistry(nil)
registry.RegisterForTenant(tenantID, NewCodexProvider("acct-a", &routeEligibilityTokenSource{
token: "token-a",
eligibility: RouteEligibility{Class: RouteEligibilityBlocked, Reason: "reauth"},
}, "http://127.0.0.1", "gpt-5.4"))
registry.RegisterForTenant(tenantID, NewCodexProvider("acct-b", &routeEligibilityTokenSource{
token: "token-b",
eligibility: RouteEligibility{Class: RouteEligibilityBlocked, Reason: "exhausted"},
}, "http://127.0.0.1", "gpt-5.4"))
router := NewChatGPTOAuthRouter(tenantID, registry, "acct-a", "round_robin", []string{"acct-b"})
_, err := router.Chat(context.Background(), ChatRequest{
Messages: []Message{{Role: "user", Content: "all blocked"}},
})
if err == nil {
t.Fatal("router.Chat() error = nil, want blocked error")
}
if !strings.Contains(err.Error(), "reauth") || !strings.Contains(err.Error(), "exhausted") {
t.Fatalf("router.Chat() error = %q, want both block reasons", err.Error())
}
}
func TestChatGPTOAuthRouterPriorityOrderKeepsPrimaryAheadOfHealthyFallbacks(t *testing.T) {
var hitsA, hitsB int
serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hitsA++
writeSSEDone(w)
}))
defer serverA.Close()
serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hitsB++
writeSSEDone(w)
}))
defer serverB.Close()
tenantID := uuid.New()
registry := NewRegistry(nil)
providerA := NewCodexProvider("acct-a", &routeEligibilityTokenSource{
token: "token-a",
eligibility: RouteEligibility{Class: RouteEligibilityUnknown, Reason: "retry_later"},
}, serverA.URL, "gpt-5.4")
providerB := NewCodexProvider("acct-b", &routeEligibilityTokenSource{
token: "token-b",
eligibility: RouteEligibility{Class: RouteEligibilityHealthy},
}, serverB.URL, "gpt-5.4")
providerA.retryConfig.Attempts = 1
providerB.retryConfig.Attempts = 1
registry.RegisterForTenant(tenantID, providerA)
registry.RegisterForTenant(tenantID, providerB)
router := NewChatGPTOAuthRouter(tenantID, registry, "acct-a", "priority_order", []string{"acct-b"})
if _, err := router.Chat(context.Background(), ChatRequest{
Messages: []Message{{Role: "user", Content: "priority first"}},
}); err != nil {
t.Fatalf("chat failed: %v", err)
}
if hitsA != 1 {
t.Fatalf("hitsA = %d, want 1", hitsA)
}
if hitsB != 0 {
t.Fatalf("hitsB = %d, want 0", hitsB)
}
}