/** @jsxImportSource @opentui/solid */ import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" import { createMemo, createSignal } from "solid-js" import { appendFileSync } from "fs" import { processPoll } from "./src/stats.ts" const DEBUG_ENABLED = false const DEBUG_LOG = "/tmp/oc-ls-stats-debug.log" function debug(...args: unknown[]) { if (!DEBUG_ENABLED) return try { const line = `${args.map(a => typeof a === "object" ? JSON.stringify(a) : String(a)).join(" ")}\n` appendFileSync(DEBUG_LOG, line) } catch {} } type StreamSample = { at: number tokens: number } const STREAM_WINDOW_MS = 5_000 const SLOT_POLL_MS = 500 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") { debug("extractProviderUrls: config is null/undefined or not object"); return [] } const obj = config as Record const provider = obj.provider if (!provider || typeof provider !== "object") { debug("extractProviderUrls: no provider in config"); return [] } const urls: string[] = [] let total = 0 let matched = 0 for (const [key, val] of Object.entries(provider as Record)) { total++ 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 debug(`extractProviderUrls: provider="${key}" baseURL=${baseURL}`) if (typeof baseURL === "string" && key.toLowerCase().includes("llama") && !key.toLowerCase().includes("ollama")) { urls.push(stripBaseUrlPath(baseURL)) matched++ debug(`extractProviderUrls: MATCH provider="${key}" -> ${urls[urls.length-1]}`) } else { debug(`extractProviderUrls: SKIP provider="${key}" (llama=${key.toLowerCase().includes("llama")} ollama=${key.toLowerCase().includes("ollama")})`) } } } } debug(`extractProviderUrls: total=${total} matched=${matched} urls=${JSON.stringify(urls)}`) 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 version: () => number clock: () => number }) { const liveTps = createMemo(() => { props.version() props.clock() const rate = props.tracker.lastGeneratedTps if (!Number.isFinite(rate) || rate <= 0) return undefined return rate }) const prefillRate = createMemo(() => { props.version() if (!props.tracker.isPrefilling) return undefined const rate = props.tracker.lastPrefillRate if (!Number.isFinite(rate) || rate <= 0) return undefined return rate }) const text = createMemo(() => { if (props.tracker.failure) return props.tracker.failure const pp = prefillRate() const tps = liveTps() if (props.tracker.isPrefilling && pp) { return `${formatPps(pp)} tps (PP)` } if (props.tracker.isGenerating && tps) { return `${formatTps(tps)} tps (TG)` } return `- tps (TG)` }) return <>{text() ? {text()} : null} } 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 [version, setVersion] = createSignal(0) const [clock, setClock] = createSignal(Date.now()) const bump = () => setVersion((value) => value + 1) 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 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 } } } debug(`pollMetrics: urls=${JSON.stringify(llamaServerUrls)} model=${model}`) for (const baseUrl of llamaServerUrls) { debug(`pollMetrics: polling ${baseUrl}`) const slots = await fetchSlots(baseUrl, model) debug(`pollMetrics: ${baseUrl} response=${JSON.stringify(slots)}`) const slotList = Array.isArray(slots) ? slots : slots ? Object.values(slots) : [] debug(`pollMetrics: ${baseUrl} slotList.length=${slotList.length}`) if (slotList.length === 0) { tracker.failure = "n/a" bump() continue } tracker.failure = null bump() const result = processPoll(slotList, tracker) if (result.isPrefilling) { debug(`PP rate=${Math.round(result.lastPrefillRate)} tps`) } if (result.isGenerating) { debug(`TG rate=${Math.round(result.lastGeneratedTps)} tps`) } if (result.ppEnded) { debug(`PP ended rate=${Math.round(result.lastPrefillRate)} tps`) } if (result.tgEnded) { debug(`TG ended rate=${Math.round(result.lastGeneratedTps)} tps`) } } if (tracker.lastPrefillRate > 0 || tracker.lastGeneratedTps > 0) { 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(() => { setClock(Date.now()) 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 }, }, }) } const plugin: TuiPluginModule & { id: string } = { id: "@troed/oc-ls-stats", tui, } export default plugin