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 ccdc81c  feat(provider): draw a manual balance down by recorded spend
ccdc81c is described below

commit ccdc81c54e291a3435dbde1483b4f91369b1d4c9
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 00:06:12 2026 +0800

    feat(provider): draw a manual balance down by recorded spend
---
 object/llm_record.go                 | 16 +++++++++
 object/provider.go                   | 11 ++++++
 object/provider_quota.go             | 40 ++++++++++++++++++++++
 web/src/components/ProviderQuota.tsx | 65 +++++++++++++++++++++++++++++-------
 web/src/lib/quota.ts                 | 22 ++++++++----
 web/src/locales/en/data.json         | 11 ++++++
 web/src/locales/zh/data.json         | 11 ++++++
 web/src/types.ts                     |  7 ++++
 8 files changed, 164 insertions(+), 19 deletions(-)

diff --git a/object/llm_record.go b/object/llm_record.go
index f88219f..d38628b 100644
--- a/object/llm_record.go
+++ b/object/llm_record.go
@@ -609,6 +609,22 @@ func GetSeenLlmModels() ([]string, error) {
        return models, err
 }
 
+// sumProviderCost totals what the priced records say was spent through one
+// provider. An empty since counts every record still retained. A database that
+// is not open yet answers zero rather than failing: a manual balance showing
+// the full starting figure is better than an error.
+func sumProviderCost(providerId string, since string) (float64, error) {
+       if ormer == nil || ormer.Engine == nil {
+               return 0, nil
+       }
+
+       session := ormer.Engine.Where("provider = ?", providerId).And("priced = 
?", true)
+       if since != "" {
+               session = session.And("created_time >= ?", since)
+       }
+       return session.Sum(&LlmRecord{}, "cost")
+}
+
 // GetLlmRecordStats totals the same records GetLlmRecords lists.
 func GetLlmRecordStats(filter LlmRecordFilter, topModels int) 
(*LlmRecordStats, error) {
        countSession := llmRecordSession(filter)
diff --git a/object/provider.go b/object/provider.go
index 3eb8eb6..27e1081 100644
--- a/object/provider.go
+++ b/object/provider.go
@@ -91,6 +91,15 @@ func encryptQuotaToken(provider *Provider) error {
        return nil
 }
 
+// stampQuotaSince fixes the point a manual balance counts spending from, the
+// first time one is saved. A client that clears the field is asking to start
+// over, so an empty Since is always refilled with now.
+func stampQuotaSince(provider *Provider) {
+       if provider.Quota != nil && provider.Quota.Manual && 
provider.Quota.Since == "" {
+               provider.Quota.Since = util.GetCurrentTime()
+       }
+}
+
 func decryptQuotaToken(provider *Provider) {
        if provider.Quota == nil || provider.Quota.Token == "" {
                return
@@ -523,6 +532,7 @@ func AddProvider(provider *Provider) (bool, error) {
        }
        provider.UpdatedTime = now
 
+       stampQuotaSince(provider)
        if err := encryptQuotaToken(provider); err != nil {
                return false, err
        }
@@ -593,6 +603,7 @@ func UpdateProvider(id string, provider *Provider) (bool, 
error) {
        if provider.Quota != nil && provider.Quota.Token == ApiKeyMask {
                provider.Quota.Token = stored.Quota.token()
        }
+       stampQuotaSince(provider)
        if err := encryptQuotaToken(provider); err != nil {
                return false, err
        }
diff --git a/object/provider_quota.go b/object/provider_quota.go
index 60f1d93..752a584 100644
--- a/object/provider_quota.go
+++ b/object/provider_quota.go
@@ -72,12 +72,27 @@ type QuotaConfig struct {
        // 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"`
+
+       // Manual asks no endpoint at all: the balance is Initial, drawn down 
by what
+       // the relay recorded spending through this provider since Since. It is 
the
+       // answer for a vendor with no balance API of any kind. The recorded 
cost is
+       // priced from the models table, so Unit only matches reality when that 
table
+       // is kept in the same currency.
+       Manual  bool    `json:"manual"`
+       Initial float64 `json:"initial"`
+       // Since is where the drawdown starts counting, in util.GetCurrentTime 
form.
+       // Set when Manual is first saved; resetting the counter moves it 
forward.
+       Since string `json:"since"`
 }
 
 func (config *QuotaConfig) isEmpty() bool {
        return config == nil || strings.TrimSpace(config.Url) == ""
 }
 
+func (config *QuotaConfig) isManual() bool {
+       return config != nil && config.Manual
+}
+
 // quotaVendor is a vendor whose balance endpoint is documented, so that a
 // provider pointed at it needs nothing configured.
 type quotaVendor struct {
@@ -285,6 +300,10 @@ func probeQuota(provider *Provider) *ProviderQuota {
                return quota
        }
 
+       if provider.Quota.isManual() {
+               return manualQuota(provider, quota)
+       }
+
        origin, err := quotaOrigin(provider.BaseUrl)
        if err != nil {
                quota.Error = err.Error()
@@ -328,6 +347,27 @@ func probeQuota(provider *Provider) *ProviderQuota {
        return quota
 }
 
+// manualQuota draws the operator's starting figure down by what the relayed
+// records say was spent through this provider. Nothing is asked of any vendor.
+func manualQuota(provider *Provider, quota *ProviderQuota) *ProviderQuota {
+       config := provider.Quota
+       quota.Supported = true
+       quota.Unit = config.Unit
+
+       spent, err := sumProviderCost(provider.GetId(), config.Since)
+       if err != nil {
+               quota.Error = err.Error()
+               return quota
+       }
+
+       total := config.Initial
+       remaining := total - spent
+       quota.Total = &total
+       quota.Used = &spent
+       quota.Remaining = &remaining
+       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) {
diff --git a/web/src/components/ProviderQuota.tsx 
b/web/src/components/ProviderQuota.tsx
index ca3895b..e4ff7d6 100644
--- a/web/src/components/ProviderQuota.tsx
+++ b/web/src/components/ProviderQuota.tsx
@@ -26,7 +26,6 @@ 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";
@@ -134,6 +133,15 @@ export function ProviderQuotaSection({
     onChange({...(config ?? emptyQuotaConfig()), [key]: value});
   };
 
+  const mode = config === null ? "auto" : config.manual ? "manual" : "custom";
+  const setMode = (next: string) => {
+    if (next === "auto") {
+      onChange(null);
+    } else {
+      onChange({...(config ?? emptyQuotaConfig()), manual: next === "manual"});
+    }
+  };
+
   return (
     <Section
       columns={2}
@@ -156,20 +164,53 @@ export function ProviderQuotaSection({
       </Field>
 
       <Field
-        label={i18next.t("provider:Custom endpoint")}
-        hint={i18next.t("provider:Custom endpoint hint")}
+        label={i18next.t("provider:Balance source")}
+        hint={i18next.t("provider:Balance source 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>
+        <SimpleSelect
+          className="max-w-xs"
+          value={mode}
+          onChange={setMode}
+          options={[
+            {label: i18next.t("provider:Automatic"), value: "auto"},
+            {label: i18next.t("provider:Custom endpoint"), value: "custom"},
+            {label: i18next.t("provider:Manual amount"), value: "manual"},
+          ]}
+        />
       </Field>
 
-      {config === null ? null : (
+      {config !== null && config.manual ? (
+        <>
+          <Field label={i18next.t("provider:Initial amount")} 
hint={i18next.t("provider:Manual amount hint")}>
+            <NumberInput min={0} value={config.initial} onChange={value => 
setConfigField("initial", value)} />
+          </Field>
+          <Field label={i18next.t("provider:Currency")} htmlFor="quota-unit" 
hint={i18next.t("provider:Manual currency hint")}>
+            <Input
+              id="quota-unit"
+              placeholder="USD"
+              value={config.unit}
+              onChange={event => setConfigField("unit", event.target.value)}
+            />
+          </Field>
+          <Field
+            label={i18next.t("provider:Counting since")}
+            hint={i18next.t("provider:Reset counter hint")}
+            className="md:col-span-2"
+          >
+            <div className="flex flex-wrap items-center gap-3">
+              <span className="text-muted-foreground text-sm">
+                {config.since || i18next.t("provider:Since the start")}
+              </span>
+              <Button type="button" size="sm" variant="outline" onClick={() => 
setConfigField("since", "")}>
+                {i18next.t("provider:Reset counter")}
+              </Button>
+            </div>
+          </Field>
+        </>
+      ) : null}
+
+      {config !== null && !config.manual ? (
         <>
           <Field label={i18next.t("provider:Preset")} 
hint={i18next.t("provider:Preset hint")}>
             <SimpleSelect
@@ -248,7 +289,7 @@ export function ProviderQuotaSection({
             <NumberInput min={1} value={scaleOf(config)} onChange={value => 
setConfigField("scale", value)} />
           </Field>
         </>
-      )}
+      ) : null}
     </Section>
   );
 }
diff --git a/web/src/lib/quota.ts b/web/src/lib/quota.ts
index d2320e9..e0a57a4 100644
--- a/web/src/lib/quota.ts
+++ b/web/src/lib/quota.ts
@@ -28,7 +28,19 @@ export function formatQuota(amount: number, unit: string) {
 }
 
 export function emptyQuotaConfig(): QuotaConfig {
-  return {url: "", headers: {}, token: "", remaining: "", used: "", total: "", 
unit: "USD", scale: 0};
+  return {
+    url: "",
+    headers: {},
+    token: "",
+    remaining: "",
+    used: "",
+    total: "",
+    unit: "USD",
+    scale: 0,
+    manual: false,
+    initial: 0,
+    since: "",
+  };
 }
 
 /** A ready-made endpoint for a family of sites, so the paths do not have to be
@@ -44,13 +56,11 @@ export const quotaPresets: QuotaPreset[] = [
     key: "newapi",
     label: "New API",
     config: {
+      ...emptyQuotaConfig(),
       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,
     },
@@ -59,13 +69,11 @@ export const quotaPresets: QuotaPreset[] = [
     key: "oneapi",
     label: "One API",
     config: {
+      ...emptyQuotaConfig(),
       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 9b07b9a..657f3cd 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -730,9 +730,20 @@
     "Asked at": "asked at",
     "Authentication": "Authentication",
     "Balance": "Balance",
+    "Automatic": "Automatic",
     "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 source": "Balance source",
+    "Balance source hint": "Automatic uses the endpoint Gateway has built in 
for the vendor. Custom endpoint names one yourself. Manual amount tracks a 
figure you enter against recorded spend, for a vendor with no balance API.",
+    "Counting since": "Counting spend since",
+    "Initial amount": "Initial amount",
+    "Manual amount": "Manual amount",
+    "Manual amount hint": "The balance starts at this figure and drops by what 
Gateway records spending through this provider. Spend is priced from the model 
pricing table, so keep that table in the same currency.",
+    "Manual currency hint": "The label the amounts carry. It converts nothing: 
the recorded spend is subtracted as it stands.",
+    "Reset counter": "Reset counter",
+    "Reset counter hint": "Saving moves the start point to now, so the balance 
counts only spend from here on.",
+    "Since the start": "set when you save",
     "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.",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 99c73df..6e807c1 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -750,10 +750,21 @@
     "Ask an endpoint of my own": "使用自定义接口",
     "Asked at": "查询于",
     "Authentication": "认证方式",
+    "Automatic": "自动",
     "Balance": "余额",
     "Balance URL": "余额接口地址",
     "Balance URL hint": "相对于该供应商 Base URL 的路径。也可以填完整地址,但必须在同一个域名下。",
     "Balance description": "厂商侧这个账号还剩多少。Gateway 内置了几家厂商的接口;其余厂商和聚合站需要在这里指定。",
+    "Balance source": "余额来源",
+    "Balance source hint": "「自动」使用 Gateway 
内置的厂商接口;「自定义接口」由你自己指定;「手动填写」用你填的金额减去已记录的花费,适用于完全没有余额接口的厂商。",
+    "Counting since": "计费起点",
+    "Initial amount": "初始金额",
+    "Manual amount": "手动填写",
+    "Manual amount hint": "余额从这个金额开始,按 Gateway 
记录的、经由该供应商的花费递减。花费依据模型价格表计算,请让该表保持同一币种。",
+    "Manual currency hint": "金额显示用的单位。它不做任何换算:记录的花费按原值扣减。",
+    "Reset counter": "重置计费起点",
+    "Reset counter hint": "保存后把起点移到当前时间,余额只统计此后的花费。",
+    "Since the start": "保存后确定",
     "Balance headers hint": "每行一个 NAME=VALUE。{{token}} 代表余额令牌,{{apiKey}} 
代表该供应商自己的密钥,两者都不必再写一遍。",
     "Balance token": "余额令牌",
     "Balance token hint": "只有当厂商的余额接口不接受推理密钥时才需要填,比如聚合站的系统令牌。存储方式与 API Key 
相同,不会返回给浏览器。",
diff --git a/web/src/types.ts b/web/src/types.ts
index 84d0991..9b5a477 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -82,6 +82,13 @@ export interface QuotaConfig {
   total: string;
   unit: string;
   scale: number;
+  /** True asks no endpoint: the balance is `initial`, drawn down by recorded
+   * spend through this provider since `since`. */
+  manual: boolean;
+  initial: number;
+  /** Where the drawdown starts counting. The server sets it; an empty value
+   * asks the server to start over from now. */
+  since: string;
 }
 
 /** What the vendor last said is left on the account behind a provider. */


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

Reply via email to