mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 12:18:59 +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
241 lines
6.9 KiB
Go
241 lines
6.9 KiB
Go
package http
|
|
|
|
import (
|
|
"encoding/json"
|
|
"math"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
)
|
|
|
|
type codexPoolSpanActivity struct {
|
|
SpanID uuid.UUID
|
|
TraceID uuid.UUID
|
|
StartedAt time.Time
|
|
DurationMS int
|
|
Status string
|
|
Provider string
|
|
Model string
|
|
Metadata json.RawMessage
|
|
}
|
|
|
|
const codexPoolRuntimeHealthSampleSize = 120
|
|
|
|
func buildCodexPoolActivity(poolProviders []string, spans []codexPoolSpanActivity) ([]codexPoolProviderCount, []codexPoolRecentRequest) {
|
|
sortedSpans := append([]codexPoolSpanActivity(nil), spans...)
|
|
slices.SortFunc(sortedSpans, func(a, b codexPoolSpanActivity) int {
|
|
return b.StartedAt.Compare(a.StartedAt)
|
|
})
|
|
|
|
countsByProvider := make(map[string]*codexPoolProviderCount, len(poolProviders))
|
|
outcomesByProvider := make(map[string][]bool, len(poolProviders))
|
|
for _, name := range poolProviders {
|
|
countsByProvider[name] = &codexPoolProviderCount{ProviderName: name}
|
|
}
|
|
|
|
recent := make([]codexPoolRecentRequest, 0, len(sortedSpans))
|
|
for _, span := range sortedSpans {
|
|
evidence := providers.ExtractChatGPTOAuthRoutingEvidence(span.Metadata)
|
|
selectedProvider := firstNonEmpty(span.Provider, evidence.SelectedProvider)
|
|
if evidence.SelectedProvider != "" {
|
|
selectedProvider = evidence.SelectedProvider
|
|
}
|
|
servingProvider := firstNonEmpty(evidence.ServingProvider)
|
|
if span.Status == "completed" {
|
|
servingProvider = firstNonEmpty(evidence.ServingProvider, span.Provider, selectedProvider)
|
|
}
|
|
failoverProviders := append([]string(nil), evidence.FailoverProviders...)
|
|
usedFailover := len(failoverProviders) > 0 || (selectedProvider != "" && servingProvider != "" && servingProvider != selectedProvider)
|
|
attemptedProviders := poolAttemptedProviders(poolProviders, evidence, selectedProvider, servingProvider)
|
|
|
|
if stat := countsByProvider[selectedProvider]; stat != nil {
|
|
stat.RequestCount++
|
|
stat.DirectSelectionCount++
|
|
updateLatestTime(&stat.LastSelectedAt, span.StartedAt)
|
|
updateLatestTime(&stat.LastUsedAt, span.StartedAt)
|
|
}
|
|
if usedFailover && servingProvider != "" && servingProvider != selectedProvider {
|
|
if stat := countsByProvider[servingProvider]; stat != nil {
|
|
stat.FailoverServeCount++
|
|
updateLatestTime(&stat.LastFailoverAt, span.StartedAt)
|
|
updateLatestTime(&stat.LastUsedAt, span.StartedAt)
|
|
}
|
|
}
|
|
recordCodexPoolOutcomes(
|
|
countsByProvider,
|
|
outcomesByProvider,
|
|
attemptedProviders,
|
|
servingProvider,
|
|
span.Status,
|
|
span.StartedAt,
|
|
)
|
|
|
|
recent = append(recent, codexPoolRecentRequest{
|
|
SpanID: span.SpanID,
|
|
TraceID: span.TraceID,
|
|
StartedAt: span.StartedAt,
|
|
Status: span.Status,
|
|
DurationMS: span.DurationMS,
|
|
ProviderName: servingProvider,
|
|
SelectedProvider: selectedProvider,
|
|
Model: span.Model,
|
|
AttemptCount: maxInt(1, evidence.AttemptCount),
|
|
UsedFailover: usedFailover,
|
|
FailoverProviders: failoverProviders,
|
|
})
|
|
}
|
|
|
|
providerCounts := make([]codexPoolProviderCount, 0, len(poolProviders))
|
|
for _, name := range poolProviders {
|
|
if stat := countsByProvider[name]; stat != nil {
|
|
finalizeCodexPoolProviderHealth(stat, outcomesByProvider[name])
|
|
providerCounts = append(providerCounts, *stat)
|
|
}
|
|
}
|
|
return providerCounts, recent
|
|
}
|
|
|
|
func poolAttemptedProviders(
|
|
poolProviders []string,
|
|
evidence providers.ChatGPTOAuthRoutingEvidence,
|
|
selectedProvider string,
|
|
servingProvider string,
|
|
) []string {
|
|
attempted := make([]string, 0, len(evidence.AttemptedProviders)+2)
|
|
for _, providerName := range evidence.AttemptedProviders {
|
|
if providerInPool(poolProviders, providerName) && !slices.Contains(attempted, providerName) {
|
|
attempted = append(attempted, providerName)
|
|
}
|
|
}
|
|
if providerInPool(poolProviders, selectedProvider) && !slices.Contains(attempted, selectedProvider) {
|
|
attempted = append(attempted, selectedProvider)
|
|
}
|
|
if providerInPool(poolProviders, servingProvider) && !slices.Contains(attempted, servingProvider) {
|
|
attempted = append(attempted, servingProvider)
|
|
}
|
|
return attempted
|
|
}
|
|
|
|
func recordCodexPoolOutcomes(
|
|
countsByProvider map[string]*codexPoolProviderCount,
|
|
outcomesByProvider map[string][]bool,
|
|
attemptedProviders []string,
|
|
servingProvider string,
|
|
status string,
|
|
startedAt time.Time,
|
|
) {
|
|
switch status {
|
|
case "completed":
|
|
if stat := countsByProvider[servingProvider]; stat != nil {
|
|
stat.SuccessCount++
|
|
updateLatestTime(&stat.LastSuccessAt, startedAt)
|
|
updateLatestTime(&stat.LastUsedAt, startedAt)
|
|
outcomesByProvider[servingProvider] = append(outcomesByProvider[servingProvider], true)
|
|
}
|
|
for _, providerName := range attemptedProviders {
|
|
if providerName == "" || providerName == servingProvider {
|
|
continue
|
|
}
|
|
if stat := countsByProvider[providerName]; stat != nil {
|
|
stat.FailureCount++
|
|
updateLatestTime(&stat.LastFailureAt, startedAt)
|
|
updateLatestTime(&stat.LastUsedAt, startedAt)
|
|
outcomesByProvider[providerName] = append(outcomesByProvider[providerName], false)
|
|
}
|
|
}
|
|
case "error":
|
|
for _, providerName := range attemptedProviders {
|
|
if stat := countsByProvider[providerName]; stat != nil {
|
|
stat.FailureCount++
|
|
updateLatestTime(&stat.LastFailureAt, startedAt)
|
|
updateLatestTime(&stat.LastUsedAt, startedAt)
|
|
outcomesByProvider[providerName] = append(outcomesByProvider[providerName], false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func finalizeCodexPoolProviderHealth(stat *codexPoolProviderCount, outcomes []bool) {
|
|
if stat == nil {
|
|
return
|
|
}
|
|
total := stat.SuccessCount + stat.FailureCount
|
|
if total > 0 {
|
|
stat.SuccessRate = int(math.Round(float64(stat.SuccessCount) * 100 / float64(total)))
|
|
}
|
|
for _, outcome := range outcomes {
|
|
if outcome {
|
|
break
|
|
}
|
|
stat.ConsecutiveFailures++
|
|
}
|
|
if total == 0 {
|
|
stat.HealthScore = 0
|
|
stat.HealthState = "idle"
|
|
return
|
|
}
|
|
score := stat.SuccessRate - minInt(45, stat.ConsecutiveFailures*15)
|
|
if stat.LastSuccessAt == nil && stat.FailureCount > 0 {
|
|
score -= 10
|
|
}
|
|
stat.HealthScore = clampInt(score, 0, 100)
|
|
switch {
|
|
case stat.ConsecutiveFailures >= 3 || stat.HealthScore < 40:
|
|
stat.HealthState = "critical"
|
|
case stat.HealthScore < 80:
|
|
stat.HealthState = "degraded"
|
|
default:
|
|
stat.HealthState = "healthy"
|
|
}
|
|
}
|
|
|
|
func updateLatestTime(target **time.Time, value time.Time) {
|
|
if target == nil {
|
|
return
|
|
}
|
|
if *target == nil || value.After(**target) {
|
|
seenAt := value
|
|
*target = &seenAt
|
|
}
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func maxInt(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func minInt(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func clampInt(value, minValue, maxValue int) int {
|
|
if value < minValue {
|
|
return minValue
|
|
}
|
|
if value > maxValue {
|
|
return maxValue
|
|
}
|
|
return value
|
|
}
|
|
|
|
func providerInPool(poolProviders []string, providerName string) bool {
|
|
return providerName != "" && slices.Contains(poolProviders, providerName)
|
|
}
|