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
12 changes: 12 additions & 0 deletions admin/api_key_concurrency.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package admin

import (
"net/http"

"github.com/gin-gonic/gin"
)

// GetAPIKeyConcurrency 读取内存快照,避免轮询时重复聚合历史用量。
func (h *Handler) GetAPIKeyConcurrency(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"concurrency": h.authCacheProxy.APIKeyConcurrencySnapshot()})
}
1 change: 1 addition & 0 deletions admin/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -1265,6 +1265,7 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
api.DELETE("/usage/logs", h.ClearUsageLogs)
api.GET("/setup-hints", h.GetSetupHints)
api.GET("/keys", h.ListAPIKeys)
api.GET("/keys-concurrency", h.GetAPIKeyConcurrency)
api.POST("/keys", h.CreateAPIKey)
api.POST("/keys/reset-all-quotas", h.ResetAllAPIKeyQuotas)
api.PATCH("/keys/:id", h.UpdateAPIKey)
Expand Down
6 changes: 3 additions & 3 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1248,6 +1248,7 @@ export const api = {
return request<{ trend: AccountEventTrendPoint[] }>(`/accounts/event-trend?${sp.toString()}`)
},
getAPIKeys: () => request<APIKeysResponse>('/keys'),
getAPIKeyConcurrency: () => request<{ concurrency: Record<string, number> }>('/keys-concurrency'),
createAPIKey: (data: CreateAPIKeyRequest) =>
request<CreateAPIKeyResponse>('/keys', {
method: 'POST',
Expand Down
57 changes: 57 additions & 0 deletions frontend/src/components/APIKeyConcurrencyBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api";
import { Badge } from "./ui/badge";

const REFRESH_INTERVAL_MS = 5_000;

export function useAPIKeyConcurrency() {
const [counts, setCounts] = useState<Record<string, number> | null>(null);
useEffect(() => {
let cancelled = false;
let pending = false;
const refresh = async () => {
if (pending || document.hidden) return;
pending = true;
try {
const response = await api.getAPIKeyConcurrency();
if (!cancelled) setCounts(response.concurrency);
} catch {
// 暂时读取失败时保留上次快照,下一次轮询重试。
} finally {
pending = false;
}
};
void refresh();
const timer = window.setInterval(() => void refresh(), REFRESH_INTERVAL_MS);
document.addEventListener("visibilitychange", refresh);
return () => {
cancelled = true;
window.clearInterval(timer);
document.removeEventListener("visibilitychange", refresh);
};
}, []);
return counts;
}

export default function APIKeyConcurrencyBadge({ current, limit }: {
current?: number;
limit?: number;
}) {
const { t } = useTranslation();
if (current === undefined || current <= 0) return null;

return (
<Badge
variant="outline"
title={t("apiKeys.limits.concurrencySuffix")}
className="gap-1.5 rounded-full border-blue-500/20 bg-blue-50 px-2 py-0.5 text-[11px] font-semibold tabular-nums text-blue-600 shadow-2xs dark:border-blue-400/20 dark:bg-blue-950 dark:text-blue-400"
>
<span
className="size-1.5 animate-pulse rounded-full bg-blue-500 dark:bg-blue-400"
aria-hidden
/>
{current}/{limit && limit > 0 ? limit : "∞"}
</Badge>
);
}
2 changes: 1 addition & 1 deletion frontend/src/components/DaybreakBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default function DaybreakBadge({

return (
<span
className={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium ring-1 ring-inset ${variant.color}`}
className={`inline-flex shrink-0 items-center rounded-md px-1.5 py-0.5 text-[11px] font-medium ring-1 ring-inset ${variant.color}`}
>
{variant.label}
</span>
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/pages/APIKeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { api } from "../api";
import APIKeyConcurrencyBadge, { useAPIKeyConcurrency } from "../components/APIKeyConcurrencyBadge";
import APIKeyTokenUsagePanel from "../components/APIKeyTokenUsagePanel";
import APIKeyModelRequestLimitsEditor from "../components/APIKeyModelRequestLimitsEditor";
import APIKeyModelRequestUsageCard from "../components/APIKeyModelRequestUsage";
Expand Down Expand Up @@ -405,6 +406,7 @@ export default function APIKeys() {
},
load: loadKeys,
});
const concurrency = useAPIKeyConcurrency();
const keys = data.keys;
const groups = data.groups;
const modelOptions = data.modelOptions;
Expand Down Expand Up @@ -1548,6 +1550,10 @@ export default function APIKeys() {
<span className="truncate text-sm font-semibold text-foreground">
{keyRow.name}
</span>
<APIKeyConcurrencyBadge
current={concurrency?.[String(keyRow.id)] ?? (concurrency ? 0 : undefined)}
limit={keyRow.limits?.max_concurrency}
/>
{isNew ? (
<Badge
variant="outline"
Expand Down Expand Up @@ -1752,6 +1758,10 @@ export default function APIKeys() {
<span className="truncate">
{keyRow.name}
</span>
<APIKeyConcurrencyBadge
current={concurrency?.[String(keyRow.id)] ?? (concurrency ? 0 : undefined)}
limit={keyRow.limits?.max_concurrency}
/>
{isNew ? (
<Badge
variant="outline"
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/Accounts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14176,6 +14176,7 @@ function AccountMobileCard({
<AccountConcurrencyBadge account={account} />
</>
)}
<DaybreakBadge models={account.daybreak_models} />
{isFullCard && resetCredits > 0 && (
<button
type="button"
Expand All @@ -14187,7 +14188,6 @@ function AccountMobileCard({
{resetCredits}
</button>
)}
<DaybreakBadge models={account.daybreak_models} />
{isFullCard && creditBalance !== null && (
<button
type="button"
Expand Down
32 changes: 28 additions & 4 deletions proxy/apikey_concurrency.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package proxy

import (
"fmt"
"math"
"net/http"
"sync"
"sync/atomic"
Expand Down Expand Up @@ -94,11 +95,11 @@ func (h *Handler) acquireAPIKeyConcurrency(c *gin.Context) (func(), bool) {
}
}
row := apiKeyRowFromContext(c)
if row == nil || row.ID <= 0 || row.Limits.MaxConcurrency <= 0 {
if row == nil || row.ID <= 0 {
return nil, true
}
limiter := h.apiKeyConcurrencyLimiter()
release, current, ok := limiter.acquire(row.ID, row.Limits.MaxConcurrency)
release, current, ok := limiter.acquireTracked(row.ID, row.Limits.MaxConcurrency)
if ok {
return release, true
}
Expand All @@ -109,14 +110,37 @@ func (h *Handler) acquireAPIKeyConcurrency(c *gin.Context) (func(), bool) {

func (h *Handler) acquireAPIKeyConcurrencyForWebSocket(c *gin.Context) (func(), *api.APIError, bool) {
row := apiKeyRowFromContext(c)
if row == nil || row.ID <= 0 || row.Limits.MaxConcurrency <= 0 {
if row == nil || row.ID <= 0 {
return nil, nil, true
}
limiter := h.apiKeyConcurrencyLimiter()
release, current, ok := limiter.acquire(row.ID, row.Limits.MaxConcurrency)
release, current, ok := limiter.acquireTracked(row.ID, row.Limits.MaxConcurrency)
if ok {
return release, nil, true
}
msg := fmt.Sprintf("API key concurrency limit exceeded: %d inflight requests (max %d)", current, row.Limits.MaxConcurrency)
return nil, api.NewAPIError(api.ErrCodeRateLimitReached, msg, api.ErrorTypeRateLimit), false
}

// acquireTracked 为不限并发的密钥保留计数,不改变限流器的旁路约定。
func (l *apiKeyConcurrencyLimiter) acquireTracked(id int64, limit int) (func(), int64, bool) {
if limit <= 0 {
limit = math.MaxInt
}
return l.acquire(id, limit)
}

// APIKeyConcurrencySnapshot 返回当前进程中各密钥的并发快照。
func (h *Handler) APIKeyConcurrencySnapshot() map[int64]int64 {
result := make(map[int64]int64)
if h == nil {
return result
}
limiter := h.apiKeyConcurrencyLimiter()
limiter.mu.Lock()
defer limiter.mu.Unlock()
for id, counter := range limiter.counters {
result[id] = atomic.LoadInt64(&counter.inflight)
}
return result
}
Loading