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]