@rizal72/oc-tps-meter 0.0.0-stage → 0.0.1
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 +63 -2
- package/dist/gui-experimental.cjs +49 -0
- package/gui.tsx +655 -0
- package/metrics.ts +229 -0
- package/package.json +30 -5
- package/vite.config.ts +6 -0
package/README.md
CHANGED
|
@@ -1,3 +1,64 @@
|
|
|
1
|
-
#
|
|
1
|
+
# oc-tps-meter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Tokens per second, step by step: how fast each answer in the session came, in a panel of the desktop app.
|
|
4
|
+
A bar per step — the session scrolls sideways when it outgrows the panel — the model behind it,
|
|
5
|
+
and what the session spent in tokens.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
opencode plugin add @rizal72/oc-tps-meter
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or by hand, one line in `~/.config/opencode/opencode.jsonc`:
|
|
14
|
+
|
|
15
|
+
```jsonc
|
|
16
|
+
"plugins": ["@rizal72/oc-tps-meter"]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Update
|
|
20
|
+
|
|
21
|
+
V2 checks package plugins at startup and never applies the update, so a package only moves when you ask:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
opencode plugin check # which of the installed packages are behind
|
|
25
|
+
opencode plugin update # apply them, and let the server reload
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or install [`@rizal72/oc-plugin-updates`](https://www.npmjs.com/package/@rizal72/oc-plugin-updates): the
|
|
29
|
+
count appears in a pill above the composer, with the update one click away.
|
|
30
|
+
|
|
31
|
+
## GUI
|
|
32
|
+
|
|
33
|
+
A tab in the session's side panel: the `+` menu beside the tabs, the command palette, or `/tps`. It opens
|
|
34
|
+
360 px wide every time; drag it wider when a model name needs the room.
|
|
35
|
+
|
|
36
|
+
Top to bottom: the speed of the last step it could measure, with the model that produced it and the wait for
|
|
37
|
+
the first token; the histogram, one bar per step, newest on the right, a dashed line at the median and one at
|
|
38
|
+
the top of the drawing, where the tallest bar ends — and, when the peak is beyond the scale, a bright cap on
|
|
39
|
+
the bars that cross it — and a gap where a step could not be measured. Pointing at a bar turns the block at the
|
|
40
|
+
top into that step, its time in place of the label. Then a pair of lines per model with how many of its steps
|
|
41
|
+
were measured, its median and its peak; and the totals, cache included.
|
|
42
|
+
|
|
43
|
+
The chart scrolls sideways: two fingers on the trackpad, shift and the wheel, or the scrollbar, and the small
|
|
44
|
+
hint between the two times says which way there is more. A time carries its date once it is not today's, at
|
|
45
|
+
the ends of the axis and in the label alike. A bar turns red when that step waited more than ten seconds for
|
|
46
|
+
its first token — and the wait in the block above turns red too — because that wait, not the speed, is what
|
|
47
|
+
says you are queued. Bar heights follow the fastest step — only a step more than twice the 95th percentile is
|
|
48
|
+
cut at the top edge, and it keeps its real figure in the block above when it is pointed at — so one exceptional
|
|
49
|
+
step does not flatten the rest.
|
|
50
|
+
|
|
51
|
+
## What it leaves out
|
|
52
|
+
|
|
53
|
+
No live speed: a step counts once it has finished, so nothing moves while a reply is streaming. No subagents:
|
|
54
|
+
they run in their own session. No cost and no quota — the app's own header shows what the session cost, and
|
|
55
|
+
[`oc-budget`](../oc-budget/README.md) shows what is left to spend. No history: the session on screen is the
|
|
56
|
+
only thing it measures.
|
|
57
|
+
|
|
58
|
+
## How it works
|
|
59
|
+
|
|
60
|
+
It reads the session's own messages through the app every two seconds while the tab is open, and measures each
|
|
61
|
+
step between its first token and the end of the stream. Nothing is stored, nothing leaves the machine. The
|
|
62
|
+
measurement, its thresholds and the traps behind it: [`docs/oc-tps-meter.md`](../../docs/oc-tps-meter.md).
|
|
63
|
+
|
|
64
|
+
Requires OpenCode v2. TypeScript, no server half, no runtime dependencies.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:`Module`}});let e=require("solid-js/web"),t=require("@opencode/plugin/gui"),n=require("solid-js");var r=e=>e!==void 0&&e>1e4,i=e=>typeof e==`number`&&Number.isFinite(e)?e:0,a=e=>typeof e==`number`&&Number.isFinite(e)?e:void 0,o=e=>e.time?.completed!==void 0,s=e=>{let t=a(e.time?.firstToken);if(t!==void 0)return t;let n;for(let t of e.content??[]){if(t?.type!==`reasoning`)continue;let e=a(t.time?.created);e!==void 0&&(n===void 0||e<n)&&(n=e)}return n},c=e=>{let t=i(e.tokens?.output)+i(e.tokens?.reasoning),n=i(e.time?.created),r=s(e),o=a(e.time?.streamed),c=r!==void 0&&o!==void 0?o-r:void 0,l=c!==void 0&&c>=200&&t>=20,u=e.model?.providerID&&e.model?.id?e.model.providerID+`/`+e.model.id:``;return{id:e.id??``,created:n,tokens:t,tps:l&&c!==void 0?t/(c/1e3):void 0,ttft:r!==void 0&&n!==0?r-n:void 0,model:u,measured:l}},l=e=>{if(e.length===0)return;let t=[...e].sort((e,t)=>e-t),n=Math.floor(t.length/2);return t.length%2?t[n]:(t[n-1]+t[n])/2},u=e=>{if(e.length===0)return;let t=[...e].sort((e,t)=>e-t);return t[Math.max(0,Math.ceil(t.length*.95)-1)]},d=e=>{if(e.length===0)return;let t=Math.max(...e);return Math.min(t,2*(u(e)??t))},f=(e,t)=>{let n=t?e.findIndex(e=>e.id===t):-1;return n===-1?{messages:[...e],found:!1}:{messages:e.slice(0,n),found:!0}},ee=e=>{let t=new Map;for(let n of e){if(n.tps===void 0)continue;let e=t.get(n.model)??[];e.push(n.tps),t.set(n.model,e)}return[...t].map(([e,t])=>({model:e,steps:t.length,median:l(t),peak:Math.max(...t)})).sort((e,t)=>t.steps-e.steps||e.model.localeCompare(t.model))},te=(e,t,n)=>{if(e<=0)return{first:0,last:-1};let r=Math.max(0,Math.min(e-1,Math.floor(t/4)));return{first:r,last:Math.min(e-1,Math.max(r,Math.ceil((t+n)/4)-1))}},p=(e,t,n)=>t-n-e<=1,m=(e,t)=>t.user?p(t.scrollLeft,t.scrollWidth,t.clientWidth):e,ne=e=>{let t=e.filter(o),n=t.map(c).sort((e,t)=>e.created-t.created),r={input:0,output:0,reasoning:0,cacheRead:0,cacheWrite:0};for(let e of t)r.input+=i(e.tokens?.input),r.output+=i(e.tokens?.output),r.reasoning+=i(e.tokens?.reasoning),r.cacheRead+=i(e.tokens?.cache?.read),r.cacheWrite+=i(e.tokens?.cache?.write);let a=r.input+r.cacheRead+r.cacheWrite;a>0&&(r.cacheHit=r.cacheRead/a);let s=n.flatMap(e=>e.tps===void 0?[]:[e.tps]),u=n.filter(e=>e.measured);return{steps:n,totals:r,measured:u.length,median:l(s),peak:s.length?Math.max(...s):void 0,scale:d(s),last:u[u.length-1]}},h=(0,e.template)(`<div class=tpsm-total><span></span><span class=tpsm-amount>`),g=(0,e.template)(`<span class=tpsm-num>`),re=(0,e.template)(`<div class=tpsm-block><div class=tpsm-label></div><div class=tpsm-figure><span class=tpsm-unit> t/s</span></div><div class=tpsm-sub><span class=tpsm-smodel>`),ie=(0,e.template)(`<svg><line x1=0 stroke=currentColor stroke-width=1 stroke-dasharray="3 3"opacity=0.3></svg>`,!1,!0,!1),ae=(0,e.template)(`<svg><line x1=0 y1=0.5 y2=0.5 stroke=currentColor stroke-width=1 stroke-dasharray="3 3"opacity=0.3></svg>`,!1,!0,!1),oe=(0,e.template)(`<div class=tpsm-warn>the revert point is older than the messages read: undone steps may be counted`),se=(0,e.template)(`<div class=tpsm-block><div class=tpsm-chart><svg class=tpsm-hist height=120 role=img></svg></div><div class=tpsm-axis><span></span><span class=tpsm-pan></span><span class=tpsm-end></span></div><div class=tpsm-counts></div><div class=tpsm-models><div class=tpsm-label>by model`),ce=(0,e.template)(`<div class=tpsm-block><div class=tpsm-label>totals`),le=(0,e.template)(`<div class=tpsm>`),ue=(0,e.template)(`<div class=tpsm-block><div class=tpsm-label>stale</div><div class=tpsm-sub>read <!> ago, the last attempt failed: `),de=(0,e.template)(`<div class=tpsm-note>`),fe=(0,e.template)(`<div class=tpsm-note>The messages could not be read: `),pe=(0,e.template)(`<div class=tpsm-note>No assistant step yet.`),me=(0,e.template)(`<span>`),he=(0,e.template)(`<svg><rect fill=currentColor></svg>`,!1,!0,!1),ge=(0,e.template)(`<svg><rect y=0 height=2 fill=currentColor opacity=1></svg>`,!1,!0,!1),_=(0,e.template)(`<div class=tpsm-mrow><div class=tpsm-mname></div><div class=tpsm-mnum>`),_e=2e3,v=360,y=120,b=200,x=2e3,S=`
|
|
2
|
+
/* Muted small capitals for labels, one large figure, hairlines between blocks, no borders and no cards. */
|
|
3
|
+
.tpsm { display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: auto; font-size: 12px;
|
|
4
|
+
color: var(--v2-text-text-base); }
|
|
5
|
+
.tpsm-block { padding: 14px 12px; }
|
|
6
|
+
.tpsm-block + .tpsm-block { border-top: 1px solid color-mix(in srgb, var(--v2-text-text-base) 8%, transparent); }
|
|
7
|
+
.tpsm-label { color: var(--v2-text-text-faint); text-transform: uppercase; letter-spacing: .06em; font-size: 11px;
|
|
8
|
+
font-weight: 600; margin-bottom: 6px; font-variant-numeric: tabular-nums; }
|
|
9
|
+
.tpsm-figure { font-size: 28px; line-height: 1.15; font-weight: 700; font-variant-numeric: tabular-nums; }
|
|
10
|
+
/* The unit is a label, not part of the figure: bold digits, a quiet "t/s" beside them. */
|
|
11
|
+
.tpsm-unit { font-size: 14px; font-weight: 400; opacity: .7; }
|
|
12
|
+
/* One line of room kept even when a step carries neither model nor time, so the chart never jumps a row. */
|
|
13
|
+
.tpsm-sub { color: var(--v2-text-text-faint); margin-top: 2px; overflow: hidden; text-overflow: ellipsis;
|
|
14
|
+
white-space: nowrap; min-height: 1.4em; }
|
|
15
|
+
/* The chart pans sideways and nothing else: a step is a fixed 4 px, so a long session scrolls natively. */
|
|
16
|
+
.tpsm-chart { display: flex; overflow-x: auto; overflow-y: hidden; }
|
|
17
|
+
.tpsm-hist { display: block; flex: 0 0 auto; margin-left: auto; height: ${y}px;
|
|
18
|
+
color: var(--v2-text-text-base); }
|
|
19
|
+
.tpsm-axis { display: grid; grid-template-columns: 1fr auto 1fr; gap: 6px; align-items: baseline; margin-top: 4px;
|
|
20
|
+
color: var(--v2-text-text-faint); font-variant-numeric: tabular-nums; }
|
|
21
|
+
/* The way to the earlier steps, between the two dates: the only place it belongs, and silent when it all fits. */
|
|
22
|
+
.tpsm-pan { font-size: 10px; letter-spacing: .04em; opacity: .8; }
|
|
23
|
+
.tpsm-end { text-align: right; }
|
|
24
|
+
/* The model of the step being described: white, so a name keeps its own against the figure above it. */
|
|
25
|
+
.tpsm-smodel { color: var(--v2-text-text-base); }
|
|
26
|
+
/* A figure the line exists for — a median, a peak, a time to first token: white and bold, the words around
|
|
27
|
+
it keeping the faint they had. */
|
|
28
|
+
.tpsm-num { color: var(--v2-text-text-base); font-weight: 600; }
|
|
29
|
+
/* A slow step wears the theme's danger at the weight it had at 45% alpha — mixed with the surface, because the
|
|
30
|
+
bar's opacity is 1 — and the pointer raises a bar to the danger itself. The figure above wears that danger
|
|
31
|
+
unmixed: eleven pixels of text at the muted tone would not be readable, and the figure is what is read.
|
|
32
|
+
Kept after the num rule on purpose: same specificity, so the later one wins. */
|
|
33
|
+
.tpsm-slow { color: var(--v2-state-fg-danger); }
|
|
34
|
+
.tpsm-hot { fill: color-mix(in srgb, var(--v2-state-fg-danger) 45%, var(--v2-background-bg-base)); }
|
|
35
|
+
.tpsm-hot-on { fill: var(--v2-state-fg-danger); }
|
|
36
|
+
.tpsm-counts { margin-top: 6px; color: var(--v2-text-text-faint); line-height: 1.4; font-variant-numeric: tabular-nums; }
|
|
37
|
+
.tpsm-warn { margin-top: 4px; color: var(--v2-text-text-faint); line-height: 1.4; }
|
|
38
|
+
.tpsm-models { margin-top: 6px; }
|
|
39
|
+
/* One model per block, two lines read as one unit: the same rhythm as the block above, and the space that
|
|
40
|
+
tells two models apart on the pair's top only, never between a name and its numbers. */
|
|
41
|
+
.tpsm-mrow { line-height: 1.4; }
|
|
42
|
+
.tpsm-mrow + .tpsm-mrow { padding-top: 6px; }
|
|
43
|
+
.tpsm-mname { overflow-wrap: anywhere; }
|
|
44
|
+
.tpsm-mnum { color: var(--v2-text-text-faint); font-variant-numeric: tabular-nums; }
|
|
45
|
+
.tpsm-total { display: flex; justify-content: space-between; gap: 12px; line-height: 1.4; }
|
|
46
|
+
.tpsm-total > :first-child { color: var(--v2-text-text-faint); }
|
|
47
|
+
.tpsm-amount { font-variant-numeric: tabular-nums; }
|
|
48
|
+
.tpsm-note { padding: 12px; color: var(--v2-text-text-faint); }
|
|
49
|
+
`,C=e=>e>=100?String(Math.round(e)):e.toFixed(1),ve=e=>e.toLocaleString(),w=(e,t)=>new Date(e).toDateString()===new Date(t).toDateString(),T=(e,t)=>t?new Date(e).toLocaleString(void 0,{month:`numeric`,day:`numeric`,hour:`2-digit`,minute:`2-digit`}):new Date(e).toLocaleTimeString(void 0,{hour:`2-digit`,minute:`2-digit`}),ye=e=>e<6e4?Math.round(e/1e3)+`s`:Math.round(e/6e4)+`m`,be=(e,t)=>t?new Date(e).toLocaleString(void 0,{month:`numeric`,day:`numeric`,hour:`2-digit`,minute:`2-digit`,second:`2-digit`}):new Date(e).toLocaleTimeString(void 0,{hour:`2-digit`,minute:`2-digit`,second:`2-digit`}),xe=e=>e.map(e=>typeof e==`string`?e:e.figure).join(``),E=e=>e===void 0?`—`:C(e),Se=async e=>{let t=[],n;for(;;){let r=await e.server.client.message.list({sessionID:e.id,limit:b,...n?{cursor:n}:{order:`desc`}});t.push(...r.data);let i=r.cursor?.next??void 0;if(!i)return{messages:t.reverse(),truncated:!1};if(t.length>=x)return{messages:t.reverse(),truncated:!0};n=i}},Ce=async e=>{try{return(await e.server.client.session.get({sessionID:e.id})).revert?.messageID}catch(e){throw Error(`the revert point could not be read (`+(e instanceof Error?e.message:String(e))+`)`)}},D=t=>(()=>{var n=h(),r=n.firstChild,i=r.nextSibling;return(0,e.insert)(r,()=>t.label),(0,e.insert)(i,()=>t.value),n})(),O=t=>(0,e.createComponent)(n.For,{get each(){return t.pieces},children:t=>typeof t==`string`?t:(()=>{var n=g();return(0,e.insert)(n,()=>t.figure),n})()}),k=t.Plugin.define({setup(i){let a=()=>i.sessions.current(),o=()=>{let[o,s]=(0,n.createSignal)(void 0),[c,l]=(0,n.createSignal)(void 0),[u,d]=(0,n.createSignal)(!1),[h,g]=(0,n.createSignal)(Date.now()),[b,x]=(0,n.createSignal)(0),[S,k]=(0,n.createSignal)(0),[A,j]=(0,n.createSignal)(void 0),M,N=!0,P,F=0;(0,t.createKeyed)(()=>a()?.key,e=>{let t=!0,r=0;s(void 0),l(void 0),d(!0),j(void 0),k(0),N=!0;let i=async()=>{let n=a();if(!n||n.key!==e)return;let i=r+=1,o=()=>t&&i===r&&a()?.key===e;try{let[e,t]=await Promise.all([Se(n),Ce(n)]);if(!o())return;let r=f(e.messages,t),i=r.messages.filter(e=>e.type===`assistant`);F=performance.now()+150,s({readAt:Date.now(),summary:ne(i),truncated:e.truncated,revertLost:t!==void 0&&!r.found&&e.truncated}),l(void 0),requestAnimationFrame(()=>G())}catch(e){if(!o())return;l({at:Date.now(),error:e instanceof Error?e.message:String(e)})}finally{t&&i===r&&d(!1)}};i();let o=setInterval(()=>{g(Date.now()),i()},_e);(0,n.onCleanup)(()=>{t=!1,clearInterval(o)})},{otherwise:()=>{s(void 0),l(void 0),d(!1),j(void 0),k(0),N=!0}});let I=()=>o(),L=()=>o()?.summary,R=()=>{let e=o(),t=c();return e&&t&&t.at>e.readAt?t:void 0},z=()=>L()?.steps??[],we=()=>L()?.last,Te=()=>L()?.median,B=()=>L()?.scale??1,V=e=>Math.max(0,y-e/(B()||1)*y),Ee=e=>e>B(),H=()=>z().length*4,U=()=>Math.max(H(),b()),W=()=>Math.max(0,U()-H()),De=()=>te(z().length,S(),b());(0,n.onMount)(()=>{i.layout.side.width(`tps`)!==v&&i.layout.side.resize(`tps`,v)});let G=()=>{let e=M;if(!e)return;F=performance.now()+150;let t=Math.max(0,e.scrollWidth-e.clientWidth),n=N?t:Math.min(e.scrollLeft,t);n!==e.scrollLeft&&(P=n,e.scrollLeft=n),k(n)},Oe=()=>{let e=M;if(!e)return;if(k(e.scrollLeft),j(void 0),P!==void 0&&Math.abs(e.scrollLeft-P)<=1){P=void 0;return}let t=performance.now()>F;N=m(N,{user:t,scrollLeft:e.scrollLeft,scrollWidth:e.scrollWidth,clientWidth:e.clientWidth})},K,ke=e=>{M=e,K?.disconnect(),K=new ResizeObserver(()=>{x(e.clientWidth),requestAnimationFrame(G)}),K.observe(e),x(e.clientWidth),requestAnimationFrame(G)};(0,n.onCleanup)(()=>K?.disconnect());let Ae=e=>{let t=z();if(!t.length)return;let n=e.currentTarget.getBoundingClientRect().left,r=Math.floor((e.clientX-n-W())/4);j(Math.max(0,Math.min(t.length-1,r)))},q=()=>{let e=A();return e!==void 0&&e<z().length?e:void 0},J=()=>{let e=q();return e===void 0?we():z()[e]},je=()=>{let e=J();return q()!==void 0&&e?be(e.created,!w(e.created,h())):`last measured`},Me=()=>ee(z()),Y=()=>{let e=Te();return e===void 0?void 0:V(e)},Ne=()=>{let e=J();return e?.tps===void 0?`—`:C(e.tps)},X=()=>J()?.model??``,Pe=()=>{let e=J()?.ttft;if(e!==void 0)return e<1e3?[String(Math.round(e)),` ms`]:[(e/1e3).toFixed(1),` s`]},Z=()=>{let e=z(),{first:t,last:n}=De(),r=e[t]?.created,i=e[n]?.created;if(r===void 0||i===void 0)return[``,``];let a=!w(r,i),o=h();return[T(r,a||!w(r,o)),T(i,a||!w(i,o))]},Fe=()=>{if(H()<=b())return``;let e=p(S(),H(),b());return S()<=1?`scroll →`:e?`← scroll`:`← scroll →`},Q=()=>{let e=L();if(!e)return[];let t=[e.steps.length+` steps · `+e.measured+` measured · median `,{figure:E(e.median)},` t/s · peak `,{figure:E(e.peak)},` t/s`];return o()?.truncated&&t.push(` · truncated`),t},Ie=()=>xe(Q()),Le=e=>[(e.steps===1?`1 step`:e.steps+` steps`)+` · med `,{figure:E(e.median)},` t/s · peak `,{figure:E(e.peak)},` t/s`],$=e=>{let t=L()?.totals;return t?ve(e(t)):``},Re=()=>{let e=L()?.totals.cacheHit;return e===void 0?`—`:Math.round(e*100)+`%`};return(()=>{var t=le();return(0,e.insert)(t,(0,e.createComponent)(n.Show,{get when(){return R()},children:t=>(()=>{var n=ue(),r=n.firstChild.nextSibling,i=r.firstChild.nextSibling;return i.nextSibling,(0,e.insert)(r,()=>ye(h()-(I()?.readAt??h())),i),(0,e.insert)(r,()=>t().error,null),n})()}),null),(0,e.insert)(t,(0,e.createComponent)(n.Show,{get when(){return I()},get fallback(){return(0,e.createComponent)(n.Show,{get when(){return c()},get fallback(){return(()=>{var t=de();return(0,e.insert)(t,()=>u()?`reading this session's messages…`:`No session is routed.`),t})()},children:t=>(()=>{var n=fe();return n.firstChild,(0,e.insert)(n,()=>t().error,null),n})()})},get children(){return(0,e.createComponent)(n.Show,{get when(){return z().length>0},get fallback(){return pe()},get children(){return[(()=>{var t=re(),i=t.firstChild,a=i.nextSibling,o=a.firstChild,s=a.nextSibling,c=s.firstChild;return(0,e.insert)(i,je),(0,e.insert)(a,Ne,o),(0,e.insert)(c,X),(0,e.insert)(s,(0,e.createComponent)(n.Show,{get when(){return Pe()},children:t=>[(0,e.memo)(()=>X()?` · `:``),`TTFT`,` `,(()=>{var n=me();return(0,e.insert)(n,()=>t()[0]),(0,e.effect)(()=>(0,e.className)(n,r(J()?.ttft)?`tpsm-num tpsm-slow`:`tpsm-num`)),n})(),(0,e.memo)(()=>t()[1])]}),null),t})(),(()=>{var t=se(),i=t.firstChild,a=i.firstChild,s=i.nextSibling,c=s.firstChild,l=c.nextSibling,u=l.nextSibling,d=s.nextSibling,f=d.nextSibling;return f.firstChild,i.addEventListener(`scroll`,Oe),(0,e.use)(ke,i),a.addEventListener(`pointerleave`,()=>j(void 0)),a.$$pointermove=Ae,(0,e.insert)(a,(0,e.createComponent)(n.For,{get each(){return z()},children:(t,i)=>{let a=t.tps;return a===void 0?null:[(()=>{var n=he();return(0,e.setAttribute)(n,`width`,3),(0,e.effect)(o=>{var s=W()+i()*4,c=V(a),l=y-V(a),u=r(t.ttft)?i()===A()?`tpsm-hot tpsm-hot-on`:`tpsm-hot`:void 0,d=r(t.ttft)||i()===A()?1:i()===z().length-1?.9:.45;return s!==o.e&&(0,e.setAttribute)(n,`x`,o.e=s),c!==o.t&&(0,e.setAttribute)(n,`y`,o.t=c),l!==o.a&&(0,e.setAttribute)(n,`height`,o.a=l),u!==o.o&&(0,e.setAttribute)(n,`class`,o.o=u),d!==o.i&&(0,e.setAttribute)(n,`opacity`,o.i=d),o},{e:void 0,t:void 0,a:void 0,o:void 0,i:void 0}),n})(),(0,e.createComponent)(n.Show,{get when(){return Ee(a)},get children(){var t=ge();return(0,e.setAttribute)(t,`width`,3),(0,e.effect)(()=>(0,e.setAttribute)(t,`x`,W()+i()*4)),t}})]}}),null),(0,e.insert)(a,(0,e.createComponent)(n.Show,{get when(){return Y()!==void 0},get children(){var t=ie();return(0,e.effect)(n=>{var r=U(),i=Y(),a=Y();return r!==n.e&&(0,e.setAttribute)(t,`x2`,n.e=r),i!==n.t&&(0,e.setAttribute)(t,`y1`,n.t=i),a!==n.a&&(0,e.setAttribute)(t,`y2`,n.a=a),n},{e:void 0,t:void 0,a:void 0}),t}}),null),(0,e.insert)(a,(0,e.createComponent)(n.Show,{get when(){return L()?.scale},get children(){var t=ae();return(0,e.effect)(()=>(0,e.setAttribute)(t,`x2`,U())),t}}),null),(0,e.insert)(c,()=>Z()[0]),(0,e.insert)(l,Fe),(0,e.insert)(u,()=>Z()[1]),(0,e.insert)(d,(0,e.createComponent)(O,{get pieces(){return Q()}})),(0,e.insert)(t,(0,e.createComponent)(n.Show,{get when(){return o()?.revertLost},get children(){return oe()}}),f),(0,e.insert)(f,(0,e.createComponent)(n.For,{get each(){return Me()},children:t=>(()=>{var n=_(),r=n.firstChild,i=r.nextSibling;return(0,e.insert)(r,()=>t.model||`—`),(0,e.insert)(i,(0,e.createComponent)(O,{get pieces(){return Le(t)}})),n})()}),null),(0,e.effect)(t=>{var n=U(),r=Ie();return n!==t.e&&(0,e.setAttribute)(a,`width`,t.e=n),r!==t.t&&(0,e.setAttribute)(a,`aria-label`,t.t=r),t},{e:void 0,t:void 0}),t})(),(()=>{var t=ce();return t.firstChild,(0,e.insert)(t,(0,e.createComponent)(D,{label:`input`,get value(){return $(e=>e.input)}}),null),(0,e.insert)(t,(0,e.createComponent)(D,{label:`output`,get value(){return $(e=>e.output)}}),null),(0,e.insert)(t,(0,e.createComponent)(D,{label:`reasoning`,get value(){return $(e=>e.reasoning)}}),null),(0,e.insert)(t,(0,e.createComponent)(D,{label:`cache read`,get value(){return $(e=>e.cacheRead)}}),null),(0,e.insert)(t,(0,e.createComponent)(D,{label:`cache write`,get value(){return $(e=>e.cacheWrite)}}),null),(0,e.insert)(t,(0,e.createComponent)(D,{label:`cache hit`,get value(){return Re()}}),null),t})()]}})}}),null),t})()},s={id:`tps`,get title(){return`TPS`},closable:`hover`};i.add(t.Style,S),i.add(t.Panel,{id:`tps`,region:`side`,width:v,list:e=>e.open.includes(`tps`)?[s]:[],render:()=>(0,e.createComponent)(o,{})});let c=()=>{let e=a();e&&i.layout.toggle(`${i.id}:tps`,e)};i.add(t.Command,()=>({id:`open`,title:`TPS Meter`,description:`Decode speed of every closed step in this session`,keywords:`tps speed tokens decode throughput latency ttft`,section:`session`,slash:{name:`tps`},enabled:!!a(),run:c})),i.add(t.MenuItem,()=>{let e=a();return{menu:`session.panel`,id:`tps`,title:`TPS Meter`,icon:`window-analytics`,keybind:`${i.id}.open`,opened:!!e&&i.layout.state(`${i.id}:tps`,e)!==`closed`,run:c}})}});(0,e.delegateEvents)([`pointermove`]),exports.default=k;
|
package/gui.tsx
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
// oc-tps-meter — GUI half: a side panel drawing tokens per second for every closed step of the session on
|
|
2
|
+
// screen, as a histogram, with the figures around it. The metric is `metrics.ts`.
|
|
3
|
+
import { Command, MenuItem, Panel, Plugin, Style, createKeyed, type MountedSession, type PanelTab } from "@opencode/plugin/gui"
|
|
4
|
+
import { For, Show, createSignal, onCleanup, onMount } from "solid-js"
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
BAR_PX,
|
|
8
|
+
atRightEnd,
|
|
9
|
+
followAfter,
|
|
10
|
+
byModel,
|
|
11
|
+
isSlow,
|
|
12
|
+
revertCut,
|
|
13
|
+
STEP_PX,
|
|
14
|
+
summarise,
|
|
15
|
+
visibleRange,
|
|
16
|
+
type Step,
|
|
17
|
+
type StepMessage,
|
|
18
|
+
type ModelStats,
|
|
19
|
+
type Summary,
|
|
20
|
+
} from "./metrics.ts"
|
|
21
|
+
|
|
22
|
+
const REFRESH_MS = 2_000
|
|
23
|
+
/** The panel's width, re-applied at every open. */
|
|
24
|
+
const PANEL_WIDTH = 360
|
|
25
|
+
/** The drawing's height in pixels, where a bar's top is measured from the baseline of a 0-based scale. */
|
|
26
|
+
const HEIGHT = 120
|
|
27
|
+
/** What a read asks for in one page, and the most it will accumulate before saying so. */
|
|
28
|
+
const PAGE = 200
|
|
29
|
+
const MAX_MESSAGES = 2_000
|
|
30
|
+
|
|
31
|
+
const CSS = `
|
|
32
|
+
/* Muted small capitals for labels, one large figure, hairlines between blocks, no borders and no cards. */
|
|
33
|
+
.tpsm { display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: auto; font-size: 12px;
|
|
34
|
+
color: var(--v2-text-text-base); }
|
|
35
|
+
.tpsm-block { padding: 14px 12px; }
|
|
36
|
+
.tpsm-block + .tpsm-block { border-top: 1px solid color-mix(in srgb, var(--v2-text-text-base) 8%, transparent); }
|
|
37
|
+
.tpsm-label { color: var(--v2-text-text-faint); text-transform: uppercase; letter-spacing: .06em; font-size: 11px;
|
|
38
|
+
font-weight: 600; margin-bottom: 6px; font-variant-numeric: tabular-nums; }
|
|
39
|
+
.tpsm-figure { font-size: 28px; line-height: 1.15; font-weight: 700; font-variant-numeric: tabular-nums; }
|
|
40
|
+
/* The unit is a label, not part of the figure: bold digits, a quiet "t/s" beside them. */
|
|
41
|
+
.tpsm-unit { font-size: 14px; font-weight: 400; opacity: .7; }
|
|
42
|
+
/* One line of room kept even when a step carries neither model nor time, so the chart never jumps a row. */
|
|
43
|
+
.tpsm-sub { color: var(--v2-text-text-faint); margin-top: 2px; overflow: hidden; text-overflow: ellipsis;
|
|
44
|
+
white-space: nowrap; min-height: 1.4em; }
|
|
45
|
+
/* The chart pans sideways and nothing else: a step is a fixed 4 px, so a long session scrolls natively. */
|
|
46
|
+
.tpsm-chart { display: flex; overflow-x: auto; overflow-y: hidden; }
|
|
47
|
+
.tpsm-hist { display: block; flex: 0 0 auto; margin-left: auto; height: ${HEIGHT}px;
|
|
48
|
+
color: var(--v2-text-text-base); }
|
|
49
|
+
.tpsm-axis { display: grid; grid-template-columns: 1fr auto 1fr; gap: 6px; align-items: baseline; margin-top: 4px;
|
|
50
|
+
color: var(--v2-text-text-faint); font-variant-numeric: tabular-nums; }
|
|
51
|
+
/* The way to the earlier steps, between the two dates: the only place it belongs, and silent when it all fits. */
|
|
52
|
+
.tpsm-pan { font-size: 10px; letter-spacing: .04em; opacity: .8; }
|
|
53
|
+
.tpsm-end { text-align: right; }
|
|
54
|
+
/* The model of the step being described: white, so a name keeps its own against the figure above it. */
|
|
55
|
+
.tpsm-smodel { color: var(--v2-text-text-base); }
|
|
56
|
+
/* A figure the line exists for — a median, a peak, a time to first token: white and bold, the words around
|
|
57
|
+
it keeping the faint they had. */
|
|
58
|
+
.tpsm-num { color: var(--v2-text-text-base); font-weight: 600; }
|
|
59
|
+
/* A slow step wears the theme's danger at the weight it had at 45% alpha — mixed with the surface, because the
|
|
60
|
+
bar's opacity is 1 — and the pointer raises a bar to the danger itself. The figure above wears that danger
|
|
61
|
+
unmixed: eleven pixels of text at the muted tone would not be readable, and the figure is what is read.
|
|
62
|
+
Kept after the num rule on purpose: same specificity, so the later one wins. */
|
|
63
|
+
.tpsm-slow { color: var(--v2-state-fg-danger); }
|
|
64
|
+
.tpsm-hot { fill: color-mix(in srgb, var(--v2-state-fg-danger) 45%, var(--v2-background-bg-base)); }
|
|
65
|
+
.tpsm-hot-on { fill: var(--v2-state-fg-danger); }
|
|
66
|
+
.tpsm-counts { margin-top: 6px; color: var(--v2-text-text-faint); line-height: 1.4; font-variant-numeric: tabular-nums; }
|
|
67
|
+
.tpsm-warn { margin-top: 4px; color: var(--v2-text-text-faint); line-height: 1.4; }
|
|
68
|
+
.tpsm-models { margin-top: 6px; }
|
|
69
|
+
/* One model per block, two lines read as one unit: the same rhythm as the block above, and the space that
|
|
70
|
+
tells two models apart on the pair's top only, never between a name and its numbers. */
|
|
71
|
+
.tpsm-mrow { line-height: 1.4; }
|
|
72
|
+
.tpsm-mrow + .tpsm-mrow { padding-top: 6px; }
|
|
73
|
+
.tpsm-mname { overflow-wrap: anywhere; }
|
|
74
|
+
.tpsm-mnum { color: var(--v2-text-text-faint); font-variant-numeric: tabular-nums; }
|
|
75
|
+
.tpsm-total { display: flex; justify-content: space-between; gap: 12px; line-height: 1.4; }
|
|
76
|
+
.tpsm-total > :first-child { color: var(--v2-text-text-faint); }
|
|
77
|
+
.tpsm-amount { font-variant-numeric: tabular-nums; }
|
|
78
|
+
.tpsm-note { padding: 12px; color: var(--v2-text-text-faint); }
|
|
79
|
+
`
|
|
80
|
+
|
|
81
|
+
type Reading = {
|
|
82
|
+
readAt: number
|
|
83
|
+
summary: Summary
|
|
84
|
+
truncated: boolean
|
|
85
|
+
/** A revert was staged and its message was older than what the read reached. */
|
|
86
|
+
revertLost: boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type Failure = { at: number; error: string }
|
|
90
|
+
|
|
91
|
+
/** A speed that reads at a glance: whole numbers once they are three digits. */
|
|
92
|
+
const speed = (value: number) => (value >= 100 ? String(Math.round(value)) : value.toFixed(1))
|
|
93
|
+
|
|
94
|
+
const count = (value: number) => value.toLocaleString()
|
|
95
|
+
|
|
96
|
+
/** Whether two instants fall on the same local day: what decides if a time needs its date beside it. */
|
|
97
|
+
const sameDay = (a: number, b: number) => new Date(a).toDateString() === new Date(b).toDateString()
|
|
98
|
+
|
|
99
|
+
const clock = (at: number, withDay: boolean) =>
|
|
100
|
+
withDay
|
|
101
|
+
? new Date(at).toLocaleString(undefined, { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" })
|
|
102
|
+
: new Date(at).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
|
|
103
|
+
|
|
104
|
+
const age = (ms: number) => (ms < 60_000 ? Math.round(ms / 1000) + "s" : Math.round(ms / 60_000) + "m")
|
|
105
|
+
|
|
106
|
+
/** `HH:MM:SS`, the granularity one step's instant needs, behind its date when that is not today. */
|
|
107
|
+
const stamp = (at: number, withDay: boolean) =>
|
|
108
|
+
withDay
|
|
109
|
+
? new Date(at).toLocaleString(undefined, {
|
|
110
|
+
month: "numeric",
|
|
111
|
+
day: "numeric",
|
|
112
|
+
hour: "2-digit",
|
|
113
|
+
minute: "2-digit",
|
|
114
|
+
second: "2-digit",
|
|
115
|
+
})
|
|
116
|
+
: new Date(at).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" })
|
|
117
|
+
|
|
118
|
+
/** A line as the words around a `figure`: the words stay faint, a figure is drawn bright, because it is the
|
|
119
|
+
* number the line exists for. One shape for the median, the peak and the time to first token, so they read
|
|
120
|
+
* the same wherever they appear. */
|
|
121
|
+
type Piece = string | { figure: string }
|
|
122
|
+
|
|
123
|
+
const piecesText = (pieces: Piece[]) => pieces.map((piece) => (typeof piece === "string" ? piece : piece.figure)).join("")
|
|
124
|
+
|
|
125
|
+
/** A speed, or the dash a line shows where it has none. */
|
|
126
|
+
const tps = (value: number | undefined) => (value === undefined ? "—" : speed(value))
|
|
127
|
+
|
|
128
|
+
/** Every message of the session, assistant and otherwise, handed back oldest first: the revert cut has to find
|
|
129
|
+
* the message it points at, which is usually a user one. */
|
|
130
|
+
const readAll = async (session: MountedSession): Promise<{ messages: StepMessage[]; truncated: boolean }> => {
|
|
131
|
+
const messages: StepMessage[] = []
|
|
132
|
+
let cursor: string | undefined
|
|
133
|
+
for (;;) {
|
|
134
|
+
// The order belongs to the first request only: a cursor carries its own direction.
|
|
135
|
+
const page = await session.server.client.message.list({
|
|
136
|
+
sessionID: session.id,
|
|
137
|
+
limit: PAGE,
|
|
138
|
+
...(cursor ? { cursor } : { order: "desc" as const }),
|
|
139
|
+
})
|
|
140
|
+
messages.push(...page.data)
|
|
141
|
+
const next = page.cursor?.next ?? undefined
|
|
142
|
+
if (!next) return { messages: messages.reverse(), truncated: false }
|
|
143
|
+
if (messages.length >= MAX_MESSAGES) return { messages: messages.reverse(), truncated: true }
|
|
144
|
+
cursor = next
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The revert point, when the session carries a staged revert. A failure is not a blank panel: it is thrown so
|
|
149
|
+
* the read keeps the figures it has, and the note says this is why. */
|
|
150
|
+
const revertOf = async (session: MountedSession): Promise<string | undefined> => {
|
|
151
|
+
try {
|
|
152
|
+
const info = await session.server.client.session.get({ sessionID: session.id })
|
|
153
|
+
return info.revert?.messageID
|
|
154
|
+
} catch (error) {
|
|
155
|
+
throw new Error("the revert point could not be read (" + (error instanceof Error ? error.message : String(error)) + ")")
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const Total = (props: { label: string; value: string }) => (
|
|
160
|
+
<div class="tpsm-total">
|
|
161
|
+
<span>{props.label}</span>
|
|
162
|
+
<span class="tpsm-amount">{props.value}</span>
|
|
163
|
+
</div>
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
const Figures = (props: { pieces: Piece[] }) => (
|
|
167
|
+
<For each={props.pieces}>
|
|
168
|
+
{(piece) => (typeof piece === "string" ? piece : <span class="tpsm-num">{piece.figure}</span>)}
|
|
169
|
+
</For>
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
export default Plugin.define({
|
|
173
|
+
setup(ctx) {
|
|
174
|
+
const current = () => ctx.sessions.current()
|
|
175
|
+
|
|
176
|
+
const View = () => {
|
|
177
|
+
const [reading, setReading] = createSignal<Reading | undefined>(undefined)
|
|
178
|
+
const [failure, setFailure] = createSignal<Failure | undefined>(undefined)
|
|
179
|
+
const [loading, setLoading] = createSignal(false)
|
|
180
|
+
/** The instant the note about age is read against, bumped by the poll so it never freezes. */
|
|
181
|
+
const [now, setNow] = createSignal(Date.now())
|
|
182
|
+
const [viewport, setViewport] = createSignal(0)
|
|
183
|
+
const [scrolled, setScrolled] = createSignal(0)
|
|
184
|
+
const [hovered, setHovered] = createSignal<number | undefined>(undefined)
|
|
185
|
+
let box: HTMLDivElement | undefined
|
|
186
|
+
/** True while the window sits at the right end. Written by the user's own scrolling only, so a read
|
|
187
|
+
* that lands while they are reading back does not yank them forward. */
|
|
188
|
+
let follow = true
|
|
189
|
+
/** Where this plugin last scrolled to itself: its own event must not touch `follow`. */
|
|
190
|
+
let ownScroll: number | undefined
|
|
191
|
+
/** Until this instant every scroll event is the layout's doing — a clamp, a resize, our own settle —
|
|
192
|
+
* never the user's word on where they want to be. */
|
|
193
|
+
let programmaticUntil = 0
|
|
194
|
+
|
|
195
|
+
// Keyed by `session.key`: another session means another read, and it starts blank so no figure of
|
|
196
|
+
// the session before is ever drawn under this one's name. Each run owns its timer and its
|
|
197
|
+
// `live` flag, so a late reply for the key it replaced is dropped twice over.
|
|
198
|
+
createKeyed(
|
|
199
|
+
() => current()?.key,
|
|
200
|
+
(key: string) => {
|
|
201
|
+
let live = true
|
|
202
|
+
/** This key's reads, in the order they started: only the latest may write, errors included. */
|
|
203
|
+
let sequence = 0
|
|
204
|
+
setReading(undefined)
|
|
205
|
+
setFailure(undefined)
|
|
206
|
+
setLoading(true)
|
|
207
|
+
// A new session inherits no offset: the right end, no hover, and following back on.
|
|
208
|
+
setHovered(undefined)
|
|
209
|
+
setScrolled(0)
|
|
210
|
+
follow = true
|
|
211
|
+
|
|
212
|
+
const read = async () => {
|
|
213
|
+
const session = current()
|
|
214
|
+
if (!session || session.key !== key) return
|
|
215
|
+
const mine = (sequence += 1)
|
|
216
|
+
const latest = () => live && mine === sequence && current()?.key === key
|
|
217
|
+
try {
|
|
218
|
+
const [page, revert] = await Promise.all([readAll(session), revertOf(session)])
|
|
219
|
+
if (!latest()) return
|
|
220
|
+
const cut = revertCut(page.messages, revert)
|
|
221
|
+
// Assistant messages only are steps; the user and compaction ones existed for the cut.
|
|
222
|
+
const kept = cut.messages.filter((message) => message.type === "assistant")
|
|
223
|
+
// The content is about to change width: any scroll event until the layout settles is
|
|
224
|
+
// the browser's, not the user's.
|
|
225
|
+
programmaticUntil = performance.now() + 150
|
|
226
|
+
setReading({
|
|
227
|
+
readAt: Date.now(),
|
|
228
|
+
summary: summarise(kept),
|
|
229
|
+
truncated: page.truncated,
|
|
230
|
+
revertLost: revert !== undefined && !cut.found && page.truncated,
|
|
231
|
+
})
|
|
232
|
+
setFailure(undefined)
|
|
233
|
+
requestAnimationFrame(() => settle())
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (!latest()) return
|
|
236
|
+
setFailure({ at: Date.now(), error: error instanceof Error ? error.message : String(error) })
|
|
237
|
+
} finally {
|
|
238
|
+
if (live && mine === sequence) setLoading(false)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
void read()
|
|
242
|
+
// The app pushes no change event for message data, so the panel asks on a clock; the same tick
|
|
243
|
+
// moves the instant the stale age is read against.
|
|
244
|
+
const timer = setInterval(() => {
|
|
245
|
+
setNow(Date.now())
|
|
246
|
+
void read()
|
|
247
|
+
}, REFRESH_MS)
|
|
248
|
+
onCleanup(() => {
|
|
249
|
+
live = false
|
|
250
|
+
clearInterval(timer)
|
|
251
|
+
})
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
otherwise: () => {
|
|
255
|
+
setReading(undefined)
|
|
256
|
+
setFailure(undefined)
|
|
257
|
+
setLoading(false)
|
|
258
|
+
setHovered(undefined)
|
|
259
|
+
setScrolled(0)
|
|
260
|
+
follow = true
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
const value = () => reading()
|
|
266
|
+
const summary = () => reading()?.summary
|
|
267
|
+
/** A failed read after a good one: the figures stay, their age says they are old. */
|
|
268
|
+
const stale = () => {
|
|
269
|
+
const last = reading()
|
|
270
|
+
const missed = failure()
|
|
271
|
+
return last && missed && missed.at > last.readAt ? missed : undefined
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const steps = () => summary()?.steps ?? []
|
|
275
|
+
const lastStep = () => summary()?.last
|
|
276
|
+
const medianTps = () => summary()?.median
|
|
277
|
+
// The drawing stops at the peak, or at twice the 95th percentile when one step is beyond both: that
|
|
278
|
+
// step is cut at the top edge instead of flattening all the others. A short session's p95 is its peak.
|
|
279
|
+
const scale = () => summary()?.scale ?? 1
|
|
280
|
+
const y = (tps: number) => Math.max(0, HEIGHT - (tps / (scale() || 1)) * HEIGHT)
|
|
281
|
+
const clipped = (tps: number) => tps > scale()
|
|
282
|
+
|
|
283
|
+
// The chart is one bar per step in real pixels, so it can be wider than the panel and scrolls sideways.
|
|
284
|
+
const contentWidth = () => steps().length * STEP_PX
|
|
285
|
+
const chartWidth = () => Math.max(contentWidth(), viewport())
|
|
286
|
+
const chartLeft = () => Math.max(0, chartWidth() - contentWidth())
|
|
287
|
+
const range = () => visibleRange(steps().length, scrolled(), viewport())
|
|
288
|
+
|
|
289
|
+
// The width is re-applied once at mount, never reactively, so a drag is not fought.
|
|
290
|
+
onMount(() => {
|
|
291
|
+
if (ctx.layout.side.width("tps") !== PANEL_WIDTH) ctx.layout.side.resize("tps", PANEL_WIDTH)
|
|
292
|
+
})
|
|
293
|
+
/** After a render: follow the newest only when the window was already at the right end, and clamp an
|
|
294
|
+
* offset the content no longer has. */
|
|
295
|
+
const settle = () => {
|
|
296
|
+
const element = box
|
|
297
|
+
if (!element) return
|
|
298
|
+
programmaticUntil = performance.now() + 150
|
|
299
|
+
const end = Math.max(0, element.scrollWidth - element.clientWidth)
|
|
300
|
+
const target = follow ? end : Math.min(element.scrollLeft, end)
|
|
301
|
+
if (target !== element.scrollLeft) {
|
|
302
|
+
ownScroll = target
|
|
303
|
+
element.scrollLeft = target
|
|
304
|
+
}
|
|
305
|
+
setScrolled(target)
|
|
306
|
+
}
|
|
307
|
+
const onScroll = () => {
|
|
308
|
+
const element = box
|
|
309
|
+
if (!element) return
|
|
310
|
+
setScrolled(element.scrollLeft)
|
|
311
|
+
// The bar the pointer was over is no longer where it pointed, so the block goes back to the newest
|
|
312
|
+
// measured step rather than describe a bar that has moved.
|
|
313
|
+
setHovered(undefined)
|
|
314
|
+
// The scroll this plugin just made arrives as an event too, and it is not the user's word.
|
|
315
|
+
if (ownScroll !== undefined && Math.abs(element.scrollLeft - ownScroll) <= 1) {
|
|
316
|
+
ownScroll = undefined
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
// Only a scroll the user made may move the latch: a clamp after the content shrank arrives as a
|
|
320
|
+
// scroll event too, and it always lands at the right end (docs/oc-tps-meter.md, Traps).
|
|
321
|
+
const user = performance.now() > programmaticUntil
|
|
322
|
+
follow = followAfter(follow, {
|
|
323
|
+
user,
|
|
324
|
+
scrollLeft: element.scrollLeft,
|
|
325
|
+
scrollWidth: element.scrollWidth,
|
|
326
|
+
clientWidth: element.clientWidth,
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
/** The chart's own wrapper, which the observer has to follow: it comes and goes with the steps. */
|
|
330
|
+
let observer: ResizeObserver | undefined
|
|
331
|
+
const watch = (element: HTMLDivElement) => {
|
|
332
|
+
box = element
|
|
333
|
+
observer?.disconnect()
|
|
334
|
+
observer = new ResizeObserver(() => {
|
|
335
|
+
setViewport(element.clientWidth)
|
|
336
|
+
requestAnimationFrame(settle)
|
|
337
|
+
})
|
|
338
|
+
observer.observe(element)
|
|
339
|
+
setViewport(element.clientWidth)
|
|
340
|
+
requestAnimationFrame(settle)
|
|
341
|
+
}
|
|
342
|
+
onCleanup(() => observer?.disconnect())
|
|
343
|
+
/** A step's slot is hoverable whether or not it was measured: the block above says which it was. */
|
|
344
|
+
const point = (event: PointerEvent & { currentTarget: SVGSVGElement }) => {
|
|
345
|
+
const list = steps()
|
|
346
|
+
if (!list.length) return
|
|
347
|
+
const left = event.currentTarget.getBoundingClientRect().left
|
|
348
|
+
const index = Math.floor((event.clientX - left - chartLeft()) / STEP_PX)
|
|
349
|
+
setHovered(Math.max(0, Math.min(list.length - 1, index)))
|
|
350
|
+
}
|
|
351
|
+
/** The bar under the pointer, when the list still holds it: a poll appends, a revert cuts the tail, and
|
|
352
|
+
* either can leave the stored index past the end. */
|
|
353
|
+
const over = (): number | undefined => {
|
|
354
|
+
const index = hovered()
|
|
355
|
+
return index !== undefined && index < steps().length ? index : undefined
|
|
356
|
+
}
|
|
357
|
+
/** The step the block describes: the one under the pointer, else the newest measured one. Nothing
|
|
358
|
+
* hovered means the panel keeps its headline, so panning never moves the big figure. */
|
|
359
|
+
const shown = (): Step | undefined => {
|
|
360
|
+
const index = over()
|
|
361
|
+
return index === undefined ? lastStep() : steps()[index]
|
|
362
|
+
}
|
|
363
|
+
/** What the block is describing: the hovered step's time, or the headline's own label. */
|
|
364
|
+
const blockLabel = () => {
|
|
365
|
+
const step = shown()
|
|
366
|
+
return over() !== undefined && step ? stamp(step.created, !sameDay(step.created, now())) : "last measured"
|
|
367
|
+
}
|
|
368
|
+
const models = () => byModel(steps())
|
|
369
|
+
const medianLine = () => {
|
|
370
|
+
const value = medianTps()
|
|
371
|
+
return value === undefined ? undefined : y(value)
|
|
372
|
+
}
|
|
373
|
+
const figure = () => {
|
|
374
|
+
const step = shown()
|
|
375
|
+
return step?.tps === undefined ? "—" : speed(step.tps)
|
|
376
|
+
}
|
|
377
|
+
const model = () => shown()?.model ?? ""
|
|
378
|
+
const ttft = () => {
|
|
379
|
+
const value = shown()?.ttft
|
|
380
|
+
// Milliseconds below a second, where the digits mean something; seconds above, where they do not.
|
|
381
|
+
if (value === undefined) return undefined
|
|
382
|
+
return value < 1000 ? ([String(Math.round(value)), " ms"] as const) : ([(value / 1000).toFixed(1), " s"] as const)
|
|
383
|
+
}
|
|
384
|
+
/** Absolute local times at the two ends of the visible window. A window that crosses midnight dates
|
|
385
|
+
* both ends — 23:58 and 00:08 alone would not say the day changed — and an end that is not today's
|
|
386
|
+
* carries its date, so panned back onto yesterday the axis cannot be mistaken for this morning. */
|
|
387
|
+
const axis = () => {
|
|
388
|
+
const list = steps()
|
|
389
|
+
const { first, last } = range()
|
|
390
|
+
const start = list[first]?.created
|
|
391
|
+
const end = list[last]?.created
|
|
392
|
+
if (start === undefined || end === undefined) return ["", ""]
|
|
393
|
+
const crosses = !sameDay(start, end)
|
|
394
|
+
const today = now()
|
|
395
|
+
return [clock(start, crosses || !sameDay(start, today)), clock(end, crosses || !sameDay(end, today))]
|
|
396
|
+
}
|
|
397
|
+
/** Which way there is more to see, between the two dates: nothing when the whole session fits. */
|
|
398
|
+
const pan = () => {
|
|
399
|
+
if (contentWidth() <= viewport()) return ""
|
|
400
|
+
const right = atRightEnd(scrolled(), contentWidth(), viewport())
|
|
401
|
+
if (scrolled() <= 1) return "scroll →"
|
|
402
|
+
return right ? "← scroll" : "← scroll →"
|
|
403
|
+
}
|
|
404
|
+
const countsLine = (): Piece[] => {
|
|
405
|
+
const value = summary()
|
|
406
|
+
if (!value) return []
|
|
407
|
+
const pieces: Piece[] = [
|
|
408
|
+
value.steps.length + " steps · " + value.measured + " measured · median ",
|
|
409
|
+
{ figure: tps(value.median) },
|
|
410
|
+
" t/s · peak ",
|
|
411
|
+
{ figure: tps(value.peak) },
|
|
412
|
+
" t/s",
|
|
413
|
+
]
|
|
414
|
+
if (reading()?.truncated) pieces.push(" · truncated")
|
|
415
|
+
return pieces
|
|
416
|
+
}
|
|
417
|
+
const counts = () => piecesText(countsLine())
|
|
418
|
+
const modelLine = (row: ModelStats): Piece[] => [
|
|
419
|
+
(row.steps === 1 ? "1 step" : row.steps + " steps") + " · med ",
|
|
420
|
+
{ figure: tps(row.median) },
|
|
421
|
+
" t/s · peak ",
|
|
422
|
+
{ figure: tps(row.peak) },
|
|
423
|
+
" t/s",
|
|
424
|
+
]
|
|
425
|
+
const amount = (pick: (totals: Summary["totals"]) => number) => {
|
|
426
|
+
const totals = summary()?.totals
|
|
427
|
+
return totals ? count(pick(totals)) : ""
|
|
428
|
+
}
|
|
429
|
+
const hit = () => {
|
|
430
|
+
const value = summary()?.totals.cacheHit
|
|
431
|
+
return value === undefined ? "—" : Math.round(value * 100) + "%"
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return (
|
|
435
|
+
<div class="tpsm">
|
|
436
|
+
<Show when={stale()}>
|
|
437
|
+
{(missed: () => Failure) => (
|
|
438
|
+
<div class="tpsm-block">
|
|
439
|
+
<div class="tpsm-label">stale</div>
|
|
440
|
+
<div class="tpsm-sub">
|
|
441
|
+
read {age(now() - (value()?.readAt ?? now()))} ago, the last attempt failed:{" "}
|
|
442
|
+
{missed().error}
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
)}
|
|
446
|
+
</Show>
|
|
447
|
+
<Show when={value()} fallback={
|
|
448
|
+
<Show
|
|
449
|
+
when={failure()}
|
|
450
|
+
fallback={
|
|
451
|
+
<div class="tpsm-note">
|
|
452
|
+
{loading() ? "reading this session's messages…" : "No session is routed."}
|
|
453
|
+
</div>
|
|
454
|
+
}
|
|
455
|
+
>
|
|
456
|
+
{(missed: () => Failure) => (
|
|
457
|
+
<div class="tpsm-note">The messages could not be read: {missed().error}</div>
|
|
458
|
+
)}
|
|
459
|
+
</Show>
|
|
460
|
+
}>
|
|
461
|
+
<Show when={steps().length > 0} fallback={<div class="tpsm-note">No assistant step yet.</div>}>
|
|
462
|
+
<div class="tpsm-block">
|
|
463
|
+
<div class="tpsm-label">{blockLabel()}</div>
|
|
464
|
+
<div class="tpsm-figure">{figure()}<span class="tpsm-unit"> t/s</span></div>
|
|
465
|
+
<div class="tpsm-sub">
|
|
466
|
+
<span class="tpsm-smodel">{model()}</span>
|
|
467
|
+
<Show when={ttft()}>
|
|
468
|
+
{(pair) => (
|
|
469
|
+
<>
|
|
470
|
+
{model() ? " · " : ""}TTFT{" "}
|
|
471
|
+
<span class={isSlow(shown()?.ttft) ? "tpsm-num tpsm-slow" : "tpsm-num"}>
|
|
472
|
+
{pair()[0]}
|
|
473
|
+
</span>
|
|
474
|
+
{pair()[1]}
|
|
475
|
+
</>
|
|
476
|
+
)}
|
|
477
|
+
</Show>
|
|
478
|
+
</div>
|
|
479
|
+
</div>
|
|
480
|
+
<div class="tpsm-block">
|
|
481
|
+
<div class="tpsm-chart" ref={watch} onScroll={onScroll}>
|
|
482
|
+
<svg
|
|
483
|
+
class="tpsm-hist"
|
|
484
|
+
width={chartWidth()}
|
|
485
|
+
height={HEIGHT}
|
|
486
|
+
role="img"
|
|
487
|
+
aria-label={counts()}
|
|
488
|
+
onPointerMove={point}
|
|
489
|
+
onPointerLeave={() => setHovered(undefined)}
|
|
490
|
+
>
|
|
491
|
+
{/* One drawing for every step, in real pixels: an unmeasured step leaves its slot empty, and the
|
|
492
|
+
two dashed lines are strokes at one pixel over the whole width. */}
|
|
493
|
+
<For each={steps()}>
|
|
494
|
+
{(step: Step, index) => {
|
|
495
|
+
const tps = step.tps
|
|
496
|
+
if (tps === undefined) return null
|
|
497
|
+
return (
|
|
498
|
+
<>
|
|
499
|
+
{/* Red when that step waited too long for its first token: the bar's height is the
|
|
500
|
+
speed, so the colour carries the wait, which is the other reason to switch provider.
|
|
501
|
+
A slow bar is filled with the muted red and keeps full opacity, so the pointer
|
|
502
|
+
has to show itself through the fill instead — hence the brighter class below. */}
|
|
503
|
+
<rect
|
|
504
|
+
x={chartLeft() + index() * STEP_PX}
|
|
505
|
+
y={y(tps)}
|
|
506
|
+
width={BAR_PX}
|
|
507
|
+
height={HEIGHT - y(tps)}
|
|
508
|
+
fill="currentColor"
|
|
509
|
+
class={
|
|
510
|
+
isSlow(step.ttft)
|
|
511
|
+
? index() === hovered()
|
|
512
|
+
? "tpsm-hot tpsm-hot-on"
|
|
513
|
+
: "tpsm-hot"
|
|
514
|
+
: undefined
|
|
515
|
+
}
|
|
516
|
+
opacity={
|
|
517
|
+
isSlow(step.ttft) || index() === hovered()
|
|
518
|
+
? 1
|
|
519
|
+
: index() === steps().length - 1
|
|
520
|
+
? 0.9
|
|
521
|
+
: 0.45
|
|
522
|
+
}
|
|
523
|
+
/>
|
|
524
|
+
{/* Cut at the top: a bright cap says the bar goes on, and the block above
|
|
525
|
+
still carries its real figure when pointed at. */}
|
|
526
|
+
<Show when={clipped(tps)}>
|
|
527
|
+
<rect
|
|
528
|
+
x={chartLeft() + index() * STEP_PX}
|
|
529
|
+
y={0}
|
|
530
|
+
width={BAR_PX}
|
|
531
|
+
height={2}
|
|
532
|
+
fill="currentColor"
|
|
533
|
+
opacity={1}
|
|
534
|
+
/>
|
|
535
|
+
</Show>
|
|
536
|
+
</>
|
|
537
|
+
)
|
|
538
|
+
}}
|
|
539
|
+
</For>
|
|
540
|
+
<Show when={medianLine() !== undefined}>
|
|
541
|
+
<line
|
|
542
|
+
x1="0"
|
|
543
|
+
x2={chartWidth()}
|
|
544
|
+
y1={medianLine()}
|
|
545
|
+
y2={medianLine()}
|
|
546
|
+
stroke="currentColor"
|
|
547
|
+
stroke-width="1"
|
|
548
|
+
stroke-dasharray="3 3"
|
|
549
|
+
opacity="0.3"
|
|
550
|
+
/>
|
|
551
|
+
</Show>
|
|
552
|
+
{/* The top of the drawing, drawn whatever the data does: the block's padding is black on
|
|
553
|
+
black, so without this line the edge is invisible and a cut bar has nothing to be cut
|
|
554
|
+
against. The tallest bar reaches it, unless the fence cut it. */}
|
|
555
|
+
<Show when={summary()?.scale}>
|
|
556
|
+
<line
|
|
557
|
+
x1="0"
|
|
558
|
+
x2={chartWidth()}
|
|
559
|
+
y1="0.5"
|
|
560
|
+
y2="0.5"
|
|
561
|
+
stroke="currentColor"
|
|
562
|
+
stroke-width="1"
|
|
563
|
+
stroke-dasharray="3 3"
|
|
564
|
+
opacity="0.3"
|
|
565
|
+
/>
|
|
566
|
+
</Show>
|
|
567
|
+
</svg>
|
|
568
|
+
</div>
|
|
569
|
+
<div class="tpsm-axis">
|
|
570
|
+
<span>{axis()[0]}</span>
|
|
571
|
+
<span class="tpsm-pan">{pan()}</span>
|
|
572
|
+
<span class="tpsm-end">{axis()[1]}</span>
|
|
573
|
+
</div>
|
|
574
|
+
<div class="tpsm-counts"><Figures pieces={countsLine()} /></div>
|
|
575
|
+
<Show when={reading()?.revertLost}>
|
|
576
|
+
<div class="tpsm-warn">
|
|
577
|
+
the revert point is older than the messages read: undone steps may be counted
|
|
578
|
+
</div>
|
|
579
|
+
</Show>
|
|
580
|
+
<div class="tpsm-models">
|
|
581
|
+
<div class="tpsm-label">by model</div>
|
|
582
|
+
<For each={models()}>
|
|
583
|
+
{(row) => (
|
|
584
|
+
<div class="tpsm-mrow">
|
|
585
|
+
<div class="tpsm-mname">{row.model || "—"}</div>
|
|
586
|
+
<div class="tpsm-mnum"><Figures pieces={modelLine(row)} /></div>
|
|
587
|
+
</div>
|
|
588
|
+
)}
|
|
589
|
+
</For>
|
|
590
|
+
</div>
|
|
591
|
+
</div>
|
|
592
|
+
<div class="tpsm-block">
|
|
593
|
+
<div class="tpsm-label">totals</div>
|
|
594
|
+
<Total label="input" value={amount((totals) => totals.input)} />
|
|
595
|
+
<Total label="output" value={amount((totals) => totals.output)} />
|
|
596
|
+
<Total label="reasoning" value={amount((totals) => totals.reasoning)} />
|
|
597
|
+
<Total label="cache read" value={amount((totals) => totals.cacheRead)} />
|
|
598
|
+
<Total label="cache write" value={amount((totals) => totals.cacheWrite)} />
|
|
599
|
+
<Total label="cache hit" value={hit()} />
|
|
600
|
+
</div>
|
|
601
|
+
</Show>
|
|
602
|
+
</Show>
|
|
603
|
+
</div>
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const tab: PanelTab = {
|
|
608
|
+
id: "tps",
|
|
609
|
+
get title() {
|
|
610
|
+
return "TPS"
|
|
611
|
+
},
|
|
612
|
+
closable: "hover",
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
ctx.add(Style, CSS)
|
|
616
|
+
ctx.add(Panel, {
|
|
617
|
+
id: "tps",
|
|
618
|
+
region: "side",
|
|
619
|
+
// The declared width puts the panel on the app's per-panel width store, which is what a drag writes.
|
|
620
|
+
width: PANEL_WIDTH,
|
|
621
|
+
list: (input) => (input.open.includes("tps") ? [tab] : []),
|
|
622
|
+
render: () => <View />,
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
const toggle = () => {
|
|
626
|
+
const session = current()
|
|
627
|
+
if (!session) return
|
|
628
|
+
ctx.layout.toggle(`${ctx.id}:tps`, session)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
ctx.add(Command, (): Command => ({
|
|
632
|
+
id: "open",
|
|
633
|
+
title: "TPS Meter",
|
|
634
|
+
description: "Decode speed of every closed step in this session",
|
|
635
|
+
keywords: "tps speed tokens decode throughput latency ttft",
|
|
636
|
+
section: "session",
|
|
637
|
+
slash: { name: "tps" },
|
|
638
|
+
enabled: !!current(),
|
|
639
|
+
run: toggle,
|
|
640
|
+
}))
|
|
641
|
+
|
|
642
|
+
ctx.add(MenuItem, (): MenuItem => {
|
|
643
|
+
const session = current()
|
|
644
|
+
return {
|
|
645
|
+
menu: "session.panel",
|
|
646
|
+
id: "tps",
|
|
647
|
+
title: "TPS Meter",
|
|
648
|
+
icon: "window-analytics",
|
|
649
|
+
keybind: `${ctx.id}.open`,
|
|
650
|
+
opened: !!session && ctx.layout.state(`${ctx.id}:tps`, session) !== "closed",
|
|
651
|
+
run: toggle,
|
|
652
|
+
}
|
|
653
|
+
})
|
|
654
|
+
},
|
|
655
|
+
})
|
package/metrics.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// oc-tps-meter — the metric, as pure functions over the messages the client returns. No imports: the test
|
|
2
|
+
// reads exactly what the panel reads. A step's speed is its own decode window, first token to last, and a
|
|
3
|
+
// step without a usable one is unmeasured, never zero.
|
|
4
|
+
|
|
5
|
+
export const MIN_TOKENS = 20
|
|
6
|
+
export const MIN_SPAN_MS = 200
|
|
7
|
+
|
|
8
|
+
/** The wait for a first token above which a step is read as queued rather than merely slow: above the 95th
|
|
9
|
+
* percentile of everything measured on this machine, so a flagged step is never ordinary slowness. The
|
|
10
|
+
* measurement behind it is in `docs/oc-tps-meter.md`, *a red bar is a wait* — the number is not repeated here. */
|
|
11
|
+
export const SLOW_TTFT_MS = 10_000
|
|
12
|
+
|
|
13
|
+
export const isSlow = (ttft: number | undefined) => ttft !== undefined && ttft > SLOW_TTFT_MS
|
|
14
|
+
|
|
15
|
+
export type MessageTime = {
|
|
16
|
+
created?: number
|
|
17
|
+
firstToken?: number
|
|
18
|
+
streamed?: number
|
|
19
|
+
completed?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type MessageTokens = {
|
|
23
|
+
input?: number
|
|
24
|
+
output?: number
|
|
25
|
+
reasoning?: number
|
|
26
|
+
cache?: { read?: number; write?: number }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type StepMessage = {
|
|
30
|
+
id?: string
|
|
31
|
+
/** `assistant`, `user`, `compaction`… Only the assistant ones are steps, but the revert cut needs them all. */
|
|
32
|
+
type?: string
|
|
33
|
+
time?: MessageTime
|
|
34
|
+
tokens?: MessageTokens
|
|
35
|
+
model?: { providerID?: string; id?: string; variant?: string }
|
|
36
|
+
content?: readonly { type?: string; time?: null | { created?: number } }[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type Step = {
|
|
40
|
+
id: string
|
|
41
|
+
/** Epoch ms. */
|
|
42
|
+
created: number
|
|
43
|
+
tokens: number
|
|
44
|
+
/** Only on a measured step. */
|
|
45
|
+
tps?: number
|
|
46
|
+
ttft?: number
|
|
47
|
+
/** `providerID/modelID`, or empty when the message carried neither. */
|
|
48
|
+
model: string
|
|
49
|
+
measured: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type Totals = {
|
|
53
|
+
input: number
|
|
54
|
+
output: number
|
|
55
|
+
reasoning: number
|
|
56
|
+
cacheRead: number
|
|
57
|
+
cacheWrite: number
|
|
58
|
+
/** `cache.read / (input + cache.read + cache.write)`; undefined when all three are zero. */
|
|
59
|
+
cacheHit?: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type Summary = {
|
|
63
|
+
/** Oldest first, whether the messages arrived oldest or newest first. */
|
|
64
|
+
steps: Step[]
|
|
65
|
+
/** Sums over the same messages, measured or not. */
|
|
66
|
+
totals: Totals
|
|
67
|
+
measured: number
|
|
68
|
+
/** The median of the measured speeds, not the mean. */
|
|
69
|
+
median?: number
|
|
70
|
+
peak?: number
|
|
71
|
+
/** The top of the drawing: the 95th percentile, so one freak step cannot flatten the other 95%. */
|
|
72
|
+
scale?: number
|
|
73
|
+
/** The newest measured step. */
|
|
74
|
+
last?: Step
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const number = (value: number | undefined) => (typeof value === "number" && Number.isFinite(value) ? value : 0)
|
|
78
|
+
|
|
79
|
+
/** A finite instant, or nothing: a non-finite one cannot measure a window. */
|
|
80
|
+
const instant = (value: number | undefined) =>
|
|
81
|
+
typeof value === "number" && Number.isFinite(value) ? value : undefined
|
|
82
|
+
|
|
83
|
+
/** A step is closed only once `completed` is there: a message still streaming is not history. */
|
|
84
|
+
export const closed = (message: StepMessage) => message.time?.completed !== undefined
|
|
85
|
+
|
|
86
|
+
/** The instant generation started: the provider's own first token, and for the messages recorded before that
|
|
87
|
+
* field existed, the first **reasoning** part. Only reasoning: a tool part's time is when the call begins,
|
|
88
|
+
* after the model has already generated, and using it collapses the window — on real rows it turned 365 tokens
|
|
89
|
+
* into 1388 t/s. A text part carries no time at all, so an old text-only or tool-only message stays a gap. */
|
|
90
|
+
const started = (message: StepMessage): number | undefined => {
|
|
91
|
+
const firstToken = instant(message.time?.firstToken)
|
|
92
|
+
if (firstToken !== undefined) return firstToken
|
|
93
|
+
let earliest: number | undefined
|
|
94
|
+
for (const part of message.content ?? []) {
|
|
95
|
+
if (part?.type !== "reasoning") continue
|
|
96
|
+
const at = instant(part.time?.created)
|
|
97
|
+
if (at !== undefined && (earliest === undefined || at < earliest)) earliest = at
|
|
98
|
+
}
|
|
99
|
+
return earliest
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const stepOf = (message: StepMessage): Step => {
|
|
103
|
+
const tokens = number(message.tokens?.output) + number(message.tokens?.reasoning)
|
|
104
|
+
const created = number(message.time?.created)
|
|
105
|
+
const firstToken = started(message)
|
|
106
|
+
const streamed = instant(message.time?.streamed)
|
|
107
|
+
const span = firstToken !== undefined && streamed !== undefined ? streamed - firstToken : undefined
|
|
108
|
+
const measured = span !== undefined && span >= MIN_SPAN_MS && tokens >= MIN_TOKENS
|
|
109
|
+
const model =
|
|
110
|
+
message.model?.providerID && message.model?.id ? message.model.providerID + "/" + message.model.id : ""
|
|
111
|
+
return {
|
|
112
|
+
id: message.id ?? "",
|
|
113
|
+
created,
|
|
114
|
+
tokens,
|
|
115
|
+
tps: measured && span !== undefined ? tokens / (span / 1000) : undefined,
|
|
116
|
+
ttft: firstToken !== undefined && created !== 0 ? firstToken - created : undefined,
|
|
117
|
+
model,
|
|
118
|
+
measured,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The middle value, averaging the two middles on an even count. Undefined for no value at all. */
|
|
123
|
+
export const median = (values: readonly number[]): number | undefined => {
|
|
124
|
+
if (values.length === 0) return undefined
|
|
125
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
126
|
+
const middle = Math.floor(sorted.length / 2)
|
|
127
|
+
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The value 95% of the speeds sit at or below, by nearest rank. */
|
|
131
|
+
export const p95 = (values: readonly number[]): number | undefined => {
|
|
132
|
+
if (values.length === 0) return undefined
|
|
133
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
134
|
+
return sorted[Math.max(0, Math.ceil(sorted.length * 0.95) - 1)]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The top of the drawing: the peak itself, unless one step is more than twice the 95th percentile — then the
|
|
138
|
+
* scale stops at that fence, so a freak bar is cut instead of flattening every other one. With no such step
|
|
139
|
+
* this is exactly the peak. */
|
|
140
|
+
export const scaleOf = (values: readonly number[]): number | undefined => {
|
|
141
|
+
if (values.length === 0) return undefined
|
|
142
|
+
const max = Math.max(...values)
|
|
143
|
+
return Math.min(max, 2 * (p95(values) ?? max))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A staged revert leaves the messages it undid stored but no longer part of the session: the message it points
|
|
147
|
+
* at and everything newer are dropped. The list is oldest first. `found` says whether that message was in it:
|
|
148
|
+
* an id from outside the window cuts nothing, and the caller has to say so. */
|
|
149
|
+
export type RevertCut = {
|
|
150
|
+
messages: StepMessage[]
|
|
151
|
+
found: boolean
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export const revertCut = (messages: readonly StepMessage[], revertMessageID: string | undefined): RevertCut => {
|
|
155
|
+
const stop = revertMessageID ? messages.findIndex((message) => message.id === revertMessageID) : -1
|
|
156
|
+
return stop === -1 ? { messages: [...messages], found: false } : { messages: messages.slice(0, stop), found: true }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** One row per model with at least one measured step, the busiest first and ties by name. The counts are of
|
|
160
|
+
* measured steps, which is what the row's speeds speak about. */
|
|
161
|
+
export type ModelStats = { model: string; steps: number; median?: number; peak?: number }
|
|
162
|
+
|
|
163
|
+
export const byModel = (steps: readonly Step[]): ModelStats[] => {
|
|
164
|
+
const held = new Map<string, number[]>()
|
|
165
|
+
for (const step of steps) {
|
|
166
|
+
if (step.tps === undefined) continue
|
|
167
|
+
const speeds = held.get(step.model) ?? []
|
|
168
|
+
speeds.push(step.tps)
|
|
169
|
+
held.set(step.model, speeds)
|
|
170
|
+
}
|
|
171
|
+
return [...held]
|
|
172
|
+
.map(([model, speeds]) => ({ model, steps: speeds.length, median: median(speeds), peak: Math.max(...speeds) }))
|
|
173
|
+
.sort((a, b) => b.steps - a.steps || a.model.localeCompare(b.model))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A bar is one step: 3 px of bar and the 1 px gap after it, so the chart is four pixels per step wide. */
|
|
177
|
+
export const BAR_PX = 3
|
|
178
|
+
export const STEP_PX = 4
|
|
179
|
+
|
|
180
|
+
/** The steps the window shows, as inclusive indices: the slot the left edge falls in and the one the right edge
|
|
181
|
+
* reaches. Nothing in view is `last < first`. */
|
|
182
|
+
export const visibleRange = (count: number, scrollLeft: number, viewport: number): { first: number; last: number } => {
|
|
183
|
+
if (count <= 0) return { first: 0, last: -1 }
|
|
184
|
+
const first = Math.max(0, Math.min(count - 1, Math.floor(scrollLeft / STEP_PX)))
|
|
185
|
+
const last = Math.min(count - 1, Math.max(first, Math.ceil((scrollLeft + viewport) / STEP_PX) - 1))
|
|
186
|
+
return { first, last }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Whether the window sits at its right end, within the native scroll's 1 px — and true when nothing overflows. */
|
|
190
|
+
export const atRightEnd = (scrollLeft: number, scrollWidth: number, clientWidth: number) =>
|
|
191
|
+
scrollWidth - clientWidth - scrollLeft <= 1
|
|
192
|
+
|
|
193
|
+
/** Whether the window follows the newest step after a scroll event: only a scroll the user made can move that
|
|
194
|
+
* latch. A clamp after the content shrank, a resize and our own settle all arrive as scroll events too, and
|
|
195
|
+
* every one of them lands at the right end — reading them as intent would drag the user forward on the next
|
|
196
|
+
* read, which is exactly what the latch exists to prevent. */
|
|
197
|
+
export const followAfter = (
|
|
198
|
+
current: boolean,
|
|
199
|
+
event: { user: boolean; scrollLeft: number; scrollWidth: number; clientWidth: number },
|
|
200
|
+
): boolean =>
|
|
201
|
+
event.user ? atRightEnd(event.scrollLeft, event.scrollWidth, event.clientWidth) : current
|
|
202
|
+
|
|
203
|
+
export const summarise = (messages: readonly StepMessage[]): Summary => {
|
|
204
|
+
const history = messages.filter(closed)
|
|
205
|
+
const steps = history.map(stepOf).sort((a, b) => a.created - b.created)
|
|
206
|
+
const totals: Totals = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 }
|
|
207
|
+
for (const message of history) {
|
|
208
|
+
totals.input += number(message.tokens?.input)
|
|
209
|
+
totals.output += number(message.tokens?.output)
|
|
210
|
+
totals.reasoning += number(message.tokens?.reasoning)
|
|
211
|
+
totals.cacheRead += number(message.tokens?.cache?.read)
|
|
212
|
+
totals.cacheWrite += number(message.tokens?.cache?.write)
|
|
213
|
+
}
|
|
214
|
+
const spent = totals.input + totals.cacheRead + totals.cacheWrite
|
|
215
|
+
if (spent > 0) totals.cacheHit = totals.cacheRead / spent
|
|
216
|
+
|
|
217
|
+
const speeds = steps.flatMap((step) => (step.tps === undefined ? [] : [step.tps]))
|
|
218
|
+
const measured = steps.filter((step) => step.measured)
|
|
219
|
+
return {
|
|
220
|
+
steps,
|
|
221
|
+
totals,
|
|
222
|
+
measured: measured.length,
|
|
223
|
+
median: median(speeds),
|
|
224
|
+
peak: speeds.length ? Math.max(...speeds) : undefined,
|
|
225
|
+
// A session with few steps gets its peak back: 95% of ten values is still nearly all of them.
|
|
226
|
+
scale: scaleOf(speeds),
|
|
227
|
+
last: measured[measured.length - 1],
|
|
228
|
+
}
|
|
229
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@rizal72/oc-tps-meter",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Tokens per second for every closed step of an OpenCode v2 session: a histogram in the desktop side panel, with the median, the peak, TTFT and the token totals",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/rizal72/opencode-plugins.git"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"files": [
|
|
12
|
+
"*.ts",
|
|
13
|
+
"*.tsx",
|
|
14
|
+
"README.md",
|
|
15
|
+
"dist",
|
|
16
|
+
"!*.test.ts"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"opencode",
|
|
20
|
+
"opencode-plugin",
|
|
21
|
+
"tps",
|
|
22
|
+
"tokens",
|
|
23
|
+
"throughput"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
"./gui-experimental": "./dist/gui-experimental.cjs"
|
|
30
|
+
}
|
|
31
|
+
}
|