Skip to content

Commit 29fa318

Browse files
committed
Add tests for AI proxy handlers and SpacetimeDB client
- Introduced unit tests for `observeAndLog` in `ai-proxy-handlers.test.ts` to ensure correct behavior when logging functions throw errors. - Added tests for `callSql` in `spacetimedb-client.test.ts` to verify 401 enrollment retry logic and error handling. - Created tests for `buildProxyLogRow` in `ai-proxy-logger.test.ts` to validate tool-name extraction from parsed logs. - Implemented validation tests for tool names in `index.test.ts` to ensure consistency with defined tool names. These additions enhance test coverage and reliability of the AI-related functionalities.
1 parent 1a8e38e commit 29fa318

8 files changed

Lines changed: 419 additions & 56 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* Ensures `observeAndLog` returns upstream bytes even when proxy logging throws.
3+
*/
4+
import { describe, expect, it, vi } from "vitest";
5+
6+
vi.mock("./loggers/ai-proxy-logger", () => ({
7+
buildProxyLogRow: vi.fn(),
8+
scheduleProxyLog: vi.fn(),
9+
}));
10+
11+
vi.mock("./openrouter-usage", async (importOriginal) => {
12+
const original = await importOriginal<Record<string, unknown>>();
13+
return {
14+
...original,
15+
extractOpenRouterUsage: vi.fn(() => ({})),
16+
scanSseForUsage: vi.fn(async () => ({})),
17+
};
18+
});
19+
20+
vi.mock("@/private", () => ({
21+
preprocessProxyBody: ({ parsedBody }: { parsedBody: unknown }) => parsedBody,
22+
}));
23+
24+
import { buildProxyLogRow, scheduleProxyLog } from "./loggers/ai-proxy-logger";
25+
import { observeAndLog, sanitizeBody } from "./ai-proxy-handlers";
26+
27+
const sanitized = sanitizeBody(new TextEncoder().encode(JSON.stringify({
28+
model: "anthropic/claude-sonnet-4.6",
29+
messages: [{ role: "user", content: "hi" }],
30+
})).buffer as ArrayBuffer);
31+
32+
describe("observeAndLog body delivery", () => {
33+
it("returns the upstream bytes even if buildProxyLogRow throws", async () => {
34+
vi.mocked(buildProxyLogRow).mockImplementation(() => {
35+
throw new Error("synthetic log construction failure");
36+
});
37+
vi.mocked(scheduleProxyLog).mockClear();
38+
39+
const upstreamBody = JSON.stringify({ id: "gen-1", choices: [{ message: { content: "hello" } }] });
40+
const upstream = new Response(upstreamBody, {
41+
status: 200,
42+
headers: { "Content-Type": "application/json" },
43+
});
44+
45+
const out = await observeAndLog({
46+
response: upstream,
47+
sanitizedBody: sanitized,
48+
callerApiKey: "stack-auth-test",
49+
correlationId: "corr-1",
50+
startedAt: 0,
51+
responseHeaders: { "Content-Type": "application/json", "Cache-Control": "no-store" },
52+
});
53+
54+
expect(out.status).toBe(200);
55+
expect(await out.text()).toBe(upstreamBody);
56+
});
57+
58+
it("returns the upstream bytes even if scheduleProxyLog throws", async () => {
59+
vi.mocked(buildProxyLogRow).mockReturnValue({
60+
correlationId: "corr-2",
61+
mode: "generate",
62+
systemPromptId: "stack-auth-test",
63+
quality: "unknown",
64+
speed: "unknown",
65+
modelId: "anthropic/claude-sonnet-4.6",
66+
isAuthenticated: false,
67+
projectId: undefined,
68+
userId: undefined,
69+
requestedToolsJson: "[]",
70+
messagesJson: "[]",
71+
stepsJson: "[]",
72+
finalText: "",
73+
inputTokens: undefined,
74+
outputTokens: undefined,
75+
cachedInputTokens: undefined,
76+
cacheCreationTokens: undefined,
77+
costUsd: undefined,
78+
cacheDiscountUsd: undefined,
79+
openrouterGenerationId: undefined,
80+
stepCount: 0,
81+
durationMs: 0n,
82+
errorMessage: undefined,
83+
conversationId: undefined,
84+
});
85+
vi.mocked(scheduleProxyLog).mockImplementation(() => {
86+
throw new Error("synthetic schedule failure");
87+
});
88+
89+
const upstreamBody = JSON.stringify({ id: "gen-2", choices: [{ message: { content: "world" } }] });
90+
const upstream = new Response(upstreamBody, {
91+
status: 200,
92+
headers: { "Content-Type": "application/json" },
93+
});
94+
95+
const out = await observeAndLog({
96+
response: upstream,
97+
sanitizedBody: sanitized,
98+
callerApiKey: "stack-auth-test",
99+
correlationId: "corr-2",
100+
startedAt: 0,
101+
responseHeaders: { "Content-Type": "application/json", "Cache-Control": "no-store" },
102+
});
103+
104+
expect(out.status).toBe(200);
105+
expect(await out.text()).toBe(upstreamBody);
106+
});
107+
108+
it("delivers full streamed bytes via tee even when the observer arm throws", async () => {
109+
vi.mocked(buildProxyLogRow).mockImplementation(() => {
110+
throw new Error("synthetic log failure inside async observer");
111+
});
112+
113+
const streamPayload = "data: {\"id\":\"gen-3\"}\n\ndata: [DONE]\n\n";
114+
const upstream = new Response(streamPayload, {
115+
status: 200,
116+
headers: { "Content-Type": "text/event-stream" },
117+
});
118+
119+
const streamingSanitized = sanitizeBody(new TextEncoder().encode(JSON.stringify({
120+
model: "anthropic/claude-sonnet-4.6",
121+
stream: true,
122+
messages: [{ role: "user", content: "hi" }],
123+
})).buffer as ArrayBuffer);
124+
125+
const out = await observeAndLog({
126+
response: upstream,
127+
sanitizedBody: streamingSanitized,
128+
callerApiKey: "stack-auth-test",
129+
correlationId: "corr-3",
130+
startedAt: 0,
131+
responseHeaders: { "Content-Type": "text/event-stream", "Cache-Control": "no-store" },
132+
});
133+
134+
expect(out.status).toBe(200);
135+
expect(await out.text()).toBe(streamPayload);
136+
});
137+
});

apps/backend/src/lib/ai/ai-proxy-handlers.ts

Lines changed: 35 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -51,42 +51,50 @@ export async function observeAndLog(args: {
5151
if (isStreaming && response.body) {
5252
const [clientStream, observerStream] = response.body.tee();
5353
runAsynchronouslyAndWaitUntil((async () => {
54-
let usage: UsageFields = {};
55-
const controller = new AbortController();
56-
const timeoutId = setTimeout(() => controller.abort(), 120_000);
5754
try {
58-
usage = (await scanSseForUsage(observerStream, controller.signal)) ?? {};
55+
let usage: UsageFields = {};
56+
const controller = new AbortController();
57+
const timeoutId = setTimeout(() => controller.abort(), 120_000);
58+
try {
59+
usage = (await scanSseForUsage(observerStream, controller.signal)) ?? {};
60+
} catch (err) {
61+
captureError("ai-proxy-scan-sse", err);
62+
} finally {
63+
clearTimeout(timeoutId);
64+
}
65+
scheduleProxyLog(buildProxyLogRow({
66+
correlationId,
67+
parsed: sanitizedBody.parsed,
68+
apiKey: callerApiKey,
69+
durationMs: BigInt(Math.round(performance.now() - startedAt)),
70+
responseStatus: response.status,
71+
usage,
72+
}));
5973
} catch (err) {
60-
captureError("ai-proxy-scan-sse", err);
61-
} finally {
62-
clearTimeout(timeoutId);
74+
captureError("ai-proxy-observer", err);
6375
}
64-
scheduleProxyLog(buildProxyLogRow({
65-
correlationId,
66-
parsed: sanitizedBody.parsed,
67-
apiKey: callerApiKey,
68-
durationMs: BigInt(Math.round(performance.now() - startedAt)),
69-
responseStatus: response.status,
70-
usage,
71-
}));
7276
})());
7377
return new Response(clientStream, { status: response.status, headers: responseHeaders });
7478
}
7579

7680
const bodyBytes = await response.arrayBuffer();
77-
let parsedBody: unknown;
7881
try {
79-
parsedBody = JSON.parse(new TextDecoder().decode(bodyBytes));
80-
} catch {
81-
parsedBody = undefined;
82+
let parsedBody: unknown;
83+
try {
84+
parsedBody = JSON.parse(new TextDecoder().decode(bodyBytes));
85+
} catch {
86+
parsedBody = undefined;
87+
}
88+
scheduleProxyLog(buildProxyLogRow({
89+
correlationId,
90+
parsed: sanitizedBody.parsed,
91+
apiKey: callerApiKey,
92+
durationMs: BigInt(Math.round(performance.now() - startedAt)),
93+
responseStatus: response.status,
94+
usage: extractOpenRouterUsage(parsedBody),
95+
}));
96+
} catch (err) {
97+
captureError("ai-proxy-log-build", err);
8298
}
83-
scheduleProxyLog(buildProxyLogRow({
84-
correlationId,
85-
parsed: sanitizedBody.parsed,
86-
apiKey: callerApiKey,
87-
durationMs: BigInt(Math.round(performance.now() - startedAt)),
88-
responseStatus: response.status,
89-
usage: extractOpenRouterUsage(parsedBody),
90-
}));
9199
return new Response(bodyBytes, { status: response.status, headers: responseHeaders });
92100
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* Tool-name extraction in `buildProxyLogRow` for Anthropic and OpenAI shapes.
3+
*/
4+
import { describe, expect, it } from "vitest";
5+
import { buildProxyLogRow } from "./ai-proxy-logger";
6+
7+
const baseInput = {
8+
correlationId: "corr-1",
9+
apiKey: "stack-auth-proxy",
10+
durationMs: 0n,
11+
responseStatus: 200,
12+
};
13+
14+
describe("buildProxyLogRow tool-name extraction", () => {
15+
it("captures Anthropic top-level tool names", () => {
16+
const row = buildProxyLogRow({
17+
...baseInput,
18+
parsed: {
19+
model: "anthropic/claude-sonnet-4.6",
20+
tools: [
21+
{ name: "get_weather", description: "...", input_schema: {} },
22+
{ name: "send_email", description: "...", input_schema: {} },
23+
],
24+
},
25+
});
26+
expect(JSON.parse(row.requestedToolsJson)).toEqual(["get_weather", "send_email"]);
27+
});
28+
29+
it("captures OpenAI/OpenRouter-format function tool names", () => {
30+
const row = buildProxyLogRow({
31+
...baseInput,
32+
parsed: {
33+
model: "anthropic/claude-sonnet-4.6",
34+
tools: [
35+
{ type: "function", function: { name: "get_weather", parameters: {} } },
36+
{ type: "function", function: { name: "send_email", parameters: {} } },
37+
],
38+
},
39+
});
40+
expect(JSON.parse(row.requestedToolsJson)).toEqual(["get_weather", "send_email"]);
41+
});
42+
43+
it("handles a mixed array gracefully", () => {
44+
const row = buildProxyLogRow({
45+
...baseInput,
46+
parsed: {
47+
model: "anthropic/claude-sonnet-4.6",
48+
tools: [
49+
{ name: "anthropic_tool", input_schema: {} },
50+
{ type: "function", function: { name: "openai_tool", parameters: {} } },
51+
{ type: "function" },
52+
null,
53+
"not an object",
54+
],
55+
},
56+
});
57+
expect(JSON.parse(row.requestedToolsJson)).toEqual(["anthropic_tool", "openai_tool"]);
58+
});
59+
60+
it("returns an empty array when tools is absent or malformed", () => {
61+
const row = buildProxyLogRow({
62+
...baseInput,
63+
parsed: {
64+
model: "anthropic/claude-sonnet-4.6",
65+
},
66+
});
67+
expect(JSON.parse(row.requestedToolsJson)).toEqual([]);
68+
});
69+
});

apps/backend/src/lib/ai/loggers/ai-proxy-logger.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@ export function buildProxyLogRow(fields: ProxyLogFields): AiQueryLogEntry {
1616
const { parsed, apiKey, durationMs, responseStatus, usage, correlationId } = fields;
1717
const tools = Array.isArray(parsed.tools) ? parsed.tools : [];
1818
const toolNames = tools
19-
.map(t => (t && typeof t === "object" && "name" in t) ? (t as { name: unknown }).name : null)
19+
.map((t) => {
20+
if (t == null || typeof t !== "object") return null;
21+
const obj = t as { name?: unknown, function?: { name?: unknown } };
22+
if (typeof obj.function?.name === "string") return obj.function.name;
23+
if (typeof obj.name === "string") return obj.name;
24+
return null;
25+
})
2026
.filter((n): n is string => typeof n === "string");
2127
const rawMessages = Array.isArray(parsed.messages) ? parsed.messages : [];
2228
const messages = typeof parsed.system === "string" && parsed.system.length > 0

0 commit comments

Comments
 (0)