/** @jsxImportSource @opentui/solid */ import type { TextRenderable } from "@opentui/core" import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" import { onCleanup } from "solid-js" import { backoffDelayMs, processPoll } from "./src/stats.ts" type StreamSample = { at: number tokens: number } const STREAM_WINDOW_MS = 5_000 const SLOT_POLL_MS = 500 const BACKOFF_BASE_MS = 1_000 const BACKOFF_MAX_MS = 10_000 type MessageTiming = { sessionID: string requestStartAt: number firstResponseAt?: number firstTokenAt?: number lastTokenAt?: number } type TrackerState = { streamSamplesBySession: Record messageTimingByID: Record lastPrefillRate: number lastGeneratedTps: number isPrefilling: boolean prefillSlotId: number | null prefillCapturedTokens: number | null prefillStartAt: number isGenerating: boolean generateSlotId: number | null generatePrevNd: number generateStartAt: number prevNdBySlot: Record prevPromptTokensBySlot: Record failure: string | null } function estimateStreamTokens(delta: string) { return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 5)) } function stripBaseUrlPath(baseURL: string): string { try { const url = new URL(baseURL) return `${url.origin}` } catch { return baseURL.replace(/\/v1(\/.*)?$/, "").replace(/\/$/, "") || baseURL } } function extractProviderUrls(config: unknown): string[] { if (!config || typeof config !== "object") return [] const obj = config as Record const provider = obj.provider if (!provider || typeof provider !== "object") return [] const urls: string[] = [] for (const [key, val] of Object.entries(provider as Record)) { if (val && typeof val === "object") { const opts = (val as Record).options if (opts && typeof opts === "object") { const baseURL = (opts as Record).baseURL || (opts as Record).base_url if (typeof baseURL === "string" && key.toLowerCase().includes("llama") && !key.toLowerCase().includes("ollama")) { urls.push(stripBaseUrlPath(baseURL)) } } } } return urls } async function loadConfigUrls(api: Parameters[0]): Promise { try { return extractProviderUrls(api.state.config as unknown) } catch (e) { return [] } } async function fetchSlots(baseUrl: string, model?: string): Promise { try { const url = model ? `${baseUrl}/slots?model=${encodeURIComponent(model)}` : `${baseUrl}/slots` const resp = await fetch(url) if (!resp.ok) return null return await resp.json() } catch { return null } } function formatPps(value: number) { if (!Number.isFinite(value) || value <= 0) return undefined return `${Math.round(value)}` } function formatTps(value: number) { if (!Number.isFinite(value) || value <= 0) return undefined return `${Math.round(value)}` } function SessionPromptRight(props: { api: Parameters[0] sessionID: string tracker: TrackerState subscribe: (listener: () => void) => () => void }) { let textRef: TextRenderable | undefined const sync = () => { if (!textRef) return textRef.content = getStatusText() props.api.renderer.requestRender() } const unsubscribe = props.subscribe(sync) onCleanup(unsubscribe) return ( { textRef = ref sync() }} fg={props.api.theme.current.textMuted} > {getStatusText()} ) function getStatusText() { const s = props.tracker if (s.failure) { return s.failure } const pp = s.isPrefilling && s.lastPrefillRate > 0 ? formatPps(s.lastPrefillRate) : undefined const tps = s.isGenerating && s.lastGeneratedTps > 0 ? formatTps(s.lastGeneratedTps) : undefined let result = "- tps (TG)" if (s.isPrefilling && pp) { result = `${pp} tps (PP)` } else if (s.isGenerating && tps) { result = `${tps} tps (TG)` } return result } } const tui: TuiPlugin = async (api) => { console.log("[oc-ls-stats] TUI plugin loaded!") const tracker: TrackerState = { streamSamplesBySession: {}, messageTimingByID: {}, lastPrefillRate: 0, lastGeneratedTps: 0, isPrefilling: false, prefillSlotId: null, prefillCapturedTokens: null, prefillStartAt: 0, isGenerating: false, generateSlotId: null, generatePrevNd: 0, generateStartAt: 0, prevNdBySlot: {}, prevPromptTokensBySlot: {}, failure: null, } const listeners = new Set<() => void>() const bump = () => { for (const listener of listeners) listener() } const pruneSamples = (now = Date.now()) => { let changed = false for (const [sessionID, samples] of Object.entries(tracker.streamSamplesBySession)) { const next = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS) if (next.length !== samples.length) { changed = true if (next.length > 0) tracker.streamSamplesBySession[sessionID] = next else delete tracker.streamSamplesBySession[sessionID] } } if (changed) bump() } const clearLiveSamples = (sessionID: string) => { if (!tracker.streamSamplesBySession[sessionID]?.length) return delete tracker.streamSamplesBySession[sessionID] bump() } const appendSample = (sessionID: string, messageID: string, sample: StreamSample) => { const now = sample.at tracker.streamSamplesBySession[sessionID] = [ ...(tracker.streamSamplesBySession[sessionID] ?? []).filter((item) => now - item.at <= STREAM_WINDOW_MS), sample, ] const timing = tracker.messageTimingByID[messageID] if (timing) { tracker.messageTimingByID[messageID] = timing.firstTokenAt ? { ...timing, lastTokenAt: now } : { ...timing, firstResponseAt: timing.firstResponseAt ?? now, firstTokenAt: now, lastTokenAt: now, } } bump() } let llamaServerUrls: string[] = [] let llamaServerModel: string | undefined const backoffByUrl = new Map() const pollMetrics = async () => { if (!llamaServerUrls.length) return let model = llamaServerModel if (!model) { const routeSessionID = api.route.current.params?.sessionID if (routeSessionID) { const session = api.state.session.get(routeSessionID) if (session?.model?.id) { model = session.model.id } } } for (const baseUrl of llamaServerUrls) { const now = Date.now() const backoff = backoffByUrl.get(baseUrl) if (backoff && now < backoff.nextRetryAt) { tracker.failure = "n/a" bump() continue } const slots = await fetchSlots(baseUrl, model) if (slots == null) { const failures = (backoff?.failures ?? 0) + 1 const delay = backoffDelayMs(failures, BACKOFF_BASE_MS, BACKOFF_MAX_MS) const jitter = Math.floor(Math.random() * (delay / 4)) backoffByUrl.set(baseUrl, { failures, nextRetryAt: Date.now() + Math.min(delay + jitter, BACKOFF_MAX_MS), }) tracker.failure = "n/a" bump() continue } backoffByUrl.delete(baseUrl) const slotList = Array.isArray(slots) ? slots : Object.values(slots) if (slotList.length === 0) { tracker.failure = "n/a" bump() continue } tracker.failure = null bump() const result = processPoll(slotList, tracker) bump() } } const onDelta = api.event.on("message.part.delta", (evt) => { if (evt.properties.field !== "text") return const parts = api.state.part(evt.properties.messageID) const part = parts.find((item) => item.id === evt.properties.partID) if (!part) return if (part.type !== "text" && part.type !== "reasoning") return appendSample(evt.properties.sessionID, evt.properties.messageID, { at: Date.now(), tokens: estimateStreamTokens(evt.properties.delta), }) }) const onMessage = api.event.on("message.updated", (evt) => { if (evt.properties.info.role !== "assistant") return if (!evt.properties.info.time.completed) { const existing = tracker.messageTimingByID[evt.properties.info.id] tracker.messageTimingByID[evt.properties.info.id] = { sessionID: evt.properties.sessionID, requestStartAt: evt.properties.info.time.created, firstResponseAt: existing?.firstResponseAt, firstTokenAt: existing?.firstTokenAt, lastTokenAt: existing?.lastTokenAt, } bump() return } delete tracker.messageTimingByID[evt.properties.info.id] pruneSamples(evt.properties.info.time.completed) bump() }) const onPart = api.event.on("message.part.updated", (evt) => { if (evt.properties.part.type !== "tool") return if ( evt.properties.part.state.status === "running" || evt.properties.part.state.status === "completed" || evt.properties.part.state.status === "error" ) { clearLiveSamples(evt.properties.sessionID) } const timing = tracker.messageTimingByID[evt.properties.part.messageID] if (!timing) return if (evt.properties.part.state.status === "pending") { tracker.messageTimingByID[evt.properties.part.messageID] = { ...timing, firstResponseAt: timing.firstResponseAt ?? evt.properties.time, } bump() return } if (evt.properties.part.state.status !== "running") return bump() }) const timer = setInterval(() => { pruneSamples() }, 1000) const slotTimer = setInterval(async () => { await pollMetrics() }, SLOT_POLL_MS) api.lifecycle.onDispose(() => { onDelta() onMessage() onPart() clearInterval(timer) clearInterval(slotTimer) }) try { const configUrls = await loadConfigUrls(api) llamaServerUrls = configUrls.length > 0 ? configUrls : ["http://localhost:8080"] } catch (e) { console.error("[oc-ls-stats] loadConfigUrls error:", e) llamaServerUrls = ["http://localhost:8080"] } api.slots.register({ slots: { session_prompt_right(_ctx, value) { return { listeners.add(listener) return () => { listeners.delete(listener) } }} /> }, }, }) } const plugin: TuiPluginModule & { id: string } = { id: "@troed/oc-ls-stats", tui, } export default plugin