mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 02:15:57 +00:00
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:
1 parent
b9c1731e31
commit
74dc086a80
35 files changed
+1695
-1455
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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:]...)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+65
-306
@@ -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>
|
||||
);
|
||||
}
|
||||
+2
-8
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user