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 2157542 feat(provider): show what the vendor says is left on the
account
2157542 is described below
commit 21575426fd324ea124afa8769a0a2ecc1ab56436
Author: Yang Luo <[email protected]>
AuthorDate: Sun Aug 23 02:52:05 2026 +0800
feat(provider): show what the vendor says is left on the account
---
controllers/provider.go | 80 ++++
object/provider.go | 73 +++-
object/provider_quota.go | 476 +++++++++++++++++++++
routers/router.go | 2 +
web/src/backend/ProviderBackend.ts | 13 +-
web/src/components/ProviderQuota.tsx | 254 +++++++++++
web/src/components/agent-config/add-mcp-dialog.tsx | 17 +-
web/src/lib/pairs.ts | 36 ++
web/src/lib/quota.ts | 72 ++++
web/src/locales/en/data.json | 32 +-
web/src/locales/zh/data.json | 32 +-
web/src/pages/ProviderEditPage.tsx | 8 +-
web/src/pages/ProviderListPage.tsx | 51 ++-
web/src/types.ts | 30 ++
14 files changed, 1146 insertions(+), 30 deletions(-)
diff --git a/controllers/provider.go b/controllers/provider.go
index c654de8..3eca23d 100644
--- a/controllers/provider.go
+++ b/controllers/provider.go
@@ -241,6 +241,86 @@ func (c *ApiController) GetProviderModels() {
c.ResponseOk(models)
}
+// quotaProviders are the providers whose vendor balances the caller may see:
an
+// admin sees every one, anybody else only their own.
+func (c *ApiController) quotaProviders() ([]*object.Provider, error) {
+ owner := c.GetSessionUsername()
+ if c.GetSessionUser().IsAdmin {
+ owner = ""
+ }
+ return object.GetProviders(owner)
+}
+
+// GetProviderQuotas returns the vendor balances that are already known. It
asks
+// no vendor anything, so a page can call it as often as it likes.
+func (c *ApiController) GetProviderQuotas() {
+ if c.RequireSignedIn() {
+ return
+ }
+
+ providers, err := c.quotaProviders()
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ c.ResponseOk(object.GetProviderQuotas(providers))
+}
+
+// RefreshProviderQuotas asks the vendors what is left. Without an id every
+// provider the caller can see is refreshed, and without force the ones whose
+// last answer is still fresh are left alone.
+func (c *ApiController) RefreshProviderQuotas() {
+ if c.RequireSignedIn() {
+ return
+ }
+
+ var request struct {
+ Id string `json:"id"`
+ Force bool `json:"force"`
+ }
+ if len(c.Ctx.Input.RequestBody) > 0 {
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request);
err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ }
+
+ if request.Id == "" {
+ providers, err := c.quotaProviders()
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(object.RefreshProviderQuotas(providers,
request.Force))
+ return
+ }
+
+ owner, _, ok := getProviderOwnerAndName(request.Id)
+ if !ok {
+ c.ResponseError("invalid provider ID: " + request.Id)
+ return
+ }
+ if !c.providerAccess(owner) {
+ c.ResponseError("unauthorized")
+ return
+ }
+
+ provider, err := object.GetProvider(request.Id)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ if provider == nil {
+ c.ResponseError("the provider does not exist")
+ return
+ }
+
+ // A refresh of one provider is always somebody pressing a button, so
it is
+ // never answered from the cache.
+ c.ResponseOk(object.RefreshProviderQuotas([]*object.Provider{provider},
true))
+}
+
// resolveProviderApiKey fills in the key a probe has to be made with. The
// browser only ever sees the mask, so an untouched key field means the one the
// provider already has stored; a provider that is not saved yet has none.
diff --git a/object/provider.go b/object/provider.go
index f45b207..16d7190 100644
--- a/object/provider.go
+++ b/object/provider.go
@@ -68,6 +68,40 @@ func encryptApiKey(provider *Provider) error {
return nil
}
+// quotaTokenAad keeps the quota token's ciphertext from being usable in the
+// api_key column of the same row, or in any other row.
+func quotaTokenAad(provider *Provider) string {
+ return provider.GetId() + "/quota"
+}
+
+// encryptQuotaToken mirrors encryptApiKey for the credential a quota endpoint
+// needs when the inference key is not the one it takes.
+func encryptQuotaToken(provider *Provider) error {
+ if provider.Quota == nil || provider.Quota.Token == "" {
+ return nil
+ }
+
+ encrypted, err := util.EncryptWithKey(apiKeyEncryptionSecret(),
provider.Quota.Token, quotaTokenAad(provider))
+ if err != nil {
+ return err
+ }
+ provider.Quota.Token = encrypted
+ return nil
+}
+
+func decryptQuotaToken(provider *Provider) {
+ if provider.Quota == nil || provider.Quota.Token == "" {
+ return
+ }
+
+ plain, err := util.DecryptWithKey(apiKeyEncryptionSecret(),
provider.Quota.Token, quotaTokenAad(provider))
+ if err != nil {
+ fmt.Printf("decryptQuotaToken(): provider [%s]: %v\n",
provider.GetId(), err)
+ return
+ }
+ provider.Quota.Token = plain
+}
+
// decryptProvider restores the plaintext ApiKey on a provider just read from
the
// database. A failure leaves the stored value in place rather than dropping
the
// provider, but is logged: otherwise a changed key looks exactly like a
healthy
@@ -77,6 +111,8 @@ func decryptProvider(provider *Provider) {
return
}
+ decryptQuotaToken(provider)
+
secret := apiKeyEncryptionSecret()
stored := provider.ApiKey
@@ -206,6 +242,9 @@ type Provider struct {
// Notes is whatever the person who added the provider wants to remember
// about it: which account the key belongs to, when it expires, who
pays.
Notes string `xorm:"varchar(500)" json:"notes"`
+ // Quota names the vendor's balance endpoint when there is no built-in
one
+ // for it. Nil leaves the built-in table in provider_quota.go to answer.
+ Quota *QuotaConfig `xorm:"mediumtext json" json:"quota"`
// TODO(1.2): Priority routing strategy will be defined in milestone
1.2.
Priority int `xorm:"int" json:"priority"`
Status string `xorm:"varchar(100)" json:"status"`
@@ -265,6 +304,13 @@ func GetMaskedProvider(provider *Provider) *Provider {
if masked.ApiKey != "" {
masked.ApiKey = ApiKeyMask
}
+ // The quota token shares a column with the rest of the quota
configuration,
+ // so the whole struct is copied before the token is replaced.
+ if masked.Quota != nil && masked.Quota.Token != "" {
+ quota := *masked.Quota
+ quota.Token = ApiKeyMask
+ masked.Quota = "a
+ }
return &masked
}
@@ -434,6 +480,9 @@ func AddProvider(provider *Provider) (bool, error) {
}
provider.UpdatedTime = now
+ if err := encryptQuotaToken(provider); err != nil {
+ return false, err
+ }
if err := encryptApiKey(provider); err != nil {
return false, err
}
@@ -444,9 +493,11 @@ func AddProvider(provider *Provider) (bool, error) {
func UpdateProvider(id string, provider *Provider) (bool, error) {
owner, name := util.GetOwnerAndNameFromId(id)
- if stored, err := getProvider(owner, name); err != nil {
+ stored, err := getProvider(owner, name)
+ if err != nil {
return false, err
- } else if stored == nil {
+ }
+ if stored == nil {
return false, nil
}
@@ -458,21 +509,34 @@ func UpdateProvider(id string, provider *Provider) (bool,
error) {
provider.Name = name
provider.UpdatedTime = util.GetCurrentTime()
+ // The quota token is masked on read like the API key, but it shares its
+ // column with the rest of the quota configuration, so the stored one
is put
+ // back rather than the column left out of the write.
+ if provider.Quota != nil && provider.Quota.Token == ApiKeyMask {
+ provider.Quota.Token = stored.Quota.token()
+ }
+ if err := encryptQuotaToken(provider); err != nil {
+ return false, err
+ }
+
session := ormer.Engine.ID(core.PK{owner, name})
// The browser only ever sees the mask, so getting it back means the
user
// did not touch the field. Any other value (including "") is written,
which
// is what makes clearing a key possible.
if provider.ApiKey == ApiKeyMask {
session = session.Omit("api_key")
- } else if err := encryptApiKey(provider); err != nil {
+ } else if err = encryptApiKey(provider); err != nil {
return false, err
}
- affected, err := session.AllCols().Update(provider)
+ var affected int64
+
+ affected, err = session.AllCols().Update(provider)
if err == nil {
// The edit may be the fix for whatever the proxy last saw, so
the
// provider starts from a clean slate.
ClearProviderHealth(provider.GetId())
+ ClearProviderQuota(provider.GetId())
}
return affected != 0, err
}
@@ -481,6 +545,7 @@ func DeleteProvider(provider *Provider) (bool, error) {
affected, err := ormer.Engine.ID(core.PK{provider.Owner,
provider.Name}).Delete(&Provider{})
if err == nil {
ClearProviderHealth(provider.GetId())
+ ClearProviderQuota(provider.GetId())
}
return affected != 0, err
}
diff --git a/object/provider_quota.go b/object/provider_quota.go
new file mode 100644
index 0000000..60f1d93
--- /dev/null
+++ b/object/provider_quota.go
@@ -0,0 +1,476 @@
+// 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 (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/apache/casbin-gateway/proxy"
+ "github.com/apache/casbin-gateway/util"
+)
+
+// ProviderQuota is what the vendor behind a provider says is left on the
+// account. Like ProviderHealth it lives in memory: it is the vendor's answer
at
+// one point in time, not a stored setting.
+//
+// The numbers are pointers because a vendor that reports only what is left
must
+// not be shown as having used nothing.
+type ProviderQuota struct {
+ Provider string `json:"provider"`
+ // Supported is false when Gateway knows no balance endpoint for the
vendor
+ // and the provider names none either, which is not a failure.
+ Supported bool `json:"supported"`
+ Remaining *float64 `json:"remaining"`
+ Used *float64 `json:"used"`
+ Total *float64 `json:"total"`
+ Unit string `json:"unit"`
+ Error string `json:"error"`
+ Time string `json:"time"`
+}
+
+// QuotaConfig points the probe at a vendor's own balance endpoint, for the
+// vendors and the aggregator sites that have no built-in entry here. It reads
+// the answer by JSON path rather than by running code: the configuration comes
+// from whoever added the provider, and this runs on a shared server.
+type QuotaConfig struct {
+ // Url is a path resolved against the base URL's origin, or an absolute
URL
+ // that has to be on that same origin.
+ Url string `json:"url"`
+ Headers map[string]string `json:"headers"`
+ // Token is a credential of its own, for the aggregator sites whose
balance
+ // endpoint does not take the inference key. It is stored and masked
like
+ // Provider.ApiKey, and headers reach it as {{token}}.
+ Token string `json:"token"`
+ // Remaining, Used and Total are dotted paths into the JSON answer,
where a
+ // numeric segment indexes an array: "balance_infos.0.total_balance".
+ Remaining string `json:"remaining"`
+ Used string `json:"used"`
+ Total string `json:"total"`
+ // Unit is the currency itself, not a path: a vendor does not vary it.
+ Unit string `json:"unit"`
+ // Scale divides every number read, for a vendor that reports in an
internal
+ // unit of its own. 0 and 1 both mean "as reported".
+ Scale float64 `json:"scale"`
+}
+
+func (config *QuotaConfig) isEmpty() bool {
+ return config == nil || strings.TrimSpace(config.Url) == ""
+}
+
+// quotaVendor is a vendor whose balance endpoint is documented, so that a
+// provider pointed at it needs nothing configured.
+type quotaVendor struct {
+ hosts []string
+ path string
+ read func(body any, quota *ProviderQuota)
+}
+
+// The endpoints and the field names below were each checked against the live
+// API. A vendor without a balance endpoint (OpenAI, Anthropic) has no entry:
+// there is nothing to ask.
+var quotaVendors = []quotaVendor{
+ {
+ hosts: []string{"api.deepseek.com"},
+ path: "/user/balance",
+ read: func(body any, quota *ProviderQuota) {
+ quota.Remaining = quotaNumber(body,
"balance_infos.0.total_balance")
+ quota.Unit = quotaString(body,
"balance_infos.0.currency")
+ },
+ },
+ {
+ hosts: []string{"api.siliconflow.cn"},
+ path: "/v1/user/info",
+ read: func(body any, quota *ProviderQuota) {
+ quota.Remaining = quotaNumber(body, "data.totalBalance")
+ quota.Unit = "CNY"
+ },
+ },
+ {
+ hosts: []string{"api.siliconflow.com"},
+ path: "/v1/user/info",
+ read: func(body any, quota *ProviderQuota) {
+ quota.Remaining = quotaNumber(body, "data.totalBalance")
+ quota.Unit = "USD"
+ },
+ },
+ {
+ hosts: []string{"api.moonshot.cn", "api.moonshot.ai"},
+ path: "/v1/users/me/balance",
+ read: func(body any, quota *ProviderQuota) {
+ quota.Remaining = quotaNumber(body,
"data.available_balance")
+ quota.Unit = "CNY"
+ },
+ },
+ {
+ hosts: []string{"openrouter.ai"},
+ path: "/api/v1/credits",
+ read: func(body any, quota *ProviderQuota) {
+ quota.Total = quotaNumber(body, "data.total_credits")
+ quota.Used = quotaNumber(body, "data.total_usage")
+ quota.Remaining = quotaMinus(quota.Total, quota.Used)
+ quota.Unit = "USD"
+ },
+ },
+ {
+ hosts: []string{"api.stepfun.com", "api.stepfun.ai"},
+ path: "/v1/accounts",
+ read: func(body any, quota *ProviderQuota) {
+ quota.Remaining = quotaNumber(body, "balance")
+ quota.Unit = "CNY"
+ },
+ },
+ {
+ hosts: []string{"api.novita.ai"},
+ path: "/v3/user/balance",
+ // Novita reports in units of 0.0001 USD.
+ read: func(body any, quota *ProviderQuota) {
+ quota.Remaining = quotaScale(quotaNumber(body,
"availableBalance"), 10000)
+ quota.Unit = "USD"
+ },
+ },
+}
+
+func quotaVendorOf(baseUrl string) *quotaVendor {
+ parsed, err := url.Parse(baseUrl)
+ if err != nil {
+ return nil
+ }
+
+ host := strings.ToLower(parsed.Hostname())
+ for i := range quotaVendors {
+ for _, candidate := range quotaVendors[i].hosts {
+ if host == candidate || strings.HasSuffix(host,
"."+candidate) {
+ return "aVendors[i]
+ }
+ }
+ }
+ return nil
+}
+
+// quotaValue walks a dotted path into a decoded JSON body, where a numeric
+// segment indexes an array.
+func quotaValue(body any, path string) any {
+ if path == "" {
+ return nil
+ }
+
+ current := body
+ for _, key := range strings.Split(path, ".") {
+ switch node := current.(type) {
+ case map[string]any:
+ current = node[key]
+ case []any:
+ index, err := strconv.Atoi(key)
+ if err != nil || index < 0 || index >= len(node) {
+ return nil
+ }
+ current = node[index]
+ default:
+ return nil
+ }
+ }
+ return current
+}
+
+// quotaNumber reads an amount, which a vendor may have encoded as a string:
+// DeepSeek answers "110.00" where OpenRouter answers 110.
+func quotaNumber(body any, path string) *float64 {
+ switch value := quotaValue(body, path).(type) {
+ case float64:
+ return &value
+ case string:
+ number, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
+ if err != nil {
+ return nil
+ }
+ return &number
+ }
+ return nil
+}
+
+func quotaString(body any, path string) string {
+ if value, ok := quotaValue(body, path).(string); ok {
+ return value
+ }
+ return ""
+}
+
+func quotaMinus(total *float64, used *float64) *float64 {
+ if total == nil || used == nil {
+ return nil
+ }
+ remaining := *total - *used
+ return &remaining
+}
+
+func quotaScale(value *float64, divisor float64) *float64 {
+ if value == nil || divisor == 0 || divisor == 1 {
+ return value
+ }
+ scaled := *value / divisor
+ return &scaled
+}
+
+// quotaOrigin is the scheme and host a provider's requests go to, which is the
+// only origin its quota may be asked of.
+func quotaOrigin(baseUrl string) (string, error) {
+ parsed, err := url.Parse(baseUrl)
+ if err != nil {
+ return "", fmt.Errorf("invalid base URL: %s", err.Error())
+ }
+ if parsed.Scheme != "http" && parsed.Scheme != "https" {
+ return "", errors.New("invalid base URL: only the http and
https schemes are supported")
+ }
+ if parsed.Host == "" {
+ return "", errors.New("invalid base URL: the host is empty")
+ }
+ return parsed.Scheme + "://" + parsed.Host, nil
+}
+
+// quotaUrl resolves a configured endpoint against the provider's own origin.
An
+// absolute URL is allowed but has to stay on that origin: a quota endpoint
+// belongs to the vendor being billed, and following it anywhere else would
turn
+// the provider form into a way of making Gateway fetch arbitrary addresses.
+func quotaUrl(configured string, origin string) (string, error) {
+ configured = strings.TrimSpace(configured)
+ if strings.HasPrefix(configured, "/") {
+ return origin + configured, nil
+ }
+
+ parsed, err := url.Parse(configured)
+ if err != nil {
+ return "", fmt.Errorf("invalid quota URL: %s", err.Error())
+ }
+ if parsed.Scheme == "" || parsed.Host == "" {
+ return "", errors.New("the quota URL has to be a path or an
absolute URL")
+ }
+ if parsed.Scheme+"://"+parsed.Host != origin {
+ return "", fmt.Errorf("the quota URL has to be on %s, the
provider's own origin", origin)
+ }
+ return parsed.String(), nil
+}
+
+// probeQuota asks the vendor what is left. Everything it can go wrong with is
+// reported on the quota itself rather than as an error: a vendor that is down
+// is a thing the page shows, not a broken request.
+func probeQuota(provider *Provider) *ProviderQuota {
+ quota := &ProviderQuota{
+ Provider: provider.GetId(),
+ Time: util.GetCurrentTime(),
+ }
+
+ if UsesClientAuth(provider) {
+ quota.Error = "the caller's own login is forwarded, so there is
no account here to ask about"
+ return quota
+ }
+
+ origin, err := quotaOrigin(provider.BaseUrl)
+ if err != nil {
+ quota.Error = err.Error()
+ return quota
+ }
+
+ config := provider.Quota
+ vendor := quotaVendorOf(provider.BaseUrl)
+ if config.isEmpty() && vendor == nil {
+ return quota
+ }
+ quota.Supported = true
+
+ endpoint := ""
+ if config.isEmpty() {
+ endpoint = origin + vendor.path
+ } else if endpoint, err = quotaUrl(config.Url, origin); err != nil {
+ quota.Error = err.Error()
+ return quota
+ }
+
+ body, err := fetchQuota(endpoint, provider, config)
+ if err != nil {
+ quota.Error = err.Error()
+ return quota
+ }
+
+ if config.isEmpty() {
+ vendor.read(body, quota)
+ } else {
+ scale := config.Scale
+ quota.Remaining = quotaScale(quotaNumber(body,
config.Remaining), scale)
+ quota.Used = quotaScale(quotaNumber(body, config.Used), scale)
+ quota.Total = quotaScale(quotaNumber(body, config.Total), scale)
+ quota.Unit = config.Unit
+ }
+
+ if quota.Remaining == nil && quota.Used == nil && quota.Total == nil {
+ quota.Error = "the vendor answered, but none of the configured
fields were in the answer"
+ }
+ return quota
+}
+
+// fetchQuota performs the GET and decodes the answer. The provider's own key
+// authenticates it unless the configuration sets its own Authorization.
+func fetchQuota(endpoint string, provider *Provider, config *QuotaConfig)
(any, error) {
+ req, err := http.NewRequest(http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/json")
+
+ for name, value := range config.headers() {
+ req.Header.Set(name, quotaExpand(value, provider))
+ }
+ if req.Header.Get("Authorization") == "" {
+ if token := config.token(); token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ } else if provider.ApiKey != "" {
+ req.Header.Set("Authorization", "Bearer
"+provider.ApiKey)
+ }
+ }
+
+ client := &http.Client{Timeout: 15 * time.Second, Transport:
proxy.Transport()}
+ resp, err := client.Do(req)
+ if err != nil {
+ var urlErr *url.Error
+ if errors.As(err, &urlErr) {
+ return nil, urlErr.Err
+ }
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, maxProbeBody))
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, fmt.Errorf("the vendor answered %s%s", resp.Status,
probeDetail(raw))
+ }
+
+ var body any
+ if err := json.Unmarshal(raw, &body); err != nil {
+ return nil, fmt.Errorf("the vendor did not answer with JSON:
%s", err.Error())
+ }
+ return body, nil
+}
+
+func (config *QuotaConfig) headers() map[string]string {
+ if config == nil {
+ return nil
+ }
+ return config.Headers
+}
+
+func (config *QuotaConfig) token() string {
+ if config == nil {
+ return ""
+ }
+ return config.Token
+}
+
+// quotaExpand fills in the placeholders a header value may carry, so that a
+// credential is not written into the configuration a second time.
+func quotaExpand(value string, provider *Provider) string {
+ value = strings.ReplaceAll(value, "{{apiKey}}", provider.ApiKey)
+ return strings.ReplaceAll(value, "{{token}}", provider.Quota.token())
+}
+
+// providerQuotaTtl is how long an answer is reused. A balance moves as
requests
+// are relayed, but not so fast that every page view should cost the vendor a
+// request.
+const providerQuotaTtl = 10 * time.Minute
+
+type cachedQuota struct {
+ quota *ProviderQuota
+ time time.Time
+}
+
+var (
+ providerQuotaMutex sync.Mutex
+ providerQuotaMap = map[string]*cachedQuota{}
+)
+
+// GetProviderQuotas returns what is already known, without asking any vendor.
+func GetProviderQuotas(providers []*Provider) []*ProviderQuota {
+ providerQuotaMutex.Lock()
+ defer providerQuotaMutex.Unlock()
+
+ result := []*ProviderQuota{}
+ for _, provider := range providers {
+ if cached, ok := providerQuotaMap[provider.GetId()]; ok {
+ result = append(result, cached.quota)
+ }
+ }
+ return result
+}
+
+// maxQuotaProbes bounds the fan-out, so refreshing a long list of providers
does
+// not open one connection per provider at once.
+const maxQuotaProbes = 6
+
+// RefreshProviderQuotas asks the vendors and caches what they answer. Without
+// force, a provider whose answer is still fresh is left alone, which is what
+// makes it safe to call on every page load.
+func RefreshProviderQuotas(providers []*Provider, force bool) []*ProviderQuota
{
+ stale := []*Provider{}
+ for _, provider := range providers {
+ if force || isQuotaStale(provider.GetId()) {
+ stale = append(stale, provider)
+ }
+ }
+
+ var wait sync.WaitGroup
+ tokens := make(chan struct{}, maxQuotaProbes)
+ for _, provider := range stale {
+ wait.Add(1)
+ go func(provider *Provider) {
+ defer wait.Done()
+ tokens <- struct{}{}
+ defer func() { <-tokens }()
+
+ quota := probeQuota(provider)
+ providerQuotaMutex.Lock()
+ providerQuotaMap[quota.Provider] = &cachedQuota{quota:
quota, time: time.Now()}
+ providerQuotaMutex.Unlock()
+ }(provider)
+ }
+ wait.Wait()
+
+ return GetProviderQuotas(providers)
+}
+
+func isQuotaStale(providerId string) bool {
+ providerQuotaMutex.Lock()
+ defer providerQuotaMutex.Unlock()
+
+ cached, ok := providerQuotaMap[providerId]
+ return !ok || time.Since(cached.time) > providerQuotaTtl
+}
+
+// ClearProviderQuota forgets a vendor's answer, which an edited or deleted
+// provider deserves: the key it was read with may be the thing that changed.
+func ClearProviderQuota(providerId string) {
+ providerQuotaMutex.Lock()
+ defer providerQuotaMutex.Unlock()
+ delete(providerQuotaMap, providerId)
+}
diff --git a/routers/router.go b/routers/router.go
index dd5bd2a..7fea20d 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -113,6 +113,8 @@ func initAPI() {
beego.Router("/api/get-provider-models", &controllers.ApiController{},
"POST:GetProviderModels")
beego.Router("/api/test-provider", &controllers.ApiController{},
"POST:TestProvider")
beego.Router("/api/get-provider-health", &controllers.ApiController{},
"GET:GetProviderHealth")
+ beego.Router("/api/get-provider-quotas", &controllers.ApiController{},
"GET:GetProviderQuotas")
+ beego.Router("/api/refresh-provider-quotas",
&controllers.ApiController{}, "POST:RefreshProviderQuotas")
beego.Router("/api/get-llm-records", &controllers.ApiController{},
"GET:GetLlmRecords")
beego.Router("/api/get-llm-record", &controllers.ApiController{},
"GET:GetLlmRecord")
diff --git a/web/src/backend/ProviderBackend.ts
b/web/src/backend/ProviderBackend.ts
index 8936165..ce06b22 100644
--- a/web/src/backend/ProviderBackend.ts
+++ b/web/src/backend/ProviderBackend.ts
@@ -13,7 +13,7 @@
// limitations under the License.
import {itemId, query, request} from "@/backend/request";
-import type {Provider, ProviderHealth, ProviderTestResult} from "@/types";
+import type {Provider, ProviderHealth, ProviderQuota, ProviderTestResult} from
"@/types";
export function getProviders(
owner: string,
@@ -66,3 +66,14 @@ export function testProvider(provider: Provider) {
export function getProviderHealth() {
return request<ProviderHealth[]>("/api/get-provider-health");
}
+
+/** The vendor balances already known. Asks no vendor anything. */
+export function getProviderQuotas() {
+ return request<ProviderQuota[]>("/api/get-provider-quotas");
+}
+
+/** Asks the vendors what is left. Without an id every provider the caller can
+ * see is refreshed, and without force only the ones with a stale answer. */
+export function refreshProviderQuotas(id = "", force = false) {
+ return request<ProviderQuota[]>("/api/refresh-provider-quotas", "POST", {id:
id, force: force});
+}
diff --git a/web/src/components/ProviderQuota.tsx
b/web/src/components/ProviderQuota.tsx
new file mode 100644
index 0000000..ca3895b
--- /dev/null
+++ b/web/src/components/ProviderQuota.tsx
@@ -0,0 +1,254 @@
+// 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.
+
+import * as React from "react";
+import {CircleX, RefreshCw, Wallet} from "lucide-react";
+import i18next from "i18next";
+
+import * as ProviderBackend from "@/backend/ProviderBackend";
+import * as Setting from "@/Setting";
+import {Field} from "@/components/shared/form-dialog";
+import {NumberInput} from "@/components/shared/number-input";
+import {PasswordInput} from "@/components/shared/password-input";
+import {Section} from "@/components/shared/page-header";
+import {SimpleSelect} from "@/components/shared/simple-select";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Input} from "@/components/ui/input";
+import {Switch} from "@/components/ui/switch";
+import {Textarea} from "@/components/ui/textarea";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {formatPairs, parsePairs} from "@/lib/pairs";
+import {emptyQuotaConfig, formatQuota, quotaPresets} from "@/lib/quota";
+import {usesClientAuth} from "@/lib/providers";
+import type {Provider, ProviderQuota, QuotaConfig} from "@/types";
+
+/** The one line a table has room for: what is left, and why there is no number
+ * when there is none. */
+export function QuotaBadge({quota}: {quota: ProviderQuota | undefined}) {
+ if (!quota) {
+ return <span className="text-muted-foreground">{i18next.t("provider:Not
asked yet")}</span>;
+ }
+ // The error comes first: a known vendor whose base URL will not parse is
+ // unsupported and has something to say about why.
+ if (quota.error !== "") {
+ return (
+ <SimpleTooltip title={quota.error}>
+ <Badge variant="destructive">
+ <CircleX />
+ {i18next.t("provider:Balance unavailable")}
+ </Badge>
+ </SimpleTooltip>
+ );
+ }
+ if (!quota.supported) {
+ return <span className="text-muted-foreground">{i18next.t("provider:No
balance endpoint")}</span>;
+ }
+
+ const amount = quota.remaining ?? quota.total ?? quota.used;
+ if (amount === null || amount === undefined) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+
+ const detail = [
+ quota.used === null ? "" : `${i18next.t("provider:Used")}
${formatQuota(quota.used, quota.unit)}`,
+ quota.total === null ? "" : `${i18next.t("provider:Granted")}
${formatQuota(quota.total, quota.unit)}`,
+ `${i18next.t("provider:Asked at")} ${quota.time}`,
+ ]
+ .filter(part => part !== "")
+ .join(" · ");
+
+ return (
+ <SimpleTooltip title={detail}>
+ <Badge variant={quota.remaining !== null && quota.remaining <= 0 ?
"warning" : "success"}>
+ {formatQuota(amount, quota.unit)}
+ </Badge>
+ </SimpleTooltip>
+ );
+}
+
+/** Reads a scale that people would rather write as a whole number than as the
+ * fraction it divides by. 0 and 1 both mean "as the vendor reports it". */
+function scaleOf(config: QuotaConfig) {
+ return config.scale === 0 ? 1 : config.scale;
+}
+
+/**
+ * What the vendor says is left, on the page where the provider is edited: the
+ * reading itself, and — for the vendors Gateway has no built-in endpoint for —
+ * where to ask and which fields of the answer to read.
+ */
+export function ProviderQuotaSection({
+ provider,
+ onChange,
+}: {
+ provider: Provider;
+ onChange: (quota: QuotaConfig | null) => void;
+}) {
+ const [quota, setQuota] = React.useState<ProviderQuota | null>(null);
+ const [loading, setLoading] = React.useState(false);
+ const id = `${provider.owner}/${provider.name}`;
+ const config = provider.quota ?? null;
+
+ const load = React.useCallback(
+ (force: boolean) => {
+ setLoading(true);
+ ProviderBackend.refreshProviderQuotas(id, force)
+ .then(res => {
+ setLoading(false);
+ if (res.status === "error") {
+ Setting.showMessage("error", `${i18next.t("provider:Failed to read
the balance")}: ${res.msg}`);
+ return;
+ }
+ setQuota((res.data ?? [])[0] ?? null);
+ })
+ .catch(error => {
+ setLoading(false);
+ Setting.showMessage("error", `${i18next.t("provider:Failed to read
the balance")}: ${error}`);
+ });
+ },
+ [id],
+ );
+
+ React.useEffect(() => {
+ if (!usesClientAuth(provider)) {
+ load(false);
+ }
+ // The reading is asked for once, for the provider as it is stored. Asking
+ // again on every keystroke would bill the vendor for typing.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [id]);
+
+ const setConfigField = <K extends keyof QuotaConfig>(key: K, value:
QuotaConfig[K]) => {
+ onChange({...(config ?? emptyQuotaConfig()), [key]: value});
+ };
+
+ return (
+ <Section
+ columns={2}
+ title={
+ <span className="flex items-center gap-2">
+ <Wallet className="size-4" />
+ {i18next.t("provider:Balance")}
+ </span>
+ }
+ description={i18next.t("provider:Balance description")}
+ >
+ <Field label={i18next.t("provider:Remaining")} className="md:col-span-2">
+ <div className="flex flex-wrap items-center gap-3">
+ <QuotaBadge quota={quota ?? undefined} />
+ <Button type="button" size="sm" variant="outline" loading={loading}
onClick={() => load(true)}>
+ <RefreshCw />
+ {i18next.t("general:Refresh")}
+ </Button>
+ </div>
+ </Field>
+
+ <Field
+ label={i18next.t("provider:Custom endpoint")}
+ hint={i18next.t("provider:Custom endpoint hint")}
+ className="md:col-span-2"
+ >
+ <label className="flex items-center gap-2 text-sm">
+ <Switch
+ checked={config !== null}
+ onCheckedChange={checked => onChange(checked ? emptyQuotaConfig()
: null)}
+ />
+ {i18next.t("provider:Ask an endpoint of my own")}
+ </label>
+ </Field>
+
+ {config === null ? null : (
+ <>
+ <Field label={i18next.t("provider:Preset")}
hint={i18next.t("provider:Preset hint")}>
+ <SimpleSelect
+ value=""
+ placeholder={i18next.t("provider:Fill in from")}
+ onChange={value => {
+ const preset = quotaPresets.find(item => item.key === value);
+ if (preset) {
+ onChange({...preset.config, headers:
{...preset.config.headers}});
+ }
+ }}
+ options={quotaPresets.map(preset => ({label: preset.label,
value: preset.key}))}
+ />
+ </Field>
+ <Field label={i18next.t("provider:Balance URL")} htmlFor="quota-url"
hint={i18next.t("provider:Balance URL hint")}>
+ <Input
+ id="quota-url"
+ placeholder="/api/user/self"
+ value={config.url}
+ onChange={event => setConfigField("url", event.target.value)}
+ />
+ </Field>
+ <Field label={i18next.t("provider:Balance token")}
htmlFor="quota-token" hint={i18next.t("provider:Balance token hint")}>
+ <PasswordInput
+ id="quota-token"
+ value={config.token}
+ onChange={event => setConfigField("token", event.target.value)}
+ />
+ </Field>
+ <Field label={i18next.t("provider:Currency")} htmlFor="quota-unit"
hint={i18next.t("provider:Currency hint")}>
+ <Input
+ id="quota-unit"
+ placeholder="USD"
+ value={config.unit}
+ onChange={event => setConfigField("unit", event.target.value)}
+ />
+ </Field>
+ <Field
+ label={i18next.t("agentConfig:Headers")}
+ htmlFor="quota-headers"
+ hint={i18next.t("provider:Balance headers hint")}
+ className="md:col-span-2"
+ >
+ <Textarea
+ id="quota-headers"
+ rows={2}
+ placeholder="Authorization=Bearer {{token}}"
+ value={formatPairs(config.headers)}
+ onChange={event => setConfigField("headers",
parsePairs(event.target.value))}
+ />
+ </Field>
+ <Field label={i18next.t("provider:Remaining path")}
htmlFor="quota-remaining" hint={i18next.t("provider:Path hint")}>
+ <Input
+ id="quota-remaining"
+ placeholder="data.quota"
+ value={config.remaining}
+ onChange={event => setConfigField("remaining",
event.target.value)}
+ />
+ </Field>
+ <Field label={i18next.t("provider:Used path")} htmlFor="quota-used">
+ <Input
+ id="quota-used"
+ placeholder="data.used_quota"
+ value={config.used}
+ onChange={event => setConfigField("used", event.target.value)}
+ />
+ </Field>
+ <Field label={i18next.t("provider:Granted path")}
htmlFor="quota-total">
+ <Input
+ id="quota-total"
+ value={config.total}
+ onChange={event => setConfigField("total", event.target.value)}
+ />
+ </Field>
+ <Field label={i18next.t("provider:Scale")}
hint={i18next.t("provider:Scale hint")}>
+ <NumberInput min={1} value={scaleOf(config)} onChange={value =>
setConfigField("scale", value)} />
+ </Field>
+ </>
+ )}
+ </Section>
+ );
+}
diff --git a/web/src/components/agent-config/add-mcp-dialog.tsx
b/web/src/components/agent-config/add-mcp-dialog.tsx
index 692fefa..273ecca 100644
--- a/web/src/components/agent-config/add-mcp-dialog.tsx
+++ b/web/src/components/agent-config/add-mcp-dialog.tsx
@@ -36,24 +36,9 @@ import {Switch} from "@/components/ui/switch";
import {TagsInput} from "@/components/ui/tags-input";
import {Textarea} from "@/components/ui/textarea";
import {counted} from "@/lib/agent-configs";
+import {parsePairs} from "@/lib/pairs";
import type {AgentConfigInventory, AgentConfigPlanItem, McpTransport} from
"@/types";
-/** "KEY=VALUE" per line, which is how both env and headers are written here.
*/
-function parsePairs(text: string) {
- const pairs: Record<string, string> = {};
- text.split("\n").forEach(line => {
- const separator = line.indexOf("=");
- if (separator <= 0) {
- return;
- }
- const key = line.slice(0, separator).trim();
- if (key !== "") {
- pairs[key] = line.slice(separator + 1).trim();
- }
- });
- return pairs;
-}
-
/**
* Setting up one MCP server from Gateway: it is written into every agent
picked
* here, in that agent's own format and file, so the agents that should share a
diff --git a/web/src/lib/pairs.ts b/web/src/lib/pairs.ts
new file mode 100644
index 0000000..cd83c78
--- /dev/null
+++ b/web/src/lib/pairs.ts
@@ -0,0 +1,36 @@
+// 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.
+
+/** "KEY=VALUE" per line, which is how env vars and headers are written in the
+ * forms here. */
+export function parsePairs(text: string) {
+ const pairs: Record<string, string> = {};
+ text.split("\n").forEach(line => {
+ const separator = line.indexOf("=");
+ if (separator <= 0) {
+ return;
+ }
+ const key = line.slice(0, separator).trim();
+ if (key !== "") {
+ pairs[key] = line.slice(separator + 1).trim();
+ }
+ });
+ return pairs;
+}
+
+export function formatPairs(pairs: Record<string, string> | undefined) {
+ return Object.entries(pairs ?? {})
+ .map(([key, value]) => `${key}=${value}`)
+ .join("\n");
+}
diff --git a/web/src/lib/quota.ts b/web/src/lib/quota.ts
new file mode 100644
index 0000000..d2320e9
--- /dev/null
+++ b/web/src/lib/quota.ts
@@ -0,0 +1,72 @@
+// 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.
+
+import type {QuotaConfig} from "@/types";
+
+const currencySymbols: Record<string, string> = {USD: "$", CNY: "¥", EUR: "€",
GBP: "£", JPY: "¥"};
+
+/** An amount as the vendor bills it. A currency with a symbol gets it;
anything
+ * else keeps its own name, because a made-up symbol would be a guess. */
+export function formatQuota(amount: number, unit: string) {
+ const rounded = Math.abs(amount) < 1 && amount !== 0 ? amount.toFixed(4) :
amount.toFixed(2);
+ const symbol = currencySymbols[unit.toUpperCase()];
+ if (symbol) {
+ return amount < 0 ? `-${symbol}${rounded.slice(1)}` :
`${symbol}${rounded}`;
+ }
+ return unit === "" ? rounded : `${rounded} ${unit}`;
+}
+
+export function emptyQuotaConfig(): QuotaConfig {
+ return {url: "", headers: {}, token: "", remaining: "", used: "", total: "",
unit: "USD", scale: 0};
+}
+
+/** A ready-made endpoint for a family of sites, so the paths do not have to be
+ * worked out from the vendor's API documentation. */
+export interface QuotaPreset {
+ key: string;
+ label: string;
+ config: QuotaConfig;
+}
+
+export const quotaPresets: QuotaPreset[] = [
+ {
+ key: "newapi",
+ label: "New API",
+ config: {
+ url: "/api/user/self",
+ headers: {Authorization: "Bearer {{token}}", "New-Api-User": ""},
+ token: "",
+ remaining: "data.quota",
+ used: "data.used_quota",
+ total: "",
+ unit: "USD",
+ // New API counts in units of 1/500000 USD.
+ scale: 500000,
+ },
+ },
+ {
+ key: "oneapi",
+ label: "One API",
+ config: {
+ url: "/api/user/self",
+ headers: {Authorization: "Bearer {{token}}"},
+ token: "",
+ remaining: "data.quota",
+ used: "data.used_quota",
+ total: "",
+ unit: "USD",
+ scale: 500000,
+ },
+ },
+];
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index ddc13ad..7f0f654 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -473,7 +473,17 @@
"Add provider": "Add provider",
"Any model": "Any model",
"Any model hint": "Leave this empty to accept every model name, which is
what a provider that forwards the caller's login usually wants.",
+ "Ask an endpoint of my own": "Ask an endpoint of my own",
+ "Asked at": "asked at",
"Authentication": "Authentication",
+ "Balance": "Balance",
+ "Balance URL": "Balance URL",
+ "Balance URL hint": "A path, resolved against the provider's own base URL.
An absolute URL works too, but it has to stay on that same host.",
+ "Balance description": "What the vendor says is left on the account this
provider bills. Gateway knows the endpoint of a few vendors; for the rest, and
for aggregator sites, name one here.",
+ "Balance headers hint": "One NAME=VALUE per line. {{token}} stands for the
balance token and {{apiKey}} for the provider's own key, so neither has to be
written out twice.",
+ "Balance token": "Balance token",
+ "Balance token hint": "Only for a vendor whose balance endpoint does not
take the inference key, as an aggregator's system token does not. Stored like
the API key and never sent back to the browser.",
+ "Balance unavailable": "Balance unavailable",
"Base URL": "Base URL",
"Base URL hint": "Requests will be sent to",
"Caller's own login": "The caller's own login",
@@ -488,6 +498,10 @@
"Connectivity": "Connectivity",
"Connectivity hint": "A read-only probe against the upstream, which costs
nothing and answers whether this key works. It also runs once when the form is
submitted.",
"Cooling down": "Cooling down",
+ "Currency": "Currency",
+ "Currency hint": "What the numbers are counted in, after the scale is
applied.",
+ "Custom endpoint": "Custom endpoint",
+ "Custom endpoint hint": "Leave this off for a vendor Gateway already knows
the balance endpoint of.",
"Custom vendor": "Custom",
"Custom vendor detail": "Any OpenAI- or Anthropic-compatible API. You give
it a base URL and a key.",
"Disabled": "Disabled",
@@ -498,9 +512,13 @@
"Failed to fetch models": "Failed to fetch models",
"Failed to get provider": "Failed to get provider",
"Failed to get providers": "Failed to get providers",
+ "Failed to read the balance": "Failed to read the balance",
"Failed to save": "Failed to save",
"Fetch models": "Fetch models",
"Fetched {count} models": "Fetched {count} models",
+ "Fill in from": "Fill in from a preset",
+ "Granted": "granted",
+ "Granted path": "Granted path",
"Health": "Health",
"Healthy": "Healthy",
"Icon": "Icon",
@@ -510,13 +528,18 @@
"Models from upstream": "Models from upstream",
"Models hint": "The model names this provider serves, used to route
requests that arrive by model name.",
"New Provider": "New Provider",
+ "No balance endpoint": "No balance endpoint",
"No providers yet": "No providers yet",
"No providers yet detail": "A provider is your own vendor account: a base
URL, the models it serves, and either an API key or the caller's own login
forwarded upstream. Add one, then bind it to an agent to route that agent's
requests through it.",
+ "Not asked yet": "Not asked yet",
"Not used yet": "Not used yet",
"Notes": "Notes",
"Notes hint": "For whoever reads this later: which account the key belongs
to, when it expires, who pays for it.",
"Nothing to test": "There is nothing to test yet",
"Page description": "One endpoint in front of your model vendors. Gateway
holds the API key, so the agents never have it — or forwards the agent's own
sign-in and holds nothing.",
+ "Path hint": "A dotted path into the answer, where a number indexes an
array: balance_infos.0.total_balance.",
+ "Preset": "Preset",
+ "Preset hint": "Fills in the paths for a family of sites, so they do not
have to be read out of the vendor's API documentation.",
"Priority": "Priority",
"Priority hint": "Smaller number means higher priority, 0 is the highest;
when multiple providers serve the same model, the one with the smaller value is
used first.",
"Provider": "Provider",
@@ -526,8 +549,13 @@
"Provider saved": "Provider saved",
"Providers": "Providers",
"Recovering": "Recovering",
+ "Refresh balances": "Refresh balances",
+ "Remaining": "Remaining",
+ "Remaining path": "Remaining path",
"Retried at": "retried at",
"Save anyway hint": "The upstream did not accept this provider. Fix it, or
press {action} again to store it as it is.",
+ "Scale": "Scale",
+ "Scale hint": "Divides every number read, for a vendor that counts in an
internal unit of its own. New API counts in 1/500000 of a dollar, so its scale
is 500000.",
"Select all": "Select all",
"Source": "Source",
"Source hint": "Where this provider gets its credentials. Picking one
fills in the type, base URL and models, so a subscription needs nothing typed
and a vendor needs only its key.",
@@ -536,7 +564,9 @@
"Test Connectivity": "Test Connectivity",
"Type": "Type",
"Usage": "Usage",
- "Usage hint": "Point any client that speaks this provider's API at Gateway
with these variables."
+ "Usage hint": "Point any client that speaks this provider's API at Gateway
with these variables.",
+ "Used": "used",
+ "Used path": "Used path"
},
"rule": {
"Allow": "Allow",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index ac504fb..f0bc82c 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -473,7 +473,17 @@
"Add provider": "添加供应商",
"Any model": "任意模型",
"Any model hint": "留空表示接受任何模型名,转发调用方登录的供应商一般都这样配。",
+ "Ask an endpoint of my own": "使用自定义接口",
+ "Asked at": "查询于",
"Authentication": "认证方式",
+ "Balance": "余额",
+ "Balance URL": "余额接口地址",
+ "Balance URL hint": "相对于该供应商 Base URL 的路径。也可以填完整地址,但必须在同一个域名下。",
+ "Balance description": "厂商侧这个账号还剩多少。Gateway 内置了几家厂商的接口;其余厂商和聚合站需要在这里指定。",
+ "Balance headers hint": "每行一个 NAME=VALUE。{{token}} 代表余额令牌,{{apiKey}}
代表该供应商自己的密钥,两者都不必再写一遍。",
+ "Balance token": "余额令牌",
+ "Balance token hint": "只有当厂商的余额接口不接受推理密钥时才需要填,比如聚合站的系统令牌。存储方式与 API Key
相同,不会返回给浏览器。",
+ "Balance unavailable": "无法获取余额",
"Base URL": "基础 URL",
"Base URL hint": "实际请求将发往",
"Caller's own login": "调用方自己的登录",
@@ -488,6 +498,10 @@
"Connectivity": "连通性",
"Connectivity hint": "对上游做一次只读探测,不产生费用,用来确认这个密钥是否可用。提交表单时也会自动探测一次。",
"Cooling down": "冷却中",
+ "Currency": "币种",
+ "Currency hint": "换算之后这些数字的单位。",
+ "Custom endpoint": "自定义接口",
+ "Custom endpoint hint": "如果 Gateway 已内置该厂商的余额接口,这里保持关闭即可。",
"Custom vendor": "自定义",
"Custom vendor detail": "任何兼容 OpenAI 或 Anthropic 的 API,由你填写 base URL 和
Key。",
"Disabled": "已禁用",
@@ -498,9 +512,13 @@
"Failed to fetch models": "获取模型列表失败",
"Failed to get provider": "获取供应商失败",
"Failed to get providers": "获取供应商列表失败",
+ "Failed to read the balance": "获取余额失败",
"Failed to save": "保存失败",
"Fetch models": "自动获取",
"Fetched {count} models": "共获取到 {count} 个模型",
+ "Fill in from": "从预设填入",
+ "Granted": "总额",
+ "Granted path": "总额字段路径",
"Health": "健康状态",
"Healthy": "正常",
"Icon": "图标",
@@ -510,13 +528,18 @@
"Models from upstream": "上游返回的模型",
"Models hint": "这个供应商提供的模型名,按模型名路由的请求靠它匹配。",
"New Provider": "新建供应商",
+ "No balance endpoint": "无余额接口",
"No providers yet": "还没有供应商",
"No providers yet detail": "一个供应商就是你自己的厂商账号:base URL、它提供的模型,以及一个 API
Key,或者转发调用方自己的登录。先建一个,再把它绑定到 Agent,该 Agent 的请求就会走它。",
+ "Not asked yet": "尚未查询",
"Not used yet": "尚未使用",
"Notes": "备注",
"Notes hint": "写给以后的自己:这个密钥属于哪个账号、什么时候过期、由谁付费。",
"Nothing to test": "暂时没有可探测的内容",
"Page description": "挡在模型厂商前面的统一入口。API Key 由 Gateway 保管,Agent 拿不到;也可以转发
Agent 自己的登录,什么都不保管。",
+ "Path hint": "返回内容里的点分路径,数字表示数组下标:balance_infos.0.total_balance。",
+ "Preset": "预设",
+ "Preset hint": "为某一类站点填好字段路径,省去翻厂商 API 文档。",
"Priority": "优先级",
"Priority hint": "数字越小优先级越高,0 为最高;同一模型有多个供应商时优先使用数值小的那个。",
"Provider": "供应商",
@@ -526,8 +549,13 @@
"Provider saved": "供应商已保存",
"Providers": "供应商",
"Recovering": "恢复中",
+ "Refresh balances": "刷新余额",
+ "Remaining": "余额",
+ "Remaining path": "余额字段路径",
"Retried at": "将于",
"Save anyway hint": "上游没有接受这个供应商。修正后重试,或再次点击「{action}」按当前内容保存。",
+ "Scale": "换算系数",
+ "Scale hint": "读到的数字都除以它,用于按内部单位计数的厂商。New API 以 1/500000 美元计数,系数就是
500000。",
"Select all": "全选",
"Source": "来源",
"Source hint": "这个供应商的凭据从哪里来。选一个就会自动填好类型、base URL 和模型:订阅登录什么都不用填,厂商只用填一个
Key。",
@@ -536,7 +564,9 @@
"Test Connectivity": "测试连接",
"Type": "类型",
"Usage": "使用方法",
- "Usage hint": "任何说这个供应商所用 API 的客户端,配上下面的变量就能走网关。"
+ "Usage hint": "任何说这个供应商所用 API 的客户端,配上下面的变量就能走网关。",
+ "Used": "已用",
+ "Used path": "已用字段路径"
},
"rule": {
"Allow": "允许",
diff --git a/web/src/pages/ProviderEditPage.tsx
b/web/src/pages/ProviderEditPage.tsx
index b427ac9..81bf903 100644
--- a/web/src/pages/ProviderEditPage.tsx
+++ b/web/src/pages/ProviderEditPage.tsx
@@ -21,6 +21,7 @@ import * as ProviderBackend from "@/backend/ProviderBackend";
import * as Setting from "@/Setting";
import {ProviderIcon, ProviderIconField} from "@/components/ProviderIcon";
import {ProviderModelsField} from "@/components/ProviderModelsField";
+import {ProviderQuotaSection} from "@/components/ProviderQuota";
import {ProviderTestField, useProviderTest} from
"@/components/ProviderTestField";
import {EnvSnippet} from "@/components/EnvSnippet";
import {Field} from "@/components/shared/form-dialog";
@@ -42,7 +43,7 @@ import {
localShell,
usesClientAuth,
} from "@/lib/providers";
-import type {Provider} from "@/types";
+import type {Provider, QuotaConfig} from "@/types";
// Mirrors object.BuildOpenAiUrl on the server.
function buildOpenAiUrl(baseUrl: string, endpoint: string) {
@@ -296,6 +297,11 @@ export default function ProviderEditPage() {
</Field>
</Section>
+ <ProviderQuotaSection
+ provider={provider}
+ onChange={(quota: QuotaConfig | null) => setField("quota", quota)}
+ />
+
<Section title={i18next.t("provider:Usage")}
description={i18next.t("provider:Usage hint")} columns={1}>
<EnvSnippet
protocol={providerProtocol(provider.type)}
diff --git a/web/src/pages/ProviderListPage.tsx
b/web/src/pages/ProviderListPage.tsx
index f933356..7c6e62c 100644
--- a/web/src/pages/ProviderListPage.tsx
+++ b/web/src/pages/ProviderListPage.tsx
@@ -14,13 +14,14 @@
import * as React from "react";
import {Link, useNavigate} from "react-router-dom";
-import {ChevronDown, CircleCheck, CircleX, Pencil, Plug, Plus, RefreshCw,
Trash2} from "lucide-react";
+import {ChevronDown, CircleCheck, CircleX, Pencil, Plug, Plus, RefreshCw,
Trash2, Wallet} from "lucide-react";
import i18next from "i18next";
import * as ProviderBackend from "@/backend/ProviderBackend";
import * as Setting from "@/Setting";
import {ProviderIcon, ProviderIconField} from "@/components/ProviderIcon";
import {ProviderModelsField} from "@/components/ProviderModelsField";
+import {QuotaBadge} from "@/components/ProviderQuota";
import {ProviderSourcePicker, sourceTitle} from
"@/components/ProviderSourcePicker";
import {ProviderTestField, useProviderTest} from
"@/components/ProviderTestField";
import {ConfirmDialog} from "@/components/shared/confirm-dialog";
@@ -46,7 +47,7 @@ import {
type ProviderSource,
} from "@/lib/providers";
import {cn} from "@/lib/utils";
-import type {Account, Provider, ProviderHealth} from "@/types";
+import type {Account, Provider, ProviderHealth, ProviderQuota} from "@/types";
function newProvider(owner: string, label = "New Provider"): Provider {
const randomName = Setting.getRandomName();
@@ -113,6 +114,8 @@ export default function ProviderListPage({account}:
{account: Account}) {
const [source, setSource] = React.useState<ProviderSource | null>(null);
const [nameError, setNameError] = React.useState("");
const [health, setHealth] = React.useState<ProviderHealth[]>([]);
+ const [quotas, setQuotas] = React.useState<ProviderQuota[]>([]);
+ const [refreshingQuotas, setRefreshingQuotas] = React.useState(false);
const test = useProviderTest(form);
const fetchProviders = React.useCallback(
@@ -166,6 +169,23 @@ export default function ProviderListPage({account}:
{account: Account}) {
return () => clearInterval(interval);
}, []);
+ // The balances are read from the cache first so the table fills in at once,
+ // then refreshed for whichever provider has a stale answer.
+ const loadQuotas = React.useCallback((force: boolean) => {
+ setRefreshingQuotas(true);
+ ProviderBackend.refreshProviderQuotas("", force)
+ .then(res => setQuotas(res.status === "ok" ? (res.data ?? []) : []))
+ .catch(() => undefined)
+ .then(() => setRefreshingQuotas(false));
+ }, []);
+
+ React.useEffect(() => {
+ ProviderBackend.getProviderQuotas()
+ .then(res => setQuotas(res.status === "ok" ? (res.data ?? []) : []))
+ .catch(() => undefined)
+ .then(() => loadQuotas(false));
+ }, [loadQuotas]);
+
const openAddDialog = (start?: ProviderSource) => {
setForm(start ? providerFromSource(account.name, start) :
newProvider(account.name));
setSource(start ?? null);
@@ -341,6 +361,14 @@ export default function ProviderListPage({account}:
{account: Account}) {
</Badge>
),
},
+ {
+ title: i18next.t("provider:Balance"),
+ key: "quota",
+ width: "140px",
+ render: (_text, record) => (
+ <QuotaBadge quota={quotas.find(item => item.provider ===
`${record.owner}/${record.name}`)} />
+ ),
+ },
{
title: i18next.t("provider:Health"),
key: "health",
@@ -447,10 +475,21 @@ export default function ProviderListPage({account}:
{account: Account}) {
description={`${total} ${i18next.t("provider:Providers")}`}
emptyIcon={Plug}
toolbar={
- <Button variant="outline" size="sm" onClick={() =>
fetchProviders()} loading={loading}>
- <RefreshCw />
- {i18next.t("general:Refresh")}
- </Button>
+ <>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => loadQuotas(true)}
+ loading={refreshingQuotas}
+ >
+ <Wallet />
+ {i18next.t("provider:Refresh balances")}
+ </Button>
+ <Button variant="outline" size="sm" onClick={() =>
fetchProviders()} loading={loading}>
+ <RefreshCw />
+ {i18next.t("general:Refresh")}
+ </Button>
+ </>
}
/>
)}
diff --git a/web/src/types.ts b/web/src/types.ts
index f38a5fc..dcf8b57 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -162,6 +162,36 @@ export interface Provider {
icon: string;
/** Whatever the person who added the provider wants to remember about it. */
notes: string;
+ /** Null leaves the built-in balance endpoints to answer. */
+ quota?: QuotaConfig | null;
+}
+
+/** Where a provider's balance is read from, when there is no built-in endpoint
+ * for its vendor. Mirrors object.QuotaConfig on the server. */
+export interface QuotaConfig {
+ url: string;
+ // An index signature rather than Record: this module declares a Record
entity
+ // of its own, which shadows the built-in type.
+ headers: {[name: string]: string};
+ token: string;
+ remaining: string;
+ used: string;
+ total: string;
+ unit: string;
+ scale: number;
+}
+
+/** What the vendor last said is left on the account behind a provider. */
+export interface ProviderQuota {
+ provider: string;
+ /** False when nothing knows where to ask, which is not an error. */
+ supported: boolean;
+ remaining: number | null;
+ used: number | null;
+ total: number | null;
+ unit: string;
+ error: string;
+ time: string;
}
export interface ProviderTestResult {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]