Files
goclaw/internal/http/providers_codex_pool_activity.go
T
Kai (Tam Nhu) TranandGitHub a524b457ba feat(providers): add provider-level Codex pool activity monitor (#539)
Add provider-scoped runtime monitor for Codex pool owners:

- New `GET /v1/providers/{id}/codex-pool-activity` endpoint aggregates pool health across all agents
- New Pool Activity section on provider detail page (pool owners only)
- Shows aggregate member health, recent requests, top agents with drill-down links
- 7-day time window on provider-scoped span query for performance
- Reuses `buildCodexPoolActivity()` — zero duplicated aggregation logic
- i18n complete (en/vi/zh), accessible markup

Closes #499
2026-03-29 12:46:23 +07:00

178 lines
5.6 KiB
Go

package http
import (
"context"
"log/slog"
"net/http"
"slices"
"strconv"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// providerCodexPoolAgentCount tracks per-agent request counts for provider-scoped activity.
type providerCodexPoolAgentCount struct {
AgentID uuid.UUID `json:"agent_id"`
AgentKey string `json:"agent_key,omitempty"`
RequestCount int `json:"request_count"`
}
// handleProviderCodexPoolActivity returns pool activity aggregated across all agents
// that use this provider's Codex pool. Only valid for chatgpt_oauth pool owners.
func (h *ProvidersHandler) handleProviderCodexPoolActivity(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
if h.tracingStore == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": i18n.T(locale, i18n.MsgInvalidRequest, "tracing store unavailable"),
})
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")})
return
}
provider, err := h.store.GetProvider(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "provider", id.String())})
return
}
if provider.ProviderType != store.ProviderChatGPTOAuth {
writeJSON(w, http.StatusOK, emptyProviderPoolActivityResponse())
return
}
// Resolve pool providers from provider settings (capped at 20 to bound query size)
const maxPoolCandidates = 20
settings := store.ParseChatGPTOAuthProviderSettings(provider.Settings)
poolCandidates := []string{provider.Name}
strategy := store.ChatGPTOAuthStrategyPrimaryFirst
if settings != nil && settings.CodexPool != nil {
if settings.CodexPool.Strategy != "" {
strategy = settings.CodexPool.Strategy
}
for _, name := range settings.CodexPool.ExtraProviderNames {
if name != "" && !slices.Contains(poolCandidates, name) {
poolCandidates = append(poolCandidates, name)
if len(poolCandidates) >= maxPoolCandidates {
break
}
}
}
}
// Filter to registered Codex providers
poolProviders := registeredCodexPoolProviders(h.providerReg, provider.TenantID, poolCandidates)
if len(poolProviders) == 0 {
writeJSON(w, http.StatusOK, emptyProviderPoolActivityResponse())
return
}
limit := 18
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 50 {
limit = parsed
}
}
statsLimit := maxInt(limit, codexPoolRuntimeHealthSampleSize)
rawSpans, err := h.tracingStore.ListCodexPoolSpansByProviders(r.Context(), provider.TenantID, poolProviders, statsLimit)
if err != nil {
slog.Error("providers.codex_pool_activity", "provider", provider.Name, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "pool activity")})
return
}
// Filter spans to those with routing evidence matching pool providers
spans := make([]store.CodexPoolSpan, 0, len(rawSpans))
agentRequestCounts := make(map[uuid.UUID]int)
for _, item := range rawSpans {
if evidence := providers.ExtractChatGPTOAuthRoutingEvidence(item.Metadata); evidence.HasData() {
if !providerInPool(poolProviders, evidence.SelectedProvider) && !providerInPool(poolProviders, evidence.ServingProvider) {
continue
}
} else if !providerInPool(poolProviders, item.Provider) {
continue
}
spans = append(spans, item.CodexPoolSpan)
agentRequestCounts[item.AgentID]++
}
providerCounts, recent := buildCodexPoolActivity(poolProviders, spans)
if len(recent) > limit {
recent = recent[:limit]
}
// Build top agents list sorted by request count
topAgents := buildTopAgents(r.Context(), agentRequestCounts, h.agents)
writeJSON(w, http.StatusOK, map[string]any{
"strategy": strategy,
"pool_providers": poolProviders,
"stats_sample_size": len(spans),
"provider_counts": providerCounts,
"recent_requests": recent,
"top_agents": topAgents,
})
}
func emptyProviderPoolActivityResponse() map[string]any {
return map[string]any{
"strategy": store.ChatGPTOAuthStrategyPrimaryFirst,
"pool_providers": []string{},
"stats_sample_size": 0,
"provider_counts": []codexPoolProviderCount{},
"recent_requests": []codexPoolRecentRequest{},
"top_agents": []providerCodexPoolAgentCount{},
}
}
// buildTopAgents converts agent request counts into a sorted slice, resolving agent keys.
func buildTopAgents(ctx context.Context, counts map[uuid.UUID]int, agentStore store.AgentCRUDStore) []providerCodexPoolAgentCount {
if len(counts) == 0 {
return []providerCodexPoolAgentCount{}
}
agentIDs := make([]uuid.UUID, 0, len(counts))
for id := range counts {
agentIDs = append(agentIDs, id)
}
keyByID := make(map[uuid.UUID]string, len(agentIDs))
if agentStore != nil {
if agents, err := agentStore.GetByIDs(ctx, agentIDs); err != nil {
slog.Warn("providers.codex_pool_activity.resolve_agents", "error", err, "count", len(agentIDs))
} else {
for _, ag := range agents {
keyByID[ag.ID] = ag.AgentKey
}
}
}
result := make([]providerCodexPoolAgentCount, 0, len(counts))
for id, count := range counts {
result = append(result, providerCodexPoolAgentCount{
AgentID: id,
AgentKey: keyByID[id],
RequestCount: count,
})
}
slices.SortFunc(result, func(a, b providerCodexPoolAgentCount) int {
return b.RequestCount - a.RequestCount
})
if len(result) > 10 {
result = result[:10]
}
return result
}