256 lines
8.5 KiB
TypeScript
Executable File
256 lines
8.5 KiB
TypeScript
Executable File
// Wave-3 observability + misc: durable KV log store, OTLP exporter,
|
|
// cache-invalidation API, JSON repair plugin, per-account proxy plumbing.
|
|
|
|
import { assert, assertEquals } from "@std/assert";
|
|
import { MemoryStateStore } from "../../packages/config/src/store_memory.ts";
|
|
import { LogStore } from "../../packages/telemetry/src/logstore.ts";
|
|
import { OtelExporter } from "../../packages/telemetry/src/otel.ts";
|
|
import type { RequestLogEntry } from "../../packages/core/src/middleware.ts";
|
|
import {
|
|
jsonRepairPlugin,
|
|
repairJson,
|
|
} from "../../packages/plugins/src/jsonparser.ts";
|
|
import { PluginManager } from "../../packages/plugins/src/lifecycle.ts";
|
|
import { ProviderClient } from "../../packages/providers/src/client.ts";
|
|
import { SemanticCache } from "../../packages/cache/src/semantic.ts";
|
|
import { createHandler } from "../../apps/gateway/main.ts";
|
|
import {
|
|
type AppContext,
|
|
NullToolExecutor,
|
|
VERSION,
|
|
} from "../../apps/gateway/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 { VirtualKeyManager } from "../../packages/governance/src/virtual_keys.ts";
|
|
import { MCPRegistry } from "../../packages/mcp/src/registry.ts";
|
|
|
|
function entry(path: string, status: number): RequestLogEntry {
|
|
return {
|
|
ts: new Date().toISOString(),
|
|
level: status >= 500 ? "error" : "info",
|
|
message: `${status}`,
|
|
requestId: crypto.randomUUID(),
|
|
method: "POST",
|
|
path,
|
|
status,
|
|
durationMs: 1,
|
|
};
|
|
}
|
|
|
|
Deno.test("log store: query filters, pagination, cap pruning, clear", async () => {
|
|
const dir = await Deno.makeTempDir();
|
|
const store = MemoryStateStore.named(`${dir}/logs.kv`);
|
|
try {
|
|
const logs = new LogStore(store, 3, 1); // cap 3, prune on every append
|
|
await logs.append(entry("/v1/chat/completions", 200));
|
|
await logs.append(entry("/v1/messages", 200));
|
|
await logs.append(entry("/v1/chat/completions", 500));
|
|
await logs.append(entry("/api/providers", 200));
|
|
await logs.append(entry("/v1/responses", 429));
|
|
|
|
// Cap enforced at write time: only the newest 3 remain.
|
|
const all = await logs.query({});
|
|
assertEquals(all.total, 3);
|
|
// Newest first.
|
|
assertEquals(all.entries[0].path, "/v1/responses");
|
|
|
|
const filtered = await logs.query({ q: "responses" });
|
|
assertEquals(filtered.total, 1);
|
|
const byStatus = await logs.query({ status: 429 });
|
|
assertEquals(byStatus.total, 1);
|
|
const page = await logs.query({ limit: 1, offset: 1 });
|
|
assertEquals(page.entries.length, 1);
|
|
assertEquals(page.total, 3);
|
|
|
|
assertEquals(await logs.clear(), 3);
|
|
assertEquals((await logs.query({})).total, 0);
|
|
} finally {
|
|
store.close();
|
|
}
|
|
});
|
|
|
|
Deno.test("otel exporter: OTLP JSON shape, bounded buffer, failure drops", async () => {
|
|
const batches: unknown[] = [];
|
|
const collector = Deno.serve({ port: 0, onListen: () => {} }, async (req) => {
|
|
batches.push(await req.json());
|
|
return new Response(null, { status: 200 });
|
|
});
|
|
const endpoint = `http://127.0.0.1:${(collector.addr as Deno.NetAddr).port}`;
|
|
try {
|
|
const otel = new OtelExporter(endpoint, "frosty-test");
|
|
otel.record({
|
|
name: "POST /v1/chat/completions",
|
|
startMs: 1000,
|
|
endMs: 1250,
|
|
attributes: { "http.response.status_code": 200 },
|
|
});
|
|
await otel.flush();
|
|
assertEquals(batches.length, 1);
|
|
const batch = batches[0] as {
|
|
resourceSpans: Array<{
|
|
resource: { attributes: Array<{ key: string; value: unknown }> };
|
|
scopeSpans: Array<{
|
|
spans: Array<{
|
|
name: string;
|
|
traceId: string;
|
|
startTimeUnixNano: string;
|
|
status: { code: number };
|
|
}>;
|
|
}>;
|
|
}>;
|
|
};
|
|
const span = batch.resourceSpans[0].scopeSpans[0].spans[0];
|
|
assertEquals(span.name, "POST /v1/chat/completions");
|
|
assertEquals(span.traceId.length, 32);
|
|
assertEquals(span.startTimeUnixNano, "1000000000");
|
|
assertEquals(span.status.code, 1);
|
|
assertEquals(
|
|
batch.resourceSpans[0].resource.attributes[0],
|
|
{ key: "service.name", value: { stringValue: "frosty-test" } },
|
|
);
|
|
|
|
// Bounded buffer: overflow increments the drop counter, never blocks.
|
|
const tiny = new OtelExporter(endpoint, "t", undefined, 1);
|
|
tiny.record({ name: "a", startMs: 0, endMs: 1 });
|
|
tiny.record({ name: "b", startMs: 0, endMs: 1 });
|
|
assertEquals(tiny.dropped, 1);
|
|
assertEquals(tiny.buffered(), 1);
|
|
|
|
// Collector failure counts spans as dropped instead of throwing.
|
|
const dead = new OtelExporter("http://127.0.0.1:9");
|
|
dead.record({ name: "x", startMs: 0, endMs: 1 });
|
|
await dead.flush();
|
|
assertEquals(dead.dropped, 1);
|
|
} finally {
|
|
await collector.shutdown();
|
|
}
|
|
});
|
|
|
|
Deno.test("repairJson completes truncated model output", () => {
|
|
assertEquals(
|
|
repairJson('{"a": {"b": [1, 2'),
|
|
'{"a": {"b": [1, 2]}}',
|
|
);
|
|
assertEquals(repairJson('{"a": "hel'), '{"a": "hel"}');
|
|
assertEquals(repairJson('{"a": 1,'), '{"a": 1}');
|
|
assertEquals(repairJson('{"a":'), '{"a": null}');
|
|
// Valid JSON passes through untouched; non-JSON is returned as-is.
|
|
assertEquals(repairJson('{"ok": true}'), '{"ok": true}');
|
|
assertEquals(repairJson("plain text"), "plain text");
|
|
});
|
|
|
|
Deno.test("jsonparser plugin repairs JSON-looking content post-response", async () => {
|
|
const plugins = new PluginManager();
|
|
plugins.register(jsonRepairPlugin());
|
|
const repaired = await plugins.executePostHooks({
|
|
id: "chatcmpl-1",
|
|
object: "chat.completion",
|
|
created: 1,
|
|
model: "m",
|
|
choices: [{
|
|
index: 0,
|
|
message: { role: "assistant", content: '{"result": [1, 2' },
|
|
finish_reason: "length",
|
|
}],
|
|
});
|
|
assertEquals(
|
|
repaired.choices[0].message.content,
|
|
'{"result": [1, 2]}',
|
|
);
|
|
});
|
|
|
|
Deno.test("provider client wires the egress proxy into fetch", async () => {
|
|
const sentinel = { close: () => {} };
|
|
const factoryCalls: unknown[] = [];
|
|
let seenClient: unknown;
|
|
const fakeFetch = ((_input: RequestInfo | URL, init?: RequestInit) => {
|
|
seenClient = (init as { client?: unknown } | undefined)?.client;
|
|
return Promise.resolve(new Response("{}"));
|
|
}) as typeof fetch;
|
|
|
|
const client = new ProviderClient(
|
|
{ proxyUrl: "http://proxy.local:8888", maxRetries: 0 },
|
|
fakeFetch,
|
|
(options) => {
|
|
factoryCalls.push(options);
|
|
return sentinel;
|
|
},
|
|
);
|
|
await (await client.fetchWithRetry("http://upstream.test/")).body?.cancel();
|
|
assertEquals(factoryCalls, [{ proxy: { url: "http://proxy.local:8888" } }]);
|
|
assert(seenClient === sentinel);
|
|
});
|
|
|
|
Deno.test("cache invalidation API clears and deletes entries", async () => {
|
|
const cache = new SemanticCache();
|
|
const request = { model: "m", messages: [{ role: "user", content: "q" }] };
|
|
await cache.set(request, {
|
|
id: "chatcmpl-c",
|
|
object: "chat.completion",
|
|
created: 1,
|
|
model: "m",
|
|
choices: [{
|
|
index: 0,
|
|
message: { role: "assistant", content: "cached" },
|
|
finish_reason: "stop",
|
|
}],
|
|
});
|
|
assertEquals(cache.size(), 1);
|
|
|
|
const ctx: AppContext = {
|
|
providers: new ProviderManager(),
|
|
metrics: new Metrics(),
|
|
logBus: new LogBus(),
|
|
virtualKeys: new VirtualKeyManager(),
|
|
mcp: new MCPRegistry(),
|
|
plugins: new PluginManager(),
|
|
cache,
|
|
toolExecutor: new NullToolExecutor(),
|
|
version: VERSION,
|
|
};
|
|
const handler = createHandler(ctx);
|
|
|
|
const byKey = await handler(
|
|
new Request("http://gateway.test/api/cache/by-key", {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(request),
|
|
}),
|
|
);
|
|
assertEquals(await byKey.json(), { deleted: true });
|
|
assertEquals(cache.size(), 0);
|
|
|
|
await cache.set(request, {
|
|
id: "chatcmpl-targeted",
|
|
object: "chat.completion",
|
|
created: 1,
|
|
model: "m",
|
|
choices: [],
|
|
}, "11111111-1111-4111-8111-111111111111");
|
|
const byRequestId = await handler(
|
|
new Request(
|
|
"http://gateway.test/api/cache/clear/11111111-1111-4111-8111-111111111111",
|
|
{ method: "DELETE" },
|
|
),
|
|
);
|
|
assertEquals(await byRequestId.json(), { deleted: true });
|
|
assertEquals(cache.size(), 0);
|
|
|
|
await cache.set(
|
|
request,
|
|
(await cache.get(request)) ?? {
|
|
id: "x",
|
|
object: "chat.completion",
|
|
created: 1,
|
|
model: "m",
|
|
choices: [],
|
|
},
|
|
);
|
|
const clear = await handler(
|
|
new Request("http://gateway.test/api/cache", { method: "DELETE" }),
|
|
);
|
|
assertEquals(await clear.json(), { cleared: 1 });
|
|
assertEquals(cache.size(), 0);
|
|
});
|