189 lines
6.3 KiB
TypeScript
Executable File
189 lines
6.3 KiB
TypeScript
Executable File
import { assert, assertEquals } from "@std/assert";
|
|
import { Router } from "../../../packages/core/src/mod.ts";
|
|
import { registerLogRoutes } from "./logs.ts";
|
|
import { type AppContext, NullToolExecutor, VERSION } from "../context.ts";
|
|
import { ProviderManager } from "../../../packages/providers/src/mod.ts";
|
|
import { Metrics } from "../../../packages/telemetry/src/metrics.ts";
|
|
import { LogBus } from "../../../packages/telemetry/src/logbus.ts";
|
|
import type { LogEntry } from "../../../packages/telemetry/src/logbus.ts";
|
|
import { LogStore } from "../../../packages/telemetry/src/logstore.ts";
|
|
import { PricingCatalog } from "../../../packages/governance/src/pricing.ts";
|
|
import { VirtualKeyManager } from "../../../packages/governance/src/virtual_keys.ts";
|
|
import { MCPRegistry } from "../../../packages/mcp/src/registry.ts";
|
|
import { PluginManager } from "../../../packages/plugins/src/lifecycle.ts";
|
|
import { MemoryStateStore } from "../../../packages/config/src/store_memory.ts";
|
|
|
|
const base = "http://gateway.test";
|
|
|
|
function makeCtx(logStore?: LogStore): AppContext {
|
|
return {
|
|
providers: new ProviderManager([]),
|
|
metrics: new Metrics(),
|
|
logBus: new LogBus(),
|
|
logStore,
|
|
pricing: new PricingCatalog(),
|
|
virtualKeys: new VirtualKeyManager(),
|
|
mcp: new MCPRegistry(),
|
|
plugins: new PluginManager(),
|
|
toolExecutor: new NullToolExecutor(),
|
|
version: VERSION,
|
|
};
|
|
}
|
|
|
|
function routerFor(ctx: AppContext): Router {
|
|
const router = new Router();
|
|
registerLogRoutes(router, ctx);
|
|
return router;
|
|
}
|
|
|
|
function entry(partial: Partial<LogEntry> = {}): LogEntry {
|
|
return {
|
|
ts: new Date().toISOString(),
|
|
level: "info",
|
|
message: "ok",
|
|
requestId: crypto.randomUUID(),
|
|
method: "POST",
|
|
path: "/v1/chat/completions",
|
|
status: 200,
|
|
durationMs: 10,
|
|
...partial,
|
|
};
|
|
}
|
|
|
|
async function withStore(
|
|
fn: (store: LogStore) => Promise<void>,
|
|
opts?: { maxEntries?: number; pruneEvery?: number },
|
|
): Promise<void> {
|
|
const dir = await Deno.makeTempDir();
|
|
const cs = MemoryStateStore.named(`${dir}/logs.kv`);
|
|
try {
|
|
await fn(new LogStore(cs, opts?.maxEntries, opts?.pruneEvery));
|
|
} finally {
|
|
cs.close();
|
|
}
|
|
}
|
|
|
|
Deno.test("GET /api/logs/stats returns the aggregate contract", async () => {
|
|
await withStore(async (store) => {
|
|
await store.append(entry({
|
|
status: 200,
|
|
provider: "openai",
|
|
model: "gpt-4o",
|
|
promptTokens: 100,
|
|
completionTokens: 50,
|
|
costMicroUsd: 500,
|
|
}));
|
|
await store.append(entry({ status: 500 }));
|
|
|
|
const res = await routerFor(makeCtx(store)).handle(
|
|
new Request(`${base}/api/logs/stats`),
|
|
);
|
|
assertEquals(res.status, 200);
|
|
const body = await res.json();
|
|
assertEquals(body.total, 2);
|
|
assertEquals(body.byStatusClass["2xx"], 1);
|
|
assertEquals(body.byStatusClass["5xx"], 1);
|
|
assertEquals(body.successCount, 1);
|
|
assertEquals(body.errorCount, 1);
|
|
assertEquals(body.promptTokens, 100);
|
|
assertEquals(body.totalTokens, 150);
|
|
assertEquals(body.costMicroUsd, 500);
|
|
assertEquals(body.costUsd, 0.0005);
|
|
});
|
|
});
|
|
|
|
Deno.test("GET /api/logs/dropped answers 0 honestly without a store", async () => {
|
|
const res = await routerFor(makeCtx(undefined)).handle(
|
|
new Request(`${base}/api/logs/dropped`),
|
|
);
|
|
assertEquals(res.status, 200);
|
|
assertEquals(await res.json(), { dropped: 0, bufferEvicted: 0 });
|
|
});
|
|
|
|
Deno.test("GET /api/logs/dropped reports store cap-prune evictions", async () => {
|
|
await withStore(async (store) => {
|
|
for (let i = 0; i < 5; i++) {
|
|
await store.append(entry({ requestId: String(i) }));
|
|
}
|
|
const res = await routerFor(makeCtx(store)).handle(
|
|
new Request(`${base}/api/logs/dropped`),
|
|
);
|
|
const body = await res.json();
|
|
assertEquals(body.dropped, 3);
|
|
}, { maxEntries: 2, pruneEvery: 1 });
|
|
});
|
|
|
|
Deno.test("GET /api/logs/filterdata returns distinct facets", async () => {
|
|
await withStore(async (store) => {
|
|
await store.append(entry({
|
|
method: "POST",
|
|
path: "/v1/chat/completions",
|
|
status: 200,
|
|
model: "gpt-4o",
|
|
provider: "openai",
|
|
}));
|
|
await store.append(
|
|
entry({ method: "GET", path: "/api/logs", status: 200 }),
|
|
);
|
|
|
|
const res = await routerFor(makeCtx(store)).handle(
|
|
new Request(`${base}/api/logs/filterdata`),
|
|
);
|
|
assertEquals(res.status, 200);
|
|
const body = await res.json();
|
|
assertEquals(body.methods, ["GET", "POST"]);
|
|
assertEquals(body.models, ["gpt-4o"]);
|
|
assertEquals(body.providers, ["openai"]);
|
|
assert(body.statuses.includes(200));
|
|
});
|
|
});
|
|
|
|
Deno.test("POST /api/logs/recalculate-cost derives cost from the pricing catalog", async () => {
|
|
await withStore(async (store) => {
|
|
// 1000 prompt tokens * $2.5/Mtok (gpt-4o) = 2500 micro-USD.
|
|
await store.append(entry({ model: "gpt-4o", promptTokens: 1000 }));
|
|
// Unpriced model => no derivable cost => left untouched.
|
|
await store.append(entry({ model: "no-such-model", promptTokens: 100 }));
|
|
|
|
const res = await routerFor(makeCtx(store)).handle(
|
|
new Request(`${base}/api/logs/recalculate-cost`, { method: "POST" }),
|
|
);
|
|
assertEquals(res.status, 200);
|
|
const body = await res.json();
|
|
assertEquals(body.recalculated, 1);
|
|
assertEquals(body.scanned, 2);
|
|
|
|
const priced = (await store.query({ q: "chat" })).entries
|
|
.find((e) => e.model === "gpt-4o");
|
|
assertEquals(priced?.costMicroUsd, 2500);
|
|
});
|
|
});
|
|
|
|
Deno.test("store-backed log endpoints 404 when the store is disabled", async () => {
|
|
const router = routerFor(makeCtx(undefined));
|
|
// All five store-backed routes answer with the canonical error envelope; the
|
|
// message names `pg`, the live "on" value (the retired `kv` is still an
|
|
// accepted input, but must never be advertised).
|
|
const routes: Array<[string, string]> = [
|
|
["GET", "/api/logs/stored"],
|
|
["GET", "/api/logs/stats"],
|
|
["GET", "/api/logs/filterdata"],
|
|
["POST", "/api/logs/recalculate-cost"],
|
|
["DELETE", "/api/logs/stored"],
|
|
];
|
|
for (const [method, path] of routes) {
|
|
const res = await router.handle(new Request(`${base}${path}`, { method }));
|
|
assertEquals(res.status, 404);
|
|
const body = await res.json();
|
|
assertEquals(body, {
|
|
error: {
|
|
message:
|
|
"Log store is disabled; set FROSTY_LOG_STORE=pg to enable the durable request log.",
|
|
type: "not_found",
|
|
param: null,
|
|
code: null,
|
|
},
|
|
});
|
|
}
|
|
});
|