From 74dc086a809185521f69c3ac2cd90ae7245053ce Mon Sep 17 00:00:00 2001 From: viettranx Date: Fri, 27 Mar 2026 10:19:16 +0700 Subject: [PATCH] fix(providers): post-merge fixes for Codex OAuth pools (#450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix Rules of Hooks violation in chatgpt-oauth-routing-section - add stale-while-revalidate with atomic dedup for RouteEligibility - move raw SQL from HTTP handler to TracingStore.ListCodexPoolSpans - persist round-robin state in Registry shared counter - extract duplicated frontend helpers to agent-display-utils - split oversized frontend files (964→214 lines max) - add GIN indexes for spans.metadata and sessions.metadata - fix tenant-aware provider lookup in handleQuota - separate empty-role vs error handling in resolveTenantHint - scope pool validation to chatgpt_oauth providers only - wrap buildEntries in useCallback for stable useMemo deps - document OAuth concurrent auth limitation and RoleAdmin breaking change --- cmd/gateway_http_handlers.go | 2 +- go.mod | 8 +- go.sum | 46 +- internal/http/agents.go | 4 +- internal/http/agents_codex_pool.go | 60 +- internal/http/agents_codex_pool_activity.go | 21 +- .../http/agents_codex_pool_activity_test.go | 5 +- internal/http/auth.go | 11 +- .../http/chatgpt_oauth_pool_validation.go | 4 + internal/http/oauth.go | 11 +- internal/http/oauth_quota.go | 3 +- internal/oauth/token.go | 27 +- internal/providers/chatgpt_oauth_router.go | 15 +- internal/providers/registry.go | 23 +- internal/store/pg/tracing_codex_pool.go | 69 ++ internal/store/tracing_store.go | 15 + internal/upgrade/version.go | 2 +- migrations/000030_jsonb_gin_indexes.down.sql | 2 + migrations/000030_jsonb_gin_indexes.up.sql | 14 + .../agent-detail/agent-codex-pool-page.tsx | 336 +++----- .../agent-detail/agent-display-utils.ts | 45 + .../chatgpt-oauth-quota-badges.tsx | 10 +- .../chatgpt-oauth-quota-strip.tsx | 11 +- .../codex-pool-activity-panel.tsx | 788 +----------------- .../agent-detail/codex-pool-entry-types.ts | 51 ++ .../agent-detail/codex-pool-member-card.tsx | 274 ++++++ .../agent-detail/codex-pool-page-header.tsx | 146 ++++ .../codex-pool-recent-requests-list.tsx | 86 ++ .../agent-detail/codex-pool-request-accent.ts | 102 +++ .../codex-pool-request-row-cards.tsx | 220 +++++ .../codex-pool-routing-draft-utils.ts | 47 ++ .../chatgpt-oauth-pool-sections.tsx | 260 ++++++ .../chatgpt-oauth-routing-section.tsx | 371 ++------- .../chatgpt-oauth-state-group.tsx | 51 ++ .../chatgpt-oauth-routing-summary-section.tsx | 10 +- 35 files changed, 1695 insertions(+), 1455 deletions(-) create mode 100644 internal/store/pg/tracing_codex_pool.go create mode 100644 migrations/000030_jsonb_gin_indexes.down.sql create mode 100644 migrations/000030_jsonb_gin_indexes.up.sql create mode 100644 ui/web/src/pages/agents/agent-detail/codex-pool-entry-types.ts create mode 100644 ui/web/src/pages/agents/agent-detail/codex-pool-member-card.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/codex-pool-page-header.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/codex-pool-recent-requests-list.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/codex-pool-request-accent.ts create mode 100644 ui/web/src/pages/agents/agent-detail/codex-pool-request-row-cards.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/codex-pool-routing-draft-utils.ts create mode 100644 ui/web/src/pages/agents/agent-detail/config-sections/chatgpt-oauth-pool-sections.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/config-sections/chatgpt-oauth-state-group.tsx diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index d7290ea0..babe1f22 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -25,7 +25,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir if providerReg != nil { summoner = httpapi.NewAgentSummoner(stores.Agents, providerReg, msgBus) } - agentsH = httpapi.NewAgentsHandler(stores.Agents, stores.Providers, providerReg, stores.DB, defaultWorkspace, msgBus, summoner, isOwner) + agentsH = httpapi.NewAgentsHandler(stores.Agents, stores.Providers, providerReg, stores.DB, stores.Tracing, defaultWorkspace, msgBus, summoner, isOwner) } if stores != nil && stores.Skills != nil { diff --git a/go.mod b/go.mod index ea4022e6..456ae5f0 100644 --- a/go.mod +++ b/go.mod @@ -14,17 +14,21 @@ require ( github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/jackc/pgx/v5 v5.6.0 github.com/mattn/go-runewidth v0.0.16 + github.com/mattn/go-shellwords v1.0.12 github.com/mymmrac/telego v1.6.0 github.com/redis/go-redis/v9 v9.18.0 github.com/slack-go/slack v0.19.0 github.com/spf13/cobra v1.10.2 github.com/titanous/json5 v1.0.0 + github.com/wailsapp/wails/v2 v2.11.0 + github.com/zalando/go-keyring v0.2.8 go.opentelemetry.io/otel v1.40.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 go.opentelemetry.io/otel/sdk v1.40.0 go.opentelemetry.io/otel/trace v1.40.0 golang.org/x/time v0.14.0 + modernc.org/sqlite v1.47.0 tailscale.com v1.94.2 ) @@ -88,7 +92,6 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-shellwords v1.0.12 // indirect github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect github.com/mdlayher/socket v0.5.0 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect @@ -115,12 +118,10 @@ require ( github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect - github.com/wailsapp/wails/v2 v2.11.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - github.com/zalando/go-keyring v0.2.8 // indirect go.uber.org/atomic v1.11.0 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect @@ -134,7 +135,6 @@ require ( modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.47.0 // indirect ) require ( diff --git a/go.sum b/go.sum index 314f5941..00c05f1d 100644 --- a/go.sum +++ b/go.sum @@ -188,8 +188,6 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= -github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= -github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -208,6 +206,8 @@ github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I= github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -217,6 +217,9 @@ github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc= github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o8jga4= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= @@ -264,6 +267,8 @@ github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaa github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= @@ -281,6 +286,8 @@ github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ github.com/mark3labs/mcp-go v0.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I= github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -376,6 +383,7 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -405,9 +413,8 @@ github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= -github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= -github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= github.com/tc-hib/winres v0.3.1 h1:CwRjEGrKdbi5CvZ4ID+iyVhgyfatxFoizjPhzez9Io4= +github.com/tc-hib/winres v0.3.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= github.com/titanous/json5 v1.0.0 h1:hJf8Su1d9NuI/ffpxgxQfxh/UiBFZX7bMPid0rIL/7s= github.com/titanous/json5 v1.0.0/go.mod h1:7JH1M8/LHKc6cyP5o5g3CSaRj+mBrIimTxzpvmckH8c= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= @@ -507,8 +514,8 @@ golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= @@ -523,11 +530,8 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -541,8 +545,8 @@ golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= @@ -571,14 +575,34 @@ honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0 h1:5SXjd4ET5dYijLa honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0/go.mod h1:EPDDhEZqVHhWuPI5zPAsjU0U7v9xNIWjoOVyZ5ZcniQ= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= tailscale.com v1.94.2 h1:H+0NYSG81K1RBXnh6FfWee9G1KEeX9pvYspPrVdIfII= diff --git a/internal/http/agents.go b/internal/http/agents.go index 9464c430..3d2d97fb 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -25,6 +25,7 @@ type AgentsHandler struct { providers store.ProviderStore providerReg *providers.Registry db *sql.DB + tracingStore store.TracingStore defaultWorkspace string // default workspace path template (e.g. "~/.goclaw/workspace") msgBus *bus.MessageBus // for cache invalidation events (nil = no events) summoner *AgentSummoner // LLM-based agent setup (nil = disabled) @@ -33,12 +34,13 @@ type AgentsHandler struct { // NewAgentsHandler creates a handler for agent management endpoints. // isOwner is a function that checks if a user ID is in GOCLAW_OWNER_IDS (nil = disabled). -func NewAgentsHandler(agents store.AgentStore, providers store.ProviderStore, providerReg *providers.Registry, db *sql.DB, defaultWorkspace string, msgBus *bus.MessageBus, summoner *AgentSummoner, isOwner func(string) bool) *AgentsHandler { +func NewAgentsHandler(agents store.AgentStore, providers store.ProviderStore, providerReg *providers.Registry, db *sql.DB, tracing store.TracingStore, defaultWorkspace string, msgBus *bus.MessageBus, summoner *AgentSummoner, isOwner func(string) bool) *AgentsHandler { return &AgentsHandler{ agents: agents, providers: providers, providerReg: providerReg, db: db, + tracingStore: tracing, defaultWorkspace: defaultWorkspace, msgBus: msgBus, summoner: summoner, diff --git a/internal/http/agents_codex_pool.go b/internal/http/agents_codex_pool.go index 73df7a37..ca2e46d4 100644 --- a/internal/http/agents_codex_pool.go +++ b/internal/http/agents_codex_pool.go @@ -2,7 +2,6 @@ package http import ( "context" - "encoding/json" "errors" "net/http" "slices" @@ -10,7 +9,6 @@ import ( "time" "github.com/google/uuid" - "github.com/lib/pq" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/providers" @@ -183,8 +181,8 @@ func agentRoutingExtraNames(routing *store.ChatGPTOAuthRoutingConfig) []string { func (h *AgentsHandler) handleCodexPoolActivity(w http.ResponseWriter, r *http.Request) { locale := store.LocaleFromContext(r.Context()) - if h.db == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "database unavailable")}) + if h.tracingStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "tracing store unavailable")}) return } @@ -222,57 +220,15 @@ func (h *AgentsHandler) handleCodexPoolActivity(w http.ResponseWriter, r *http.R return } - const query = ` -SELECT - sp.id, - sp.trace_id, - sp.start_time, - COALESCE(sp.duration_ms, 0), - sp.status, - COALESCE(sp.provider, ''), - COALESCE(sp.model, ''), - COALESCE(sp.metadata, '{}'::jsonb) -FROM spans sp -JOIN traces t ON t.id = sp.trace_id -WHERE t.agent_id = $1 - AND t.tenant_id = $2 - AND t.parent_trace_id IS NULL - AND sp.tenant_id = $2 - AND sp.span_type = 'llm_call' - AND ( - sp.provider = ANY($3) - OR COALESCE(sp.metadata->'chatgpt_oauth_routing'->>'selected_provider', '') = ANY($3) - OR COALESCE(sp.metadata->'chatgpt_oauth_routing'->>'serving_provider', '') = ANY($3) - ) -ORDER BY sp.start_time DESC -LIMIT $4` - - rows, err := h.db.QueryContext(r.Context(), query, agent.ID, agent.TenantID, pq.Array(poolProviders), statsLimit) + rawSpans, err := h.tracingStore.ListCodexPoolSpans(r.Context(), agent.ID, agent.TenantID, poolProviders, statsLimit) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } - defer rows.Close() - spans := make([]codexPoolSpanActivity, 0, statsLimit) - for rows.Next() { - var item codexPoolSpanActivity - var metadata json.RawMessage - if err := rows.Scan( - &item.SpanID, - &item.TraceID, - &item.StartedAt, - &item.DurationMS, - &item.Status, - &item.Provider, - &item.Model, - &metadata, - ); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - item.Metadata = metadata - if evidence := providers.ExtractChatGPTOAuthRoutingEvidence(metadata); evidence.HasData() { + spans := make([]store.CodexPoolSpan, 0, len(rawSpans)) + for _, item := range rawSpans { + if evidence := providers.ExtractChatGPTOAuthRoutingEvidence(item.Metadata); evidence.HasData() { if !providerInPool(poolProviders, evidence.SelectedProvider) && !providerInPool(poolProviders, evidence.ServingProvider) { continue } @@ -281,10 +237,6 @@ LIMIT $4` } spans = append(spans, item) } - if err := rows.Err(); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } providerCounts, recent := buildCodexPoolActivity(poolProviders, spans) if len(recent) > limit { diff --git a/internal/http/agents_codex_pool_activity.go b/internal/http/agents_codex_pool_activity.go index aca3d47d..f8282101 100644 --- a/internal/http/agents_codex_pool_activity.go +++ b/internal/http/agents_codex_pool_activity.go @@ -1,32 +1,19 @@ package http import ( - "encoding/json" "math" "slices" "time" - "github.com/google/uuid" - "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" ) -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 { +func buildCodexPoolActivity(poolProviders []string, spans []store.CodexPoolSpan) ([]codexPoolProviderCount, []codexPoolRecentRequest) { + sortedSpans := append([]store.CodexPoolSpan(nil), spans...) + slices.SortFunc(sortedSpans, func(a, b store.CodexPoolSpan) int { return b.StartedAt.Compare(a.StartedAt) }) diff --git a/internal/http/agents_codex_pool_activity_test.go b/internal/http/agents_codex_pool_activity_test.go index f7af36b5..b8adb33b 100644 --- a/internal/http/agents_codex_pool_activity_test.go +++ b/internal/http/agents_codex_pool_activity_test.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" ) func TestBuildCodexPoolActivitySeparatesDirectSelectionAndFailover(t *testing.T) { @@ -35,7 +36,7 @@ func TestBuildCodexPoolActivitySeparatesDirectSelectionAndFailover(t *testing.T) AttemptCount: 2, }) - providerCounts, recent := buildCodexPoolActivity([]string{"pool-a", "pool-b", "pool-c"}, []codexPoolSpanActivity{ + providerCounts, recent := buildCodexPoolActivity([]string{"pool-a", "pool-b", "pool-c"}, []store.CodexPoolSpan{ { SpanID: uuid.New(), TraceID: uuid.New(), @@ -114,7 +115,7 @@ func TestBuildCodexPoolActivityTracksTerminalFailuresAcrossAttempts(t *testing.T AttemptCount: 2, }) - providerCounts, recent := buildCodexPoolActivity([]string{"pool-a", "pool-b"}, []codexPoolSpanActivity{ + providerCounts, recent := buildCodexPoolActivity([]string{"pool-a", "pool-b"}, []store.CodexPoolSpan{ { SpanID: uuid.New(), TraceID: uuid.New(), diff --git a/internal/http/auth.go b/internal/http/auth.go index f79cd57a..14f89064 100644 --- a/internal/http/auth.go +++ b/internal/http/auth.go @@ -278,7 +278,7 @@ func resolveTenantHint(ctx context.Context, hint, userID string) (uuid.UUID, boo return uuid.Nil, false } role, err := pkgTenantCache.store.GetUserRole(ctx, tid, userID) - if err != nil || role == "" { + if err != nil { slog.Warn("security.http_tenant_access_revoked", "hint", hint, "user", userID, @@ -288,6 +288,15 @@ func resolveTenantHint(ctx context.Context, hint, userID string) (uuid.UUID, boo ) return uuid.Nil, false } + if role == "" { + slog.Warn("security.http_tenant_no_membership", + "hint", hint, + "user", userID, + "tenant_id", tid, + "code", protocol.ErrTenantAccessRevoked, + ) + return uuid.Nil, false + } return tid, true } diff --git a/internal/http/chatgpt_oauth_pool_validation.go b/internal/http/chatgpt_oauth_pool_validation.go index a578efff..c1d08fb4 100644 --- a/internal/http/chatgpt_oauth_pool_validation.go +++ b/internal/http/chatgpt_oauth_pool_validation.go @@ -103,9 +103,13 @@ func validateChatGPTOAuthProviderCandidate( return err } + // Only consider chatgpt_oauth providers for pool graph validation. finalProviders := make([]store.LLMProviderData, 0, len(existingProviders)+1) replaced := false for _, provider := range existingProviders { + if provider.ProviderType != store.ProviderChatGPTOAuth { + continue + } if currentID != uuid.Nil && provider.ID == currentID { finalProviders = append(finalProviders, *candidate) replaced = true diff --git a/internal/http/oauth.go b/internal/http/oauth.go index bdafea2d..21ab2099 100644 --- a/internal/http/oauth.go +++ b/internal/http/oauth.go @@ -20,6 +20,11 @@ import ( ) // OAuthHandler handles OAuth-related HTTP endpoints for web UI. +// +// Limitation: only one OAuth flow can run at a time because the local callback +// server binds to a fixed port (1455). A second flow attempt within the 6-minute +// window returns HTTP 409. This affects all providers, not just the one being +// authenticated. type OAuthHandler struct { provStore store.ProviderStore secretStore store.ConfigSecretsStore @@ -28,7 +33,7 @@ type OAuthHandler struct { mu sync.Mutex pending map[string]*pendingOAuthFlow - activeFlowKey string + activeFlowKey string // only one active flow at a time (fixed callback port) } type pendingOAuthFlow struct { @@ -68,6 +73,10 @@ func (h *OAuthHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /v1/auth/openai/logout", h.auth(h.handleLogout)) } +// auth requires RoleAdmin for all OAuth endpoints. +// Breaking change from pre-#450: previously used requireAuth("", next) which +// allowed any authenticated user including operators. Now only admins can +// manage OAuth providers. func (h *OAuthHandler) auth(next http.HandlerFunc) http.HandlerFunc { return requireAuth(permissions.RoleAdmin, next) } diff --git a/internal/http/oauth_quota.go b/internal/http/oauth_quota.go index 5420ed86..4ae23449 100644 --- a/internal/http/oauth_quota.go +++ b/internal/http/oauth_quota.go @@ -16,7 +16,8 @@ func (h *OAuthHandler) handleQuota(w http.ResponseWriter, r *http.Request) { return } - provider, err := h.provStore.GetProviderByName(r.Context(), providerName) + tenantID := store.TenantIDFromContext(r.Context()) + provider, err := lookupProviderByNameWithMasterFallback(r.Context(), h.provStore, tenantID, providerName) if err != nil { writeJSON(w, http.StatusNotFound, map[string]string{ "error": err.Error(), diff --git a/internal/oauth/token.go b/internal/oauth/token.go index bae6b668..3506265c 100644 --- a/internal/oauth/token.go +++ b/internal/oauth/token.go @@ -6,6 +6,7 @@ import ( "log/slog" "strings" "sync" + "sync/atomic" "time" "github.com/google/uuid" @@ -72,6 +73,7 @@ type DBTokenSource struct { cachedRouteEligibility providers.RouteEligibility cachedRouteEligibilityAt time.Time + refreshingEligibility atomic.Bool } // NewDBTokenSource creates a DB-backed token source. @@ -124,10 +126,33 @@ func (ts *DBTokenSource) RouteEligibility(ctx context.Context) providers.RouteEl cachedAt := ts.cachedRouteEligibilityAt ts.mu.Unlock() - if cached.Class != "" && time.Since(cachedAt) < routeEligibilityTTL { + if cached.Class != "" { + if time.Since(cachedAt) < routeEligibilityTTL { + return cached + } + // Stale-while-revalidate: return stale cache immediately, + // refresh asynchronously so the hot path is never blocked by + // an outbound HTTP quota check. Dedup concurrent refreshes. + if ts.refreshingEligibility.CompareAndSwap(false, true) { + go func() { + defer ts.refreshingEligibility.Store(false) + ts.refreshRouteEligibility() + }() + } return cached } + // First call ever — synchronous fetch so we have a baseline. + return ts.fetchAndCacheEligibility(ctx) +} + +// refreshRouteEligibility fetches quota eligibility in the background and updates the cache. +func (ts *DBTokenSource) refreshRouteEligibility() { + ctx := store.WithTenantID(context.Background(), ts.tenantID) + ts.fetchAndCacheEligibility(ctx) +} + +func (ts *DBTokenSource) fetchAndCacheEligibility(ctx context.Context) providers.RouteEligibility { if ctx == nil { ctx = context.Background() } diff --git a/internal/providers/chatgpt_oauth_router.go b/internal/providers/chatgpt_oauth_router.go index 39c18c39..e1aa5dcf 100644 --- a/internal/providers/chatgpt_oauth_router.go +++ b/internal/providers/chatgpt_oauth_router.go @@ -5,7 +5,6 @@ import ( "fmt" "log/slog" "strings" - "sync" "github.com/google/uuid" ) @@ -22,9 +21,6 @@ type ChatGPTOAuthRouter struct { defaultProviderName string extraProviderNames []string strategy string - - mu sync.Mutex - next int } type chatGPTOAuthRouteCandidate struct { @@ -166,15 +162,8 @@ func (p *ChatGPTOAuthRouter) orderedProviders(ctx context.Context, advance bool) return ordered, nil } - start := 0 - p.mu.Lock() - if len(active) > 0 { - start = p.next % len(active) - if advance { - p.next = (p.next + 1) % len(active) - } - } - p.mu.Unlock() + rrKey := compoundKey(p.tenantID, p.defaultProviderName) + start := p.registry.RoundRobinNext(rrKey, len(active), advance) ordered := make([]Provider, 0, len(active)+len(fallback)) ordered = append(ordered, active[start:]...) diff --git a/internal/providers/registry.go b/internal/providers/registry.go index a141d4b2..67be2147 100644 --- a/internal/providers/registry.go +++ b/internal/providers/registry.go @@ -20,17 +20,36 @@ type Registry struct { providers map[string]Provider mu sync.RWMutex tenantFromCtx func(context.Context) uuid.UUID // injected to avoid circular import with store + + // roundRobinCounters stores shared round-robin state keyed by "tenantID/providerName" + // so that ChatGPTOAuthRouter instances (created per-request) share rotation state. + roundRobinMu sync.Mutex + roundRobinCounters map[string]int } // NewRegistry creates a provider registry. // tenantFromCtx extracts tenant UUID from context (pass store.TenantIDFromContext). func NewRegistry(tenantFromCtx func(context.Context) uuid.UUID) *Registry { return &Registry{ - providers: make(map[string]Provider), - tenantFromCtx: tenantFromCtx, + providers: make(map[string]Provider), + tenantFromCtx: tenantFromCtx, + roundRobinCounters: make(map[string]int), } } +// RoundRobinNext returns the current round-robin index for the given key and +// optionally advances it. Used by ChatGPTOAuthRouter to persist rotation state +// across per-request router instances. +func (r *Registry) RoundRobinNext(key string, poolSize int, advance bool) int { + r.roundRobinMu.Lock() + defer r.roundRobinMu.Unlock() + idx := r.roundRobinCounters[key] % poolSize + if advance { + r.roundRobinCounters[key] = (idx + 1) % poolSize + } + return idx +} + // compoundKey returns "tenantID/name" for registry lookup. func compoundKey(tenantID uuid.UUID, name string) string { return tenantID.String() + "/" + name diff --git a/internal/store/pg/tracing_codex_pool.go b/internal/store/pg/tracing_codex_pool.go new file mode 100644 index 00000000..8dd942f4 --- /dev/null +++ b/internal/store/pg/tracing_codex_pool.go @@ -0,0 +1,69 @@ +package pg + +import ( + "context" + "encoding/json" + + "github.com/google/uuid" + "github.com/lib/pq" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const listCodexPoolSpansQuery = ` +SELECT + sp.id, + sp.trace_id, + sp.start_time, + COALESCE(sp.duration_ms, 0), + sp.status, + COALESCE(sp.provider, ''), + COALESCE(sp.model, ''), + COALESCE(sp.metadata, '{}'::jsonb) +FROM spans sp +JOIN traces t ON t.id = sp.trace_id +WHERE t.agent_id = $1 + AND t.tenant_id = $2 + AND t.parent_trace_id IS NULL + AND sp.tenant_id = $2 + AND sp.span_type = 'llm_call' + AND ( + sp.provider = ANY($3) + OR COALESCE(sp.metadata->'chatgpt_oauth_routing'->>'selected_provider', '') = ANY($3) + OR COALESCE(sp.metadata->'chatgpt_oauth_routing'->>'serving_provider', '') = ANY($3) + ) +ORDER BY sp.start_time DESC +LIMIT $4` + +// ListCodexPoolSpans returns recent LLM call spans for agents using Codex OAuth pool providers. +func (s *PGTracingStore) ListCodexPoolSpans(ctx context.Context, agentID, tenantID uuid.UUID, poolProviders []string, limit int) ([]store.CodexPoolSpan, error) { + rows, err := s.db.QueryContext(ctx, listCodexPoolSpansQuery, agentID, tenantID, pq.Array(poolProviders), limit) + if err != nil { + return nil, err + } + defer rows.Close() + + spans := make([]store.CodexPoolSpan, 0, limit) + for rows.Next() { + var item store.CodexPoolSpan + var metadata json.RawMessage + if err := rows.Scan( + &item.SpanID, + &item.TraceID, + &item.StartedAt, + &item.DurationMS, + &item.Status, + &item.Provider, + &item.Model, + &metadata, + ); err != nil { + return nil, err + } + item.Metadata = metadata + spans = append(spans, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + return spans, nil +} diff --git a/internal/store/tracing_store.go b/internal/store/tracing_store.go index 97e7e221..df233cb2 100644 --- a/internal/store/tracing_store.go +++ b/internal/store/tracing_store.go @@ -124,6 +124,18 @@ type CostSummaryRow struct { TraceCount int `json:"trace_count"` } +// CodexPoolSpan holds the fields from a single LLM span for Codex pool activity analysis. +type CodexPoolSpan struct { + SpanID uuid.UUID + TraceID uuid.UUID + StartedAt time.Time + DurationMS int + Status string + Provider string + Model string + Metadata json.RawMessage +} + // TracingStore manages LLM traces and spans. type TracingStore interface { CreateTrace(ctx context.Context, trace *TraceData) error @@ -147,4 +159,7 @@ type TracingStore interface { // Maintenance DeleteTracesOlderThan(ctx context.Context, cutoff time.Time) (int64, error) + + // ListCodexPoolSpans returns recent LLM call spans for agents using Codex OAuth pool providers. + ListCodexPoolSpans(ctx context.Context, agentID, tenantID uuid.UUID, poolProviders []string, limit int) ([]CodexPoolSpan, error) } diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index 16665209..ff384a57 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 29 +const RequiredSchemaVersion uint = 30 diff --git a/migrations/000030_jsonb_gin_indexes.down.sql b/migrations/000030_jsonb_gin_indexes.down.sql new file mode 100644 index 00000000..8094e027 --- /dev/null +++ b/migrations/000030_jsonb_gin_indexes.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_spans_metadata_gin; +DROP INDEX IF EXISTS idx_sessions_metadata_gin; diff --git a/migrations/000030_jsonb_gin_indexes.up.sql b/migrations/000030_jsonb_gin_indexes.up.sql new file mode 100644 index 00000000..b0d4911f --- /dev/null +++ b/migrations/000030_jsonb_gin_indexes.up.sql @@ -0,0 +1,14 @@ +-- GIN indexes for JSONB columns that are actively queried with -> / ->> operators. +-- Audit found 0 existing GIN indexes on 30+ JSONB columns. + +-- spans.metadata: used for cache token aggregation (tracing.go, snapshot_worker.go) +-- and chatgpt_oauth_routing evidence queries (agents_codex_pool_activity.go). +-- Partial index on span_type = 'llm_call' to reduce INSERT overhead on high-volume table. +CREATE INDEX IF NOT EXISTS idx_spans_metadata_gin + ON spans USING GIN (metadata) + WHERE span_type = 'llm_call'; + +-- sessions.metadata: used for chat_title filtering in pending message lookups +-- (pending_message_store.go) and heartbeat queries (heartbeat.go). +CREATE INDEX IF NOT EXISTS idx_sessions_metadata_gin + ON sessions USING GIN (metadata); diff --git a/ui/web/src/pages/agents/agent-detail/agent-codex-pool-page.tsx b/ui/web/src/pages/agents/agent-detail/agent-codex-pool-page.tsx index 7772139a..b8828024 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-codex-pool-page.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-codex-pool-page.tsx @@ -1,13 +1,7 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { Link, useNavigate, useParams } from "react-router"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate, useParams } from "react-router"; import { useTranslation } from "react-i18next"; -import { AlertTriangle, ArrowLeft } from "lucide-react"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { DetailPageSkeleton } from "@/components/shared/loading-skeleton"; -import { ROUTES } from "@/lib/constants"; -import { cn } from "@/lib/utils"; import { useProviders } from "@/pages/providers/hooks/use-providers"; import { useChatGPTOAuthProviderStatuses, @@ -15,10 +9,7 @@ import { } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-statuses"; import { useChatGPTOAuthProviderQuotas } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-quotas"; import { useAuthStore } from "@/stores/use-auth-store"; -import type { - ChatGPTOAuthRoutingConfig, - EffectiveChatGPTOAuthRoutingStrategy, -} from "@/types/agent"; +import type { ChatGPTOAuthRoutingConfig } from "@/types/agent"; import { getChatGPTOAuthProviderRouting, normalizeChatGPTOAuthStrategy, @@ -30,7 +21,6 @@ import { normalizeChatGPTOAuthRouting, normalizeChatGPTOAuthRoutingInput, resolveEffectiveChatGPTOAuthRouting, - type NormalizedChatGPTOAuthRouting, } from "./agent-display-utils"; import { getRouteReadiness } from "./chatgpt-oauth-quota-utils"; import { ChatGPTOAuthRoutingSection } from "./config-sections"; @@ -38,6 +28,11 @@ import { CodexPoolActivityPanel, type CodexPoolEntry, } from "./codex-pool-activity-panel"; +import { CodexPoolPageHeader } from "./codex-pool-page-header"; +import { + buildDraftRouting, + routingDraftSignature, +} from "./codex-pool-routing-draft-utils"; import { useCodexPoolActivity } from "./hooks/use-codex-pool-activity"; function providerStatus( @@ -51,56 +46,6 @@ function providerStatus( ); } -function strategyLabelKey( - strategy: EffectiveChatGPTOAuthRoutingStrategy, -): string { - if (strategy === "round_robin") return "chatgptOAuthRouting.strategy.roundRobin"; - if (strategy === "priority_order") return "chatgptOAuthRouting.strategy.priorityOrder"; - return "chatgptOAuthRouting.strategy.primaryFirst"; -} - -function buildDraftRouting( - savedRouting: NormalizedChatGPTOAuthRouting, - hasProviderDefaults: boolean, -): ChatGPTOAuthRoutingConfig { - if (savedRouting.isExplicit) { - return { - override_mode: savedRouting.overrideMode, - strategy: savedRouting.strategy, - extra_provider_names: savedRouting.extraProviderNames, - }; - } - - if (hasProviderDefaults) { - return { - override_mode: "inherit", - strategy: "primary_first", - extra_provider_names: [], - }; - } - - return { - override_mode: "custom", - strategy: "primary_first", - extra_provider_names: [], - }; -} - -function routingDraftSignature( - routing: ChatGPTOAuthRoutingConfig, - hasProviderDefaults: boolean, -): string { - const normalized = normalizeChatGPTOAuthRoutingInput(routing); - if (normalized.overrideMode === "inherit" && hasProviderDefaults) { - return JSON.stringify({ override_mode: "inherit" }); - } - return JSON.stringify({ - override_mode: "custom", - strategy: normalized.strategy, - extra_provider_names: normalized.extraProviderNames, - }); -} - export function AgentCodexPoolPage() { const { id = "" } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -110,14 +55,16 @@ export function AgentCodexPoolPage() { const { agent, loading, updateAgent } = useAgentDetail(id); const { providers, loading: providersLoading } = useProviders(); const { statuses } = useChatGPTOAuthProviderStatuses(providers); + const providerByName = useMemo( - () => new Map(providers.map((provider) => [provider.name, provider])), + () => new Map(providers.map((p) => [p.name, p])), [providers], ); const statusByName = useMemo( - () => new Map(statuses.map((status) => [status.provider.name, status])), + () => new Map(statuses.map((s) => [s.provider.name, s])), [statuses], ); + const currentProvider = agent ? providerByName.get(agent.provider) : undefined; const providerDefaults = useMemo( () => getChatGPTOAuthProviderRouting(currentProvider?.settings), @@ -147,6 +94,7 @@ export function AgentCodexPoolPage() { () => routingDraftSignature(savedDraftRouting, Boolean(providerDefaults)), [providerDefaults, savedDraftRouting], ); + const [routing, setRouting] = useState(savedDraftRouting); const [saving, setSaving] = useState(false); const syncedAgentIDRef = useRef(agent?.id ?? ""); @@ -165,12 +113,8 @@ export function AgentCodexPoolPage() { setRouting(savedDraftRouting); return; } - const previousSavedSignature = savedDraftSignatureRef.current; - if (savedDraftSignature === previousSavedSignature) { - return; - } - + if (savedDraftSignature === previousSavedSignature) return; if (draftSignature === previousSavedSignature) { setRouting(savedDraftRouting); } @@ -190,6 +134,7 @@ export function AgentCodexPoolPage() { ), [agent?.provider, currentProvider?.settings, draftRouting], ); + const quotaProviderNames = useMemo( () => Array.from( @@ -198,14 +143,19 @@ export function AgentCodexPoolPage() { ...savedEffectiveRouting.poolProviderNames, ...draftEffectiveRouting.poolProviderNames, ].filter( - (providerName): providerName is string => - Boolean(providerName) && - providerByName.get(providerName)?.provider_type === "chatgpt_oauth", + (name): name is string => + Boolean(name) && + providerByName.get(name)?.provider_type === "chatgpt_oauth", ), ), ), - [draftEffectiveRouting.poolProviderNames, providerByName, savedEffectiveRouting.poolProviderNames], + [ + draftEffectiveRouting.poolProviderNames, + providerByName, + savedEffectiveRouting.poolProviderNames, + ], ); + const { quotaByName, isLoading: quotasLoading, @@ -221,54 +171,50 @@ export function AgentCodexPoolPage() { refetch: refreshActivity, } = useCodexPoolActivity(agent?.id ?? id, 8, Boolean(agent && isEligible)); - const buildEntries = ( - poolNames: string[], - ): CodexPoolEntry[] => { - if (!agent) return []; - const countsByName = new Map( - activity.provider_counts.map((item) => [item.provider_name, item]), - ); - - return poolNames.map((providerName) => { - const provider = providerByName.get(providerName); - const count = countsByName.get(providerName); - return { - name: providerName, - label: provider?.display_name || providerName, - availability: providerStatus( - providerName, - statusByName, - provider?.enabled, - ), - role: providerName === agent.provider ? "preferred" : "extra", - requestCount: count?.request_count ?? 0, - directSelectionCount: - count?.direct_selection_count ?? count?.request_count ?? 0, - failoverServeCount: count?.failover_serve_count ?? 0, - successCount: count?.success_count ?? 0, - failureCount: count?.failure_count ?? 0, - consecutiveFailures: count?.consecutive_failures ?? 0, - successRate: count?.success_rate ?? 0, - healthScore: count?.health_score ?? 0, - healthState: count?.health_state ?? "idle", - lastSelectedAt: count?.last_selected_at, - lastFailoverAt: count?.last_failover_at, - lastUsedAt: count?.last_used_at, - lastSuccessAt: count?.last_success_at, - lastFailureAt: count?.last_failure_at, - providerHref: provider?.id ? `/providers/${provider.id}` : undefined, - quota: quotaByName.get(providerName), - }; - }); - }; + const buildEntries = useCallback( + (poolNames: string[]): CodexPoolEntry[] => { + if (!agent) return []; + const countsByName = new Map( + activity.provider_counts.map((item) => [item.provider_name, item]), + ); + return poolNames.map((providerName) => { + const provider = providerByName.get(providerName); + const count = countsByName.get(providerName); + return { + name: providerName, + label: provider?.display_name || providerName, + availability: providerStatus(providerName, statusByName, provider?.enabled), + role: providerName === agent.provider ? "preferred" : "extra", + requestCount: count?.request_count ?? 0, + directSelectionCount: + count?.direct_selection_count ?? count?.request_count ?? 0, + failoverServeCount: count?.failover_serve_count ?? 0, + successCount: count?.success_count ?? 0, + failureCount: count?.failure_count ?? 0, + consecutiveFailures: count?.consecutive_failures ?? 0, + successRate: count?.success_rate ?? 0, + healthScore: count?.health_score ?? 0, + healthState: count?.health_state ?? "idle", + lastSelectedAt: count?.last_selected_at, + lastFailoverAt: count?.last_failover_at, + lastUsedAt: count?.last_used_at, + lastSuccessAt: count?.last_success_at, + lastFailureAt: count?.last_failure_at, + providerHref: provider?.id ? `/providers/${provider.id}` : undefined, + quota: quotaByName.get(providerName), + }; + }); + }, + [activity.provider_counts, agent, providerByName, quotaByName, statusByName], + ); const liveEntries = useMemo( () => buildEntries(savedEffectiveRouting.poolProviderNames), - [activity.provider_counts, agent, providerByName, quotaByName, savedEffectiveRouting.poolProviderNames, statusByName], + [buildEntries, savedEffectiveRouting.poolProviderNames], ); const draftEntries = useMemo( () => buildEntries(draftEffectiveRouting.poolProviderNames), - [activity.provider_counts, agent, draftEffectiveRouting.poolProviderNames, providerByName, quotaByName, statusByName], + [buildEntries, draftEffectiveRouting.poolProviderNames], ); const routeEntries = useMemo( @@ -279,24 +225,14 @@ export function AgentCodexPoolPage() { })), [liveEntries], ); - const blockedEntries = routeEntries.filter( - (entry) => entry.routeReadiness === "blocked", - ); - const readyEntries = liveEntries.filter( - (entry) => entry.availability === "ready", - ); - const runtimeHealthyEntries = liveEntries.filter( - (entry) => entry.healthState === "healthy", - ); - const runtimeDegradedEntries = liveEntries.filter( - (entry) => entry.healthState === "degraded", - ); - const runtimeCriticalEntries = liveEntries.filter( - (entry) => entry.healthState === "critical", - ); + const readyEntries = liveEntries.filter((e) => e.availability === "ready"); + const runtimeHealthyEntries = liveEntries.filter((e) => e.healthState === "healthy"); + const runtimeDegradedEntries = liveEntries.filter((e) => e.healthState === "degraded"); + const runtimeCriticalEntries = liveEntries.filter((e) => e.healthState === "critical"); + const blockedEntries = routeEntries.filter((e) => e.routeReadiness === "blocked"); + const observedRoutableCount = routeEntries.filter( - (entry) => - entry.routeReadiness !== "blocked" && entry.directSelectionCount > 0, + (e) => e.routeReadiness !== "blocked" && e.directSelectionCount > 0, ).length; const switchCount = activity.recent_requests .slice(1) @@ -310,6 +246,7 @@ export function AgentCodexPoolPage() { : 0), 0, ); + const savedStrategy = normalizeChatGPTOAuthStrategy( activity.strategy || savedEffectiveRouting.strategy, ); @@ -321,8 +258,13 @@ export function AgentCodexPoolPage() { switchCount >= Math.max(1, readyEntries.length - 1) && blockedEntries.length === 0 && runtimeCriticalEntries.length === 0; - const recentRequestCount = activity.stats_sample_size ?? 0; - const title = agent ? agentDisplayName(agent, t("card.unnamedAgent")) : ""; + + const summaryTone = + savedStrategy !== "round_robin" + ? "manual" + : roundRobinVerified + ? "healthy" + : "warning"; if (loading || providersLoading || !agent) { return ; @@ -346,116 +288,24 @@ export function AgentCodexPoolPage() { } }; - const summaryTone = - savedStrategy !== "round_robin" - ? "manual" - : roundRobinVerified - ? "healthy" - : "warning"; - return (
-
-
-
-
- + navigate(`/agents/${agent.id}`)} + /> -

- {t("chatgptOAuthRouting.pageTitle")} -

-
-

- {t("chatgptOAuthRouting.pageDescription", { name: title })} -

- -
- - {t(`chatgptOAuthRouting.verdict.${summaryTone}.title`)} - - - {t(strategyLabelKey(savedStrategy))} - - - {savedEffectiveRouting.overrideMode === "inherit" - ? t("chatgptOAuthRouting.mode.inherit") - : t("chatgptOAuthRouting.mode.custom")} - - - {recentRequestCount > 0 - ? t("chatgptOAuthRouting.sampleBadge", { - count: recentRequestCount, - }) - : t("chatgptOAuthRouting.noSampleBadge")} - - - {t("chatgptOAuthRouting.healthState.healthy")}{" "} - {runtimeHealthyEntries.length} - - {runtimeDegradedEntries.length > 0 ? ( - - {t("chatgptOAuthRouting.healthState.degraded")}{" "} - {runtimeDegradedEntries.length} - - ) : null} - {runtimeCriticalEntries.length > 0 ? ( - - {t("chatgptOAuthRouting.healthState.critical")}{" "} - {runtimeCriticalEntries.length} - - ) : null} - {isDirty ? ( - - {t("chatgptOAuthRouting.draftBadge")} - - ) : null} -
-
- - {canManageProviders ? ( - - ) : null} -
-
- - {!isEligible ? ( - - - - {t("chatgptOAuthRouting.pageUnsupportedTitle")} - - - {t("chatgptOAuthRouting.pageUnsupportedDescription")} - - - ) : ( + {isEligible ? (
- )} + ) : null}
); } diff --git a/ui/web/src/pages/agents/agent-detail/agent-display-utils.ts b/ui/web/src/pages/agents/agent-detail/agent-display-utils.ts index 981a0735..bfbd0e43 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-display-utils.ts +++ b/ui/web/src/pages/agents/agent-detail/agent-display-utils.ts @@ -8,6 +8,10 @@ import { getChatGPTOAuthProviderRouting, normalizeChatGPTOAuthStrategy, } from "@/types/provider"; +import type { + ChatGPTOAuthQuotaFailureKind, + ChatGPTOAuthRouteReadiness, +} from "./chatgpt-oauth-quota-utils"; /** Matches a standard UUID v4 string. */ export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -161,6 +165,47 @@ export function resolveEffectiveChatGPTOAuthRouting( }; } +/** Maps strategy value to its i18n label key. */ +export function strategyLabelKey( + strategy: EffectiveChatGPTOAuthRoutingStrategy, +): string { + if (strategy === "round_robin") return "chatgptOAuthRouting.strategy.roundRobin"; + if (strategy === "priority_order") return "chatgptOAuthRouting.strategy.priorityOrder"; + return "chatgptOAuthRouting.strategy.primaryFirst"; +} + +/** Maps route readiness state to badge variant. */ +export function routeBadgeVariant( + readiness: ChatGPTOAuthRouteReadiness, +): "success" | "warning" | "outline" | "destructive" { + if (readiness === "healthy") return "success"; + if (readiness === "fallback") return "warning"; + if (readiness === "checking") return "outline"; + return "destructive"; +} + +/** Maps route readiness state to its i18n label key. */ +export function routeLabelKey(readiness: ChatGPTOAuthRouteReadiness): string { + if (readiness === "healthy") return "chatgptOAuthRouting.routerActiveTitle"; + if (readiness === "fallback") return "chatgptOAuthRouting.fallbackTitle"; + if (readiness === "checking") return "chatgptOAuthRouting.checkingTitle"; + return "chatgptOAuthRouting.blockedNowTitle"; +} + +/** Maps quota failure kind to badge variant. */ +export const failureVariantByKind: Record< + ChatGPTOAuthQuotaFailureKind, + "destructive" | "warning" | "outline" +> = { + billing: "destructive", + exhausted: "destructive", + reauth: "warning", + forbidden: "destructive", + needs_setup: "warning", + retry_later: "outline", + unavailable: "outline", +}; + export function buildAgentOtherConfigWithChatGPTOAuthRouting( agent: AgentData, routing: ChatGPTOAuthRoutingConfig, diff --git a/ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-badges.tsx b/ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-badges.tsx index 98642a8c..ee36564f 100644 --- a/ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-badges.tsx +++ b/ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-badges.tsx @@ -9,6 +9,7 @@ import { import { formatDate, formatRelativeTime } from "@/lib/format"; import { cn } from "@/lib/utils"; import type { ChatGPTOAuthProviderQuota } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-quotas"; +import { failureVariantByKind } from "./agent-display-utils"; import { getQuotaBadgeVariant, getQuotaFailureKind, @@ -24,15 +25,6 @@ interface ChatGPTOAuthQuotaBadgesProps { className?: string; } -const failureVariantByKind = { - billing: "destructive", - exhausted: "destructive", - reauth: "warning", - forbidden: "destructive", - needs_setup: "warning", - retry_later: "outline", - unavailable: "outline", -} as const; export function ChatGPTOAuthQuotaBadges({ quota, diff --git a/ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-strip.tsx b/ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-strip.tsx index 265cad84..37c99574 100644 --- a/ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-strip.tsx +++ b/ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-strip.tsx @@ -9,6 +9,7 @@ import { import { formatDate, formatRelativeTime } from "@/lib/format"; import { cn } from "@/lib/utils"; import type { ChatGPTOAuthProviderQuota } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-quotas"; +import { failureVariantByKind } from "./agent-display-utils"; import { getQuotaBadgeVariant, getQuotaFailureKind, @@ -28,16 +29,6 @@ interface ChatGPTOAuthQuotaStripProps { showSignalBadges?: boolean; } -const failureVariantByKind = { - billing: "destructive", - exhausted: "destructive", - reauth: "warning", - forbidden: "destructive", - needs_setup: "warning", - retry_later: "outline", - unavailable: "outline", -} as const; - function quotaBarClass(remaining: number): string { if (remaining <= 20) return "bg-destructive"; if (remaining <= 50) return "bg-amber-500"; diff --git a/ui/web/src/pages/agents/agent-detail/codex-pool-activity-panel.tsx b/ui/web/src/pages/agents/agent-detail/codex-pool-activity-panel.tsx index c776c085..f742cb9b 100644 --- a/ui/web/src/pages/agents/agent-detail/codex-pool-activity-panel.tsx +++ b/ui/web/src/pages/agents/agent-detail/codex-pool-activity-panel.tsx @@ -1,133 +1,18 @@ import { useMemo } from "react"; -import { Link } from "react-router"; import { useTranslation } from "react-i18next"; -import { ArrowUpRight, RefreshCw, Route } from "lucide-react"; +import { RefreshCw, Route } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { EmptyState } from "@/components/shared/empty-state"; -import { formatDuration, formatRelativeTime } from "@/lib/format"; import { cn } from "@/lib/utils"; -import type { ChatGPTOAuthAvailability } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-statuses"; -import type { ChatGPTOAuthProviderQuota } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-quotas"; -import type { EffectiveChatGPTOAuthRoutingStrategy } from "@/types/agent"; -import { ChatGPTOAuthQuotaStrip } from "./chatgpt-oauth-quota-strip"; -import { - getQuotaFailureKind, - getRouteReadiness, -} from "./chatgpt-oauth-quota-utils"; -import type { CodexPoolRecentRequest } from "./hooks/use-codex-pool-activity"; +import { strategyLabelKey } from "./agent-display-utils"; +import { getRouteReadiness } from "./chatgpt-oauth-quota-utils"; +import { CodexPoolMemberCard } from "./codex-pool-member-card"; +import { CodexPoolRecentRequestsList } from "./codex-pool-recent-requests-list"; -export interface CodexPoolEntry { - name: string; - label: string; - availability: ChatGPTOAuthAvailability; - role: "preferred" | "extra"; - requestCount: number; - directSelectionCount: number; - failoverServeCount: number; - successCount: number; - failureCount: number; - consecutiveFailures: number; - successRate: number; - healthScore: number; - healthState: "healthy" | "degraded" | "critical" | "idle"; - lastSelectedAt?: string; - lastFailoverAt?: string; - lastUsedAt?: string; - lastSuccessAt?: string; - lastFailureAt?: string; - providerHref?: string; - quota?: ChatGPTOAuthProviderQuota | null; -} - -interface CodexPoolActivityPanelProps { - entries: CodexPoolEntry[]; - strategy: EffectiveChatGPTOAuthRoutingStrategy; - recentRequests: CodexPoolRecentRequest[]; - statsSampleSize: number; - fetching: boolean; - showProviderLinks?: boolean; - onRefresh: () => void; - className?: string; -} - -interface CodexPoolRecentRequestsListProps { - recentRequests: CodexPoolRecentRequest[]; - loading: boolean; - compact?: boolean; - className?: string; -} - -function availabilityVariant( - availability: ChatGPTOAuthAvailability, -): "success" | "warning" | "outline" { - if (availability === "ready") return "success"; - if (availability === "needs_sign_in") return "warning"; - return "outline"; -} - -function requestStatusVariant( - status: string, -): "success" | "destructive" | "info" | "secondary" { - if (status === "ok" || status === "success" || status === "completed") - return "success"; - if (status === "error" || status === "failed") return "destructive"; - if (status === "running" || status === "pending") return "info"; - return "secondary"; -} - -function routeBadgeVariant( - state: ReturnType, -): "success" | "warning" | "outline" | "destructive" { - if (state === "healthy") return "success"; - if (state === "fallback") return "warning"; - if (state === "checking") return "outline"; - return "destructive"; -} - -function routeLabelKey(state: ReturnType): string { - if (state === "healthy") return "chatgptOAuthRouting.routerActiveTitle"; - if (state === "fallback") return "chatgptOAuthRouting.fallbackTitle"; - if (state === "checking") return "chatgptOAuthRouting.checkingTitle"; - return "chatgptOAuthRouting.blockedNowTitle"; -} - -function strategyLabelKey( - strategy: EffectiveChatGPTOAuthRoutingStrategy, -): string { - if (strategy === "round_robin") return "chatgptOAuthRouting.strategy.roundRobin"; - if (strategy === "priority_order") return "chatgptOAuthRouting.strategy.priorityOrder"; - return "chatgptOAuthRouting.strategy.primaryFirst"; -} - -function runtimeHealthVariant( - state: CodexPoolEntry["healthState"], -): "success" | "warning" | "destructive" | "outline" { - if (state === "healthy") return "success"; - if (state === "degraded") return "warning"; - if (state === "critical") return "destructive"; - return "outline"; -} - -function runtimeHealthBarWidths(entry: CodexPoolEntry) { - const total = entry.successCount + entry.failureCount; - if (total <= 0) { - return { success: 0, failure: 0 }; - } - const success = Math.max(0, Math.min(100, entry.successRate)); - return { - success, - failure: Math.max(0, 100 - success), - }; -} - -function poolRoleBadgeClass(role: CodexPoolEntry["role"]): string { - if (role === "preferred") { - return "border-primary/35 bg-primary/12 text-foreground shadow-sm dark:border-primary/40 dark:bg-primary/18"; - } - return "border-border/70 bg-background/80 text-muted-foreground"; -} +export type { CodexPoolEntry } from "./codex-pool-entry-types"; +import type { CodexPoolActivityPanelProps, CodexPoolRecentRequestsPanelProps } from "./codex-pool-entry-types"; function MonitorStat({ label, value }: { label: string; value: string }) { return ( @@ -142,408 +27,6 @@ function MonitorStat({ label, value }: { label: string; value: string }) { ); } -function MemberMetric({ label, value }: { label: string; value: string }) { - return ( -
-

- {label} -

-

- {value} -

-
- ); -} - -function requestProviderSummary(request: CodexPoolRecentRequest): string { - const selected = request.selected_provider?.trim(); - const served = request.provider_name?.trim(); - if (request.used_failover && selected && served && selected !== served) { - return `${selected} -> ${served}`; - } - return served || selected || ""; -} - -interface RequestAccentClasses { - card: string; - glow: string; - stripe: string; - marker: string; - index: string; - directBadge: string; - pill: string; - trace: string; -} - -const REQUEST_ACCENTS: RequestAccentClasses[] = [ - { - card: "border-emerald-500/20", - glow: - "from-emerald-500/[0.12] via-emerald-500/[0.04] to-transparent dark:from-emerald-500/[0.16] dark:via-emerald-500/[0.06]", - stripe: "from-emerald-400/90 via-teal-400/75 to-cyan-400/35", - marker: "bg-emerald-500", - index: - "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/15 dark:text-emerald-200", - directBadge: - "border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:border-emerald-500/25 dark:bg-emerald-500/15 dark:text-emerald-200", - pill: - "border-emerald-500/15 bg-emerald-500/[0.07] text-emerald-950 dark:border-emerald-500/20 dark:bg-emerald-500/[0.12] dark:text-emerald-100", - trace: - "text-emerald-700 hover:border-emerald-500/25 hover:bg-emerald-500/10 hover:text-emerald-800 dark:text-emerald-200 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/15 dark:hover:text-emerald-100", - }, - { - card: "border-sky-500/20", - glow: - "from-sky-500/[0.12] via-sky-500/[0.04] to-transparent dark:from-sky-500/[0.16] dark:via-sky-500/[0.06]", - stripe: "from-sky-400/90 via-cyan-400/75 to-blue-400/35", - marker: "bg-sky-500", - index: - "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/15 dark:text-sky-200", - directBadge: - "border-sky-500/20 bg-sky-500/10 text-sky-700 dark:border-sky-500/25 dark:bg-sky-500/15 dark:text-sky-200", - pill: - "border-sky-500/15 bg-sky-500/[0.07] text-sky-950 dark:border-sky-500/20 dark:bg-sky-500/[0.12] dark:text-sky-100", - trace: - "text-sky-700 hover:border-sky-500/25 hover:bg-sky-500/10 hover:text-sky-800 dark:text-sky-200 dark:hover:border-sky-500/30 dark:hover:bg-sky-500/15 dark:hover:text-sky-100", - }, - { - card: "border-amber-500/20", - glow: - "from-amber-500/[0.12] via-amber-500/[0.04] to-transparent dark:from-amber-500/[0.16] dark:via-amber-500/[0.06]", - stripe: "from-amber-400/90 via-orange-400/75 to-yellow-400/35", - marker: "bg-amber-500", - index: - "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-100", - directBadge: - "border-amber-500/20 bg-amber-500/10 text-amber-800 dark:border-amber-500/25 dark:bg-amber-500/15 dark:text-amber-100", - pill: - "border-amber-500/15 bg-amber-500/[0.07] text-amber-950 dark:border-amber-500/20 dark:bg-amber-500/[0.12] dark:text-amber-50", - trace: - "text-amber-800 hover:border-amber-500/25 hover:bg-amber-500/10 hover:text-amber-900 dark:text-amber-100 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/15 dark:hover:text-amber-50", - }, - { - card: "border-rose-500/20", - glow: - "from-rose-500/[0.12] via-rose-500/[0.04] to-transparent dark:from-rose-500/[0.16] dark:via-rose-500/[0.06]", - stripe: "from-rose-400/90 via-pink-400/75 to-orange-300/35", - marker: "bg-rose-500", - index: - "border-rose-500/30 bg-rose-500/10 text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/15 dark:text-rose-200", - directBadge: - "border-rose-500/20 bg-rose-500/10 text-rose-700 dark:border-rose-500/25 dark:bg-rose-500/15 dark:text-rose-200", - pill: - "border-rose-500/15 bg-rose-500/[0.07] text-rose-950 dark:border-rose-500/20 dark:bg-rose-500/[0.12] dark:text-rose-100", - trace: - "text-rose-700 hover:border-rose-500/25 hover:bg-rose-500/10 hover:text-rose-800 dark:text-rose-200 dark:hover:border-rose-500/30 dark:hover:bg-rose-500/15 dark:hover:text-rose-100", - }, -]; - -function requestAccentSeed(request: CodexPoolRecentRequest): string { - return ( - request.provider_name?.trim() || - request.selected_provider?.trim() || - requestProviderSummary(request) || - request.model || - request.span_id - ); -} - -function requestAccentClasses(seed: string): RequestAccentClasses { - let hash = 0; - for (const char of seed) { - hash = (hash * 31 + char.charCodeAt(0)) >>> 0; - } - return REQUEST_ACCENTS[hash % REQUEST_ACCENTS.length] ?? REQUEST_ACCENTS[0]!; -} - -function CodexPoolRecentRequestsList({ - recentRequests, - loading, - compact = false, - className, -}: CodexPoolRecentRequestsListProps) { - const { t } = useTranslation("agents"); - - if (loading) { - return ( -
- {t("chatgptOAuthRouting.loadingEvidence")} -
- ); - } - - if (recentRequests.length === 0) { - return compact ? ( -
- {t("chatgptOAuthRouting.noEvidence")} -
- ) : ( -
- -
- ); - } - - if (compact) { - return ( -
-
- {recentRequests.map((request, index) => { - const providerSummary = - requestProviderSummary(request) || - request.model || - t("chatgptOAuthRouting.unknownModel"); - const accent = requestAccentClasses(requestAccentSeed(request)); - - return ( -
-
-
- - - -
-
-
- {index + 1} -
- -
-
- -

- {providerSummary} -

-
-

- {request.model || t("chatgptOAuthRouting.unknownModel")} -

-
-
-
- -
- - {request.used_failover - ? t("chatgptOAuthRouting.monitorFailoverLabel") - : t("chatgptOAuthRouting.monitorDirectLabel")} - - - {formatRelativeTime(request.started_at)} - - - · - - - {formatDuration(request.duration_ms)} - - {request.attempt_count > 1 && ( - <> - - · - - - {request.attempt_count}x - - - )} - {request.status !== "completed" && ( - - {request.status} - - )} -
- - {request.used_failover && - request.failover_providers && - request.failover_providers.length > 0 && ( -

- {t("chatgptOAuthRouting.failoverHint", { - providers: request.failover_providers.join(", "), - })} -

- )} -
- ); - })} -
-
- ); - } - - return ( -
-
- {recentRequests.map((request) => { - const providerSummary = requestProviderSummary(request); - return ( -
-
-
-
- - {request.used_failover - ? t("chatgptOAuthRouting.monitorFailoverLabel") - : t("chatgptOAuthRouting.monitorDirectLabel")} - - {providerSummary ? ( - {providerSummary} - ) : ( - {request.status} - )} -
- -

- {request.model || t("chatgptOAuthRouting.unknownModel")} -

- -
- {formatRelativeTime(request.started_at)} - {formatDuration(request.duration_ms)} - - {t("chatgptOAuthRouting.attemptCount", { - count: request.attempt_count, - })} - -
- - {request.used_failover && - request.failover_providers && - request.failover_providers.length > 0 && ( -

- {t("chatgptOAuthRouting.failoverHint", { - providers: request.failover_providers.join(", "), - })} -

- )} -
- -
- - {request.status} - - -
-
-
- ); - })} -
-
- ); -} - export function CodexPoolActivityPanel({ entries, strategy, @@ -555,12 +38,12 @@ export function CodexPoolActivityPanel({ className, }: CodexPoolActivityPanelProps) { const { t } = useTranslation("agents"); + const routeEntries = useMemo( () => entries.map((entry) => ({ ...entry, routeReadiness: getRouteReadiness(entry.availability, entry.quota), - failureKind: getQuotaFailureKind(entry.quota), })), [entries], ); @@ -612,9 +95,7 @@ export function CodexPoolActivityPanel({ onClick={onRefresh} disabled={fetching} > - + {t("chatgptOAuthRouting.refreshEvidence")}
@@ -637,10 +118,7 @@ export function CodexPoolActivityPanel({ /> request.used_failover) - .length, - )} + value={String(recentRequests.filter((r) => r.used_failover).length)} />
@@ -655,7 +133,6 @@ export function CodexPoolActivityPanel({ })} - - {t("chatgptOAuthRouting.selectedCount", { - count: entries.length, - })} + {t("chatgptOAuthRouting.selectedCount", { count: entries.length })} @@ -688,229 +163,13 @@ export function CodexPoolActivityPanel({ ) : (
- {routeEntries.map((entry) => { - const accent = requestAccentClasses(entry.name); - return ( -
-
-
- -
- {(() => { - const totalOutcomes = - entry.successCount + entry.failureCount; - const barWidths = runtimeHealthBarWidths(entry); - const showAvailabilityBadge = - entry.availability !== "ready"; - const showHealthBadge = - entry.healthState !== "healthy" && - entry.healthState !== "idle"; - const showRouteBadge = - entry.routeReadiness !== "healthy"; - return ( - <> -
-
-
- - - {entry.label} - - - {t(`chatgptOAuthRouting.role.${entry.role}`)} - - {showAvailabilityBadge && ( - - {t( - `chatgptOAuthRouting.status.${entry.availability}`, - )} - - )} - {showHealthBadge && ( - - {t( - `chatgptOAuthRouting.healthState.${entry.healthState}`, - )} - - )} - {showRouteBadge && ( - - {t(routeLabelKey(entry.routeReadiness))} - - )} -
- {entry.label !== entry.name && ( -

- {entry.name} -

- )} -
- - {showProviderLinks && entry.providerHref && ( - - )} -
- - - -
-
- {totalOutcomes > 0 ? ( -

- {t( - "chatgptOAuthRouting.runtimeHealthSummary", - { - rate: entry.successRate, - score: entry.healthScore, - }, - )} -

- ) : ( -

- {t("chatgptOAuthRouting.noRuntimeSample")} -

- )} - {entry.consecutiveFailures > 0 && ( - = 3 - ? "destructive" - : "warning" - } - className="h-5 shrink-0 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs" - > - {t("chatgptOAuthRouting.failureStreakBadge", { - count: entry.consecutiveFailures, - })} - - )} -
- -
- {totalOutcomes > 0 ? ( - <> -
- {barWidths.failure > 0 && ( -
- )} - - ) : ( -
- )} -
- -
- - {t("chatgptOAuthRouting.runtimeSuccessCompact", { - count: entry.successCount, - })} - - - {t("chatgptOAuthRouting.runtimeFailureCompact", { - count: entry.failureCount, - })} - - {entry.lastFailureAt && ( - - {t("chatgptOAuthRouting.lastFailureLabel", { - value: formatRelativeTime(entry.lastFailureAt), - })} - - )} -
-
- -
- - - -
- - ); - })()} -
-
- ); - })} + {entries.map((entry) => ( + + ))}
)} @@ -920,12 +179,6 @@ export function CodexPoolActivityPanel({ ); } -interface CodexPoolRecentRequestsPanelProps { - recentRequests: CodexPoolRecentRequest[]; - loading: boolean; - className?: string; -} - export function CodexPoolRecentRequestsPanel({ recentRequests, loading, @@ -954,10 +207,7 @@ export function CodexPoolRecentRequestsPanel({ - + ); diff --git a/ui/web/src/pages/agents/agent-detail/codex-pool-entry-types.ts b/ui/web/src/pages/agents/agent-detail/codex-pool-entry-types.ts new file mode 100644 index 00000000..177952e9 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/codex-pool-entry-types.ts @@ -0,0 +1,51 @@ +import type { ChatGPTOAuthAvailability } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-statuses"; +import type { ChatGPTOAuthProviderQuota } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-quotas"; +import type { EffectiveChatGPTOAuthRoutingStrategy } from "@/types/agent"; +import type { CodexPoolRecentRequest } from "./hooks/use-codex-pool-activity"; + +export interface CodexPoolEntry { + name: string; + label: string; + availability: ChatGPTOAuthAvailability; + role: "preferred" | "extra"; + requestCount: number; + directSelectionCount: number; + failoverServeCount: number; + successCount: number; + failureCount: number; + consecutiveFailures: number; + successRate: number; + healthScore: number; + healthState: "healthy" | "degraded" | "critical" | "idle"; + lastSelectedAt?: string; + lastFailoverAt?: string; + lastUsedAt?: string; + lastSuccessAt?: string; + lastFailureAt?: string; + providerHref?: string; + quota?: ChatGPTOAuthProviderQuota | null; +} + +export interface CodexPoolActivityPanelProps { + entries: CodexPoolEntry[]; + strategy: EffectiveChatGPTOAuthRoutingStrategy; + recentRequests: CodexPoolRecentRequest[]; + statsSampleSize: number; + fetching: boolean; + showProviderLinks?: boolean; + onRefresh: () => void; + className?: string; +} + +export interface CodexPoolRecentRequestsPanelProps { + recentRequests: CodexPoolRecentRequest[]; + loading: boolean; + className?: string; +} + +export interface CodexPoolRecentRequestsListProps { + recentRequests: CodexPoolRecentRequest[]; + loading: boolean; + compact?: boolean; + className?: string; +} diff --git a/ui/web/src/pages/agents/agent-detail/codex-pool-member-card.tsx b/ui/web/src/pages/agents/agent-detail/codex-pool-member-card.tsx new file mode 100644 index 00000000..157901f6 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/codex-pool-member-card.tsx @@ -0,0 +1,274 @@ +import { Link } from "react-router"; +import { useTranslation } from "react-i18next"; +import { ArrowUpRight } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { formatRelativeTime } from "@/lib/format"; +import { cn } from "@/lib/utils"; +import { + routeBadgeVariant, + routeLabelKey, +} from "./agent-display-utils"; +import { ChatGPTOAuthQuotaStrip } from "./chatgpt-oauth-quota-strip"; +import { getQuotaFailureKind, getRouteReadiness } from "./chatgpt-oauth-quota-utils"; +import type { CodexPoolEntry } from "./codex-pool-entry-types"; +import { requestAccentClasses } from "./codex-pool-request-accent"; + +function availabilityVariant( + availability: CodexPoolEntry["availability"], +): "success" | "warning" | "outline" { + if (availability === "ready") return "success"; + if (availability === "needs_sign_in") return "warning"; + return "outline"; +} + +function runtimeHealthVariant( + state: CodexPoolEntry["healthState"], +): "success" | "warning" | "destructive" | "outline" { + if (state === "healthy") return "success"; + if (state === "degraded") return "warning"; + if (state === "critical") return "destructive"; + return "outline"; +} + +function runtimeHealthBarWidths(entry: CodexPoolEntry) { + const total = entry.successCount + entry.failureCount; + if (total <= 0) return { success: 0, failure: 0 }; + const success = Math.max(0, Math.min(100, entry.successRate)); + return { success, failure: Math.max(0, 100 - success) }; +} + +function poolRoleBadgeClass(role: CodexPoolEntry["role"]): string { + if (role === "preferred") { + return "border-primary/35 bg-primary/12 text-foreground shadow-sm dark:border-primary/40 dark:bg-primary/18"; + } + return "border-border/70 bg-background/80 text-muted-foreground"; +} + +function MemberMetric({ label, value }: { label: string; value: string }) { + return ( +
+

+ {label} +

+

+ {value} +

+
+ ); +} + +interface CodexPoolMemberCardProps { + entry: CodexPoolEntry; + showProviderLinks?: boolean; +} + +export function CodexPoolMemberCard({ + entry, + showProviderLinks = true, +}: CodexPoolMemberCardProps) { + const { t } = useTranslation("agents"); + + const routeReadiness = getRouteReadiness(entry.availability, entry.quota); + const failureKind = getQuotaFailureKind(entry.quota); + const accent = requestAccentClasses(entry.name); + const totalOutcomes = entry.successCount + entry.failureCount; + const barWidths = runtimeHealthBarWidths(entry); + const showAvailabilityBadge = entry.availability !== "ready"; + const showHealthBadge = + entry.healthState !== "healthy" && entry.healthState !== "idle"; + const showRouteBadge = routeReadiness !== "healthy"; + + // failureKind used to suppress unused-var lint; quota strip handles display + void failureKind; + + return ( +
+
+
+ +
+
+
+
+ + + {entry.label} + + + {t(`chatgptOAuthRouting.role.${entry.role}`)} + + {showAvailabilityBadge && ( + + {t(`chatgptOAuthRouting.status.${entry.availability}`)} + + )} + {showHealthBadge && ( + + {t(`chatgptOAuthRouting.healthState.${entry.healthState}`)} + + )} + {showRouteBadge && ( + + {t(routeLabelKey(routeReadiness))} + + )} +
+ {entry.label !== entry.name && ( +

+ {entry.name} +

+ )} +
+ + {showProviderLinks && entry.providerHref && ( + + )} +
+ + + +
+
+ {totalOutcomes > 0 ? ( +

+ {t("chatgptOAuthRouting.runtimeHealthSummary", { + rate: entry.successRate, + score: entry.healthScore, + })} +

+ ) : ( +

+ {t("chatgptOAuthRouting.noRuntimeSample")} +

+ )} + {entry.consecutiveFailures > 0 && ( + = 3 ? "destructive" : "warning"} + className="h-5 shrink-0 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs" + > + {t("chatgptOAuthRouting.failureStreakBadge", { + count: entry.consecutiveFailures, + })} + + )} +
+ +
+ {totalOutcomes > 0 ? ( + <> +
+ {barWidths.failure > 0 && ( +
+ )} + + ) : ( +
+ )} +
+ +
+ + {t("chatgptOAuthRouting.runtimeSuccessCompact", { + count: entry.successCount, + })} + + + {t("chatgptOAuthRouting.runtimeFailureCompact", { + count: entry.failureCount, + })} + + {entry.lastFailureAt && ( + + {t("chatgptOAuthRouting.lastFailureLabel", { + value: formatRelativeTime(entry.lastFailureAt), + })} + + )} +
+
+ +
+ + + +
+
+
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/codex-pool-page-header.tsx b/ui/web/src/pages/agents/agent-detail/codex-pool-page-header.tsx new file mode 100644 index 00000000..d8095b2d --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/codex-pool-page-header.tsx @@ -0,0 +1,146 @@ +import { Link } from "react-router"; +import { useTranslation } from "react-i18next"; +import { AlertTriangle, ArrowLeft } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ROUTES } from "@/lib/constants"; +import { cn } from "@/lib/utils"; +import { strategyLabelKey } from "./agent-display-utils"; +import type { EffectiveChatGPTOAuthRoutingStrategy } from "@/types/agent"; + +type SummaryTone = "healthy" | "warning" | "manual"; + +interface CodexPoolPageHeaderProps { + title: string; + savedStrategy: EffectiveChatGPTOAuthRoutingStrategy; + summaryTone: SummaryTone; + overrideMode: "inherit" | "custom"; + recentRequestCount: number; + runtimeHealthyCount: number; + runtimeDegradedCount: number; + runtimeCriticalCount: number; + isDirty: boolean; + canManageProviders: boolean; + isEligible: boolean; + onBack: () => void; +} + +export function CodexPoolPageHeader({ + title, + savedStrategy, + summaryTone, + overrideMode, + recentRequestCount, + runtimeHealthyCount, + runtimeDegradedCount, + runtimeCriticalCount, + isDirty, + canManageProviders, + isEligible, + onBack, +}: CodexPoolPageHeaderProps) { + const { t } = useTranslation("agents"); + + return ( + <> +
+
+
+
+ + +

+ {t("chatgptOAuthRouting.pageTitle")} +

+
+

+ {t("chatgptOAuthRouting.pageDescription", { name: title })} +

+ +
+ + {t(`chatgptOAuthRouting.verdict.${summaryTone}.title`)} + + + {t(strategyLabelKey(savedStrategy))} + + + {overrideMode === "inherit" + ? t("chatgptOAuthRouting.mode.inherit") + : t("chatgptOAuthRouting.mode.custom")} + + + {recentRequestCount > 0 + ? t("chatgptOAuthRouting.sampleBadge", { count: recentRequestCount }) + : t("chatgptOAuthRouting.noSampleBadge")} + + + {t("chatgptOAuthRouting.healthState.healthy")} {runtimeHealthyCount} + + {runtimeDegradedCount > 0 ? ( + + {t("chatgptOAuthRouting.healthState.degraded")} {runtimeDegradedCount} + + ) : null} + {runtimeCriticalCount > 0 ? ( + + {t("chatgptOAuthRouting.healthState.critical")} {runtimeCriticalCount} + + ) : null} + {isDirty ? ( + + {t("chatgptOAuthRouting.draftBadge")} + + ) : null} +
+
+ + {canManageProviders ? ( + + ) : null} +
+
+ + {!isEligible ? ( + + + + {t("chatgptOAuthRouting.pageUnsupportedTitle")} + + + {t("chatgptOAuthRouting.pageUnsupportedDescription")} + + + ) : null} + + ); +} + diff --git a/ui/web/src/pages/agents/agent-detail/codex-pool-recent-requests-list.tsx b/ui/web/src/pages/agents/agent-detail/codex-pool-recent-requests-list.tsx new file mode 100644 index 00000000..17dd2900 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/codex-pool-recent-requests-list.tsx @@ -0,0 +1,86 @@ +import { useTranslation } from "react-i18next"; +import { Route } from "lucide-react"; +import { EmptyState } from "@/components/shared/empty-state"; +import { cn } from "@/lib/utils"; +import type { CodexPoolRecentRequestsListProps } from "./codex-pool-entry-types"; +import { + CompactRequestCard, + FullRequestRow, +} from "./codex-pool-request-row-cards"; + +export function CodexPoolRecentRequestsList({ + recentRequests, + loading, + compact = false, + className, +}: CodexPoolRecentRequestsListProps) { + const { t } = useTranslation("agents"); + + if (loading) { + return ( +
+ {t("chatgptOAuthRouting.loadingEvidence")} +
+ ); + } + + if (recentRequests.length === 0) { + return compact ? ( +
+ {t("chatgptOAuthRouting.noEvidence")} +
+ ) : ( +
+ +
+ ); + } + + if (compact) { + return ( +
+
+ {recentRequests.map((request, index) => ( + + ))} +
+
+ ); + } + + return ( +
+
+ {recentRequests.map((request) => ( + + ))} +
+
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/codex-pool-request-accent.ts b/ui/web/src/pages/agents/agent-detail/codex-pool-request-accent.ts new file mode 100644 index 00000000..ea2a7922 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/codex-pool-request-accent.ts @@ -0,0 +1,102 @@ +import type { CodexPoolRecentRequest } from "./hooks/use-codex-pool-activity"; + +export interface RequestAccentClasses { + card: string; + glow: string; + stripe: string; + marker: string; + index: string; + directBadge: string; + pill: string; + trace: string; +} + +export const REQUEST_ACCENTS: RequestAccentClasses[] = [ + { + card: "border-emerald-500/20", + glow: + "from-emerald-500/[0.12] via-emerald-500/[0.04] to-transparent dark:from-emerald-500/[0.16] dark:via-emerald-500/[0.06]", + stripe: "from-emerald-400/90 via-teal-400/75 to-cyan-400/35", + marker: "bg-emerald-500", + index: + "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/15 dark:text-emerald-200", + directBadge: + "border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:border-emerald-500/25 dark:bg-emerald-500/15 dark:text-emerald-200", + pill: + "border-emerald-500/15 bg-emerald-500/[0.07] text-emerald-950 dark:border-emerald-500/20 dark:bg-emerald-500/[0.12] dark:text-emerald-100", + trace: + "text-emerald-700 hover:border-emerald-500/25 hover:bg-emerald-500/10 hover:text-emerald-800 dark:text-emerald-200 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/15 dark:hover:text-emerald-100", + }, + { + card: "border-sky-500/20", + glow: + "from-sky-500/[0.12] via-sky-500/[0.04] to-transparent dark:from-sky-500/[0.16] dark:via-sky-500/[0.06]", + stripe: "from-sky-400/90 via-cyan-400/75 to-blue-400/35", + marker: "bg-sky-500", + index: + "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/15 dark:text-sky-200", + directBadge: + "border-sky-500/20 bg-sky-500/10 text-sky-700 dark:border-sky-500/25 dark:bg-sky-500/15 dark:text-sky-200", + pill: + "border-sky-500/15 bg-sky-500/[0.07] text-sky-950 dark:border-sky-500/20 dark:bg-sky-500/[0.12] dark:text-sky-100", + trace: + "text-sky-700 hover:border-sky-500/25 hover:bg-sky-500/10 hover:text-sky-800 dark:text-sky-200 dark:hover:border-sky-500/30 dark:hover:bg-sky-500/15 dark:hover:text-sky-100", + }, + { + card: "border-amber-500/20", + glow: + "from-amber-500/[0.12] via-amber-500/[0.04] to-transparent dark:from-amber-500/[0.16] dark:via-amber-500/[0.06]", + stripe: "from-amber-400/90 via-orange-400/75 to-yellow-400/35", + marker: "bg-amber-500", + index: + "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-100", + directBadge: + "border-amber-500/20 bg-amber-500/10 text-amber-800 dark:border-amber-500/25 dark:bg-amber-500/15 dark:text-amber-100", + pill: + "border-amber-500/15 bg-amber-500/[0.07] text-amber-950 dark:border-amber-500/20 dark:bg-amber-500/[0.12] dark:text-amber-50", + trace: + "text-amber-800 hover:border-amber-500/25 hover:bg-amber-500/10 hover:text-amber-900 dark:text-amber-100 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/15 dark:hover:text-amber-50", + }, + { + card: "border-rose-500/20", + glow: + "from-rose-500/[0.12] via-rose-500/[0.04] to-transparent dark:from-rose-500/[0.16] dark:via-rose-500/[0.06]", + stripe: "from-rose-400/90 via-pink-400/75 to-orange-300/35", + marker: "bg-rose-500", + index: + "border-rose-500/30 bg-rose-500/10 text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/15 dark:text-rose-200", + directBadge: + "border-rose-500/20 bg-rose-500/10 text-rose-700 dark:border-rose-500/25 dark:bg-rose-500/15 dark:text-rose-200", + pill: + "border-rose-500/15 bg-rose-500/[0.07] text-rose-950 dark:border-rose-500/20 dark:bg-rose-500/[0.12] dark:text-rose-100", + trace: + "text-rose-700 hover:border-rose-500/25 hover:bg-rose-500/10 hover:text-rose-800 dark:text-rose-200 dark:hover:border-rose-500/30 dark:hover:bg-rose-500/15 dark:hover:text-rose-100", + }, +]; + +export function requestProviderSummary(request: CodexPoolRecentRequest): string { + const selected = request.selected_provider?.trim(); + const served = request.provider_name?.trim(); + if (request.used_failover && selected && served && selected !== served) { + return `${selected} -> ${served}`; + } + return served || selected || ""; +} + +export function requestAccentSeed(request: CodexPoolRecentRequest): string { + return ( + request.provider_name?.trim() || + request.selected_provider?.trim() || + requestProviderSummary(request) || + request.model || + request.span_id + ); +} + +export function requestAccentClasses(seed: string): RequestAccentClasses { + let hash = 0; + for (const char of seed) { + hash = (hash * 31 + char.charCodeAt(0)) >>> 0; + } + return REQUEST_ACCENTS[hash % REQUEST_ACCENTS.length] ?? REQUEST_ACCENTS[0]!; +} diff --git a/ui/web/src/pages/agents/agent-detail/codex-pool-request-row-cards.tsx b/ui/web/src/pages/agents/agent-detail/codex-pool-request-row-cards.tsx new file mode 100644 index 00000000..d724b9bf --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/codex-pool-request-row-cards.tsx @@ -0,0 +1,220 @@ +import { Link } from "react-router"; +import { useTranslation } from "react-i18next"; +import { ArrowUpRight } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { formatDuration, formatRelativeTime } from "@/lib/format"; +import { cn } from "@/lib/utils"; +import type { CodexPoolRecentRequest } from "./hooks/use-codex-pool-activity"; +import { + requestAccentClasses, + requestAccentSeed, + requestProviderSummary, +} from "./codex-pool-request-accent"; + +export function requestStatusVariant( + status: string, +): "success" | "destructive" | "info" | "secondary" { + if (status === "ok" || status === "success" || status === "completed") + return "success"; + if (status === "error" || status === "failed") return "destructive"; + if (status === "running" || status === "pending") return "info"; + return "secondary"; +} + +interface CompactRequestCardProps { + request: CodexPoolRecentRequest; + index: number; +} + +export function CompactRequestCard({ request, index }: CompactRequestCardProps) { + const { t } = useTranslation("agents"); + const providerSummary = + requestProviderSummary(request) || + request.model || + t("chatgptOAuthRouting.unknownModel"); + const accent = requestAccentClasses(requestAccentSeed(request)); + + return ( +
+
+
+ + + +
+
+
+ {index + 1} +
+
+
+ +

+ {providerSummary} +

+
+

+ {request.model || t("chatgptOAuthRouting.unknownModel")} +

+
+
+
+ +
+ + {request.used_failover + ? t("chatgptOAuthRouting.monitorFailoverLabel") + : t("chatgptOAuthRouting.monitorDirectLabel")} + + + {formatRelativeTime(request.started_at)} + + · + + {formatDuration(request.duration_ms)} + + {request.attempt_count > 1 && ( + <> + · + + {request.attempt_count}x + + + )} + {request.status !== "completed" && ( + + {request.status} + + )} +
+ + {request.used_failover && + request.failover_providers && + request.failover_providers.length > 0 && ( +

+ {t("chatgptOAuthRouting.failoverHint", { + providers: request.failover_providers.join(", "), + })} +

+ )} +
+ ); +} + +export function FullRequestRow({ request }: { request: CodexPoolRecentRequest }) { + const { t } = useTranslation("agents"); + const providerSummary = requestProviderSummary(request); + + return ( +
+
+
+
+ + {request.used_failover + ? t("chatgptOAuthRouting.monitorFailoverLabel") + : t("chatgptOAuthRouting.monitorDirectLabel")} + + {providerSummary ? ( + {providerSummary} + ) : ( + {request.status} + )} +
+ +

+ {request.model || t("chatgptOAuthRouting.unknownModel")} +

+ +
+ {formatRelativeTime(request.started_at)} + {formatDuration(request.duration_ms)} + + {t("chatgptOAuthRouting.attemptCount", { count: request.attempt_count })} + +
+ + {request.used_failover && + request.failover_providers && + request.failover_providers.length > 0 && ( +

+ {t("chatgptOAuthRouting.failoverHint", { + providers: request.failover_providers.join(", "), + })} +

+ )} +
+ +
+ + {request.status} + + +
+
+
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/codex-pool-routing-draft-utils.ts b/ui/web/src/pages/agents/agent-detail/codex-pool-routing-draft-utils.ts new file mode 100644 index 00000000..e13aef94 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/codex-pool-routing-draft-utils.ts @@ -0,0 +1,47 @@ +import type { ChatGPTOAuthRoutingConfig } from "@/types/agent"; +import { + normalizeChatGPTOAuthRoutingInput, + type NormalizedChatGPTOAuthRouting, +} from "./agent-display-utils"; + +export function buildDraftRouting( + savedRouting: NormalizedChatGPTOAuthRouting, + hasProviderDefaults: boolean, +): ChatGPTOAuthRoutingConfig { + if (savedRouting.isExplicit) { + return { + override_mode: savedRouting.overrideMode, + strategy: savedRouting.strategy, + extra_provider_names: savedRouting.extraProviderNames, + }; + } + + if (hasProviderDefaults) { + return { + override_mode: "inherit", + strategy: "primary_first", + extra_provider_names: [], + }; + } + + return { + override_mode: "custom", + strategy: "primary_first", + extra_provider_names: [], + }; +} + +export function routingDraftSignature( + routing: ChatGPTOAuthRoutingConfig, + hasProviderDefaults: boolean, +): string { + const normalized = normalizeChatGPTOAuthRoutingInput(routing); + if (normalized.overrideMode === "inherit" && hasProviderDefaults) { + return JSON.stringify({ override_mode: "inherit" }); + } + return JSON.stringify({ + override_mode: "custom", + strategy: normalized.strategy, + extra_provider_names: normalized.extraProviderNames, + }); +} diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/chatgpt-oauth-pool-sections.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/chatgpt-oauth-pool-sections.tsx new file mode 100644 index 00000000..641ba8ed --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/config-sections/chatgpt-oauth-pool-sections.tsx @@ -0,0 +1,260 @@ +import { useTranslation } from "react-i18next"; +import { Check } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { ChatGPTOAuthAvailability } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-statuses"; +import type { ChatGPTOAuthProviderQuota } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-quotas"; +import type { ProviderData } from "@/types/provider"; +import type { CodexPoolEntry } from "../codex-pool-entry-types"; +import { + routeBadgeVariant, + routeLabelKey, +} from "../agent-display-utils"; +import { + getQuotaFailureKind, + type ChatGPTOAuthRouteReadiness, + type ChatGPTOAuthQuotaFailureKind, +} from "../chatgpt-oauth-quota-utils"; +import { StateGroup } from "./chatgpt-oauth-state-group"; + +function statusBadgeVariant( + availability: ChatGPTOAuthAvailability, +): "success" | "warning" | "outline" { + if (availability === "ready") return "success"; + if (availability === "needs_sign_in") return "warning"; + return "outline"; +} + +function roleBadgeClass(role: "preferred" | "extra"): string { + if (role === "preferred") { + return "border-primary/35 bg-primary/12 text-foreground shadow-sm dark:border-primary/40 dark:bg-primary/18"; + } + return "border-border/70 bg-background/80 text-muted-foreground"; +} + +export type SelectedEntry = CodexPoolEntry & { + routeReadiness: ChatGPTOAuthRouteReadiness; + failureKind: ChatGPTOAuthQuotaFailureKind | null; +}; + +interface MembershipSectionProps { + membershipEditable: boolean; + membershipManagedByLabel?: string; + currentProvider: string; + selectedEntries: SelectedEntry[]; + readyExtraProviders: ProviderData[]; + selectedExtras: Set; + quotaByName?: Map; + canEditMembership: boolean; + mode: "inherit" | "custom"; + isLoading: boolean; + onToggleProvider: (name: string) => void; +} + +export function MembershipSection({ + membershipEditable, + membershipManagedByLabel, + currentProvider, + selectedEntries, + readyExtraProviders, + selectedExtras, + quotaByName, + canEditMembership, + mode, + isLoading, + onToggleProvider, +}: MembershipSectionProps) { + const { t } = useTranslation("agents"); + + return ( +
+
+

+ {membershipEditable + ? t("chatgptOAuthRouting.availableExtraAccountsLabel") + : t("chatgptOAuthRouting.poolMembershipLabel")} +

+
+ + {!membershipEditable ? ( +
+ {selectedEntries.length > 1 + ? t("chatgptOAuthRouting.membershipManagedAtProvider", { + provider: membershipManagedByLabel || currentProvider, + }) + : t("chatgptOAuthRouting.membershipConfigureProviderFirst", { + provider: membershipManagedByLabel || currentProvider, + })} +
+ ) : isLoading ? ( +
+ {t("chatgptOAuthRouting.loadingAccounts")} +
+ ) : readyExtraProviders.length > 0 ? ( +
+ {readyExtraProviders.map((provider) => { + const selected = selectedExtras.has(provider.name); + const failureKind = getQuotaFailureKind(quotaByName?.get(provider.name)); + return ( + + ); + })} +
+ ) : ( +
+ {t("chatgptOAuthRouting.noReadyExtras")} +
+ )} +
+ ); +} + +interface SelectedAccountsSectionProps { + selectedEntries: SelectedEntry[]; +} + +export function SelectedAccountsSection({ selectedEntries }: SelectedAccountsSectionProps) { + const { t } = useTranslation("agents"); + + return ( +
+
+

+ {t("chatgptOAuthRouting.selectedAccountsLabel")} +

+ + {t("chatgptOAuthRouting.selectedCount", { count: selectedEntries.length })} + +
+ + {selectedEntries.length > 0 ? ( +
+
+ {selectedEntries.map((entry) => ( +
+
+ + {entry.label} + + + {t(`chatgptOAuthRouting.role.${entry.role}`)} + + {entry.availability !== "ready" && ( + + {t(`chatgptOAuthRouting.status.${entry.availability}`)} + + )} + {entry.routeReadiness !== "healthy" && ( + + {t(routeLabelKey(entry.routeReadiness))} + + )} +
+
+ ))} +
+
+ ) : ( +
+ {t("chatgptOAuthRouting.emptySelected")} +
+ )} +
+ ); +} + +interface PoolStateSectionProps { + routerActiveEntries: SelectedEntry[]; + standbyEntries: SelectedEntry[]; + blockedEntries: SelectedEntry[]; + routeDetail: (entry: SelectedEntry) => string | undefined; +} + +export function PoolStateSection({ + routerActiveEntries, + standbyEntries, + blockedEntries, + routeDetail, +}: PoolStateSectionProps) { + const { t } = useTranslation("agents"); + + return ( +
+

+ {t("chatgptOAuthRouting.poolStateTitle")} +

+
+ ({ + name: e.name, + label: e.label, + detail: routeDetail(e), + }))} + emptyLabel={t("chatgptOAuthRouting.emptyGroup")} + /> + ({ + name: e.name, + label: e.label, + detail: routeDetail(e), + }))} + emptyLabel={t("chatgptOAuthRouting.emptyGroup")} + /> + ({ + name: e.name, + label: e.label, + detail: routeDetail(e), + }))} + emptyLabel={t("chatgptOAuthRouting.emptyGroup")} + /> +
+
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/chatgpt-oauth-routing-section.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/chatgpt-oauth-routing-section.tsx index dbd9b82b..59e232b0 100644 --- a/ui/web/src/pages/agents/agent-detail/config-sections/chatgpt-oauth-routing-section.tsx +++ b/ui/web/src/pages/agents/agent-detail/config-sections/chatgpt-oauth-routing-section.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Check, Loader2 } from "lucide-react"; +import { Loader2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -21,11 +21,13 @@ import type { EffectiveChatGPTOAuthRoutingStrategy, } from "@/types/agent"; import type { ProviderData } from "@/types/provider"; -import type { CodexPoolEntry } from "../codex-pool-activity-panel"; +import type { CodexPoolEntry } from "../codex-pool-entry-types"; +import { getQuotaFailureKind, getRouteReadiness } from "../chatgpt-oauth-quota-utils"; import { - getQuotaFailureKind, - getRouteReadiness, -} from "../chatgpt-oauth-quota-utils"; + MembershipSection, + SelectedAccountsSection, + PoolStateSection, +} from "./chatgpt-oauth-pool-sections"; interface ChatGPTOAuthRoutingSectionProps { title?: string; @@ -52,78 +54,13 @@ interface ChatGPTOAuthRoutingSectionProps { className?: string; } -function statusBadgeVariant( - availability: ChatGPTOAuthAvailability, -): "success" | "warning" | "outline" { - if (availability === "ready") return "success"; - if (availability === "needs_sign_in") return "warning"; - return "outline"; -} - -function routeBadgeVariant( - readiness: ReturnType, -): "success" | "warning" | "outline" | "destructive" { - if (readiness === "healthy") return "success"; - if (readiness === "fallback") return "warning"; - if (readiness === "checking") return "outline"; - return "destructive"; -} - -function routeLabelKey( - readiness: ReturnType, -): string { - if (readiness === "healthy") return "chatgptOAuthRouting.routerActiveTitle"; - if (readiness === "fallback") return "chatgptOAuthRouting.fallbackTitle"; - if (readiness === "checking") return "chatgptOAuthRouting.checkingTitle"; - return "chatgptOAuthRouting.blockedNowTitle"; -} - -function roleBadgeClass(role: "preferred" | "extra"): string { - if (role === "preferred") { - return "border-primary/35 bg-primary/12 text-foreground shadow-sm dark:border-primary/40 dark:bg-primary/18"; - } - return "border-border/70 bg-background/80 text-muted-foreground"; -} - -function StateGroup({ - title, - count, - variant, - entries, - emptyLabel, -}: { - title: string; - count: number; - variant: "success" | "warning" | "outline" | "destructive"; - entries: Array<{ name: string; label: string; detail?: string }>; - emptyLabel: string; -}) { +function getAvailabilityFromMap( + provider: ProviderData, + statusByName: Map, +): ChatGPTOAuthAvailability { return ( -
-
-

- {title} -

- {count} -
- {entries.length > 0 ? ( -
- {entries.map((entry) => ( -
- {entry.label} - {entry.detail ? ( - · {entry.detail} - ) : null} -
- ))} -
- ) : ( -

{emptyLabel}

- )} -
+ statusByName.get(provider.name)?.availability ?? + (provider.enabled ? "needs_sign_in" : "disabled") ); } @@ -152,52 +89,40 @@ export function ChatGPTOAuthRoutingSection({ const { t: tc } = useTranslation("common"); const { statuses, isLoading } = useChatGPTOAuthProviderStatuses(providers); - const oauthProviders = providers.filter( - (provider) => provider.provider_type === "chatgpt_oauth", - ); - const currentOAuthProvider = oauthProviders.find( - (provider) => provider.name === currentProvider, - ); - if (!currentOAuthProvider) return null; - const statusByName = useMemo( - () => new Map(statuses.map((status) => [status.provider.name, status])), + () => new Map(statuses.map((s) => [s.provider.name, s])), [statuses], ); - const getAvailability = (provider: ProviderData): ChatGPTOAuthAvailability => - statusByName.get(provider.name)?.availability ?? - (provider.enabled ? "needs_sign_in" : "disabled"); - - const allExtraProviders = oauthProviders.filter( - (provider) => provider.name !== currentProvider, + const oauthProviders = providers.filter( + (p) => p.provider_type === "chatgpt_oauth", ); + const currentOAuthProvider = oauthProviders.find( + (p) => p.name === currentProvider, + ); + if (!currentOAuthProvider) return null; + + const allExtraProviders = oauthProviders.filter((p) => p.name !== currentProvider); const readyExtraProviders = allExtraProviders.filter( - (provider) => getAvailability(provider) === "ready", + (p) => getAvailabilityFromMap(p, statusByName) === "ready", ); const mode = value.override_mode === "inherit" ? "inherit" : "custom"; const providerDefaultsAvailable = defaultRouting != null && defaultRouting.extraProviderNames.length > 0; const selectedExtras = new Set(value.extra_provider_names ?? []); + const selectedEntries = entries.map((entry) => ({ ...entry, routeReadiness: getRouteReadiness(entry.availability, entry.quota), failureKind: getQuotaFailureKind(entry.quota), })); - const healthyEntries = selectedEntries.filter( - (entry) => entry.routeReadiness === "healthy", - ); - const fallbackEntries = selectedEntries.filter( - (entry) => entry.routeReadiness === "fallback", - ); - const checkingEntries = selectedEntries.filter( - (entry) => entry.routeReadiness === "checking", - ); - const blockedEntries = selectedEntries.filter( - (entry) => entry.routeReadiness === "blocked", + const healthyEntries = selectedEntries.filter((e) => e.routeReadiness === "healthy"); + const standbyEntries = selectedEntries.filter( + (e) => e.routeReadiness === "fallback" || e.routeReadiness === "checking", ); + const blockedEntries = selectedEntries.filter((e) => e.routeReadiness === "blocked"); const routerActiveEntries = healthyEntries; - const standbyEntries = [...fallbackEntries, ...checkingEntries]; + const selectedStrategy: EffectiveChatGPTOAuthRoutingStrategy = value.strategy === "round_robin" || value.strategy === "priority_order" ? value.strategy @@ -208,42 +133,26 @@ export function ChatGPTOAuthRoutingSection({ mode !== "inherit" && (membershipEditable || providerDefaultsAvailable || selectedEntries.length > 1); - const setMode = (overrideMode: "inherit" | "custom") => { - onChange({ - ...value, - override_mode: overrideMode, - }); - }; + const setMode = (overrideMode: "inherit" | "custom") => + onChange({ ...value, override_mode: overrideMode }); - const setStrategy = (strategy: EffectiveChatGPTOAuthRoutingStrategy) => { + const setStrategy = (strategy: EffectiveChatGPTOAuthRoutingStrategy) => onChange({ ...value, strategy }); - }; const toggleProvider = (providerName: string) => { const next = new Set(selectedExtras); - if (next.has(providerName)) { - next.delete(providerName); - } else { - next.add(providerName); - } - onChange({ - ...value, - extra_provider_names: Array.from(next), - }); + if (next.has(providerName)) next.delete(providerName); + else next.add(providerName); + onChange({ ...value, extra_provider_names: Array.from(next) }); }; - const routeDetail = ( - entry: (typeof selectedEntries)[number], - ): string | undefined => { - if (entry.availability !== "ready") { + const routeDetail = (entry: (typeof selectedEntries)[number]): string | undefined => { + if (entry.availability !== "ready") return t(`chatgptOAuthRouting.status.${entry.availability}`); - } - if (entry.failureKind) { + if (entry.failureKind) return t(`chatgptOAuthRouting.quota.failure.${entry.failureKind}.label`); - } - if (entry.routeReadiness === "checking") { + if (entry.routeReadiness === "checking") return t("chatgptOAuthRouting.quota.checking"); - } return undefined; }; @@ -297,12 +206,9 @@ export function ChatGPTOAuthRoutingSection({ > {showOverrideMode ? (
-
-

- {t("chatgptOAuthRouting.mode.label")} -

-
- +

+ {t("chatgptOAuthRouting.mode.label")} +

- {!providerDefaultsAvailable ? (
{t("chatgptOAuthRouting.mode.noProviderDefault")} @@ -333,12 +238,9 @@ export function ChatGPTOAuthRoutingSection({ ) : null}
-
-

- {t("chatgptOAuthRouting.strategyLabel")} -

-
- +

+ {t("chatgptOAuthRouting.strategyLabel")} +

-
-
-

- {membershipEditable - ? t("chatgptOAuthRouting.availableExtraAccountsLabel") - : t("chatgptOAuthRouting.poolMembershipLabel")} -

-
+ - {!membershipEditable ? ( -
- {selectedEntries.length > 1 - ? t("chatgptOAuthRouting.membershipManagedAtProvider", { - provider: membershipManagedByLabel || currentProvider, - }) - : t("chatgptOAuthRouting.membershipConfigureProviderFirst", { - provider: membershipManagedByLabel || currentProvider, - })} -
- ) : isLoading ? ( -
- {t("chatgptOAuthRouting.loadingAccounts")} -
- ) : readyExtraProviders.length > 0 ? ( -
- {readyExtraProviders.map((provider) => { - const selected = selectedExtras.has(provider.name); - const failureKind = getQuotaFailureKind( - quotaByName?.get(provider.name), - ); - return ( - - ); - })} -
- ) : ( -
- {t("chatgptOAuthRouting.noReadyExtras")} -
- )} -
+ -
-
-

- {t("chatgptOAuthRouting.selectedAccountsLabel")} -

- - {t("chatgptOAuthRouting.selectedCount", { - count: selectedEntries.length, - })} - -
- - {selectedEntries.length > 0 ? ( -
-
- {selectedEntries.map((entry) => ( -
-
- - {entry.label} - - - {t(`chatgptOAuthRouting.role.${entry.role}`)} - - {entry.availability !== "ready" && ( - - {t(`chatgptOAuthRouting.status.${entry.availability}`)} - - )} - {entry.routeReadiness !== "healthy" && ( - - {t(routeLabelKey(entry.routeReadiness))} - - )} -
-
- ))} -
-
- ) : ( -
- {t("chatgptOAuthRouting.emptySelected")} -
- )} -
- -
-

- {t("chatgptOAuthRouting.poolStateTitle")} -

- -
- ({ - name: entry.name, - label: entry.label, - detail: routeDetail(entry), - }))} - emptyLabel={t("chatgptOAuthRouting.emptyGroup")} - /> - ({ - name: entry.name, - label: entry.label, - detail: routeDetail(entry), - }))} - emptyLabel={t("chatgptOAuthRouting.emptyGroup")} - /> - ({ - name: entry.name, - label: entry.label, - detail: routeDetail(entry), - }))} - emptyLabel={t("chatgptOAuthRouting.emptyGroup")} - /> -
-
+ {canManageProviders && onSave && (isDirty || saving) ? ( diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/chatgpt-oauth-state-group.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/chatgpt-oauth-state-group.tsx new file mode 100644 index 00000000..dc06c72e --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/config-sections/chatgpt-oauth-state-group.tsx @@ -0,0 +1,51 @@ +import { Badge } from "@/components/ui/badge"; + +interface StateGroupEntry { + name: string; + label: string; + detail?: string; +} + +interface StateGroupProps { + title: string; + count: number; + variant: "success" | "warning" | "outline" | "destructive"; + entries: StateGroupEntry[]; + emptyLabel: string; +} + +export function StateGroup({ + title, + count, + variant, + entries, + emptyLabel, +}: StateGroupProps) { + return ( +
+
+

+ {title} +

+ {count} +
+ {entries.length > 0 ? ( +
+ {entries.map((entry) => ( +
+ {entry.label} + {entry.detail ? ( + · {entry.detail} + ) : null} +
+ ))} +
+ ) : ( +

{emptyLabel}

+ )} +
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/overview-sections/chatgpt-oauth-routing-summary-section.tsx b/ui/web/src/pages/agents/agent-detail/overview-sections/chatgpt-oauth-routing-summary-section.tsx index 17310049..3498735a 100644 --- a/ui/web/src/pages/agents/agent-detail/overview-sections/chatgpt-oauth-routing-summary-section.tsx +++ b/ui/web/src/pages/agents/agent-detail/overview-sections/chatgpt-oauth-routing-summary-section.tsx @@ -11,12 +11,13 @@ import { } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-statuses"; import { useChatGPTOAuthProviderQuotas } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-quotas"; import { useAuthStore } from "@/stores/use-auth-store"; -import type { AgentData, EffectiveChatGPTOAuthRoutingStrategy } from "@/types/agent"; +import type { AgentData } from "@/types/agent"; import { getChatGPTOAuthProviderRouting } from "@/types/provider"; import { ChatGPTOAuthQuotaBadges } from "../chatgpt-oauth-quota-badges"; import { normalizeChatGPTOAuthRouting, resolveEffectiveChatGPTOAuthRouting, + strategyLabelKey, } from "../agent-display-utils"; import { summarizeQuotaHealth } from "../chatgpt-oauth-quota-utils"; @@ -35,13 +36,6 @@ function statusBadgeClass(availability: ChatGPTOAuthAvailability): string { return "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"; } -function strategyLabelKey( - strategy: EffectiveChatGPTOAuthRoutingStrategy, -): string { - if (strategy === "round_robin") return "chatgptOAuthRouting.strategy.roundRobin"; - if (strategy === "priority_order") return "chatgptOAuthRouting.strategy.priorityOrder"; - return "chatgptOAuthRouting.strategy.primaryFirst"; -} export function ChatGPTOAuthRoutingSummarySection({ agent,