fix(providers): post-merge fixes for Codex OAuth pools (#450)

- 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
This commit is contained in:
viettranx committed 2026-03-27 10:19:16 +07:00
1 parent b9c1731e31
commit 74dc086a80
35 files changed
+1695 -1455

No files matched your search

+1 -1
View File
@@ -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 {
+4 -4
View File
@@ -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 (
+35 -11
View File
@@ -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=
+3 -1
View File
@@ -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,
+6 -54
View File
@@ -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 {
+4 -17
View File
@@ -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)
})
@@ -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(),
+10 -1
View File
@@ -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
}
@@ -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
+10 -1
View File
@@ -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)
}
+2 -1
View File
@@ -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(),
+26 -1
View File
@@ -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()
}
+2 -13
View File
@@ -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:]...)
+21 -2
View File
@@ -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
+69
View File
@@ -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
}
+15
View File
@@ -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)
}
+1 -1
View File
@@ -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
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_spans_metadata_gin;
DROP INDEX IF EXISTS idx_sessions_metadata_gin;
@@ -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);
@@ -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<ChatGPTOAuthRoutingConfig>(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 <DetailPageSkeleton tabs={0} />;
@@ -346,116 +288,24 @@ export function AgentCodexPoolPage() {
}
};
const summaryTone =
savedStrategy !== "round_robin"
? "manual"
: roundRobinVerified
? "healthy"
: "warning";
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden p-3 sm:p-4 xl:p-5 [@media(max-height:760px)]:p-2.5">
<section className="shrink-0 rounded-xl border bg-card/70 px-3 py-2.5 shadow-sm sm:px-4 sm:py-3 [@media(max-height:760px)]:py-2">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between [@media(max-height:760px)]:gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-col items-start gap-1.5 [@media(max-height:760px)]:flex-row [@media(max-height:760px)]:items-center [@media(max-height:760px)]:gap-2">
<Button
variant="ghost"
size="sm"
className="mb-1.5 h-7 gap-1.5 px-2 text-xs sm:h-8 sm:px-2.5 sm:text-sm [@media(max-height:760px)]:mb-0 [@media(max-height:760px)]:h-7 [@media(max-height:760px)]:px-1.5"
onClick={() => navigate(`/agents/${agent.id}`)}
>
<ArrowLeft className="h-3.5 w-3.5" />
{t("chatgptOAuthRouting.backToAgent")}
</Button>
<CodexPoolPageHeader
title={agentDisplayName(agent, t("card.unnamedAgent"))}
savedStrategy={savedStrategy}
summaryTone={summaryTone}
overrideMode={savedEffectiveRouting.overrideMode}
recentRequestCount={activity.stats_sample_size ?? 0}
runtimeHealthyCount={runtimeHealthyEntries.length}
runtimeDegradedCount={runtimeDegradedEntries.length}
runtimeCriticalCount={runtimeCriticalEntries.length}
isDirty={isDirty}
canManageProviders={canManageProviders}
isEligible={isEligible}
onBack={() => navigate(`/agents/${agent.id}`)}
/>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl [@media(max-height:760px)]:text-lg">
{t("chatgptOAuthRouting.pageTitle")}
</h1>
</div>
<p className="mt-1 max-w-3xl text-xs text-muted-foreground sm:text-sm [@media(max-height:760px)]:hidden">
{t("chatgptOAuthRouting.pageDescription", { name: title })}
</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5 [@media(max-height:760px)]:mt-1.5">
<Badge
variant="outline"
className={cn(
"px-2.5 py-1 font-semibold",
summaryTone === "healthy" &&
"border-emerald-500/30 bg-emerald-500/[0.07] text-emerald-700 dark:text-emerald-200",
summaryTone === "warning" &&
"border-amber-500/30 bg-amber-500/[0.08] text-amber-800 dark:text-amber-200",
summaryTone === "manual" && "border-border/70 bg-muted/20",
)}
>
{t(`chatgptOAuthRouting.verdict.${summaryTone}.title`)}
</Badge>
<Badge variant="outline">
{t(strategyLabelKey(savedStrategy))}
</Badge>
<Badge variant="outline">
{savedEffectiveRouting.overrideMode === "inherit"
? t("chatgptOAuthRouting.mode.inherit")
: t("chatgptOAuthRouting.mode.custom")}
</Badge>
<Badge variant="outline" className="[@media(max-height:760px)]:hidden">
{recentRequestCount > 0
? t("chatgptOAuthRouting.sampleBadge", {
count: recentRequestCount,
})
: t("chatgptOAuthRouting.noSampleBadge")}
</Badge>
<Badge variant="success">
{t("chatgptOAuthRouting.healthState.healthy")}{" "}
{runtimeHealthyEntries.length}
</Badge>
{runtimeDegradedEntries.length > 0 ? (
<Badge variant="warning">
{t("chatgptOAuthRouting.healthState.degraded")}{" "}
{runtimeDegradedEntries.length}
</Badge>
) : null}
{runtimeCriticalEntries.length > 0 ? (
<Badge variant="destructive">
{t("chatgptOAuthRouting.healthState.critical")}{" "}
{runtimeCriticalEntries.length}
</Badge>
) : null}
{isDirty ? (
<Badge variant="warning">
{t("chatgptOAuthRouting.draftBadge")}
</Badge>
) : null}
</div>
</div>
{canManageProviders ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 shrink-0 self-start px-3 [@media(max-height:760px)]:h-7"
>
<Link to={ROUTES.PROVIDERS}>
{t("chatgptOAuthRouting.openProviders")}
</Link>
</Button>
) : null}
</div>
</section>
{!isEligible ? (
<Alert className="mt-3 shrink-0">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>
{t("chatgptOAuthRouting.pageUnsupportedTitle")}
</AlertTitle>
<AlertDescription>
{t("chatgptOAuthRouting.pageUnsupportedDescription")}
</AlertDescription>
</Alert>
) : (
{isEligible ? (
<div className="mt-2 flex min-h-0 flex-1 flex-col gap-3 overflow-hidden [@media(max-height:760px)]:gap-2">
<div className="grid min-h-0 flex-1 gap-3 overflow-y-auto overscroll-contain lg:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.95fr)] lg:items-start lg:overflow-hidden [@media(max-height:760px)]:gap-2">
<CodexPoolActivityPanel
@@ -502,7 +352,7 @@ export function AgentCodexPoolPage() {
</div>
</div>
</div>
)}
) : null}
</div>
);
}
@@ -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,
@@ -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,
@@ -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";
@@ -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<typeof getRouteReadiness>,
): "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<typeof getRouteReadiness>): 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 (
<div className="flex h-full items-center justify-between gap-1 rounded-md border bg-background/70 px-2 py-1 xl:px-2.5">
<p className="truncate text-[9px] font-medium text-muted-foreground xl:text-[10px]">
{label}
</p>
<p className="shrink-0 text-[12px] font-semibold leading-tight tabular-nums xl:text-[13px]">
{value}
</p>
</div>
);
}
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 (
<div
className={cn(
"rounded-lg border border-dashed text-muted-foreground",
compact ? "px-3 py-3 text-xs" : "px-4 py-4 text-sm",
className,
)}
>
{t("chatgptOAuthRouting.loadingEvidence")}
</div>
);
}
if (recentRequests.length === 0) {
return compact ? (
<div
className={cn(
"rounded-lg border border-dashed bg-muted/5 px-3 py-3 text-xs text-muted-foreground",
className,
)}
>
{t("chatgptOAuthRouting.noEvidence")}
</div>
) : (
<div className={cn("rounded-lg border border-dashed bg-muted/5", className)}>
<EmptyState
icon={Route}
title={t("chatgptOAuthRouting.sequenceEmptyTitle")}
description={t("chatgptOAuthRouting.noEvidence")}
className="py-6"
/>
</div>
);
}
if (compact) {
return (
<div
className={cn(
"overflow-x-auto overflow-y-hidden overscroll-contain pb-1",
className,
)}
>
<div className="flex min-w-max gap-2">
{recentRequests.map((request, index) => {
const providerSummary =
requestProviderSummary(request) ||
request.model ||
t("chatgptOAuthRouting.unknownModel");
const accent = requestAccentClasses(requestAccentSeed(request));
return (
<div
key={request.span_id}
className={cn(
"relative isolate flex min-h-[4.85rem] w-[12.75rem] shrink-0 snap-start flex-col overflow-hidden rounded-lg border bg-background/80 p-2.5",
"lg:min-h-[5.35rem] lg:w-[13.75rem] xl:min-h-[5.85rem] xl:w-[14.75rem] sm:xl:w-[15rem]",
"[@media(max-height:760px)]:min-h-[4.35rem] [@media(max-height:760px)]:w-[11.75rem] [@media(max-height:760px)]:p-1.5",
"transition-colors hover:bg-background",
accent.card,
)}
>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 top-0 h-1 bg-gradient-to-r",
accent.stripe,
)}
/>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 bg-gradient-to-br",
accent.glow,
)}
/>
<Button
asChild
variant="ghost"
size="icon"
className={cn(
"absolute right-1.5 top-1.5 z-10 h-5 w-5 shrink-0 rounded-full border border-transparent xl:right-2 xl:top-2 xl:h-6 xl:w-6",
accent.trace,
)}
>
<Link
to={`/traces/${request.trace_id}`}
aria-label={t("chatgptOAuthRouting.openTrace")}
title={t("chatgptOAuthRouting.openTrace")}
>
<ArrowUpRight className="h-2.5 w-2.5 xl:h-3 xl:w-3" />
</Link>
</Button>
<div className="relative z-10 flex min-w-0 items-start gap-2 pr-6 xl:pr-7">
<div className="flex min-w-0 flex-1 items-start gap-2">
<div
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full border text-[9px] font-semibold tabular-nums",
"xl:h-6 xl:w-6 xl:text-[10px]",
accent.index,
)}
>
{index + 1}
</div>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
<span
aria-hidden
className={cn(
"h-2 w-2 shrink-0 rounded-full",
accent.marker,
)}
/>
<p className="truncate text-[13px] font-semibold leading-tight xl:text-sm">
{providerSummary}
</p>
</div>
<p className="truncate text-[10px] text-muted-foreground xl:text-[11px]">
{request.model || t("chatgptOAuthRouting.unknownModel")}
</p>
</div>
</div>
</div>
<div className="relative z-10 mt-1 flex items-center gap-1.5 overflow-hidden whitespace-nowrap text-[10px] text-muted-foreground xl:mt-2 xl:gap-2 xl:text-[11px]">
<Badge
variant={request.used_failover ? "warning" : "outline"}
className={cn(
"h-5 shrink-0 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs",
request.used_failover ? undefined : accent.directBadge,
)}
>
{request.used_failover
? t("chatgptOAuthRouting.monitorFailoverLabel")
: t("chatgptOAuthRouting.monitorDirectLabel")}
</Badge>
<span className="shrink-0 font-medium tabular-nums">
{formatRelativeTime(request.started_at)}
</span>
<span aria-hidden className="shrink-0 text-muted-foreground/70">
·
</span>
<span className="shrink-0 font-medium tabular-nums">
{formatDuration(request.duration_ms)}
</span>
{request.attempt_count > 1 && (
<>
<span
aria-hidden
className="shrink-0 text-muted-foreground/70"
>
·
</span>
<span className="shrink-0 font-medium tabular-nums">
{request.attempt_count}x
</span>
</>
)}
{request.status !== "completed" && (
<Badge
variant={requestStatusVariant(request.status)}
className="h-5 shrink-0 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs"
>
{request.status}
</Badge>
)}
</div>
{request.used_failover &&
request.failover_providers &&
request.failover_providers.length > 0 && (
<p className="relative z-10 mt-0.5 truncate text-[10px] text-muted-foreground xl:text-[11px]">
{t("chatgptOAuthRouting.failoverHint", {
providers: request.failover_providers.join(", "),
})}
</p>
)}
</div>
);
})}
</div>
</div>
);
}
return (
<div className={cn("min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1", className)}>
<div className="space-y-2">
{recentRequests.map((request) => {
const providerSummary = requestProviderSummary(request);
return (
<div
key={request.span_id}
className={cn(
"rounded-lg border bg-muted/10",
"px-3 py-2.5",
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={request.used_failover ? "warning" : "info"}>
{request.used_failover
? t("chatgptOAuthRouting.monitorFailoverLabel")
: t("chatgptOAuthRouting.monitorDirectLabel")}
</Badge>
{providerSummary ? (
<Badge variant="outline">{providerSummary}</Badge>
) : (
<Badge variant="secondary">{request.status}</Badge>
)}
</div>
<p className="truncate text-sm font-medium">
{request.model || t("chatgptOAuthRouting.unknownModel")}
</p>
<div
className={cn(
"flex flex-wrap gap-x-3 gap-y-1 text-muted-foreground",
"text-xs",
)}
>
<span>{formatRelativeTime(request.started_at)}</span>
<span>{formatDuration(request.duration_ms)}</span>
<span>
{t("chatgptOAuthRouting.attemptCount", {
count: request.attempt_count,
})}
</span>
</div>
{request.used_failover &&
request.failover_providers &&
request.failover_providers.length > 0 && (
<p
className={cn(
"text-muted-foreground",
"text-xs",
)}
>
{t("chatgptOAuthRouting.failoverHint", {
providers: request.failover_providers.join(", "),
})}
</p>
)}
</div>
<div className="flex items-center gap-2">
<Badge variant={requestStatusVariant(request.status)}>
{request.status}
</Badge>
<Button
asChild
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
>
<Link
to={`/traces/${request.trace_id}`}
aria-label={t("chatgptOAuthRouting.openTrace")}
title={t("chatgptOAuthRouting.openTrace")}
>
<ArrowUpRight className="h-4 w-4" />
</Link>
</Button>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
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}
>
<RefreshCw
className={`h-4 w-4${fetching ? " animate-spin" : ""}`}
/>
<RefreshCw className={`h-4 w-4${fetching ? " animate-spin" : ""}`} />
{t("chatgptOAuthRouting.refreshEvidence")}
</Button>
</div>
@@ -637,10 +118,7 @@ export function CodexPoolActivityPanel({
/>
<MonitorStat
label={t("chatgptOAuthRouting.metrics.failovers")}
value={String(
recentRequests.filter((request) => request.used_failover)
.length,
)}
value={String(recentRequests.filter((r) => r.used_failover).length)}
/>
</div>
@@ -655,7 +133,6 @@ export function CodexPoolActivityPanel({
})}
</Badge>
</div>
<CodexPoolRecentRequestsList
recentRequests={recentRequests}
loading={fetching && recentRequests.length === 0}
@@ -670,9 +147,7 @@ export function CodexPoolActivityPanel({
{t("chatgptOAuthRouting.poolMembersTitle")}
</h3>
<Badge variant="outline">
{t("chatgptOAuthRouting.selectedCount", {
count: entries.length,
})}
{t("chatgptOAuthRouting.selectedCount", { count: entries.length })}
</Badge>
</div>
@@ -688,229 +163,13 @@ export function CodexPoolActivityPanel({
) : (
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1">
<div className="grid auto-rows-min content-start gap-2.5 [grid-template-columns:repeat(auto-fit,minmax(min(100%,12.25rem),1fr))] lg:[grid-template-columns:repeat(auto-fit,minmax(min(100%,14rem),1fr))] xl:[grid-template-columns:repeat(auto-fit,minmax(min(100%,15rem),1fr))] [@media(max-height:760px)]:gap-2 [@media(max-height:760px)]:[grid-template-columns:repeat(auto-fit,minmax(min(100%,12.25rem),1fr))]">
{routeEntries.map((entry) => {
const accent = requestAccentClasses(entry.name);
return (
<div
key={entry.name}
className={cn(
"relative isolate overflow-hidden rounded-lg border bg-background/80 p-2 lg:min-h-[10.5rem] lg:p-2.5 xl:min-h-[11rem]",
"[@media(max-height:760px)]:min-h-0 [@media(max-height:760px)]:p-1.5",
accent.card,
)}
>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 top-0 h-1 bg-gradient-to-r",
accent.stripe,
)}
/>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 bg-gradient-to-br",
accent.glow,
)}
/>
<div className="relative z-10">
{(() => {
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 (
<>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-1.5 xl:gap-2">
<span className="inline-flex min-w-0 items-center gap-1 truncate text-[13px] font-medium xl:gap-1.5 xl:text-sm">
<span
aria-hidden
className={cn(
"h-2 w-2 shrink-0 rounded-full",
accent.marker,
)}
/>
<span className="truncate">{entry.label}</span>
</span>
<Badge
variant="outline"
className={cn(
"h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs",
poolRoleBadgeClass(entry.role),
)}
>
{t(`chatgptOAuthRouting.role.${entry.role}`)}
</Badge>
{showAvailabilityBadge && (
<Badge
variant={availabilityVariant(entry.availability)}
className="h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs"
>
{t(
`chatgptOAuthRouting.status.${entry.availability}`,
)}
</Badge>
)}
{showHealthBadge && (
<Badge
variant={runtimeHealthVariant(entry.healthState)}
className="h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs"
>
{t(
`chatgptOAuthRouting.healthState.${entry.healthState}`,
)}
</Badge>
)}
{showRouteBadge && (
<Badge
variant={routeBadgeVariant(entry.routeReadiness)}
className="h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs"
>
{t(routeLabelKey(entry.routeReadiness))}
</Badge>
)}
</div>
{entry.label !== entry.name && (
<p className="truncate font-mono text-[10px] text-muted-foreground xl:text-xs">
{entry.name}
</p>
)}
</div>
{showProviderLinks && entry.providerHref && (
<Button
asChild
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 shrink-0 rounded-full xl:h-8 xl:w-8",
accent.trace,
)}
>
<Link
to={entry.providerHref}
aria-label={t("chatgptOAuthRouting.openProvider")}
title={t("chatgptOAuthRouting.openProvider")}
>
<ArrowUpRight className="h-3.5 w-3.5 xl:h-4 xl:w-4" />
</Link>
</Button>
)}
</div>
<ChatGPTOAuthQuotaStrip
quota={entry.quota}
className="mt-1 xl:mt-1.5"
compact
/>
<div className="mt-1 rounded-md border bg-background/75 px-2 py-1.5 xl:mt-1.5 xl:px-2.5">
<div className="flex items-center justify-between gap-2">
{totalOutcomes > 0 ? (
<p className="truncate text-[11px] font-medium text-foreground xl:text-xs">
{t(
"chatgptOAuthRouting.runtimeHealthSummary",
{
rate: entry.successRate,
score: entry.healthScore,
},
)}
</p>
) : (
<p className="truncate text-[11px] text-muted-foreground xl:text-xs">
{t("chatgptOAuthRouting.noRuntimeSample")}
</p>
)}
{entry.consecutiveFailures > 0 && (
<Badge
variant={
entry.consecutiveFailures >= 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,
})}
</Badge>
)}
</div>
<div className="mt-1 flex h-2 overflow-hidden rounded-full bg-muted xl:mt-1.5">
{totalOutcomes > 0 ? (
<>
<div
className="h-full bg-emerald-500 transition-all"
style={{ width: `${barWidths.success}%` }}
/>
{barWidths.failure > 0 && (
<div
className="h-full bg-rose-500/80 transition-all"
style={{ width: `${barWidths.failure}%` }}
/>
)}
</>
) : (
<div className="h-full w-full bg-muted" />
)}
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-[10px] text-muted-foreground xl:mt-1.5 xl:gap-3 xl:text-[11px]">
<span>
{t("chatgptOAuthRouting.runtimeSuccessCompact", {
count: entry.successCount,
})}
</span>
<span>
{t("chatgptOAuthRouting.runtimeFailureCompact", {
count: entry.failureCount,
})}
</span>
{entry.lastFailureAt && (
<span className="truncate">
{t("chatgptOAuthRouting.lastFailureLabel", {
value: formatRelativeTime(entry.lastFailureAt),
})}
</span>
)}
</div>
</div>
<div className="mt-1 grid gap-1 sm:grid-cols-3 xl:mt-1.5 xl:gap-1.5">
<MemberMetric
label={t("chatgptOAuthRouting.monitorDirectLabel")}
value={String(entry.directSelectionCount)}
/>
<MemberMetric
label={t("chatgptOAuthRouting.monitorFailoverLabel")}
value={String(entry.failoverServeCount)}
/>
<MemberMetric
label={t("chatgptOAuthRouting.lastSeenLabel")}
value={
entry.lastUsedAt
? formatRelativeTime(entry.lastUsedAt)
: t("chatgptOAuthRouting.never")
}
/>
</div>
</>
);
})()}
</div>
</div>
);
})}
{entries.map((entry) => (
<CodexPoolMemberCard
key={entry.name}
entry={entry}
showProviderLinks={showProviderLinks}
/>
))}
</div>
</div>
)}
@@ -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({
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-3">
<CodexPoolRecentRequestsList
recentRequests={recentRequests}
loading={loading}
/>
<CodexPoolRecentRequestsList recentRequests={recentRequests} loading={loading} />
</CardContent>
</Card>
);
@@ -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;
}
@@ -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 (
<div className="flex h-full items-center justify-between gap-1 rounded-md border bg-background/70 px-2 py-1 xl:px-2.5">
<p className="truncate text-[9px] font-medium text-muted-foreground xl:text-[10px]">
{label}
</p>
<p className="shrink-0 text-[12px] font-semibold leading-tight tabular-nums xl:text-[13px]">
{value}
</p>
</div>
);
}
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 (
<div
className={cn(
"relative isolate overflow-hidden rounded-lg border bg-background/80 p-2 lg:min-h-[10.5rem] lg:p-2.5 xl:min-h-[11rem]",
"[@media(max-height:760px)]:min-h-0 [@media(max-height:760px)]:p-1.5",
accent.card,
)}
>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 top-0 h-1 bg-gradient-to-r",
accent.stripe,
)}
/>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 bg-gradient-to-br",
accent.glow,
)}
/>
<div className="relative z-10">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-1.5 xl:gap-2">
<span className="inline-flex min-w-0 items-center gap-1 truncate text-[13px] font-medium xl:gap-1.5 xl:text-sm">
<span
aria-hidden
className={cn("h-2 w-2 shrink-0 rounded-full", accent.marker)}
/>
<span className="truncate">{entry.label}</span>
</span>
<Badge
variant="outline"
className={cn(
"h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs",
poolRoleBadgeClass(entry.role),
)}
>
{t(`chatgptOAuthRouting.role.${entry.role}`)}
</Badge>
{showAvailabilityBadge && (
<Badge
variant={availabilityVariant(entry.availability)}
className="h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs"
>
{t(`chatgptOAuthRouting.status.${entry.availability}`)}
</Badge>
)}
{showHealthBadge && (
<Badge
variant={runtimeHealthVariant(entry.healthState)}
className="h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs"
>
{t(`chatgptOAuthRouting.healthState.${entry.healthState}`)}
</Badge>
)}
{showRouteBadge && (
<Badge
variant={routeBadgeVariant(routeReadiness)}
className="h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs"
>
{t(routeLabelKey(routeReadiness))}
</Badge>
)}
</div>
{entry.label !== entry.name && (
<p className="truncate font-mono text-[10px] text-muted-foreground xl:text-xs">
{entry.name}
</p>
)}
</div>
{showProviderLinks && entry.providerHref && (
<Button
asChild
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 shrink-0 rounded-full xl:h-8 xl:w-8",
accent.trace,
)}
>
<Link
to={entry.providerHref}
aria-label={t("chatgptOAuthRouting.openProvider")}
title={t("chatgptOAuthRouting.openProvider")}
>
<ArrowUpRight className="h-3.5 w-3.5 xl:h-4 xl:w-4" />
</Link>
</Button>
)}
</div>
<ChatGPTOAuthQuotaStrip
quota={entry.quota}
className="mt-1 xl:mt-1.5"
compact
/>
<div className="mt-1 rounded-md border bg-background/75 px-2 py-1.5 xl:mt-1.5 xl:px-2.5">
<div className="flex items-center justify-between gap-2">
{totalOutcomes > 0 ? (
<p className="truncate text-[11px] font-medium text-foreground xl:text-xs">
{t("chatgptOAuthRouting.runtimeHealthSummary", {
rate: entry.successRate,
score: entry.healthScore,
})}
</p>
) : (
<p className="truncate text-[11px] text-muted-foreground xl:text-xs">
{t("chatgptOAuthRouting.noRuntimeSample")}
</p>
)}
{entry.consecutiveFailures > 0 && (
<Badge
variant={entry.consecutiveFailures >= 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,
})}
</Badge>
)}
</div>
<div className="mt-1 flex h-2 overflow-hidden rounded-full bg-muted xl:mt-1.5">
{totalOutcomes > 0 ? (
<>
<div
className="h-full bg-emerald-500 transition-all"
style={{ width: `${barWidths.success}%` }}
/>
{barWidths.failure > 0 && (
<div
className="h-full bg-rose-500/80 transition-all"
style={{ width: `${barWidths.failure}%` }}
/>
)}
</>
) : (
<div className="h-full w-full bg-muted" />
)}
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-[10px] text-muted-foreground xl:mt-1.5 xl:gap-3 xl:text-[11px]">
<span>
{t("chatgptOAuthRouting.runtimeSuccessCompact", {
count: entry.successCount,
})}
</span>
<span>
{t("chatgptOAuthRouting.runtimeFailureCompact", {
count: entry.failureCount,
})}
</span>
{entry.lastFailureAt && (
<span className="truncate">
{t("chatgptOAuthRouting.lastFailureLabel", {
value: formatRelativeTime(entry.lastFailureAt),
})}
</span>
)}
</div>
</div>
<div className="mt-1 grid gap-1 sm:grid-cols-3 xl:mt-1.5 xl:gap-1.5">
<MemberMetric
label={t("chatgptOAuthRouting.monitorDirectLabel")}
value={String(entry.directSelectionCount)}
/>
<MemberMetric
label={t("chatgptOAuthRouting.monitorFailoverLabel")}
value={String(entry.failoverServeCount)}
/>
<MemberMetric
label={t("chatgptOAuthRouting.lastSeenLabel")}
value={
entry.lastUsedAt
? formatRelativeTime(entry.lastUsedAt)
: t("chatgptOAuthRouting.never")
}
/>
</div>
</div>
</div>
);
}
@@ -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 (
<>
<section className="shrink-0 rounded-xl border bg-card/70 px-3 py-2.5 shadow-sm sm:px-4 sm:py-3 [@media(max-height:760px)]:py-2">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between [@media(max-height:760px)]:gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-col items-start gap-1.5 [@media(max-height:760px)]:flex-row [@media(max-height:760px)]:items-center [@media(max-height:760px)]:gap-2">
<Button
variant="ghost"
size="sm"
className="mb-1.5 h-7 gap-1.5 px-2 text-xs sm:h-8 sm:px-2.5 sm:text-sm [@media(max-height:760px)]:mb-0 [@media(max-height:760px)]:h-7 [@media(max-height:760px)]:px-1.5"
onClick={onBack}
>
<ArrowLeft className="h-3.5 w-3.5" />
{t("chatgptOAuthRouting.backToAgent")}
</Button>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl [@media(max-height:760px)]:text-lg">
{t("chatgptOAuthRouting.pageTitle")}
</h1>
</div>
<p className="mt-1 max-w-3xl text-xs text-muted-foreground sm:text-sm [@media(max-height:760px)]:hidden">
{t("chatgptOAuthRouting.pageDescription", { name: title })}
</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5 [@media(max-height:760px)]:mt-1.5">
<Badge
variant="outline"
className={cn(
"px-2.5 py-1 font-semibold",
summaryTone === "healthy" &&
"border-emerald-500/30 bg-emerald-500/[0.07] text-emerald-700 dark:text-emerald-200",
summaryTone === "warning" &&
"border-amber-500/30 bg-amber-500/[0.08] text-amber-800 dark:text-amber-200",
summaryTone === "manual" && "border-border/70 bg-muted/20",
)}
>
{t(`chatgptOAuthRouting.verdict.${summaryTone}.title`)}
</Badge>
<Badge variant="outline">
{t(strategyLabelKey(savedStrategy))}
</Badge>
<Badge variant="outline">
{overrideMode === "inherit"
? t("chatgptOAuthRouting.mode.inherit")
: t("chatgptOAuthRouting.mode.custom")}
</Badge>
<Badge variant="outline" className="[@media(max-height:760px)]:hidden">
{recentRequestCount > 0
? t("chatgptOAuthRouting.sampleBadge", { count: recentRequestCount })
: t("chatgptOAuthRouting.noSampleBadge")}
</Badge>
<Badge variant="success">
{t("chatgptOAuthRouting.healthState.healthy")} {runtimeHealthyCount}
</Badge>
{runtimeDegradedCount > 0 ? (
<Badge variant="warning">
{t("chatgptOAuthRouting.healthState.degraded")} {runtimeDegradedCount}
</Badge>
) : null}
{runtimeCriticalCount > 0 ? (
<Badge variant="destructive">
{t("chatgptOAuthRouting.healthState.critical")} {runtimeCriticalCount}
</Badge>
) : null}
{isDirty ? (
<Badge variant="warning">
{t("chatgptOAuthRouting.draftBadge")}
</Badge>
) : null}
</div>
</div>
{canManageProviders ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 shrink-0 self-start px-3 [@media(max-height:760px)]:h-7"
>
<Link to={ROUTES.PROVIDERS}>
{t("chatgptOAuthRouting.openProviders")}
</Link>
</Button>
) : null}
</div>
</section>
{!isEligible ? (
<Alert className="mt-3 shrink-0">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>
{t("chatgptOAuthRouting.pageUnsupportedTitle")}
</AlertTitle>
<AlertDescription>
{t("chatgptOAuthRouting.pageUnsupportedDescription")}
</AlertDescription>
</Alert>
) : null}
</>
);
}
@@ -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 (
<div
className={cn(
"rounded-lg border border-dashed text-muted-foreground",
compact ? "px-3 py-3 text-xs" : "px-4 py-4 text-sm",
className,
)}
>
{t("chatgptOAuthRouting.loadingEvidence")}
</div>
);
}
if (recentRequests.length === 0) {
return compact ? (
<div
className={cn(
"rounded-lg border border-dashed bg-muted/5 px-3 py-3 text-xs text-muted-foreground",
className,
)}
>
{t("chatgptOAuthRouting.noEvidence")}
</div>
) : (
<div className={cn("rounded-lg border border-dashed bg-muted/5", className)}>
<EmptyState
icon={Route}
title={t("chatgptOAuthRouting.sequenceEmptyTitle")}
description={t("chatgptOAuthRouting.noEvidence")}
className="py-6"
/>
</div>
);
}
if (compact) {
return (
<div
className={cn(
"overflow-x-auto overflow-y-hidden overscroll-contain pb-1",
className,
)}
>
<div className="flex min-w-max gap-2">
{recentRequests.map((request, index) => (
<CompactRequestCard key={request.span_id} request={request} index={index} />
))}
</div>
</div>
);
}
return (
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1",
className,
)}
>
<div className="space-y-2">
{recentRequests.map((request) => (
<FullRequestRow key={request.span_id} request={request} />
))}
</div>
</div>
);
}
@@ -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]!;
}
@@ -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 (
<div
className={cn(
"relative isolate flex min-h-[4.85rem] w-[12.75rem] shrink-0 snap-start flex-col overflow-hidden rounded-lg border bg-background/80 p-2.5",
"lg:min-h-[5.35rem] lg:w-[13.75rem] xl:min-h-[5.85rem] xl:w-[14.75rem] sm:xl:w-[15rem]",
"[@media(max-height:760px)]:min-h-[4.35rem] [@media(max-height:760px)]:w-[11.75rem] [@media(max-height:760px)]:p-1.5",
"transition-colors hover:bg-background",
accent.card,
)}
>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 top-0 h-1 bg-gradient-to-r",
accent.stripe,
)}
/>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 bg-gradient-to-br",
accent.glow,
)}
/>
<Button
asChild
variant="ghost"
size="icon"
className={cn(
"absolute right-1.5 top-1.5 z-10 h-5 w-5 shrink-0 rounded-full border border-transparent xl:right-2 xl:top-2 xl:h-6 xl:w-6",
accent.trace,
)}
>
<Link
to={`/traces/${request.trace_id}`}
aria-label={t("chatgptOAuthRouting.openTrace")}
title={t("chatgptOAuthRouting.openTrace")}
>
<ArrowUpRight className="h-2.5 w-2.5 xl:h-3 xl:w-3" />
</Link>
</Button>
<div className="relative z-10 flex min-w-0 items-start gap-2 pr-6 xl:pr-7">
<div className="flex min-w-0 flex-1 items-start gap-2">
<div
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full border text-[9px] font-semibold tabular-nums",
"xl:h-6 xl:w-6 xl:text-[10px]",
accent.index,
)}
>
{index + 1}
</div>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
<span
aria-hidden
className={cn("h-2 w-2 shrink-0 rounded-full", accent.marker)}
/>
<p className="truncate text-[13px] font-semibold leading-tight xl:text-sm">
{providerSummary}
</p>
</div>
<p className="truncate text-[10px] text-muted-foreground xl:text-[11px]">
{request.model || t("chatgptOAuthRouting.unknownModel")}
</p>
</div>
</div>
</div>
<div className="relative z-10 mt-1 flex items-center gap-1.5 overflow-hidden whitespace-nowrap text-[10px] text-muted-foreground xl:mt-2 xl:gap-2 xl:text-[11px]">
<Badge
variant={request.used_failover ? "warning" : "outline"}
className={cn(
"h-5 shrink-0 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs",
request.used_failover ? undefined : accent.directBadge,
)}
>
{request.used_failover
? t("chatgptOAuthRouting.monitorFailoverLabel")
: t("chatgptOAuthRouting.monitorDirectLabel")}
</Badge>
<span className="shrink-0 font-medium tabular-nums">
{formatRelativeTime(request.started_at)}
</span>
<span aria-hidden className="shrink-0 text-muted-foreground/70">·</span>
<span className="shrink-0 font-medium tabular-nums">
{formatDuration(request.duration_ms)}
</span>
{request.attempt_count > 1 && (
<>
<span aria-hidden className="shrink-0 text-muted-foreground/70">·</span>
<span className="shrink-0 font-medium tabular-nums">
{request.attempt_count}x
</span>
</>
)}
{request.status !== "completed" && (
<Badge
variant={requestStatusVariant(request.status)}
className="h-5 shrink-0 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs"
>
{request.status}
</Badge>
)}
</div>
{request.used_failover &&
request.failover_providers &&
request.failover_providers.length > 0 && (
<p className="relative z-10 mt-0.5 truncate text-[10px] text-muted-foreground xl:text-[11px]">
{t("chatgptOAuthRouting.failoverHint", {
providers: request.failover_providers.join(", "),
})}
</p>
)}
</div>
);
}
export function FullRequestRow({ request }: { request: CodexPoolRecentRequest }) {
const { t } = useTranslation("agents");
const providerSummary = requestProviderSummary(request);
return (
<div className="rounded-lg border bg-muted/10 px-3 py-2.5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={request.used_failover ? "warning" : "info"}>
{request.used_failover
? t("chatgptOAuthRouting.monitorFailoverLabel")
: t("chatgptOAuthRouting.monitorDirectLabel")}
</Badge>
{providerSummary ? (
<Badge variant="outline">{providerSummary}</Badge>
) : (
<Badge variant="secondary">{request.status}</Badge>
)}
</div>
<p className="truncate text-sm font-medium">
{request.model || t("chatgptOAuthRouting.unknownModel")}
</p>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>{formatRelativeTime(request.started_at)}</span>
<span>{formatDuration(request.duration_ms)}</span>
<span>
{t("chatgptOAuthRouting.attemptCount", { count: request.attempt_count })}
</span>
</div>
{request.used_failover &&
request.failover_providers &&
request.failover_providers.length > 0 && (
<p className="text-xs text-muted-foreground">
{t("chatgptOAuthRouting.failoverHint", {
providers: request.failover_providers.join(", "),
})}
</p>
)}
</div>
<div className="flex items-center gap-2">
<Badge variant={requestStatusVariant(request.status)}>
{request.status}
</Badge>
<Button asChild variant="ghost" size="icon" className="h-8 w-8 shrink-0">
<Link
to={`/traces/${request.trace_id}`}
aria-label={t("chatgptOAuthRouting.openTrace")}
title={t("chatgptOAuthRouting.openTrace")}
>
<ArrowUpRight className="h-4 w-4" />
</Link>
</Button>
</div>
</div>
</div>
);
}
@@ -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,
});
}
@@ -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<string>;
quotaByName?: Map<string, ChatGPTOAuthProviderQuota>;
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 (
<section className="space-y-2.5 [@media(max-height:760px)]:space-y-2">
<div className="flex flex-wrap items-center gap-1.5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{membershipEditable
? t("chatgptOAuthRouting.availableExtraAccountsLabel")
: t("chatgptOAuthRouting.poolMembershipLabel")}
</p>
</div>
{!membershipEditable ? (
<div className="rounded-lg border border-dashed px-3 py-3 text-sm text-muted-foreground">
{selectedEntries.length > 1
? t("chatgptOAuthRouting.membershipManagedAtProvider", {
provider: membershipManagedByLabel || currentProvider,
})
: t("chatgptOAuthRouting.membershipConfigureProviderFirst", {
provider: membershipManagedByLabel || currentProvider,
})}
</div>
) : isLoading ? (
<div className="rounded-lg border border-dashed px-3 py-3 text-sm text-muted-foreground">
{t("chatgptOAuthRouting.loadingAccounts")}
</div>
) : readyExtraProviders.length > 0 ? (
<div className="grid gap-2 xl:grid-cols-2">
{readyExtraProviders.map((provider) => {
const selected = selectedExtras.has(provider.name);
const failureKind = getQuotaFailureKind(quotaByName?.get(provider.name));
return (
<Button
key={provider.name}
type="button"
variant="outline"
size="sm"
className={cn(
"h-8 justify-start gap-1.5 rounded-lg px-3 text-left text-[13px] xl:h-9 xl:text-sm [@media(max-height:760px)]:h-8",
selected &&
"border-primary/40 bg-primary/10 text-foreground hover:bg-primary/15 dark:border-primary/30 dark:bg-primary/10",
!selected &&
failureKind &&
"border-amber-500/40 text-amber-700 dark:text-amber-300",
selected &&
failureKind &&
"border-amber-500/40 bg-amber-500/10 text-amber-900 hover:bg-amber-500/15 dark:text-amber-200",
)}
onClick={() => onToggleProvider(provider.name)}
disabled={!canEditMembership || mode === "inherit"}
>
{selected ? <Check className="h-3.5 w-3.5" /> : null}
{provider.display_name || provider.name}
</Button>
);
})}
</div>
) : (
<div className="rounded-lg border border-dashed px-3 py-3 text-sm text-muted-foreground">
{t("chatgptOAuthRouting.noReadyExtras")}
</div>
)}
</section>
);
}
interface SelectedAccountsSectionProps {
selectedEntries: SelectedEntry[];
}
export function SelectedAccountsSection({ selectedEntries }: SelectedAccountsSectionProps) {
const { t } = useTranslation("agents");
return (
<section className="space-y-3 [@media(max-height:760px)]:space-y-2">
<div className="flex flex-wrap items-center justify-between gap-1.5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("chatgptOAuthRouting.selectedAccountsLabel")}
</p>
<Badge variant="outline" className="h-6 px-2 text-[11px]">
{t("chatgptOAuthRouting.selectedCount", { count: selectedEntries.length })}
</Badge>
</div>
{selectedEntries.length > 0 ? (
<div className="rounded-lg border bg-muted/10 p-3 [@media(max-height:760px)]:p-2.5">
<div className="grid gap-1.5 xl:grid-cols-2 [@media(max-height:760px)]:gap-1">
{selectedEntries.map((entry) => (
<div
key={entry.name}
className="rounded-lg border bg-background/80 px-2.5 py-2 [@media(max-height:760px)]:px-2 [@media(max-height:760px)]:py-1.5"
>
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 truncate text-sm font-medium">
{entry.label}
</span>
<Badge
variant="outline"
className={cn(
"h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs",
roleBadgeClass(entry.role),
)}
>
{t(`chatgptOAuthRouting.role.${entry.role}`)}
</Badge>
{entry.availability !== "ready" && (
<Badge
variant={statusBadgeVariant(entry.availability)}
className="h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs"
>
{t(`chatgptOAuthRouting.status.${entry.availability}`)}
</Badge>
)}
{entry.routeReadiness !== "healthy" && (
<Badge
variant={routeBadgeVariant(entry.routeReadiness)}
className="h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs"
>
{t(routeLabelKey(entry.routeReadiness))}
</Badge>
)}
</div>
</div>
))}
</div>
</div>
) : (
<div className="rounded-lg border border-dashed px-3 py-3 text-sm text-muted-foreground">
{t("chatgptOAuthRouting.emptySelected")}
</div>
)}
</section>
);
}
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 (
<section className="space-y-2.5 [@media(max-height:760px)]:space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("chatgptOAuthRouting.poolStateTitle")}
</p>
<div className="grid items-start gap-1.5 xl:grid-cols-3 [@media(max-height:760px)]:gap-1">
<StateGroup
title={t("chatgptOAuthRouting.routerActiveTitle")}
count={routerActiveEntries.length}
variant="success"
entries={routerActiveEntries.map((e) => ({
name: e.name,
label: e.label,
detail: routeDetail(e),
}))}
emptyLabel={t("chatgptOAuthRouting.emptyGroup")}
/>
<StateGroup
title={t("chatgptOAuthRouting.fallbackTitle")}
count={standbyEntries.length}
variant="warning"
entries={standbyEntries.map((e) => ({
name: e.name,
label: e.label,
detail: routeDetail(e),
}))}
emptyLabel={t("chatgptOAuthRouting.emptyGroup")}
/>
<StateGroup
title={t("chatgptOAuthRouting.blockedNowTitle")}
count={blockedEntries.length}
variant="destructive"
entries={blockedEntries.map((e) => ({
name: e.name,
label: e.label,
detail: routeDetail(e),
}))}
emptyLabel={t("chatgptOAuthRouting.emptyGroup")}
/>
</div>
</section>
);
}
@@ -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<typeof getRouteReadiness>,
): "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<typeof getRouteReadiness>,
): 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<string, { availability: ChatGPTOAuthAvailability }>,
): ChatGPTOAuthAvailability {
return (
<div className="self-start rounded-lg border bg-muted/10 px-2.5 py-2 [@media(max-height:760px)]:px-2 [@media(max-height:760px)]:py-1.5">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{title}
</p>
<Badge variant={variant}>{count}</Badge>
</div>
{entries.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{entries.map((entry) => (
<div
key={entry.name}
className="rounded-md border bg-background/80 px-2 py-1 text-xs"
>
<span className="font-medium">{entry.label}</span>
{entry.detail ? (
<span className="text-muted-foreground"> · {entry.detail}</span>
) : null}
</div>
))}
</div>
) : (
<p className="mt-2 text-xs text-muted-foreground">{emptyLabel}</p>
)}
</div>
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 ? (
<section className="space-y-2.5 [@media(max-height:760px)]:space-y-2">
<div className="flex flex-wrap items-center gap-1.5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("chatgptOAuthRouting.mode.label")}
</p>
</div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("chatgptOAuthRouting.mode.label")}
</p>
<div className="grid gap-1.5 xl:grid-cols-2">
<Button
type="button"
@@ -323,7 +229,6 @@ export function ChatGPTOAuthRoutingSection({
{t("chatgptOAuthRouting.mode.custom")}
</Button>
</div>
{!providerDefaultsAvailable ? (
<div className="rounded-lg border border-dashed px-3 py-3 text-sm text-muted-foreground">
{t("chatgptOAuthRouting.mode.noProviderDefault")}
@@ -333,12 +238,9 @@ export function ChatGPTOAuthRoutingSection({
) : null}
<section className="space-y-2.5 [@media(max-height:760px)]:space-y-2">
<div className="flex flex-wrap items-center gap-1.5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("chatgptOAuthRouting.strategyLabel")}
</p>
</div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("chatgptOAuthRouting.strategyLabel")}
</p>
<div className="grid gap-2 sm:grid-cols-3">
<Button
type="button"
@@ -370,171 +272,28 @@ export function ChatGPTOAuthRoutingSection({
</div>
</section>
<section className="space-y-2.5 [@media(max-height:760px)]:space-y-2">
<div className="flex flex-wrap items-center gap-1.5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{membershipEditable
? t("chatgptOAuthRouting.availableExtraAccountsLabel")
: t("chatgptOAuthRouting.poolMembershipLabel")}
</p>
</div>
<MembershipSection
membershipEditable={membershipEditable}
membershipManagedByLabel={membershipManagedByLabel}
currentProvider={currentProvider}
selectedEntries={selectedEntries}
readyExtraProviders={readyExtraProviders}
selectedExtras={selectedExtras}
quotaByName={quotaByName}
canEditMembership={canEditMembership}
mode={mode}
isLoading={isLoading}
onToggleProvider={toggleProvider}
/>
{!membershipEditable ? (
<div className="rounded-lg border border-dashed px-3 py-3 text-sm text-muted-foreground">
{selectedEntries.length > 1
? t("chatgptOAuthRouting.membershipManagedAtProvider", {
provider: membershipManagedByLabel || currentProvider,
})
: t("chatgptOAuthRouting.membershipConfigureProviderFirst", {
provider: membershipManagedByLabel || currentProvider,
})}
</div>
) : isLoading ? (
<div className="rounded-lg border border-dashed px-3 py-3 text-sm text-muted-foreground">
{t("chatgptOAuthRouting.loadingAccounts")}
</div>
) : readyExtraProviders.length > 0 ? (
<div className="grid gap-2 xl:grid-cols-2">
{readyExtraProviders.map((provider) => {
const selected = selectedExtras.has(provider.name);
const failureKind = getQuotaFailureKind(
quotaByName?.get(provider.name),
);
return (
<Button
key={provider.name}
type="button"
variant="outline"
size="sm"
className={cn(
"h-8 justify-start gap-1.5 rounded-lg px-3 text-left text-[13px] xl:h-9 xl:text-sm [@media(max-height:760px)]:h-8",
selected &&
"border-primary/40 bg-primary/10 text-foreground hover:bg-primary/15 dark:border-primary/30 dark:bg-primary/10",
!selected &&
failureKind &&
"border-amber-500/40 text-amber-700 dark:text-amber-300",
selected &&
failureKind &&
"border-amber-500/40 bg-amber-500/10 text-amber-900 hover:bg-amber-500/15 dark:text-amber-200",
)}
onClick={() => toggleProvider(provider.name)}
disabled={!canEditMembership || mode === "inherit"}
>
{selected ? <Check className="h-3.5 w-3.5" /> : null}
{provider.display_name || provider.name}
</Button>
);
})}
</div>
) : (
<div className="rounded-lg border border-dashed px-3 py-3 text-sm text-muted-foreground">
{t("chatgptOAuthRouting.noReadyExtras")}
</div>
)}
</section>
<SelectedAccountsSection selectedEntries={selectedEntries} />
<section className="space-y-3 [@media(max-height:760px)]:space-y-2">
<div className="flex flex-wrap items-center justify-between gap-1.5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("chatgptOAuthRouting.selectedAccountsLabel")}
</p>
<Badge variant="outline" className="h-6 px-2 text-[11px]">
{t("chatgptOAuthRouting.selectedCount", {
count: selectedEntries.length,
})}
</Badge>
</div>
{selectedEntries.length > 0 ? (
<div className="rounded-lg border bg-muted/10 p-3 [@media(max-height:760px)]:p-2.5">
<div className="grid gap-1.5 xl:grid-cols-2 [@media(max-height:760px)]:gap-1">
{selectedEntries.map((entry) => (
<div
key={entry.name}
className="rounded-lg border bg-background/80 px-2.5 py-2 [@media(max-height:760px)]:px-2 [@media(max-height:760px)]:py-1.5"
>
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 truncate text-sm font-medium">
{entry.label}
</span>
<Badge
variant="outline"
className={cn(
"h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs",
roleBadgeClass(entry.role),
)}
>
{t(`chatgptOAuthRouting.role.${entry.role}`)}
</Badge>
{entry.availability !== "ready" && (
<Badge
variant={statusBadgeVariant(entry.availability)}
className="h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs"
>
{t(`chatgptOAuthRouting.status.${entry.availability}`)}
</Badge>
)}
{entry.routeReadiness !== "healthy" && (
<Badge
variant={routeBadgeVariant(entry.routeReadiness)}
className="h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs"
>
{t(routeLabelKey(entry.routeReadiness))}
</Badge>
)}
</div>
</div>
))}
</div>
</div>
) : (
<div className="rounded-lg border border-dashed px-3 py-3 text-sm text-muted-foreground">
{t("chatgptOAuthRouting.emptySelected")}
</div>
)}
</section>
<section className="space-y-2.5 [@media(max-height:760px)]:space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("chatgptOAuthRouting.poolStateTitle")}
</p>
<div className="grid items-start gap-1.5 xl:grid-cols-3 [@media(max-height:760px)]:gap-1">
<StateGroup
title={t("chatgptOAuthRouting.routerActiveTitle")}
count={routerActiveEntries.length}
variant="success"
entries={routerActiveEntries.map((entry) => ({
name: entry.name,
label: entry.label,
detail: routeDetail(entry),
}))}
emptyLabel={t("chatgptOAuthRouting.emptyGroup")}
/>
<StateGroup
title={t("chatgptOAuthRouting.fallbackTitle")}
count={standbyEntries.length}
variant="warning"
entries={standbyEntries.map((entry) => ({
name: entry.name,
label: entry.label,
detail: routeDetail(entry),
}))}
emptyLabel={t("chatgptOAuthRouting.emptyGroup")}
/>
<StateGroup
title={t("chatgptOAuthRouting.blockedNowTitle")}
count={blockedEntries.length}
variant="destructive"
entries={blockedEntries.map((entry) => ({
name: entry.name,
label: entry.label,
detail: routeDetail(entry),
}))}
emptyLabel={t("chatgptOAuthRouting.emptyGroup")}
/>
</div>
</section>
<PoolStateSection
routerActiveEntries={routerActiveEntries}
standbyEntries={standbyEntries}
blockedEntries={blockedEntries}
routeDetail={routeDetail}
/>
</CardContent>
{canManageProviders && onSave && (isDirty || saving) ? (
@@ -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 (
<div className="self-start rounded-lg border bg-muted/10 px-2.5 py-2 [@media(max-height:760px)]:px-2 [@media(max-height:760px)]:py-1.5">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{title}
</p>
<Badge variant={variant}>{count}</Badge>
</div>
{entries.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{entries.map((entry) => (
<div
key={entry.name}
className="rounded-md border bg-background/80 px-2 py-1 text-xs"
>
<span className="font-medium">{entry.label}</span>
{entry.detail ? (
<span className="text-muted-foreground"> · {entry.detail}</span>
) : null}
</div>
))}
</div>
) : (
<p className="mt-2 text-xs text-muted-foreground">{emptyLabel}</p>
)}
</div>
);
}
@@ -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,