Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### English

- Keep Qoder accounts routable when their base quota is exhausted but an available add-on or organization resource package still has credits, and show resource-package balance in the console

### 中文

- Qoder 主额度用尽但可用附加包或组织资源包仍有余额时继续参与路由,并在控制台展示资源包余额

## 0.4.3 - 2026-09-10

### English
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@ export type AccountQuota = {
has_add_on?: boolean
add_on_used?: number
add_on_total?: number
add_on_remaining?: number
add_on_unit?: string
add_on_available?: boolean
has_resource_package?: boolean
resource_package_used?: number
resource_package_total?: number
resource_package_remaining?: number
resource_package_unit?: string
resource_package_available?: boolean
fetched_at?: string
}

Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/account/AccountCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ export function AccountCard({
usedLabel={t('quotaUsed')}
remainingLabel={t('quotaRemaining')}
addOnLabel={t('quotaAddOn')}
resourcePackageLabel={t('quotaResourcePackage')}
exceededLabel={t('quotaExceeded')}
/>
) : <span className="text-[11px] text-foreground/65">{t('statsUnknown')}</span>}
Expand Down
11 changes: 8 additions & 3 deletions frontend/src/components/account/QuotaMeter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,23 @@ type Props = {
usedLabel: string
remainingLabel: string
addOnLabel: string
resourcePackageLabel: string
exceededLabel: string
}

export function QuotaMeter({ quota, label, usedLabel, remainingLabel, addOnLabel, exceededLabel }: Props) {
export function QuotaMeter({ quota, label, usedLabel, remainingLabel, addOnLabel, resourcePackageLabel, exceededLabel }: Props) {
const ratio = quotaUsedRatio(quota)
const tone = quotaTone(quota)
const color = tone === 'danger' ? 'danger' : tone === 'warn' ? 'warning' : 'success'
const unit = quota.unit || 'credits'
const used = `${formatQuotaAmount(quota.used)} / ${formatQuotaAmount(quota.total)} ${unit}`
const remaining = `${formatQuotaAmount(quota.remaining)} ${unit}`
const addOn = quota.has_add_on
const addOn = quota.has_add_on && quota.add_on_available !== false
? `${addOnLabel} ${formatQuotaAmount(quota.add_on_used)} / ${formatQuotaAmount(quota.add_on_total)} ${quota.add_on_unit || 'credits'}`
: ''
const resourcePackage = quota.has_resource_package && quota.resource_package_available !== false
? `${resourcePackageLabel} ${remainingLabel} ${formatQuotaAmount(quota.resource_package_remaining)} ${quota.resource_package_unit || 'credits'}`
: ''

return (
<Meter
Expand All @@ -31,7 +35,7 @@ export function QuotaMeter({ quota, label, usedLabel, remainingLabel, addOnLabel
maxValue={100}
value={Math.round(ratio * 100)}
aria-label={label}
valueLabel={`${usedLabel} ${used} · ${remainingLabel} ${remaining}${addOn ? ` · ${addOn}` : ''}`}
valueLabel={`${usedLabel} ${used} · ${remainingLabel} ${remaining}${addOn ? ` · ${addOn}` : ''}${resourcePackage ? ` · ${resourcePackage}` : ''}`}
>
<Label className="text-[11px] font-medium">
{label}
Expand All @@ -40,6 +44,7 @@ export function QuotaMeter({ quota, label, usedLabel, remainingLabel, addOnLabel
<Meter.Output className="mono text-[10px] text-foreground/65">
{usedLabel} {used} · {remainingLabel} {remaining}
{addOn ? ` · ${addOn}` : ''}
{resourcePackage ? ` · ${resourcePackage}` : ''}
</Meter.Output>
<Meter.Track>
<Meter.Fill />
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,7 @@ export const messages: Record<Lang, Dict> = {
quotaUsed: 'Used',
quotaRemaining: 'Remaining',
quotaAddOn: 'Add-on',
quotaResourcePackage: 'Resource package',
quotaExceeded: 'Quota exceeded',
errorKind: 'Error kind',
qoderLoginHint: 'This signs in the upstream account only. It does not change the console password.',
Expand Down Expand Up @@ -1092,6 +1093,7 @@ export const messages: Record<Lang, Dict> = {
quotaUsed: '已用',
quotaRemaining: '剩余',
quotaAddOn: '附加包',
quotaResourcePackage: '资源包',
quotaExceeded: '额度已用尽',
errorKind: '错误类型',
qoderLoginHint: '这里登录的是该账号的上游登录态,不会改控制台密码。',
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function quotaUsedRatio(quota: AccountQuota) {

export function quotaTone(quota: AccountQuota): QuotaTone {
const percentage = quota.percentage ?? 0
if (quota.exceeded || percentage >= 100) return 'danger'
if (quota.exceeded) return 'danger'
if (percentage >= 80) return 'warn'
return 'ok'
}
2 changes: 1 addition & 1 deletion frontend/src/pages/OverviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ function errorLabel(kind: string, t: (key: string) => string) {
}

function quotaTone(percentage?: number, exceeded?: boolean) {
if (exceeded || (percentage ?? 0) >= 100) return 'danger'
if (exceeded) return 'danger'
if ((percentage ?? 0) >= 80) return 'warning'
return 'ok'
}
Expand Down
26 changes: 24 additions & 2 deletions internal/accounts/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -1134,8 +1134,9 @@ func (m *Manager) refreshOne(ctx context.Context, item Item, forceQuota bool) er
if err := m.store.Observe(ctx, item.ID, health.UID, status, health.LastError, ""); err != nil {
return err
}
// Quota is display-only: fetch after the health/observe path so a quota
// outage never flips account readiness or scheduling state.
// Fetch quota after the health/observe path so a quota endpoint outage
// never changes readiness. A successful exhausted snapshot may still
// keep the account out of request routing.
if health.Hot || ready {
m.fetchQuota(ctx, item.ID, item.URL, forceQuota)
m.fetchAccountModels(ctx, item)
Expand Down Expand Up @@ -1380,6 +1381,11 @@ type workerQuotaBlock struct {
Remaining float64 `json:"remaining"`
Percentage float64 `json:"percentage"`
Unit string `json:"unit"`
Available *bool `json:"available"`
}

func (w *workerQuotaBlock) hasRemaining() bool {
return w != nil && w.Remaining > 0 && (w.Available == nil || *w.Available)
}

func (w *workerQuota) snapshot() *QuotaSnapshot {
Expand All @@ -1402,11 +1408,27 @@ func (w *workerQuota) snapshot() *QuotaSnapshot {
snapshot.HasAddOn = true
snapshot.AddOnUsed = w.AddOnQuota.Used
snapshot.AddOnTotal = w.AddOnQuota.Total
snapshot.AddOnRemaining = w.AddOnQuota.Remaining
snapshot.AddOnUnit = w.AddOnQuota.Unit
snapshot.AddOnAvailable = w.AddOnQuota.Available
if snapshot.AddOnUnit == "" {
snapshot.AddOnUnit = "credits"
}
}
if w.OrgResourcePackage != nil {
snapshot.HasResourcePackage = true
snapshot.ResourcePackageUsed = w.OrgResourcePackage.Used
snapshot.ResourcePackageTotal = w.OrgResourcePackage.Total
snapshot.ResourcePackageRemaining = w.OrgResourcePackage.Remaining
snapshot.ResourcePackageUnit = w.OrgResourcePackage.Unit
snapshot.ResourcePackageAvailable = w.OrgResourcePackage.Available
if snapshot.ResourcePackageUnit == "" {
snapshot.ResourcePackageUnit = "credits"
}
}
if snapshot.Exceeded && (w.AddOnQuota.hasRemaining() || w.OrgResourcePackage.hasRemaining()) {
snapshot.Exceeded = false
}
return snapshot
}

Expand Down
27 changes: 27 additions & 0 deletions internal/accounts/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,33 @@ type fakeProcess struct {
done chan error
}

func TestWorkerQuotaSnapshotUsesResourcePackage(t *testing.T) {
quota := (&workerQuota{
UserQuota: &workerQuotaBlock{Total: 100, Used: 100, Percentage: 100},
OrgResourcePackage: &workerQuotaBlock{Total: 50, Used: 10, Remaining: 40, Unit: "credits"},
IsQuotaExceeded: true,
}).snapshot()

if quota == nil || quota.Exceeded || !quota.HasResourcePackage || quota.ResourcePackageRemaining != 40 {
t.Fatalf("quota = %+v", quota)
}
}

func TestWorkerQuotaSnapshotIgnoresUnavailableResourcePackage(t *testing.T) {
available := false
quota := (&workerQuota{
UserQuota: &workerQuotaBlock{Total: 100, Used: 100, Percentage: 100},
OrgResourcePackage: &workerQuotaBlock{
Total: 50, Remaining: 40, Available: &available,
},
IsQuotaExceeded: true,
}).snapshot()

if quota == nil || !quota.Exceeded || quota.ResourcePackageAvailable == nil || *quota.ResourcePackageAvailable {
t.Fatalf("quota = %+v", quota)
}
}

func (p *fakeProcess) URL() string { return p.url }
func (p *fakeProcess) Done() <-chan error { return p.done }
func (p *fakeProcess) Stop() error {
Expand Down
36 changes: 22 additions & 14 deletions internal/accounts/pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,28 @@ import (
"time"
)

// QuotaSnapshot is the display-only quota state for one account.
// A zero value means "unknown"; it never influences routing or cooldown.
// QuotaSnapshot is the quota state for one account.
// A zero value means "unknown"; only Exceeded influences routing.
type QuotaSnapshot struct {
Used float64 `json:"used"`
Total float64 `json:"total"`
Remaining float64 `json:"remaining"`
Percentage float64 `json:"percentage"`
Unit string `json:"unit"`
Exceeded bool `json:"exceeded"`
HasAddOn bool `json:"has_add_on"`
AddOnUsed float64 `json:"add_on_used"`
AddOnTotal float64 `json:"add_on_total"`
AddOnUnit string `json:"add_on_unit"`
FetchedAt string `json:"fetched_at"`
Used float64 `json:"used"`
Total float64 `json:"total"`
Remaining float64 `json:"remaining"`
Percentage float64 `json:"percentage"`
Unit string `json:"unit"`
Exceeded bool `json:"exceeded"`
HasAddOn bool `json:"has_add_on"`
AddOnUsed float64 `json:"add_on_used"`
AddOnTotal float64 `json:"add_on_total"`
AddOnRemaining float64 `json:"add_on_remaining"`
AddOnUnit string `json:"add_on_unit"`
AddOnAvailable *bool `json:"add_on_available,omitempty"`
HasResourcePackage bool `json:"has_resource_package"`
ResourcePackageUsed float64 `json:"resource_package_used"`
ResourcePackageTotal float64 `json:"resource_package_total"`
ResourcePackageRemaining float64 `json:"resource_package_remaining"`
ResourcePackageUnit string `json:"resource_package_unit"`
ResourcePackageAvailable *bool `json:"resource_package_available,omitempty"`
FetchedAt string `json:"fetched_at"`
}

const (
Expand Down Expand Up @@ -326,7 +334,7 @@ func itemReady(item Item) bool {
}

func routeBaseMatches(item Item, q RouteQuery) bool {
if item.Quota != nil && (item.Quota.Exceeded || item.Quota.Percentage >= 100) {
if item.Quota != nil && item.Quota.Exceeded {
return false
}
if !itemReady(item) {
Expand Down
15 changes: 15 additions & 0 deletions internal/accounts/pool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ func TestRoundRobinSkipsDownAccounts(t *testing.T) {
}
}

func TestPickKeepsMonthlyExhaustedAccountWithResourcePackage(t *testing.T) {
p := NewPool([]string{"http://a:3020"}, []string{"a"})
p.MergeQuota("a", &QuotaSnapshot{
Percentage: 100,
Exceeded: false,
HasResourcePackage: true,
ResourcePackageRemaining: 50,
})

item, ok := p.Pick("", nil)
if !ok || item.ID != "a" {
t.Fatalf("resource-package account should remain routable, got %+v ok=%v", item, ok)
}
}

func TestRoundRobinSkipsNotReadyAccounts(t *testing.T) {
p := NewPool([]string{"http://a:3020", "http://b:3020", "http://c:3020"}, []string{"a", "b", "c"})
p.MergeHealth("a", false, false, 0, 0, "account not found")
Expand Down
2 changes: 1 addition & 1 deletion internal/accounts/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,7 @@ func (s *Store) SaveQuota(ctx context.Context, id string, quota *QuotaSnapshot)
return fmt.Errorf("marshal quota: %w", err)
}
status := "ready"
if quota.Exceeded || quota.Percentage >= 100 {
if quota.Exceeded {
status = "quota_exhausted"
}
result, err := s.db.ExecContext(ctx, `
Expand Down
13 changes: 13 additions & 0 deletions internal/accounts/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -578,4 +578,17 @@ func TestStorePersistsQuotaAndStatus(t *testing.T) {
if stored.Status != "ready" || stored.Quota == nil || stored.Quota.Remaining != 90 {
t.Fatalf("recovered account state = %+v", stored)
}
if err := store.SaveQuota(ctx, account.ID, &QuotaSnapshot{
Used: 100, Total: 100, Percentage: 100, Exceeded: false,
HasResourcePackage: true, ResourcePackageRemaining: 25,
}); err != nil {
t.Fatal(err)
}
stored, err = store.Get(ctx, account.ID)
if err != nil {
t.Fatal(err)
}
if stored.Status != "ready" || stored.Quota == nil || stored.Quota.Exceeded {
t.Fatalf("resource-package account state = %+v", stored)
}
}
5 changes: 3 additions & 2 deletions internal/providers/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,9 @@ type AccountHealth struct {
LastError string
}

// QuotaInfo is display-only usage for the accounts console. Callers must treat
// probe readiness and quota independently; quota errors never flip Ready.
// QuotaInfo is account usage for the console and exhausted-account routing.
// Callers must treat probe readiness and quota independently; quota errors
// never flip Ready.
type QuotaInfo struct {
Used float64
Total float64
Expand Down
2 changes: 1 addition & 1 deletion internal/providers/workbuddy/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -647,7 +647,7 @@ func (c *Client) Probe(ctx context.Context, accountID string) (providers.Account
}, nil
}

// Quota fetches display-only remaining credits from the billing meter API.
// Quota fetches remaining credits from the billing meter API.
// Failures return an error for the caller to ignore without flipping readiness.
func (c *Client) Quota(ctx context.Context, accountID string) (*providers.QuotaInfo, error) {
credential, err := c.resolvedCredential(ctx, accountID)
Expand Down

Large diffs are not rendered by default.

28 changes: 0 additions & 28 deletions internal/webui/static/assets/index-BAjBDEmY.js

This file was deleted.

28 changes: 28 additions & 0 deletions internal/webui/static/assets/index-DfvsD18H.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion internal/webui/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-BAjBDEmY.js"></script>
<script type="module" crossorigin src="/assets/index-DfvsD18H.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cz5uICSf.css">
</head>
<body>
Expand Down
Loading