@ipv9/tokentracker-cli 0.39.40 → 0.39.41
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.
- package/README.md +5 -5
- package/dashboard/dist/assets/{Card-JS4gjUZf.js → Card-BwY_Qv6N.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-C7b_GjA-.js +60 -0
- package/dashboard/dist/assets/{FadeIn-DvLfvwys.js → FadeIn-DiAafUDN.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-Dcfc0Uts.js → IpCheckPage-CYgWVZJr.js} +1 -1
- package/dashboard/dist/assets/{LimitsPage-Vz-GVdi9.js → LimitsPage-C1y75ftN.js} +1 -1
- package/dashboard/dist/assets/{LocalOnlyNotice-8xF5couh.js → LocalOnlyNotice-B5_-ydeu.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-7HrUzeRx.js → PopoverPopup-DDjjQ5CG.js} +1 -1
- package/dashboard/dist/assets/{Select-9XDsaO_C.js → Select-BPspkl9p.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-DE5Fs9p6.js → SelectItemText-CX0dWYB1.js} +1 -1
- package/dashboard/dist/assets/{SettingsPage-CMnfAptE.js → SettingsPage-9K5ST4D_.js} +1 -1
- package/dashboard/dist/assets/{SkillsPage-CbvLHN0L.js → SkillsPage-BpCSS7ls.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-CeGQlyt7.js → WidgetsPage-aBisLWrM.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-Bux8qLUC.js → WrappedPage-BgkuBG_j.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-B7rCoZdW.js → arrow-up-right-DPb2FRSP.js} +1 -1
- package/dashboard/dist/assets/{download-CtTYpbcx.js → download-CbZ8YL8m.js} +1 -1
- package/dashboard/dist/assets/{format-BuQKK6kQ.js → format-aRaCvnht.js} +1 -1
- package/dashboard/dist/assets/limitDisplay-1O9-AdgI.js +1 -0
- package/dashboard/dist/assets/{main-Ck4SUH4m.js → main-DRcFJLLD.js} +3 -3
- package/dashboard/dist/assets/mock-data-sZ3-GZV0.js +1 -0
- package/dashboard/dist/assets/{use-limits-display-prefs-BKWYPgeA.js → use-limits-display-prefs-2-qYvffB.js} +1 -1
- package/dashboard/dist/assets/{use-native-settings-D7Nj1dXR.js → use-native-settings-C1uhDKdL.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-BX3pvyhh.js → useCurrency-BPCF4zUv.js} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/package.json +3 -2
- package/src/commands/serve.js +121 -38
- package/src/lib/fs.js +149 -25
- package/src/lib/local-api.js +20 -2
- package/src/lib/pricing/curated-overrides.json +10 -3
- package/src/lib/pricing/index.js +197 -18
- package/src/lib/pricing/litellm-fetcher.js +6 -1
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/dashboard/dist/assets/DashboardPage-BoXhzJb1.js +0 -60
- package/dashboard/dist/assets/limitDisplay-WwuTdS_a.js +0 -1
- package/dashboard/dist/assets/mock-data-Bvbe8tYK.js +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{t as A}from"./format-aRaCvnht.js";function ht(){if(typeof Intl>"u"||!Intl.DateTimeFormat)return null;try{return Intl.DateTimeFormat().resolvedOptions().timeZone||null}catch{return null}}function kt(t=new Date){const e=t instanceof Date?t:new Date(t);if(!Number.isFinite(e.getTime()))return 0;const n=e.getTimezoneOffset();return Number.isFinite(n)?-n:0}function V(t){if(!Number.isFinite(t)||t===0)return"UTC";const e=t>=0?"+":"-",n=Math.abs(t),o=String(Math.floor(n/60)).padStart(2,"0"),a=String(n%60).padStart(2,"0");return`UTC${e}${o}:${a}`}function bt({timeZone:t,offsetMinutes:e}={}){const n=V(e);return t&&t!=="UTC"?`${t} (${n})`:n}function Dt({timeZone:t,offsetMinutes:e}={}){return Number.isFinite(e)?V(e):t||"UTC"}function wt({timeZone:t,offsetMinutes:e,date:n=new Date}={}){const o=n instanceof Date?n:new Date(n);if(!Number.isFinite(o.getTime()))return"";if(t&&typeof Intl<"u"&&Intl.DateTimeFormat)try{const s=new Intl.DateTimeFormat("en-CA",{timeZone:t,year:"numeric",month:"2-digit",day:"2-digit"}).formatToParts(o).reduce((m,c)=>(c.type&&c.value&&(m[c.type]=c.value),m),{}),i=s.year,u=s.month,_=s.day;if(i&&u&&_)return`${i}-${u}-${_}`}catch{}if(typeof e=="number"&&Number.isFinite(e)){const a=new Date(o.getTime()+e*6e4);return Z(a)}return G(o)}function J({timeZone:t,offsetMinutes:e,date:n=new Date}={}){const o=n instanceof Date?n:new Date(n);if(!Number.isFinite(o.getTime()))return null;if(t&&typeof Intl<"u"&&Intl.DateTimeFormat)try{const s=new Intl.DateTimeFormat("en-CA",{timeZone:t,year:"numeric",month:"2-digit",day:"2-digit"}).formatToParts(o).reduce((m,c)=>(c.type&&c.value&&(m[c.type]=c.value),m),{}),i=Number(s.year),u=Number(s.month),_=Number(s.day);if(Number.isFinite(i)&&Number.isFinite(u)&&Number.isFinite(_))return{year:i,month:u,day:_}}catch{}if(typeof e=="number"&&Number.isFinite(e)){const a=new Date(o.getTime()+e*6e4);return{year:a.getUTCFullYear(),month:a.getUTCMonth()+1,day:a.getUTCDate()}}return{year:o.getFullYear(),month:o.getMonth()+1,day:o.getDate()}}function yt({timeZone:t,offsetMinutes:e}={}){return t?`tz:${t}`:typeof e=="number"&&Number.isFinite(e)?`offset:${Math.trunc(e)}`:"utc"}function Z(t){const e=t.getUTCFullYear(),n=String(t.getUTCMonth()+1).padStart(2,"0"),o=String(t.getUTCDate()).padStart(2,"0");return`${e}-${n}-${o}`}function G(t){const e=t.getFullYear(),n=String(t.getMonth()+1).padStart(2,"0"),o=String(t.getDate()).padStart(2,"0");return`${e}-${n}-${o}`}function f(t){return new Date(Date.UTC(t.getUTCFullYear(),t.getUTCMonth(),t.getUTCDate())).toISOString().slice(0,10)}function F(t){const e=t instanceof Date?t:new Date(t);if(!Number.isFinite(e.getTime()))return"";const n=e.getFullYear(),o=String(e.getMonth()+1).padStart(2,"0"),a=String(e.getDate()).padStart(2,"0");return`${n}-${o}-${a}`}function Q(t){if(!t)return null;const e=String(t).trim(),n=e.split("-");if(n.length!==3)return null;const o=Number(n[0]),a=Number(n[1])-1,r=Number(n[2]);if(!Number.isFinite(o)||!Number.isFinite(a)||!Number.isFinite(r))return null;const s=new Date(Date.UTC(o,a,r));return Number.isFinite(s.getTime())&&f(s)===e?s:null}function X(t){if(!t)return"";const e=t.year,n=String(t.month).padStart(2,"0"),o=String(t.day).padStart(2,"0");return!e||!n||!o?"":`${e}-${n}-${o}`}function tt(t,{timeZone:e,offsetMinutes:n,now:o}={}){const a=o instanceof Date&&Number.isFinite(o.getTime())?o:new Date,r=J({timeZone:e,offsetMinutes:n,date:a});if(!r){const u=F(new Date);return{from:u,to:u}}const s=X(r),i=Q(s);if(!i)return{from:s,to:s};if(t==="day")return{from:s,to:s};if(t==="24h"){const u=new Date(i);return u.setUTCDate(u.getUTCDate()-1),{from:f(u),to:s}}if(t==="week"){const u=new Date(i);return u.setUTCDate(u.getUTCDate()-6),{from:f(u),to:s}}if(t==="month"){const u=new Date(i);return u.setUTCDate(u.getUTCDate()-29),{from:f(u),to:s}}if(t==="total"){const u=new Date(Date.UTC(i.getUTCFullYear(),i.getUTCMonth()-23,1));return{from:f(u),to:s}}return tt("week")}function P(t){if(typeof t!="string")return null;const e=/^(\d{4})-(\d{2})-(\d{2})$/.exec(t.trim());if(!e)return null;const n=Number(e[1]),o=Number(e[2])-1,a=Number(e[3]),r=new Date(Date.UTC(n,o,a));return Number.isFinite(r.getTime())&&f(r)===t.trim()?r:null}function v(t,e){return new Date(Date.UTC(t.getUTCFullYear(),t.getUTCMonth(),t.getUTCDate()+e))}function et(t,e){const n=Date.UTC(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate())-Date.UTC(t.getUTCFullYear(),t.getUTCMonth(),t.getUTCDate());return Math.floor(n/864e5)}function R(t,e){if(!Array.isArray(t)||t.length===0)return 0;const n=t.length,o=(n-1)*e,a=Math.floor(o),r=o-a,s=t[a]??t[n-1],i=t[Math.min(n-1,a+1)]??t[n-1];return Math.round(s+(i-s)*r)}function nt(t){return t<=0?0:t>=4?4:t}function q({weeks:t=52,now:e,weekStartsOn:n="sun"}={}){const o=e instanceof Date&&Number.isFinite(e.getTime())?e:new Date,a=F(o),r=P(a)||new Date(Date.UTC(o.getFullYear(),o.getMonth(),o.getDate())),s=n==="mon"?1:0,i=r.getUTCDay(),u=v(r,-((i-s+7)%7)),_=v(u,-7*(Math.max(1,t)-1));return{from:f(_),to:a}}function ot({dailyRows:t,weeks:e=52,to:n,weekStartsOn:o="sun"}={}){const a=P(n)||new Date(Date.UTC(new Date().getFullYear(),new Date().getMonth(),new Date().getDate())),{from:r}=q({weeks:e,now:a,weekStartsOn:o}),s=P(r)||v(a,-(e*7-1)),i=(()=>{const h=(s.getUTCDay()-(o==="mon"?1:0)+7)%7;return v(s,-h)})(),u=new Map;for(const l of Array.isArray(t)?t:[]){const d=typeof l?.day=="string"?l.day:null;if(!d)continue;const h=A(l?.conversation_count)??0,y=A(l?.billable_total_tokens??l?.total_tokens)??0;u.set(d,{value:Math.max(0,h),tokens:Math.max(0,y),models:l?.models||null})}const _=et(i,a)+1,m=Math.ceil(_/7),c=[];for(let l=0;l<_;l++){const d=v(i,l),h=f(d),y=u.get(h),b=y?y.value:0;b>0&&c.push(b)}c.sort((l,d)=>l-d);const p=R(c,.5),C=R(c,.75),w=R(c,.9);function S(l){return!l||l<=0?0:l<=p?1:l<=C?2:l<=w?3:4}const g=[];for(let l=0;l<m;l++){const d=[];for(let h=0;h<7;h++){const y=l*7+h,b=v(i,y);if(b.getTime()>a.getTime()){d.push(null);continue}const T=f(b),D=u.get(T),M=D?D.value:0,N=D?D.tokens:0,x=D?D.models:null;d.push({day:T,value:M,total_tokens:N,level:nt(S(M)),models:x})}g.push(d)}const k=g.length>e?g.slice(g.length-e):g;return{from:r,to:f(a),weeks:k,thresholds:{t1:p,t2:C,t3:w}}}function at({dailyRows:t,to:e}={}){const n=P(e)||new Date(Date.UTC(new Date().getFullYear(),new Date().getMonth(),new Date().getDate())),o=new Map;for(const r of Array.isArray(t)?t:[]){const s=typeof r?.day=="string"?r.day:null;if(!s)continue;const i=A(r?.conversation_count??r?.billable_total_tokens??r?.total_tokens)??0;o.set(s,Math.max(0,i))}let a=0;for(let r=0;r<370;r++){const s=f(v(n,-r));if((o.get(s)??0)>0){a+=1;continue}break}return a}const Y={},H="tokentracker",st=["tokentracker/tokentracker","spacedriveapp/spacedrive","acme/alpha","acme/beta","neo/nebula","signal/flux","matrix/terminal","orbit/atlas","lumen/core","delta/horizon"];function rt(){if(typeof window<"u"){const t=new URLSearchParams(window.location.search),e=String(t.get("mock")||"").toLowerCase();if(e==="0"||e==="false"||e==="off"||e==="no")return!1;if(e==="1"||e==="true"||e==="on"||e==="yes")return!0}if(typeof import.meta<"u"&&Y){const t="".toLowerCase();if(t==="1"||t==="true")return!0}return!1}function it(){if(typeof import.meta<"u"&&Y){const t="".trim();if(t)return t;const e="".trim();if(e)return e}if(typeof window<"u"){const t=new URLSearchParams(window.location.search),e=String(t.get("mock_now")||"").trim();if(e)return e;const n=String(t.get("mock_today")||"").trim();if(n)return n}return""}function ut(t){if(!t)return null;const e=String(t).trim();if(!e)return null;if(/^\d{4}-\d{2}-\d{2}$/.test(e)){const[o,a,r]=e.split("-").map(Number);if(!Number.isFinite(o)||!Number.isFinite(a)||!Number.isFinite(r))return null;const s=new Date(o,a-1,r,12,0,0);return Number.isFinite(s.getTime())?s:null}const n=new Date(e);return Number.isFinite(n.getTime())?n:null}function Mt(){return rt()?ut(it()):null}function ct(){if(typeof import.meta<"u"&&Y){const t="".trim();if(t)return t}if(typeof window<"u"){const t=new URLSearchParams(window.location.search),e=String(t.get("mock_seed")||"").trim();if(e)return e}return H}function lt(){if(typeof import.meta<"u"&&Y){const t="".trim(),e=Number(t);if(Number.isFinite(e)&&e>0)return Math.floor(e)}if(typeof window<"u"){const t=new URLSearchParams(window.location.search),e=String(t.get("mock_missing")||"").trim(),n=Number(e);if(Number.isFinite(n)&&n>0)return Math.floor(n)}return 0}function I(t){return(t==null?ct():String(t)).trim()||H}function L(t){let e=2166136261;for(let n=0;n<t.length;n+=1)e^=t.charCodeAt(n),e=Math.imul(e,16777619);return e>>>0}function U(t){if(typeof t!="string")return null;const e=/^(\d{4})-(\d{2})-(\d{2})$/.exec(t.trim());if(!e)return null;const n=Number(e[1]),o=Number(e[2])-1,a=Number(e[3]),r=new Date(Date.UTC(n,o,a));return Number.isFinite(r.getTime())&&f(r)===t.trim()?r:null}function K(t,e){return new Date(Date.UTC(t.getUTCFullYear(),t.getUTCMonth(),t.getUTCDate()+e))}function W(t,e){return new Date(Date.UTC(t.getUTCFullYear(),t.getUTCMonth()+e,1))}function _t(t){return`${t.getUTCFullYear()}-${String(t.getUTCMonth()+1).padStart(2,"0")}`}function $({from:t,to:e,seed:n}){const o=U(F(new Date))||new Date,a=U(t)||o,r=U(e)||a,s=[],i=I(n),u=Math.floor((Date.UTC(r.getUTCFullYear(),r.getUTCMonth(),r.getUTCDate())-Date.UTC(a.getUTCFullYear(),a.getUTCMonth(),a.getUTCDate()))/864e5)+1;for(let _=0;_<u;_+=1){const m=K(a,_),c=f(m),p=L(`${i}:${c}`),C=p%1e3/1e3,w=.6+.4*Math.sin(_/6*Math.PI*.5),S=m.getUTCDay()===0||m.getUTCDay()===6?.65:1,g=18e3+Math.round(12e3*C),k=Math.max(0,Math.round(g*w*S)),l=Math.round(k*.46),d=Math.round(k*.34),h=Math.round(k*.14),y=Math.max(0,k-l-d-h),b={};if(k>0){const T=["gpt-4o","claude-3.5-sonnet","gemini-1.5-pro","deepseek-coder"],D=1+p%3;let M=k;for(let N=0;N<D;N++){const x=T[(p+N)%T.length];if(N===D-1)b[x]=M;else{const O=Math.round(M*(.3+.4*((p+N*17)%10)/10));b[x]=O,M-=O}}}s.push({day:c,total_tokens:k,billable_total_tokens:k,input_tokens:l,output_tokens:d,cached_input_tokens:h,reasoning_output_tokens:y,conversation_count:1+p%5,models:b})}return s}function mt({day:t,seed:e}){const n=U(t)||U(F(new Date))||new Date,o=f(n),a=I(e),r=Array.from({length:48},(i,u)=>{const _=Math.floor(u/2),m=u%2===0?0:30,c=L(`${a}:${o}:${_}:${m}`),p=c%1e3/1e3,C=(_+m/60)/24,w=.4+.6*Math.sin(C*Math.PI*2),S=900+Math.round(700*p),g=Math.max(0,Math.round(S*w)),k=Math.round(g*.46),l=Math.round(g*.34),d=Math.round(g*.14),h=Math.max(0,g-k-l-d),y={};if(g>0){const b=["gpt-4o","claude-3.5-sonnet","gemini-1.5-pro","deepseek-coder"],T=1+c%2;let D=g;for(let M=0;M<T;M++){const N=b[(c+M)%b.length];if(M===T-1)y[N]=D;else{const x=Math.round(D*(.3+.4*((c+M*17)%10)/10));y[N]=x,D-=x}}}return{hour:`${o}T${String(_).padStart(2,"0")}:${String(m).padStart(2,"0")}:00`,total_tokens:g,billable_total_tokens:g,input_tokens:k,output_tokens:l,cached_input_tokens:d,reasoning_output_tokens:h,models:y}}),s=Math.max(0,Math.min(48,lt()));if(s>0){const i=Date.now(),u=r.map((c,p)=>{const C=Date.parse(c.hour);return Number.isFinite(C)&&C<=i?{index:p,ts:C}:null}).filter(Boolean),_=Math.max(0,u.length-s),m=u.slice(_);for(const c of m){const p=r[c.index];p&&(r[c.index]={...p,missing:!0})}}return r}function dt({months:t=24,to:e,seed:n}){const o=U(e)||U(F(new Date))||new Date,a=new Date(Date.UTC(o.getUTCFullYear(),o.getUTCMonth(),1)),r=I(n),s=[];for(let i=t-1;i>=0;i-=1){const u=W(a,-i),_=_t(u),m=L(`${r}:${_}`),c=m%1e3/1e3,p=.7+.3*Math.sin(s.length/6*Math.PI*.5),C=36e4+Math.round(2e5*c),w=Math.max(0,Math.round(C*p)),S=Math.round(w*.46),g=Math.round(w*.34),k=Math.round(w*.14),l=Math.max(0,w-S-g-k),d={};if(w>0){const h=["gpt-4o","claude-3.5-sonnet","gemini-1.5-pro","deepseek-coder"],y=1+m%3;let b=w;for(let T=0;T<y;T++){const D=h[(m+T)%h.length];if(T===y-1)d[D]=b;else{const M=Math.round(b*(.3+.4*((m+T*17)%10)/10));d[D]=M,b-=M}}}s.push({month:_,total_tokens:w,billable_total_tokens:w,input_tokens:S,output_tokens:g,cached_input_tokens:k,reasoning_output_tokens:l,models:d})}return s}function B(t){return t.reduce((e,n)=>(e.total_tokens+=Number(n.total_tokens||0),e.billable_total_tokens+=Number(n.billable_total_tokens||n.total_tokens||0),e.input_tokens+=Number(n.input_tokens||0),e.output_tokens+=Number(n.output_tokens||0),e.cached_input_tokens+=Number(n.cached_input_tokens||0),e.reasoning_output_tokens+=Number(n.reasoning_output_tokens||0),e.conversation_count+=Number(n.conversation_count||0),e),{total_tokens:0,billable_total_tokens:0,input_tokens:0,output_tokens:0,cached_input_tokens:0,reasoning_output_tokens:0,conversation_count:0})}function ft(t,e=1.75){const n=Number(t||0);return!Number.isFinite(n)||n<=0?"0.000000":(n*e/1e6).toFixed(6)}function j(t,e){const n=Number.isFinite(e)?e:0;return{total_tokens:Math.max(0,Math.round(t.total_tokens*n)),billable_total_tokens:Math.max(0,Math.round((t.billable_total_tokens??t.total_tokens)*n)),input_tokens:Math.max(0,Math.round(t.input_tokens*n)),output_tokens:Math.max(0,Math.round(t.output_tokens*n)),cached_input_tokens:Math.max(0,Math.round(t.cached_input_tokens*n)),reasoning_output_tokens:Math.max(0,Math.round(t.reasoning_output_tokens*n))}}function z(t){return{...t,total_cost_usd:ft(t.total_tokens)}}function Tt({from:t,to:e,seed:n}={}){const o=$({from:t,to:e,seed:n});return{from:t,to:e,data:o}}function Ut({day:t,seed:e}={}){const n=U(t)||new Date,o=f(n),a=mt({day:o,seed:e});return{day:o,data:a}}function Ct({months:t=24,to:e,seed:n}={}){const o=U(e)||U(F(new Date))||new Date,a=f(o),r=dt({months:t,to:a,seed:n}),s=W(new Date(Date.UTC(o.getUTCFullYear(),o.getUTCMonth(),1)),-(t-1));return{from:f(s),to:a,months:t,data:r}}function Nt({from:t,to:e,seed:n,rolling:o=!0}={}){const a=$({from:t,to:e,seed:n}),r=B(a),s=z(r),i=o?gt({to:e,seed:n}):null;return{from:t,to:e,days:a.length,totals:s,...i?{rolling:i}:{}}}function gt({to:t,seed:e}={}){const n=U(t)||U(F(new Date))||new Date,o=f(n),a=f(K(n,-6)),r=f(K(n,-29)),s=$({from:a,to:o,seed:e}),i=$({from:r,to:o,seed:e});return{last_7d:E({rows:s,from:a,to:o}),last_30d:E({rows:i,from:r,to:o})}}function E({rows:t,from:e,to:n}={}){const o=B(t||[]),a=(t||[]).filter(s=>Number(s.billable_total_tokens??s.total_tokens)>0).length,r=a>0?Math.floor(o.billable_total_tokens/a):0;return{from:e,to:n,totals:{billable_total_tokens:o.billable_total_tokens,conversation_count:o.conversation_count},active_days:a,avg_per_active_day:r}}function Ft({seed:t,limit:e=3}={}){const n=I(t),o=st.map((a,r)=>{const i=12e4+L(`${n}:${a}`)%9e5,u=r%4*4200,_=Math.max(0,i+u),m=Math.max(0,Math.round(_*.96));return{project_key:a,project_ref:`https://github.com/${a}`,total_tokens:String(_),billable_total_tokens:String(m)}}).sort((a,r)=>Number(r.billable_total_tokens)-Number(a.billable_total_tokens)).slice(0,Math.max(1,Math.min(10,Math.floor(Number(e)||3))));return{generated_at:new Date().toISOString(),entries:o}}function St({weeks:t=52,to:e,weekStartsOn:n="sun",seed:o}={}){const a=q({weeks:t,now:U(e)||U(F(new Date))||new Date,weekStartsOn:n}),r=$({from:a.from,to:a.to,seed:o});return{...ot({dailyRows:r,weeks:t,to:a.to,weekStartsOn:n}),week_starts_on:n,active_days:r.filter(i=>Number(i.billable_total_tokens??i.total_tokens)>0).length,streak_days:at({dailyRows:r,to:a.to})}}function xt({from:t,to:e,seed:n}={}){const o=$({from:t,to:e,seed:n}),a=B(o),s=[{source:"codex",weight:.7,models:[{model:"gpt-5.2-codex",model_id:"gpt-5.2-codex",weight:.2},{model:"unknown",model_id:"unknown",weight:.8}]},{source:"claude",weight:.2,models:[{model:"claude-3.5",model_id:"claude-3.5",weight:.3},{model:"unknown",model_id:"unknown",weight:.7}]},{source:"every-code",weight:.1,models:[{model:"unknown",model_id:"unknown",weight:1}]}].map(i=>{const u=j(a,i.weight),_=i.models.map(m=>{const c=j(u,m.weight);return{model:m.model,model_id:m.model_id||m.model,totals:z(c)}});return{source:i.source,totals:z(u),models:_}});return{from:t,to:e,days:o.length,sources:s,pricing:{model:"mock",pricing_mode:"add",source:"mock",effective_from:F(new Date),rates_per_million_usd:{input:"1.750000",cached_input:"0.175000",output:"14.000000",reasoning_output:"14.000000"}}}}function vt(){return{fetched_at:"2025-12-31T12:00:00Z",claude:{configured:!0,plan_label:"Max",five_hour:{utilization:92},seven_day:{utilization:68}},codex:{configured:!0,plan_label:"Pro",primary_window:{used_percent:34},secondary_window:{used_percent:78}},cursor:{configured:!1},gemini:{configured:!1},kimi:{configured:!1},zai:{configured:!1},kiro:{configured:!1},copilot:{configured:!1},antigravity:{configured:!1}}}function $t({from:t,to:e,source:n="claude"}={}){if(n==="codex")return{from:t,to:e,source:"codex",scope:"supported",totals:{input_tokens:142e4,cached_input_tokens:588e4,cache_creation_input_tokens:92e4,output_tokens:256e4,reasoning_output_tokens:61e4,total_tokens:1078e4},session_count:188,message_count:5436,tool_calls_breakdown:{categories:[{name:"Execution",calls:742,totals:{input_tokens:32e4,cached_input_tokens:104e4,cache_creation_input_tokens:11e4,output_tokens:46e4,reasoning_output_tokens:12e4,total_tokens:193e4},tools:[{name:"exec_command",calls:510,totals:{input_tokens:25e4,cached_input_tokens:82e4,cache_creation_input_tokens:8e4,output_tokens:37e4,reasoning_output_tokens:9e4,total_tokens:152e4}},{name:"write_stdin",calls:232,totals:{input_tokens:7e4,cached_input_tokens:22e4,cache_creation_input_tokens:3e4,output_tokens:9e4,reasoning_output_tokens:3e4,total_tokens:41e4}}]},{name:"Browser",calls:406,totals:{input_tokens:21e4,cached_input_tokens:88e4,cache_creation_input_tokens:9e4,output_tokens:42e4,reasoning_output_tokens:1e5,total_tokens:17e5},tools:[{name:"take_snapshot",calls:180,totals:{input_tokens:1e5,cached_input_tokens:4e5,cache_creation_input_tokens:4e4,output_tokens:18e4,reasoning_output_tokens:4e4,total_tokens:76e4}},{name:"click",calls:126,totals:{input_tokens:6e4,cached_input_tokens:25e4,cache_creation_input_tokens:3e4,output_tokens:12e4,reasoning_output_tokens:35e3,total_tokens:495e3}},{name:"evaluate_script",calls:100,totals:{input_tokens:5e4,cached_input_tokens:23e4,cache_creation_input_tokens:2e4,output_tokens:12e4,reasoning_output_tokens:25e3,total_tokens:445e3}}]}],tools_total:363e4,privacy:{includes_inputs:!1,note:"Aggregated tool names only; no tool arguments or outputs are included."}},exec_command_breakdown:{by_type:[{name:"test",calls:168,failures:14,duration_ms:482e3,max_duration_ms:18400,output_chars:45e4,output_lines:8600,totals:{input_tokens:9e4,cached_input_tokens:32e4,output_tokens:16e4,reasoning_output_tokens:42e3,total_tokens:612e3}},{name:"build",calls:116,failures:9,duration_ms:392e3,max_duration_ms:22100,output_chars:31e4,output_lines:5200,totals:{input_tokens:7e4,cached_input_tokens:24e4,output_tokens:13e4,reasoning_output_tokens:3e4,total_tokens:47e4}}],by_exit:[{name:"completed:0",calls:438,failures:0,duration_ms:104e4,max_duration_ms:22100,output_chars:83e4,output_lines:13100,totals:{input_tokens:22e4,cached_input_tokens:78e4,output_tokens:35e4,reasoning_output_tokens:82e3,total_tokens:1432e3}},{name:"completed:1",calls:72,failures:72,duration_ms:155e3,max_duration_ms:11800,output_chars:12e4,output_lines:2300,totals:{input_tokens:3e4,cached_input_tokens:11e4,output_tokens:55e3,reasoning_output_tokens:8e3,total_tokens:195e3}}]}};if(n!=="claude")return{from:t,to:e,source:n,scope:"unsupported",totals:{input_tokens:0,cached_input_tokens:0,cache_creation_input_tokens:0,output_tokens:0,reasoning_output_tokens:0,total_tokens:0},categories:["system_prefix","conversation_history","user_input","tool_calls","subagents","reasoning","assistant_response"].map(s=>({key:s,totals:{input_tokens:0,cached_input_tokens:0,cache_creation_input_tokens:0,output_tokens:0,reasoning_output_tokens:0,total_tokens:0},percent:0})),session_count:0,message_count:0};const o=[{key:"system_prefix",total:12896539},{key:"conversation_history",total:320207200},{key:"user_input",total:998025},{key:"tool_calls",total:4808739},{key:"subagents",total:1022828},{key:"reasoning",total:11073118},{key:"assistant_response",total:1289174}],a=o.reduce((s,i)=>s+i.total,0),r=o.map(({key:s,total:i})=>({key:s,totals:{input_tokens:s==="user_input"?i:0,cached_input_tokens:s==="conversation_history"?Math.round(i*.95):0,cache_creation_input_tokens:s==="system_prefix"?i:s==="conversation_history"?Math.round(i*.05):0,output_tokens:["tool_calls","subagents","reasoning","assistant_response"].includes(s)?i:0,reasoning_output_tokens:s==="reasoning"?i:0,total_tokens:i},percent:Number((i/a*100).toFixed(2))}));return{from:t,to:e,source:"claude",scope:"supported",totals:{input_tokens:998025,cached_input_tokens:Math.round(320207200*.95),cache_creation_input_tokens:12896539+Math.round(320207200*.05),output_tokens:18193859,reasoning_output_tokens:11073118,total_tokens:a},categories:r,session_count:312,message_count:12046}}export{yt as a,ot as b,at as c,wt as d,kt as e,f,q as g,ht as h,rt as i,Mt as j,tt as k,bt as l,Dt as m,Tt as n,Nt as o,xt as p,Ut as q,Ft as r,Ct as s,St as t,vt as u,$t as v};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{r as o,$ as I,a0 as h,a1 as C,a2 as R}from"./main-
|
|
1
|
+
import{r as o,$ as I,a0 as h,a1 as C,a2 as R}from"./main-DRcFJLLD.js";const d=["claude","codex","cursor","gemini","kimi","zai","kiro","copilot","antigravity"],Y={claude:"Claude",codex:"Codex",cursor:"Cursor",gemini:"Gemini",kimi:"Kimi",zai:"Z.AI",kiro:"Kiro",copilot:"GitHub Copilot",antigravity:"Antigravity"},P={claude:"/brand-logos/claude-code.svg",codex:"/brand-logos/codex.svg",cursor:"/brand-logos/cursor.svg",gemini:"/brand-logos/gemini.svg",kimi:"/brand-logos/kimi.svg",zai:null,kiro:"/brand-logos/kiro.svg",copilot:"/brand-logos/copilot.svg",antigravity:"/brand-logos/antigravity.svg"},w="tt.limits.providerOrder",m="tt.limits.providerVisibility",S="tt.limits.displayMode",p="limitsDisplayMode",f=Object.freeze({USED:"used",REMAINING:"remaining"}),E=new Set(Object.values(f));function O(){if(typeof window>"u")return[...d];try{const n=window.localStorage.getItem(w);if(!n)return[...d];const a=JSON.parse(n);if(!Array.isArray(a))return[...d];const r=a.filter(c=>d.includes(c));for(const c of d)r.includes(c)||r.push(c);return r}catch{return[...d]}}function D(){const n=Object.fromEntries(d.map(a=>[a,!0]));if(typeof window>"u")return n;try{const a=window.localStorage.getItem(m);if(!a)return n;const r=JSON.parse(a);if(!r||typeof r!="object")return n;const c={...n};for(const u of d)typeof r[u]=="boolean"&&(c[u]=r[u]);return c}catch{return n}}function v(){if(typeof window>"u")return f.USED;try{const n=window.localStorage.getItem(S);return E.has(n)?n:f.USED}catch{return f.USED}}function T(){const[n,a]=o.useState(O),[r,c]=o.useState(D),[u,l]=o.useState(v),g=o.useCallback(t=>{E.has(t)&&(l(t),I()&&h(p,t))},[]);o.useEffect(()=>{if(!(typeof window>"u"))try{window.localStorage.setItem(w,JSON.stringify(n))}catch{}},[n]),o.useEffect(()=>{if(!(typeof window>"u"))try{window.localStorage.setItem(m,JSON.stringify(r))}catch{}},[r]),o.useEffect(()=>{if(!(typeof window>"u"))try{window.localStorage.setItem(S,u)}catch{}},[u]),o.useEffect(()=>{if(!I())return;const t=C(e=>{const i=e?.[p];E.has(i)&&l(i)});return R(),t},[]),o.useEffect(()=>{if(typeof window>"u")return;const t=e=>{e.key===w&&a(O()),e.key===m&&c(D()),e.key===S&&l(v())};return window.addEventListener("storage",t),()=>window.removeEventListener("storage",t)},[]);const k=o.useCallback(t=>{c(e=>({...e,[t]:!e[t]}))},[]),x=o.useCallback(t=>{a(e=>{const i=e.indexOf(t);if(i<=0)return e;const s=[...e];return[s[i-1],s[i]]=[s[i],s[i-1]],s})},[]),M=o.useCallback(t=>{a(e=>{const i=e.indexOf(t);if(i<0||i>=e.length-1)return e;const s=[...e];return[s[i],s[i+1]]=[s[i+1],s[i]],s})},[]),_=o.useCallback((t,e)=>{t!==e&&a(i=>{const s=i.indexOf(t),b=i.indexOf(e);if(s<0||b<0)return i;const y=[...i],[N]=y.splice(s,1);return y.splice(b,0,N),y})},[]),L=o.useCallback(()=>{a([...d]),c(Object.fromEntries(d.map(t=>[t,!0]))),g(f.USED)},[g]),A=o.useMemo(()=>n.filter(t=>r[t]!==!1),[n,r]);return{order:n,visibility:r,displayMode:u,setDisplayMode:g,visibleOrdered:A,toggle:k,moveUp:x,moveDown:M,moveToward:_,reset:L}}export{f as L,P as a,Y as b,T as u};
|
package/dashboard/dist/assets/{use-native-settings-D7Nj1dXR.js → use-native-settings-C1uhDKdL.js}
RENAMED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{j as a,x,r as n,b0 as g,b1 as m,a1 as b,a2 as u,a0 as f,a$ as h}from"./main-
|
|
1
|
+
import{j as a,x,r as n,b0 as g,b1 as m,a1 as b,a2 as u,a0 as f,a$ as h}from"./main-DRcFJLLD.js";import{C as y}from"./Card-BwY_Qv6N.js";function k({checked:s,onChange:t,disabled:e,ariaLabel:i}){return a.jsx("button",{type:"button",role:"switch","aria-checked":s,"aria-label":i,onClick:t,disabled:e,className:x("relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-oai-brand-500 disabled:opacity-50 disabled:cursor-not-allowed",s?"bg-oai-brand-500":"bg-oai-gray-300 dark:bg-oai-gray-700"),children:a.jsx("span",{className:x("inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",s?"translate-x-[18px]":"translate-x-[3px]")})})}function p({label:s,hint:t,control:e}){return a.jsxs("div",{className:"flex items-center justify-between gap-4 py-3",children:[a.jsxs("div",{className:"min-w-0 flex-1",children:[a.jsx("div",{className:"text-sm text-oai-gray-900 dark:text-oai-gray-200",children:s}),t?a.jsx("div",{className:"mt-0.5 text-xs text-oai-gray-500 dark:text-oai-gray-400",children:t}):null]}),a.jsx("div",{className:"shrink-0",children:e})]})}function N({title:s,subtitle:t,action:e,children:i}){return a.jsxs(y,{children:[a.jsxs("div",{className:"mb-3 flex items-start justify-between gap-4",children:[a.jsxs("div",{className:"min-w-0 flex-1",children:[a.jsx("h2",{className:"text-sm font-medium text-oai-gray-500 dark:text-oai-gray-300 uppercase tracking-wide",children:s}),t?a.jsx("p",{className:"mt-1 truncate text-xs text-oai-gray-500 dark:text-oai-gray-400",children:t}):null]}),e?a.jsx("div",{className:"shrink-0",children:e}):null]}),a.jsx("div",{className:"-mb-3 divide-y divide-oai-gray-200/60 dark:divide-oai-gray-800/60",children:i})]})}function w({options:s,value:t,onChange:e}){return a.jsx("div",{className:"inline-flex items-center rounded-lg border border-oai-gray-200 bg-oai-gray-50 p-0.5 dark:border-oai-gray-800 dark:bg-oai-gray-900",children:s.map(({value:i,label:d,Icon:l})=>{const r=t===i;return a.jsxs("button",{type:"button",onClick:()=>e(i),"aria-pressed":r,className:x("inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors",r?"bg-white text-oai-black shadow-sm dark:bg-oai-gray-800 dark:text-white":"text-oai-gray-500 hover:text-oai-black dark:text-oai-gray-400 dark:hover:text-white"),children:[l?a.jsx(l,{className:"h-3.5 w-3.5","aria-hidden":!0}):null,a.jsx("span",{children:d})]},i)})})}function S(){const[s,t]=n.useState(null),e=g()&&m();n.useEffect(()=>{if(!e)return;const r=b(o=>t(o));return u(),r},[e]);const i=n.useCallback((r,o)=>{e&&(t(c=>c&&{...c,[r]:o}),f(r,o))},[e]),d=n.useCallback(r=>{e&&h(r)},[e]),l=n.useCallback(()=>{e&&u()},[e]);return{available:e,settings:s,setSetting:i,runAction:d,refresh:l}}export{N as S,k as T,p as a,w as b,S as u};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{c as e,r as t,aZ as c,a_ as r,C as a}from"./main-
|
|
1
|
+
import{c as e,r as t,aZ as c,a_ as r,C as a}from"./main-DRcFJLLD.js";const o=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M12 16v-4",key:"1dtifu"}],["path",{d:"M12 8h.01",key:"e9boi3"}]],C=e("info",o),n=Object.freeze({currency:a,rate:1,symbol:"$",rates:{...r},rateSource:"default",rateFetchedAt:null,setCurrency:()=>{}});function y(){return t.useContext(c)??n}export{C as I,y as u};
|
|
@@ -225,7 +225,7 @@
|
|
|
225
225
|
]
|
|
226
226
|
}
|
|
227
227
|
</script>
|
|
228
|
-
<script type="module" crossorigin src="/assets/main-
|
|
228
|
+
<script type="module" crossorigin src="/assets/main-DRcFJLLD.js"></script>
|
|
229
229
|
<link rel="stylesheet" crossorigin href="/assets/main-ZrWkoMlr.css">
|
|
230
230
|
</head>
|
|
231
231
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ipv9/tokentracker-cli",
|
|
3
|
-
"version": "0.39.
|
|
3
|
+
"version": "0.39.41",
|
|
4
4
|
"description": "Local-first token and cost dashboard for 22 AI coding tools, including Claude Code, Codex, Cursor, Gemini, Kiro, OpenCode, OpenClaw, Copilot, Antigravity, Zed, and Goose.",
|
|
5
5
|
"main": "src/cli.js",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"access": "public"
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
|
-
"ci:local": "npm run dashboard:build && npm --prefix dashboard run test && npm test && npm run validate:copy && npm run validate:ui-hardcode && npm run validate:guardrails && npm run docs:openwiki:check && node --test test/architecture-guardrails.test.js",
|
|
26
|
+
"ci:local": "npm run dashboard:build && npm --prefix dashboard run test && npm test && npm run validate:copy && npm run validate:ui-hardcode && npm run validate:guardrails && npm run validate:curated-expiry && npm run docs:openwiki:check && node --test test/architecture-guardrails.test.js",
|
|
27
27
|
"copy:pull": "node scripts/copy-sync.cjs pull",
|
|
28
28
|
"copy:push": "node scripts/copy-sync.cjs push",
|
|
29
29
|
"dashboard:build": "npm --prefix dashboard run build",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"prepublishOnly": "node scripts/build-pricing-seed.cjs",
|
|
43
43
|
"test": "node --test test/*.test.js",
|
|
44
44
|
"validate:copy": "node scripts/validate-copy-registry.cjs",
|
|
45
|
+
"validate:curated-expiry": "node scripts/validate-curated-expiry.cjs",
|
|
45
46
|
"validate:guardrails": "node scripts/validate-architecture-guardrails.cjs",
|
|
46
47
|
"validate:retros": "node scripts/validate-retros.cjs",
|
|
47
48
|
"validate:ui-hardcode": "node scripts/ops/validate-ui-hardcode.cjs"
|
package/src/commands/serve.js
CHANGED
|
@@ -4,7 +4,7 @@ const path = require("node:path");
|
|
|
4
4
|
const fssync = require("node:fs");
|
|
5
5
|
|
|
6
6
|
const { resolveTrackerPaths } = require("../lib/tracker-paths");
|
|
7
|
-
const { createLocalApiHandler, resolveQueuePath } = require("../lib/local-api");
|
|
7
|
+
const { createLocalApiHandler, resolveQueuePath, isLoopbackHostname } = require("../lib/local-api");
|
|
8
8
|
const {
|
|
9
9
|
buildServeDataPreflightMessage,
|
|
10
10
|
summarizeQueueData,
|
|
@@ -18,6 +18,122 @@ const DEFAULT_MAX_PORT_ATTEMPTS = 20;
|
|
|
18
18
|
const NPM_PACKAGE_NAME = "@ipv9/tokentracker-cli";
|
|
19
19
|
const LOCAL_BIND_HOST = "127.0.0.1";
|
|
20
20
|
|
|
21
|
+
// Anti-DNS-rebinding guard. Binding the socket to loopback does not make the
|
|
22
|
+
// Host header trustworthy: under DNS rebinding a browser sends
|
|
23
|
+
// `Host: attacker.example:<port>` to 127.0.0.1 and treats the response as
|
|
24
|
+
// same-origin, so CORS never applies. Mutations are already gated on a loopback
|
|
25
|
+
// Origin; without this check every GET /functions/* endpoint — full spend
|
|
26
|
+
// history, model mix, project names — is readable by any page the victim
|
|
27
|
+
// happens to have open. Issue #88.
|
|
28
|
+
//
|
|
29
|
+
// Only the hostname matters: the port is chosen at runtime by
|
|
30
|
+
// listenOnAvailablePort, so pinning it here would be fragile without adding any
|
|
31
|
+
// protection. An absent Host (HTTP/1.0, some local probes) is allowed — the
|
|
32
|
+
// socket is already loopback-bound, and there is no rebinding vector without a
|
|
33
|
+
// browser sending a name.
|
|
34
|
+
function isAllowedHostHeader(hostHeader) {
|
|
35
|
+
if (hostHeader == null || hostHeader === "") return true;
|
|
36
|
+
// Userinfo has no meaning in a Host header, so anything carrying it is
|
|
37
|
+
// malformed. Tested on the RAW value: an EMPTY userinfo ("@localhost",
|
|
38
|
+
// ":@localhost") parses to a falsy url.username, so checking the parsed
|
|
39
|
+
// fields alone lets exactly the malformed forms through.
|
|
40
|
+
if (hostHeader.includes("@")) return false;
|
|
41
|
+
// Same reason as the request target: URL parsing strips these bytes, so they
|
|
42
|
+
// can smuggle a different authority past a check on the raw string.
|
|
43
|
+
if (/[\u0000-\u0020\u007f]/.test(hostHeader)) return false;
|
|
44
|
+
try {
|
|
45
|
+
const url = new URL(`http://${hostHeader}`);
|
|
46
|
+
// `localhost.` is the valid fully-qualified spelling of localhost. WHATWG
|
|
47
|
+
// URL canonicalises the trailing dot away for IPv4 literals but not for
|
|
48
|
+
// names, so strip it here or the FQDN form gets a spurious 403.
|
|
49
|
+
return isLoopbackHostname(url.hostname.replace(/\.$/, ""));
|
|
50
|
+
} catch (_e) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// An origin server is not a proxy: a request-target must be origin-form
|
|
56
|
+
// ("/path") or asterisk-form ("*"). Absolute-form ("GET http://evil/x") carries
|
|
57
|
+
// its own authority, which WOULD win over the Host header when the URL is
|
|
58
|
+
// parsed for routing — so the Host allowlist and the routing would disagree
|
|
59
|
+
// about which site this request is for. Refuse instead of picking a winner.
|
|
60
|
+
function isAllowedRequestTarget(target) {
|
|
61
|
+
if (typeof target !== "string" || target === "") return false;
|
|
62
|
+
if (target === "*") return true;
|
|
63
|
+
if (!target.startsWith("/")) return false;
|
|
64
|
+
// "//evil/x" is a network-path reference, and WHATWG URL treats a backslash
|
|
65
|
+
// like a slash, so "/\evil/x" behaves the same way: both make the parsed URL
|
|
66
|
+
// adopt a foreign authority even though the Host header said loopback.
|
|
67
|
+
// Routing only reads url.pathname today, but leaving the parsed URL pointing
|
|
68
|
+
// at someone else's origin is the same guard-vs-parser disagreement that
|
|
69
|
+
// absolute-form creates.
|
|
70
|
+
if (target.startsWith("//") || target.startsWith("/\\")) return false;
|
|
71
|
+
// WHATWG URL strips tab, LF and CR from its input BEFORE parsing, so
|
|
72
|
+
// "/<tab>//evil/x" becomes "//evil/x" and adopts a foreign authority after
|
|
73
|
+
// passing the prefix checks above. Node's own parser rejects these bytes in a
|
|
74
|
+
// request-target with a 400 before the handler runs, so this is not reachable
|
|
75
|
+
// over the wire today — but a guard that only holds because a different layer
|
|
76
|
+
// is strict is the disagreement this function exists to prevent.
|
|
77
|
+
if (/[\u0000-\u0020\u007f]/.test(target)) return false;
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Extracted from cmdServe so the wiring — not just the predicate — is testable:
|
|
82
|
+
// a guard that exists but is never reached is the failure mode this is guarding
|
|
83
|
+
// against in the first place.
|
|
84
|
+
function createRequestHandler({ handleApi, dashboardDir }) {
|
|
85
|
+
return async function handleRequest(req, res) {
|
|
86
|
+
try {
|
|
87
|
+
// Reject rebound hostnames before anything reads the request. Issue #88.
|
|
88
|
+
if (!isAllowedHostHeader(req.headers.host)) {
|
|
89
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
90
|
+
res.end("Forbidden: TokenTracker only serves loopback hosts.\n");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!isAllowedRequestTarget(req.url)) {
|
|
95
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
96
|
+
res.end("Bad Request: absolute-form request targets are not served.\n");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
101
|
+
|
|
102
|
+
// CORS preflight
|
|
103
|
+
if (req.method === "OPTIONS") {
|
|
104
|
+
res.writeHead(204, {
|
|
105
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
106
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
107
|
+
});
|
|
108
|
+
res.end();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// API routes
|
|
113
|
+
if (
|
|
114
|
+
url.pathname.startsWith("/functions/")
|
|
115
|
+
|| url.pathname.startsWith("/api/")
|
|
116
|
+
|| url.pathname.startsWith("/proxy/")
|
|
117
|
+
) {
|
|
118
|
+
const handled = await handleApi(req, res, url);
|
|
119
|
+
if (handled) return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Static files
|
|
123
|
+
const served = await serveStaticFile(dashboardDir, url.pathname, res);
|
|
124
|
+
if (served) return;
|
|
125
|
+
|
|
126
|
+
// SPA fallback
|
|
127
|
+
await serveStaticFile(dashboardDir, "/index.html", res);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
if (!res.headersSent) {
|
|
130
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
131
|
+
res.end("Internal Server Error");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
21
137
|
function buildPortInUseHint(port) {
|
|
22
138
|
return `Port ${port} is unavailable. Try: npx ${NPM_PACKAGE_NAME} serve --port ${port + 1}\n`;
|
|
23
139
|
}
|
|
@@ -110,43 +226,7 @@ async function cmdServe(argv) {
|
|
|
110
226
|
// 3. Create handler
|
|
111
227
|
const handleApi = createLocalApiHandler({ queuePath });
|
|
112
228
|
|
|
113
|
-
const server = http.createServer(
|
|
114
|
-
try {
|
|
115
|
-
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
116
|
-
|
|
117
|
-
// CORS preflight
|
|
118
|
-
if (req.method === "OPTIONS") {
|
|
119
|
-
res.writeHead(204, {
|
|
120
|
-
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
121
|
-
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
122
|
-
});
|
|
123
|
-
res.end();
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// API routes
|
|
128
|
-
if (
|
|
129
|
-
url.pathname.startsWith("/functions/")
|
|
130
|
-
|| url.pathname.startsWith("/api/")
|
|
131
|
-
|| url.pathname.startsWith("/proxy/")
|
|
132
|
-
) {
|
|
133
|
-
const handled = await handleApi(req, res, url);
|
|
134
|
-
if (handled) return;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Static files
|
|
138
|
-
const served = await serveStaticFile(dashboardDir, url.pathname, res);
|
|
139
|
-
if (served) return;
|
|
140
|
-
|
|
141
|
-
// SPA fallback
|
|
142
|
-
await serveStaticFile(dashboardDir, "/index.html", res);
|
|
143
|
-
} catch (e) {
|
|
144
|
-
if (!res.headersSent) {
|
|
145
|
-
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
146
|
-
res.end("Internal Server Error");
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
});
|
|
229
|
+
const server = http.createServer(createRequestHandler({ handleApi, dashboardDir }));
|
|
150
230
|
|
|
151
231
|
// 4. Listen. Default startup follows README behavior and picks the next
|
|
152
232
|
// available port; an explicit --port/PORT remains strict.
|
|
@@ -316,6 +396,9 @@ module.exports = {
|
|
|
316
396
|
NPM_PACKAGE_NAME,
|
|
317
397
|
LOCAL_BIND_HOST,
|
|
318
398
|
isPortUnavailableError,
|
|
399
|
+
isAllowedHostHeader,
|
|
400
|
+
isAllowedRequestTarget,
|
|
401
|
+
createRequestHandler,
|
|
319
402
|
listenOnAvailablePort,
|
|
320
403
|
getLocalServerUrl,
|
|
321
404
|
parseArgs,
|
package/src/lib/fs.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const fs = require("node:fs/promises");
|
|
2
|
+
const os = require("node:os");
|
|
2
3
|
const path = require("node:path");
|
|
3
4
|
|
|
4
5
|
async function ensureDir(p) {
|
|
@@ -47,37 +48,157 @@ async function chmod600IfPossible(filePath) {
|
|
|
47
48
|
} catch (_e) {}
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
// The holder heartbeats the lock's mtime, so "stale" now means "the holder
|
|
52
|
+
// died", not "the holder is slow". That lets the threshold be generous: the old
|
|
53
|
+
// 5-minute window silently stole the lock from any sync that ran longer than
|
|
54
|
+
// one local-sync tick (full-corpus rebuilds and migration reparses do — see the
|
|
55
|
+
// post-mortem at src/commands/sync.js:74-123), letting two writers interleave
|
|
56
|
+
// appends into queue.jsonl. A torn line is skipped by the reader, and a skipped
|
|
57
|
+
// retraction row is a permanent silent overcount. Issue #89.
|
|
58
|
+
const LOCK_STALE_MS = 30 * 60 * 1000; // 30 minutes
|
|
59
|
+
const LOCK_HEARTBEAT_MS = 30 * 1000; // touch mtime every 30s while held
|
|
60
|
+
const MAX_LOCK_ATTEMPTS = 3;
|
|
51
61
|
|
|
52
|
-
|
|
62
|
+
// mkdir is atomic and exclusive, which makes it a usable mutex on every
|
|
63
|
+
// filesystem we care about. Held only for the few syscalls of a takeover.
|
|
64
|
+
const TAKEOVER_ABANDONED_MS = 60 * 1000;
|
|
65
|
+
|
|
66
|
+
function takeoverMutexPath(lockPath) {
|
|
67
|
+
return `${lockPath}.takeover`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function acquireTakeoverMutex(lockPath) {
|
|
71
|
+
const mutexPath = takeoverMutexPath(lockPath);
|
|
53
72
|
try {
|
|
54
|
-
|
|
55
|
-
return
|
|
56
|
-
async release() {
|
|
57
|
-
await handle.close().catch(() => {});
|
|
58
|
-
await fs.unlink(lockPath).catch(() => {});
|
|
59
|
-
},
|
|
60
|
-
};
|
|
73
|
+
await fs.mkdir(mutexPath);
|
|
74
|
+
return true;
|
|
61
75
|
} catch (e) {
|
|
62
|
-
if (e
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
} catch (_statErr) {
|
|
71
|
-
// Lock file disappeared between checks, retry
|
|
72
|
-
return openLock(lockPath, { quietIfLocked });
|
|
73
|
-
}
|
|
74
|
-
if (!quietIfLocked) {
|
|
75
|
-
process.stdout.write("Another sync is already running.\n");
|
|
76
|
-
}
|
|
77
|
-
return null;
|
|
76
|
+
if (!e || e.code !== "EEXIST") return false;
|
|
77
|
+
}
|
|
78
|
+
// A process that died mid-takeover would otherwise block every future
|
|
79
|
+
// takeover forever. The window is milliseconds, so anything this old is dead.
|
|
80
|
+
try {
|
|
81
|
+
const stat = await fs.stat(mutexPath);
|
|
82
|
+
if (Date.now() - stat.mtimeMs > TAKEOVER_ABANDONED_MS) {
|
|
83
|
+
await fs.rmdir(mutexPath).catch(() => {});
|
|
78
84
|
}
|
|
85
|
+
} catch (_e) {
|
|
86
|
+
// Vanished under us — the caller retries either way.
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function readLockOwner(lockPath) {
|
|
92
|
+
try {
|
|
93
|
+
const raw = await fs.readFile(lockPath, "utf8");
|
|
94
|
+
const parsed = JSON.parse(raw);
|
|
95
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
96
|
+
} catch (_e) {
|
|
97
|
+
// Empty, truncated, or pre-upgrade lock file — fall back to the mtime rule.
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isProcessAlive(pid) {
|
|
103
|
+
try {
|
|
104
|
+
process.kill(pid, 0);
|
|
105
|
+
return true;
|
|
106
|
+
} catch (e) {
|
|
107
|
+
// EPERM means the process exists but belongs to someone else.
|
|
108
|
+
return Boolean(e && e.code === "EPERM");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// A lock whose owner process is gone is stale immediately — no need to wait out
|
|
113
|
+
// the full window. Only trust the pid when the lock was taken on this host, and
|
|
114
|
+
// never when it is our own pid (a recycled pid would look alive forever).
|
|
115
|
+
function isOwnerGone(owner) {
|
|
116
|
+
if (!owner || typeof owner.pid !== "number" || !Number.isInteger(owner.pid)) return false;
|
|
117
|
+
if (owner.host !== os.hostname()) return false;
|
|
118
|
+
if (owner.pid === process.pid) return false;
|
|
119
|
+
return !isProcessAlive(owner.pid);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Throws EEXIST when the lock is already held — that is the caller's signal to
|
|
123
|
+
// decide whether the existing lock is stale.
|
|
124
|
+
async function createHeldLock(lockPath, heartbeatMs) {
|
|
125
|
+
const handle = await fs.open(lockPath, "wx");
|
|
126
|
+
const owner = { pid: process.pid, host: os.hostname(), startedAt: new Date().toISOString() };
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await handle.writeFile(`${JSON.stringify(owner, null, 2)}\n`, { encoding: "utf8" });
|
|
130
|
+
} catch (e) {
|
|
131
|
+
// A write failure would otherwise leave a leaked fd plus an empty lock file
|
|
132
|
+
// that nothing releases — blocking every sync until the stale window elapses.
|
|
133
|
+
await handle.close().catch(() => {});
|
|
134
|
+
await fs.unlink(lockPath).catch(() => {});
|
|
79
135
|
throw e;
|
|
80
136
|
}
|
|
137
|
+
|
|
138
|
+
// unref'd so a held lock can never keep the CLI process alive on its own.
|
|
139
|
+
const heartbeat = setInterval(() => {
|
|
140
|
+
const now = new Date();
|
|
141
|
+
fs.utimes(lockPath, now, now).catch(() => {});
|
|
142
|
+
}, heartbeatMs);
|
|
143
|
+
if (typeof heartbeat.unref === "function") heartbeat.unref();
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
owner,
|
|
147
|
+
async release() {
|
|
148
|
+
clearInterval(heartbeat);
|
|
149
|
+
await handle.close().catch(() => {});
|
|
150
|
+
await fs.unlink(lockPath).catch(() => {});
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Removing a stale lock is a check-then-act, and the caller's check ran outside
|
|
156
|
+
// any mutual exclusion: by now another waiter may already have reclaimed it and
|
|
157
|
+
// be holding a *fresh* lock. Deleting (or renaming) the path blind would drop
|
|
158
|
+
// that winner's lock and let two syncs run at once. The mkdir mutex makes the
|
|
159
|
+
// re-check and the delete atomic with respect to other waiters.
|
|
160
|
+
async function reclaimStaleLock(lockPath) {
|
|
161
|
+
if (!(await acquireTakeoverMutex(lockPath))) return;
|
|
162
|
+
try {
|
|
163
|
+
const current = await fs.stat(lockPath).catch(() => null);
|
|
164
|
+
if (!current) return;
|
|
165
|
+
const currentOwner = await readLockOwner(lockPath);
|
|
166
|
+
const stillStale = Date.now() - current.mtimeMs > LOCK_STALE_MS || isOwnerGone(currentOwner);
|
|
167
|
+
if (stillStale) await fs.unlink(lockPath).catch(() => {});
|
|
168
|
+
} finally {
|
|
169
|
+
await fs.rmdir(takeoverMutexPath(lockPath)).catch(() => {});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function openLock(lockPath, { quietIfLocked, heartbeatMs = LOCK_HEARTBEAT_MS } = {}, attempt = 1) {
|
|
174
|
+
try {
|
|
175
|
+
return await createHeldLock(lockPath, heartbeatMs);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
if (!e || e.code !== "EEXIST") throw e;
|
|
178
|
+
|
|
179
|
+
const retry = () => openLock(lockPath, { quietIfLocked, heartbeatMs }, attempt + 1);
|
|
180
|
+
const giveUp = () => {
|
|
181
|
+
if (!quietIfLocked) process.stdout.write("Another sync is already running.\n");
|
|
182
|
+
return null;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
if (attempt >= MAX_LOCK_ATTEMPTS) return giveUp();
|
|
186
|
+
|
|
187
|
+
let stat;
|
|
188
|
+
try {
|
|
189
|
+
stat = await fs.stat(lockPath);
|
|
190
|
+
} catch (_statErr) {
|
|
191
|
+
return retry(); // Lock file disappeared between checks.
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const owner = await readLockOwner(lockPath);
|
|
195
|
+
const expired = Date.now() - stat.mtimeMs > LOCK_STALE_MS;
|
|
196
|
+
// A live holder heartbeats its lock, so a fresh mtime means "still working".
|
|
197
|
+
if (!expired && !isOwnerGone(owner)) return giveUp();
|
|
198
|
+
|
|
199
|
+
await reclaimStaleLock(lockPath);
|
|
200
|
+
return retry();
|
|
201
|
+
}
|
|
81
202
|
}
|
|
82
203
|
|
|
83
204
|
module.exports = {
|
|
@@ -88,4 +209,7 @@ module.exports = {
|
|
|
88
209
|
writeJson,
|
|
89
210
|
chmod600IfPossible,
|
|
90
211
|
openLock,
|
|
212
|
+
// Exported for tests.
|
|
213
|
+
LOCK_STALE_MS,
|
|
214
|
+
LOCK_HEARTBEAT_MS,
|
|
91
215
|
};
|
package/src/lib/local-api.js
CHANGED
|
@@ -30,6 +30,8 @@ const avatarProxyCache = new Map();
|
|
|
30
30
|
const {
|
|
31
31
|
MODEL_PRICING,
|
|
32
32
|
getModelPricing,
|
|
33
|
+
getModelPricingMeta,
|
|
34
|
+
getPricingDiagnostics,
|
|
33
35
|
computeRowCost,
|
|
34
36
|
ensurePricingLoaded,
|
|
35
37
|
} = require("./pricing");
|
|
@@ -1147,7 +1149,15 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1147
1149
|
model: m.model,
|
|
1148
1150
|
source: s.source,
|
|
1149
1151
|
});
|
|
1150
|
-
|
|
1152
|
+
// How the price was resolved, so the dashboard can say "unpriced"
|
|
1153
|
+
// or "matched by substring" instead of inferring it from a $0 cost
|
|
1154
|
+
// — a genuinely free model and an unknown one both cost $0.
|
|
1155
|
+
const { tier } = getModelPricingMeta(m.model, { source: s.source });
|
|
1156
|
+
return {
|
|
1157
|
+
...m,
|
|
1158
|
+
pricing_tier: tier,
|
|
1159
|
+
totals: { ...m.totals, total_cost_usd: cost.toFixed(6) },
|
|
1160
|
+
};
|
|
1151
1161
|
})
|
|
1152
1162
|
.sort((a, b) => b.totals.total_tokens - a.totals.total_tokens);
|
|
1153
1163
|
const sourceCost = s.models.reduce((sum, m) => sum + Number(m.totals.total_cost_usd), 0);
|
|
@@ -1157,7 +1167,13 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1157
1167
|
|
|
1158
1168
|
json(res, {
|
|
1159
1169
|
from, to, days: 0, scope, excluded_sources: excludedSources, sources,
|
|
1160
|
-
pricing: {
|
|
1170
|
+
pricing: {
|
|
1171
|
+
model: "per-model",
|
|
1172
|
+
pricing_mode: "per_token_type",
|
|
1173
|
+
source: "litellm",
|
|
1174
|
+
effective_from: new Date().toISOString().slice(0, 10),
|
|
1175
|
+
...getPricingDiagnostics(),
|
|
1176
|
+
},
|
|
1161
1177
|
});
|
|
1162
1178
|
return true;
|
|
1163
1179
|
}
|
|
@@ -1522,6 +1538,8 @@ module.exports = {
|
|
|
1522
1538
|
resolveQueuePath,
|
|
1523
1539
|
// Exported for cross-consumer tests (pricing + native contract lock).
|
|
1524
1540
|
MODEL_PRICING,
|
|
1541
|
+
// Shared with serve.js so the Host allowlist and the Origin allowlist agree.
|
|
1542
|
+
isLoopbackHostname,
|
|
1525
1543
|
getModelPricing,
|
|
1526
1544
|
computeRowCost,
|
|
1527
1545
|
ensurePricingLoaded,
|
|
@@ -2,8 +2,15 @@
|
|
|
2
2
|
"_meta": {
|
|
3
3
|
"note": "Curated price overrides. Always wins over LiteLLM. Two reasons to live here: (1) self-defined alias names that LiteLLM will never carry (kiro-*, hy3-*, composer-*, kimi-for-coding, free-tier OpenRouter routes); (2) prices we want to pin even if LiteLLM has the model (e.g. cache_write fields LiteLLM often omits). Units: USD per million tokens. Edit this file to override pricing without redeploying.",
|
|
4
4
|
"units": "usd_per_million_tokens",
|
|
5
|
-
"
|
|
6
|
-
"
|
|
5
|
+
"expiries_note": "Time-boxed pricing facts. Machine-checked by scripts/validate-curated-expiry.cjs (npm run validate:curated-expiry, part of ci:local): once expires_at has passed, the check FAILS until a human applies `action` and then removes or advances the entry. Free-text expiry notes are not allowed here — a date nobody checks is how deepseek-v4-pro stayed on a 75%-off promo price for 55 days past its cutover (issue #87).",
|
|
6
|
+
"expiries": [
|
|
7
|
+
{
|
|
8
|
+
"id": "sonnet5-intro-price",
|
|
9
|
+
"expires_at": "2026-08-31",
|
|
10
|
+
"what": "Sonnet 5 introductory pricing (2/10) reverts to the 3/15 sticker price.",
|
|
11
|
+
"action": "No curated entry to edit — the src pricing path auto-tracks LiteLLM. Re-vendor the bundled seed (npm run pricing:build-seed) so a cold start also prices Sonnet 5 correctly, then delete this entry. See issue #16."
|
|
12
|
+
}
|
|
13
|
+
]
|
|
7
14
|
},
|
|
8
15
|
"exact": {
|
|
9
16
|
"claude-fable-5": { "input": 10, "output": 50, "cache_read": 1, "cache_write": 12.5, "note": "Pinned from Anthropic public pricing until bundled LiteLLM seed carries Fable 5." },
|
|
@@ -20,7 +27,7 @@
|
|
|
20
27
|
"MiniMax-M2.7": { "input": 0.3, "output": 1.2, "cache_read": 0.06, "cache_write": 0.375 },
|
|
21
28
|
"MiniMax-M2.7-highspeed":{ "input": 0.6, "output": 2.4, "cache_read": 0.06, "cache_write": 0.375 },
|
|
22
29
|
"deepseek-v4-flash":{ "input": 0.14, "output": 0.28, "cache_read": 0.0028, "cache_write": 0.14 },
|
|
23
|
-
"deepseek-v4-pro": { "input":
|
|
30
|
+
"deepseek-v4-pro": { "input": 1.74, "output": 3.48, "cache_read": 0.0145, "cache_write": 1.74, "note": "Standard (post-promo) pricing. The 75%-off launch promo expired 2026-05-31; these rates applied 2026-07-25 (issue #87)." },
|
|
24
31
|
"deepseek-chat": { "input": 0.14, "output": 0.28, "cache_read": 0.0028, "cache_write": 0.14 },
|
|
25
32
|
"grok-build": { "input": 1.25, "output": 2.50, "cache_read": 0.20, "note": "Grok Build TUI estimate. Local telemetry currently exposes totalTokens without a stable prompt/output/cache split, so TokenTracker estimates input/output split until Grok exposes per-call usage details." },
|
|
26
33
|
"grok-4-0709": { "input": 3.00, "output": 15.00, "cache_read": 0.75 },
|