Files
Troed Sångberg aa5ae2b724 fix: stop polling llama-servers during external-model sessions
selectSessionUrls fell back to all discovered servers when the current
session's provider matched no candidate, so external sessions kept
polling local servers: one answered /slots with idle slots (showing
'- tps (TG)') while another rejected unknown models with HTTP 400
(flickering 'n/a'). Narrow to zero URLs instead and surface n/a.
2026-08-21 15:52:50 +02:00

342 lines
9.7 KiB
TypeScript

/** @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, resolveServerUrls, selectSessionUrls } 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<string, StreamSample[]>
messageTimingByID: Record<string, MessageTiming>
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<number, { prevNd: number; idTask: number; classification: "TG" | "PP" | "neither" }>
prevPromptTokensBySlot: Record<number, { tokens: number; idTask: number }>
failure: string | null
}
function estimateStreamTokens(delta: string) {
return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 5))
}
async function fetchSlots(baseUrl: string, model?: string): Promise<unknown | null> {
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<TuiPlugin>[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 (
<text
ref={(ref: TextRenderable) => {
textRef = ref
sync()
}}
fg={props.api.theme.current.textMuted}
>
{getStatusText()}
</text>
)
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, options) => {
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<string, { failures: number; nextRetryAt: number }>()
const pollMetrics = async () => {
if (!llamaServerUrls.length) return
let model = llamaServerModel
let sessionProviderId: string | undefined
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
}
sessionProviderId = session?.model?.providerID
}
}
const activeUrls = selectSessionUrls(llamaServerUrls, api.state.provider, sessionProviderId)
if (!activeUrls.length) {
tracker.failure = "n/a"
bump()
return
}
for (const baseUrl of activeUrls) {
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 {
llamaServerUrls = resolveServerUrls(options, api.state.provider, api.state.config)
} catch (e) {
console.error("[oc-ls-stats] resolveServerUrls error:", e)
llamaServerUrls = ["http://localhost:8080"]
}
api.slots.register({
slots: {
session_prompt_right(_ctx, value) {
return <SessionPromptRight api={api} sessionID={value.session_id} tracker={tracker} subscribe={(listener) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}} />
},
},
})
}
const plugin: TuiPluginModule & { id: string } = {
id: "@troed/oc-ls-stats",
tui,
}
export default plugin