fix(api-potluck): 修复令牌计数回退并改进显示格式
- 在 normalizeUsageCandidate 中添加缺失的令牌计数回退字段(inputTokenCount/outputTokenCount) - 确保凭证切换重试上下文始终可用 - 为令牌数量添加紧凑格式化函数(K/M/G 单位),在多个统计页面中应用 - 更新版本号至 2.13.4
This commit is contained in:
parent
8f8c700a0f
commit
8afcb479fa
6 changed files with 55 additions and 17 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
2.13.3
|
||||
2.13.4
|
||||
|
|
|
|||
|
|
@ -51,13 +51,15 @@ function normalizeUsageCandidate(candidate) {
|
|||
candidate.prompt_tokens ??
|
||||
usage?.prompt_tokens ??
|
||||
usage?.input_tokens ??
|
||||
usage?.promptTokenCount
|
||||
usage?.promptTokenCount ??
|
||||
usage?.inputTokenCount
|
||||
);
|
||||
const completionTokens = toNumber(
|
||||
candidate.completion_tokens ??
|
||||
usage?.completion_tokens ??
|
||||
usage?.output_tokens ??
|
||||
usage?.candidatesTokenCount
|
||||
usage?.candidatesTokenCount ??
|
||||
usage?.outputTokenCount
|
||||
);
|
||||
const totalTokens = toNumber(
|
||||
candidate.total_tokens ??
|
||||
|
|
|
|||
|
|
@ -1025,7 +1025,7 @@ export async function handleContentGenerationRequest(req, res, service, endpoint
|
|||
// - 凭证切换重试:凭证被标记不健康后切换到其他凭证
|
||||
// 当没有不同的健康凭证可用时,重试会自动停止
|
||||
const credentialSwitchMaxRetries = CONFIG.CREDENTIAL_SWITCH_MAX_RETRIES || 5;
|
||||
const retryContext = providerPoolManager ? { CONFIG, currentRetry: 0, maxRetries: credentialSwitchMaxRetries } : null;
|
||||
const retryContext = { CONFIG, currentRetry: 0, maxRetries: credentialSwitchMaxRetries };
|
||||
|
||||
if (isStream) {
|
||||
await handleStreamRequest(res, service, model, processedRequestBody, fromProvider, toProvider, CONFIG.PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, actualUuid, actualCustomName, retryContext);
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@
|
|||
import{initThemeSwitcher,setTheme,getCurrentTheme}from'./app/theme-switcher.js';setTheme(getCurrentTheme());initThemeSwitcher('themeToggle');
|
||||
</script>
|
||||
<script>
|
||||
const API_BASE='/api/model-usage-stats',STORAGE_KEY='model_usage_stats_auth';let rows=[];const el=id=>document.getElementById(id),fmt=v=>new Intl.NumberFormat('zh-CN').format(Number(v||0));
|
||||
const API_BASE='/api/model-usage-stats',STORAGE_KEY='model_usage_stats_auth';let rows=[];const el=id=>document.getElementById(id),fmt=v=>new Intl.NumberFormat('zh-CN').format(Number(v||0)),fmtToken=v=>{const value=Number(v||0);if(!Number.isFinite(value))return'0';const abs=Math.abs(value);const units=[{threshold:1e9,suffix:'G'},{threshold:1e6,suffix:'M'},{threshold:1e3,suffix:'K'}];for(const unit of units){if(abs>=unit.threshold){const scaled=value/unit.threshold;const digits=Math.abs(scaled)>=100?0:Math.abs(scaled)>=10?1:2;return`${scaled.toFixed(digits).replace(/\.0+$|(\.\d*[1-9])0+$/,'$1')}${unit.suffix}`}}return fmt(value)};
|
||||
function rel(iso){if(!iso)return'未记录';const d=new Date(iso);if(Number.isNaN(d.getTime()))return iso;const m=Math.floor((Date.now()-d.getTime())/6e4);if(m<1)return'刚刚';if(m<60)return`${m} 分钟前`;const h=Math.floor(m/60);if(h<24)return`${h} 小时前`;const day=Math.floor(h/24);return day<30?`${day} 天前`:d.toLocaleString('zh-CN')}
|
||||
function status(msg,type='info'){const icon=type==='error'?'fa-triangle-exclamation':type==='success'?'fa-circle-check':'fa-circle-info';el('status').className=`status ${type} show`;el('status').innerHTML=`<i class="fas ${icon}"></i><span>${msg}</span>`}
|
||||
function badge(text,ok=false){const n=el('authBadge');n.className=ok?'pill':'pill idle';n.textContent=text}
|
||||
|
|
@ -114,11 +114,11 @@
|
|||
function headers(){const c=credential();if(!c.value)throw new Error('请先输入访问凭证。');return{Authorization:`Bearer ${c.value}`,'x-api-key':c.value}}
|
||||
async function request(url,options={}){const res=await fetch(url,{...options,headers:{...(options.headers||{}),...headers()}}),text=await res.text();let payload=null;try{payload=text?JSON.parse(text):null}catch{throw new Error(`接口返回了非 JSON 内容: ${text||'(empty)'}`)}if(!res.ok){if(payload?.error?.code==='PLUGIN_DISABLED')throw new Error('插件未启用:请先在插件管理中启用 model-usage-stats。');throw new Error(payload?.error?.message||payload?.message||`请求失败 (${res.status})`)}return payload}
|
||||
function flatten(data){const list=[];for(const [provider,pd] of Object.entries(data.providers||{})){for(const [model,md] of Object.entries(pd.models||{}))list.push({provider,model,...md})}return list}
|
||||
function bars(id,items,getValue,getLabel){const box=el(id);box.innerHTML='';if(!items.length){box.innerHTML='<div class="mini-box"><div class="mini-label">暂无数据</div><div class="mini-value" style="font-size:14px">等待统计写入</div></div>';return}const max=Math.max(...items.map(getValue),1);items.forEach(item=>{const v=Number(getValue(item)||0),w=Math.max(v/max*100,2),node=document.createElement('div');node.className='bar';node.innerHTML=`<div class="bar-head"><div class="bar-name" title="${getLabel(item)}">${getLabel(item)}</div><div class="bar-value">${fmt(v)}</div></div><div class="track"><div class="fill" style="width:${w}%"></div></div>`;box.appendChild(node)})}
|
||||
function renderSummary(data){const s=data.summary||{};el('totalRequests').textContent=fmt(s.requestCount);el('promptTokens').textContent=fmt(s.promptTokens);el('completionTokens').textContent=fmt(s.completionTokens);el('totalTokens').textContent=fmt(s.totalTokens);el('updatedAt').textContent=data.updatedAt?`更新于 ${new Date(data.updatedAt).toLocaleString('zh-CN')}`:'尚未写入'}
|
||||
function renderProviders(data){const providers=Object.entries(data.providers||{}).map(([name,p])=>({name,data:p,count:Object.keys(p.models||{}).length})).sort((a,b)=>(b.data.summary?.totalTokens||0)-(a.data.summary?.totalTokens||0));const box=el('providerCards');box.innerHTML='';if(!providers.length){box.innerHTML='<div class="provider"><div class="provider-name">暂无 Provider 统计</div><div class="provider-sub">当前还没有可展示的累计数据。</div></div>';return}providers.forEach(p=>{const s=p.data.summary||{};const node=document.createElement('article');node.className='provider';node.innerHTML=`<div class="provider-head"><div><div class="provider-name">${p.name}</div><div class="provider-sub">包含 ${fmt(p.count)} 个模型</div></div><span class="provider-badge"><i class="fas fa-bolt"></i>${fmt(s.requestCount)} 次调用</span></div><div class="mini"><div class="mini-box"><div class="mini-label">Total Tokens</div><div class="mini-value">${fmt(s.totalTokens)}</div></div><div class="mini-box"><div class="mini-label">Prompt Tokens</div><div class="mini-value">${fmt(s.promptTokens)}</div></div><div class="mini-box"><div class="mini-label">Completion Tokens</div><div class="mini-value">${fmt(s.completionTokens)}</div></div><div class="mini-box"><div class="mini-label">最近使用</div><div class="mini-value" style="font-size:14px">${rel(s.lastUsedAt)}</div></div></div>`;box.appendChild(node)})}
|
||||
function bars(id,items,getValue,getLabel){const box=el(id);box.innerHTML='';if(!items.length){box.innerHTML='<div class="mini-box"><div class="mini-label">暂无数据</div><div class="mini-value" style="font-size:14px">等待统计写入</div></div>';return}const max=Math.max(...items.map(getValue),1);items.forEach(item=>{const v=Number(getValue(item)||0),w=Math.max(v/max*100,2),node=document.createElement('div');node.className='bar';node.innerHTML=`<div class="bar-head"><div class="bar-name" title="${getLabel(item)}">${getLabel(item)}</div><div class="bar-value">${fmtToken(v)}</div></div><div class="track"><div class="fill" style="width:${w}%"></div></div>`;box.appendChild(node)})}
|
||||
function renderSummary(data){const s=data.summary||{};el('totalRequests').textContent=fmt(s.requestCount);el('promptTokens').textContent=fmtToken(s.promptTokens);el('completionTokens').textContent=fmtToken(s.completionTokens);el('totalTokens').textContent=fmtToken(s.totalTokens);el('updatedAt').textContent=data.updatedAt?`更新于 ${new Date(data.updatedAt).toLocaleString('zh-CN')}`:'尚未写入'}
|
||||
function renderProviders(data){const providers=Object.entries(data.providers||{}).map(([name,p])=>({name,data:p,count:Object.keys(p.models||{}).length})).sort((a,b)=>(b.data.summary?.totalTokens||0)-(a.data.summary?.totalTokens||0));const box=el('providerCards');box.innerHTML='';if(!providers.length){box.innerHTML='<div class="provider"><div class="provider-name">暂无 Provider 统计</div><div class="provider-sub">当前还没有可展示的累计数据。</div></div>';return}providers.forEach(p=>{const s=p.data.summary||{};const node=document.createElement('article');node.className='provider';node.innerHTML=`<div class="provider-head"><div><div class="provider-name">${p.name}</div><div class="provider-sub">包含 ${fmt(p.count)} 个模型</div></div><span class="provider-badge"><i class="fas fa-bolt"></i>${fmt(s.requestCount)} 次调用</span></div><div class="mini"><div class="mini-box"><div class="mini-label">Total Tokens</div><div class="mini-value">${fmtToken(s.totalTokens)}</div></div><div class="mini-box"><div class="mini-label">Prompt Tokens</div><div class="mini-value">${fmtToken(s.promptTokens)}</div></div><div class="mini-box"><div class="mini-label">Completion Tokens</div><div class="mini-value">${fmtToken(s.completionTokens)}</div></div><div class="mini-box"><div class="mini-label">最近使用</div><div class="mini-value" style="font-size:14px">${rel(s.lastUsedAt)}</div></div></div>`;box.appendChild(node)})}
|
||||
function filteredRows(){const keyword=el('searchInput').value.trim().toLowerCase();const [field,dir]=el('sortSelect').value.split('-');const list=keyword?rows.filter(r=>r.provider.toLowerCase().includes(keyword)||r.model.toLowerCase().includes(keyword)):rows.slice();list.sort((a,b)=>{if(field==='provider'||field==='model'){const l=String(a[field]||''),r=String(b[field]||'');return dir==='desc'?r.localeCompare(l):l.localeCompare(r)}const l=Number(a[field]||0),r=Number(b[field]||0);return dir==='desc'?r-l:l-r});return list}
|
||||
function renderTable(){const tbody=el('tableBody'),list=filteredRows();tbody.innerHTML='';if(!list.length){tbody.innerHTML='<tr><td colspan="7" style="text-align:center;color:var(--text-tertiary)">没有匹配的数据</td></tr>';return}list.forEach(r=>{const tr=document.createElement('tr');tr.innerHTML=`<td><span class="tag"><i class="fas fa-server"></i>${r.provider}</span></td><td>${r.model}</td><td class="mono">${fmt(r.requestCount)}</td><td class="mono">${fmt(r.promptTokens)}</td><td class="mono">${fmt(r.completionTokens)}</td><td class="mono">${fmt(r.totalTokens)}</td><td>${rel(r.lastUsedAt)}</td>`;tbody.appendChild(tr)})}
|
||||
function renderTable(){const tbody=el('tableBody'),list=filteredRows();tbody.innerHTML='';if(!list.length){tbody.innerHTML='<tr><td colspan="7" style="text-align:center;color:var(--text-tertiary)">没有匹配的数据</td></tr>';return}list.forEach(r=>{const tr=document.createElement('tr');tr.innerHTML=`<td><span class="tag"><i class="fas fa-server"></i>${r.provider}</span></td><td>${r.model}</td><td class="mono">${fmt(r.requestCount)}</td><td class="mono">${fmtToken(r.promptTokens)}</td><td class="mono">${fmtToken(r.completionTokens)}</td><td class="mono">${fmtToken(r.totalTokens)}</td><td>${rel(r.lastUsedAt)}</td>`;tbody.appendChild(tr)})}
|
||||
function render(data){rows=flatten(data);el('emptyState').style.display=rows.length?'none':'block';renderSummary(data);const providers=Object.entries(data.providers||{}).map(([name,p])=>({name,totalTokens:Number(p.summary?.totalTokens||0)})).sort((a,b)=>b.totalTokens-a.totalTokens).slice(0,8);const topModels=rows.slice().sort((a,b)=>Number(b.totalTokens||0)-Number(a.totalTokens||0)).slice(0,8);bars('providerBars',providers,i=>i.totalTokens,i=>i.name);bars('topModelBars',topModels,i=>Number(i.totalTokens||0),i=>`${i.provider} / ${i.model}`);renderProviders(data);renderTable()}
|
||||
async function loadData(){try{saveCredential();status('正在加载统计数据...');const payload=await request(API_BASE);render(payload.data||payload);badge('已连接',true);status(`已加载 ${fmt(rows.length)} 条模型统计。`,'success')}catch(error){console.error(error);badge('连接失败',false);status(error.message,'error')}}
|
||||
async function resetData(){if(!confirm('确认重置全部模型统计吗?此操作会清空已落库的累计数据。'))return;try{status('正在重置统计数据...');const payload=await request(`${API_BASE}/reset`,{method:'POST'});render(payload.data||payload);status('统计数据已重置。','success')}catch(error){console.error(error);status(error.message,'error')}}
|
||||
|
|
|
|||
|
|
@ -475,6 +475,24 @@
|
|||
let currentApiKey = '';
|
||||
let isLoggedIn = false;
|
||||
const formatNumber = (num) => new Intl.NumberFormat('zh-CN').format(Number(num || 0));
|
||||
const formatTokenCompact = (num) => {
|
||||
const value = Number(num || 0);
|
||||
if (!Number.isFinite(value)) return '0';
|
||||
const abs = Math.abs(value);
|
||||
const units = [
|
||||
{ threshold: 1e9, suffix: 'G' },
|
||||
{ threshold: 1e6, suffix: 'M' },
|
||||
{ threshold: 1e3, suffix: 'K' }
|
||||
];
|
||||
for (const unit of units) {
|
||||
if (abs >= unit.threshold) {
|
||||
const scaled = value / unit.threshold;
|
||||
const digits = Math.abs(scaled) >= 100 ? 0 : Math.abs(scaled) >= 10 ? 1 : 2;
|
||||
return `${scaled.toFixed(digits).replace(/\.0+$|(\.\d*[1-9])0+$/,'$1')}${unit.suffix}`;
|
||||
}
|
||||
}
|
||||
return formatNumber(value);
|
||||
};
|
||||
const usageCount = (entry) => typeof entry === 'number' ? entry : Number(entry?.requestCount || 0);
|
||||
const usageTokens = (entry) => typeof entry === 'number' ? 0 : Number(entry?.totalTokens || 0);
|
||||
|
||||
|
|
@ -577,8 +595,8 @@
|
|||
document.getElementById('statTotal').textContent = data.total || 0;
|
||||
|
||||
// Token 用量
|
||||
document.getElementById('statTodayTokens').textContent = formatNumber(data.usage?.totalTokens || 0);
|
||||
document.getElementById('statTotalTokens').textContent = formatNumber(data.tokens?.total || 0);
|
||||
document.getElementById('statTodayTokens').textContent = formatTokenCompact(data.usage?.totalTokens || 0);
|
||||
document.getElementById('statTotalTokens').textContent = formatTokenCompact(data.tokens?.total || 0);
|
||||
|
||||
// 最后使用时间
|
||||
if (data.lastUsedAt) {
|
||||
|
|
@ -634,7 +652,7 @@
|
|||
|
||||
// 渲染提供商分布
|
||||
renderDistribution('providerDistribution', aggregatedProviders, totalCalls);
|
||||
document.getElementById('providerTotalCount').textContent = `${formatNumber(totalCalls)} 次 / ${formatNumber(totalTokens)} Tokens`;
|
||||
document.getElementById('providerTotalCount').textContent = `${formatNumber(totalCalls)} 次 / ${formatTokenCompact(totalTokens)} Tokens`;
|
||||
|
||||
// 渲染模型分布
|
||||
renderDistribution('modelDistribution', aggregatedModels, totalCalls);
|
||||
|
|
|
|||
|
|
@ -795,6 +795,24 @@
|
|||
|
||||
function getToken() { return localStorage.getItem('authToken'); }
|
||||
function formatNumber(num) { return new Intl.NumberFormat('zh-CN').format(Number(num || 0)); }
|
||||
function formatTokenCompact(num) {
|
||||
const value = Number(num || 0);
|
||||
if (!Number.isFinite(value)) return '0';
|
||||
const abs = Math.abs(value);
|
||||
const units = [
|
||||
{ threshold: 1e9, suffix: 'G' },
|
||||
{ threshold: 1e6, suffix: 'M' },
|
||||
{ threshold: 1e3, suffix: 'K' }
|
||||
];
|
||||
for (const unit of units) {
|
||||
if (abs >= unit.threshold) {
|
||||
const scaled = value / unit.threshold;
|
||||
const digits = Math.abs(scaled) >= 100 ? 0 : Math.abs(scaled) >= 10 ? 1 : 2;
|
||||
return `${scaled.toFixed(digits).replace(/\.0+$|(\.\d*[1-9])0+$/,'$1')}${unit.suffix}`;
|
||||
}
|
||||
}
|
||||
return formatNumber(value);
|
||||
}
|
||||
function usageCount(entry) { return typeof entry === 'number' ? entry : Number(entry?.requestCount || 0); }
|
||||
function usageTokens(entry) { return typeof entry === 'number' ? 0 : Number(entry?.totalTokens || 0); }
|
||||
|
||||
|
|
@ -816,8 +834,8 @@
|
|||
document.getElementById('enabledKeys').textContent = stats.enabledKeys;
|
||||
document.getElementById('todayUsage').textContent = stats.todayTotalUsage;
|
||||
document.getElementById('totalUsage').textContent = stats.totalUsage;
|
||||
document.getElementById('todayTokens').textContent = formatNumber(stats.todayTotalTokens);
|
||||
document.getElementById('totalTokens').textContent = formatNumber(stats.totalTokens);
|
||||
document.getElementById('todayTokens').textContent = formatTokenCompact(stats.todayTotalTokens);
|
||||
document.getElementById('totalTokens').textContent = formatTokenCompact(stats.totalTokens);
|
||||
|
||||
// 渲染使用历史分布
|
||||
renderUsageHistory(stats.usageHistory);
|
||||
|
|
@ -865,7 +883,7 @@
|
|||
|
||||
// 渲染提供商分布
|
||||
renderDistribution('providerDistribution', aggregatedProviders, totalCalls);
|
||||
document.getElementById('providerTotalCount').textContent = `${formatNumber(totalCalls)} 次 / ${formatNumber(totalTokens)} Tokens`;
|
||||
document.getElementById('providerTotalCount').textContent = `${formatNumber(totalCalls)} 次 / ${formatTokenCompact(totalTokens)} Tokens`;
|
||||
|
||||
// 渲染模型分布 (模型总数与提供商总数一致)
|
||||
renderDistribution('modelDistribution', aggregatedModels, totalCalls);
|
||||
|
|
@ -1025,13 +1043,13 @@
|
|||
<div class="key-stat">
|
||||
<div class="label">今日/限额</div>
|
||||
<div class="value ${valueClass}">${key.todayUsage}/${key.dailyLimit}</div>
|
||||
<div class="value muted">${formatNumber(key.todayTotalTokens || 0)} Tokens</div>
|
||||
<div class="value muted">${formatTokenCompact(key.todayTotalTokens || 0)} Tokens</div>
|
||||
<div class="progress-bar"><div class="fill ${progressClass}" style="width:${Math.min(usagePercent, 100)}%"></div></div>
|
||||
</div>
|
||||
<div class="key-stat">
|
||||
<div class="label">累计</div>
|
||||
<div class="value">${key.totalUsage}</div>
|
||||
<div class="value muted">${formatNumber(key.totalTokens || 0)} Tokens</div>
|
||||
<div class="value muted">${formatTokenCompact(key.totalTokens || 0)} Tokens</div>
|
||||
</div>
|
||||
<div class="key-stat">
|
||||
<div class="label">最后调用</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue