Skip to content
Open
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
3 changes: 2 additions & 1 deletion _public/static/admin/js/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@ const LOCALE_MAP = {
"fail_threshold": { title: "失败阈值", desc: "单个 Token 连续失败多少次后被标记为不可用。" },
"save_delay_ms": { title: "保存延迟", desc: "Token 变更合并写入的延迟(毫秒)。" },
"usage_flush_interval_sec": { title: "用量落库间隔", desc: "用量类字段写入数据库的最小间隔(秒)。" },
"reload_interval_sec": { title: "同步间隔", desc: "多 worker 场景下 Token 状态刷新间隔(秒)。" }
"reload_interval_sec": { title: "同步间隔", desc: "多 worker 场景下 Token 状态刷新间隔(秒)。" },
"consumed_mode_enabled": { title: "启用消耗模式", desc: "启用新额度管理逻辑:使用本地消耗记录而非 API 返回值,支持更均衡的负载分配。(试验性功能,默认关闭)" }
},


Expand Down
76 changes: 70 additions & 6 deletions _public/static/admin/js/token.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
let apiKey = '';
let consumedModeEnabled = false;
let allTokens = {};
let flatTokens = [];
let isBatchProcessing = false;
Expand Down Expand Up @@ -123,9 +124,11 @@ async function loadData() {
});
if (res.ok) {
const data = await res.json();
allTokens = data;
processTokens(data);
updateStats(data);
allTokens = data.tokens;
consumedModeEnabled = data.consumed_mode_enabled || false;
updateQuotaHeader();
processTokens(data.tokens);
updateStats(data.tokens);
renderTable();
} else if (res.status === 401) {
logout();
Expand All @@ -151,6 +154,7 @@ function processTokens(data) {
token: t.token,
status: t.status || 'active',
quota: t.quota || 0,
consumed: t.consumed || 0,
note: t.note || '',
fail_count: t.fail_count || 0,
use_count: t.use_count || 0,
Expand All @@ -168,6 +172,19 @@ function processTokens(data) {
});
}

function updateQuotaHeader() {
const thQuota = document.getElementById('th-quota');
if (thQuota) {
if (consumedModeEnabled) {
thQuota.textContent = t('token.tableQuotaConsumed');
thQuota.dataset.i18n = 'token.tableQuotaConsumed';
} else {
thQuota.textContent = t('token.tableQuota');
thQuota.dataset.i18n = 'token.tableQuota';
}
}
}

function updateStats(data) {
// Logic same as before, simplified reuse if possible, but let's re-run on flatTokens
let totalTokens = flatTokens.length;
Expand Down Expand Up @@ -197,14 +214,27 @@ function updateStats(data) {
});

const imageQuota = Math.floor(chatQuota / 2);
const totalConsumed = flatTokens.reduce((sum, t) => sum + (t.consumed || 0), 0);

// 更新统计卡片 (这些不受 consumedMode 影响)
setText('stat-total', totalTokens.toLocaleString());
setText('stat-active', activeTokens.toLocaleString());
setText('stat-cooling', coolingTokens.toLocaleString());
setText('stat-invalid', invalidTokens.toLocaleString());

setText('stat-chat-quota', chatQuota.toLocaleString());
setText('stat-image-quota', imageQuota.toLocaleString());
// 根据配置决定显示消耗还是剩余
if (consumedModeEnabled) {
setText('stat-chat-quota', totalConsumed.toLocaleString());
setText('stat-image-quota', Math.floor(totalConsumed / 2).toLocaleString());
const chatLabel = document.querySelector('[data-i18n="token.statChatQuota"]');
const imageLabel = document.querySelector('[data-i18n="token.statImageQuota"]');
if (chatLabel) chatLabel.textContent = t('token.statChatConsumed');
if (imageLabel) imageLabel.textContent = t('token.statImageConsumed');
} else {
setText('stat-chat-quota', chatQuota.toLocaleString());
setText('stat-image-quota', imageQuota.toLocaleString());
}

setText('stat-total-calls', totalCalls.toLocaleString());

updateTabCounts({
Expand Down Expand Up @@ -293,7 +323,16 @@ function renderTable() {
// Quota (Center)
const tdQuota = document.createElement('td');
tdQuota.className = 'text-center font-mono text-xs';
tdQuota.innerText = item.quota;
// 根据配置决定显示消耗还是剩余
if (consumedModeEnabled) {
tdQuota.innerText = item.consumed;
tdQuota.title = t('token.tableQuotaConsumed');
} else {
tdQuota.innerText = item.quota;
tdQuota.title = t('token.tableQuota');
}



// Note (Left)
const tdNote = document.createElement('td');
Expand Down Expand Up @@ -503,6 +542,23 @@ function openEditModal(index) {
byId('edit-pool').value = item.pool;
byId('edit-quota').value = item.quota;
byId('edit-note').value = item.note;

// 根据配置决定是否禁用 quota 编辑
const quotaInput = byId('edit-quota');
const quotaInputParent = quotaInput?.closest('div');
const quotaLabel = quotaInputParent?.previousElementSibling;
if (consumedModeEnabled) {
quotaInput.disabled = true;
quotaInput.classList.add('bg-gray-100', 'text-gray-400');
if (quotaLabel) quotaLabel.textContent = t('token.tableQuotaConsumed');
} else {
quotaInput.disabled = false;
quotaInput.classList.remove('bg-gray-100', 'text-gray-400');
if (quotaLabel) quotaLabel.textContent = t('token.editQuota');
}

document.querySelector('#edit-modal h3').innerText = t('token.editTitle');
byId('edit-note').value = item.note;
document.querySelector('#edit-modal h3').innerText = t('token.editTitle');
} else {
// New Token
Expand All @@ -518,6 +574,14 @@ function openEditModal(index) {
byId('edit-quota').value = getDefaultQuotaForPool('ssoBasic');
byId('edit-note').value = '';
document.querySelector('#edit-modal h3').innerText = t('token.addTitle');

// 新建 Token 时启用 quota 编辑
const newQuotaInput = byId('edit-quota');
const newQuotaInputParent = newQuotaInput?.closest('div');
const newQuotaLabel = newQuotaInputParent?.previousElementSibling;
newQuotaInput.disabled = false;
newQuotaInput.classList.remove('bg-gray-100', 'text-gray-400');
if (newQuotaLabel) newQuotaLabel.textContent = t('token.editQuota');
}

openModal('edit-modal');
Expand Down
2 changes: 1 addition & 1 deletion _public/static/admin/pages/token.html
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ <h2 class="text-2xl font-semibold tracking-tight" data-i18n="token.title">Token
<th class="w-56 text-left" data-i18n="token.tableToken">Token</th>
<th class="w-24" data-i18n="token.tableType">类型</th>
<th class="w-24" data-i18n="token.tableStatus">状态</th>
<th class="w-20" data-i18n="token.tableQuota">额度</th>
<th class="w-20" id="th-quota" data-i18n="token.tableQuota">额度</th>
<th class="text-left" data-i18n="token.tableNote">备注</th>
<th class="w-44 text-center" data-i18n="token.tableActions">操作</th>
</tr>
Expand Down
3 changes: 3 additions & 0 deletions _public/static/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@
"statInvalid": "Invalid",
"statChatQuota": "Chat Remaining",
"statImageQuota": "Image Remaining",
"statChatConsumed": "Chat Consumed",
"statImageConsumed": "Image Consumed",
"statVideoQuota": "Video Remaining",
"statVideoUnavailable": "N/A",
"statTotalCalls": "Total Calls",
Expand All @@ -258,6 +260,7 @@
"tableType": "Type",
"tableStatus": "Status",
"tableQuota": "Quota",
"tableQuotaConsumed": "Consumed",
"tableNote": "Note",
"tableActions": "Actions",
"refreshStatus": "Refresh status",
Expand Down
3 changes: 3 additions & 0 deletions _public/static/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@
"statInvalid": "Token 失效",
"statChatQuota": "Chat 剩余",
"statImageQuota": "Image 剩余",
"statChatConsumed": "Chat 已消耗",
"statImageConsumed": "Image 已消耗",
"statVideoQuota": "Video 剩余",
"statVideoUnavailable": "无法统计",
"statTotalCalls": "总调用次数",
Expand All @@ -258,6 +260,7 @@
"tableType": "类型",
"tableStatus": "状态",
"tableQuota": "额度",
"tableQuotaConsumed": "已消耗",
"tableNote": "备注",
"tableActions": "操作",
"refreshStatus": "刷新状态",
Expand Down
9 changes: 8 additions & 1 deletion app/api/v1/admin/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,14 @@ async def get_tokens():
"""获取所有 Token"""
storage = get_storage()
tokens = await storage.load_tokens()
return tokens or {}
# 获取消耗模式配置
from app.core.config import get_config
consumed_mode = get_config("token.consumed_mode_enabled", False)

return {
"tokens": tokens or {},
"consumed_mode_enabled": consumed_mode
}


@router.post("/tokens", dependencies=[Depends(verify_app_key)])
Expand Down
33 changes: 32 additions & 1 deletion app/services/token/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,18 @@ async def consume(
token = pool.get(raw_token)
if token:
old_status = token.status
consumed = token.consume(effort)
# 检查是否启用消耗模式
consumed_mode = False
try:
from app.core.config import get_config
consumed_mode = get_config("token.consumed_mode_enabled", False)
except Exception:
pass

if consumed_mode:
consumed = token.consume_with_consumed(effort)
else:
consumed = token.consume(effort)
logger.debug(
f"Token {raw_token[:10]}...: consumed {consumed} quota, use_count={token.use_count}"
)
Expand Down Expand Up @@ -662,6 +673,7 @@ async def mark_rate_limited(self, token_str: str) -> bool:
old_quota = token.quota
token.quota = 0
token.status = TokenStatus.COOLING
token.consumed = 0 # 进入冷却时重置本轮消耗
logger.warning(
f"Token {raw_token[:10]}...: marked as rate limited "
f"(quota {old_quota} -> 0, status -> cooling)"
Expand Down Expand Up @@ -936,6 +948,25 @@ async def _refresh_one(item: tuple[str, TokenInfo]) -> dict:
old_status = token_info.status

token_info.update_quota(new_quota)

# 检查是否启用 consumed 模式
consumed_mode = False
try:
from app.core.config import get_config
consumed_mode = get_config("token.consumed_mode_enabled", False)
except Exception:
pass

if consumed_mode:
# Consumed 模式:使用新逻辑
token_info.update_quota_with_consumed(new_quota)
else:
# 默认模式:使用旧逻辑
token_info.update_quota(new_quota)

# 刷新成功后如果 quota > 0,恢复活跃状态
if new_quota > 0:
token_info.status = TokenStatus.ACTIVE
token_info.mark_synced()

window_size = self._extract_window_size_seconds(result)
Expand Down
Loading
Loading