Files
goclaw/internal/http/agents_codex_pool_activity.go
T
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

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)
}