dsh-cost-meter 1.7.49 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ window.__ModuleLoader__.load({id:"dsh-cost-meter",chunk:"client.statistics.js",factory:gt=>{const bt=gt("react"),{createElement:t,useState:k,useEffect:ht,Fragment:H}=bt,ft=["input","output","cacheRead","cacheWrite","reasoning","calls","cost","apiCost"],N=(s,n)=>n==="total"?s.cost:n==="plan"?Math.max(0,s.cost-s.apiCost):s.apiCost,X=s=>s.input+s.cacheRead+s.cacheWrite+s.output,O=(s,n)=>n>0?(100*s/n).toFixed(1)+"%":"—",vt=(s,n)=>new Date(Date.parse(s)+n*864e5).toISOString().slice(0,10),K=s=>n=>{if(typeof n!==s||s==="number"&&!Number.isFinite(n))throw new Error("Invalid statistics "+s);return n},b=K("number"),r=K("string"),M=K("boolean"),S=s=>n=>{if(!Array.isArray(n))throw new Error("Invalid statistics array");return n.map(s)},C=s=>n=>{if(!n||typeof n!="object"||Array.isArray(n))throw new Error("Invalid statistics object");return Object.fromEntries(Object.entries(s).map(([o,d])=>[o,d(n[o])]))},D=Object.fromEntries(ft.map(s=>[s,b])),Y=C(D),Z=C({provider:r,model:r,bucket:r,tokens:b,rate:b,cost:b,priced:M,plan:M}),xt=C({kind:r,provider:r,model:r,atMs:b,cost:b,apiCost:b,plan:M,priced:M,longContext:M,rows:S(Z)}),tt=C({from:r,to:r,retainedFrom:r,retainedTo:r,totals:Y,days:S(C({...D,date:r})),models:S(C({...D,key:r,provider:r,model:r,priced:M})),sessions:S(C({...D,id:r,title:r})),providers:S(r),modelOptions:S(r),sessionCount:b,offset:b,unassignedCost:b,unmodeledCost:b}),at=C({found:M,cost:b,apiCost:b,rows:S(Z),calls:S(xt),totalCalls:b,offset:b,recorded:Y}),yt=s=>{const n=C({from:r,to:r,provider:r,model:r,sessionId:r,basis:r,offset:b})(s);if(!["api","plan","total"].includes(n.basis)||!Number.isSafeInteger(n.offset)||n.offset<0||n.offset>1e7)throw new Error("Invalid statistics query");return n},et=(s,n)=>{const o={parse:n};return{mode:"strict",typeSymbol:"dsh-cost-meter#"+s,schema:o,create:()=>o}},st={package:"dsh-cost-meter",descriptors:[["getBillingStatistics","BillingStatistics",tt],["getSessionBilling","SessionBilling",at]].map(([s,n,o])=>({id:"dsh-cost-meter#costMeter/"+s,service:"costMeter",namespace:"costMeter",method:s,invocation:{kind:"direct"},parameters:[{name:"query",wire:"query",source:"json",codec:et("StatisticsQuery",yt)}],result:et(n,o)}))},kt=`
2
+ .cm-stat{--cm-accent:#367dce;--cm-plan:#a16aca;--cm-muted:var(--dsw-alias-label-tertiary,#747f8e);color:var(--dsw-alias-label-primary,#1e293b);font-size:13px;line-height:1.5;container-type:inline-size}
3
+ .cm-stat *{box-sizing:border-box}.cm-stat h2,.cm-stat h3,.cm-stat p{margin:0}.cm-stat h2{font-size:24px;letter-spacing:-.6px}.cm-stat h3{font-size:14px;font-weight:650}
4
+ .cm-stat-head{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:10px 0 20px}.cm-stat-sub{font-size:12px;color:var(--cm-muted)}
5
+ .cm-stat button,.cm-stat select,.cm-stat input{font:inherit;color:inherit}.cm-stat button{cursor:pointer}.cm-stat button:disabled{cursor:default;opacity:.45}.cm-stat button:focus-visible,.cm-stat select:focus-visible,.cm-stat input:focus-visible{outline:2px solid var(--cm-accent);outline-offset:3px}
6
+ .cm-stat-controls{display:flex;flex-wrap:wrap;gap:10px;align-items:end;margin:14px 0}.cm-stat-controls label{display:grid;gap:4px;font-size:11px;color:var(--cm-muted)}
7
+ .cm-stat input,.cm-stat select{background:var(--dsw-alias-bg-layer-2,#f5f7fa);border:1px solid var(--dsw-alias-border-l1,#e0e5ed);border-radius:7px;padding:7px 9px;max-width:210px;min-width:0}
8
+ .cm-stat-periods{display:flex;flex-wrap:wrap;gap:4px}.cm-stat-btn{border:1px solid var(--dsw-alias-border-l1,#dce3ec);border-radius:7px;background:transparent;padding:6px 11px}.cm-stat-btn[aria-pressed=true]{background:var(--cm-accent);color:white;border-color:var(--cm-accent)}
9
+ .cm-stat-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin:18px 0}.cm-stat-metric{border-top:3px solid var(--cm-accent);padding:14px 16px;background:var(--dsw-alias-bg-layer-2,#f5f7fa);border-radius:4px 4px 10px 10px}.cm-stat-metric:nth-child(2){border-color:var(--cm-plan)}
10
+ .cm-stat-value{font-variant-numeric:tabular-nums;font-size:clamp(17px,2.7cqw,28px);font-weight:650;letter-spacing:-.6px;margin:7px 0;overflow-wrap:anywhere}.cm-stat-metric .cm-stat-sub{font-size:11px}
11
+ .cm-stat-panel{border:1px solid var(--dsw-alias-border-l1,#dce3ec);border-radius:12px;padding:18px;margin:14px 0;min-width:0}.cm-stat-panel-head{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:16px}
12
+ .cm-stat-chart{height:170px;display:flex;align-items:end;gap:clamp(2px,.5cqw,7px);border-bottom:1px solid var(--dsw-alias-border-l1,#dce3ec);background:repeating-linear-gradient(to top,transparent 0,transparent 41px,var(--dsw-alias-border-l1,#e9edf3) 42px,transparent 43px);padding:0 2px}
13
+ .cm-stat-bar{height:100%;flex:1;min-width:1px;display:flex;align-items:end;justify-content:center;background:transparent;border:0;padding:0;position:relative}.cm-stat-bar>span{display:block;width:100%;max-width:45px;border-radius:4px 4px 0 0;background:var(--cm-accent);min-height:2px;opacity:.83}.cm-stat-bar:hover>span,.cm-stat-bar[aria-pressed=true]>span{opacity:1;background:var(--cm-plan)}
14
+ .cm-stat-axis{display:flex;justify-content:space-between;font-size:11px;color:var(--cm-muted);margin-top:6px}.cm-stat-grid{display:grid;grid-template-columns:minmax(0,1.3fr) minmax(0,1fr);gap:14px}.cm-stat-grid>.cm-stat-panel{margin:0}
15
+ .cm-stat-rank{list-style:none;padding:0;margin:0}.cm-stat-rank li{margin-top:14px}.cm-stat-rank button{width:100%;border:0;background:transparent;text-align:left;padding:0;color:inherit}.cm-stat-rankline{display:flex;justify-content:space-between;gap:10px}.cm-stat-rankline>span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cm-stat-track{height:5px;margin-top:7px;background:var(--dsw-alias-bg-layer-2,#f0f3f7);border-radius:4px;overflow:hidden}.cm-stat-track>span{display:block;height:100%;background:var(--cm-accent);border-radius:4px}.cm-stat-number{font-variant-numeric:tabular-nums;white-space:nowrap}
16
+ .cm-stat-table{width:100%;border-collapse:collapse;font-size:12px}.cm-stat-table th,.cm-stat-table td{padding:11px 9px;border-bottom:1px solid var(--dsw-alias-border-l1,#e0e5ed);text-align:right;font-variant-numeric:tabular-nums}.cm-stat-table th{font-weight:500;color:var(--cm-muted)}.cm-stat-table th:first-child,.cm-stat-table td:first-child{text-align:left}.cm-stat-table button{color:var(--cm-accent);border:0;background:transparent;text-align:left;padding:0;max-width:300px;overflow-wrap:anywhere}.cm-stat-scroll{overflow:auto}
17
+ .cm-stat-page{display:flex;gap:12px;align-items:center;justify-content:flex-end;margin-top:14px}.cm-stat-note{padding:10px 12px;background:var(--dsw-alias-bg-layer-2,#f5f7fa);border-radius:8px;margin:12px 0!important;font-size:12px;color:var(--cm-muted)}.cm-stat-error{color:var(--dsw-alias-state-error-primary,#c75040);white-space:pre-wrap}.cm-stat-empty{padding:30px;text-align:center;color:var(--cm-muted)}.cm-stat-call{padding:11px 0;border-bottom:1px solid var(--dsw-alias-border-l1,#dce3ec)}.cm-stat-call summary{cursor:pointer;overflow-wrap:anywhere}.cm-stat-call p{overflow-wrap:anywhere;margin-top:6px}
18
+ @container(max-width:700px){.cm-stat-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.cm-stat-grid{grid-template-columns:1fr}.cm-stat-panel{padding:12px}.cm-stat-head{align-items:start}.cm-stat-controls label{flex:1}.cm-stat input,.cm-stat select{max-width:100%;width:100%}.cm-stat-value{font-size:23px}}
19
+ `;function nt(s,n,o,d){const[p,c]=k({value:null,error:"",loading:!0}),a=JSON.stringify(o);return ht(()=>{let i=!0;return c({value:null,error:"",loading:!0}),s[n](JSON.parse(a)).then(v=>{i&&c({value:v,error:"",loading:!1})},v=>{i&&c({value:null,error:String(v.message??v),loading:!1})}),()=>{i=!1}},[s,n,a,d]),p}const j=(s,n,o={})=>t("button",{type:"button",className:"cm-stat-btn",onClick:n,...o},s);function lt({offset:s,count:n,size:o,onChange:d,text:p}){return t("div",{className:"cm-stat-page"},t("span",{className:"cm-stat-sub"},n?`${s+1}–${Math.min(s+o,n)} / ${n}`:"0"),j(p("上一页","Previous"),()=>d(Math.max(0,s-o)),{disabled:!s}),j(p("下一页","Next"),()=>d(s+o),{disabled:s+o>=n}))}function ct({rows:s,money:n,text:o,onSelect:d,label:p}){const c=Math.max(0,...s.map(i=>i.value)),a=s.reduce((i,v)=>i+v.value,0);return t("div",null,t("div",{className:"cm-stat-panel-head"},t("h3",null,p),t("span",{className:"cm-stat-sub"},o("最高 ","Peak ")+n(c)+" · "+o("合计 ","Total ")+n(a))),t("div",{className:"cm-stat-chart",role:"group","aria-label":p},s.map((i,v)=>t("button",{key:v,type:"button",className:"cm-stat-bar",title:i.label+" · "+n(i.value),"aria-label":i.label+" · "+n(i.value),onClick:()=>d(i)},t("span",{style:{height:c?Math.max(1,i.value/c*100)+"%":"0%",opacity:i.value?void 0:.2}})))),t("div",{className:"cm-stat-axis"},t("span",null,s[0]?.label),t("span",null,s.at(-1)?.label)))}function ot(s,n){const o=Math.max(1,Math.ceil(s.length/60)),d=[];for(let p=0;p<s.length;p+=o){const c=s.slice(p,p+o),a=c[0].date,i=c.at(-1).date;d.push({from:a,to:i,label:a===i?a:a+" – "+i,value:c.reduce((v,P)=>v+N(P,n),0)})}return d}function it({api:s,query:n,revision:o,money:d,formatTokens:p,text:c}){const[a,i]=k(0),[v,P]=k(-1),R=nt(s,"getSessionBilling",{...n,offset:a},o),u=R.value,T=n.basis,I={input:c("输入","Input"),output:c("输出","Output"),cacheRead:c("缓存读取","Cache read"),cacheWrite:c("缓存写入","Cache write"),reasoning:c("推理","Reasoning")},B={model:c("模型调用","Model call"),compaction:c("上下文压缩","Compaction"),search:c("原生搜索","Native search")};if(R.loading)return t("p",{className:"cm-stat-empty",role:"status"},c("正在读取本对话的用量明细…","Loading this conversation’s usage records…"));if(R.error)return t("p",{className:"cm-stat-error",role:"alert"},R.error);if(!u.found)return t("p",{className:"cm-stat-note"},c("没有可用的调用日志。上方账本统计仍然有效;明细不会按零费用处理。","Call logs are unavailable. The ledger totals above remain valid; missing details do not mean zero cost."));const z=l=>T==="total"||(T==="plan"?l.plan:!l.plan),q=Object.keys(I).map(l=>{const f=u.rows.filter(x=>x.bucket===l&&z(x));return{bucket:l,tokens:f.reduce((x,L)=>x+L.tokens,0),cost:f.reduce((x,L)=>x+L.cost,0),unpriced:f.some(x=>!x.priced)}}),h=N(u,T),E=N(u.recorded,T),F=Math.abs(h-E)>Math.max(1e-8,Math.abs(E)*1e-8)||u.totalCalls!==u.recorded.calls,A=(l,f)=>t("p",{key:f,className:"cm-stat-sub"},I[l.bucket]+": "+l.tokens.toLocaleString()+" × "+(l.priced?d(l.rate):"?")+" / 1,000,000 = "+(l.priced?d(l.cost):"?"));return t("section",{className:"cm-stat-panel"},t("div",{className:"cm-stat-panel-head"},t("h3",null,c("单对话费用明细","Conversation cost details")),t("span",{className:"cm-stat-sub"},c("只统计本对话自身的调用","Own calls only; child conversations are separate"))),t("p",{className:"cm-stat-note"},c("明细按日志中的调用时间、用量和当前配置的历史价格规则计算。上方汇总采用已入账金额;调整价格后两者可能不同。","Details use logged usage and call times with the currently configured historical price rules. The summary above uses recorded ledger amounts; changing prices can produce a difference.")),F?t("p",{className:"cm-stat-note"},c("账本与可用明细不同:账本 ","Ledger and available details differ: ledger ")+d(E)+" / "+u.recorded.calls+c(" 次;明细 "," calls; details ")+d(h)+" / "+u.totalCalls+c(" 次。"," calls.")):null,t("div",{className:"cm-stat-scroll"},t("table",{className:"cm-stat-table"},t("thead",null,t("tr",null,...[c("费用构成","Cost component"),"Tokens",c("费用","Cost"),c("费用占比","Cost share")].map(l=>t("th",{key:l},l)))),t("tbody",null,q.map(l=>t("tr",{key:l.bucket},t("td",null,I[l.bucket]),t("td",null,l.tokens.toLocaleString()),t("td",null,d(l.cost)+(l.unpriced?" + ?":"")),t("td",null,O(l.cost,h))))))),t("p",{className:"cm-stat-sub",style:{marginTop:10}},c("推理 Token 可能包含在输出中;单价为 0 时不另收推理费。未定价用量显示 ?。","Reasoning tokens may overlap output; a zero reasoning rate adds no separate charge. Unpriced usage is marked ?.")),t("div",{style:{marginTop:24}},t(ct,{rows:u.calls.map((l,f)=>({label:"#"+(a+f+1),index:f,value:N(l,T)})),money:d,text:c,label:c("逐次调用费用 · 当前页","Cost per call · current page"),onSelect:l=>{P(l.index),document.getElementById("cm-stat-call-"+l.index)?.scrollIntoView({block:"nearest",behavior:"smooth"})}})),u.calls.map((l,f)=>t("details",{key:f,id:"cm-stat-call-"+f,className:"cm-stat-call",open:v===f,onToggle:x=>{!x.currentTarget.open&&v===f&&P(-1)}},t("summary",null,"#"+(a+f+1)+" · "+(B[l.kind]??l.kind)+" · "+l.provider+" / "+l.model+" · "+(l.plan?"Plan ":"API ")+(l.priced?d(l.cost):"?")+(l.longContext?c(" · 长上下文价"," · long-context rate"):"")),t("p",{className:"cm-stat-sub"},new Date(l.atMs).toLocaleString()),l.rows.map(A))),t(lt,{offset:a,count:u.totalCalls,size:50,onChange:l=>{i(l),P(-1)},text:c}))}function rt({state:s,api:n,sessionId:o="",formatMoneyUsd:d,formatTokens:p}){const c=s.config.locale==="en"||s.config.locale==="auto"&&!/^zh\b/i.test(navigator.language),a=(e,y)=>c?y:e,[i,v]=k(o?"all":"week"),[P,R]=k(null),[u,T]=k({id:o,title:o}),[I,B]=k(""),[z,q]=k(""),[h,E]=k("api"),[F,A]=k(0),[l,f]=k(0),[x,L]=k(!1),W=s.meta.dayKey||new Date().toISOString().slice(0,10),U=i==="all"?"":i==="custom"?P?.from||W:vt(W,i==="week"?-6:i==="month"?-29:0),_=i==="custom"&&P?.to||W,mt={from:U,to:_,provider:I,model:z,basis:h,sessionId:u.id,offset:F},$=nt(n,"getBillingStatistics",mt,l+":"+s.meta.now),m=$.value,w=e=>d(e,{...s.config,decimals:Math.max(e!==0&&Math.abs(e)<.01?8:4,s.config.decimals??2)}),dt=e=>{v(e),A(0)},pt=e=>{T(e),A(0),B(""),q("")},wt=h==="total"?a("API + Plan 等值","API + Plan equivalent"):h==="plan"?a("Plan 等值","Plan equivalent"):a("API 费用","API cost"),V=(e,y,G,St)=>t("label",null,e,t("select",{value:y,onChange:Q=>{G(Q.target.value),A(0)}},St.map(([Q,Pt])=>t("option",{key:Q,value:Q},Pt)))),Ct={input:a("未缓存输入","Uncached input"),cacheRead:a("缓存读取","Cache read"),cacheWrite:a("缓存写入","Cache write"),output:a("输出","Output")},ut=(e,y)=>t("section",{className:"cm-stat-panel"},t("div",{className:"cm-stat-panel-head"},t("h3",null,e)),y),g=m?.totals,J=(e,y,G)=>t("div",{className:"cm-stat-metric",key:e},t("div",{className:"cm-stat-sub"},e),t("div",{className:"cm-stat-value"},y),t("div",{className:"cm-stat-sub"},G));return t("div",{className:"cm-stat"},t("style",null,kt),t("header",{className:"cm-stat-head"},t("div",null,t("h2",null,a("计费统计","Cost statistics")),t("p",{className:"cm-stat-sub"},u.id?a("单对话 · ","Conversation · ")+(u.title===u.id&&m?.sessions[0]?.title||u.title):a("全部对话 · 从总额查看每一笔调用","All conversations · from totals to individual calls"))),j(a("刷新","Refresh"),()=>f(e=>e+1))),u.id?j(a("← 全部对话","← All conversations"),()=>pt({id:"",title:""})):null,t("div",{className:"cm-stat-controls"},t("div",{className:"cm-stat-periods"},...[["today",a("今天","Today")],["week",a("近 7 天","Last 7 days")],["month",a("近 30 天","Last 30 days")],["all",a("全部记录","All retained")],["custom",a("自定义","Custom")]].map(([e,y])=>j(y,()=>dt(e),{key:e,"aria-pressed":i===e})))),i==="custom"?t("div",{className:"cm-stat-controls"},...["from","to"].map(e=>t("label",{key:e},e==="from"?a("开始日期","From"):a("结束日期","To"),t("input",{type:"date",value:e==="from"?U:_,onChange:y=>{R({from:U,to:_,[e]:y.target.value}),A(0)}})))):null,t("div",{className:"cm-stat-controls"},V(a("计费口径","Cost basis"),h,E,[["api",a("API 费用","API cost")],["plan",a("Plan 等值","Plan equivalent")],["total",a("API + Plan 等值","API + Plan equivalent")]]),V(a("提供商","Provider"),I,e=>{B(e),q("")},[["",a("全部提供商","All providers")],...[...new Set([I,...m?.providers??[]])].filter(Boolean).map(e=>[e,e])]),V(a("模型","Model"),z,q,[["",a("全部模型","All models")],...[...new Set([z,...m?.modelOptions??[]])].filter(Boolean).map(e=>[e,e])])),t("p",{className:"cm-stat-sub"},a("按宿主时区归日:","Days use the host timezone: ")+(s.meta.timezone||"UTC")+" · "+a("API 金额是按已记录用量和配置单价计算的估算;Plan 为订阅用量的 API 等值,不是订阅账单。外部导入用量在原概览中单列。","API amounts estimate recorded usage at configured rates; Plan is API-equivalent usage, not the subscription invoice. External usage remains separate in Overview.")),$.loading?t("p",{className:"cm-stat-empty",role:"status"},a("加载统计…","Loading statistics…")):$.error?t("p",{className:"cm-stat-error",role:"alert"},$.error):m?t(H,null,t("p",{className:"cm-stat-sub",style:{marginTop:8}},m.from+" – "+m.to+(m.retainedFrom?" · "+a("账本保留范围 ","Retained ledger ")+m.retainedFrom+" – "+m.retainedTo:"")),t("div",{className:"cm-stat-metrics"},J(a("API 费用","API cost"),w(g.apiCost),a("已入账估算","Recorded estimate")),J(a("Plan 等值费用","Plan equivalent"),w(Math.max(0,g.cost-g.apiCost)),a("不代表实际扣款","Not an actual debit")),J(a("调用次数","Calls"),g.calls.toLocaleString(),m.sessionCount+a(" 个对话 · 平均 "," conversations · average ")+w(g.calls?N(g,h)/g.calls:0)),J(a("缓存命中率","Cache hit rate"),O(g.cacheRead,g.input+g.cacheRead+g.cacheWrite),p(X(g))+" Tokens")),m.models.some(e=>!e.priced)?t("p",{className:"cm-stat-note"},a("部分模型当前未配置价格,金额可能不完整;未定价不等于免费。","Some models have no configured price. Amounts may be incomplete; unpriced usage is not free.")):null,!g.calls&&!g.cost?t("p",{className:"cm-stat-empty"},a("所选范围没有已记录的用量。","No recorded usage in this range.")):null,t("section",{className:"cm-stat-panel"},t(ct,{rows:ot(m.days,h),money:w,text:a,label:a("费用趋势 · ","Cost over time · ")+wt,onSelect:e=>{R({from:e.from,to:e.to}),dt("custom")}}),t("p",{className:"cm-stat-sub",style:{marginTop:12}},a("点击柱形查看该日期范围。长区间按相邻日期合并,保留全部金额。","Select a bar to inspect its date range. Long ranges group adjacent days without dropping costs."))),t("div",{className:"cm-stat-grid"},ut(a("模型费用排行","Cost by model"),t(H,null,t("ol",{className:"cm-stat-rank"},(x?m.models:m.models.slice(0,8)).map(e=>t("li",{key:e.key},t("button",{type:"button",onClick:()=>{B(e.provider),q(e.model),A(0)},title:e.key},t("div",{className:"cm-stat-rankline"},t("span",null,e.model),t("span",{className:"cm-stat-number"},w(N(e,h))+(e.priced?"":" + ?"))),t("div",{className:"cm-stat-sub"},e.provider+" · "+e.calls+a(" 次 · "," calls · ")+O(N(e,h),N(g,h))),t("div",{className:"cm-stat-track"},t("span",{style:{width:O(N(e,h),N(g,h))}}))))),m.models.length>8?j(x?a("收起","Show less"):a("显示全部模型","Show all models"),()=>L(e=>!e)):null,m.unmodeledCost>1e-8?t("p",{className:"cm-stat-note"},a("历史未分配到模型的费用:","Historical cost without a model: ")+w(m.unmodeledCost)):null)),ut(a("Token 构成","Token composition"),t(H,null,t("ol",{className:"cm-stat-rank"},Object.entries(Ct).map(([e,y])=>t("li",{key:e},t("div",{className:"cm-stat-rankline"},t("span",null,y),t("span",{className:"cm-stat-number"},p(g[e]))),t("div",{className:"cm-stat-track"},t("span",{style:{width:O(g[e],X(g)),background:e.startsWith("cache")?"var(--cm-plan)":void 0}}))))),t("p",{className:"cm-stat-note"},a("这里展示 Token 数量占比,不是费用占比。缓存写入计入输入分母,不算缓存命中。","These are token shares, not cost shares. Cache writes count as input, not cache hits.")),t("p",{className:"cm-stat-sub"},a("单独上报的推理 Token:","Reported reasoning tokens: ")+p(g.reasoning))))),u.id?t(it,{key:JSON.stringify([u.id,U,_,I,z,h]),api:n,query:{...mt,from:m.from,to:m.to,offset:0},revision:l,money:w,formatTokens:p,text:a}):t("section",{className:"cm-stat-panel"},t("div",{className:"cm-stat-panel-head"},t("h3",null,a("对话费用排行","Cost by conversation")),t("span",{className:"cm-stat-sub"},a("点击对话查看明细 · 子代理分别计入,不重复相加","Select a conversation for details · each agent counted once"))),t("div",{className:"cm-stat-scroll"},t("table",{className:"cm-stat-table"},t("thead",null,t("tr",null,...[a("对话","Conversation"),a("调用","Calls"),"API",a("Plan 等值","Plan equivalent"),a("缓存命中","Cache hits")].map(e=>t("th",{key:e},e)))),t("tbody",null,m.sessions.map(e=>t("tr",{key:e.id},t("td",null,t("button",{type:"button",title:e.id,onClick:()=>pt({id:e.id,title:e.title})},e.title)),t("td",null,e.calls.toLocaleString()),t("td",null,w(e.apiCost)),t("td",null,w(Math.max(0,e.cost-e.apiCost))),t("td",null,O(e.cacheRead,e.input+e.cacheRead+e.cacheWrite))))))),m.unassignedCost>1e-8?t("p",{className:"cm-stat-note"},a("未关联对话的费用:","Cost not linked to a conversation: ")+w(m.unassignedCost)):null,t(lt,{offset:F,count:m.sessionCount,size:25,onChange:A,text:a})))):null)}async function Nt(s){const n=await s.get("remote").$mount(st);s.effect(()=>()=>n(),"cost-meter: statistics contribution");const o=s.get("remote.costMeter"),d=Object.fromEntries(["getBillingStatistics","getSessionBilling"].map(p=>[p,async c=>{const a=await o[p](c);if(!a?.ok)throw new Error(a?.error?.message||"Statistics request failed");return a.value}]));return p=>t(rt,{...p,api:d})}return{mount:Nt,Statistics:rt,SessionDetail:it,CONTRIBUTION:st,parseStatistics:tt,parseDetail:at,dailyChartRows:ot}}});
package/lib/index.js CHANGED
@@ -37,7 +37,8 @@ import { readExternalUsageSnapshot, externalTodayCostForReconcile } from './exte
37
37
  import { queryQwenCli } from './qwen-cli.js'
38
38
  import { queryBailianCli } from './bailian-cli.js'
39
39
  import { getSessionCost as readSessionCost } from './session-tree.js'
40
- import { getTurnCost } from './turn-cost.js'
40
+ import { getTurnCost, getSessionBilling } from './turn-cost.js'
41
+ import { billingStatistics } from './billing-statistics.js'
41
42
 
42
43
  export const name = 'cost-meter'
43
44
 
@@ -2395,6 +2396,8 @@ function createService(ctx, ledger, openRouterPrices) {
2395
2396
  async getTurnCost(sessionId, startSeq, endSeq) {
2396
2397
  return getTurnCost(ledger, ctx, sessionId, startSeq, endSeq)
2397
2398
  },
2399
+ async getBillingStatistics(query) { return billingStatistics(ledger, query) },
2400
+ async getSessionBilling(query) { return getSessionBilling(ledger, ctx, query) },
2398
2401
  async getTopSessions(limit, sort = 'cost', dir = 'desc') {
2399
2402
  const n = Math.max(1, Math.min(500, Math.floor(Number(limit)) || 100))
2400
2403
  const sortKey = sort === 'time' || sort === 'recent' ? sort : 'cost'
package/lib/turn-cost.js CHANGED
@@ -4,6 +4,8 @@ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
4
4
  import { listSessionLogs, iterateSessionRecords, replaySessionRecords } from './backfill.js'
5
5
  import { nativeSearchRecordsFor } from './native-search-history.js'
6
6
  import { tierFor, usdFromCost } from './pricing.js'
7
+ import { isNativeSearchUsageEvent } from './native-search-events.js'
8
+ import { ledgerStatisticsQuery, matchesStats, selectedStats, emptyStats, STAT_FIELDS } from './billing-statistics.js'
7
9
 
8
10
  const cache = new Map()
9
11
  const fields = { input: 'cacheMiss', output: 'output', cacheRead: 'cacheHit', cacheWrite: 'cacheWrite', reasoning: 'reasoning' }
@@ -16,6 +18,10 @@ export function turnCostFromRecords(records, config, startSeq, endSeq, native =
16
18
  const selected = [...headers, ...records.filter(row => row.seq >= startSeq && row.seq <= endSeq),
17
19
  ...native.filter(row => row.data.startedAtMs >= start.time && row.data.startedAtMs <= end.time)]
18
20
  const { samples } = replaySessionRecords(selected, config, null, true)
21
+ return costFromSamples(samples, config)
22
+ }
23
+
24
+ export function costFromSamples(samples, config) {
19
25
  const parts = new Map()
20
26
  const calls = []
21
27
  for (const sample of samples) {
@@ -42,10 +48,16 @@ export function turnCostFromRecords(records, config, startSeq, endSeq, native =
42
48
 
43
49
  export async function getTurnCost(ledger, ctx, sessionId, startSeq, endSeq) {
44
50
  if (typeof sessionId !== 'string' || !sessionId || sessionId.length > 512 || ![startSeq, endSeq].every(n => Number.isSafeInteger(n) && n >= 0) || endSeq < startSeq) throw new Error('invalid turn identity')
45
- const live = ctx?.get?.('sessions')?.get?.(sessionId)?.snapshotEvents?.()
46
- if (Array.isArray(live)) return turnCostFromRecords(live, ledger.config, startSeq, endSeq, await nativeSearchRecordsFor(ledger.path, sessionId))
51
+ const records = await sessionUsageRecords(ctx, sessionId)
52
+ return turnCostFromRecords(records, ledger.config, startSeq, endSeq, await nativeSearchRecordsFor(ledger.path, sessionId))
53
+ }
54
+
55
+ async function sessionUsageRecords(ctx, sessionId) {
56
+ const session = ctx?.get?.('sessions')?.get?.(sessionId)
57
+ const live = session?.snapshotEvents?.()
58
+ if (Array.isArray(live)) return [{ type: 'session', id: sessionId, createdAt: session.header?.createdAt ?? 0 }, ...live]
47
59
  const paths = listSessionLogs(join(resolveDshHome(), 'sessions'), new Set([sessionId]))
48
- if (!paths.length) return emptyTurn()
60
+ if (!paths.length) return []
49
61
  const path = paths[0], info = statSync(path)
50
62
  const key = `${path}:${info.size}:${info.mtimeMs}`
51
63
  let pending = cache.get(key)
@@ -53,7 +65,9 @@ export async function getTurnCost(ledger, ctx, sessionId, startSeq, endSeq) {
53
65
  pending = (async () => {
54
66
  const rows = []
55
67
  for await (const row of iterateSessionRecords(path)) {
56
- if (row.type === 'request/header') rows.push({ type: row.type, seq: row.seq, time: row.time, data: { header: { config: { model: row.data?.header?.config?.model, provider: row.data?.header?.config?.provider } } } })
68
+ if (row.type === 'session') rows.push({ type: 'session', id: row.id, createdAt: row.createdAt })
69
+ else if (isNativeSearchUsageEvent(row)) rows.push(row)
70
+ else if (row.type === 'request/header') rows.push({ type: row.type, seq: row.seq, time: row.time, data: { header: { config: { model: row.data?.header?.config?.model, provider: row.data?.header?.config?.provider } } } })
57
71
  else if (['turn/start', 'turn/end', 'compaction/summary', 'assistant/message'].includes(row.type) || row.type === 'assistant/chunk' && row.data?.chunk?.type === 'usage') {
58
72
  rows.push({ type: row.type, seq: row.seq, time: row.time, data: { turn: row.data?.turn, step: row.data?.step, provider: row.data?.provider, model: row.data?.model,
59
73
  usage: row.data?.usage, message: { source: row.data?.message?.source }, chunk: row.data?.chunk?.type === 'usage' ? row.data.chunk : undefined } })
@@ -66,6 +80,24 @@ export async function getTurnCost(ledger, ctx, sessionId, startSeq, endSeq) {
66
80
  if (cache.size > 16) cache.delete(cache.keys().next().value)
67
81
  pending.catch(() => cache.delete(key))
68
82
  }
69
- const records = await pending
70
- return turnCostFromRecords(records, ledger.config, startSeq, endSeq, await nativeSearchRecordsFor(ledger.path, sessionId))
83
+ return pending
84
+ }
85
+
86
+ /** Detail is reconstructed only for the selected session; the stored ledger remains authoritative. */
87
+ export async function getSessionBilling(ledger, ctx, raw) {
88
+ const query = ledgerStatisticsQuery(ledger, raw)
89
+ if (!query.sessionId) throw new Error('sessionId is required for billing detail')
90
+ const records = await sessionUsageRecords(ctx, query.sessionId)
91
+ const native = await nativeSearchRecordsFor(ledger.path, query.sessionId)
92
+ const { samples } = replaySessionRecords([...records, ...native], ledger.config, null, true)
93
+ const selected = samples.filter(s => !s.seed && s.date >= query.from && s.date <= query.to && matchesStats(s.providerKey, query))
94
+ const detail = costFromSamples(selected, ledger.config)
95
+ const recorded = emptyStats()
96
+ for (const [date, day] of Object.entries(ledger.days)) {
97
+ if (date < query.from || date > query.to) continue
98
+ const row = selectedStats(day.sessions?.find(s => s.id === query.sessionId), query)
99
+ for (const key of STAT_FIELDS) recorded[key] += row[key]
100
+ }
101
+ // Limit transport size, not the totals or category breakdown. Every call is reachable by paging.
102
+ return { ...detail, calls: detail.calls.slice(query.offset, query.offset + 50), totalCalls: detail.calls.length, offset: query.offset, recorded }
71
103
  }
@@ -66,6 +66,22 @@ export const turnCostSchema = z.object({
66
66
  calls: z.array(z.object({ kind: z.string(), provider: z.string(), model: z.string(), atMs: num, cost: num, apiCost: num, plan: z.boolean(), priced: z.boolean(), longContext: z.boolean(), rows: z.array(turnCostRowSchema) })),
67
67
  })
68
68
 
69
+ export const statisticsQuerySchema = z.object({
70
+ from: z.string().max(10).optional(), to: z.string().max(10).optional(),
71
+ provider: z.string().max(512).optional(), model: z.string().max(512).optional(), sessionId: z.string().max(512).optional(),
72
+ basis: z.enum(['api', 'plan', 'total']).optional(), offset: z.number().int().min(0).max(1e7).optional(),
73
+ })
74
+ const statisticsBuckets = z.object(Object.fromEntries(['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning', 'calls', 'cost', 'apiCost'].map(key => [key, num])))
75
+ export const billingStatisticsSchema = z.object({
76
+ from: z.string(), to: z.string(), retainedFrom: z.string(), retainedTo: z.string(), totals: statisticsBuckets,
77
+ days: z.array(statisticsBuckets.extend({ date: z.string() })),
78
+ models: z.array(statisticsBuckets.extend({ key: z.string(), provider: z.string(), model: z.string(), priced: z.boolean() })),
79
+ providers: z.array(z.string()), modelOptions: z.array(z.string()),
80
+ sessions: z.array(statisticsBuckets.extend({ id: z.string(), title: z.string() })),
81
+ sessionCount: num, offset: num, unassignedCost: num, unmodeledCost: num,
82
+ })
83
+ export const sessionBillingSchema = turnCostSchema.extend({ totalCalls: num, offset: num, recorded: statisticsBuckets })
84
+
69
85
  const longContextSchema = z.object({
70
86
  aboveInputTokens: z.number().positive(),
71
87
  cacheHit: z.number().nonnegative(),
@@ -688,6 +704,11 @@ export const TYPERT = {
688
704
  parameters: [{ name: 'sessionId', wire: 'sessionId', source: 'json', codec: _sessionId$codec }, { name: 'startSeq', wire: 'startSeq', source: 'json', codec: _turnSeq$codec }, { name: 'endSeq', wire: 'endSeq', source: 'json', codec: _turnSeq$codec }],
689
705
  result: _turnCost$codec,
690
706
  },
707
+ ...[['getBillingStatistics', 'BillingStatistics', billingStatisticsSchema], ['getSessionBilling', 'SessionBilling', sessionBillingSchema]].map(([method, name, schema]) => ({
708
+ id: 'dsh-cost-meter#costMeter/' + method, service: 'costMeter', namespace: 'costMeter', method, invocation: { kind: 'direct' },
709
+ parameters: [{ name: 'query', wire: 'query', source: 'json', codec: strictCodec('StatisticsQuery', statisticsQuerySchema) }],
710
+ result: strictCodec(name, schema),
711
+ })),
691
712
  {
692
713
  // 写入一枚密钥到 DSH 凭据库(v1.6.8):密钥不再经 updateConfig 传递,值只进凭据库。
693
714
  id: 'dsh-cost-meter#costMeter/setCredential',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cost-meter",
3
- "version": "1.7.49",
3
+ "version": "1.8.0",
4
4
  "description": "DeepSeek Harness 会话费用统计插件:本会话成本、当日费用、历史记录与官方价格同步,支持多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配)、主流 Coding Plan 订阅额度查询与显示(11 家,含小米 MiMo Token Plan 控制台 Cookie 查询、SCNet Token Plan 本地 Credits 计量与火山方舟 Volcano Ark Coding Plan AK/SK 签名查询)、自定义 Provider 余额查询(可配任意 HTTP 端点)与余额进度条、峰谷计价时段显示与切换前弹窗/系统通知提醒(位置/提前量/类型可配),界面中英双语。Session cost tracking plugin for DeepSeek Harness: per-conversation cost, daily totals, history and official price sync, with multi-vendor model pricing (built-in 90+ model catalog with auto-matching), Coding Plan quota queries & display for 11 vendors (incl. Xiaomi MiMo Token Plan via console cookie, local credits metering for the SCNet Token Plan and Volcano Ark Coding Plan via AK/SK signature), plan/API dual-track billing with per-1% & full-window Token Plan usage estimates, custom provider balance lookup (configurable HTTP endpoint) and balance progress bar, plus peak/off-peak pricing display with pre-switch popup & system-notification alerts (position / lead time / type configurable), in a bilingual (Chinese/English) UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -95,6 +95,7 @@
95
95
  "lib",
96
96
  "cordis.patch.yml",
97
97
  "docs/provider-pricing.json",
98
+ "docs/billing-statistics.md",
98
99
  "docs/external-usage.md"
99
100
  ],
100
101
  "dependencies": {