Web UI: runtime console + KV session panel, with runtime/storage libs and tests (#32)

* Add web runtime console and KV sessions

* test(web): cover runtime and KV session behavior

* Fix runtime helper formatting
This commit is contained in:
ZacharyZcR
2026-07-12 07:40:01 +08:00
committed by GitHub
parent d7ffdc45be
commit 57730f6196
9 changed files with 349 additions and 36 deletions
+56 -2
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest"
import { afterEach, describe, expect, it, vi } from "vitest"
import { extractSSE } from "./api"
import { extractSSE, getHealth, serverEndpoint, streamChat } from "./api"
afterEach(() => vi.unstubAllGlobals())
describe("extractSSE", () => {
it("keeps an incomplete frame for the next network chunk", () => {
@@ -15,3 +17,55 @@ describe("extractSSE", () => {
expect(parsed.rest).toBe("")
})
})
describe("runtime API", () => {
it.each([
["http://127.0.0.1:8000/v1", "http://127.0.0.1:8000/health"],
["https://example.test/api/v1/", "https://example.test/api/health"],
["https://example.test/api", "https://example.test/api/health"],
])("resolves the health endpoint outside the OpenAI v1 prefix", (baseUrl, expected) => {
expect(serverEndpoint(baseUrl, "health")).toBe(expected)
})
it("requests health with the configured bearer credential", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ status: "ok", scheduler: { active: true } })))
vi.stubGlobal("fetch", fetchMock)
await expect(getHealth("http://localhost:8000/v1/", "secret")).resolves.toMatchObject({ status: "ok" })
expect(fetchMock).toHaveBeenCalledWith("http://localhost:8000/health", expect.objectContaining({
headers: expect.objectContaining({ Authorization: "Bearer secret" }),
}))
})
})
describe("chat request extensions", () => {
const completedStream = () => new Response("data: [DONE]\n\n", {
headers: { "content-type": "text/event-stream" },
})
async function requestBody(cacheSlot?: number) {
const fetchMock = vi.fn().mockResolvedValue(completedStream())
vi.stubGlobal("fetch", fetchMock)
await streamChat({
baseUrl: "http://localhost:8000/v1",
apiKey: "",
model: "test-model",
messages: [],
temperature: 0,
maxTokens: 8,
enableThinking: false,
cacheSlot,
signal: new AbortController().signal,
onDelta: () => undefined,
})
return JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>
}
it("omits cache_slot for a generic OpenAI-compatible backend", async () => {
expect(await requestBody()).not.toHaveProperty("cache_slot")
})
it("sends cache_slot zero when colibrì advertises KV slots", async () => {
expect(await requestBody(0)).toMatchObject({ cache_slot: 0 })
})
})
+64 -5
View File
@@ -10,10 +10,46 @@ interface OpenAIError {
error?: { message?: string }
}
function endpoint(baseUrl: string, path: string) {
export interface SchedulerHealth {
active: boolean | number
capacity?: number
queued: number
max_queue: number
queue_timeout_seconds: number
admitted: number
completed: number
rejected: number
timed_out: number
cancelled: number
}
export interface HealthResponse {
status: string
scheduler?: SchedulerHealth
kv_slots?: number
}
export interface TokenUsage {
prompt_tokens: number
completion_tokens: number
total_tokens: number
}
export interface StreamChatResult {
finishReason: string | null
usage: TokenUsage | null
requestId: string | null
queueWaitMs: number | null
}
export function endpoint(baseUrl: string, path: string) {
return `${baseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`
}
export function serverEndpoint(baseUrl: string, path: string) {
return endpoint(baseUrl.replace(/\/v1\/?$/, ""), path)
}
function headers(apiKey: string) {
return {
"Content-Type": "application/json",
@@ -38,6 +74,12 @@ export async function listModels(baseUrl: string, apiKey: string, signal?: Abort
return (body.data || []).map((model) => model.id)
}
export async function getHealth(baseUrl: string, apiKey = "", signal?: AbortSignal): Promise<HealthResponse> {
const response = await fetch(serverEndpoint(baseUrl, "health"), { headers: headers(apiKey), signal })
if (!response.ok) throw new Error(await responseError(response))
return (await response.json()) as HealthResponse
}
export function extractSSE(buffer: string) {
const frames = buffer.split(/\r?\n\r?\n/)
const rest = frames.pop() || ""
@@ -50,7 +92,7 @@ export function extractSSE(buffer: string) {
return { data, rest }
}
interface StreamChatOptions {
export interface StreamChatOptions {
baseUrl: string
apiKey: string
model: string
@@ -58,11 +100,12 @@ interface StreamChatOptions {
temperature: number
maxTokens: number
enableThinking: boolean
cacheSlot?: number
signal: AbortSignal
onDelta: (text: string) => void
}
export async function streamChat(options: StreamChatOptions) {
export async function streamChat(options: StreamChatOptions): Promise<StreamChatResult> {
const response = await fetch(endpoint(options.baseUrl, "chat/completions"), {
method: "POST",
headers: headers(options.apiKey),
@@ -73,6 +116,7 @@ export async function streamChat(options: StreamChatOptions) {
temperature: options.temperature,
max_completion_tokens: options.maxTokens,
enable_thinking: options.enableThinking,
...(options.cacheSlot === undefined ? {} : { cache_slot: options.cacheSlot }),
stream: true,
stream_options: { include_usage: true },
}),
@@ -83,14 +127,20 @@ export async function streamChat(options: StreamChatOptions) {
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
let finishReason: string | null = null
let usage: TokenUsage | null = null
const consume = (data: string) => {
if (data === "[DONE]") return
const event = JSON.parse(data) as {
choices?: Array<{ delta?: { content?: string } }>
choices?: Array<{ delta?: { content?: string }; finish_reason?: string | null }>
usage?: TokenUsage | null
}
const text = event.choices?.[0]?.delta?.content
const choice = event.choices?.[0]
const text = choice?.delta?.content
if (text) options.onDelta(text)
if (choice?.finish_reason) finishReason = choice.finish_reason
if (event.usage) usage = event.usage
}
while (true) {
@@ -101,4 +151,13 @@ export async function streamChat(options: StreamChatOptions) {
parsed.data.forEach(consume)
if (done) break
}
const queueWaitHeader = response.headers.get("x-colibri-queue-wait-ms")
const parsedQueueWait = queueWaitHeader === null ? null : Number(queueWaitHeader)
return {
finishReason,
usage,
requestId: response.headers.get("x-request-id"),
queueWaitMs: parsedQueueWait !== null && Number.isFinite(parsedQueueWait) ? parsedQueueWait : null,
}
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest"
import type { HealthResponse } from "./api"
import { activeRequests, supportsCacheSlots } from "./runtime"
const healthWithActive = (active: boolean | number): HealthResponse => ({
status: "ok",
scheduler: {
active,
queued: 0,
max_queue: 0,
queue_timeout_seconds: 0,
admitted: 0,
completed: 0,
rejected: 0,
timed_out: 0,
cancelled: 0,
},
})
describe("runtime capability normalization", () => {
it.each([
[true, 1],
[false, 0],
[3, 3],
[0, 0],
] as const)("normalizes scheduler.active %s to %d", (active, expected) => {
expect(activeRequests(healthWithActive(active))).toBe(expected)
})
it("treats missing scheduler metrics as idle", () => {
expect(activeRequests({ status: "ok" })).toBe(0)
expect(activeRequests(null)).toBe(0)
})
it("only enables cache slots when the health response advertises them", () => {
expect(supportsCacheSlots({ status: "ok", kv_slots: 4 })).toBe(true)
expect(supportsCacheSlots({ status: "ok" })).toBe(false)
expect(supportsCacheSlots(null)).toBe(false)
})
})
+9
View File
@@ -0,0 +1,9 @@
import type { HealthResponse } from "./api"
export function activeRequests(health: HealthResponse | null): number {
return Number(health?.scheduler?.active || 0)
}
export function supportsCacheSlots(health: HealthResponse | null): boolean {
return typeof health?.kv_slots === "number" && health.kv_slots > 0
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from "vitest"
import { persistPublicSettings, stored, type StringStorage } from "./storage"
function memoryStorage(initial: Record<string, string> = {}): StringStorage & { values: Map<string, string> } {
const values = new Map(Object.entries(initial))
return {
values,
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => { values.set(key, value) },
removeItem: (key) => { values.delete(key) },
}
}
describe("browser settings persistence", () => {
it("persists endpoint and model but removes legacy API credentials", () => {
const storage = memoryStorage({ "colibri.apiKey": "legacy-secret" })
persistPublicSettings(storage, "https://localhost/v1", "test-model")
expect(Object.fromEntries(storage.values)).toEqual({
"colibri.baseUrl": "https://localhost/v1",
"colibri.model": "test-model",
})
})
it("does not attempt to write an API key", () => {
const storage = memoryStorage()
const setItem = vi.spyOn(storage, "setItem")
persistPublicSettings(storage, "http://localhost/v1", "test-model")
expect(setItem).not.toHaveBeenCalledWith("colibri.apiKey", expect.anything())
})
it("uses a fallback when storage access is unavailable", () => {
expect(stored({ getItem: () => { throw new Error("denied") } }, "key", "fallback")).toBe("fallback")
})
})
+19
View File
@@ -0,0 +1,19 @@
export interface StringStorage {
getItem(key: string): string | null
setItem(key: string, value: string): void
removeItem(key: string): void
}
export function stored(storage: Pick<StringStorage, "getItem">, key: string, fallback: string) {
try { return storage.getItem(key) || fallback } catch { return fallback }
}
export function persistPublicSettings(storage: StringStorage, baseUrl: string, model: string) {
try {
storage.setItem("colibri.baseUrl", baseUrl)
storage.setItem("colibri.model", model)
// API credentials intentionally remain memory-only. Remove values left by
// older web releases whenever public settings are persisted.
storage.removeItem("colibri.apiKey")
} catch { /* restricted storage mode */ }
}