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:
+56
-2
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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 */ }
|
||||
}
|
||||
Reference in New Issue
Block a user