This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git


The following commit(s) were added to refs/heads/master by this push:
     new ffb424e  feat(agents): show what each saved account has left of its 
limits (#229)
ffb424e is described below

commit ffb424ec297b2f8b1a315281764a8bfb9ce915f3
Author: qioo777 <[email protected]>
AuthorDate: Wed Sep 16 18:25:26 2026 +0800

    feat(agents): show what each saved account has left of its limits (#229)
---
 agentusage/codex.go                  | 155 +++++++++++++++++++++++++++++++++
 agentusage/codex_test.go             | 162 +++++++++++++++++++++++++++++++++++
 controllers/agent_account.go         |  15 ++++
 object/agent_account_usage.go        |  77 +++++++++++++++++
 routers/router.go                    |   1 +
 web/src/backend/AgentBackend.ts      |   8 ++
 web/src/components/AgentAccounts.tsx |  71 ++++++++++++++-
 web/src/lib/agents.ts                |  74 ++++++++++++++--
 web/src/locales/en/data.json         |   7 +-
 web/src/locales/zh/data.json         |   7 +-
 web/src/types.ts                     |  23 +++++
 11 files changed, 592 insertions(+), 8 deletions(-)

diff --git a/agentusage/codex.go b/agentusage/codex.go
new file mode 100644
index 0000000..ca30e91
--- /dev/null
+++ b/agentusage/codex.go
@@ -0,0 +1,155 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package agentusage asks the vendor what the account behind a stored sign-in
+// has left of its limits, using that account's own token.
+package agentusage
+
+import (
+       "encoding/json"
+       "errors"
+       "fmt"
+       "io"
+       "math"
+       "net/http"
+       "time"
+
+       "github.com/apache/casbin-gateway/proxy"
+)
+
+const (
+       // codexOriginator names the client this token was granted to, which the
+       // endpoint wants on every request made with it.
+       codexOriginator = "codex_cli_rs"
+       usageTimeout    = 15 * time.Second
+       maxUsageReply   = 1 << 20
+)
+
+// Vars so a test can answer the usage call locally.
+var (
+       codexUsageUrl = "https://chatgpt.com/backend-api/wham/usage";
+       usageClient   = &http.Client{Timeout: usageTimeout, Transport: 
proxy.Transport()}
+)
+
+// Window is one rate limit window: what is left of it, and when it resets.
+type Window struct {
+       RemainingPercent float64 `json:"remainingPercent"`
+       WindowMinutes    int     `json:"windowMinutes"`
+       ResetTime        string  `json:"resetTime,omitempty"`
+}
+
+// Usage is what one Codex account has left. Primary is the short window - five
+// hours on the plans today - and Secondary the weekly one.
+type Usage struct {
+       Primary   *Window `json:"primary,omitempty"`
+       Secondary *Window `json:"secondary,omitempty"`
+       // ResetCredits is how many rate limit resets the account still holds. 
Each
+       // one empties a window before its own time.
+       ResetCredits int `json:"resetCredits"`
+}
+
+// codexWindow is one window as the endpoint reports it: what has been spent
+// rather than what is left, and the reset both as a moment and as a countdown.
+type codexWindow struct {
+       UsedPercent   float64 `json:"used_percent"`
+       WindowSeconds int64   `json:"limit_window_seconds"`
+       ResetAfter    int64   `json:"reset_after_seconds"`
+       ResetAt       int64   `json:"reset_at"`
+}
+
+// Codex reads what one Codex credential's account has left. The credential is
+// the whole auth.json the agent keeps; an API key has no account to ask about.
+func Codex(credential string) (*Usage, error) {
+       var auth struct {
+               Tokens struct {
+                       AccessToken string `json:"access_token"`
+                       AccountId   string `json:"account_id"`
+               } `json:"tokens"`
+       }
+       if err := json.Unmarshal([]byte(credential), &auth); err != nil {
+               return nil, err
+       }
+       if auth.Tokens.AccessToken == "" {
+               return nil, errors.New("this account holds no ChatGPT sign-in, 
so it has no plan limits to report")
+       }
+
+       request, err := http.NewRequest(http.MethodGet, codexUsageUrl, nil)
+       if err != nil {
+               return nil, err
+       }
+       request.Header.Set("Authorization", "Bearer "+auth.Tokens.AccessToken)
+       request.Header.Set("Accept", "application/json")
+       request.Header.Set("Originator", codexOriginator)
+       request.Header.Set("User-Agent", codexOriginator)
+       if auth.Tokens.AccountId != "" {
+               request.Header.Set("Chatgpt-Account-Id", auth.Tokens.AccountId)
+       }
+
+       response, err := usageClient.Do(request)
+       if err != nil {
+               return nil, err
+       }
+       defer response.Body.Close()
+
+       // A refused sign-in is answered with an HTML sign-in page, not with 
numbers.
+       if response.StatusCode == http.StatusUnauthorized || 
response.StatusCode == http.StatusForbidden {
+               return nil, fmt.Errorf("the stored sign-in is no longer 
accepted (%s), sign in again", response.Status)
+       }
+       if response.StatusCode >= 400 {
+               return nil, fmt.Errorf("chatgpt answered %s", response.Status)
+       }
+
+       var answer struct {
+               RateLimit struct {
+                       Primary   *codexWindow `json:"primary_window"`
+                       Secondary *codexWindow `json:"secondary_window"`
+               } `json:"rate_limit"`
+               ResetCredits struct {
+                       AvailableCount int `json:"available_count"`
+               } `json:"rate_limit_reset_credits"`
+       }
+       if err := json.NewDecoder(io.LimitReader(response.Body, 
maxUsageReply)).Decode(&answer); err != nil {
+               return nil, err
+       }
+       if answer.RateLimit.Primary == nil && answer.RateLimit.Secondary == nil 
{
+               return nil, errors.New("chatgpt reported no limits for this 
account")
+       }
+
+       return &Usage{
+               Primary:      codexWindowOf(answer.RateLimit.Primary),
+               Secondary:    codexWindowOf(answer.RateLimit.Secondary),
+               ResetCredits: answer.ResetCredits.AvailableCount,
+       }, nil
+}
+
+func codexWindowOf(window *codexWindow) *Window {
+       if window == nil {
+               return nil
+       }
+
+       // The moment beats the countdown, which the local clock can skew.
+       reset := ""
+       if window.ResetAt > 0 {
+               reset = time.Unix(window.ResetAt, 0).UTC().Format(time.RFC3339)
+       } else if window.ResetAfter > 0 {
+               reset = time.Now().Add(time.Duration(window.ResetAfter) * 
time.Second).UTC().Format(time.RFC3339)
+       }
+
+       remaining := math.Round((100-window.UsedPercent)*10) / 10
+       return &Window{
+               RemainingPercent: math.Min(100, math.Max(0, remaining)),
+               WindowMinutes:    int(window.WindowSeconds / 60),
+               ResetTime:        reset,
+       }
+}
diff --git a/agentusage/codex_test.go b/agentusage/codex_test.go
new file mode 100644
index 0000000..fd38744
--- /dev/null
+++ b/agentusage/codex_test.go
@@ -0,0 +1,162 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentusage
+
+import (
+       "net/http"
+       "net/http/httptest"
+       "strings"
+       "testing"
+       "time"
+)
+
+// signedIn is a ChatGPT sign-in cut down to the fields the usage call reads.
+const signedIn = 
`{"tokens":{"access_token":"token-abc","account_id":"account-1","refresh_token":"r"}}`
+
+const usageReply = `{
+  "plan_type": "plus",
+  "rate_limit": {
+    "allowed": true,
+    "primary_window": {"used_percent": 28.5, "limit_window_seconds": 18000, 
"reset_after_seconds": 7971, "reset_at": 1789485149},
+    "secondary_window": {"used_percent": 6, "limit_window_seconds": 604800, 
"reset_after_seconds": 560539, "reset_at": 1790037717}
+  },
+  "credits": {"has_credits": false, "balance": "0"},
+  "rate_limit_reset_credits": {"available_count": 2, 
"applicable_available_count": 0}
+}`
+
+// serveUsage points the usage call at a local handler for one test.
+func serveUsage(t *testing.T, handler http.HandlerFunc) {
+       t.Helper()
+
+       server := httptest.NewServer(handler)
+       previousUrl, previousClient := codexUsageUrl, usageClient
+       codexUsageUrl, usageClient = server.URL, server.Client()
+       t.Cleanup(func() {
+               codexUsageUrl, usageClient = previousUrl, previousClient
+               server.Close()
+       })
+}
+
+func TestCodexReadsWhatIsLeft(t *testing.T) {
+       var got *http.Request
+       serveUsage(t, func(writer http.ResponseWriter, request *http.Request) {
+               got = request.Clone(request.Context())
+               writer.Header().Set("Content-Type", "application/json")
+               _, _ = writer.Write([]byte(usageReply))
+       })
+
+       usage, err := Codex(signedIn)
+       if err != nil {
+               t.Fatalf("Codex() = %v", err)
+       }
+
+       if got.Header.Get("Authorization") != "Bearer token-abc" {
+               t.Errorf("Authorization = %q", got.Header.Get("Authorization"))
+       }
+       if got.Header.Get("Chatgpt-Account-Id") != "account-1" {
+               t.Errorf("Chatgpt-Account-Id = %q", 
got.Header.Get("Chatgpt-Account-Id"))
+       }
+       if got.Header.Get("Originator") != codexOriginator {
+               t.Errorf("Originator = %q", got.Header.Get("Originator"))
+       }
+
+       // The endpoint reports what has been spent; this is what is left.
+       if usage.Primary == nil || usage.Primary.RemainingPercent != 71.5 {
+               t.Fatalf("Primary = %+v, want 71.5 percent left", usage.Primary)
+       }
+       if usage.Primary.WindowMinutes != 300 {
+               t.Errorf("Primary.WindowMinutes = %d, want 300", 
usage.Primary.WindowMinutes)
+       }
+       if want := time.Unix(1789485149, 0).UTC().Format(time.RFC3339); 
usage.Primary.ResetTime != want {
+               t.Errorf("Primary.ResetTime = %q, want %q", 
usage.Primary.ResetTime, want)
+       }
+       if usage.Secondary == nil || usage.Secondary.RemainingPercent != 94 {
+               t.Fatalf("Secondary = %+v, want 94 percent left", 
usage.Secondary)
+       }
+       if usage.Secondary.WindowMinutes != 10080 {
+               t.Errorf("Secondary.WindowMinutes = %d, want 10080", 
usage.Secondary.WindowMinutes)
+       }
+       if usage.ResetCredits != 2 {
+               t.Errorf("ResetCredits = %d, want 2", usage.ResetCredits)
+       }
+}
+
+func TestCodexClampsAndFallsBackToCountdown(t *testing.T) {
+       serveUsage(t, func(writer http.ResponseWriter, _ *http.Request) {
+               _, _ = writer.Write([]byte(`{
+                 "plan_type": "pro",
+                 "rate_limit": {
+                   "primary_window": {"used_percent": 130, 
"limit_window_seconds": 18000, "reset_after_seconds": 600},
+                   "secondary_window": null
+                 },
+                 "rate_limit_reset_credits": {"available_count": 0}
+               }`))
+       })
+
+       usage, err := Codex(signedIn)
+       if err != nil {
+               t.Fatalf("Codex() = %v", err)
+       }
+       if usage.Primary.RemainingPercent != 0 {
+               t.Errorf("RemainingPercent = %v, want 0", 
usage.Primary.RemainingPercent)
+       }
+       if usage.Secondary != nil {
+               t.Errorf("Secondary = %+v, want nil", usage.Secondary)
+       }
+
+       reset, err := time.Parse(time.RFC3339, usage.Primary.ResetTime)
+       if err != nil {
+               t.Fatalf("ResetTime = %q: %v", usage.Primary.ResetTime, err)
+       }
+       if left := time.Until(reset); left < 9*time.Minute || left > 
11*time.Minute {
+               t.Errorf("ResetTime is %v away, want about 10m", left)
+       }
+}
+
+func TestCodexRefusesAnApiKey(t *testing.T) {
+       serveUsage(t, func(writer http.ResponseWriter, _ *http.Request) {
+               t.Error("an API key must not be asked about")
+               writer.WriteHeader(http.StatusInternalServerError)
+       })
+
+       if _, err := 
Codex(`{"OPENAI_API_KEY":"sk-test","tokens":null,"last_refresh":null}`); err == 
nil {
+               t.Fatal("Codex() accepted an API key")
+       }
+}
+
+func TestCodexReportsARefusedSignIn(t *testing.T) {
+       serveUsage(t, func(writer http.ResponseWriter, _ *http.Request) {
+               writer.WriteHeader(http.StatusUnauthorized)
+               _, _ = writer.Write([]byte("<html>sign in</html>"))
+       })
+
+       _, err := Codex(signedIn)
+       if err == nil {
+               t.Fatal("Codex() accepted a refused sign-in")
+       }
+       if !strings.Contains(err.Error(), "sign in again") {
+               t.Errorf("err = %v, want it to say the sign-in has to be 
renewed", err)
+       }
+}
+
+func TestCodexRejectsAnEmptyAnswer(t *testing.T) {
+       serveUsage(t, func(writer http.ResponseWriter, _ *http.Request) {
+               _, _ = writer.Write([]byte(`{"rate_limit": {}}`))
+       })
+
+       if _, err := Codex(signedIn); err == nil {
+               t.Fatal("Codex() accepted an answer with no limits in it")
+       }
+}
diff --git a/controllers/agent_account.go b/controllers/agent_account.go
index ee23bda..e25e542 100644
--- a/controllers/agent_account.go
+++ b/controllers/agent_account.go
@@ -93,6 +93,21 @@ func (c *ApiController) GetAgentAccounts() {
        c.ResponseOk(view)
 }
 
+// GetAgentAccountsUsage reports what each stored sign-in of one agent has 
left.
+// It is separate from the listing because it asks the vendor, not the 
database.
+func (c *ApiController) GetAgentAccountsUsage() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       usage, err := object.GetAgentAccountsUsage(c.Input().Get("agent"))
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(usage)
+}
+
 // agentAccountForm is what the account calls take: the installation, and
 // whichever of the stored name, the label and the key the call needs.
 type agentAccountForm struct {
diff --git a/object/agent_account_usage.go b/object/agent_account_usage.go
new file mode 100644
index 0000000..1d1dc97
--- /dev/null
+++ b/object/agent_account_usage.go
@@ -0,0 +1,77 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package object
+
+import (
+       "fmt"
+       "sync"
+
+       "github.com/apache/casbin-gateway/agentauth"
+       "github.com/apache/casbin-gateway/agentusage"
+)
+
+// AgentAccountUsage is what one stored account has left, or why that could not
+// be read.
+type AgentAccountUsage struct {
+       Usage *agentusage.Usage `json:"usage,omitempty"`
+       Error string            `json:"error,omitempty"`
+}
+
+// GetAgentAccountsUsage reads what each stored sign-in of one agent has left,
+// keyed by the stored name. An account it cannot ask about is left out rather
+// than reported as a failure.
+func GetAgentAccountsUsage(agentId string) (map[string]*AgentAccountUsage, 
error) {
+       accounts, err := GetAgentAccounts(agentId)
+       if err != nil {
+               return nil, err
+       }
+
+       answers := map[string]*AgentAccountUsage{}
+       var lock sync.Mutex
+       var group sync.WaitGroup
+       for _, account := range accounts {
+               if account.Kind != agentauth.KindSubscription || 
!agentauth.Supports(account.AgentId) {
+                       continue
+               }
+
+               group.Add(1)
+               go func(name string) {
+                       defer group.Done()
+                       answer := agentAccountUsage(name)
+
+                       lock.Lock()
+                       answers[name] = answer
+                       lock.Unlock()
+               }(account.Name)
+       }
+       group.Wait()
+       return answers, nil
+}
+
+func agentAccountUsage(name string) *AgentAccountUsage {
+       account, err := GetAgentAccount(name)
+       if err != nil {
+               return &AgentAccountUsage{Error: err.Error()}
+       }
+       if account == nil {
+               return &AgentAccountUsage{Error: fmt.Sprintf("no agent account 
is stored under this name: %s", name)}
+       }
+
+       usage, err := agentusage.Codex(account.Credential)
+       if err != nil {
+               return &AgentAccountUsage{Error: err.Error()}
+       }
+       return &AgentAccountUsage{Usage: usage}
+}
diff --git a/routers/router.go b/routers/router.go
index a6f1709..e88dade 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -106,6 +106,7 @@ func initAPI() {
        beego.Router("/api/get-weixin-login-status", 
&controllers.ApiController{}, "GET:GetWeixinLoginStatus")
 
        beego.Router("/api/get-agent-accounts", &controllers.ApiController{}, 
"GET:GetAgentAccounts")
+       beego.Router("/api/get-agent-accounts-usage", 
&controllers.ApiController{}, "GET:GetAgentAccountsUsage")
        beego.Router("/api/save-agent-account", &controllers.ApiController{}, 
"POST:SaveAgentAccount")
        beego.Router("/api/add-agent-account", &controllers.ApiController{}, 
"POST:AddAgentAccount")
        beego.Router("/api/switch-agent-account", &controllers.ApiController{}, 
"POST:SwitchAgentAccount")
diff --git a/web/src/backend/AgentBackend.ts b/web/src/backend/AgentBackend.ts
index 84ef6d5..5004b51 100644
--- a/web/src/backend/AgentBackend.ts
+++ b/web/src/backend/AgentBackend.ts
@@ -14,6 +14,7 @@
 
 import {query, request} from "@/backend/request";
 import type {
+  AccountUsageResult,
   Agent,
   AgentCatalogEntry,
   AgentInstallJob,
@@ -195,6 +196,13 @@ export function getAgentAccounts(target: PatchTarget) {
   );
 }
 
+/** What each stored sign-in of one agent has left, keyed by the stored name. 
*/
+export function getAgentAccountsUsage(agentId: string) {
+  return request<Record<string, AccountUsageResult>>(
+    `/api/get-agent-accounts-usage${query({agent: agentId})}`,
+  );
+}
+
 /** Keeps a copy of the sign-in the agent is using, so a swap can be undone. */
 export function saveAgentAccount(target: PatchTarget, displayName = "") {
   return request<SavedAccount>("/api/save-agent-account", "POST", {
diff --git a/web/src/components/AgentAccounts.tsx 
b/web/src/components/AgentAccounts.tsx
index c945180..0ba7081 100644
--- a/web/src/components/AgentAccounts.tsx
+++ b/web/src/components/AgentAccounts.tsx
@@ -47,10 +47,12 @@ import {
   accountName,
   accountsOf,
   agentKey,
+  usageResetLabel,
+  usageWindowLabel,
   useAgentAccounts,
   type AgentAccountControls,
 } from "@/lib/agents";
-import type {Agent, SavedAccount} from "@/types";
+import type {AccountUsageWindow, Agent, SavedAccount} from "@/types";
 
 /** The mark for one kind of sign-in, so a key is told from an account at a 
glance. */
 function KindIcon({kind, className}: {kind: string; className?: string}) {
@@ -61,6 +63,68 @@ function KindIcon({kind, className}: {kind: string; 
className?: string}) {
   );
 }
 
+/** One rate limit window: how much of it is left, and when it fills back up. 
*/
+function UsageWindow({limit}: {limit: AccountUsageWindow}) {
+  const reset = usageResetLabel(limit.resetTime);
+  return (
+    <span className="flex items-center gap-1.5 whitespace-nowrap">
+      <span className="text-muted-foreground w-8 shrink-0">
+        {usageWindowLabel(limit.windowMinutes)}
+      </span>
+      <span className="font-medium">
+        {i18next.t("agent:{percent}% left").replace("{percent}", 
String(limit.remainingPercent))}
+      </span>
+      {reset ? (
+        <span className="text-muted-foreground">
+          {i18next.t("agent:Resets {time}").replace("{time}", reset)}
+        </span>
+      ) : null}
+    </span>
+  );
+}
+
+/** What one stored account has left, or that the vendor would not say. */
+function AccountUsageCell({
+  account,
+  controls,
+}: {
+  account: SavedAccount;
+  controls: AgentAccountControls;
+}) {
+  // A key is billed by what it spends and has no plan limits.
+  if (account.kind === "apikey") {
+    return <span className="text-muted-foreground text-xs">-</span>;
+  }
+
+  // Not answered for yet, which is not the vendor refusing to answer.
+  const result = controls.usage[account.name];
+  if (!result) {
+    return (
+      <span className="text-muted-foreground text-xs">
+        {controls.usageLoading ? i18next.t("general:Loading") : "-"}
+      </span>
+    );
+  }
+  if (!result.usage) {
+    return (
+      <SimpleTooltip title={result.error ?? ""}>
+        <span className="text-muted-foreground 
text-xs">{i18next.t("agent:Unavailable")}</span>
+      </SimpleTooltip>
+    );
+  }
+
+  const usage = result.usage;
+  return (
+    <div className="flex flex-col gap-0.5 text-xs">
+      {usage.primary ? <UsageWindow limit={usage.primary} /> : null}
+      {usage.secondary ? <UsageWindow limit={usage.secondary} /> : null}
+      <span className="text-muted-foreground whitespace-nowrap">
+        {i18next.t("agent:{count} resets left").replace("{count}", 
String(usage.resetCredits))}
+      </span>
+    </div>
+  );
+}
+
 /** Editable label of one stored account, so a second account of the same plan
  *  is told from the first without reading its email. */
 function AccountName({
@@ -277,6 +341,11 @@ export function AgentAccounts({agent, enabled = true}: 
{agent: Agent; enabled?:
         </div>
       ),
     },
+    {
+      title: i18next.t("agent:Usage left"),
+      key: "usage",
+      render: (_value, record) => <AccountUsageCell account={record} 
controls={controls} />,
+    },
     {
       title: i18next.t("agent:Status"),
       key: "current",
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index 5c4fe9c..2412925 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -20,6 +20,7 @@ import * as Setting from "@/Setting";
 import type {BadgeVariant} from "@/components/ui/badge";
 import {providerProtocol, servesResponsesApi} from "@/lib/providers";
 import type {
+  AccountUsageResult,
   Agent,
   AgentCatalogEntry,
   AgentInstallJob,
@@ -1040,6 +1041,8 @@ const noAccounts: SavedAccounts = {accounts: [], stored: 
false};
  */
 export function useAgentAccounts(agents: Agent[], enabled = true) {
   const [byAgent, setByAgent] = React.useState<Record<string, 
SavedAccounts>>({});
+  const [usage, setUsage] = React.useState<Record<string, 
AccountUsageResult>>({});
+  const [usageLoading, setUsageLoading] = React.useState(false);
   const [loading, setLoading] = React.useState(false);
   const [busyKey, setBusyKey] = React.useState("");
   const [session, setSession] = React.useState<AgentSignin | null>(null);
@@ -1079,9 +1082,35 @@ export function useAgentAccounts(agents: Agent[], 
enabled = true) {
       .then(() => setLoading(false));
   }, [enabled, targets]);
 
-  React.useEffect(() => {
+  // The vendor answers these, so they land after the listing instead of 
holding
+  // it up, and one call covers an agent: an account is not per installation.
+  const loadUsage = React.useCallback(() => {
+    const agentIds = [...new Set(targets.map(target => target.agentId))];
+    if (!enabled || agentIds.length === 0) {
+      setUsage({});
+      return;
+    }
+
+    setUsageLoading(true);
+    Promise.all(
+      agentIds.map(agentId =>
+        AgentBackend.getAgentAccountsUsage(agentId)
+          .then(res => (res.status === "ok" && res.data ? res.data : {}))
+          .catch(() => ({}) as Record<string, AccountUsageResult>),
+      ),
+    )
+      .then(listings => setUsage(Object.assign({}, ...listings)))
+      .then(() => setUsageLoading(false));
+  }, [enabled, targets]);
+
+  const reload = React.useCallback(() => {
     load();
-  }, [load]);
+    loadUsage();
+  }, [load, loadUsage]);
+
+  React.useEffect(() => {
+    reload();
+  }, [reload]);
 
   // A sign-in is finished in a browser, so the page asks how it went rather
   // than holding the call open for as long as somebody takes to type.
@@ -1101,8 +1130,15 @@ export function useAgentAccounts(agents: Agent[], 
enabled = true) {
     return () => clearInterval(timer);
   }, [session?.id, session?.running]);
 
+  // withUsage is for the calls that change which sign-in is stored or in use; 
a
+  // rename changes neither, so it asks the vendor nothing.
   const call = React.useCallback(
-    (key: string, run: () => Promise<{status: string; msg?: string}>, done: 
string) => {
+    (
+      key: string,
+      run: () => Promise<{status: string; msg?: string}>,
+      done: string,
+      withUsage = false,
+    ) => {
       setBusyKey(key);
       return run()
         .then(res => {
@@ -1111,6 +1147,9 @@ export function useAgentAccounts(agents: Agent[], enabled 
= true) {
               Setting.showMessage("success", done);
             }
             load();
+            if (withUsage) {
+              loadUsage();
+            }
           } else {
             Setting.showMessage("error", res.msg ?? "");
           }
@@ -1118,7 +1157,7 @@ export function useAgentAccounts(agents: Agent[], enabled 
= true) {
         .catch(err => Setting.showMessage("error", err.message || String(err)))
         .then(() => setBusyKey(""));
     },
-    [load],
+    [load, loadUsage],
   );
 
   /** Puts one stored sign-in back into the agent, saving what it replaces. */
@@ -1128,6 +1167,7 @@ export function useAgentAccounts(agents: Agent[], enabled 
= true) {
         account.name,
         () => AgentBackend.switchAgentAccount(targetOf(agent), account.name),
         `${i18next.t("agent:Account switched")}: ${accountName(account)}`,
+        true,
       ),
     [call],
   );
@@ -1139,6 +1179,7 @@ export function useAgentAccounts(agents: Agent[], enabled 
= true) {
         `${agentKey(agent)}:save`,
         () => AgentBackend.saveAgentAccount(targetOf(agent)),
         i18next.t("agent:Account saved"),
+        true,
       ),
     [call],
   );
@@ -1149,6 +1190,7 @@ export function useAgentAccounts(agents: Agent[], enabled 
= true) {
         `${agentKey(agent)}:add`,
         () => AgentBackend.addAgentAccount(targetOf(agent), apiKey, 
displayName),
         i18next.t("agent:Account saved"),
+        true,
       ),
     [call],
   );
@@ -1203,10 +1245,12 @@ export function useAgentAccounts(agents: Agent[], 
enabled = true) {
 
   return {
     byAgent,
+    usage,
+    usageLoading,
     loading,
     busyKey,
     session,
-    reload: load,
+    reload,
     switchTo,
     saveCurrent,
     add,
@@ -1225,6 +1269,26 @@ export function accountsOf(controls: 
AgentAccountControls, agent: Agent) {
   return controls.byAgent[agentKey(agent)] ?? noAccounts;
 }
 
+/** A rate limit window named by how long it is: "5h", "7d". */
+export function usageWindowLabel(minutes: number) {
+  if (minutes > 0 && minutes % (60 * 24) === 0) {
+    return `${minutes / (60 * 24)}d`;
+  }
+  if (minutes > 0 && minutes % 60 === 0) {
+    return `${minutes / 60}h`;
+  }
+  return `${minutes}m`;
+}
+
+/** When a window fills back up, in the reader's own time zone. */
+export function usageResetLabel(resetTime: string | undefined) {
+  if (!resetTime) {
+    return "";
+  }
+  const at = new Date(resetTime);
+  return Number.isNaN(at.getTime()) ? "" : at.toLocaleString();
+}
+
 /** The name of one stored account: what it was called, or whoever it signs 
in. */
 export function accountName(account: SavedAccount) {
   return account.displayName || account.email || account.name.split("/").pop() 
|| account.name;
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 74d83fd..9d8ca85 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -266,6 +266,7 @@
     "Remove instance hint": "Gateway forgets this instance. Its state 
directory, and the account signed in to it, are left on disk.",
     "Remove the line to clear it": "Remove that line, then start the agent 
again.",
     "Reported from": "Reported from",
+    "Resets {time}": "resets {time}",
     "Restore configuration": "Restore configuration",
     "Restore confirm hint": "Puts back the provider settings this agent had 
before Gateway first wrote them, and leaves every other setting untouched.",
     "Restore the configuration of {agent}?": "Restore the configuration of 
{agent}?",
@@ -329,6 +330,7 @@
     "Tool use ID": "Tool use ID",
     "Transcript truncated": "Some of this session was too large to show, so 
what follows is not all of it",
     "Turn on monitoring to collect activity": "Turn on monitoring to collect 
activity",
+    "Unavailable": "Unavailable",
     "Uninstall": "Uninstall",
     "Uninstall hint": "Removes the program the way it was installed. The 
agent's own directory, with its sign-in and its history, stays where it is.",
     "Uninstall {agent}?": "Uninstall {agent}?",
@@ -349,6 +351,7 @@
     "Upgrade {agent}?": "Upgrade {agent}?",
     "Upgrading": "Upgrading",
     "Usage": "Usage",
+    "Usage left": "Usage left",
     "Use": "Use",
     "Use this program": "Use this program",
     "User": "User",
@@ -380,7 +383,9 @@
     "{count} agents not installed": "{count} agents not installed",
     "{count} from transcripts": "{count} from transcripts",
     "{count} messages": "{count} messages",
-    "{count} running": "{count} running"
+    "{count} resets left": "{count} rate limit resets left",
+    "{count} running": "{count} running",
+    "{percent}% left": "{percent}% left"
   },
   "agentConfig": {
     "1 file": "1 file",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index ed63795..3dea2ab 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -266,6 +266,7 @@
     "Remove instance hint": "Gateway将不再管理该实例。它的状态目录以及其中登录的账号仍保留在磁盘上。",
     "Remove the line to clear it": "请删除该行,然后重新启动该Agent。",
     "Reported from": "上报来源",
+    "Resets {time}": "{time}重置",
     "Restore configuration": "还原配置",
     "Restore confirm hint": "把该Agent在Gateway首次写入之前的供应商配置还原回去,其他配置保持不变。",
     "Restore the configuration of {agent}?": "还原{agent}的配置?",
@@ -329,6 +330,7 @@
     "Tool use ID": "工具调用ID",
     "Transcript truncated": "这个会话有部分内容太大,没能全部展示出来",
     "Turn on monitoring to collect activity": "开启监控后才会收集活动",
+    "Unavailable": "无法获取",
     "Uninstall": "卸载",
     "Uninstall hint": "按当初的安装方式删除程序。Agent自己的目录(登录状态、历史记录)会原封不动。",
     "Uninstall {agent}?": "卸载{agent}?",
@@ -349,6 +351,7 @@
     "Upgrade {agent}?": "升级{agent}?",
     "Upgrading": "升级中",
     "Usage": "用量",
+    "Usage left": "剩余用量",
     "Use": "切换",
     "Use this program": "使用该程序",
     "User": "用户",
@@ -380,7 +383,9 @@
     "{count} agents not installed": "{count}个Agent未安装",
     "{count} from transcripts": "{count}个来自会话记录",
     "{count} messages": "{count}条消息",
-    "{count} running": "{count}个在运行"
+    "{count} resets left": "剩余{count}次重置额度",
+    "{count} running": "{count}个在运行",
+    "{percent}% left": "剩余{percent}%"
   },
   "agentConfig": {
     "1 file": "1个文件",
diff --git a/web/src/types.ts b/web/src/types.ts
index 72c7009..10c624c 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -239,6 +239,29 @@ export interface SavedAccount {
   current?: boolean;
 }
 
+/** One rate limit window of an account: what is left of it, and when it 
fills. */
+export interface AccountUsageWindow {
+  /** What is left of the window, 0 to 100. */
+  remainingPercent: number;
+  windowMinutes: number;
+  resetTime?: string;
+}
+
+/** What one stored account has left of its plan limits. */
+export interface AccountUsage {
+  /** The short window, and the weekly one. Either is absent with no such 
limit. */
+  primary?: AccountUsageWindow;
+  secondary?: AccountUsageWindow;
+  /** Rate limit resets the account still holds, each emptying a window early. 
*/
+  resetCredits: number;
+}
+
+/** One account's limits as the server answers for them, or why it could not. 
*/
+export interface AccountUsageResult {
+  usage?: AccountUsage;
+  error?: string;
+}
+
 /** The saved sign-ins of one agent beside the one it is using now. */
 export interface SavedAccounts {
   accounts: SavedAccount[];


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to