mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-18 02:14:43 +00:00
* 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
281 lines
8.8 KiB
Go
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)
|
|
}
|
|
}
|